mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
fix(cursor): fence denied-turn recovery and attest deferred read input
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2783390f88
commit
c45cf9f418
11 files changed
+210
-12
No files matched your search
@@ -235,3 +235,28 @@ or remote cleanup is unconfirmed. This repairs the demonstrated loss of the
|
||||
failed-create journal after the controller exited. It changes the harness only;
|
||||
the provider packs, daemon, and Linux image remain frozen at `5623ff`. Remote
|
||||
qualification and promotion are still pending.
|
||||
|
||||
### Demonstrated remote blockers after allocation recovered
|
||||
|
||||
The third remote denial attempt reached the native request and delivered the
|
||||
exact rejection. The target stayed absent and the remote process observer proved
|
||||
retirement. Its overall result remains failed: Cursor announced an empty read
|
||||
card before streaming the bootstrap file path, and the strict single-file proof
|
||||
could not attest that origin. The controller then applied generic missing-result
|
||||
recovery after the denied turn ended, making two failed session-resume attempts.
|
||||
|
||||
The blocker repair completes only an entirely empty pending read origin from a
|
||||
full single-path shape update before execution progress. Every attested notice
|
||||
retains that same digest; late, changed, multi-path, and unsafe input stays
|
||||
unproven. This changes passive evidence, not provider permissions or file access.
|
||||
Committed Cursor permission declines followed by a completed turn without a
|
||||
semantic result now fail the run once, keep the task open, and assign recovery to
|
||||
the operator with no automatic wake. Other missing-result recovery remains intact.
|
||||
The decision proof binds company, agent, run, turn, normalized session, source,
|
||||
request, and strict event order. It does not assert response delivery or absence
|
||||
of effects; those remain separate live requirements.
|
||||
|
||||
Focused verification passed: 534 controller tests, 43 Cursor runtime tests, and
|
||||
43 fixture evidence tests, plus affected typechecks. The provider CLI pin,
|
||||
native patch, and command/profile digest remain unchanged; the candidate source
|
||||
and Node pack identities must be rebuilt before repeating affected qualification.
|
||||
@@ -11,7 +11,7 @@ const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 &&
|
||||
const nativeToolId = (v: unknown): string | undefined => typeof v === "string" && v.length > 0 && v.length <= 240 ? cursorToolIdentity(v) : undefined;
|
||||
const digest = (s: string) => createHash("sha256").update(s).digest("hex");
|
||||
type Fields = Record<string, string | boolean>;
|
||||
type Tool = { kind?: string; commandSha256?: string; read?: SingleReadEvidence };
|
||||
type Tool = { kind?: string; commandSha256?: string; read?: SingleReadEvidence; pendingReadNotice?: boolean };
|
||||
type Permission = { requestId: string; kind?: string; hasInput: boolean; commandSha256?: string; declineOffered: boolean; requested?: boolean; outcome?: string; delivered?: boolean };
|
||||
type ProjectionFailureReason =
|
||||
| "evidence_limit" | "tool_limit" | "missing_command_origin" | "reused_tool_origin"
|
||||
@@ -90,7 +90,15 @@ export function createCursorToolEvidence(binding: {
|
||||
if (state.kind && state.kind !== call.kind) throw new ProjectionFailure("changed_tool_kind");
|
||||
state.kind = call.kind;
|
||||
}
|
||||
if (state.kind === "read") state.read = updateSingleReadEvidence(state.read, call, binding.workingDirectory);
|
||||
if (state.kind === "read") {
|
||||
state.read = updateSingleReadEvidence(state.read, call, binding.workingDirectory);
|
||||
if (state.read.pendingOriginInput && call.tag === "tool_call") state.pendingReadNotice = true;
|
||||
if (state.pendingReadNotice && !state.read.pendingOriginInput) {
|
||||
notice("tool", toolId, { status: "pending", operation: "read", ...(state.read.targetSha256 ? { readTargetSha256: state.read.targetSha256 } : {}) });
|
||||
state.pendingReadNotice = false;
|
||||
}
|
||||
if (state.pendingReadNotice) return;
|
||||
}
|
||||
if (call.rawInput !== undefined && state.kind === "execute") {
|
||||
const hash = command(call);
|
||||
if (!hash || (call.tag !== "tool_call" && !state.commandSha256) || (state.commandSha256 && state.commandSha256 !== hash)) throw new ProjectionFailure("changed_or_missing_command");
|
||||
|
||||
@@ -30,6 +30,19 @@ it("does not establish proof from a late update or location/title without scalar
|
||||
const state = updateSingleReadEvidence(undefined, { ...origin, rawInput: undefined, title: `Read ${path}` }, "/workspace");
|
||||
expect(state).toEqual({}); expect(updateSingleReadEvidence(state, origin, "/workspace")).toEqual({});
|
||||
});
|
||||
it("completes an empty pending origin only from its full shape before execution progress", () => {
|
||||
const pending = updateSingleReadEvidence(undefined, { ...origin, rawInput: {}, locations: undefined }, "/workspace");
|
||||
expect(pending).toEqual({ pendingOriginInput: true });
|
||||
expect(updateSingleReadEvidence(pending, { tag: "tool_call_update", rawInput: { path }, locations: [{ path }] }, "/workspace")).toEqual({ targetSha256: hash });
|
||||
for (const status of ["in_progress", "completed", "failed"]) {
|
||||
const unproven = updateSingleReadEvidence(pending, { tag: "tool_call_update", status }, "/workspace");
|
||||
expect(updateSingleReadEvidence(unproven, { tag: "tool_call_update", rawInput: { path } }, "/workspace")).toEqual({});
|
||||
}
|
||||
for (const rawInput of [{ paths: [path, "private.txt"] }, { path, fileName: path }, { path: "../private.txt" }]) {
|
||||
const unproven = updateSingleReadEvidence(pending, { tag: "tool_call_update", rawInput }, "/workspace");
|
||||
expect(updateSingleReadEvidence(unproven, { tag: "tool_call_update", rawInput: { path } }, "/workspace")).toEqual({});
|
||||
}
|
||||
});
|
||||
for (const [provider, create] of [["cursor", createCursorToolEvidence]] as const) {
|
||||
describe(`${provider} passive single-read origin`, () => {
|
||||
function setup() { const rows: any[] = []; const projector = create({ sessionId: "session", turnId: "turn", workingDirectory: "/workspace", active: () => true,
|
||||
@@ -51,5 +64,14 @@ for (const [provider, create] of [["cursor", createCursorToolEvidence]] as const
|
||||
s.projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "other", kind: "read", status: "completed", rawInput: { path } });
|
||||
expect(s.rows).toHaveLength(1); expect(s.rows[0].readTargetSha256).toBeUndefined();
|
||||
});
|
||||
it("publishes the pending origin after its delayed complete input, retaining exact proof on every notice", () => {
|
||||
const s = setup();
|
||||
s.projector.tool({ ...origin, rawInput: {}, locations: undefined });
|
||||
expect(s.rows).toEqual([]);
|
||||
s.projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "read", rawInput: { path }, locations: [{ path }] });
|
||||
s.projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "read", status: "in_progress" });
|
||||
s.projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "read", status: "completed" });
|
||||
expect(s.rows.map(row => [row.status, row.readTargetSha256])).toEqual([["pending", hash], ["in_progress", hash], ["completed", hash]]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { safeAcpxLocations } from "./safe-locations.js";
|
||||
|
||||
export interface SingleReadEvidence { targetSha256?: string }
|
||||
export interface SingleReadEvidence { targetSha256?: string; pendingOriginInput?: boolean }
|
||||
const record = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||
const digest = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
const scalarOptions = new Set(["offset", "limit", "startLine", "endLine"]);
|
||||
@@ -26,13 +26,29 @@ function singleInputPath(value: unknown, cwd: string): string | undefined {
|
||||
|
||||
/** Passive attestation of the pinned single-file read input, never permission or
|
||||
* filesystem authority. Multi-path tools and shell-output reads remain visible
|
||||
* but cannot acquire this proof. Only the actual origin may establish a digest;
|
||||
* but cannot acquire this proof. Only the actual origin's complete input establishes a digest;
|
||||
* absent update fields retain it, while changed or ambiguous fields invalidate
|
||||
* evidence without changing the provider's response or execution. */
|
||||
* evidence without changing the provider's response or execution. An empty
|
||||
* pending origin may acquire its complete input before execution progress. */
|
||||
export function updateSingleReadEvidence(state: SingleReadEvidence | undefined, call: Record<string, unknown>, cwd: string): SingleReadEvidence {
|
||||
if (!state) {
|
||||
const path = call.tag === "tool_call" ? singleInputPath(call.rawInput, cwd) : undefined;
|
||||
return path && locationsAgree(call.locations, cwd, path) ? { targetSha256: digest(path) } : {};
|
||||
if (path && locationsAgree(call.locations, cwd, path)) return { targetSha256: digest(path) };
|
||||
// Cursor announces an empty pending card before streaming its arguments.
|
||||
// Only this wholly absent input can be completed by a shape update before
|
||||
// execution progress; ambiguous or already executing reads stay unproven.
|
||||
const input = record(call.rawInput);
|
||||
const absent = call.rawInput === undefined || (input !== undefined && Object.keys(input).every(key => key === "path" && input[key] === undefined));
|
||||
return call.tag === "tool_call" && call.status === "pending" && absent
|
||||
&& (call.locations === undefined || (Array.isArray(call.locations) && call.locations.length === 0))
|
||||
? { pendingOriginInput: true } : {};
|
||||
}
|
||||
if (state.pendingOriginInput) {
|
||||
const path = call.tag === "tool_call_update" && (call.status === undefined || call.status === "pending")
|
||||
? singleInputPath(call.rawInput, cwd) : undefined;
|
||||
if (path && locationsAgree(call.locations, cwd, path)) return { targetSha256: digest(path) };
|
||||
if (call.rawInput !== undefined || call.locations !== undefined || (call.status !== undefined && call.status !== "pending")) return {};
|
||||
return state;
|
||||
}
|
||||
if (!state.targetSha256) return state; // A later delta cannot create an origin.
|
||||
if (call.rawInput !== undefined) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PrpEvent } from "../../vendor/paperclip-runner/index.js";
|
||||
import { hasCompletedCursorPermissionDecline } from "./native-cursor-permission-decline.js";
|
||||
|
||||
const binding = { companyId: "company", runId: "run", agentId: "agent" };
|
||||
function row(seq: number, eventType: string, payload: unknown): any {
|
||||
const event = { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", runId: "run", turnId: "turn",
|
||||
normalizedSessionId: "session", sourceInstanceId: "runner", sourceEventId: `runner:${seq}`, sourceSeq: seq, eventType, payload };
|
||||
return { ...binding, seq, eventType, protocolSchemaVersion: 1, sourceInstanceId: "runner", sourceEventId: event.sourceEventId, sourceSeq: seq, payload: { prpEvent: event } };
|
||||
}
|
||||
function facts() {
|
||||
const created = row(1, "runtime_request.created", { request: { requestId: "request", type: "permission", status: "pending", turnId: "turn",
|
||||
origin: { adapter: "acpx-runtime-sidecar", provider: "cursor", method: "session/request_permission" } } });
|
||||
const resolved = row(2, "runtime_request.resolved", { requestId: "request", action: "decline", turnId: "turn", requestKind: "permission_approval" });
|
||||
const completed = row(3, "turn.completed", { status: "completed", error: null });
|
||||
return { rows: [created, resolved, completed], terminal: structuredClone(completed.payload.prpEvent) as PrpEvent };
|
||||
}
|
||||
describe("completed Cursor permission decline", () => {
|
||||
it("fences a committed denial without treating it as semantic success or requiring provider billing", () => {
|
||||
const f = facts(); expect(hasCompletedCursorPermissionDecline(f.rows, binding, f.terminal)).toBe(true);
|
||||
});
|
||||
it.each(["company", "agent", "run", "turn", "session", "source", "provider", "method", "accept", "expired", "duplicate", "order", "terminal"])("rejects %s mismatches and ambiguous proof", mutation => {
|
||||
const f = facts(), created = f.rows[0], resolved = f.rows[1];
|
||||
if (mutation === "company") created.companyId = "foreign";
|
||||
if (mutation === "agent") created.agentId = "foreign";
|
||||
if (mutation === "run") created.runId = "foreign";
|
||||
if (mutation === "turn") created.payload.prpEvent.turnId = "foreign";
|
||||
if (mutation === "session") created.payload.prpEvent.normalizedSessionId = "foreign";
|
||||
if (mutation === "source") resolved.sourceSeq = 99;
|
||||
if (mutation === "provider") created.payload.prpEvent.payload.request.origin.provider = "claude";
|
||||
if (mutation === "method") created.payload.prpEvent.payload.request.origin.method = "cursor/create_plan";
|
||||
if (mutation === "accept") resolved.payload.prpEvent.payload.action = "accept";
|
||||
if (mutation === "expired") { resolved.eventType = "runtime_request.expired"; resolved.payload.prpEvent.eventType = resolved.eventType; }
|
||||
if (mutation === "duplicate") f.rows.push(structuredClone(resolved));
|
||||
if (mutation === "order") resolved.seq = 4;
|
||||
if (mutation === "terminal") f.terminal.sourceEventId = "foreign";
|
||||
expect(hasCompletedCursorPermissionDecline(f.rows, binding, f.terminal)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { heartbeatRunEvents, type Db } from "@paperclipai/db";
|
||||
import type { PrpEvent } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
const record = (value: unknown): Record<string, any> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {};
|
||||
type Binding = { companyId: string; runId: string; agentId: string };
|
||||
const TYPES = ["runtime_request.created", "runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired", "turn.completed"];
|
||||
|
||||
export class NativeCursorPermissionDeclinedError extends Error {
|
||||
constructor() {
|
||||
super("native_finalization_missing: session returned no semantic result; Cursor permission was declined; explicit direction is required");
|
||||
this.name = "NativeCursorPermissionDeclinedError";
|
||||
}
|
||||
}
|
||||
|
||||
/** A committed user denial fences automatic disposition recovery. It does not
|
||||
* claim the provider received the response or prove absence of file effects. */
|
||||
export function hasCompletedCursorPermissionDecline(rows: readonly unknown[], binding: Binding, terminal: PrpEvent): boolean {
|
||||
if (terminal.eventType !== "turn.completed" || !terminal.turnId || !terminal.normalizedSessionId || !terminal.sourceInstanceId) return false;
|
||||
const selected = rows.map(record).map(row => ({ row, event: record(record(row.payload).prpEvent) })).filter(({ row, event }) =>
|
||||
row.companyId === binding.companyId && row.agentId === binding.agentId && row.runId === binding.runId
|
||||
&& row.protocolSchemaVersion === 1 && event.schema === "paperclip.prp.event.v1" && event.schemaVersion === 1
|
||||
&& event.sourceKind === "runner" && event.runId === binding.runId && event.turnId === terminal.turnId
|
||||
&& event.normalizedSessionId === terminal.normalizedSessionId && event.sourceInstanceId === terminal.sourceInstanceId
|
||||
&& event.sourceEventId === row.sourceEventId && event.sourceInstanceId === row.sourceInstanceId && event.sourceSeq === row.sourceSeq
|
||||
&& row.eventType === event.eventType && Number.isSafeInteger(row.seq) && Number.isSafeInteger(event.sourceSeq));
|
||||
const terminals = selected.filter(({ event }) => event.eventType === "turn.completed" && event.sourceEventId === terminal.sourceEventId);
|
||||
if (terminals.length !== 1) return false;
|
||||
const end = terminals[0]!;
|
||||
return selected.some(created => {
|
||||
if (created.event.eventType !== "runtime_request.created") return false;
|
||||
const request = record(record(created.event.payload).request), origin = record(request.origin);
|
||||
if (request.type !== "permission" || request.status !== "pending" || !request.requestId || request.turnId !== terminal.turnId
|
||||
|| origin.provider !== "cursor" || origin.method !== "session/request_permission"
|
||||
|| !["acpx-runtime", "acpx-runtime-sidecar"].includes(origin.adapter)) return false;
|
||||
if (selected.filter(({ event }) => event.eventType === "runtime_request.created" && record(record(event.payload).request).requestId === request.requestId).length !== 1) return false;
|
||||
const outcomes = selected.filter(({ event }) => ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(event.eventType)
|
||||
&& record(event.payload).requestId === request.requestId);
|
||||
if (outcomes.length !== 1) return false;
|
||||
const resolved = outcomes[0]!, payload = record(resolved.event.payload);
|
||||
return resolved.event.eventType === "runtime_request.resolved" && payload.action === "decline"
|
||||
&& payload.turnId === terminal.turnId && payload.requestKind === "permission_approval"
|
||||
&& created.row.seq < resolved.row.seq && resolved.row.seq < end.row.seq
|
||||
&& created.event.sourceSeq < resolved.event.sourceSeq && resolved.event.sourceSeq < end.event.sourceSeq;
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
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");
|
||||
return hasCompletedCursorPermissionDecline(rows, binding, terminal);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
NativeSessionProtocolIntegrityError,
|
||||
} from "../../vendor/paperclip-runner/index.js";
|
||||
import * as issueServiceModule from "../issues.js";
|
||||
import { NativeCursorPermissionDeclinedError } from "./native-cursor-permission-decline.js";
|
||||
import {
|
||||
createNativeHarnessBackupStamp,
|
||||
verifyNativeHarnessBackupStamp,
|
||||
@@ -7374,6 +7375,20 @@ describe("native warm session supervision", () => {
|
||||
});
|
||||
|
||||
describe("native session bounded recovery", () => {
|
||||
it("leaves a denied Cursor task open and gives recovery to the operator without another provider attempt", async () => {
|
||||
const updates: Array<{ table: unknown; values: Record<string, unknown> }> = [];
|
||||
const failure = new NativeCursorPermissionDeclinedError();
|
||||
state.execute.mockReset().mockRejectedValueOnce(failure);
|
||||
state.upsertRecoveryAction.mockReset().mockResolvedValue({});
|
||||
await expect(executePaperclipNativeSession({ db: leaseDb(execution, {}, {}, updates), execution, runnerInstanceId: "runner" })).rejects.toBe(failure);
|
||||
expect(updates.find(update => update.table === nativeRunFinalizations && update.values.phase === "terminal_failure")?.values).toMatchObject({
|
||||
failureCode: "native_permission_declined", nextAttemptAt: null,
|
||||
failureDetail: { recoverable: false, nextAction: expect.stringContaining("Automatic recovery is stopped") },
|
||||
});
|
||||
expect(updates.some(update => update.table === issues && update.values.status === "blocked")).toBe(false);
|
||||
expect(state.upsertRecoveryAction).toHaveBeenCalledWith(expect.objectContaining({ cause: "native_permission_declined", ownerType: "board", wakePolicy: null }));
|
||||
expect(state.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each(["operator", "reassignment"])("does not turn an acknowledged %s Stop before completion into a failure or a retry", async (source) => {
|
||||
const updates: Array<{ table: unknown; values: Record<string, unknown> }> = [];
|
||||
const stop: Record<string, unknown> = {};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { nativeRetryCancellationEligible, rethrowNativeCancellationLockConflict, assertCancellationRequest, cancellationIntentId as callerCancellationIntentId, cancellationRequestId } from "./native-cancellation-request.js";
|
||||
import { readNativeCursorPlanWait } from "./native-cursor-plan-wait.js";
|
||||
import { NativeCursorPermissionDeclinedError, readCompletedCursorPermissionDecline } from "./native-cursor-permission-decline.js";
|
||||
import { resolveAcpxQualification } from "./acpx-qualification.js";
|
||||
import { readLocalAiCredentialFile } from "../local-ai-credential-file.js";
|
||||
import { prepareGrokRunnerCredentials } from "./grok-runner-credentials.js";
|
||||
@@ -6048,12 +6049,13 @@ export function nativeSessionFailureDisposition(
|
||||
sourceFailureCode === "native_session_cleanup_quarantined" ||
|
||||
sourceFailureCode === "native_adopted_runner_authentication_timeout" ||
|
||||
sourceFailureCode === "native_provider_usage_limit";
|
||||
const exhausted = permanentFailure || attempt >= 3;
|
||||
const permissionDeclined = sourceFailureCode === "native_permission_declined";
|
||||
const exhausted = permanentFailure || permissionDeclined || attempt >= 3;
|
||||
return {
|
||||
phase: exhausted
|
||||
? ("terminal_failure" as const)
|
||||
: ("retryable_failure" as const),
|
||||
failureCode: permanentFailure
|
||||
failureCode: permanentFailure || permissionDeclined
|
||||
? sourceFailureCode!
|
||||
: exhausted
|
||||
? ("native_session_retry_exhausted" as const)
|
||||
@@ -6072,6 +6074,7 @@ export function nativeSessionRecoveryProjection(input: {
|
||||
exhausted,
|
||||
issueStatus:
|
||||
exhausted &&
|
||||
input.failureCode !== "native_permission_declined" &&
|
||||
input.failureCode !== NATIVE_ADOPTED_RUNNER_AUTHENTICATION_TIMEOUT
|
||||
? ("blocked" as const)
|
||||
: null,
|
||||
@@ -6090,6 +6093,7 @@ export function nativeSessionRecoveryProjection(input: {
|
||||
export function nativeSessionFailureSourceCode(
|
||||
error: unknown,
|
||||
):
|
||||
| "native_permission_declined"
|
||||
| "native_provider_terminal_failed"
|
||||
| "native_provider_approval_required"
|
||||
| "native_provider_usage_limit"
|
||||
@@ -6113,6 +6117,7 @@ export function nativeSessionFailureSourceCode(
|
||||
| "native_current_wake_comments_unread"
|
||||
| "native_current_wake_comments_changed_after_read"
|
||||
| "native_session_interrupted" {
|
||||
if (error instanceof NativeCursorPermissionDeclinedError) return "native_permission_declined";
|
||||
if (error instanceof NativeProviderTerminalFailure) {
|
||||
if (error.providerCode === "approval_required") return "native_provider_approval_required";
|
||||
// Failed terminals retain their security meaning across the provider facade.
|
||||
@@ -8427,6 +8432,10 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
const planWait = await readNativeCursorPlanWait(input.db, input.execution.binding);
|
||||
if (planWait?.source.terminalEventId === terminalEvent.sourceEventId) return planWait.result;
|
||||
}
|
||||
if (input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor"
|
||||
&& await readCompletedCursorPermissionDecline(input.db, input.execution.binding, terminalEvent)) {
|
||||
throw new NativeCursorPermissionDeclinedError();
|
||||
}
|
||||
const [conversation] = await input.db
|
||||
.select({ agentId: issues.conversationAgentId })
|
||||
.from(issues)
|
||||
@@ -8877,7 +8886,9 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
checkpointExists: recoveryEvidence.checkpointExists,
|
||||
recoveryOwner: recoveryProjection.recoveryOwner,
|
||||
nextAction:
|
||||
sourceFailureCode === "native_provider_approval_required"
|
||||
sourceFailureCode === "native_permission_declined"
|
||||
? "Cursor permission was declined. Inspect the task and give explicit direction before starting further provider work. Automatic recovery is stopped."
|
||||
: sourceFailureCode === "native_provider_approval_required"
|
||||
? "Approval required. Review the operation and update the agent's permission setting before retrying. This runner has no interactive approval handler."
|
||||
: sourceFailureCode === "native_session_cleanup_quarantined"
|
||||
? NATIVE_CLEANUP_OPERATOR_RECOVERY_MESSAGE
|
||||
@@ -9052,7 +9063,9 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
recoveryEvidence.providerSessionEstablished,
|
||||
},
|
||||
nextAction:
|
||||
sourceFailureCode === "native_provider_approval_required"
|
||||
sourceFailureCode === "native_permission_declined"
|
||||
? "Cursor permission was declined. Inspect the task and give explicit direction before starting further provider work. Automatic recovery is stopped."
|
||||
: sourceFailureCode === "native_provider_approval_required"
|
||||
? "Approval required. Review the operation and update the agent's permission setting before retrying. This runner has no interactive approval handler."
|
||||
: sourceFailureCode === "native_session_cleanup_quarantined"
|
||||
? NATIVE_CLEANUP_OPERATOR_RECOVERY_MESSAGE
|
||||
|
||||
@@ -126,6 +126,9 @@ it("requires a correlated failed semantic finalization after the delivered denia
|
||||
const input = { runId: "run", turnId: "turn", requestId: "request", issue: { id: "issue", status: "in_progress" },
|
||||
run: { id: "run", nativeIssueId: "issue", runtimeMode: "native", status: "failed", errorCode: "native_session_interrupted", error: "native_finalization_missing: session returned no semantic result" }, events: [evidence, terminal] };
|
||||
expect(hasCursorDeniedTurnTerminal(input)).toBe(true);
|
||||
const declined = { ...input, run: { ...input.run, errorCode: "native_permission_declined", error: "native_finalization_missing: session returned no semantic result; Cursor permission was declined; explicit direction is required" } };
|
||||
expect(hasCursorDeniedTurnTerminal(declined)).toBe(true);
|
||||
expect(hasCursorDeniedTurnTerminal({ ...declined, events: [terminal] })).toBe(false);
|
||||
for (const status of ["succeeded", "cancelled", "timed_out", "running"]) expect(hasCursorDeniedTurnTerminal({ ...input, run: { ...input.run, status } })).toBe(false);
|
||||
expect(hasCursorDeniedTurnTerminal({ ...input, issue: { ...input.issue, status: "done" } })).toBe(false);
|
||||
expect(hasCursorDeniedTurnTerminal({ ...input, run: { ...input.run, errorCode: "other" } })).toBe(false);
|
||||
|
||||
@@ -73,9 +73,11 @@ export function hasCursorDeniedTurnTerminal(input: {
|
||||
run: unknown; issue: unknown; events: readonly unknown[]; runId: string; turnId: string; requestId: string;
|
||||
}): boolean {
|
||||
const run = rec(input.run), issue = rec(input.issue);
|
||||
const missingResult = run.errorCode === "native_session_interrupted" && run.error === "native_finalization_missing: session returned no semantic result";
|
||||
const declined = run.errorCode === "native_permission_declined" && run.error === "native_finalization_missing: session returned no semantic result; Cursor permission was declined; explicit direction is required";
|
||||
if (!id(input.runId) || !id(input.turnId) || !id(input.requestId) || run.id !== input.runId || run.nativeIssueId !== issue.id
|
||||
|| run.status !== "failed" || run.runtimeMode !== "native" || issue.status !== "in_progress"
|
||||
|| run.errorCode !== "native_session_interrupted" || run.error !== "native_finalization_missing: session returned no semantic result") return false;
|
||||
|| (!missingResult && !declined)) return false;
|
||||
const terminals = input.events.map(rec).map(row => ({ row, event: rec(rec(row.payload).prpEvent) }))
|
||||
.filter(({ event }) => ["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"].includes(event.eventType));
|
||||
const delivered = readCursorToolEvidence(input.events, input.runId).filter(notice => notice.stage === "permission_delivered"
|
||||
|
||||
@@ -838,7 +838,7 @@ async function runAttempt(input: {
|
||||
Object.assign(publishedResult, {
|
||||
status: "failed",
|
||||
failureClass: "cleanup_failure",
|
||||
error: message,
|
||||
error: publishedResult.error ? `${publishedResult.error}; ${message}` : message,
|
||||
cleanup: "failed",
|
||||
} satisfies Partial<RunnerE2EResult>);
|
||||
const safeResult = `${JSON.stringify(
|
||||
|
||||
Reference in new issue
Block a user