mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
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:
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) ||
|
||||
|
||||
Reference in new issue
Block a user