Retain skipped-lock settlement and failed native cancellation work

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-30 22:06:37 -05:00
1 parent f2b65b665f
commit 4888457d1f
9 files changed
+167 -19

No files matched your search

@@ -6,23 +6,29 @@ import {
} from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { createHeartbeatAgentStatus, settledAgentWorkCondition } from "./agent-status.js";
const support = await getEmbeddedPostgresTestSupport();
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_AGENT_STATUS_TEST_DATABASE_URL");
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
const describePostgres = support.supported ? describe : describe.skip;
if (!support.supported) console.warn(`Skipping agent status database tests: ${support.reason}`);
describePostgres("heartbeat agent status", () => {
let db: Db;
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let service: ReturnType<typeof createHeartbeatAgentStatus>;
const live = new Set<string>();
const old = () => new Date(Date.now() - 60 * 60_000);
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-agent-status-");
db = createDb(database.connectionString);
if (!external) database = await startEmbeddedPostgresTestDatabase("paperclip-agent-status-");
db = createDb(external ?? database!.connectionString);
if (external) {
db.$client.options.onnotice = () => {};
await db.execute(sql`truncate companies cascade`);
}
service = createHeartbeatAgentStatus(db);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
beforeEach(async () => {
@@ -38,7 +44,7 @@ describePostgres("heartbeat agent status", () => {
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => { await database?.cleanup(); });
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await database?.cleanup(); });
async function seed(status = "interrupted") {
const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID(), issueId = randomUUID();
@@ -134,7 +134,13 @@ export function createHeartbeatAgentStatus(db: Db) {
const [locked] = await tx.select({ id: agents.id }).from(agents).where(and(
eq(agents.id, candidate.id), eq(agents.companyId, candidate.companyId),
)).for("update", { skipLocked: true });
if (!locked) return null;
if (!locked) {
const [pending] = await tx.select({ id: agents.id }).from(agents).where(and(
eq(agents.id, candidate.id), eq(agents.companyId, candidate.companyId),
));
if (pending) scope.onDeferredDeadline?.(Date.now() + 30_000);
return null;
}
const [current] = await tx.select().from(agents).where(and(
eq(agents.id, candidate.id), eq(agents.companyId, candidate.companyId),
eq(agents.status, "running"), eq(agents.lifecycleState, "ready"),
@@ -1,3 +1,4 @@
import { pendingFailedNativeRetryCancellationCondition } from "../native-runtime/native-cancellation-request.js";
import { settledNativeProcessIdentityCondition } from "../native-local-process-stop.js";
import { and, asc, eq, gt, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
import { agents, environmentLeases, heartbeatRuns, nativeRunFinalizations, type Db } from "@paperclipai/db";
@@ -69,7 +70,8 @@ export function registerLiveRecoveryWork(db: Db, coordinator: ReturnType<typeof
kind === "native" ? and(eq(heartbeatRuns.runtimeMode, "native"), inArray(heartbeatRuns.status, ["running", "failed"]),
eq(nativeRunFinalizations.phase, "retryable_failure"), isNull(nativeRunFinalizations.resultId),
sql`${heartbeatRuns.contextSnapshot}->'nativeQuestionCancellation' is null`)
: kind === "question" ? and(inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry"]),
: kind === "question" ? and(or(inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry"]),
pendingFailedNativeRetryCancellationCondition()),
sql`${heartbeatRuns.contextSnapshot}->'nativeQuestionCancellation' is not null`)
: kind === "active" ? and(eq(heartbeatRuns.status, "running"),
sql`${heartbeatRuns.contextSnapshot}->'nativeQuestionCancellation' is null`)
+3 -2
View File
@@ -1,3 +1,4 @@
import { pendingFailedNativeRetryCancellationCondition } from "../native-runtime/native-cancellation-request.js";
import { registerLiveRecoveryWork } from "./live-recovery-work.js";
import { nativeEnvironmentSettlementDisposition, nativeEnvironmentSettlementWork } from "../native-runtime/native-environment-settlement-work.js";
import { nativeWorkspaceFinalizationWork } from "../native-runtime/native-workspace-finalization-work.js";
@@ -2127,9 +2128,9 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
and(
scope.companyId ? eq(heartbeatRuns.companyId, scope.companyId) : undefined,
scope.runId ? eq(heartbeatRuns.id, scope.runId) : undefined,
inArray(heartbeatRuns.status, [
or(inArray(heartbeatRuns.status, [
...CANCELLABLE_HEARTBEAT_RUN_STATUSES,
]),
]), pendingFailedNativeRetryCancellationCondition()),
sql`${heartbeatRuns.contextSnapshot} -> ${NATIVE_QUESTION_CANCELLATION_CONTEXT_KEY} is not null`,
),
);
+5 -4
View File
@@ -1242,18 +1242,19 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
// under lock, and the final CAS requires its own unchanged acknowledged
// retry fence. Re-reading only retryable_failure here would strand replay.
const pendingNativeRetry =
run.runtimeMode === "native" && run.status === "failed" && (
run.runtimeMode === "native" && run.status === "failed" && Boolean(run.nativeIssueId) && (
caseHandoffRetryClaimed || Boolean(options.cancellationRequestId) || await db
.select({ runId: nativeRunFinalizations.runId })
.select({ phase: nativeRunFinalizations.phase, failureCode: nativeRunFinalizations.failureCode })
.from(nativeRunFinalizations)
.where(
and(
eq(nativeRunFinalizations.runId, run.id),
eq(nativeRunFinalizations.companyId, run.companyId),
eq(nativeRunFinalizations.phase, "retryable_failure"),
eq(nativeRunFinalizations.issueId, run.nativeIssueId!),
),
)
.then((rows) => rows.length > 0)
.then(([coordinator]) => nativeRetryCancellationEligible({ runId: run!.id, companyId: run!.companyId,
issueId: run!.nativeIssueId!, resultJson: run!.resultJson }, coordinator))
);
if (
!pendingNativeRetry &&
@@ -41,6 +41,32 @@ export function nativeRetryCancellationEligible(input: {
&& !!native.acknowledgementAuditId && native.acknowledgementAuditId !== native.intentAuditId));
}
export function pendingFailedNativeRetryCancellationCondition() {
const native = sql`${heartbeatRuns.resultJson}->'nativeCancellation'`;
const continuingStop = sql`${nativeRunFinalizations.phase} = 'terminal_failure'
and ${nativeRunFinalizations.failureCode} = 'native_retry_cancelled'
and ${native}->>'schema' = 'paperclip.native-cancellation.v1'
and jsonb_typeof(${native}->'intentId') = 'string' and length(${native}->>'intentId') > 0
and ${native}->>'runId' = ${heartbeatRuns.id}::text
and ${native}->>'companyId' = ${heartbeatRuns.companyId}::text
and ${native}->>'issueId' = ${heartbeatRuns.nativeIssueId}::text
and ${native}->>'scope' = 'run'
and ${native}->>'dispatchState' in ('pending', 'acknowledged')
and jsonb_typeof(${native}->'dispatched') = 'boolean'
and jsonb_typeof(${native}->'intentAuditId') = 'string' and length(${native}->>'intentAuditId') > 0
and (${native}->>'dispatchState' = 'pending' or (
jsonb_typeof(${native}->'acknowledgementAuditId') = 'string'
and length(${native}->>'acknowledgementAuditId') > 0
and ${native}->>'acknowledgementAuditId' <> ${native}->>'intentAuditId'))`;
return sql`${heartbeatRuns.runtimeMode} = 'native' and ${heartbeatRuns.status} = 'failed'
and exists (select 1 from ${nativeRunFinalizations}
where ${nativeRunFinalizations.runId} = ${heartbeatRuns.id}
and ${nativeRunFinalizations.companyId} = ${heartbeatRuns.companyId}
and ${nativeRunFinalizations.issueId} = ${heartbeatRuns.nativeIssueId}
and (${nativeRunFinalizations.phase} = 'retryable_failure' or (${continuingStop}))
and ${nativeRunFinalizations.resultId} is null)`;
}
/** Final failed -> cancelled CAS: a later board/recovery outcome must win.
* The row-locking subquery prevents a coordinator-only writer from changing
* the retry disposition between this check and the status update. */