diff --git a/doc/plans/2026-10-03-cursor-production-readiness.md b/doc/plans/2026-10-03-cursor-production-readiness.md index d727d222da8..4d69bd37f15 100644 --- a/doc/plans/2026-10-03-cursor-production-readiness.md +++ b/doc/plans/2026-10-03-cursor-production-readiness.md @@ -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. diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.ts index 1a4eedd6176..0cb099b8ea6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.ts @@ -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; -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"); diff --git a/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts index f9e1a93ff55..b0c597ce562 100644 --- a/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts @@ -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]]); + }); }); } diff --git a/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.ts index cbea42d835e..11cace154cb 100644 --- a/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.ts +++ b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.ts @@ -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 | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : 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, 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) { diff --git a/server/src/services/native-runtime/native-cursor-permission-decline.test.ts b/server/src/services/native-runtime/native-cursor-permission-decline.test.ts new file mode 100644 index 00000000000..de6997547ac --- /dev/null +++ b/server/src/services/native-runtime/native-cursor-permission-decline.test.ts @@ -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); + }); +}); diff --git a/server/src/services/native-runtime/native-cursor-permission-decline.ts b/server/src/services/native-runtime/native-cursor-permission-decline.ts new file mode 100644 index 00000000000..292739e11df --- /dev/null +++ b/server/src/services/native-runtime/native-cursor-permission-decline.ts @@ -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 => value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +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); +} diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 4fc22f8b4cf..839e25557a1 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -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 }> = []; + 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 }> = []; const stop: Record = {}; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index ff93f73a990..1572c39e712 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -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 diff --git a/tests/runner-e2e/cursor-native-evidence.test.ts b/tests/runner-e2e/cursor-native-evidence.test.ts index f874244070e..ea0ee23776c 100644 --- a/tests/runner-e2e/cursor-native-evidence.test.ts +++ b/tests/runner-e2e/cursor-native-evidence.test.ts @@ -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); diff --git a/tests/runner-e2e/cursor-native-evidence.ts b/tests/runner-e2e/cursor-native-evidence.ts index 1c2545cb875..79afb5df2cf 100644 --- a/tests/runner-e2e/cursor-native-evidence.ts +++ b/tests/runner-e2e/cursor-native-evidence.ts @@ -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" diff --git a/tests/runner-e2e/launch.ts b/tests/runner-e2e/launch.ts index 0ac0be50673..4dc060d8a2b 100644 --- a/tests/runner-e2e/launch.ts +++ b/tests/runner-e2e/launch.ts @@ -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); const safeResult = `${JSON.stringify(