mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 10:21:41 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native runs on a remote sandbox keep a warm Runner between turns. A follow-up run takes over its settled predecessor's provider session and Runner. > - When a deploy stops the server, a follow-up that is still preparing fails with `local_runtime_api_listener_unavailable`. The executor saves this failure as a same-run bootstrap retry (#20786). The saved failure proves that the run did no provider work of its own. > - After the restart, restart recovery saw a checkpoint and a provider session, and chose `reattach_remote_runner`. But the checkpoint and the Runner identity belong to the predecessor. The Runner identity check then failed with `native_remote_recovery_lease_mismatch: stage=runner_verification field=identity_run_id`. The run ended terminal and the task needed Retry. > - This pull request recognizes that durable shape in restart recovery and restarts the run as a fresh bootstrap on its warm session. It does not try to reattach to a Runner that belongs to a different run. > - The pull request also stops the queue from claiming a queued run after shutdown closed run admission during the queue scan. > - The benefit is that a deploy during a follow-up's preparation no longer ends the run terminal with a Retry. ## Linked Issues or Issue Description Refs #20786, #20928, #20852, #20904 **What happened?** In three Codex runs across two Daytona restart-chaos waves (warm sandboxes, CR1 and K15 with overlap), a warm follow-up run (`automation`, `retryOfRunId` = its predecessor) started 0–2 s before its server got SIGTERM. Its bootstrap failed with `local_runtime_api_listener_unavailable` and saved a `bootstrap_retry` with `providerSessionEstablished: false`, `providerEventsExist: false`. After the restart, `native.recovery.transition` chose `reattach_remote_runner`. `verifyRemoteRunnerReattachment` then threw `identity_run_id`, because the warm Runner's identity still named the predecessor. The run ended with `native_continuation_requires_reconciliation`. In every sandbox capture the Runner was cleanly suspended, with no stderr. **Expected behavior** The run did no provider work and never wrote its own Runner identity. The next server must restart it without a Retry. **Steps to reproduce** 1. Finish a remote warm native run, so that its provider session and Runner identity are saved. 2. Start a follow-up run. Admission carries the predecessor's provider session and Runner process identity into the follow-up. 3. Send SIGTERM while the follow-up prepares, so that it fails with `local_runtime_api_listener_unavailable`. 4. Restart the server. Restart recovery chooses `reattach_remote_runner`, and the run fails with `identity_run_id`. ## What Changed - `native-restart-recovery.ts` (`claimNativeRestartRecoveries`): add the `remoteFollowUpNeverAttached` proof. It is true only when all of these are true: - the run is a failed, settled `bootstrap_retry` whose original failure is `local_runtime_api_listener_unavailable`, with `providerSessionEstablished: false`, `providerEventsExist: false`, no successor and no lease owner; - the run is on a prepared remote sandbox workspace with the current preparation fencing version, it is not a plugin task, and it has no Runner controller checkpoint and no staged attachment of its own; - its session checkpoint is only the carried admission checkpoint (no cursor, turn, result, terminal or pending request), and a settled earlier run of the same session saved that exact provider session; - its Runner process identity is absent or is exactly the identity of a settled predecessor; - it has no provider event, no Runner-originated event, and no `native.process_identity_recorded` event of its own. With that proof, the run gets a `bootstrap_incomplete` claim with the reason `remote_follow_up_never_attached`, and it passes the same current-admission check as the other settled bootstrap retries. - `verifyRemoteRunnerReattachment` is unchanged. It still refuses a Runner that names a different run. - `heartbeat/queue.ts` (`startNextQueuedRunForAgent`): check run admission again before each claim. A run that a scan reaches after shutdown closed admission stays queued for the next server. - Tests: - `native-runner-restart-recovery.integration.test.ts`: eight cases for the staging shape. Three restart (inherited Runner identity; inherited identity plus a Runner request; a Runner request with no identity). Five stay held (own recorded Runner identity; a Runner-originated event; a carried active turn; a provider session that no settled run saved; a saved failure that established a provider session). - `queue.test.ts`: a queued run stays queued and is not executed when admission closes during the scan. ### How this relates to #20852 and #20904 - #20852 hands a preparing run to the next controller and persists launch intents, so that preparation itself survives a deploy. It does not change restart recovery classification for a settled `bootstrap_retry`, and it does not touch the run-admission gate. This PR covers runs that already failed and saved a `bootstrap_retry` (including state that older releases saved), so the two are complementary. - #20904 changes startup readiness for native-controller preservation obligations. It does not touch either code path in this PR. ### Finding about the "draining server" admission In both waves the follow-up was claimed before the process observed shutdown. In CR1 (seed 9511) the run was created at 21:54:04.695Z and started at 21:54:04.854Z; the server logged "shutdown signal received" at 21:54:05 (the harness sent SIGTERM at 21:54:03.256Z, under load average 10). In the K15 overlap (seed 9513) the runs started at 23:31:10.9Z and 23:31:11.5Z, and the old server got SIGTERM at 23:31:13Z. During the overlap the old server is fully live and has no signal that a newer deploy serves traffic. So the existing admission gate (`closeProcessRunAdmission`) was not bypassed. The only gap in the gate was the check-then-claim window inside `startNextQueuedRunForAgent`, which this PR closes. Holding work on a superseded but not yet signaled server needs a new "superseded deploy" signal; this PR does not add one. ## Verification - Red on master (`23909077ad`): `node cli/node_modules/tsx/dist/cli.mjs packages/db/src/test-postgres-command.ts -- node node_modules/vitest/vitest.mjs run --project @paperclipai/server server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts -t "closed the runtime listener before it attached"`: the three restart cases failed with `"kind": "reattach_remote_runner"` (the staging disposition); the five held cases passed. - Red on master: `queue.test.ts -t "closes admission during the queue scan"` failed (`expected [ { …(81) } ] to deeply equal []`). - Green with the fix: both filters pass. - `native-runner-restart-recovery`, `native-runner-restart-preparation`, `native-startup-retry` and `native-controller-recovery` integration files: 267 passed, 14 skipped. - `queue.test.ts`: 89 passed. - `cd server && pnpm typecheck`: passed. - Chaos waves: see the PR comments for the reruns of seeds 9511 (CR1) and 9513 (K15 overlap). ## Risks - The new proof applies only to the closed-listener `bootstrap_retry` that #20786 writes, on a remote sandbox, with a carried session that a settled predecessor saved. Every other shape keeps its earlier classification. - A `bootstrap_incomplete` claim sends the run through the existing bootstrap path, which verifies the prior owner of the session root before it starts. The s9205 follow-ups already use this path with an inherited Runner identity. - Upgrade safety: no persisted shape changes. The proof reads only fields that the current and recent releases write. A row saved without these facts keeps the earlier verdict. - No wait or file work is added before `turn.start`. The new queries run only in restart recovery. - The queue change only stops claiming after admission closed. Runs stay queued for the next server, the same as the existing entry check. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2069 lines
103 KiB
TypeScript
2069 lines
103 KiB
TypeScript
import { carriesInheritedRunnerIdentity } from "./native-inherited-runner-retry.js";
|
|
import { readPendingNativeRunStop } from "./native-cancellation-request.js";
|
|
import { nativeRunHandedOffTask } from "./native-human-self-handoff.js";
|
|
import { CONTROLLER_LEASE_RENEW_MS } from "../controller-instance-lease.js";
|
|
import { currentControllerProcessStamp, UNKNOWN_PROCESS_SPACE } from "../controller-process-identity.js";
|
|
import { readNativeSessionResumptionAgent, restoreNativeSessionExecution } from "./native-session-resumption-admission.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,
|
|
nativeRunnerCheckpointRemoteBootstrapProof } 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,
|
|
recordNativeLocalProcessStopInTransaction,
|
|
} from "../native-local-process-stop.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { and, desc, eq, gt, inArray, isNotNull, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
|
|
import { alias } from "drizzle-orm/pg-core";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agents,
|
|
environmentLeases,
|
|
heartbeatRunEvents,
|
|
heartbeatRuns,
|
|
issues,
|
|
issueRecoveryActions,
|
|
nativeRunFinalizations,
|
|
} from "@paperclipai/db";
|
|
import { REMOTE_CONTROLLER_LEASE_MS } from "./native-runner-recovery-contract.js";
|
|
import { PROVIDER_ATTEMPT_CREDIT_GENERATION_LIMIT, providerAttemptChargesBudget, providerAttemptWindowStart, readProviderAttemptOutcome } from "./provider-attempt-outcome.js";
|
|
import { readProcessStartedAt } from "../hot-restart.js";
|
|
import { currentProcessProbe, proveOwnerGone, type ProcessProbe } from "../owner-proof.js";
|
|
import { persistActivity } from "../activity-log.js";
|
|
import { OPERATOR_ATTESTED_CONTROLLER_HOST, requestNativeControllerStopConfirmation } from "./native-controller-host-recovery.js";
|
|
import { currentControllerBootId, currentProcessSpaceHash, currentProcessStartedAtMs, processStartsDefinitelyDiffer } from "../controller-process-identity.js";
|
|
import { getServerProcessStartedAt } from "../../server-info.js";
|
|
import { redactSensitiveText } from "../../redaction.js";
|
|
import { isUniqueViolation } from "../../db-errors.js";
|
|
import { logger } from "../../middleware/logger.js";
|
|
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
|
import { isNewTerminalRunFailure, reportRunFailure } from "../run-failure-report.js";
|
|
import { nativeControllerAuthorityDigest, isNativeRunnerOwnershipHeld, readNativeRemoteExecutionOwnership } from "./native-runner-ownership.js";
|
|
import { nativeRestartContinuationInFlight, nativeRestartInterruptedTurnId, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js";
|
|
import { retireSupersededNativeRetry } from "./native-superseded-retry.js";
|
|
import { legacyWorkspaceRecoveryNotMigrated } from "../legacy-workspace-recovery-migration.js";
|
|
import { requestNativeStartupCleanup, readNativeStartupContext, promoteNativeStartupCleanup } from "./native-startup-cleanup.js";
|
|
import { createSystemRecovery, failSystemRecovery, readSystemRecovery, reserveSystemRecoveryAttempt,
|
|
SystemRecoveryFailure, type SystemRecoveryState } from "../system-recovery-state.js";
|
|
|
|
export type NativeControllerIdentity = {
|
|
bootId: string;
|
|
pid: number;
|
|
processStartedAt: Date;
|
|
/** PID space of `pid`; "unknown" when this host's space cannot be read,
|
|
* which no later probe accepts. Optional for identities built in tests. */
|
|
hostHash?: string;
|
|
};
|
|
|
|
export type NativeRestartKind = "hot" | "hard" | "graceful";
|
|
|
|
/** A recovery claim for one run failed unexpectedly and was deferred. */
|
|
export const NATIVE_RESTART_RECOVERY_FAILED = "restart_recovery_failed";
|
|
|
|
export const NATIVE_PROVIDER_RECOVERY_ATTEMPT_LIMIT = 3;
|
|
|
|
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">;
|
|
exitedBeforeSessionOpen?: true;
|
|
}
|
|
| {
|
|
kind: "resume_dead_runner";
|
|
runId: string;
|
|
leaseOwner: string;
|
|
controllerGeneration: number;
|
|
providerAttempt: number;
|
|
restartKind: NativeRestartKind;
|
|
recoveryRequestId: string | null;
|
|
/** False when the prior controller suspended this run in a clean drain. */
|
|
chargesProviderAttempt?: boolean;
|
|
}
|
|
| {
|
|
kind: "bootstrap_incomplete";
|
|
runId: string;
|
|
leaseOwner: string;
|
|
controllerGeneration: number;
|
|
providerAttempt: number;
|
|
restartKind: NativeRestartKind;
|
|
recoveryRequestId: string | null;
|
|
/** False when the prior controller suspended this run in a clean drain. */
|
|
chargesProviderAttempt?: boolean;
|
|
};
|
|
|
|
export type NativeRestartRecoveryDisposition =
|
|
| NativeRestartRecoveryClaim
|
|
| {
|
|
kind: "awaiting_evidence" | "blocked" | "already_finalized";
|
|
runId: string;
|
|
reason: string;
|
|
retryAt?: Date;
|
|
transitionUnchanged?: boolean;
|
|
};
|
|
|
|
export function nextNativeProviderAttempt(
|
|
currentAttempt: number,
|
|
recoveryKind?: NativeRestartRecoveryClaim["kind"],
|
|
chargesProviderAttempt = true,
|
|
): number {
|
|
return !chargesProviderAttempt ||
|
|
recoveryKind === "reattach_existing_runner" ||
|
|
recoveryKind === "reattach_remote_runner"
|
|
? currentAttempt
|
|
: currentAttempt + 1;
|
|
}
|
|
|
|
/**
|
|
* The execution recovery budget bounds runs that keep failing their provider
|
|
* turn. A graceful drain that suspended the run for reattachment is an
|
|
* infrastructure restart the run did not cause, so the next controller's
|
|
* resume of that turn does not spend an attempt. A crash (no drain record)
|
|
* still charges one. The drain record is consumed by the next claim; waits for
|
|
* ownership evidence or a deferred block in between do not erase it.
|
|
*/
|
|
const RECOVERY_CLAIM_DISPOSITIONS: ReadonlySet<unknown> = new Set<NativeRestartRecoveryClaim["kind"]>([
|
|
"reattach_existing_runner", "reattach_remote_runner", "resume_dead_runner", "bootstrap_incomplete",
|
|
]);
|
|
export function suspendedByCleanDrain(coordinator: {
|
|
attempt: number;
|
|
recoveryHistory: ReadonlyArray<{ disposition?: unknown }>;
|
|
}): boolean {
|
|
// Attempt 0 means no execution has claimed the run yet. Its first provider
|
|
// start must still be counted, whatever interrupted preparation.
|
|
return coordinator.attempt > 0 && handedToNextControllerSinceClaim(coordinator.recoveryHistory);
|
|
}
|
|
|
|
/** The latest record since the last claim hands the run to the next server. */
|
|
function handedToNextControllerSinceClaim(history: ReadonlyArray<{ disposition?: unknown }>): boolean {
|
|
for (let index = history.length - 1; index >= 0; index -= 1) {
|
|
const disposition = history[index]?.disposition;
|
|
if (disposition === "restart_suspended") return true;
|
|
if (RECOVERY_CLAIM_DISPOSITIONS.has(disposition)) return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* A controller restart (a crash, so no drain record) charges the interrupted
|
|
* attempt only by the work-based rule in provider-attempt-outcome.ts: the
|
|
* provider accepted a turn in that attempt and no accepted turn completed work
|
|
* before the crash. Each claim records the run's event-log position, so the
|
|
* next claim judges only what happened after it. A resume the controller died
|
|
* before starting, a runner that never admitted its provider, and a turn.start
|
|
* the provider did not accept are not charged. After
|
|
* NATIVE_PROGRESS_CREDIT_GENERATION_LIMIT controller generations only a proven
|
|
* unstarted attempt keeps credit, and only until NATIVE_NO_PROGRESS_RESTART_LIMIT
|
|
* consecutive restarts made no provider progress, so a crash loop that never
|
|
* starts the provider, or that makes progress and then crashes the controller
|
|
* every time, is still bounded.
|
|
*/
|
|
export const NATIVE_PROGRESS_CREDIT_GENERATION_LIMIT = PROVIDER_ATTEMPT_CREDIT_GENERATION_LIMIT;
|
|
export const NATIVE_NO_PROGRESS_RESTART_LIMIT = PROVIDER_ATTEMPT_CREDIT_GENERATION_LIMIT;
|
|
|
|
type RecoveryClaimProgressEntry = {
|
|
disposition?: unknown; progressWatermarkSeq?: unknown; progressAttempt?: unknown; noProgressRestarts?: unknown;
|
|
};
|
|
|
|
function previousNoProgressRestarts(recoveryHistory: ReadonlyArray<RecoveryClaimProgressEntry>): number {
|
|
for (let index = recoveryHistory.length - 1; index >= 0; index -= 1) {
|
|
const entry = recoveryHistory[index];
|
|
if (!RECOVERY_CLAIM_DISPOSITIONS.has(entry?.disposition)) continue;
|
|
const count = entry?.noProgressRestarts;
|
|
return typeof count === "number" && Number.isSafeInteger(count) && count >= 0 ? count : 0;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* The most recent recovery claim's event-log position and the provider attempt
|
|
* its resume runs as. Entries from releases that did not record them give none.
|
|
*/
|
|
export function lastRecoveryClaimResume(
|
|
recoveryHistory: ReadonlyArray<RecoveryClaimProgressEntry>,
|
|
): { watermark: number; attempt: number; launchesRunner: boolean } | null {
|
|
const previous = previousRecoveryClaim(recoveryHistory);
|
|
return previous === "none" || previous === "unrecorded" ? null : previous;
|
|
}
|
|
|
|
function previousRecoveryClaim(
|
|
recoveryHistory: ReadonlyArray<RecoveryClaimProgressEntry>,
|
|
): { watermark: number; attempt: number; launchesRunner: boolean } | "none" | "unrecorded" {
|
|
for (let index = recoveryHistory.length - 1; index >= 0; index -= 1) {
|
|
const entry = recoveryHistory[index];
|
|
if (!RECOVERY_CLAIM_DISPOSITIONS.has(entry?.disposition)) continue;
|
|
const watermark = entry?.progressWatermarkSeq;
|
|
const attempt = entry?.progressAttempt;
|
|
// A reattachment adopts a runner that had already started its provider.
|
|
const launchesRunner = entry?.disposition === "resume_dead_runner" || entry?.disposition === "bootstrap_incomplete";
|
|
return typeof watermark === "number" && Number.isSafeInteger(watermark) && watermark >= 0
|
|
&& typeof attempt === "number" && Number.isSafeInteger(attempt)
|
|
? { watermark, attempt, launchesRunner } : "unrecorded";
|
|
}
|
|
return "none";
|
|
}
|
|
|
|
/**
|
|
* Whether the resume claimed at `watermark` reached execution. The executor
|
|
* records native.process_start_requested before any backend may spawn a
|
|
* runner, and every runner event carries a source event ID.
|
|
*/
|
|
async function nativeExecutionStartedSince(tx: Pick<Db, "select">, runId: string, watermark: number): Promise<boolean> {
|
|
const [started] = await tx
|
|
.select({ seq: heartbeatRunEvents.seq })
|
|
.from(heartbeatRunEvents)
|
|
.where(and(
|
|
eq(heartbeatRunEvents.runId, runId),
|
|
gt(heartbeatRunEvents.seq, watermark),
|
|
or(
|
|
isNotNull(heartbeatRunEvents.sourceEventId),
|
|
inArray(heartbeatRunEvents.eventType, [
|
|
"adapter.invoke", PROCESS_START_REQUESTED, PROCESS_IDENTITY_RECORDED, "tool.execution.started",
|
|
]),
|
|
),
|
|
))
|
|
.limit(1);
|
|
return started !== undefined;
|
|
}
|
|
|
|
async function nativeRunEventWatermark(tx: Pick<Db, "select">, runId: string): Promise<number> {
|
|
const [row] = await tx
|
|
.select({ seq: sql<string | number | null>`max(${heartbeatRunEvents.seq})` })
|
|
.from(heartbeatRunEvents)
|
|
.where(eq(heartbeatRunEvents.runId, runId));
|
|
return Number(row?.seq ?? 0);
|
|
}
|
|
|
|
/**
|
|
* Whether this restart claim keeps the provider attempt instead of charging
|
|
* one, judged by the work-based rule (provider-attempt-outcome.ts) over the
|
|
* attempt the restart interrupted:
|
|
* - The first restart judges the original execution's attempt.
|
|
* - A later restart judges the attempt that ran after the previous claim's
|
|
* recorded position. A normal retry in between starts a new attempt window,
|
|
* so earlier work does not count for it.
|
|
* - When the previous claim launched a new runner but that resume never
|
|
* reached execution, it consumed nothing and this claim replaces it. Its
|
|
* charge decision was never applied, so this claim carries it: the attempt
|
|
* ends at the attempt that resume was to run as, never above it.
|
|
* - A claim history written before claims recorded their position charges.
|
|
* At or after NATIVE_PROGRESS_CREDIT_GENERATION_LIMIT a restart charges unless
|
|
* the interrupted attempt is proven unstarted and fewer than
|
|
* NATIVE_NO_PROGRESS_RESTART_LIMIT consecutive restarts made no progress.
|
|
*/
|
|
export async function restartResumeKeepsAttempt(tx: Pick<Db, "select">, input: {
|
|
runId: string;
|
|
coordinator: {
|
|
attempt: number;
|
|
controllerGeneration: number;
|
|
recoveryHistory: ReadonlyArray<RecoveryClaimProgressEntry>;
|
|
};
|
|
}): Promise<{ keepsAttempt: boolean; noProgressRestarts: number }> {
|
|
const outcome = await interruptedAttemptOutcome(tx, input);
|
|
const noProgressRestarts = outcome === "provider_progressed" ? 0
|
|
: previousNoProgressRestarts(input.coordinator.recoveryHistory) + 1;
|
|
const keepsAttempt = input.coordinator.controllerGeneration < NATIVE_PROGRESS_CREDIT_GENERATION_LIMIT
|
|
? outcome !== "charged"
|
|
: outcome === "provider_not_started" && noProgressRestarts < NATIVE_NO_PROGRESS_RESTART_LIMIT;
|
|
return { keepsAttempt, noProgressRestarts };
|
|
}
|
|
|
|
async function interruptedAttemptOutcome(tx: Pick<Db, "select">, input: {
|
|
runId: string;
|
|
coordinator: {
|
|
attempt: number;
|
|
recoveryHistory: ReadonlyArray<RecoveryClaimProgressEntry>;
|
|
};
|
|
}): Promise<"provider_not_started" | "provider_progressed" | "charged"> {
|
|
if (input.coordinator.attempt <= 0) return "charged";
|
|
const previous = previousRecoveryClaim(input.coordinator.recoveryHistory);
|
|
if (previous === "unrecorded") return "charged";
|
|
if (previous !== "none" && previous.launchesRunner
|
|
&& !await nativeExecutionStartedSince(tx, input.runId, previous.watermark))
|
|
return input.coordinator.attempt >= previous.attempt ? "provider_not_started" : "charged";
|
|
const claimedAt = previous === "none" ? 0 : previous.watermark;
|
|
// A reattachment adopted a provider that had already started, and it keeps
|
|
// the attempt; a normal retry after it starts a new attempt.
|
|
const adoptedProviderWork = previous !== "none" && !previous.launchesRunner
|
|
&& input.coordinator.attempt === previous.attempt;
|
|
const window = adoptedProviderWork ? { seq: claimedAt }
|
|
: await providerAttemptWindowStart(tx, {
|
|
runId: input.runId, sinceSeq: claimedAt, providerAttempt: input.coordinator.attempt,
|
|
});
|
|
if (window === "unstarted") return "provider_not_started";
|
|
const outcome = await readProviderAttemptOutcome(tx, {
|
|
runId: input.runId,
|
|
sinceSeq: window.seq,
|
|
adoptedProviderWork,
|
|
});
|
|
if (providerAttemptChargesBudget(outcome)) return "charged";
|
|
return outcome === "provider_progressed" ? outcome : "provider_not_started";
|
|
}
|
|
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/** The native controller stamp of this process. The boot id is the single
|
|
* process boot id (`controller-process-identity.ts`) and the start time is read
|
|
* from the OS once, so claims and renewals on the turn path spawn no `ps`. */
|
|
export async function currentNativeControllerIdentity(): Promise<NativeControllerIdentity> {
|
|
const observed = await currentProcessStartedAtMs();
|
|
const fallback = new Date(getServerProcessStartedAt());
|
|
return {
|
|
bootId: currentControllerBootId(),
|
|
pid: process.pid,
|
|
processStartedAt:
|
|
observed !== null ? new Date(observed) : (Number.isNaN(fallback.getTime()) ? new Date() : fallback),
|
|
hostHash: currentProcessSpaceHash(),
|
|
};
|
|
}
|
|
|
|
export function isCurrentNativeControllerProcess(owner: {
|
|
controllerBootId: string | null;
|
|
controllerPid: number | null;
|
|
}): boolean {
|
|
return owner.controllerBootId === currentControllerBootId() && owner.controllerPid === process.pid;
|
|
}
|
|
|
|
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 {
|
|
// Shared with every other owner proof: a sub-second gap is ambiguous.
|
|
return processStartsDefinitelyDiffer(left, right);
|
|
}
|
|
|
|
export type NativeControllerTakeoverDecision = {
|
|
allowed: boolean;
|
|
reason: string;
|
|
/** The proof used the one-release local fallback for a row with no recorded host. */
|
|
legacyHostFallback?: true;
|
|
};
|
|
|
|
/** Activity-log action for each use of the null-host fallback. The release
|
|
* that removes the fallback counts these to see whether any rows still need
|
|
* it. It is not a run-log event: run events are progress evidence for
|
|
* restart recovery, and this record must not change any recovery decision. */
|
|
export const LEGACY_HOST_FALLBACK_EVENT = "legacy_host_unknown_dead_pid_proof";
|
|
|
|
export async function recordLegacyHostFallbackProof(db: Db, run: { companyId: string; id: string; agentId: string },
|
|
decision: NativeControllerTakeoverDecision, source: string) {
|
|
if (!decision.legacyHostFallback) return;
|
|
await persistActivity(db, { companyId: run.companyId, actorType: "system", actorId: "native-restart-recovery",
|
|
action: LEGACY_HOST_FALLBACK_EVENT, entityType: "heartbeat_run", entityId: run.id, runId: run.id, agentId: run.agentId,
|
|
details: { reason: decision.reason, source } });
|
|
}
|
|
|
|
export async function evaluateNativeControllerTakeover(input: {
|
|
owner: Pick<
|
|
typeof nativeRunFinalizations.$inferSelect,
|
|
| "leaseOwner"
|
|
| "leaseExpiresAt"
|
|
| "controllerPid"
|
|
| "controllerProcessStartedAt"
|
|
> & {
|
|
/** PID space of controllerPid. Absent for owners built from receipts
|
|
* that never stored one. Null on coordinator rows saved before the column
|
|
* existed: those keep the previous local proof for one release, outside
|
|
* ephemeral mode only (see LEGACY_HOST_FALLBACK_EVENT). */
|
|
controllerHostHash?: string | null;
|
|
controllerBootId?: string | null;
|
|
updatedAt?: Date;
|
|
};
|
|
now: Date;
|
|
coordinatedPreviousController?: {
|
|
pid: number;
|
|
processStartedAt: Date | null;
|
|
} | null;
|
|
isProcessAlive?: (pid: number) => boolean;
|
|
readProcessStartedAt?: (pid: number) => Promise<Date | null>;
|
|
requireStoppedController?: boolean;
|
|
requireRecordedProcessSpace?: boolean;
|
|
remoteLeaseFenced?: boolean;
|
|
}): Promise<NativeControllerTakeoverDecision> {
|
|
const { owner, now } = input;
|
|
if (input.remoteLeaseFenced) {
|
|
if (!owner.leaseExpiresAt || owner.leaseExpiresAt <= now) {
|
|
return { allowed: true, reason: "remote_controller_lease_released_or_expired" };
|
|
}
|
|
// The lease fences a second live controller from driving the same remote
|
|
// runner. When the lease holder recorded this exact process space, the
|
|
// shared prover can show it is gone (a hard restart), so the takeover need
|
|
// not wait out the lease. A controller in another process space (another
|
|
// container or host) is never probed here: its lease must expire. The
|
|
// one-release null-host fallback is never enough for a remote runner.
|
|
const sameProcessSpace = Object.prototype.hasOwnProperty.call(owner, "controllerHostHash")
|
|
&& typeof owner.controllerHostHash === "string"
|
|
&& owner.controllerHostHash !== UNKNOWN_PROCESS_SPACE
|
|
&& owner.controllerHostHash === currentProcessProbe().processSpace;
|
|
if (sameProcessSpace && owner.controllerPid) {
|
|
const local = await evaluateNativeControllerTakeover({ ...input, remoteLeaseFenced: false, requireStoppedController: true });
|
|
if (local.allowed && !local.legacyHostFallback) return { allowed: true, reason: `remote_lease_${local.reason}` };
|
|
}
|
|
return { allowed: false, reason: "remote_controller_lease_live" };
|
|
}
|
|
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",
|
|
};
|
|
}
|
|
const recordsHost = Object.prototype.hasOwnProperty.call(owner, "controllerHostHash");
|
|
// A board operator confirmed this exact controller stopped (see
|
|
// confirmNativeControllerStopped); that attestation is the proof.
|
|
if (recordsHost && owner.controllerHostHash === OPERATOR_ATTESTED_CONTROLLER_HOST) {
|
|
return { allowed: true, reason: "operator_attested_controller_stopped" };
|
|
}
|
|
// The local branch is the shared prover (plan Phase 2): a PID is judged
|
|
// only in the PID space that recorded it, a reused PID must have started
|
|
// definitely later, and EPERM is alive. A coordinator row saved before the
|
|
// host column keeps the one-release local proof on single-host servers
|
|
// (`processSpaceUnrecorded`; each use is recorded by the caller as
|
|
// LEGACY_HOST_FALLBACK_EVENT), and an ephemeral server never judges it.
|
|
// Owners built from receipts that never stored a PID space are judged in
|
|
// this PID space: their callers checked the recorded host first.
|
|
const base = currentProcessProbe();
|
|
const probe: ProcessProbe = {
|
|
...base,
|
|
// No platform-deployment shortcut: this stamp records no worker.
|
|
deployment: null,
|
|
pidExists: input.isProcessAlive ?? base.pidExists,
|
|
readStartedAt: input.readProcessStartedAt ?? base.readStartedAt,
|
|
};
|
|
const needsBootProof = input.requireRecordedProcessSpace || (owner.controllerHostHash != null
|
|
&& (owner.controllerHostHash !== probe.processSpace || owner.controllerHostHash === UNKNOWN_PROCESS_SPACE));
|
|
const proof = await proveOwnerGone({
|
|
kind: "local", scope: "native_controller", generation: null, host: null,
|
|
processSpace: recordsHost ? owner.controllerHostHash ?? null : probe.processSpace,
|
|
processSpaceUnrecorded: recordsHost && owner.controllerHostHash == null,
|
|
...(input.requireRecordedProcessSpace ? { requireRecordedProcessSpace: true } : {}),
|
|
bootId: needsBootProof ? owner.controllerBootId ?? null : null, touchedAt: owner.updatedAt?.toISOString() ?? null,
|
|
pid: priorPid, processStartedAt: owner.controllerProcessStartedAt?.toISOString() ?? null,
|
|
processGroupId: null,
|
|
}, probe);
|
|
const fallback = proof.legacyHostFallback ? { legacyHostFallback: true as const } : {};
|
|
if (proof.verdict === "gone") {
|
|
if (proof.reason === "controller_fenced" || proof.reason === "controller_stopped") return { allowed: true, reason: proof.reason };
|
|
if (proof.reason === "pid_reused") return { allowed: true, reason: "controller_pid_recycled", ...fallback };
|
|
return {
|
|
allowed: true,
|
|
reason:
|
|
owner.leaseExpiresAt !== null && owner.leaseExpiresAt <= now
|
|
? "expired_lease_controller_process_dead"
|
|
: "controller_process_dead",
|
|
...fallback,
|
|
};
|
|
}
|
|
if (proof.reason === "controller_lease_held" || proof.reason === "controller_lease_grace") return { allowed: false, reason: proof.reason };
|
|
if (proof.reason === "process_space_unrecorded") return { allowed: false, reason: "controller_host_unrecorded" };
|
|
if (proof.reason === "different_process_space" || proof.reason === "unknown_process_space"
|
|
|| proof.reason === "different_host") {
|
|
return { allowed: false, reason: "controller_host_unprovable" };
|
|
}
|
|
// A live PID observed to start before the recorded controller is an
|
|
// inconsistent stamp, never proof of reuse.
|
|
if (proof.reason === "pid_start_inconsistent") return { allowed: false, reason: "controller_pid_start_inconsistent" };
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/** Run event types that can carry a provider process identity. */
|
|
/** Projected events a runner emits while opening its provider session, before any turn. */
|
|
const SESSION_OPEN_PROVIDER_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|
"harness.ready", "session.started", "session.resumed", "session.updated",
|
|
]);
|
|
|
|
export const NATIVE_PROVIDER_PROCESS_EVENT_TYPES = [
|
|
"harness.ready",
|
|
"session.started",
|
|
"session.resumed",
|
|
"session.updated",
|
|
"turn.started",
|
|
"provider.event",
|
|
"provider.rpc_result",
|
|
] as const;
|
|
|
|
function parsedProcessStart(value: unknown): Date | null {
|
|
const parsed = typeof value === "string" ? new Date(value) : null;
|
|
return parsed && !Number.isNaN(parsed.getTime()) ? parsed : null;
|
|
}
|
|
|
|
/** Provider process identities from a saved checkpoint and recent run events. */
|
|
export function nativeProviderProcessIdentities(
|
|
checkpoint: Record<string, unknown>,
|
|
providerEvents: ReadonlyArray<{ payload: Record<string, unknown> | null }>,
|
|
): NativeProviderProcessIdentity[] {
|
|
const identities: NativeProviderProcessIdentity[] = [];
|
|
const checkpointProcess =
|
|
checkpoint.process && typeof checkpoint.process === "object" && !Array.isArray(checkpoint.process)
|
|
? (checkpoint.process as Record<string, unknown>)
|
|
: {};
|
|
for (const [field, startedAtField] of [
|
|
["providerPid", "providerProcessStartedAt"],
|
|
["codexPid", "codexProcessStartedAt"],
|
|
["sidecarPid", "sidecarProcessStartedAt"],
|
|
["agentPid", "agentProcessStartedAt"],
|
|
] as const) {
|
|
const value = checkpointProcess[field];
|
|
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
|
|
identities.push({ pid: value, processStartedAt: parsedProcessStart(checkpointProcess[startedAtField]) });
|
|
}
|
|
}
|
|
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) {
|
|
identities.push({ pid: processId,
|
|
processStartedAt: parsedProcessStart(eventPayload.processStartedAt ?? eventPayload.providerProcessStartedAt) });
|
|
}
|
|
}
|
|
return identities;
|
|
}
|
|
|
|
export type NativeLocalRunnerStopProof =
|
|
| { stopped: true }
|
|
| {
|
|
stopped: false;
|
|
reason:
|
|
| "runner_process_identity_missing"
|
|
| "runner_process_alive"
|
|
| "runner_process_identity_unverifiable"
|
|
| "runner_process_group_alive"
|
|
| "provider_process_alive"
|
|
| "provider_process_identity_unverifiable";
|
|
};
|
|
|
|
/**
|
|
* Proves that a finished local runner and its recorded provider processes
|
|
* stopped, with the same exact PID and start-time checks as restart recovery.
|
|
* A PID that now belongs to a different process counts as stopped. A live
|
|
* process with the recorded identity, a live process group, or a live PID
|
|
* whose start time cannot be compared is not proof.
|
|
*/
|
|
export async function proveNativeLocalRunnerStopped(input: {
|
|
processPid: number | null;
|
|
processGroupId: number | null;
|
|
processStartedAt: Date | null;
|
|
providerProcesses?: readonly NativeProviderProcessIdentity[];
|
|
/** A current, server-authored stop receipt already proved this exact Runner and group absent. */
|
|
runnerStopRecorded?: boolean;
|
|
isProcessAlive?: (pid: number) => boolean;
|
|
isProcessGroupAlive?: (processGroupId: number) => boolean;
|
|
readProcessStartedAt?: (pid: number) => Promise<Date | null>;
|
|
}): Promise<NativeLocalRunnerStopProof> {
|
|
const refuse = (reason: Extract<NativeLocalRunnerStopProof, { stopped: false }>["reason"]) =>
|
|
({ stopped: false as const, reason });
|
|
// A local Runner leads its own process group. Without both identities, a
|
|
// live child in that group could outlive the Runner unseen.
|
|
if (!input.processPid || !Number.isInteger(input.processPid) || input.processPid <= 1 || !input.processGroupId
|
|
|| (!input.processStartedAt && !input.runnerStopRecorded)) {
|
|
return refuse("runner_process_identity_missing");
|
|
}
|
|
if (!input.runnerStopRecorded) {
|
|
const runner = await evaluateNativeProviderProcesses({
|
|
identities: [{ pid: input.processPid, processStartedAt: input.processStartedAt }],
|
|
isProcessAlive: input.isProcessAlive,
|
|
readProcessStartedAt: input.readProcessStartedAt,
|
|
});
|
|
if (runner.livePids.length > 0) return refuse("runner_process_alive");
|
|
if (runner.ambiguousLivePids.length > 0) return refuse("runner_process_identity_unverifiable");
|
|
}
|
|
const groupAlive = input.isProcessGroupAlive ?? processGroupIsAlive;
|
|
if (groupAlive(input.processGroupId)) return refuse("runner_process_group_alive");
|
|
const providers = await evaluateNativeProviderProcesses({
|
|
identities: (input.providerProcesses ?? []).filter(identity => identity.pid !== input.processPid),
|
|
isProcessAlive: input.isProcessAlive,
|
|
readProcessStartedAt: input.readProcessStartedAt,
|
|
});
|
|
if (providers.livePids.length > 0) return refuse("provider_process_alive");
|
|
if (providers.ambiguousLivePids.length > 0) return refuse("provider_process_identity_unverifiable");
|
|
return { stopped: true };
|
|
}
|
|
|
|
export function classifyNativeRunnerRecoveryEvidence(input: {
|
|
runnerPidAlive: boolean;
|
|
runnerGroupAlive: boolean;
|
|
remoteSandbox?: boolean;
|
|
unclaimedBootstrap?: boolean;
|
|
/** Durable evidence proves a claimed remote bootstrap reached no provider work. */
|
|
remoteBootstrapProvenIncomplete?: boolean;
|
|
remoteBootstrapIncompleteReason?: "remote_runner_never_launched" | "remote_session_opened_without_turn"
|
|
| "remote_runner_exited_before_provider_work" | "remote_runner_never_received_commands"
|
|
| "remote_follow_up_never_attached";
|
|
processStartMatches: boolean;
|
|
knownProviderProcessAlive?: boolean;
|
|
knownProviderProcessIdentityAmbiguous?: boolean;
|
|
hasCheckpoint: boolean;
|
|
checkpointIdentityMatches?: boolean;
|
|
hasProviderEvidence: boolean;
|
|
checkpointFailed?: boolean;
|
|
stagedAttachmentVerified?: boolean;
|
|
providerAttempt?: number;
|
|
/** False when resuming keeps the current attempt (a clean drain). */
|
|
resumeChargesAttempt?: boolean;
|
|
}): {
|
|
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" };
|
|
}
|
|
// Callers prove provider evidence is absent or only session-open events.
|
|
if (input.remoteBootstrapProvenIncomplete === true && !input.hasCheckpoint) {
|
|
// Each same-run bootstrap is another provider attempt; keep restarts bounded.
|
|
if (input.resumeChargesAttempt !== false && (input.providerAttempt ?? 0) >= 3)
|
|
return { claimKind: null, reason: "execution_recovery_budget_exhausted" };
|
|
return { claimKind: "bootstrap_incomplete",
|
|
reason: input.remoteBootstrapIncompleteReason ?? "remote_bootstrap_proven_incomplete" };
|
|
}
|
|
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",
|
|
};
|
|
}
|
|
// Continuing an attempt already counted needs no budget; a new one does.
|
|
if (input.resumeChargesAttempt !== false && (input.providerAttempt ?? 0) >= NATIVE_PROVIDER_RECOVERY_ATTEMPT_LIMIT)
|
|
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" };
|
|
}
|
|
|
|
/** Any record that execution reached a runner: a requested or identified
|
|
* process, an adapter invocation, a tool start, or a runner-sourced event. */
|
|
/** Whether a runner or provider produced any event for this run. Unlike
|
|
* hasNativeExecutionStartEvidence, the controller's own process-start and
|
|
* identity records do not count: a relaunched runner that exited before run.prepare
|
|
* has those, but nothing the runner itself sent. */
|
|
async function hasNativeRunnerOriginatedEvidence(
|
|
tx: Pick<Db, "select">, run: { companyId: string; id: string },
|
|
): Promise<boolean> {
|
|
const [event] = await tx.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents)
|
|
.where(and(eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
|
|
or(isNotNull(heartbeatRunEvents.sourceEventId),
|
|
inArray(heartbeatRunEvents.eventType, ["adapter.invoke", "tool.execution.started"]))))
|
|
.limit(1);
|
|
return event !== undefined;
|
|
}
|
|
|
|
/** A settled earlier run of the same native session saved this exact provider
|
|
* session. Admission rebinds that checkpoint to a follow-up run, so the
|
|
* follow-up's copy of the provider session is carried, not its own work. */
|
|
export async function carriesSettledPredecessorSession(
|
|
tx: Pick<Db, "select">,
|
|
run: { companyId: string; id: string; nativeSessionId: string | null; createdAt: Date },
|
|
providerSessionId: unknown,
|
|
): Promise<boolean> {
|
|
if (typeof providerSessionId !== "string" || !providerSessionId || !run.nativeSessionId) return false;
|
|
const [predecessor] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, run.companyId), eq(heartbeatRuns.nativeSessionId, run.nativeSessionId),
|
|
ne(heartbeatRuns.id, run.id), lt(heartbeatRuns.createdAt, run.createdAt), isNotNull(heartbeatRuns.finishedAt),
|
|
inArray(heartbeatRuns.status, ["succeeded", "failed", "cancelled", "timed_out", "interrupted"]),
|
|
sql`${heartbeatRuns.runnerProfileJson}#>>'{sessionCheckpoint,providerSessionId}' = ${providerSessionId}`,
|
|
)).limit(1);
|
|
return predecessor !== undefined;
|
|
}
|
|
|
|
async function hasNativeRunEvent(
|
|
tx: Pick<Db, "select">, run: { companyId: string; id: string }, eventType: string,
|
|
): Promise<boolean> {
|
|
const [event] = await tx.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents)
|
|
.where(and(eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
|
|
eq(heartbeatRunEvents.eventType, eventType)))
|
|
.limit(1);
|
|
return event !== undefined;
|
|
}
|
|
|
|
export async function hasNativeExecutionStartEvidence(
|
|
tx: Pick<Db, "select">, run: { companyId: string; id: string },
|
|
): Promise<boolean> {
|
|
const [executionEvent] = await tx
|
|
.select({ id: heartbeatRunEvents.id })
|
|
.from(heartbeatRunEvents)
|
|
.where(and(
|
|
eq(heartbeatRunEvents.companyId, run.companyId),
|
|
eq(heartbeatRunEvents.runId, run.id),
|
|
or(
|
|
isNotNull(heartbeatRunEvents.sourceEventId),
|
|
inArray(heartbeatRunEvents.eventType, [
|
|
"adapter.invoke",
|
|
PROCESS_START_REQUESTED,
|
|
PROCESS_IDENTITY_RECORDED,
|
|
"tool.execution.started",
|
|
]),
|
|
),
|
|
))
|
|
.limit(1);
|
|
return executionEvent !== undefined;
|
|
}
|
|
|
|
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>;
|
|
}
|
|
|
|
export 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
|
|
)
|
|
)`;
|
|
}
|
|
|
|
/** Shared by restart inspection and failed-owner cleanup under their existing
|
|
* issue/coordinator/run locks. Publishing attention never grants a new turn. */
|
|
export async function surfaceNativeControllerNamespaceAttention(db: Db, input: {
|
|
run: Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "agentId">;
|
|
coordinator: typeof nativeRunFinalizations.$inferSelect;
|
|
expiryEpoch: string | null;
|
|
remoteProcess: boolean;
|
|
}): Promise<void> {
|
|
const reason = input.remoteProcess ? "remote_controller_process_space_unverified" : "controller_process_space_unverified";
|
|
const attention = issueRecoveryActionService(db);
|
|
const digest = nativeControllerAuthorityDigest(input.coordinator, input.expiryEpoch);
|
|
const fingerprint = `native-controller-space:${input.run.id}:${digest}`;
|
|
const [existing] = await db.select().from(issueRecoveryActions).where(and(
|
|
eq(issueRecoveryActions.companyId, input.run.companyId), eq(issueRecoveryActions.sourceIssueId, input.coordinator.issueId),
|
|
inArray(issueRecoveryActions.status, ["active", "escalated"]),
|
|
)).for("update").limit(1);
|
|
// One incident per exact authority. A repeat neither spends a retry
|
|
// attempt nor emits a new wake. Renewed/replaced authority gets a new
|
|
// incident, preserving the prior confirmation/history. An unrelated
|
|
// active recovery owner is never overwritten by this observation.
|
|
const sameSource = existing?.cause === "native_restart_recovery_blocked"
|
|
&& existing.evidence.runId === input.run.id
|
|
&& ["controller_process_space_unverified", "remote_controller_process_space_unverified"].includes(String(existing.evidence.reason));
|
|
if (!existing || (sameSource && existing.fingerprint !== fingerprint)) {
|
|
await attention.upsertSourceScoped({
|
|
companyId: input.run.companyId, sourceIssueId: input.coordinator.issueId,
|
|
kind: "active_run_watchdog", ownerType: "board", returnOwnerAgentId: input.run.agentId,
|
|
cause: "native_restart_recovery_blocked", fingerprint,
|
|
evidence: { runId: input.run.id, reason, executorStopEvidence: "unverified",
|
|
...(!input.remoteProcess ? { controllerAuthority: { schema: "paperclip.native-controller-authority.v1",
|
|
digest, leaseExpiresAt: input.coordinator.leaseExpiresAt!.toISOString() } } : {}) },
|
|
nextAction: input.remoteProcess
|
|
? "Use the task's ordinary Stop action and its environment provider's stop/cleanup recovery. Wait for that exact controller/provider acknowledgement. Local PID checks and local containment confirmation cannot authorize remote recovery."
|
|
: "Stop the original controller and provider on their original host. After the saved lease expires, a board operator must record both stops and review action outcomes. Recording containment does not retry the task.",
|
|
createOnly: !existing, supersedeOnIdentityChange: true,
|
|
attemptCount: 0, maxAttempts: null, timeoutAt: null, lastAttemptAt: null,
|
|
wakePolicy: null, monitorPolicy: null,
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function claimNativeRestartRecoveries(input: {
|
|
db: Db;
|
|
controller?: NativeControllerIdentity;
|
|
restartKind: NativeRestartKind;
|
|
recoveryRequestId?: string | null;
|
|
runIds?: string[];
|
|
liveRunExecutions?: Pick<ReadonlySet<string>, "has">;
|
|
/** Runs this process handed to the next server (graceful drain or hot restart). */
|
|
handedOffForShutdown?: (runId: string) => boolean;
|
|
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 controllerHostHash = currentProcessSpaceHash();
|
|
const candidateQuery = input.db
|
|
.select({
|
|
runId: heartbeatRuns.id,
|
|
issueId: nativeRunFinalizations.issueId,
|
|
companyId: heartbeatRuns.companyId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.innerJoin(agents, eq(agents.id, heartbeatRuns.agentId))
|
|
.innerJoin(
|
|
nativeRunFinalizations,
|
|
eq(nativeRunFinalizations.runId, heartbeatRuns.id),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.runtimeMode, "native"),
|
|
legacyWorkspaceRecoveryNotMigrated(),
|
|
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, companyId: issues.companyId, status: issues.status,
|
|
executionRunId: issues.executionRunId, checkoutRunId: issues.checkoutRunId,
|
|
assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId })
|
|
.from(issues)
|
|
.where(and(eq(issues.id, candidate.issueId), eq(issues.companyId, candidate.companyId)))
|
|
.for("update", { skipLocked: true });
|
|
if (!issue) return busy();
|
|
const row = await tx
|
|
.select({
|
|
run: heartbeatRuns,
|
|
coordinator: nativeRunFinalizations,
|
|
controllerLeaseExpiryEpoch: sql<string | null>`extract(epoch from ${nativeRunFinalizations.leaseExpiresAt})::numeric::text`,
|
|
issueExecutionRunId: issues.executionRunId,
|
|
issueCheckoutRunId: issues.checkoutRunId,
|
|
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();
|
|
}
|
|
if (row.run.runnerProfileJson?.legacyWorkspaceRecoveryMigration != null)
|
|
return { kind: "blocked", runId: row.run.id, reason: "legacy_workspace_recovery_migrated" } as const;
|
|
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;
|
|
const profile = row.run.runnerProfileJson ?? {};
|
|
const storedRemoteWorkspace = remoteWorkspaceRunReferenceSchema.safeParse(profile.remoteWorkspace);
|
|
const remoteWorkspace = readNativeWorkspaceSyncReference(profile.nativeWorkspaceSync)
|
|
?? (storedRemoteWorkspace.success ? storedRemoteWorkspace.data : null);
|
|
const { pluginTask, remoteProcess } = await readNativeRemoteExecutionOwnership(tx as unknown as Db,
|
|
{ run: row.run, issueId: issue.id });
|
|
const failedRemoteRetry = row.run.status === "failed" && row.coordinator.phase === "retryable_failure"
|
|
&& row.coordinator.failureDetail?.recoveryMode === "exact_checkpoint_resume" && (remoteWorkspace !== null || pluginTask !== undefined);
|
|
// A same-run bootstrap retry cannot take back an issue lock that moved
|
|
// off it. Without a bounded admission it would re-check forever, never
|
|
// settle, and keep its sandbox lease from the orphan-lease sweep.
|
|
const lockLostBootstrapRetry = row.run.status === "failed" && row.coordinator.phase === "retryable_failure"
|
|
&& row.coordinator.failureDetail?.recoveryMode === "bootstrap_retry" && row.issueExecutionRunId !== row.run.id;
|
|
const releasedRemoteRetry = failedRemoteRetry && row.issueExecutionRunId === null;
|
|
const exhaustedRemoteRetry = failedRemoteRetry && row.coordinator.attempt >= 3;
|
|
const needsResumptionAdmission = releasedRemoteRetry || exhaustedRemoteRetry ||
|
|
(failedRemoteRetry && ["done", "cancelled"].includes(issue.status));
|
|
const admission = { exhausted: null as { reason: string; policy: SystemRecoveryState } | null };
|
|
const deferRemoteRetry = async (reason: string, permanent = false) => {
|
|
if (input.liveRunExecutions?.has(row.run.id)) {
|
|
return { kind: "awaiting_evidence", runId: row.run.id, reason: "current_controller_preparing" } as const;
|
|
}
|
|
const existingDeadline = Math.max(row.coordinator.nextAttemptAt?.getTime() ?? 0,
|
|
row.coordinator.leaseOwner ? row.coordinator.leaseExpiresAt?.getTime() ?? 0 : 0);
|
|
if (existingDeadline > now.getTime()) {
|
|
return { kind: "blocked", runId: row.run.id, reason, retryAt: new Date(existingDeadline),
|
|
transitionUnchanged: true } as const;
|
|
}
|
|
const previousPolicy = readSystemRecovery(row.coordinator.failureDetail);
|
|
const policy = previousPolicy?.sourceRunId === row.run.id ? previousPolicy : createSystemRecovery(row.run.id);
|
|
const reserved = reserveSystemRecoveryAttempt(policy, now.getTime());
|
|
const failed = reserved ? failSystemRecovery(reserved, new SystemRecoveryFailure(
|
|
permanent ? "source_binding_invalid" : "allocation_ownership_ambiguous",
|
|
permanent ? "user_action_required" : "recheck_evidence"), { now: now.getTime(), evidenceFingerprint: reason })
|
|
: { ...policy, state: "exhausted" as const, nextAttemptAt: null };
|
|
if (failed.state === "exhausted") {
|
|
admission.exhausted = { reason, policy: failed };
|
|
return null;
|
|
}
|
|
const retryAt = new Date(failed.nextAttemptAt!);
|
|
const previous = row.coordinator.recoveryHistory.at(-1);
|
|
const transitionUnchanged = previous?.disposition === "blocked" && previous.reason === reason;
|
|
await notifyLiveRunRecovery(tx, { companyId: row.run.companyId, runId: row.run.id });
|
|
const updated = await tx.update(nativeRunFinalizations).set({ nextAttemptAt: retryAt, updatedAt: now,
|
|
failureDetail: { ...row.coordinator.failureDetail, systemRecovery: failed },
|
|
...(!transitionUnchanged ? { recoveryHistory: appendBoundedRecoveryHistory({
|
|
at: now.toISOString(), restartKind: input.restartKind, controllerBootId: controller.bootId,
|
|
controllerGeneration: row.coordinator.controllerGeneration, disposition: "blocked", reason,
|
|
recoveryRequestId: input.recoveryRequestId ?? null, providerAttempt: row.coordinator.attempt,
|
|
stateRootAction: "preserve_fail_closed", retryAt: retryAt.toISOString(),
|
|
}) } : {}),
|
|
}).where(and(eq(nativeRunFinalizations.companyId, row.run.companyId),
|
|
eq(nativeRunFinalizations.issueId, issue.id), eq(nativeRunFinalizations.runId, row.run.id),
|
|
eq(nativeRunFinalizations.phase, "retryable_failure"), eq(nativeRunFinalizations.attempt, row.coordinator.attempt),
|
|
isNull(nativeRunFinalizations.resultId),
|
|
or(isNull(nativeRunFinalizations.nextAttemptAt), lte(nativeRunFinalizations.nextAttemptAt, now)),
|
|
or(isNull(nativeRunFinalizations.leaseOwner), isNull(nativeRunFinalizations.leaseExpiresAt), lte(nativeRunFinalizations.leaseExpiresAt, now)),
|
|
)).returning({ runId: nativeRunFinalizations.runId });
|
|
if (!updated.length) throw new Error("native_retry_deferral_claim_changed");
|
|
return { kind: "blocked", runId: row.run.id, reason, retryAt, transitionUnchanged } as const;
|
|
};
|
|
if (failedRemoteRetry || lockLostBootstrapRetry) {
|
|
const source = alias(heartbeatRuns, "native_retry_source");
|
|
const [newer] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns)
|
|
.innerJoin(source, and(eq(source.companyId, row.run.companyId), eq(source.id, row.run.id)))
|
|
.where(and(
|
|
eq(heartbeatRuns.companyId, row.run.companyId),
|
|
or(gt(heartbeatRuns.createdAt, source.createdAt), and(
|
|
eq(heartbeatRuns.createdAt, source.createdAt), gt(heartbeatRuns.id, source.id),
|
|
)),
|
|
or(eq(heartbeatRuns.issueId, issue.id), eq(heartbeatRuns.nativeIssueId, issue.id),
|
|
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issue.id}`),
|
|
)).limit(1);
|
|
if (newer) {
|
|
const deferred = await deferRemoteRetry("native_retry_source_superseded", true);
|
|
if (deferred) return deferred;
|
|
}
|
|
}
|
|
const resumptionAgent = needsResumptionAdmission
|
|
? await readNativeSessionResumptionAgent(tx as unknown as Db, { issue, run: row.run, coordinator: row.coordinator })
|
|
: null;
|
|
if (needsResumptionAdmission && !resumptionAgent && !admission.exhausted) {
|
|
const deferred = await deferRemoteRetry("native_retry_admission_ineligible", ["done", "cancelled"].includes(issue.status));
|
|
if (deferred) return deferred;
|
|
}
|
|
if (lockLostBootstrapRetry && !admission.exhausted) {
|
|
const deferred = await deferRemoteRetry("issue_execution_lock_changed");
|
|
if (deferred) return deferred;
|
|
}
|
|
// The run handed its own task to a person. It is not resumed: the
|
|
// ownership gate below settles it (task_ownership_or_status_changed)
|
|
// without touching the person's task, instead of leaving it running.
|
|
const handedOff = row.issueExecutionRunId === null && nativeRunHandedOffTask(row.run.resultJson, issue);
|
|
if (row.issueExecutionRunId !== row.run.id && !(stopCleanup && row.issueExecutionRunId === null) && !releasedRemoteRetry
|
|
&& !admission.exhausted && !handedOff) {
|
|
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 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 durableAuthority = (remoteWorkspace || pluginTask) && await hasNativeRunnerCheckpoint(tx, {
|
|
companyId: row.run.companyId, issueId: row.coordinator.issueId, runId: row.run.id,
|
|
});
|
|
// Lease expiry alone does not prove a local in-flight owner stopped.
|
|
// Only this boot's own PID or the same known process space can make a
|
|
// local PID observation meaningful. Durable remote leases keep their
|
|
// existing released/expired authority instead of using local probes.
|
|
const processSpaceUnverified = !durableAuthority
|
|
&& Boolean(row.coordinator.leaseOwner && row.coordinator.leaseExpiresAt)
|
|
&& !isCurrentNativeControllerProcess(row.coordinator)
|
|
&& (controllerHostHash === "unknown" || row.coordinator.controllerHostHash !== controllerHostHash);
|
|
const nonExecutionOwner = row.coordinator.leaseOwner?.startsWith("native-finalizer:")
|
|
|| row.coordinator.leaseOwner?.startsWith("native-cleanup:");
|
|
// The recorded controller is this exact process (same boot and PID), so
|
|
// a PID probe can only ever answer "alive" and the wait would never end.
|
|
// This process knows whether it still drives the run: its executor is
|
|
// registered for the whole execution. While it is, leave the run to it.
|
|
// Once it has exited (for example after a stall outlived its lease and
|
|
// renewal detached it for "the next controller"), the prior owner is
|
|
// provably gone, and the evidence checks below decide as for any
|
|
// stopped controller. An unexpired self lease still waits: a claim that
|
|
// has not reached its executor yet holds it, and it lapses on its own.
|
|
// A run this process handed to the next server (graceful drain or hot
|
|
// restart) stays with that server: this process is about to exit.
|
|
const ownControllerProcess = !nonExecutionOwner && isCurrentNativeControllerProcess(row.coordinator);
|
|
if (ownControllerProcess && activeNativeExecutionRunIds.has(row.run.id)) {
|
|
return { kind: "awaiting_evidence", runId: row.run.id, reason: "current_controller_preparing" } as const;
|
|
}
|
|
const ownLeaseLapsed = ownControllerProcess && row.coordinator.leaseExpiresAt !== null
|
|
&& row.coordinator.leaseExpiresAt <= now && !input.handedOffForShutdown?.(row.run.id)
|
|
&& !handedToNextControllerSinceClaim(row.coordinator.recoveryHistory);
|
|
const evaluatedTakeover = nonExecutionOwner
|
|
? { allowed: false, reason: "native_finalization_or_cleanup_owner" }
|
|
: ownLeaseLapsed ? { allowed: true, reason: "current_controller_released" }
|
|
: await evaluateNativeControllerTakeover({
|
|
owner: row.coordinator,
|
|
now,
|
|
requireRecordedProcessSpace: processSpaceUnverified,
|
|
remoteLeaseFenced: Boolean(durableAuthority),
|
|
coordinatedPreviousController:
|
|
input.coordinatedPreviousController ?? null,
|
|
});
|
|
const independentControllerWait = !evaluatedTakeover.allowed
|
|
&& ["controller_lease_held", "controller_lease_grace"].includes(evaluatedTakeover.reason);
|
|
// Controller death does not prove a foreign local Runner or provider
|
|
// stopped. Remote execution never uses local PID probes and still owes
|
|
// its existing checkpoint or never-launched proof below.
|
|
const independentlyStoppedRemoteController = remoteProcess && evaluatedTakeover.allowed
|
|
&& ["controller_stopped", "controller_fenced"].includes(evaluatedTakeover.reason);
|
|
const namespaceAttentionNeeded = processSpaceUnverified && !nonExecutionOwner
|
|
&& !independentControllerWait && !independentlyStoppedRemoteController;
|
|
const takeover = namespaceAttentionNeeded
|
|
? { allowed: false, reason: remoteProcess ? "remote_controller_process_space_unverified" : "controller_process_space_unverified" }
|
|
: evaluatedTakeover;
|
|
await recordLegacyHostFallbackProof(tx as unknown as Db, row.run, takeover, "native_restart_recovery");
|
|
if (!takeover.allowed && ["controller_host_unrecorded", "controller_host_unprovable"].includes(takeover.reason)) {
|
|
await requestNativeControllerStopConfirmation(tx as unknown as Db, row.coordinator);
|
|
}
|
|
if (!takeover.allowed) {
|
|
if (namespaceAttentionNeeded) {
|
|
await surfaceNativeControllerNamespaceAttention(tx as unknown as Db, {
|
|
run: row.run, coordinator: row.coordinator, expiryEpoch: row.controllerLeaseExpiryEpoch, remoteProcess,
|
|
});
|
|
}
|
|
const previous = row.coordinator.recoveryHistory.at(-1);
|
|
const waitAlreadyRecorded = previous?.disposition === "awaiting_evidence" && previous.reason === takeover.reason;
|
|
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,
|
|
// Repeated scans of one ownership wait record it once, so the
|
|
// bounded history keeps the drain record the next claim reads.
|
|
...(waitAlreadyRecorded ? {} : { recoveryHistory: appendBoundedRecoveryHistory(event) }),
|
|
updatedAt: waitAlreadyRecorded && row.coordinator.recoveryState === "awaiting_evidence"
|
|
&& row.coordinator.recoveryRequestId === (input.recoveryRequestId ?? null) ? row.coordinator.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 }
|
|
// This process's own unexpired lease with no driver here lapses by
|
|
// itself; look again then instead of waiting for another signal.
|
|
: ownControllerProcess && row.coordinator.leaseExpiresAt && row.coordinator.leaseExpiresAt > now
|
|
&& !input.handedOffForShutdown?.(row.run.id) ? { retryAt: row.coordinator.leaseExpiresAt }
|
|
: ["controller_lease_held", "controller_lease_grace"].includes(takeover.reason)
|
|
? { retryAt: new Date(now.getTime() + CONTROLLER_LEASE_RENEW_MS) } : {}),
|
|
} 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,
|
|
eventType: heartbeatRunEvents.eventType,
|
|
payload: heartbeatRunEvents.payload,
|
|
})
|
|
.from(heartbeatRunEvents)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRunEvents.runId, row.run.id),
|
|
inArray(heartbeatRunEvents.eventType, [...NATIVE_PROVIDER_PROCESS_EVENT_TYPES]),
|
|
),
|
|
)
|
|
.orderBy(desc(heartbeatRunEvents.id))
|
|
.limit(100);
|
|
const providerProcessIdentities = nativeProviderProcessIdentities(checkpointRecord, providerEvents);
|
|
const providerProcesses = await evaluateNativeProviderProcesses({
|
|
identities: (remoteProcess ? [] : providerProcessIdentities).filter(
|
|
(identity) => identity.pid !== row.run.processPid,
|
|
),
|
|
});
|
|
const hasProviderEvidence =
|
|
hasCheckpointProviderIdentity || providerEvents.length > 0;
|
|
|
|
const admissionCheckpointOnly = !hasCheckpoint || (checkpointIdentityMatches && checkpointRecord.cursor == null
|
|
&& (!Array.isArray(checkpointRecord.terminalTurns) || checkpointRecord.terminalTurns.length === 0)
|
|
&& checkpointRecord.activeTurnId == null && checkpointRecord.semanticResult == null && checkpointRecord.terminal == null
|
|
&& (checkpointRecord.pendingRuntimeRequests == null
|
|
|| (Array.isArray(checkpointRecord.pendingRuntimeRequests) && checkpointRecord.pendingRuntimeRequests.length === 0)));
|
|
const inheritedCheckpointOnly = hasCheckpoint && admissionCheckpointOnly && remoteSandbox && !pluginTask
|
|
&& !durableAuthority && stagedAttachment === null && providerEvents.length === 0;
|
|
const inheritedRunnerIdentity = inheritedCheckpointOnly && Boolean(row.run.processPid || row.run.processGroupId)
|
|
&& await carriesInheritedRunnerIdentity(tx, row.run);
|
|
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 &&
|
|
// A graceful shutdown stamps an expiry on every coordinator it
|
|
// suspends. Without an owner, an elapsed expiry grants no authority.
|
|
(!row.coordinator.leaseExpiresAt || row.coordinator.leaseExpiresAt <= now) &&
|
|
!row.coordinator.failureDetail?.successorRunId &&
|
|
(inheritedRunnerIdentity || (!row.run.processPid && !row.run.processGroupId && !row.run.processStartedAt)) &&
|
|
!row.run.sessionIdBefore &&
|
|
!row.run.sessionIdAfter &&
|
|
Boolean(row.run.nativeSessionId && row.run.runnerInstanceId) &&
|
|
(!hasCheckpoint || inheritedCheckpointOnly) &&
|
|
(!hasProviderEvidence || inheritedCheckpointOnly);
|
|
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;
|
|
}
|
|
unclaimedBootstrap = !(await hasNativeExecutionStartEvidence(tx, row.run));
|
|
}
|
|
|
|
// A claimed remote run whose executor died while still preparing. The
|
|
// executor durably records native.process_start_requested before any
|
|
// backend may spawn a runner, and a remote runner exchanges commands only
|
|
// through a Postgres controller checkpoint. With neither, nothing reached
|
|
// a runner. The previous controller is gone (takeover was allowed above).
|
|
const pendingInheritedRequests = Array.isArray(checkpointRecord.pendingRuntimeRequests)
|
|
&& checkpointRecord.pendingRuntimeRequests.length > 0;
|
|
const continuedSessionOnly = hasCheckpoint && checkpointIdentityMatches && providerEvents.length === 0
|
|
&& !pendingInheritedRequests && checkpointRecord.activeTurnId == null && checkpointRecord.semanticResult == null
|
|
&& checkpointRecord.terminal == null;
|
|
const remoteRunnerNeverLaunched = !unclaimedBootstrap && remoteWorkspace?.state === "prepared" && !pluginTask
|
|
&& !durableAuthority && stagedAttachment === null
|
|
&& row.run.runnerProfileJson?.nativePreparationFencingVersion === NATIVE_PREPARATION_FENCING_VERSION
|
|
&& row.coordinator.attempt >= 1 && row.coordinator.controllerGeneration >= 1
|
|
&& Boolean(row.coordinator.controllerBootId && row.coordinator.controllerPid && row.coordinator.controllerProcessStartedAt)
|
|
// Either the executor still held the claim, or it settled a same-run
|
|
// bootstrap retry itself (for example after a shutdown closed its local
|
|
// runtime listener) and released the lease. That retry already proved
|
|
// no provider session, event or checkpoint; the checks below re-prove it.
|
|
&& (row.coordinator.phase === "observed" ? Boolean(row.coordinator.leaseOwner)
|
|
: row.coordinator.phase === "retryable_failure" && row.run.status === "failed"
|
|
&& row.coordinator.failureDetail?.recoveryMode === "bootstrap_retry" && !row.coordinator.leaseOwner)
|
|
&& !row.coordinator.failureDetail?.successorRunId
|
|
&& !row.run.processPid && !row.run.processGroupId && !row.run.processStartedAt
|
|
&& (!row.run.sessionIdBefore || continuedSessionOnly) && !row.run.sessionIdAfter
|
|
&& Boolean(row.run.nativeSessionId && row.run.runnerInstanceId)
|
|
&& (continuedSessionOnly || (!hasCheckpoint && !hasProviderEvidence))
|
|
&& !(await hasNativeExecutionStartEvidence(tx, row.run));
|
|
// A settled retry starts new work, so it needs the same current
|
|
// admission as the resumption reconciler: task pause, agent status,
|
|
// dependencies, ownership and competing runs. Keep it waiting otherwise.
|
|
// The executor relaunched a remote runner for a same-run bootstrap
|
|
// retry, and that runner exited (observed by its transport) before any
|
|
// provider work: the executor's settled failure durably records no
|
|
// provider session, no provider event and no checkpoint, and none exists
|
|
// now. The runner's own exit is the remote proof that it is gone; a
|
|
// recorded runner PID alone would not be (Daytona wave s9301).
|
|
const settledFailure = row.coordinator.failureDetail;
|
|
const remoteRunnerExitedBeforeProviderWork = !unclaimedBootstrap && !remoteRunnerNeverLaunched
|
|
&& remoteWorkspace?.state === "prepared" && !pluginTask && stagedAttachment === null
|
|
&& row.run.runnerProfileJson?.nativePreparationFencingVersion === NATIVE_PREPARATION_FENCING_VERSION
|
|
&& row.coordinator.phase === "retryable_failure" && row.run.status === "failed" && !row.coordinator.leaseOwner
|
|
&& settledFailure?.recoveryMode === "bootstrap_retry"
|
|
// The runner exited on its own, or it rejected session.open and the
|
|
// controller's close then proved it settled and exited. A failure
|
|
// saved without that proof (older releases) stays held.
|
|
&& (settledFailure?.originalFailureCode === "native_runner_process_exited"
|
|
|| (settledFailure?.originalFailureCode === "provider_session_open_rejected"
|
|
&& settledFailure?.runnerExitConfirmed === true))
|
|
&& settledFailure?.providerSessionEstablished === false && settledFailure?.providerEventsExist === false
|
|
&& settledFailure?.checkpointExists === false && !settledFailure?.successorRunId
|
|
&& !row.run.sessionIdBefore && !row.run.sessionIdAfter
|
|
&& Boolean(row.run.nativeSessionId && row.run.runnerInstanceId)
|
|
&& !hasCheckpoint && !hasProviderEvidence
|
|
// Fresh evidence wins over the saved failure: any event the runner or
|
|
// provider sent contradicts "no provider work".
|
|
&& !(await hasNativeRunnerOriginatedEvidence(tx, row.run));
|
|
const remoteFollowUpNeverAttached = !unclaimedBootstrap && !remoteRunnerNeverLaunched && !remoteRunnerExitedBeforeProviderWork
|
|
&& remoteWorkspace?.state === "prepared" && !pluginTask && !durableAuthority && stagedAttachment === null
|
|
&& row.run.runnerProfileJson?.nativePreparationFencingVersion === NATIVE_PREPARATION_FENCING_VERSION
|
|
&& row.coordinator.phase === "retryable_failure" && row.run.status === "failed" && !row.coordinator.leaseOwner
|
|
&& settledFailure?.recoveryMode === "bootstrap_retry"
|
|
&& settledFailure?.originalFailureCode === "local_runtime_api_listener_unavailable"
|
|
&& settledFailure?.providerSessionEstablished === false && settledFailure?.providerEventsExist === false
|
|
&& !settledFailure?.successorRunId
|
|
&& continuedSessionOnly && inheritedCheckpointOnly && !row.run.sessionIdAfter
|
|
&& Boolean(row.run.nativeSessionId && row.run.runnerInstanceId)
|
|
&& (inheritedRunnerIdentity || (!row.run.processPid && !row.run.processGroupId && !row.run.processStartedAt))
|
|
&& await carriesSettledPredecessorSession(tx, row.run, checkpointRecord.providerSessionId)
|
|
&& !(await hasNativeRunnerOriginatedEvidence(tx, row.run))
|
|
&& !(await hasNativeRunEvent(tx, row.run, PROCESS_IDENTITY_RECORDED));
|
|
if ((remoteRunnerNeverLaunched || remoteRunnerExitedBeforeProviderWork || remoteFollowUpNeverAttached)
|
|
&& row.coordinator.phase === "retryable_failure" && !admission.exhausted
|
|
&& !await readNativeSessionResumptionAgent(tx as unknown as Db, { issue, run: row.run, coordinator: row.coordinator })) {
|
|
const deferred = await deferRemoteRetry("native_retry_admission_ineligible", ["done", "cancelled"].includes(issue.status));
|
|
if (deferred) return deferred;
|
|
}
|
|
|
|
// A claimed remote bootstrap (runner launched, perhaps connected) has no
|
|
// provider evidence when its controller dies before session.open
|
|
// completes. The owner's lease has expired above, and the controller
|
|
// writes every command to Postgres before sending it, so that checkpoint
|
|
// is the complete record of what reached the sandbox runner. When it
|
|
// proves no provider work, bootstrap again instead of holding the task.
|
|
// A graceful drain can also suspend a run whose runner had just opened its
|
|
// provider session: session-open events may already be projected here,
|
|
// but no session snapshot or turn exists. Those events alone do not make
|
|
// the checkpoint proof ambiguous; any turn or provider event does.
|
|
// A follow-up on a continued session carries only its rebound admission
|
|
// checkpoint: the previous turn's provider session, with no cursor,
|
|
// turn, result or request of this run. When a settled earlier run of the
|
|
// same session saved that exact provider session, the carried session is
|
|
// not this run's provider work, so it does not make the controller
|
|
// checkpoint proof ambiguous (Daytona waves s9202, s9205).
|
|
const reboundSessionOnly = continuedSessionOnly && Boolean(durableAuthority) && remoteWorkspace !== null
|
|
&& hasCheckpointProviderIdentity && !unclaimedBootstrap && !pluginTask && stagedAttachment === null
|
|
&& await carriesSettledPredecessorSession(tx, row.run, checkpointRecord.providerSessionId);
|
|
const providerEvidenceIsSessionOpenOnly = (!hasCheckpointProviderIdentity || reboundSessionOnly)
|
|
&& providerEvents.every(event => SESSION_OPEN_PROVIDER_EVENT_TYPES.has(event.eventType));
|
|
const remoteBootstrapProof = !unclaimedBootstrap && remoteWorkspace !== null && !pluginTask
|
|
&& Boolean(durableAuthority) && stagedAttachment === null && (!hasCheckpoint || reboundSessionOnly)
|
|
&& providerEvidenceIsSessionOpenOnly
|
|
&& Boolean(row.run.runnerInstanceId && row.run.nativeSessionId)
|
|
? await nativeRunnerCheckpointRemoteBootstrapProof(tx, {
|
|
companyId: row.run.companyId, issueId: row.coordinator.issueId, runId: row.run.id,
|
|
}, { runnerInstanceId: row.run.runnerInstanceId!, normalizedSessionId: row.run.nativeSessionId! })
|
|
: null;
|
|
// Projected session-open events need the stronger session-opened proof.
|
|
const ownProviderEvidence = reboundSessionOnly ? providerEvents.length > 0 : hasProviderEvidence;
|
|
const remoteBootstrapProvenIncomplete = remoteBootstrapProof !== null
|
|
&& (!ownProviderEvidence || remoteBootstrapProof === "session_opened_without_turn");
|
|
|
|
const remoteRunnerNeverReceivedCommands = !unclaimedBootstrap && !remoteRunnerNeverLaunched
|
|
&& !remoteRunnerExitedBeforeProviderWork && !remoteBootstrapProvenIncomplete && !remoteFollowUpNeverAttached
|
|
&& remoteSandbox && remoteWorkspace !== null && !pluginTask && !durableAuthority && stagedAttachment === null
|
|
&& admissionCheckpointOnly && providerEvents.length === 0
|
|
&& row.run.runnerProfileJson?.nativePreparationFencingVersion === NATIVE_PREPARATION_FENCING_VERSION
|
|
&& row.coordinator.phase === "observed" && Boolean(row.coordinator.leaseOwner)
|
|
&& row.coordinator.attempt >= 1 && row.coordinator.controllerGeneration >= 1
|
|
&& Boolean(row.coordinator.controllerBootId && row.coordinator.controllerPid && row.coordinator.controllerProcessStartedAt)
|
|
&& !row.coordinator.failureDetail?.successorRunId
|
|
// A follow-up may carry its predecessor's settled runner identity and
|
|
// session; neither is this run's runner or provider work.
|
|
&& (inheritedRunnerIdentity || (!row.run.processPid && !row.run.processGroupId && !row.run.processStartedAt))
|
|
&& (!row.run.sessionIdBefore || continuedSessionOnly) && !row.run.sessionIdAfter
|
|
&& Boolean(row.run.nativeSessionId && row.run.runnerInstanceId)
|
|
&& !(await hasNativeRunnerOriginatedEvidence(tx, row.run))
|
|
&& !(await hasNativeRunEvent(tx, row.run, PROCESS_IDENTITY_RECORDED));
|
|
|
|
// Read before this claim appends its own history entry.
|
|
const cleanDrainSuspension = suspendedByCleanDrain(row.coordinator);
|
|
const restartCredit = cleanDrainSuspension
|
|
? { keepsAttempt: true, noProgressRestarts: previousNoProgressRestarts(row.coordinator.recoveryHistory) }
|
|
: await restartResumeKeepsAttempt(tx, { runId: row.run.id, coordinator: row.coordinator });
|
|
const resumeKeepsAttempt = restartCredit.keepsAttempt;
|
|
const classification = classifyNativeRunnerRecoveryEvidence({
|
|
remoteSandbox: remoteSandbox || Boolean(pluginTask && durableAuthority),
|
|
unclaimedBootstrap,
|
|
remoteBootstrapProvenIncomplete: remoteBootstrapProvenIncomplete || remoteRunnerNeverLaunched
|
|
|| remoteRunnerExitedBeforeProviderWork || remoteRunnerNeverReceivedCommands || remoteFollowUpNeverAttached,
|
|
remoteBootstrapIncompleteReason: remoteRunnerNeverLaunched ? "remote_runner_never_launched"
|
|
: remoteRunnerExitedBeforeProviderWork ? "remote_runner_exited_before_provider_work"
|
|
: remoteRunnerNeverReceivedCommands ? "remote_runner_never_received_commands"
|
|
: remoteFollowUpNeverAttached ? "remote_follow_up_never_attached"
|
|
: remoteBootstrapProof === "session_opened_without_turn" ? "remote_session_opened_without_turn" : undefined,
|
|
runnerPidAlive,
|
|
runnerGroupAlive,
|
|
processStartMatches: exactRunnerIdentity,
|
|
knownProviderProcessAlive: providerProcesses.livePids.length > 0,
|
|
knownProviderProcessIdentityAmbiguous:
|
|
providerProcesses.ambiguousLivePids.length > 0,
|
|
hasCheckpoint: hasCheckpoint && !remoteRunnerNeverReceivedCommands && !(unclaimedBootstrap && inheritedCheckpointOnly)
|
|
&& !remoteRunnerNeverLaunched && !remoteFollowUpNeverAttached && !(remoteBootstrapProvenIncomplete && reboundSessionOnly),
|
|
stagedAttachmentVerified: stagedAttachment !== null,
|
|
checkpointIdentityMatches:
|
|
checkpointIdentityMatches &&
|
|
(!remoteSandbox || remoteWorkspace !== null),
|
|
hasProviderEvidence: hasProviderEvidence && !(unclaimedBootstrap && inheritedCheckpointOnly),
|
|
checkpointFailed:
|
|
(checkpointRecord.terminal as Record<string, unknown> | undefined)
|
|
?.runTerminalState === "failed" &&
|
|
nativeRestartInterruptedTurnId(checkpointRecord as unknown as PersistedNativeSession) === null &&
|
|
!nativeRestartContinuationInFlight(checkpointRecord as unknown as PersistedNativeSession),
|
|
providerAttempt: row.coordinator.attempt,
|
|
resumeChargesAttempt: !resumeKeepsAttempt,
|
|
});
|
|
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);
|
|
if (needsResumptionAdmission && (!durableAuthority || ownershipChanged || classification.claimKind !== "reattach_remote_runner")) {
|
|
if (!admission.exhausted) {
|
|
const deferred = await deferRemoteRetry("native_retry_remote_authority_unverified");
|
|
if (deferred) return deferred;
|
|
}
|
|
}
|
|
|
|
const hold = admission.exhausted;
|
|
const preserveExecutionBinding = hold !== null;
|
|
const claimKind = hold || ownershipChanged || exhaustedRemoteRetry ? null : classification.claimKind;
|
|
const reason = hold?.reason ?? (ownershipChanged
|
|
? "task_ownership_or_status_changed"
|
|
: exhaustedRemoteRetry ? "execution_recovery_budget_exhausted" : classification.reason);
|
|
const nextAction = hold
|
|
? "Automatic admission checks exhausted. The original executor stop and state remain unverified. The provider PID, checkpoint and task bindings are preserved; reconcile that execution before starting fresh work."
|
|
: "Inspect the preserved checkpoint and reconcile the previous execution before starting a fresh session.";
|
|
|
|
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,
|
|
...(hold ? { systemRecovery: hold.policy } : {}),
|
|
reason,
|
|
nextAction,
|
|
},
|
|
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),
|
|
),
|
|
);
|
|
if (!preserveExecutionBinding) {
|
|
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 });
|
|
if (!preserveExecutionBinding) 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),
|
|
),
|
|
);
|
|
if (!preserveExecutionBinding) 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 (
|
|
preserveExecutionBinding || (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,
|
|
// A saved retryable failure is a same-run retry, not a restart
|
|
// hold. Read from the coordinator so a deferred or post-restart
|
|
// check of that retry records the same trigger.
|
|
...(row.coordinator.phase === "retryable_failure" ? { trigger: "same_run_retry" } : {}),
|
|
providerAttempt: row.coordinator.attempt,
|
|
...(hold ? { automaticAdmissionExhausted: true, executorStopEvidence: "unverified",
|
|
providerPid: row.run.processPid, checkpointPreserved: hasCheckpoint } : {}),
|
|
},
|
|
nextAction,
|
|
maxAttempts: 3,
|
|
wakePolicy: null,
|
|
supersedeOnIdentityChange: true,
|
|
});
|
|
if (updatedRun && !preserveExecutionBinding) await preserveNativeFailedWorkspace(tx as unknown as Db, updatedRun);
|
|
return { kind: "blocked", runId: row.run.id, reason } as const;
|
|
}
|
|
|
|
const providerAttempt = failedRemoteRetry
|
|
? nextNativeProviderAttempt(row.coordinator.attempt)
|
|
: row.coordinator.attempt;
|
|
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,
|
|
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,
|
|
});
|
|
// The next claim credits this resume only for work recorded after here.
|
|
const progressWatermarkSeq = await nativeRunEventWatermark(tx, row.run.id);
|
|
const attemptKeptForProgress = resumeKeepsAttempt && !cleanDrainSuspension;
|
|
// The attempt the executor runs this resume as (see its execution claim).
|
|
const progressAttempt = nextNativeProviderAttempt(providerAttempt, claimKind, !resumeKeepsAttempt);
|
|
await notifyLiveRunRecovery(tx, { companyId: row.run.companyId, runId: row.run.id, agentId: row.run.agentId });
|
|
currentControllerProcessStamp();
|
|
const claimed = await tx
|
|
.update(nativeRunFinalizations)
|
|
.set({
|
|
phase: "observed",
|
|
attempt: providerAttempt,
|
|
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,
|
|
controllerHostHash,
|
|
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, progressWatermarkSeq, progressAttempt, noProgressRestarts: restartCredit.noProgressRestarts,
|
|
...(attemptKeptForProgress ? { attemptKeptForProgress } : {}),
|
|
}),
|
|
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;
|
|
}
|
|
|
|
// A successful ordinary claim has now reconciled this exact earlier
|
|
// authority. Retire only its namespace attention; stale/other incidents
|
|
// remain owned and no operator confirmation is fabricated.
|
|
await issueRecoveryActionService(tx as unknown as Db).resolveActiveForIssue({
|
|
companyId: row.run.companyId, sourceIssueId: row.coordinator.issueId,
|
|
kind: "active_run_watchdog", cause: "native_restart_recovery_blocked",
|
|
fingerprint: `native-controller-space:${row.run.id}:${nativeControllerAuthorityDigest(row.coordinator, row.controllerLeaseExpiryEpoch)}`,
|
|
status: "resolved", outcome: "restored",
|
|
resolutionNote: "Normal restart recovery verified the previous authority and committed its exact replacement claim.",
|
|
}, tx);
|
|
|
|
if (
|
|
claimKind !== "reattach_existing_runner" &&
|
|
claimKind !== "reattach_remote_runner"
|
|
) {
|
|
await recordNativeLocalProcessStopInTransaction(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));
|
|
|
|
if (releasedRemoteRetry && resumptionAgent) {
|
|
await restoreNativeSessionExecution(tx as unknown as Db, { issue, run: row.run, agentName: resumptionAgent.name, now });
|
|
}
|
|
|
|
claimScope = { companyId: row.run.companyId, issueId: row.coordinator.issueId, controller, remote: Boolean(durableAuthority) };
|
|
const common = {
|
|
runId: row.run.id,
|
|
leaseOwner,
|
|
controllerGeneration: generation,
|
|
providerAttempt,
|
|
restartKind: input.restartKind,
|
|
recoveryRequestId: input.recoveryRequestId ?? null,
|
|
chargesProviderAttempt: !resumeKeepsAttempt,
|
|
};
|
|
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,
|
|
},
|
|
...(!pluginTask && row.coordinator.failureDetail?.runnerExitedBeforeSessionOpen === true
|
|
? { exitedBeforeSessionOpen: true as const } : {}),
|
|
} 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;
|
|
}).catch((error: unknown) => {
|
|
// The whole candidate transaction rolled back, so this run's state is
|
|
// unchanged and nothing was claimed. Keep the obligation on the existing
|
|
// recovery deadline and continue the batch: one run must not stop
|
|
// startup recovery.
|
|
terminalRunToReport = null;
|
|
claimScope = undefined;
|
|
const routineBusy = isUniqueViolation(error, "issues_open_routine_execution_uq");
|
|
if (!routineBusy) {
|
|
logger.error({ err: error, runId: candidate.runId }, "native restart recovery failed for one run; deferring it");
|
|
}
|
|
return { kind: "awaiting_evidence" as const, runId: candidate.runId,
|
|
reason: routineBusy ? "routine_execution_busy" : NATIVE_RESTART_RECOVERY_FAILED,
|
|
retryAt: new Date(now.getTime() + 30_000) };
|
|
});
|
|
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]),
|
|
);
|
|
}
|