mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
Retain skipped-lock settlement and failed native cancellation work
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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`)
|
||||
|
||||
@@ -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`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user