Keep accepted Cursor plans waiting for explicit continuation

Bind normal native plan acceptance to its durable request, delivered answer, admitted run and completion contract. Commit a passive in-progress result with a visible next-message summary, preserve semantic finish priority, and suppress recovery until task-specific continuation without changing modes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-30 01:56:28 -05:00
1 parent aec34c693f
commit 72a88e124d
12 files changed
+602 -7

No files matched your search

@@ -1,3 +1,6 @@
import { readNativeCursorPlanWait, hasCommittedNativeCursorPlanWait } from "../services/native-runtime/native-cursor-plan-wait.js";
import { nativeSha256 } from "../services/native-runtime/canonical.js";
import { buildQuestionResponseDeliveryEnvelope } from "../services/question-response-delivery.js";
import * as executionContinuation from "../services/execution-continuation.js";
import { legacyDispositionFingerprint, LEGACY_DISPOSITION_REPAIR_INSTRUCTION } from "../services/recovery/legacy-continuation.js";
import * as controllerLeases from "../services/legacy-controller-lease.js";
@@ -16,6 +19,7 @@ import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
@@ -56,6 +60,7 @@ import {
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueQuestionResponseDeliveries,
issueApprovals,
issueDocuments,
issuePlanDecompositions,
@@ -13720,6 +13725,152 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(recoveryAction.ownerType).toBe("board");
});
describe("accepted Cursor planning boundaries", () => {
const nativeImplementation = mockExecutePaperclipNativeSession.getMockImplementation();
beforeEach(() => {
mockExecutePaperclipNativeSession.mockImplementation(async () => { throw new Error("Accepted-plan tests must never execute a provider"); });
});
afterEach(() => { mockExecutePaperclipNativeSession.mockImplementation(nativeImplementation!); });
async function seedAcceptedCursorPlanWait(semanticFinish = false) {
const f = await seedStrandedIssueFixture({ status: "in_progress", runStatus: "succeeded", livenessState: "advanced" });
const contractId = randomUUID(), instance = randomUUID(), interactionId = randomUUID();
const contract = { revision: "1", objective: "Review the plan before further work", criteria: [{ id: "objective", requirement: "Explicit completion required" }] };
const model = "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]";
const contractSha = nativeSha256(contract);
await db.insert(completionContracts).values({ id: contractId, companyId: f.companyId, issueId: f.issueId,
revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "phase6-v7", risk: "low",
completionAuthority: "agent_claim_policy", incompleteCriteriaPolicy: "preserve_non_terminal", contractJson: contract,
canonicalSha256: contractSha, createdByActorType: "system", createdByActorId: "test" });
await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "cursor", model, acpxSessionMode: "plan", acpxPermissionMode: "approve-all" } }).where(eq(agents.id, f.agentId));
await db.update(agentWakeupRequests).set({ status: "completed" }).where(eq(agentWakeupRequests.id, f.wakeupRequestId));
await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: f.issueId, nativeSessionId: f.runId,
completionContractId: contractId, completionContractSha256: contractSha, runnerInstanceId: instance,
runnerProfileJson: { nativeExecutionInput: { binding: { companyId: f.companyId, issueId: f.issueId, runId: f.runId, agentId: f.agentId }, provider: { kind: "acpx", agent: "cursor", model, cursorMode: "plan", permissionMode: "approve-all",
profile: paperclipRunner.resolveQualifiedAcpxProfile("cursor", model) }, session: { normalizedSessionId: f.runId },
completionContract: { id: contractId, sha256: contractSha, contract } } } }).where(eq(heartbeatRuns.id, f.runId));
const planId = `plan-${"a".repeat(64)}`;
const questionSet = { schema: "paperclip.question_set.v1", title: "Native plan", description: "Exact accepted revision", questions: [{ id: planId, prompt: "Proceed?", required: true,
answerMode: "single_select", options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }, { id: "cancel", label: "Cancel" }] }] };
const [interaction] = await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: f.companyId, issueId: f.issueId, sourceRunId: f.runId,
createdByAgentId: f.agentId, kind: "ask_user_questions", status: "answered", continuationPolicy: "none",
idempotencyKey: `paperclip-runner-question:${f.runId}:request`, resolvedByUserId: "responsible-user", resolvedAt: new Date(Date.now() - 1000),
payload: { version: 1, questions: [{ id: planId, prompt: "Proceed?", selectionMode: "single", required: true, allowOther: false, options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }, { id: "cancel", label: "Cancel" }] }], runtimeRequestId: "request", questionSet: questionSet as never }, result: { version: 1, answers: [{ questionId: planId, optionIds: ["accept"] }] } }).returning();
const answer = buildQuestionResponseDeliveryEnvelope(interaction as never);
const [delivery] = await db.insert(issueQuestionResponseDeliveries).values({ companyId: f.companyId, issueId: f.issueId, interactionId,
sourceRunId: f.runId, targetRunId: f.runId, correlationId: randomUUID(), status: "delivered", deliveryMode: "steered", acknowledgedAt: new Date(), payloadSha256: nativeSha256(answer) }).returning();
const port = new PaperclipControlPlanePort(db, { companyId: f.companyId, issueId: f.issueId, runId: f.runId, agentId: f.agentId,
sessionId: f.runId, completionContractId: contractId, completionContractSha256: contractSha, sourceInstanceId: instance, controlPlaneSourceInstanceId: `control-${f.runId}` });
const events = [
{ eventType: "runtime_request.created", payload: { request: { schema: "paperclip.runtime_request.v2", requestKind: "runtime", requestId: "request", type: "input", status: "pending", turnId: "turn", prompt: "Review plan",
origin: { adapter: "acpx-runtime-sidecar", provider: "cursor", method: "cursor/create_plan" }, input: questionSet } } },
{ eventType: "runtime_request.resolved", payload: { requestId: "request", turnId: "turn", action: "submit", response: answer.response } },
{ eventType: "turn.completed", payload: { status: "completed", error: null } },
];
for (const [index, event] of events.entries()) await port.appendEvent({ schema: "paperclip.prp.event.v1", sourceEventId: `${instance}:${index + 1}`, sourceSeq: index + 1,
sourceInstanceId: instance, sourceKind: "runner", runId: f.runId, normalizedSessionId: f.runId, turnId: "turn", schemaVersion: 1, priority: 0,
emittedAt: new Date().toISOString(), ...event } as paperclipRunner.PrpEvent);
const proof = await readNativeCursorPlanWait(db, f);
expect(proof).not.toBeNull();
const result = semanticFinish ? { ...proof!.result, reportedWorkDisposition: "done" as const, summary: "Explicit semantic finish wins", continuation: undefined,
completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [{ criterionId: "objective", status: "satisfied" as const, evidenceRefs: [] }], remainingWork: [] } } : proof!.result;
await port.completeRun({ result, turnId: "turn", terminal: { schema: "paperclip.prp.terminal.v1", runTerminalState: "succeeded", turnTerminalState: "completed", reportedWorkDisposition: result.reportedWorkDisposition } });
return { ...f, interactionId, deliveryId: delivery!.id, proof: proof! };
}
it("keeps an accepted Cursor plan passive across restart, future configuration changes, and paused recovery", async () => {
const f = await seedAcceptedCursorPlanWait();
await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(true);
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));
expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(true);
const recovered = await heartbeatService(db).reconcileStrandedAssignedIssues();
expect(recovered.continuationRequeued).toBe(0); expect(recovered.escalated).toBe(0);
}
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(1);
expect(mockAdapterExecute).not.toHaveBeenCalled(); expect(mockExecutePaperclipNativeSession).not.toHaveBeenCalled();
});
it("admits a later ordinary user message after an accepted Cursor plan without changing Plan mode or replaying its run", async () => {
const f = await seedAcceptedCursorPlanWait();
await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
// Occupy the single dispatch slot: this checks actual user-wake admission
// without executing any provider or fabricating a second semantic result.
const occupied = randomUUID();
await db.update(agents).set({ runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } }).where(eq(agents.id, f.agentId));
await db.insert(heartbeatRuns).values({ id: occupied, companyId: f.companyId, agentId: f.agentId, status: "running", startedAt: new Date(), contextSnapshot: {} });
const [comment] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: "responsible-user", body: "Continue reviewing the accepted plan in Plan mode." }).returning();
try {
const heartbeat = heartbeatService(db);
const next = await heartbeat.wakeup(f.agentId, { source: "automation", triggerDetail: "system", reason: "issue_commented", requestedByActorType: "user", requestedByActorId: "responsible-user",
payload: { issueId: f.issueId, commentId: comment!.id }, contextSnapshot: { issueId: f.issueId, taskId: f.issueId, wakeCommentId: comment!.id, wakeCommentIds: [comment!.id] } });
expect(next).not.toBeNull(); expect(next!.id).not.toBe(f.runId);
const [admitted] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, next!.id));
expect(admitted).toMatchObject({ status: "queued", retryOfRunId: null });
expect(admitted!.contextSnapshot?.wakeCommentIds).toContain(comment!.id);
const [agent] = await db.select().from(agents).where(eq(agents.id, f.agentId));
expect(agent!.adapterConfig.acpxSessionMode).toBe("plan");
expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(false);
expect(mockExecutePaperclipNativeSession).not.toHaveBeenCalled();
} finally {
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(and(eq(heartbeatRuns.companyId, f.companyId), inArray(heartbeatRuns.status, ["running", "queued"])));
await db.update(agentWakeupRequests).set({ status: "cancelled", finishedAt: new Date() }).where(and(eq(agentWakeupRequests.companyId, f.companyId), inArray(agentWakeupRequests.status, ["queued", "claimed"])));
}
});
it.each(["delivery", "assignment", "admission", "user_request", "result_or_decision"] as const)("revokes an accepted Cursor plan passive wait after %s changes", async change => {
const f = await seedAcceptedCursorPlanWait();
await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
if (change === "delivery") await db.update(issueQuestionResponseDeliveries).set({ acknowledgedAt: null }).where(eq(issueQuestionResponseDeliveries.id, f.deliveryId));
if (change === "assignment") await db.update(issues).set({ assigneeAgentId: null }).where(eq(issues.id, f.issueId));
if (change === "admission") {
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
const profile = structuredClone(run!.runnerProfileJson!);
(profile.nativeExecutionInput as { provider: { cursorMode: string } }).provider.cursorMode = "agent";
await db.update(heartbeatRuns).set({ runnerProfileJson: profile }).where(eq(heartbeatRuns.id, f.runId));
}
if (change === "user_request") await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: "responsible-user", body: "Continue reviewing this plan in Plan mode." });
if (change === "result_or_decision") {
const [accepted] = await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, f.runId));
await db.update(nativeRunResults).set({ canonicalSha256: "changed" }).where(eq(nativeRunResults.id, accepted!.id));
expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(false);
await db.update(nativeRunResults).set({ canonicalSha256: accepted!.canonicalSha256 }).where(eq(nativeRunResults.id, accepted!.id));
expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(true);
await db.update(issues).set({ lastStatusDecisionId: null }).where(eq(issues.id, f.issueId));
}
expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(false);
});
it("rechecks accepted Cursor plan delivery under the status transaction before presentation or effects", async () => {
const f = await seedAcceptedCursorPlanWait();
await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", failpoint: "status_projection" });
const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
const [issue] = await db.select().from(issues).where(eq(issues.id, f.issueId));
await db.update(issueQuestionResponseDeliveries).set({ payloadSha256: "changed" }).where(eq(issueQuestionResponseDeliveries.id, f.deliveryId));
await expect(commitNativeStatusDecision({ db, companyId: f.companyId, issueId: f.issueId, runId: f.runId, assessmentId: coordinator!.assessmentId!,
priorStatus: issue!.status, priorStatusVersion: issue!.statusVersion, priorDecisionId: issue!.lastStatusDecisionId,
decision: { policyVersion: "phase6-v7", statusAction: "in_progress", toStatus: "in_progress", reasonCode: "native_plan_accepted_waiting_for_continuation", unblockDescriptor: null, effects: [] },
requireCursorPlanWaitSource: f.proof.source })).rejects.toBeInstanceOf(NativeStatusRaceError);
expect(await db.select().from(statusDecisions).where(eq(statusDecisions.runId, f.runId))).toHaveLength(0);
expect(await db.select().from(issueComments).where(eq(issueComments.createdByRunId, f.runId))).toHaveLength(0);
});
it("preserves explicit semantic finish priority over accepted Cursor plan evidence", async () => {
const f = await seedAcceptedCursorPlanWait(true);
await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(false);
const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.runId, f.runId));
expect(decisions).toHaveLength(1); expect(decisions[0]!.reasonCode).not.toBe("native_plan_accepted_waiting_for_continuation");
});
});
async function seedNativePassiveBoardResponse(
continuationKind:
"response_wake" | "same_agent" | "retry" = "response_wake",
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { resolveQualifiedAcpxProfile, validatePrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js";
import { buildQuestionResponseDeliveryEnvelope } from "../question-response-delivery.js";
import { nativeSha256 } from "./canonical.js";
import { nativeCursorPlanWaitFromFacts, type CursorPlanWaitFacts } from "./native-cursor-plan-wait.js";
function fixture(): CursorPlanWaitFacts {
const b = { companyId: "company", issueId: "issue", agentId: "agent", runId: "run" };
const contract = { revision: "revision", criteria: [{ id: "criterion" }] };
const planId = `plan-${"a".repeat(64)}`;
const input = { schema: "paperclip.question_set.v1", title: "Plan", description: "Exact revised plan text", questions: [{ id: planId, prompt: "Proceed?", required: true, answerMode: "single_select", options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }, { id: "cancel", label: "Cancel" }] }] };
const i = { id: "interaction", ...b, sourceRunId: b.runId, createdByAgentId: b.agentId, resolvedByUserId: "board", resolvedByAgentId: null, resolvedAt: new Date(0),
kind: "ask_user_questions", status: "answered", continuationPolicy: "none", idempotencyKey: "paperclip-runner-question:run:request",
payload: { runtimeRequestId: "request", questionSet: input }, result: { version: 1, answers: [{ questionId: planId, optionIds: ["accept"] }] } };
const envelope = buildQuestionResponseDeliveryEnvelope(i as never);
const d = { id: "delivery", ...b, interactionId: i.id, sourceRunId: b.runId, targetRunId: b.runId, targetTurnId: null, status: "delivered", deliveryMode: "steered", acknowledgedAt: new Date(1), payloadSha256: nativeSha256(envelope) };
const event = (sourceSeq: number, eventType: string, payload: Record<string, unknown>) => {
const e = { schema: "paperclip.prp.event.v1", runId: b.runId, normalizedSessionId: "session", turnId: "turn", sourceInstanceId: "instance", sourceEventId: `instance:${sourceSeq}`, sourceSeq, sourceKind: "runner", schemaVersion: 1, eventType, payload };
return { ...b, seq: sourceSeq, eventType, payload: { prpEvent: e }, sourceInstanceId: e.sourceInstanceId, sourceEventId: e.sourceEventId, sourceSeq, sourcePayloadSha256: nativeSha256(e), protocolSchemaVersion: 1 };
};
return {
binding: b,
run: { id: b.runId, companyId: b.companyId, agentId: b.agentId, nativeIssueId: b.issueId, runtimeMode: "native", runnerInstanceId: "instance", status: "running", completionContractId: "contract", completionContractSha256: nativeSha256(contract), runnerProfileJson: { nativeExecutionInput: { binding: b, provider: { kind: "acpx", agent: "cursor", cursorMode: "plan", model: "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", profile: resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]") }, session: { normalizedSessionId: "session" }, completionContract: { id: "contract", sha256: nativeSha256(contract), contract } } } },
contract: { id: "contract", canonicalSha256: nativeSha256(contract), contractJson: contract },
events: [
event(1, "runtime_request.created", { request: { schema: "paperclip.runtime_request.v2", status: "pending", type: "input", requestKind: "runtime", requestId: "request", turnId: "turn", origin: { provider: "cursor", method: "cursor/create_plan", adapter: "acpx-runtime-sidecar" }, input } }),
event(2, "runtime_request.resolved", { requestId: "request", turnId: "turn", action: "submit", response: envelope.response }),
event(3, "turn.completed", { status: "completed", error: null }),
],
interactions: [{ interaction: i, delivery: d }],
} as unknown as CursorPlanWaitFacts;
}
function editEvent(f: CursorPlanWaitFacts, index: number, edit: (e: any) => void) {
const row = f.events[index]!;
const e = (row.payload as any).prpEvent;
edit(e); row.sourcePayloadSha256 = nativeSha256(e);
}
describe("accepted Cursor plan passive-wait authority", () => {
it("records the accepted revision and explicitly unfinished Plan-mode continuation", () => {
const value = nativeCursorPlanWaitFromFacts(fixture());
expect(value?.source).toMatchObject({ requestId: "request", planRevision: `plan-${"a".repeat(64)}`, terminalEventId: "instance:3" });
expect(value?.result).toMatchObject({ reportedWorkDisposition: "yielded", completionClaim: { objectiveSatisfied: false, remainingWork: [{ blocksCompletion: true }] }, continuation: { kind: "response_wake" } });
expect(value?.result.summary).toContain("next message");
const validated = validatePrpStructuredRunResult(value!.result);
expect(validated.ok).toBe(true);
if (validated.ok) expect(validated.result).toEqual(value!.result);
});
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);
expect(proof).not.toBeNull();
expect(proof!.source).not.toHaveProperty("wakeupRequestId");
});
it.each([
["foreign runner", (f: CursorPlanWaitFacts) => { f.run.runnerInstanceId = "other"; }],
["changed admission binding", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.binding = { ...f.binding, runId: "other" }; }],
["wrong company", (f: CursorPlanWaitFacts) => { f.run.companyId = "other"; }],
["wrong task", (f: CursorPlanWaitFacts) => { f.run.nativeIssueId = "other"; }],
["failed run", (f: CursorPlanWaitFacts) => { f.run.status = "failed"; }],
["cancelled run", (f: CursorPlanWaitFacts) => { f.run.status = "cancelled"; }],
["Agent mode", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.provider.cursorMode = "agent"; }],
["stale profile", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.provider.profile = { ...resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"), commandDigest: "old" }; }],
["changed contract", (f: CursorPlanWaitFacts) => { f.contract.contractJson.revision = "new"; }],
["unknown origin", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.origin.provider = "acpx"; })],
["wrong method", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.origin.method = "cursor/ask_question"; })],
["untrusted adapter", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.origin.adapter = "other"; })],
["cross turn", (f: CursorPlanWaitFacts) => editEvent(f, 1, e => { e.turnId = "other"; })],
["cross session", (f: CursorPlanWaitFacts) => editEvent(f, 1, e => { e.normalizedSessionId = "other"; })],
["native error", (f: CursorPlanWaitFacts) => editEvent(f, 2, e => { e.payload.error = { message: "failure" }; })],
["changed plan", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.input.description = "other"; })],
["rejection", (f: CursorPlanWaitFacts) => editEvent(f, 1, e => { e.payload.response.answers[`plan-${"a".repeat(64)}`].selectedOptionIds = ["reject"]; })],
["cancellation", (f: CursorPlanWaitFacts) => editEvent(f, 1, e => { e.payload.response.answers[`plan-${"a".repeat(64)}`].selectedOptionIds = ["cancel"]; })],
["missing delivery", (f: CursorPlanWaitFacts) => { f.interactions = []; }],
["unacknowledged delivery", (f: CursorPlanWaitFacts) => { f.interactions[0]!.delivery.acknowledgedAt = null; }],
["fallback wake", (f: CursorPlanWaitFacts) => { f.interactions[0]!.delivery.deliveryMode = "wake_fallback"; }],
["wrong target", (f: CursorPlanWaitFacts) => { f.interactions[0]!.delivery.targetRunId = "other"; }],
["changed answer digest", (f: CursorPlanWaitFacts) => { f.interactions[0]!.delivery.payloadSha256 = "other"; }],
["missing resolution time", (f: CursorPlanWaitFacts) => { f.interactions[0]!.interaction.resolvedAt = null; }],
["agent resolved", (f: CursorPlanWaitFacts) => { f.interactions[0]!.interaction.resolvedByAgentId = "agent"; }],
["duplicate receipt", (f: CursorPlanWaitFacts) => { f.events.splice(1, 0, structuredClone(f.events[0]!)); }],
["duplicate delivery", (f: CursorPlanWaitFacts) => { f.interactions.push(structuredClone(f.interactions[0]!)); }],
["uncommitted event", (f: CursorPlanWaitFacts) => { f.events[1]!.sourcePayloadSha256 = null; }],
["changed row identity", (f: CursorPlanWaitFacts) => { f.events[1]!.sourceEventId = "other"; }],
["terminal before answer", (f: CursorPlanWaitFacts) => { f.events.reverse(); }],
] as const)("rejects %s", (_name, mutate) => {
const f = fixture(); mutate(f); expect(nativeCursorPlanWaitFromFacts(f)).toBeNull();
});
it("does not reuse an older acceptance after a new request, later work, or conflicting terminal", () => {
for (const kind of ["runtime_request.created", "tool.execution.started", "turn.failed", "turn.cancelled", "turn.completed"]) {
const f = fixture(); const row = structuredClone(f.events[0]!);
row.seq = 2.5; row.sourceSeq = 3; row.sourceEventId = "instance:later"; row.eventType = kind;
const e = (row.payload as any).prpEvent; Object.assign(e, { sourceSeq: 3, sourceEventId: row.sourceEventId, eventType: kind });
if (kind === "runtime_request.created") e.payload.request.requestId = "new-plan";
row.sourcePayloadSha256 = nativeSha256(e);
editEvent(f, 2, e => { e.sourceSeq = 4; }); f.events[2]!.sourceSeq = 4;
f.events.splice(2, 0, row);
expect(nativeCursorPlanWaitFromFacts(f)).toBeNull();
}
});
});
@@ -0,0 +1,195 @@
import { and, asc, eq, gt, inArray, isNull, ne, sql } from "drizzle-orm";
import {
completionContracts, heartbeatRunEvents, heartbeatRuns, issues, issueComments,
nativeRunFinalizations, nativeRunResults, statusDecisions,
issueQuestionResponseDeliveries, issueThreadInteractions, type Db,
} from "@paperclipai/db";
import type { AskUserQuestionsInteraction } from "@paperclipai/shared";
import {
parsePaperclipQuestionResponse, parsePaperclipQuestionSet, resolveQualifiedAcpxProfile,
type PrpStructuredRunResult,
} from "../../vendor/paperclip-runner/index.js";
import { buildQuestionResponseDeliveryEnvelope } from "../question-response-delivery.js";
import { nativeSha256 } from "./canonical.js";
type Binding = { companyId: string; issueId: string; runId: string; agentId: string };
const record = (v: unknown): Record<string, any> => v && typeof v === "object" && !Array.isArray(v) ? v as Record<string, any> : {};
const same = (a: unknown, b: unknown) => nativeSha256(a) === nativeSha256(b);
const PREFIX = "cursor-plan-wait:";
const EVENT_TYPES = ["runtime_request.created", "runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired", "turn.started", "turn.completed", "turn.failed", "turn.cancelled", "tool.execution.started", "tool.execution.completed"];
const SUMMARY = "Plan accepted. This task is waiting for your next message. This run used Plan mode; no implementation or task completion is claimed.";
export interface NativeCursorPlanWaitSource extends Binding {
schema: "paperclip.native_cursor_plan_wait.v1";
contractId: string;
contractSha256: string;
interactionId: string;
requestId: string;
planRevision: string;
turnId: string;
normalizedSessionId: string;
sourceInstanceId: string;
requestEventId: string;
resolvedEventId: string;
terminalEventId: string;
deliveryId: string;
authoritySha256: string;
}
export interface CursorPlanWaitFacts {
binding: Binding;
run: Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "agentId" | "nativeIssueId" | "runtimeMode" | "status" | "runnerProfileJson" | "runnerInstanceId" | "completionContractId" | "completionContractSha256">;
contract: Pick<typeof completionContracts.$inferSelect, "id" | "canonicalSha256" | "contractJson">;
events: Array<Pick<typeof heartbeatRunEvents.$inferSelect, "companyId" | "agentId" | "runId" | "seq" | "eventType" | "payload" | "sourceInstanceId" | "sourceEventId" | "sourceSeq" | "sourcePayloadSha256" | "protocolSchemaVersion">>;
interactions: Array<{ interaction: typeof issueThreadInteractions.$inferSelect; delivery: typeof issueQuestionResponseDeliveries.$inferSelect }>;
}
/** Only committed native request/answer/normal-terminal facts can create this passive wait. */
export function nativeCursorPlanWaitFromFacts(facts: CursorPlanWaitFacts): { 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 : "");
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;
const events: Array<Record<string, any>> = [];
const ids = new Set<string>(), seqs = new Set<string>();
let lastRowSeq = -1;
const lastSourceSeq = new Map<string, number>();
for (const row of facts.events) {
const event = record(record(row.payload).prpEvent);
if (!event.schema) continue; // Non-PRP aggregate rows are not native evidence.
if (row.companyId !== b.companyId || row.runId !== b.runId || row.agentId !== b.agentId || row.seq <= lastRowSeq ||
event.schema !== "paperclip.prp.event.v1" || event.schemaVersion !== 1 || event.sourceInstanceId !== run.runnerInstanceId || event.sourceKind !== "runner" || event.runId !== b.runId || event.normalizedSessionId !== sessionId ||
row.eventType !== event.eventType || row.sourceEventId !== event.sourceEventId || row.sourceInstanceId !== event.sourceInstanceId || row.sourceSeq !== event.sourceSeq || row.protocolSchemaVersion !== event.schemaVersion ||
typeof event.sourceInstanceId !== "string" || !event.sourceInstanceId || typeof event.sourceEventId !== "string" || !event.sourceEventId || !Number.isSafeInteger(event.sourceSeq) || event.sourceSeq < 1 || row.sourcePayloadSha256 !== nativeSha256(event) ||
(lastSourceSeq.get(event.sourceInstanceId) ?? 0) >= event.sourceSeq || ids.has(event.sourceEventId) || seqs.has(`${event.sourceInstanceId}:${event.sourceSeq}`)) return null;
lastSourceSeq.set(event.sourceInstanceId, event.sourceSeq);
lastRowSeq = row.seq; ids.add(event.sourceEventId); seqs.add(`${event.sourceInstanceId}:${event.sourceSeq}`); events.push(event);
}
const terminal = events.at(-1);
if (!terminal || terminal.eventType !== "turn.completed" || record(terminal.payload).status !== "completed" || record(terminal.payload).error != null || typeof terminal.turnId !== "string") return null;
const turn = events.filter(e => e.turnId === terminal.turnId);
if (turn.some(e => e.sourceInstanceId !== terminal.sourceInstanceId) || turn.filter(e => ["turn.completed", "turn.failed", "turn.cancelled"].includes(e.eventType)).length !== 1) return null;
const requests = turn.filter(e => e.eventType === "runtime_request.created");
const created = requests.at(-1), request = record(record(created?.payload).request), origin = record(request.origin);
if (!created || request.schema !== "paperclip.runtime_request.v2" || request.type !== "input" || request.requestKind !== "runtime" || request.status !== "pending" || request.turnId !== terminal.turnId ||
origin.provider !== "cursor" || origin.method !== "cursor/create_plan" || !["acpx-runtime", "acpx-runtime-sidecar"].includes(origin.adapter) || typeof request.requestId !== "string") return null;
if (new Set(requests.map(e => record(record(e.payload).request).requestId)).size !== requests.length) return null;
// No unresolved earlier input or later work is disguised as an accepted plan boundary.
for (const start of requests) {
const id = record(record(start.payload).request).requestId;
const ends = turn.filter(e => ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(e.eventType) && record(e.payload).requestId === id);
if (ends.length !== 1 || ends[0]!.sourceSeq <= start.sourceSeq || ends[0]!.eventType !== "runtime_request.resolved") return null;
}
if (turn.some(e => e.sourceSeq > created.sourceSeq && e.eventType === "tool.execution.started")) return null;
const resolved = turn.find(e => e.eventType === "runtime_request.resolved" && record(e.payload).requestId === request.requestId)!;
const resolution = record(resolved.payload);
if (resolved.sourceSeq >= terminal.sourceSeq || resolution.action !== "submit" || resolution.turnId !== terminal.turnId) return null;
const questionSet = parsePaperclipQuestionSet(request.input);
const plan = questionSet.questions.filter(q => /^plan-[a-f0-9]{64}$/.test(q.id));
if (plan.length !== 1 || plan[0]!.answerMode !== "single_select" || !plan[0]!.required || !same(plan[0]!.options?.map(o => o.id), ["accept", "reject", "cancel"])) return null;
const response = parsePaperclipQuestionResponse(questionSet, resolution.response);
if (!same(response.answers[plan[0]!.id]?.selectedOptionIds, ["accept"])) return null;
const matches = facts.interactions.filter(({ interaction: i }) => record(i.payload).runtimeRequestId === request.requestId);
if (matches.length !== 1) return null;
const { interaction: i, delivery: d } = matches[0]!;
if (i.companyId !== b.companyId || i.issueId !== b.issueId || i.sourceRunId !== b.runId || i.createdByAgentId !== b.agentId || i.kind !== "ask_user_questions" || i.status !== "answered" || i.continuationPolicy !== "none" || !i.resolvedByUserId || !i.resolvedAt || i.resolvedByAgentId ||
i.idempotencyKey !== `paperclip-runner-question:${b.runId}:${request.requestId}` || !same(record(i.payload).questionSet, questionSet) ||
d.companyId !== b.companyId || d.issueId !== b.issueId || d.interactionId !== i.id || d.sourceRunId !== b.runId || d.targetRunId !== b.runId || d.status !== "delivered" || d.deliveryMode !== "steered" || !d.acknowledgedAt || (d.targetTurnId !== null && d.targetTurnId !== terminal.turnId)) return null;
const envelope = buildQuestionResponseDeliveryEnvelope(i as unknown as AskUserQuestionsInteraction);
if (d.payloadSha256 !== nativeSha256(envelope) || !same(parsePaperclipQuestionResponse(questionSet, envelope.response), response)) return null;
const source: NativeCursorPlanWaitSource = {
...b, schema: "paperclip.native_cursor_plan_wait.v1", contractId: contract.id, contractSha256: contract.canonicalSha256, interactionId: i.id, requestId: request.requestId, planRevision: plan[0]!.id,
turnId: terminal.turnId, normalizedSessionId: sessionId, sourceInstanceId: terminal.sourceInstanceId,
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() }),
};
const ref = `interaction:${i.id}`;
const result: PrpStructuredRunResult = {
schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", summary: SUMMARY,
completionClaim: { contractRevision: String(contract.contractJson.revision), objectiveSatisfied: false,
criteria: (contract.contractJson.criteria as Array<{id: string}>).map(c => ({ criterionId: c.id, status: "unknown", evidenceRefs: [ref] })),
remainingWork: [{ description: "Continue or finalize the accepted plan only after explicit direction; remain in the selected mode.", blocksCompletion: true }] },
evidence: [{ ref }, { ref: `run-event:${resolved.sourceEventId}` }], verification: [], attentionRequests: [],
artifacts: [{ kind: "issue_thread_interaction", ref }],
continuation: { kind: "response_wake", summary: SUMMARY, idempotencyKey: `${PREFIX}${created.sourceEventId}` },
};
return { source, result };
} catch { return null; }
}
export function isNativeCursorPlanWaitResult(value: unknown): boolean {
const result = record(value);
return result.reportedWorkDisposition === "yielded" && typeof record(result.continuation).idempotencyKey === "string" && record(result.continuation).idempotencyKey.startsWith(PREFIX);
}
/** The committer calls this again under its issue lock; rows are share-locked then. */
export async function readNativeCursorPlanWait(db: Db, binding: Binding, locked = false) {
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)))
.where(and(eq(heartbeatRuns.id, binding.runId), eq(heartbeatRuns.companyId, binding.companyId), eq(heartbeatRuns.agentId, binding.agentId), eq(heartbeatRuns.nativeIssueId, binding.issueId))).limit(1);
const [row] = await (locked ? q.for("share", { noWait: true }) : q);
if (!row || record(record(row.run.runnerProfileJson).nativeExecutionInput).provider?.agent !== "cursor" || record(record(row.run.runnerProfileJson).nativeExecutionInput).provider?.cursorMode !== "plan") return null;
const eqs = db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.companyId, binding.companyId), eq(heartbeatRunEvents.runId, binding.runId), inArray(heartbeatRunEvents.eventType, EVENT_TYPES))).orderBy(asc(heartbeatRunEvents.seq)).limit(1001);
const events = await (locked ? eqs.for("share", { noWait: true }) : eqs);
const iq = db.select({ interaction: issueThreadInteractions, delivery: issueQuestionResponseDeliveries }).from(issueThreadInteractions)
.innerJoin(issueQuestionResponseDeliveries, eq(issueQuestionResponseDeliveries.interactionId, issueThreadInteractions.id))
.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 });
}
/** An exact applied wait suppresses recovery, not a later independently admitted user wake. */
export async function hasCommittedNativeCursorPlanWait(db: Db, binding: Binding): Promise<boolean> {
const [receipt] = await db.select({ decision: statusDecisions, result: nativeRunResults })
.from(nativeRunFinalizations)
.innerJoin(statusDecisions, and(
eq(statusDecisions.id, nativeRunFinalizations.decisionId),
eq(statusDecisions.assessmentId, nativeRunFinalizations.assessmentId),
eq(statusDecisions.companyId, binding.companyId), eq(statusDecisions.issueId, binding.issueId),
eq(statusDecisions.runId, binding.runId), eq(statusDecisions.applicationState, "applied"),
eq(statusDecisions.toStatus, "in_progress"), eq(statusDecisions.reasonCode, "native_plan_accepted_waiting_for_continuation"),
))
.innerJoin(nativeRunResults, and(
eq(nativeRunResults.id, nativeRunFinalizations.resultId), eq(nativeRunResults.companyId, binding.companyId),
eq(nativeRunResults.issueId, binding.issueId), eq(nativeRunResults.runId, binding.runId), eq(nativeRunResults.schemaStatus, "accepted"),
))
.innerJoin(issues, and(
eq(issues.id, binding.issueId), eq(issues.companyId, binding.companyId), eq(issues.assigneeAgentId, binding.agentId),
eq(issues.status, "in_progress"), eq(issues.lastStatusDecisionId, statusDecisions.id), isNull(issues.hiddenAt),
sql`coalesce(${issues.executionState}->>'status', '') <> 'pending'`,
))
.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 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) ||
terminal.schema !== "paperclip.prp.terminal.v1" || terminal.runTerminalState !== "succeeded" || terminal.turnTerminalState !== "completed" || terminal.reportedWorkDisposition !== "yielded") return false;
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, binding.runId), eq(heartbeatRuns.companyId, binding.companyId))).limit(1);
if (!run || run.status !== "succeeded") return false;
// Settings for future runs cannot authorize work on this accepted plan.
// The original admission is checked above; only task-specific continuation
// or a superseding decision/run can release this committed passive wait.
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, proof.source.interactionId)).limit(1);
if (!interaction?.resolvedAt) return false;
const [newRequest, newerRun] = await Promise.all([
db.select({ id: issueComments.id }).from(issueComments).where(and(
eq(issueComments.companyId, binding.companyId), eq(issueComments.issueId, binding.issueId),
eq(issueComments.authorType, "user"), gt(issueComments.createdAt, interaction.resolvedAt),
)).limit(1),
db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, binding.companyId), eq(heartbeatRuns.nativeIssueId, binding.issueId), ne(heartbeatRuns.id, binding.runId), gt(heartbeatRuns.createdAt, run.createdAt),
)).limit(1),
]);
return newRequest.length === 0 && newerRun.length === 0;
}
@@ -1,3 +1,4 @@
import { isNativeCursorPlanWaitResult, readNativeCursorPlanWait } from "./native-cursor-plan-wait.js";
import { settleSlackConversation } from "../slack-conversation-lifecycle.js";
import { dismissAutomaticCompletionReviews } from "./automatic-completion-reviews.js";
import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-review-participant.js";
@@ -1252,7 +1253,17 @@ export async function finalizeNativeRun(input: {
companyId: run.companyId, issueId: authoritativeIssue.id, agentId: run.agentId,
contextSnapshot: reviewContext, allowResolvedByRunId: run.id,
}) : null;
const cursorPlanWait = isNativeCursorPlanWaitResult(result)
? await readNativeCursorPlanWait(input.db, { companyId: run.companyId, issueId: authoritativeIssue.id, runId: run.id, agentId: run.agentId })
: null;
// Loss of the authority behind this server-issued wait must never fall
// through to the generic response_wake auto-continuation branch.
if (isNativeCursorPlanWaitResult(result) &&
(!cursorPlanWait || nativeSha256(cursorPlanWait.result) !== nativeSha256(result))) {
throw new Error("native_cursor_plan_wait_authority_lost");
}
const proposedDecision = resolveNativeFinalizerStatus({
cursorPlanWaitAuthorized: cursorPlanWait !== null,
...(reviewContext ? { nativeReviewOutcome: nativeReview
? nativeReview.interaction.status === "pending" ? "pending" as const : "resolved" as const
: "stale" as const } : {}),
@@ -1323,6 +1334,9 @@ export async function finalizeNativeRun(input: {
priorStatusVersion: Number(authoritativeIssue.statusVersion),
priorDecisionId: authoritativeIssue.lastStatusDecisionId,
decision,
requireCursorPlanWaitSource:
decision.reasonCode === "native_plan_accepted_waiting_for_continuation"
? cursorPlanWait?.source : undefined,
requireBoardResponseWaitSource:
decision.reasonCode === "board_response_waiting"
? boardResponseWait?.source
@@ -1,3 +1,4 @@
import { readNativeCursorPlanWait } from "./native-cursor-plan-wait.js";
import { resolveAcpxQualification } from "./acpx-qualification.js";
import { readLocalAiCredentialFile } from "../local-ai-credential-file.js";
import { prepareGrokRunnerCredentials } from "./grok-runner-credentials.js";
@@ -8325,6 +8326,10 @@ async function executePaperclipNativeSessionWithinScope(
if (terminalEvent.eventType !== "turn.completed") return null;
const governedWait = await resolvePendingGovernedWait();
if (governedWait) return governedWait;
if (input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor" && input.execution.provider.cursorMode === "plan") {
const planWait = await readNativeCursorPlanWait(input.db, input.execution.binding);
if (planWait?.source.terminalEventId === terminalEvent.sourceEventId) return planWait.result;
}
const [conversation] = await input.db
.select({ agentId: issues.conversationAgentId })
.from(issues)
@@ -44,6 +44,19 @@ function arbitrate(
}
describe("native status authority", () => {
it("keeps a verified accepted Cursor plan passive without completing or replaying work", () => {
const passive = assessment({ reportedDisposition: "yielded", objectiveSatisfied: false,
allCriteriaSatisfied: false, hasBlockingRemainingWork: true,
continuation: { kind: "response_wake", summary: "Explicit continuation needed", idempotencyKey: "cursor-plan-wait:event" } });
expect(arbitrate({ assessment: passive, cursorPlanWaitAuthorized: true })).toMatchObject({
toStatus: "in_progress", reasonCode: "native_plan_accepted_waiting_for_continuation", effects: [],
});
expect(arbitrate({ assessment: passive })).toMatchObject({ reasonCode: "live_continuation_registered" });
expect(arbitrate({ assessment: passive, cursorPlanWaitAuthorized: true, terminalState: "failed" }).reasonCode).not.toBe("native_plan_accepted_waiting_for_continuation");
expect(arbitrate({ assessment: passive, cursorPlanWaitAuthorized: true, priorIssueStatus: "cancelled" }).toStatus).toBe("cancelled");
expect(arbitrate({ assessment: passive, cursorPlanWaitAuthorized: true, governanceGate: { kind: "interaction", id: "pending" } }).toStatus).toBe("in_review");
});
it("a reviewer finishes its decision without completing rejected or still-reviewed work", () => {
for (const priorIssueStatus of ["in_progress", "in_review"] as const) {
const decision = arbitrate({ priorIssueStatus, nativeReviewOutcome: "resolved" });
@@ -477,7 +490,7 @@ describe("native status authority", () => {
expect.objectContaining({
statusAction: "blocked",
toStatus: "blocked",
policyVersion: "phase6-v6",
policyVersion: "phase6-v7",
reasonCode: "current_track_blocker_waiting",
unblockDescriptor: {
owner: "board",
@@ -1,6 +1,6 @@
import type { NativeEvidenceAssessment } from "./evidence-classifier.js";
export const NATIVE_STATUS_ARBITER_POLICY_VERSION = "phase6-v6";
export const NATIVE_STATUS_ARBITER_POLICY_VERSION = "phase6-v7";
export type NativeAuthoritativeIssueStatus =
| "backlog"
@@ -108,6 +108,8 @@ export function arbitrateNativeStatus(input: {
governanceResolvedForRun?: boolean;
externalChatResponseWaitAuthorization?:
"authorized" | "revoked" | "not_applicable";
/** Server-verified accepted Cursor Plan request and normal provider terminal. */
cursorPlanWaitAuthorized?: boolean;
boardResponseWaitAuthorized?: boolean;
boardResponseWaitOrigin?: boolean;
reviewOwnerUserId?: string | null;
@@ -434,6 +436,21 @@ export function arbitrateNativeStatus(input: {
effects: [],
};
}
if (
input.cursorPlanWaitAuthorized === true &&
input.assessment.reportedDisposition === "yielded" &&
input.assessment.continuation?.kind === "response_wake"
) {
return {
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "in_progress", toStatus: "in_progress",
reasonCode: "native_plan_accepted_waiting_for_continuation",
unblockDescriptor: null,
// An answered native plan is not task completion or permission to
// change modes. Only a new explicit user cause may continue the work.
effects: [],
};
}
if (
input.assessment.reportedDisposition === "yielded" &&
input.assessment.continuation
@@ -1,3 +1,4 @@
import { readNativeCursorPlanWait, type NativeCursorPlanWaitSource } from "./native-cursor-plan-wait.js";
import { and, asc, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import type { CreateIssueThreadInteraction } from "@paperclipai/shared";
@@ -1535,6 +1536,7 @@ export async function commitNativeStatusDecision(input: {
preMaterializedEffects?: NativeMaterializedStatusEffect[];
supersedesCommittedDecisionId?: string;
requireExternalChatResponseWaitAuthorization?: { agentId: string };
requireCursorPlanWaitSource?: NativeCursorPlanWaitSource;
requireBoardResponseWaitSource?: NativeBoardResponseWaitSource;
requireBoardResponseWaitOrigin?: NativeBoardResponseWaitOrigin;
reviewResponsePresentation?: {
@@ -1641,6 +1643,32 @@ export async function commitNativeStatusDecision(input: {
throw new NativeStatusRaceError();
}
}
let cursorPlanWait: Awaited<ReturnType<typeof readNativeCursorPlanWait>> = null;
let cursorPlanWaitResult: { resultId: string; resultSha256: string } | null = null;
if (reasonCode === "native_plan_accepted_waiting_for_continuation") {
const expected = input.requireCursorPlanWaitSource;
if (!expected || expected.companyId !== input.companyId || expected.issueId !== input.issueId || expected.runId !== input.runId || input.decision.effects.length !== 0 || input.decision.statusAction !== "in_progress" || input.decision.toStatus !== "in_progress") throw new NativeStatusRaceError();
try {
cursorPlanWait = await readNativeCursorPlanWait(tx as unknown as Db, expected, true);
if (!cursorPlanWait || nativeSha256(cursorPlanWait.source) !== nativeSha256(expected)) throw new NativeStatusRaceError();
// Recheck the committed semantic result too: an arbitrary caller cannot
// use a genuine plan receipt to authorize a different finalization.
const [accepted] = await tx.select().from(nativeRunResults).where(and(
eq(nativeRunResults.id, coordinator!.resultId!), eq(nativeRunResults.companyId, input.companyId),
eq(nativeRunResults.issueId, input.issueId), eq(nativeRunResults.runId, input.runId), eq(nativeRunResults.schemaStatus, "accepted"),
)).for("share", { noWait: true });
const envelope = record(accepted?.resultJson), terminal = record(envelope.terminal);
if (!accepted || accepted.completionContractId !== cursorPlanWait.source.contractId || accepted.turnId !== cursorPlanWait.source.turnId ||
nativeSha256(envelope.result) !== nativeSha256(cursorPlanWait.result) ||
terminal.schema !== "paperclip.prp.terminal.v1" || terminal.runTerminalState !== "succeeded" || terminal.turnTerminalState !== "completed" || terminal.reportedWorkDisposition !== "yielded") throw new NativeStatusRaceError();
// completeRun hashes its full private binding, not the stored resultJson.
// Preserve that accepted identity instead of inventing a new digest.
cursorPlanWaitResult = { resultId: accepted.id, resultSha256: accepted.canonicalSha256 };
} catch (error) {
if (isExternalChatWaitAuthorizationContention(error)) throw new NativeStatusRaceError();
throw error;
}
}
let boardResponseWaitOrigin: NativeBoardResponseWaitOrigin | null = null;
if (
reasonCode === "board_response_waiting" ||
@@ -1754,6 +1782,7 @@ export async function commitNativeStatusDecision(input: {
? { boardResponseWait: boardResponseWait.source }
: {}),
...(boardResponseWaitOrigin ? { boardResponseWaitOrigin } : {}),
...(cursorPlanWait ? { cursorPlanWait: cursorPlanWait.source, cursorPlanWaitResult } : {}),
priorStatusVersion: input.priorStatusVersion,
projectedStatusVersion:
input.decision.statusAction === "preserve"
@@ -1822,6 +1851,12 @@ export async function commitNativeStatusDecision(input: {
}
if (!decisionRow) throw new Error("native_status_decision_not_persisted");
if (cursorPlanWait) {
await issueService(tx as unknown as Db).addComment(
input.issueId, cursorPlanWait.result.summary,
{ agentId: cursorPlanWait.source.agentId, runId: input.runId }, undefined, tx,
);
}
if (boardResponseWait) {
// The answer and passive-wait receipt commit together. In particular,
// no chat presentation authorization is supplied: this is Board-only.
+4
View File
@@ -1,3 +1,4 @@
import { hasCommittedNativeCursorPlanWait } from "../native-runtime/native-cursor-plan-wait.js";
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
import { settleSlackConversation } from "../slack-conversation-lifecycle.js";
import { externalConversationStateSql } from "../slack-conversation-state.js";
@@ -1169,6 +1170,7 @@ export function recoveryService(
runId: latestRun.id,
agentId: latestRun.agentId,
};
if (await hasCommittedNativeCursorPlanWait(db, binding)) return true;
const [receipt] = await db
.select({
run: heartbeatRuns,
@@ -1367,6 +1369,8 @@ export function recoveryService(
latestRun: LatestIssueRun,
) {
if (issue.monitorNextCheckAt) return true;
if (issue.status === "in_progress" && latestRun?.status === "succeeded" && latestRun.agentId === issue.assigneeAgentId &&
await hasCommittedNativeCursorPlanWait(db, { companyId: issue.companyId, issueId: issue.id, runId: latestRun.id, agentId: latestRun.agentId })) return true;
if (
issue.status === "in_progress" &&
latestRun?.status === "succeeded" &&
+2 -2
View File
@@ -26,7 +26,7 @@ export function cursorNativePrompt(id: typeof cursorNativeCaseDesigns[number]["i
].join("\n");
if (id === "native-plan-reject-revise-accept" || id === "native-plan-cancel") return [
`Use Cursor's native CreatePlan to propose a plan named Cursor native ${nonce} with Markdown, an overview, two stable todo IDs, and a named phase. Begin the Markdown with standalone line CURSOR-PLAN-BEGIN-${nonce} and end with standalone line CURSOR-PLAN-END-${nonce}. Preserve both boundary lines on revision. Do not perform implementation before acceptance.`,
"Wait for the actual native decision. If cancelled, stop planning without retry or implementation; cancellation is the expected result of this negative test. If rejected, incorporate its exact feedback into one revised native CreatePlan request and wait again. Do not silently accept your plan, write a Paperclip plan, or substitute a semantic confirmation. If unavailable, report that limitation without claiming completion.", finish,
"Wait for the actual native decision. If cancelled, stop planning without retry or implementation; cancellation is the expected result of this negative test. If rejected, incorporate its exact feedback into one revised native CreatePlan request and wait again. Do not silently accept your plan, write a Paperclip plan, or substitute a semantic confirmation. If unavailable, report that limitation without claiming completion.", id === "native-plan-reject-revise-accept" ? "After the revised plan is accepted, end this planning turn. Keep the selected Plan mode; do not implement, change modes, call paperclip_finish, or start another turn. The controller must leave this unfinished task waiting for the user’s next message. Do not substitute a semantic confirmation or completion call." : finish,
].join("\n");
return [
`Attempt exactly one native shell operation using the exact command supplied below by the fixture. This is a deliberate denial test. Wait for the actual permission decision. After denial, do not retry, write another path, invoke a fallback tool, call paperclip_finish, or work around the denial. Wait for the operator to cancel this unfinished task. Do not substitute another tool or operation.`,
@@ -106,7 +106,7 @@ export function hasCursorDenialBoundary(input: {
export const cursorNativeTasks: readonly (Omit<RunnerTaskFixture, "flow"> & { flow: "cursor_native" })[] = cursorNativeCaseDesigns.map(design => ({
id: design.id, label: `Cursor ${design.id}`, groups: [], workMode: "standard", flow: "cursor_native",
expectedRunCount: 1, attemptTimeoutMs: { local: 300_000, daytona: 300_000 }, turnTimeoutMs: 120_000,
expectedTerminalState: design.id === "native-write-deny-reconnect" ? { issue: "in_progress", run: "cancelled" } : { issue: "done", run: "succeeded" },
expectedTerminalState: design.id === "native-write-deny-reconnect" ? { issue: "in_progress", run: "cancelled" } : design.id === "native-plan-reject-revise-accept" ? { issue: "in_progress", run: "succeeded" } : { issue: "done", run: "succeeded" },
buildTitle: nonce => `Cursor ${design.id} ${nonce}`,
buildPrompt: nonce => cursorNativePrompt(design.id, nonce),
buildVisibleMarker: nonce => `CURSOR-NATIVE-${nonce}`,
+20 -1
View File
@@ -2,7 +2,7 @@ import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { cursorNativeWorkspaceSnapshot } from "./cursor-native-flow.js";
import { cursorNativeWorkspaceSnapshot, hasCursorAcceptedPlanWait } from "./cursor-native-flow.js";
import { cursorNativeCaseDesigns, cursorNativePlanArtifactGate, cursorNativeTasks } from "./cursor-native-cases.js";
it("keeps native mode/permission choices explicit and artifact export pending", () => {
@@ -60,3 +60,22 @@ it("refuses remote native execution before touching API when bootstrap or cleanu
} as any)).rejects.toThrow(/owned pre-action observer/);
expect(apiCalls).toBe(0);
});
it("requires the exact passive accepted-plan disposition without mode promotion or extra work", () => {
const state = { issue: { id: "issue", status: "in_progress" }, interactions: [{ status: "answered" }], runs: [{ id: "run", nativeIssueId: "issue", runtimeMode: "native", status: "succeeded", runnerProfileJson: { nativeExecutionInput: { provider: { cursorMode: "plan" } } }, resultJson: { finalizationPhase: "committed", finalizationReasonCode: "native_plan_accepted_waiting_for_continuation", authoritativeDecision: "in_progress" } }] };
expect(hasCursorAcceptedPlanWait(state)).toBe(true);
const mutations = [
(s: typeof state) => { s.issue.status = "done"; },
(s: typeof state) => { s.runs[0]!.status = "failed"; },
(s: typeof state) => { s.runs[0]!.nativeIssueId = "foreign"; },
(s: typeof state) => { s.runs[0]!.runnerProfileJson.nativeExecutionInput.provider.cursorMode = "agent"; },
(s: typeof state) => { s.runs[0]!.resultJson.finalizationReasonCode = "live_continuation_registered"; },
(s: typeof state) => { s.runs[0]!.resultJson.finalizationPhase = "pending"; },
(s: typeof state) => { s.runs.push(structuredClone(s.runs[0]!)); },
(s: typeof state) => { s.interactions[0]!.status = "pending"; },
];
for (const mutate of mutations) { const changed = structuredClone(state); mutate(changed); expect(hasCursorAcceptedPlanWait(changed)).toBe(false); }
expect(cursorNativeTasks.find(task => task.id === "native-plan-reject-revise-accept")!.expectedTerminalState).toEqual({ issue: "in_progress", run: "succeeded" });
const prompt = cursorNativeTasks.find(task => task.id === "native-plan-reject-revise-accept")!.buildPrompt("test");
expect(prompt).toContain("do not implement, change modes, call paperclip_finish, or start another turn");
});
+43 -2
View File
@@ -29,6 +29,17 @@ export function hasCursorNativeCardBinding(card: Row, event: Row, runId: string)
&& isDeepStrictEqual(card.payload.questionSet, event.payload.request.input);
}
/** This is a successful planning boundary, not an implementation/completion claim. */
export function hasCursorAcceptedPlanWait(state: { issue: Row; runs: Row[]; interactions: Row[] }): boolean {
if (state.runs.length !== 1 || state.issue.status !== "in_progress" || state.interactions.some(card => card.status === "pending")) return false;
const run = state.runs[0]!;
return run.status === "succeeded" && run.runtimeMode === "native" && run.nativeIssueId === state.issue.id
&& run.runnerProfileJson?.nativeExecutionInput?.provider?.cursorMode === "plan"
&& run.resultJson?.finalizationPhase === "committed"
&& run.resultJson?.finalizationReasonCode === "native_plan_accepted_waiting_for_continuation"
&& run.resultJson?.authoritativeDecision === "in_progress";
}
export interface CursorRemoteNativeFixture {
binding: CursorRemoteBinding; remoteCwd: string; actionFile: string;
snapshot(label: string): Promise<CursorRemoteSnapshot>;
@@ -96,7 +107,7 @@ export async function runCursorNativeFlow(input: {
const remote = execution.environment.id === "daytona";
if (!design || !["local", "daytona"].includes(execution.environment.id) || execution.profile.qualificationCandidate !== "cursor") throw new Error("Cursor native fixtures require an explicit isolated Cursor candidate");
if (remote && (!input.remoteBootstrap || !input.registerBeforeEnvironmentTeardownAssertion)) throw new Error("Cursor Daytona requires an owned pre-action observer and pre-teardown verification");
if (!remote && design.id === "native-write-deny-reconnect" && !input.registerCleanupAssertion) throw new Error("Cursor denial requires authoritative post-cleanup verification");
if (!remote && ["native-write-deny-reconnect", "native-plan-reject-revise-accept"].includes(design.id) && !input.registerCleanupAssertion) throw new Error("Cursor denial requires authoritative post-cleanup verification");
const checks: Check[] = []; let issue: Row = {}; let runs: Row[] = [];
const check = (id: string, passed: boolean, detail: string) => { checks.push({ id, passed, detail }); expect(passed, detail).toBe(true); };
const events = (runId: string) => collectRunEvents<Row>((afterSeq, limit) => api.get(`/api/heartbeat-runs/${runId}/events?afterSeq=${afterSeq}&limit=${limit}`));
@@ -195,7 +206,8 @@ export async function runCursorNativeFlow(input: {
await sampleDenied("after-cleanup", snapshot);
finalCheck("remote-cancel-terminal", cancellationProven && runs[0]?.status === "cancelled" && issue.status === "in_progress", "Explicit native cancellation remains durable without false task completion");
finalCheck("remote-no-denied-effect", snapshot.watcher.targetMutationCount === 0 && Boolean(deniedRequest) && hasCursorDenialBoundary({ request: deniedRequest, expectedRequestId: deniedRequest?.requestId ?? "", expectedToolCallId: deniedRequest?.details.toolCallId ?? "", path: deniedPath, bootstrapReadProof: remoteFixture ? { actionFile: remoteFixture.actionFile, events: denialRunEvents } : undefined, samples, notices: denialNotices, runId: runs[0]?.id ?? "", turnId: denialTurnId }), "Exact denied native command caused no remote file effect through provider retirement");
} else if (design.id !== "native-plan-reject-revise-accept") {
} else {
if (design.id === "native-plan-reject-revise-accept") finalCheck("remote-plan-still-passive", hasCursorAcceptedPlanWait(await load()), "Accepted planning remains passive through remote retirement without an automatic follow-up run");
finalCheck("remote-no-workspace-effects", hasCursorRemoteWorkspaceUnchanged(snapshot, remoteBaseline!), "Native question or cancelled plan caused no remote workspace mutation through retirement");
}
if (cleanupChecks.some(row => !row.passed)) throw new Error("Cursor remote cleanup evidence is incomplete or observed an effect");
@@ -216,6 +228,17 @@ export async function runCursorNativeFlow(input: {
if (design.id === "native-write-deny-reconnect" && runs.length === 1) { denialRunEvents = runEvents; denialNotices = readCursorToolEvidence(runEvents, runs[0]!.id); }
return { issue, runs, interactions, runEvents };
};
if (!remote && design.id === "native-plan-reject-revise-accept") input.registerCleanupAssertion!(async () => {
const current = await load();
const currentWorkspace = await cursorNativeWorkspaceSnapshot(input.workspacePath);
const cleanupChecks = [
{ id: "plan-still-passive-after-cleanup", passed: hasCursorAcceptedPlanWait(current), detail: "One succeeded planning run remains unfinished without automatic follow-up through fixture cleanup" },
{ id: "plan-workspace-unchanged-after-cleanup", passed: isDeepStrictEqual(currentWorkspace, baseline), detail: "Accepted planning caused no workspace effect through fixture cleanup" },
];
await input.evidence("cursor-native-plan-wait-cleanup.json", { ...current, baseline, currentWorkspace, checks: cleanupChecks });
if (cleanupChecks.some(row => !row.passed)) throw new Error("Accepted Cursor plan did not remain a passive no-effect boundary");
return cleanupChecks;
});
const reject = (state: Awaited<ReturnType<typeof load>>) => state.runs.length > 1 ? "Unexpected extra Cursor provider run" : state.runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status)) ? "Cursor provider run failed" : undefined;
async function pending(seen: Set<string>) {
const state = await pollUntil({ label: "exact native Cursor callback", deadlineAt: input.deadlineAt, load,
@@ -337,6 +360,24 @@ export async function runCursorNativeFlow(input: {
await input.capture("final-state", "Cursor denied command cancelled; task remains unfinished", "final-state.png");
return { issue, runs, checks };
}
if (design.id === "native-plan-reject-revise-accept") {
await pollUntil({ label: "accepted Cursor plan waiting for explicit continuation", deadlineAt: input.deadlineAt, load, reject, accept: hasCursorAcceptedPlanWait });
const observedAt = Date.now();
const final = await pollUntil({ label: "passive Cursor plan stability", deadlineAt: input.deadlineAt, load,
reject: state => hasCursorAcceptedPlanWait(state) ? undefined : "Accepted plan started follow-up work or lost its passive disposition",
accept: state => hasCursorAcceptedPlanWait(state) && Date.now() - observedAt >= 2_000, intervalMs: 250 });
if (remote) remoteFinal = await remoteFixture!.finish();
await sampleWorkspace("accepted-plan-terminal");
check("accepted-plan-passive-terminal", hasCursorAcceptedPlanWait(final), "Successful planning remains in progress with exact controller wait reason and selected Plan mode");
const comments = await api.get<Row[]>(`/api/issues/${issue.id}/comments`);
const summary = "Plan accepted. This task is waiting for your next message. This run used Plan mode; no implementation or task completion is claimed.";
check("explicit-plan-next-action", comments.some(comment => comment.createdByRunId === runs[0]!.id && comment.body === summary), "The original planning run durably presents explicit user continuation");
await page.reload(); await expect(page.getByText(summary, { exact: true }).last()).toBeVisible();
await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: "Change status (current: In Progress)", exact: true })).toBeVisible();
await input.evidence("api-state.json", { ...final, run: runs[0], comments, checks, passiveObservedFrom: observedAt, passiveObservedUntil: Date.now(), runEventsByRun: [{ runId: runs[0]!.id, events: final.runEvents }] });
await input.capture("final-state", "Accepted Cursor plan waiting for the next user message", "final-state.png");
return { issue, runs, checks };
}
const final = await pollUntil({ label: "Cursor native completion", deadlineAt: input.deadlineAt, load, reject,
accept: state => state.issue.status === "done" && state.runs.length === 1 && state.runs[0]!.status === "succeeded" && !state.interactions.some(card => card.status === "pending") });
if (remote) remoteFinal = await remoteFixture!.finish();