mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
108 lines
7.6 KiB
TypeScript
108 lines
7.6 KiB
TypeScript
import { isComputerAdmissionWait } from "./execution-recovery-attempt.js";
|
|
import { adapterExecutionControls } from "./adapter-execution-control.js";
|
|
import { isPreDispatchReviewWait } from "./pre-dispatch-review-wait.js";
|
|
import { and, eq, inArray, isNotNull, or } from "drizzle-orm";
|
|
import { computers, environmentLeases, heartbeatRunEvents, heartbeatRuns, nativeRunFinalizations, type Db } from "@paperclipai/db";
|
|
import { claimedAdapterType } from "./conversation-continuation.js";
|
|
import { PROCESS_IDENTITY_RECORDED, PROCESS_START_REQUESTED } from "./native-local-process-stop.js";
|
|
import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
|
|
import { canContinueCancelledRun } from "./run-cancellation.js";
|
|
import { legacyControllerBootId } from "./legacy-controller-lease.js";
|
|
|
|
type Run = typeof heartbeatRuns.$inferSelect;
|
|
type Coordinator = typeof nativeRunFinalizations.$inferSelect;
|
|
|
|
/** Candidate for explicit user Retry only, never automatic replay after Stop. */
|
|
export async function canRetryStoppedRun(db: Db, run: Run): Promise<boolean> {
|
|
if (run.errorCode === "native_session_cleanup_quarantined") return false;
|
|
if (["failed", "timed_out"].includes(run.status) || canContinueCancelledRun(run)) return true;
|
|
if (run.status !== "cancelled" || !run.finishedAt || run.processPid || run.processGroupId ||
|
|
run.processStartedAt || run.sessionIdAfter) return false;
|
|
const [coordinator] = await db.select().from(nativeRunFinalizations).where(and(
|
|
eq(nativeRunFinalizations.companyId, run.companyId), eq(nativeRunFinalizations.runId, run.id),
|
|
));
|
|
return isCancelledNativeStartup(db, run, coordinator);
|
|
}
|
|
|
|
/** Caller holds the coordinator and run locks when using this proof to admit
|
|
* work. Attempt zero is a durable never-claimed receipt: every native executor
|
|
* commits its first claim before it can start or attach a provider. */
|
|
export async function isCancelledNativeStartup(db: Db, run: Run, coordinator: Coordinator | undefined) {
|
|
if (run.status !== "cancelled" || !run.finishedAt || run.processPid || run.processGroupId ||
|
|
run.processStartedAt || run.sessionIdAfter) return false;
|
|
const cancellation = run.resultJson?.startupCancellation as Record<string, unknown> | undefined;
|
|
// Older builds could omit the cancellation fence or its unwind marker. Their immutable
|
|
// native-adapter claim and unresolved preparation stage still prove that
|
|
// provider dispatch did not begin. Require an expired owner from another
|
|
// server boot; neither a missing PID nor mutable agent settings is proof.
|
|
const historicalBeforeSelection = run.runtimeMode === "legacy" && !run.runtimeModeResolvedAt &&
|
|
run.executionStage === "preparing" && !run.nativeIssueId && !run.nativeSessionId && !coordinator &&
|
|
claimedAdapterType(run) === "paperclip_runner" && run.errorCode === "operator_interrupted" &&
|
|
(run.resultJson === null || cancellation?.beforeNativeSelection === true) &&
|
|
Boolean(run.controllerBootId && run.controllerBootId !== legacyControllerBootId &&
|
|
run.controllerLeaseExpiresAt && run.controllerLeaseExpiresAt <= new Date());
|
|
const beforeReviewDispatch = isPreDispatchReviewWait(run) && !coordinator;
|
|
const beforeSelection = beforeReviewDispatch || historicalBeforeSelection || run.runtimeMode === "legacy" && !run.runtimeModeResolvedAt &&
|
|
!run.nativeSessionId && !coordinator && claimedAdapterType(run) === "paperclip_runner" &&
|
|
cancellation?.beforeNativeSelection === true;
|
|
const neverClaimed = run.runtimeMode === "native" && coordinator &&
|
|
["observed", "terminal_failure"].includes(coordinator.phase) && coordinator.attempt === 0 &&
|
|
coordinator.controllerGeneration === 0 && !coordinator.controllerBootId &&
|
|
!coordinator.controllerPid && !coordinator.leaseOwner && !coordinator.leaseExpiresAt &&
|
|
!coordinator.resultId && !coordinator.failureDetail?.successorRunId;
|
|
if (!beforeSelection && !neverClaimed) return false;
|
|
const settled = beforeReviewDispatch || typeof run.resultJson?.startupPreparationSettledAt === "string";
|
|
// The old preparer can still be unwinding even though the run is terminal.
|
|
if (!settled && run.controllerLeaseExpiresAt && run.controllerLeaseExpiresAt > new Date()) return false;
|
|
const leases = await db.select().from(environmentLeases).where(and(
|
|
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
|
|
));
|
|
if ((!settled && leases.length === 0 && !historicalBeforeSelection) || leases.some(lease =>
|
|
lease.provider === "local"
|
|
? !lease.releasedAt || lease.status === "pending_cleanup" || lease.cleanupStatus === "failed"
|
|
: !hasRemoteTerminationReceipt(lease))) return false;
|
|
// Reject contradictory retained evidence, including a crash after a launch
|
|
// request but before the PID callback. Provider events never certify a stop.
|
|
const [execution] = await db.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,
|
|
"harness.ready", "session.started", "session.resumed", "session.updated", "turn.started",
|
|
"provider.event", "provider.rpc_result", "tool.execution.started"])),
|
|
)).limit(1);
|
|
return !execution;
|
|
}
|
|
|
|
/** Resource waiting is replayable only before any provider admission, independently of operator Stop. */
|
|
export async function canRetryComputerAdmissionWait(db: Db, run: Run): Promise<boolean> {
|
|
if (!isComputerAdmissionWait(run) || !run.finishedAt || run.runtimeModeResolvedAt ||
|
|
run.nativeIssueId || run.nativeSessionId || run.processPid || run.processGroupId ||
|
|
run.processStartedAt || run.sessionIdAfter || adapterExecutionControls.has(run.id)) return false;
|
|
const settledAt = run.resultJson?.computerAdmissionPreparationSettledAt;
|
|
const settled = typeof settledAt === "string" && Number.isFinite(Date.parse(settledAt));
|
|
if (!settled && !(run.executionStage === "preparing" && run.controllerBootId &&
|
|
run.controllerBootId !== legacyControllerBootId && run.controllerLeaseExpiresAt &&
|
|
run.controllerLeaseExpiresAt <= new Date())) return false;
|
|
const admission = run.resultJson!.computerAdmission as Record<string, string>;
|
|
const [computer] = await db.select({ id: computers.id }).from(computers).where(and(
|
|
eq(computers.id, admission.computerId), eq(computers.companyId, run.companyId),
|
|
eq(computers.environmentId, admission.environmentId), eq(computers.provider, "boat"),
|
|
)).limit(1);
|
|
if (!computer) return false;
|
|
const [lease] = await db.select({ id: environmentLeases.id }).from(environmentLeases).where(and(
|
|
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
|
|
)).limit(1);
|
|
const [coordinator] = await db.select({ id: nativeRunFinalizations.runId }).from(nativeRunFinalizations).where(and(
|
|
eq(nativeRunFinalizations.companyId, run.companyId), eq(nativeRunFinalizations.runId, run.id),
|
|
)).limit(1);
|
|
if (lease || coordinator) return false;
|
|
const [execution] = await db.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,
|
|
"harness.ready", "session.started", "session.resumed", "session.updated", "turn.started",
|
|
"provider.event", "provider.rpc_result", "tool.execution.started"])),
|
|
)).limit(1);
|
|
return !execution;
|
|
}
|