Preserve Cursor recovery evidence before publication

Scope denied-turn proof reads before event limits, distinguish bootstrap failures from uncertain resource admission, and retain raw cleanup authority.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-03 16:27:21 -05:00
1 parent c48d0fba50
commit df1c6569f7
10 files changed
+72 -8

No files matched your search

@@ -272,3 +272,9 @@ operator and reassignment cancellation wins over the new typed decline error.
The frozen v11 runtime packs and image remain at `c45cf9`; these follow-up changes
touch the controller and harness only, and their distinct source identity is
recorded with subsequent results.
Review found three recovery gaps. Permission-decline lookup now scopes the query to the exact turn, session and runner before applying its event budget, so earlier turns cannot hide a committed denial. Cleanup retention reads raw results before packaging and records resource admission before any test provisioning; an unpublished result or worker crash can no longer discard uncertain allocation state. Confirmed pre-allocation bootstrap failures retain their original classification without an invented cleanup failure.
The second local denial repeat passed its behavioral and cleanup assertions but failed a browser assertion because the Blocked status label includes the current blocker count. The assertion now checks the actual Blocked status while allowing that displayed count. This failed attempt retains its original result; the affected repeat remains required.
Review-fix verification passed: 538 controller tests and 38 fixture tests, server and fixture typechecks, and production verification of the actual Linux pack. The first controller test invocation hit sandbox denial for its default checkpoint directory; the isolated-home repeat passed. The immutable c45cf9 qualification image is `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:1344d8168f15b8c1102ffbf60bf518f278b1804c9672135fb7f10642499e70ec`. All three actual platform packs now bind runtime source c45cf9.
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { PgDialect } from "drizzle-orm/pg-core";
import type { Db } from "@paperclipai/db";
import type { PrpEvent } from "../../vendor/paperclip-runner/index.js";
import { hasCompletedCursorPermissionDecline } from "./native-cursor-permission-decline.js";
import { hasCompletedCursorPermissionDecline, readCompletedCursorPermissionDecline } from "./native-cursor-permission-decline.js";
const binding = { companyId: "company", runId: "run", agentId: "agent" };
function row(seq: number, eventType: string, payload: unknown): any {
@@ -37,3 +39,29 @@ describe("completed Cursor permission decline", () => {
expect(hasCompletedCursorPermissionDecline(f.rows, binding, f.terminal)).toBe(false);
});
});
function readerDb(rows: unknown[]) {
const query = {
from: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(), limit: vi.fn(async () => rows),
};
return { db: { select: vi.fn(() => query) } as unknown as Db, query };
}
describe("permission decline reader event budget", () => {
it("scopes the database read to the terminal turn and runner before limiting events", async () => {
const f = facts(), { db, query } = readerDb(f.rows);
expect(await readCompletedCursorPermissionDecline(db, binding, f.terminal)).toBe(true);
const predicate = new PgDialect().sqlToQuery(query.where.mock.calls[0]![0]);
expect(predicate.sql).toContain("->'prpEvent'->>'turnId'");
expect(predicate.sql).toContain("->'prpEvent'->>'normalizedSessionId'");
expect(predicate.sql).toContain('"source_instance_id"');
expect(predicate.params.slice(0, 6)).toEqual(["company", "run", "agent", "turn", "session", "runner"]);
expect(query.limit).toHaveBeenCalledWith(1001);
expect(query.where.mock.invocationCallOrder[0]).toBeLessThan(query.limit.mock.invocationCallOrder[0]!);
});
it("fails closed if this same turn exceeds its control-event budget", async () => {
const f = facts(), { db } = readerDb(Array.from({ length: 1001 }, () => f.rows[0]));
await expect(readCompletedCursorPermissionDecline(db, binding, f.terminal)).rejects.toThrow("exceeds control-event budget");
});
});
@@ -1,4 +1,4 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import { and, asc, eq, inArray, sql } from "drizzle-orm";
import { heartbeatRunEvents, type Db } from "@paperclipai/db";
import type { PrpEvent } from "../../vendor/paperclip-runner/index.js";
@@ -48,6 +48,9 @@ export function hasCompletedCursorPermissionDecline(rows: readonly unknown[], bi
export async function readCompletedCursorPermissionDecline(db: Db, binding: Binding, terminal: PrpEvent) {
const rows = await db.select().from(heartbeatRunEvents).where(and(
eq(heartbeatRunEvents.companyId, binding.companyId), eq(heartbeatRunEvents.runId, binding.runId), eq(heartbeatRunEvents.agentId, binding.agentId),
sql`${heartbeatRunEvents.payload}->'prpEvent'->>'turnId' = ${terminal.turnId}`,
sql`${heartbeatRunEvents.payload}->'prpEvent'->>'normalizedSessionId' = ${terminal.normalizedSessionId}`,
eq(heartbeatRunEvents.sourceInstanceId, terminal.sourceInstanceId),
inArray(heartbeatRunEvents.eventType, TYPES),
)).orderBy(asc(heartbeatRunEvents.seq)).limit(1001);
if (rows.length > 1000) throw new Error("native_event_replay_conflict: permission recovery proof exceeds control-event budget");
+2
View File
@@ -1595,3 +1595,5 @@ staff remains a separate follow-up qualification.
The v3 hiring grader uses turn-accounting v2 in both executable guards. It requires complete per-run public event streams, exact native tool-use/result pairing and canonical execution IDs for completion actions. Only successful known GET issue/document/comment operations, verified reads/discovery, and attributed native chat finish are admitted. Writes, failed mutation attempts, incomplete streams and unknown actions cannot pass. Separate ACPX host request IDs and provider execution IDs are not joined by name/order/count; missing mapping is uncomparable action coverage, not a measured task failure. The original source-read and exact template checks remain unchanged.
The live fixture retries entire bracketed observations, waits for both known task callbacks and attributed replies (including batching), checks untruncated pending-wake diagnostics, and requires two equal settled observations. Silence before outbox enqueue is not delivery. Five-turn generic accounting remains calibrated for no owed notifications; this delegated fixture owes two completions. All actual runs remain counted for usage and cost. Retained original, limited sidecar-v1, initial executable, and stricter v3 assessments remain separately versioned; no models are rerun by the repair.
Recovery-state retention uses raw cleanup results before evidence publication. An owner-only resource-admission marker also preserves state if the test worker dies before producing a result. Confirmed bootstrap failures before allocation remove their temporary state and retain their original failure classification.
+2
View File
@@ -292,3 +292,5 @@ Cursor, Copilot, GitHub and GH environment variables are stripped from the
server environment. GitHub PAT shapes are included in retained-evidence scans.
Candidates have no automatic infrastructure retries; spending must be reconciled
before a deliberate repeat.
The private resource-admission marker and raw cleanup results control recovery-state retention independently of evidence packaging. A worker crash after admission keeps the owner-only recovery database; a confirmed pre-allocation bootstrap failure does not. Neither the marker nor the database enters published evidence.
+15 -1
View File
@@ -3,11 +3,25 @@ import { mustPreserveRecoveryState, runCleanupWithObservers, verifyCleanupAssert
it("retains uncertain remote allocation state even after every local process exits", () => {
expect(mustPreserveRecoveryState({ processCleanupFailed: false, results: [{ cleanup: "failed" }] })).toBe(true);
expect(mustPreserveRecoveryState({ processCleanupFailed: false, results: [{ cleanup: "not_started" }] })).toBe(true);
expect(mustPreserveRecoveryState({ processCleanupFailed: false, results: [{ cleanup: "not_started" }] })).toBe(false);
expect(mustPreserveRecoveryState({ processCleanupFailed: true, results: [{ cleanup: "passed" }] })).toBe(true);
expect(mustPreserveRecoveryState({ processCleanupFailed: false, results: [{ cleanup: "passed" }] })).toBe(false);
});
it("retains raw cleanup failures even when no evidence was published", () => {
expect(mustPreserveRecoveryState({ processCleanupFailed: false, resourceAdmissionStarted: true, results: [{ cleanup: "failed" }] })).toBe(true);
});
it("distinguishes pre-admission bootstrap failures from uncertain worker failures", () => {
const synthetic = { cleanup: "not_started", synthetic: true };
expect(mustPreserveRecoveryState({ processCleanupFailed: false, results: [synthetic] })).toBe(false);
expect(mustPreserveRecoveryState({ processCleanupFailed: false, resourceAdmissionStarted: true, results: [synthetic] })).toBe(true);
expect(mustPreserveRecoveryState({ processCleanupFailed: false, resourceAdmissionStarted: true, results: [] })).toBe(true);
expect(mustPreserveRecoveryState({ processCleanupFailed: false, results: [] })).toBe(false);
expect(mustPreserveRecoveryState({ processCleanupFailed: false, resourceAdmissionStarted: true, results: [{ cleanup: "not_started" }] })).toBe(false);
expect(mustPreserveRecoveryState({ processCleanupFailed: false, resourceAdmissionStarted: true, results: [{ cleanup: "passed" }] })).toBe(false);
});
it("retains a failed cleanup proof and still closes every later observer", async () => {
let closed = 0;
const result = await verifyCleanupAssertions([
+4 -2
View File
@@ -5,9 +5,11 @@ export type CleanupAssertion = () => Promise<CleanupCheck[]>;
* its private recovery database whenever a cell lacks confirmed cleanup. */
export function mustPreserveRecoveryState(input: {
processCleanupFailed: boolean;
results: readonly { cleanup: string }[];
resourceAdmissionStarted?: boolean;
results: readonly { cleanup: string; synthetic?: boolean }[];
}) {
return input.processCleanupFailed || input.results.some(result => result.cleanup !== "passed");
return input.processCleanupFailed || input.results.some(result => result.cleanup === "failed")
|| (input.resourceAdmissionStarted === true && (input.results.length === 0 || input.results.some(result => result.synthetic)));
}
/** Always finish every registered observer, even when an earlier proof fails. */
+1 -1
View File
@@ -357,7 +357,7 @@ export async function runCursorNativeFlow(input: {
await sampleDenied("after-terminal", remoteFinal ?? undefined);
check("negative-task-unfinished", hasCursorDeniedTurnTerminal({ run: terminal.runs[0], issue: terminal.issue, events: terminal.runEvents, ...identity }), "Denied native turn supplied no semantic completion; its failed run does not falsely complete the task");
await page.reload();
await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: `Change status (current: ${terminal.issue.status === "blocked" ? "Blocked" : "In Progress"})`, exact: true })).toBeVisible();
await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: terminal.issue.status === "blocked" ? /^Change status \(current: Blocked(?: · .+)?\)$/u : "Change status (current: In Progress)" })).toBeVisible();
const comments = await api.get<Row[]>(`/api/issues/${issue.id}/comments`);
await input.evidence("api-state.json", { ...terminal, run: runs[0], comments, checks, notices: denialNotices, commandSha256: deniedCommand!.commandSha256, denialTerminalProven, runEventsByRun: [{ runId: native.runId, events: terminal.runEvents }] });
await input.capture("final-state", "Cursor denied command ended without completing the task", "final-state.png");
+6 -1
View File
@@ -485,6 +485,7 @@ async function runAttempt(input: {
const publishedResultPaths = new Map<string, string>();
let attemptSecrets: string[] = [];
let processCleanupFailed = false;
const rawCleanupResults: Array<{ cleanup: string; synthetic: boolean }> = [];
try {
const paperclipHome = path.join(temporaryRoot, "paperclip-home");
const workspace = path.join(temporaryRoot, "workspace");
@@ -628,6 +629,8 @@ async function runAttempt(input: {
processFailureClass,
);
const result = await readResult(resultPath, fallback);
// Keep cleanup authority before evidence copying/publication can fail.
rawCleanupResults.push({ cleanup: result.cleanup, synthetic: result === fallback });
return enforceResultProcessIntegrity(result, processResult);
}),
);
@@ -800,7 +803,9 @@ async function runAttempt(input: {
} finally {
if (!processCleanupFailed) reapNewDetachedDarwinSharedMemory(sharedMemoryBaseline);
let cleanupError: unknown;
if (mustPreserveRecoveryState({ processCleanupFailed, results: publishedResults })) {
const resourceAdmissionStarted = await access(path.join(temporaryRoot, "artifacts-private", "resource-admission-started"))
.then(() => true).catch((error: NodeJS.ErrnoException) => error.code !== "ENOENT");
if (mustPreserveRecoveryState({ processCleanupFailed, resourceAdmissionStarted, results: rawCleanupResults })) {
// Remote allocation cleanup is journaled in this instance's database.
// Deleting it after the controller exits would strand uncertain creates.
await chmod(temporaryRoot, 0o700);
+2
View File
@@ -554,6 +554,8 @@ for (const execution of executions) {
? deadlineMs
: deadlineMs + 90_000,
);
// Durable admission evidence survives a worker crash before result publication.
await writeFile(path.join(privateRoot, "resource-admission-started"), "started\n", { mode: 0o600 });
const startedAtMs = Date.now();
const startedAt = new Date(startedAtMs).toISOString();
const nonce = `${randomBytes(6).toString("hex")}-${attempt}`;