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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-30 01:56:28 -05:00
1 parent 72a88e124d
commit ade7c83dbb
4 files changed
+86 -12

No files matched your search

@@ -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.
@@ -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();
});
@@ -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);
@@ -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) ||