From ade7c83dbb5569b64c8b143fc60311f0beff387f Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 30 Sep 2026 00:39:53 -0500 Subject: [PATCH] Preserve committed Cursor plan waits across profile upgrades Keep current profile qualification mandatory when first creating a wait. Recovery uses its scoped committed receipt to verify the entire original proof, so future catalog/settings changes cannot authorize task work. Cover actual persisted finalization, catalog drift, original-profile tampering, and document explicit user continuation. Co-Authored-By: Paperclip --- .../runner-cursor-capabilities.md | 35 +++++++++++++++++++ .../heartbeat-process-recovery.test.ts | 25 +++++++++---- .../native-cursor-plan-wait.test.ts | 11 +++++- .../native-runtime/native-cursor-plan-wait.ts | 27 +++++++++++--- 4 files changed, 86 insertions(+), 12 deletions(-) diff --git a/doc/architecture/runner-cursor-capabilities.md b/doc/architecture/runner-cursor-capabilities.md index 2fd24933e53..d9612d9f281 100644 --- a/doc/architecture/runner-cursor-capabilities.md +++ b/doc/architecture/runner-cursor-capabilities.md @@ -356,3 +356,38 @@ Profile v5 retains the native distribution closures and instruction patch. Its canonical declaration adds the admitted mode policy and binds ACPX patch `79aad2d688b03362e8cfcbf7a08f78a8383869f6882a9c9ed66f1d18efb94f2b`, which preserves identified empty rich-input chunks at the native parser boundary. + +## Accepted native plans wait for explicit continuation (2026-09-30) + +With Cursor CLI `2026.09.26-dd393fe`, accepting native CreatePlan can finish the +planning turn normally without a Paperclip semantic completion result. The +controller recognizes this boundary only from the admitted Plan-mode run, +company/task scope, exact native plan request and accepted revision, acknowledged +human response delivery, and normal terminal event. An explicit semantic result +still takes precedence. + +The planning run succeeds while the task stays `in_progress`. Its durable comment +says: “Plan accepted. This task is waiting for your next message. This run used +Plan mode; no implementation or task completion is claimed.” Acceptance neither +changes the mode nor schedules implementation or an automatic follow-up. A user +can send a new task message to continue; that message follows ordinary admission +with the settings selected for the new run. + +The committed wait survives controller restart, agent pause, changes to settings +for future runs, and later profile-catalog revisions. Those changes are not +permission to start task work. New waits must match the current qualified +profile; recovery retains an older committed wait only while its original +admission, contract, native request/answer/terminal proof, accepted-result +identity, assignment, and applied task decision remain unchanged. A new user +message or superseding task decision/run ends that wait's authority. Altering the +original proof does not receive the historical-profile exception. + +Deterministic tests cover real result persistence and transactional finalization, +stale delivery rejection, restart/recovery without automatic wakes, catalog and +future-setting changes, ordinary user continuation, and semantic-finish priority. +The Product fixture preserves its native decision and workspace no-effect checks +and now expects a successful planning run with an unfinished task and visible +next-message guidance. Paid requalification of this controller behavior is still +pending; the retained earlier Product failure is not reclassified as a pass. +Runner, sidecar, provider distribution, profile, and image bytes are unchanged by +this controller settlement change. diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index d2fad977d3e..774abc864ec 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -13786,13 +13786,26 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(await db.select().from(statusDecisions).where(eq(statusDecisions.runId, f.runId))).toEqual([expect.objectContaining({ reasonCode: "native_plan_accepted_waiting_for_continuation", toStatus: "in_progress" })]); expect(await db.select().from(statusDecisionEffects).where(eq(statusDecisionEffects.issueId, f.issueId))).toEqual([expect.objectContaining({ effectKind: "issue_status_projection", targetType: "issue", deliveryState: "delivered" })]); expect(await db.select().from(issueComments).where(eq(issueComments.createdByRunId, f.runId))).toEqual([expect.objectContaining({ body: expect.stringContaining("next message") })]); - for (const status of ["idle", "paused"] as const) { - await db.update(agents).set({ status, adapterConfig: { provider: "acpx", acpxAgent: "cursor", - model: "future-model", acpxSessionMode: "agent", acpxPermissionMode: "approve-reads" } }).where(eq(agents.id, f.agentId)); + const current = paperclipRunner.resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"); + const resolver = vi.spyOn(paperclipRunner, "resolveQualifiedAcpxProfile").mockReturnValue({ ...current, + agentProfileVersion: 7, commandDigest: `sha256:${"b".repeat(64)}` }); + try { + expect(await readNativeCursorPlanWait(db, f)).toBeNull(); // Old profile cannot create a new wait. + for (const status of ["idle", "paused"] as const) { + await db.update(agents).set({ status, adapterConfig: { provider: "acpx", acpxAgent: "cursor", + model: "future-model", acpxSessionMode: "agent", acpxPermissionMode: "approve-reads" } }).where(eq(agents.id, f.agentId)); + expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(true); + const recovered = await heartbeatService(db).reconcileStrandedAssignedIssues(); + expect(recovered.continuationRequeued).toBe(0); expect(recovered.escalated).toBe(0); + } + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + const changed = structuredClone(run!.runnerProfileJson!); + (changed.nativeExecutionInput as { provider: { profile: { commandDigest: string } } }).provider.profile.commandDigest = "tampered-original-profile"; + await db.update(heartbeatRuns).set({ runnerProfileJson: changed }).where(eq(heartbeatRuns.id, f.runId)); + expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(false); + await db.update(heartbeatRuns).set({ runnerProfileJson: run!.runnerProfileJson }).where(eq(heartbeatRuns.id, f.runId)); expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(true); - const recovered = await heartbeatService(db).reconcileStrandedAssignedIssues(); - expect(recovered.continuationRequeued).toBe(0); expect(recovered.escalated).toBe(0); - } + } finally { resolver.mockRestore(); } expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(1); expect(mockAdapterExecute).not.toHaveBeenCalled(); expect(mockExecutePaperclipNativeSession).not.toHaveBeenCalled(); }); diff --git a/server/src/services/native-runtime/native-cursor-plan-wait.test.ts b/server/src/services/native-runtime/native-cursor-plan-wait.test.ts index 3d524ec277c..87fbf791ca7 100644 --- a/server/src/services/native-runtime/native-cursor-plan-wait.test.ts +++ b/server/src/services/native-runtime/native-cursor-plan-wait.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import * as runner from "../../vendor/paperclip-runner/index.js"; import { resolveQualifiedAcpxProfile, validatePrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js"; import { buildQuestionResponseDeliveryEnvelope } from "../question-response-delivery.js"; import { nativeSha256 } from "./canonical.js"; @@ -46,6 +47,14 @@ describe("accepted Cursor plan passive-wait authority", () => { expect(validated.ok).toBe(true); if (validated.ok) expect(validated.result).toEqual(value!.result); }); + it("does not create a new wait from an earlier profile after the catalog advances", () => { + const facts = fixture(); + expect(nativeCursorPlanWaitFromFacts(facts)).not.toBeNull(); + const current = resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"); + const resolver = vi.spyOn(runner, "resolveQualifiedAcpxProfile").mockReturnValue({ ...current, agentProfileVersion: 7, commandDigest: `sha256:${"b".repeat(64)}` }); + try { expect(nativeCursorPlanWaitFromFacts(facts)).toBeNull(); } + finally { resolver.mockRestore(); } + }); it("projects only declared scope keys from a wider typed caller binding", () => { const f = fixture(); Object.assign(f.binding, { wakeupRequestId: "unrelated-caller-metadata" }); const proof = nativeCursorPlanWaitFromFacts(f); diff --git a/server/src/services/native-runtime/native-cursor-plan-wait.ts b/server/src/services/native-runtime/native-cursor-plan-wait.ts index c9a4f2e4be2..372fe11d28f 100644 --- a/server/src/services/native-runtime/native-cursor-plan-wait.ts +++ b/server/src/services/native-runtime/native-cursor-plan-wait.ts @@ -44,16 +44,24 @@ export interface CursorPlanWaitFacts { } /** Only committed native request/answer/normal-terminal facts can create this passive wait. */ -export function nativeCursorPlanWaitFromFacts(facts: CursorPlanWaitFacts): { source: NativeCursorPlanWaitSource; result: PrpStructuredRunResult } | null { +export function nativeCursorPlanWaitFromFacts(facts: CursorPlanWaitFacts) { + return cursorPlanWaitFromFacts(facts); +} + +function cursorPlanWaitFromFacts(facts: CursorPlanWaitFacts, committedSource?: NativeCursorPlanWaitSource): { source: NativeCursorPlanWaitSource; result: PrpStructuredRunResult } | null { try { const { run, contract } = facts; const b: Binding = { companyId: facts.binding.companyId, issueId: facts.binding.issueId, runId: facts.binding.runId, agentId: facts.binding.agentId }; const admission = record(record(run.runnerProfileJson).nativeExecutionInput); const provider = record(admission.provider), profile = record(provider.profile); - const expected = resolveQualifiedAcpxProfile("cursor", typeof provider.model === "string" ? provider.model : ""); + // First admission must match today's qualified runtime. Recovery may retain + // a previously committed wait only if the entire original proof is unchanged. + if (!committedSource) { + const expected = resolveQualifiedAcpxProfile("cursor", typeof provider.model === "string" ? provider.model : ""); + if (!["agent", "driverKind", "protocolVersion", "acpxVersion", "commandDigest", "agentProfileVersion", "agentServerPackage", "agentServerVersion", "agentRuntimePackage", "agentRuntimeVersion"].every(key => profile[key] === record(expected)[key])) return null; + } if (run.id !== b.runId || run.companyId !== b.companyId || run.agentId !== b.agentId || run.nativeIssueId !== b.issueId || run.runtimeMode !== "native" || !["running", "succeeded"].includes(run.status) || !Object.entries(b).every(([key, value]) => record(admission.binding)[key] === value) || provider.kind !== "acpx" || provider.agent !== "cursor" || provider.cursorMode !== "plan" || typeof provider.model !== "string" || !provider.model.trim() || - !["agent", "driverKind", "protocolVersion", "acpxVersion", "commandDigest", "agentProfileVersion", "agentServerPackage", "agentServerVersion", "agentRuntimePackage", "agentRuntimeVersion"].every(key => profile[key] === record(expected)[key]) || record(admission.completionContract).id !== contract.id || record(admission.completionContract).sha256 !== contract.canonicalSha256 || nativeSha256(contract.contractJson) !== contract.canonicalSha256 || run.completionContractId !== contract.id || run.completionContractSha256 !== contract.canonicalSha256 || !same(contract.contractJson, record(admission.completionContract).contract)) return null; const sessionId = record(admission.session).normalizedSessionId; if (typeof sessionId !== "string" || !sessionId || facts.events.length === 0 || facts.events.length > 1000) return null; @@ -110,6 +118,7 @@ export function nativeCursorPlanWaitFromFacts(facts: CursorPlanWaitFacts): { sou requestEventId: created.sourceEventId, resolvedEventId: resolved.sourceEventId, terminalEventId: terminal.sourceEventId, deliveryId: d.id, authoritySha256: nativeSha256({ admission, contract, created, resolved, terminal, interaction: i, delivery: d, resolvedAt: i.resolvedAt?.toISOString(), acknowledgedAt: d.acknowledgedAt.toISOString() }), }; + if (committedSource && !same(source, committedSource)) return null; const ref = `interaction:${i.id}`; const result: PrpStructuredRunResult = { schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", summary: SUMMARY, @@ -131,6 +140,12 @@ export function isNativeCursorPlanWaitResult(value: unknown): boolean { /** The committer calls this again under its issue lock; rows are share-locked then. */ export async function readNativeCursorPlanWait(db: Db, binding: Binding, locked = false) { + return readCursorPlanWaitProof(db, binding, locked); +} + +// Historical profile validation is available only after the committed-receipt +// query below establishes its scoped authority. New wait callers cannot select it. +async function readCursorPlanWaitProof(db: Db, binding: Binding, locked: boolean, committedSource?: NativeCursorPlanWaitSource) { const q = db.select({ run: heartbeatRuns, contract: completionContracts }).from(heartbeatRuns) .innerJoin(completionContracts, and(eq(completionContracts.id, heartbeatRuns.completionContractId), eq(completionContracts.companyId, binding.companyId), eq(completionContracts.issueId, binding.issueId))) .innerJoin(issues, and(eq(issues.id, binding.issueId), eq(issues.companyId, binding.companyId), eq(issues.assigneeAgentId, binding.agentId))) @@ -144,7 +159,7 @@ export async function readNativeCursorPlanWait(db: Db, binding: Binding, locked .where(and(eq(issueThreadInteractions.companyId, binding.companyId), eq(issueThreadInteractions.issueId, binding.issueId), eq(issueThreadInteractions.sourceRunId, binding.runId))).limit(101); const interactions = await (locked ? iq.for("share", { noWait: true }) : iq); if (interactions.length > 100) return null; - return nativeCursorPlanWaitFromFacts({ binding, ...row, events, interactions }); + return cursorPlanWaitFromFacts({ binding, ...row, events, interactions }, committedSource); } /** An exact applied wait suppresses recovery, not a later independently admitted user wake. */ @@ -170,7 +185,9 @@ export async function hasCommittedNativeCursorPlanWait(db: Db, binding: Binding) .where(and(eq(nativeRunFinalizations.companyId, binding.companyId), eq(nativeRunFinalizations.issueId, binding.issueId), eq(nativeRunFinalizations.runId, binding.runId), eq(nativeRunFinalizations.phase, "committed"))).limit(1); if (!receipt) return false; - const proof = await readNativeCursorPlanWait(db, binding); + const committedSource = record(receipt.decision.decisionJson).cursorPlanWait as NativeCursorPlanWaitSource | undefined; + if (!committedSource) return false; + const proof = await readCursorPlanWaitProof(db, binding, false, committedSource); const envelope = record(receipt.result.resultJson), terminal = record(envelope.terminal); const acceptedIdentity = record(record(receipt.decision.decisionJson).cursorPlanWaitResult); if (!proof || receipt.result.completionContractId !== proof.source.contractId || receipt.result.turnId !== proof.source.turnId || acceptedIdentity.resultId !== receipt.result.id || acceptedIdentity.resultSha256 !== receipt.result.canonicalSha256 || !same(record(receipt.decision.decisionJson).cursorPlanWait, proof.source) || !same(envelope.result, proof.result) ||