fix: stop completion reviews caused by policy upgrades (#13266)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The native runtime records completion assessments and task
decisions.
> - An application update can change the assessment policy version.
> - The previous code treated that version change as a reason for human
review.
> - An upgrade alone does not give the user a new decision to make.
> - This change keeps existing decisions and withdraws obsolete upgrade
review cards.

## Linked Issues or Issue Description

**What happened?**

A rules version change moved unfinished tasks into review and created a
card that said, "Review the superseding native policy assessment." The
task did not need new work or a human decision.

**Expected behavior**

New runs use the current rules. An upgrade leaves existing task
decisions alone. The saved policy version remains available in the audit
history.

**Steps to reproduce**

1. Save a native run assessment and a task decision.
2. Change the native status policy version.
3. Run finalization reconciliation without new task evidence.
4. The previous code created a review card. The corrected code keeps the
saved assessment and decision.

Related context: #13038 changed the native policy version. Searches
found no duplicate fix for upgrade-only completion reviews.

## What Changed

- Remove policy-version mismatch as a reconciliation trigger.
- Withdraw pending cards only when their source decision, effect ledger,
creator, key, and prompt match the old upgrade-only review.
- Restore the previous status only while that decision and status
version remain current and no other review gate is pending.
- Preserve answered cards, real review requests, later task changes, and
historical assessments and decisions.
- Record cleanup activity and retire any corresponding chat review
actions.
- Isolate cleanup failures so one old card cannot block other cleanup or
normal finalization.
- Update the status conformance fixture, regression tests, and
architecture documentation.

## Verification

- Passed: `pnpm exec vitest run
server/src/__tests__/native-status-arbiter-corpus.test.ts` (23 tests,
including the 53-fixture status corpus).
- Passed: `pnpm -r typecheck`.
- Passed: `git diff --check`.
- Passed: `pnpm build`.
- Passed: fresh Greptile review at 5/5 on `d24e5ecaf`, with no open
review threads.
- Passed: the 23 focused tests in the isolated full-suite environment.
- Local `pnpm test:run`: the server group finished with 10,638 passed
and one missing-fixture failure. Built the required
`fake-codex-app-server` fixture and reran the entire affected native
session-resume suite: 37/37 passed. The initial full command exited on
that server-group failure, so remaining groups are covered by CI.
- Passed: the workspace-runtime-exposure suite (25 tests, 3 platform
skips).
- Passed: all CI gates, including every test shard, browser tests,
typecheck, and build. The server shard passed on one retry after an
unrelated host-port conflict in the unchanged workspace-exposure tests.
- Cleanup tests cover repeat runs, real reviews, answered cards, later
statuses, newer decision identity, status changes back to review, other
pending requests, run scope, cleanup failures, retry, and live
publication failures.

## Risks

- Cleanup changes stored task state. It checks the exact obsolete
decision and status version under database locks before restoring
status.
- Pending approvals, interactions, and execution stages prevent
restoration out of review.
- The cleanup handles at most 100 matching cards per reconciliation
pass. It does not rewrite old decisions or accept an agent's completion
claim.
- New evidence and explicit task changes still use the existing
reconciliation paths. This change does not reevaluate old work merely
because Paperclip was updated.

## Model Used

OpenAI Codex, GPT-6. The exact deployment identifier and context window
are not exposed in this session. Used reasoning, repository inspection,
code editing, shell execution, and automated tests.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 16:55:46 -05:00 committed by GitHub
parent 19c76bfc3f
commit a12bbd1824
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 388 additions and 60 deletions

View File

@ -220,6 +220,19 @@ Examples:
- a materialization failure records the failed phase and next retry time rather
than silently dropping the side effect.
## Policy upgrades
The policy version on an assessment is audit metadata. New runs use the current
rules. A version change alone does not reassess an old run, change task status,
or ask a person to review completion. New evidence and explicit status changes
still use the existing reconciliation paths.
Reconciliation also withdraws pending review cards created solely by the old
policy-version check. It restores the previous status only if that exact decision
and status version are still current and no other review gate is pending. A later
user or agent decision takes precedence. The old assessments and decisions remain
in the audit history; cleanup does not accept or reject the agent's work.
## Diagnosing an unexpected status
Start with the terminal heartbeat run and inspect:

View File

@ -341,9 +341,9 @@
"mode": "native",
"covers": { "decisionRows": [], "terminalRows": [], "attentionRows": [], "livenessRows": [], "reconciliationRows": ["REC-08"], "compatibilityRows": [], "migrationRows": [] },
"tags": ["supersession", "reconciliation", "deterministic_replay"],
"given": { "priorIssueStatus": "in_progress", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "done", "nativeFinalization": "present", "completionState": "new_policy_requires_review", "trigger": "authorized_agent" },
"expected": { "runStatus": "succeeded", "statusAction": "in_review", "reasonCode": "completion_review_required", "requiredEffects": ["bind_reviewer", "append_superseding_assessment"], "forbiddenEffects": ["mutate_old_decision"], "livePathKind": "review", "preserveClaim": true, "nativeRecords": true, "decisionCount": 2, "maxWakeCount": 1, "maxNotificationCount": 1 },
"replay": { "attempts": 2, "sameDecisionDigest": false, "maxSemanticDecisions": 2, "maxDomainEffectsPerKey": 1 }
"given": { "priorIssueStatus": "in_progress", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "done", "nativeFinalization": "present", "completionState": "policy_version_changed", "trigger": "authorized_agent" },
"expected": { "runStatus": "succeeded", "statusAction": "preserve", "reasonCode": "prior_fixture_decision", "requiredEffects": [], "forbiddenEffects": ["mutate_old_decision", "bind_reviewer", "append_superseding_assessment"], "livePathKind": null, "preserveClaim": true, "nativeRecords": true, "decisionCount": 1, "maxWakeCount": 0, "maxNotificationCount": 0 },
"replay": { "attempts": 2, "sameDecisionDigest": true, "maxSemanticDecisions": 1, "maxDomainEffectsPerKey": 1 }
},
{
"id": "legacy-adapter-unchanged",

View File

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { and, eq } from "drizzle-orm";
import {
agents,
@ -63,6 +63,8 @@ import {
reconcileNativeFinalizations,
resolveNativeReconciliationStatus,
} from "../services/native-runtime/native-finalization-reconciler.js";
import * as activityLog from "../services/activity-log.js";
import { dismissObsoleteNativePolicyReviews } from "../services/native-runtime/obsolete-policy-reviews.js";
import { issueService } from "../services/issues.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
@ -242,11 +244,11 @@ const nativeStatusEffectKinds = new Set<NativeStatusEffect["kind"]>([
const supersedingDecisionStates = new Set([
"new_evidence_satisfies_contract", "dependency_now_done", "explicit_resume_capability",
"board_cancelled_before_cas", "new_policy_requires_review", "authorized_writer_incremented_version",
"board_cancelled_before_cas", "authorized_writer_incremented_version",
]);
const liveReconciliationStates = new Set([
"board_cancelled_before_cas", "new_evidence_satisfies_contract", "new_policy_requires_review",
"board_cancelled_before_cas", "new_evidence_satisfies_contract", "policy_version_changed",
]);
function initialRunStatus(fixture: Fixture) {
@ -319,7 +321,6 @@ function reconciliationFactsFor(completionState: string) {
case "new_evidence_satisfies_contract": return { newEvidenceSatisfiesContract: true };
case "dependency_now_done": return { dependencyResolved: true };
case "explicit_resume_capability": return { authorizedResume: true };
case "new_policy_requires_review": return { policyVersionChanged: true };
case "authorized_writer_incremented_version": return { statusVersionAdvanced: true };
default: return null;
}
@ -529,7 +530,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
triggerActorCompanyId: companyId,
priorIssueStatus: completionState === "board_cancelled_before_cas" ? "in_progress" : priorStatus,
priorStatusVersion: 0,
policyVersion: completionState === "new_policy_requires_review"
policyVersion: completionState === "policy_version_changed"
? "phase6-v1"
: NATIVE_STATUS_ARBITER_POLICY_VERSION,
assessmentJson: {
@ -955,7 +956,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
issueId: seeded.issueId,
assessmentId: seeded.assessmentId,
decisionVersion: 1,
policyVersion: completionState === "new_policy_requires_review"
policyVersion: completionState === "policy_version_changed"
? "phase6-v1"
: NATIVE_STATUS_ARBITER_POLICY_VERSION,
fromStatus: completionState === "board_cancelled_before_cas" ? "in_progress" : priorIssueStatus,
@ -980,20 +981,43 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
updatedAt: new Date(Date.now() + 1_000),
}).where(eq(issueWorkProducts.id, seeded.workProductId));
}
const [reconciled] = await reconcileNativeFinalizations(db, [seeded.runId]);
if (!reconciled?.reconciliationDecision || !reconciled.decisionId) {
throw new Error(`${fixture.id}: live reconciliation did not commit an authoritative decision`);
if (completionState === "policy_version_changed") {
await db.insert(workspaceOperations).values({
companyId, heartbeatRunId: seeded.runId, issueId: seeded.issueId,
phase: "workspace_finalize", status: "succeeded", exitCode: 0, cwd: process.cwd(), finishedAt: new Date(),
});
}
const [reconciled] = await reconcileNativeFinalizations(db, [seeded.runId]);
if (completionState === "policy_version_changed") {
const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, seeded.issueId));
const assessments = await db.select().from(workAssessments).where(eq(workAssessments.issueId, seeded.issueId));
expect(decisions).toHaveLength(1);
expect(decisions[0]!.id).toBe(priorDecision!.id);
expect(assessments).toHaveLength(1);
expect(assessments[0]!.policyVersion).toBe("phase6-v1");
semanticConsumer = "native-reconciliation-consumer";
consumerDecision = pushDecisionConsumer(semanticConsumer, {
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "preserve", toStatus: decisions[0]!.toStatus as NativeStatusDecision["toStatus"],
reasonCode: decisions[0]!.reasonCode, unblockDescriptor: null, effects: [],
});
liveEntrypointCommitted = true;
consumerExecutions.push({ consumer: "native-reconciliation-entrypoint", observed: { decisionId: priorDecision!.id } });
} else {
if (!reconciled?.reconciliationDecision || !reconciled.decisionId) {
throw new Error(`${fixture.id}: live reconciliation did not commit an authoritative decision`);
}
semanticConsumer = "native-reconciliation-consumer";
consumerDecision = pushDecisionConsumer(semanticConsumer, reconciled.reconciliationDecision);
liveEntrypointCommitted = true;
consumerExecutions.push({
consumer: "native-reconciliation-entrypoint",
observed: {
action: reconciled.reconciliationAction,
decisionId: reconciled.decisionId,
},
});
}
semanticConsumer = "native-reconciliation-consumer";
consumerDecision = pushDecisionConsumer(semanticConsumer, reconciled.reconciliationDecision);
liveEntrypointCommitted = true;
consumerExecutions.push({
consumer: "native-reconciliation-entrypoint",
observed: {
action: reconciled.reconciliationAction,
decisionId: reconciled.decisionId,
},
});
}
if (
seeded.nativeRecords
@ -1858,7 +1882,10 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
];
for (const [field, expected] of mutations) {
const mutated = { ...fixture, expected };
expect(comparisonFailures(mutated, observed), `${fixture.id}:${field}`).not.toEqual([]);
const mutationObserved = field === "forbiddenEffects" && observed.effects.length === 0
? { ...observed, effects: [observedEffect] }
: observed;
expect(comparisonFailures(mutated, mutationObserved), `${fixture.id}:${field}`).not.toEqual([]);
}
}
}, 60_000);
@ -1973,24 +2000,177 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
.rejects.toThrow("native_pending_effect_target_missing:enqueue_continuation");
}, 30_000);
it("preserves terminal issues when a newer reconciliation policy is available", () => {
expect(resolveNativeReconciliationStatus({
facts: { policyVersionChanged: true },
priorIssueStatus: "done",
agentId,
})).toMatchObject({
statusAction: "preserve",
toStatus: "done",
reasonCode: "prior_status_terminal_preserved",
effects: [{ kind: "append_superseding_assessment" }],
async function seedPolicyReview(options: { genuine?: boolean; priorStatus?: "in_progress" | "blocked" | "in_review" } = {}) {
const template = corpus.fixtures.find((candidate) => candidate.mode === "native")!;
const priorStatus = options.priorStatus ?? "in_progress";
const seeded = await seedFixture({
...template, id: `policy-review-${randomUUID()}`,
given: { ...template.given, priorIssueStatus: priorStatus, completionState: "policy_review_cleanup" },
});
const [assessment] = await db.select().from(workAssessments).where(eq(workAssessments.id, seeded.assessmentId));
const previousAssessmentId = randomUUID();
await db.insert(workAssessments).values({
...assessment!, id: previousAssessmentId, policyVersion: "previous-policy",
inputDigest: `previous-assessment:${seeded.issueId}`,
});
await db.update(workAssessments).set({ supersedesAssessmentId: previousAssessmentId })
.where(eq(workAssessments.id, seeded.assessmentId));
const committed = await commitNativeStatusDecision({
db, companyId, issueId: seeded.issueId, runId: seeded.runId,
assessmentId: seeded.assessmentId, priorStatus, priorStatusVersion: 0, priorDecisionId: null,
decision: {
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "in_review", toStatus: "in_review", reasonCode: "completion_review_required", unblockDescriptor: null,
effects: options.genuine
? [{ kind: "bind_reviewer", prompt: "Review the release before publishing.", ownerUserId: null }]
: [
{ kind: "bind_reviewer", prompt: "Review the superseding native policy assessment.", ownerUserId: null },
{ kind: "append_superseding_assessment" },
],
},
});
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, seeded.issueId));
const [decision] = await db.select().from(statusDecisions).where(eq(statusDecisions.id, committed.decision.id));
await db.insert(workspaceOperations).values({
companyId, heartbeatRunId: seeded.runId, issueId: seeded.issueId,
phase: "workspace_finalize", status: "succeeded", exitCode: 0, cwd: process.cwd(), finishedAt: new Date(),
});
return { ...seeded, decision: decision!, interaction: interaction! };
}
it("withdraws obsolete policy reviews, restores the prior status, and is idempotent", async () => {
const seeded = await seedPolicyReview();
await reconcileNativeFinalizations(db, [seeded.runId]);
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
expect(interaction).toMatchObject({ status: "cancelled", result: { outcome: "withdrawn" } });
expect(issue).toMatchObject({ status: "in_progress", statusVersion: 2, lastStatusDecisionId: null });
const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, seeded.issueId));
expect(decisions.find((row) => row.id === seeded.decision.id)).toEqual(seeded.decision);
await reconcileNativeFinalizations(db, [seeded.runId]);
expect(await db.select().from(issues).where(eq(issues.id, seeded.issueId))).toEqual([issue]);
expect(await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, seeded.issueId))).toEqual(decisions);
}, 30_000);
it.each(["accepted", "rejected"])("leaves an already %s review untouched", async (status) => {
const seeded = await seedPolicyReview();
await db.update(issueThreadInteractions).set({ status }).where(eq(issueThreadInteractions.id, seeded.interaction.id));
await dismissObsoleteNativePolicyReviews(db, [seeded.runId]);
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
expect(issue).toMatchObject({ status: "in_review", lastStatusDecisionId: seeded.decision.id });
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
expect(interaction!.status).toBe(status);
});
it("preserves real completion reviews and limits cleanup to the requested runs", async () => {
const genuine = await seedPolicyReview({ genuine: true });
const other = await seedPolicyReview();
await dismissObsoleteNativePolicyReviews(db, [genuine.runId]);
for (const seeded of [genuine, other]) {
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
expect(interaction!.status).toBe("pending");
}
});
it.each(["blocked", "done", "cancelled"])("dismisses the obsolete card without undoing a later %s status", async (status) => {
const seeded = await seedPolicyReview();
await issueService(db).update(seeded.issueId, { status });
const before = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
await dismissObsoleteNativePolicyReviews(db, [seeded.runId]);
expect(await db.select().from(issues).where(eq(issues.id, seeded.issueId))).toEqual(before);
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
expect(interaction!.status).toBe(status === "blocked" ? "cancelled" : "expired");
});
it("does not restore status after a newer decision or a status change back to review", async () => {
for (const changedPointer of [false, true]) {
const seeded = await seedPolicyReview();
if (changedPointer) {
await db.update(issues).set({ lastStatusDecisionId: null }).where(eq(issues.id, seeded.issueId));
} else {
await issueService(db).update(seeded.issueId, { status: "in_progress" });
await issueService(db).update(seeded.issueId, { status: "in_review" });
}
const before = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
await dismissObsoleteNativePolicyReviews(db, [seeded.runId]);
expect(await db.select().from(issues).where(eq(issues.id, seeded.issueId))).toEqual(before);
}
});
it("keeps review status when a separate request still needs a response", async () => {
const seeded = await seedPolicyReview();
await db.insert(issueThreadInteractions).values({
companyId, issueId: seeded.issueId, kind: "request_confirmation", status: "pending",
payload: { version: 1, prompt: "Approve publishing the release." },
});
await dismissObsoleteNativePolicyReviews(db, [seeded.runId]);
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
expect(issue!.status).toBe("in_review");
});
it("isolates a failed cleanup candidate and retries it on the next pass", async () => {
const first = await seedPolicyReview();
const second = await seedPolicyReview();
const runIds = [first.runId, second.runId];
const transaction = vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("injected cleanup failure"));
try {
await expect(dismissObsoleteNativePolicyReviews(db, runIds)).resolves.toBeUndefined();
} finally {
transaction.mockRestore();
}
const statuses = await Promise.all([first, second].map(async (seeded) => {
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
return interaction!.status;
}));
expect(statuses.sort()).toEqual(["cancelled", "pending"]);
await dismissObsoleteNativePolicyReviews(db, runIds);
for (const seeded of [first, second]) {
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
expect(issue!.status).toBe("in_progress");
}
});
it("keeps committed cleanup and continues publication after a live event fails", async () => {
const first = await seedPolicyReview();
const second = await seedPolicyReview();
const publish = vi.spyOn(activityLog, "publishActivity").mockImplementationOnce(() => {
throw new Error("injected live publication failure");
});
try {
await dismissObsoleteNativePolicyReviews(db, [first.runId, second.runId]);
expect(publish.mock.calls.length).toBeGreaterThan(1);
for (const seeded of [first, second]) {
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, seeded.interaction.id));
expect(issue!.status).toBe("in_progress");
expect(interaction!.status).toBe("cancelled");
}
} finally {
publish.mockRestore();
}
});
it("continues native finalization when the obsolete-card lookup fails", async () => {
const seeded = await seedPolicyReview({ genuine: true });
const select = vi.spyOn(db, "select").mockImplementationOnce(() => {
throw new Error("injected cleanup lookup failure");
});
try {
const reconciled = await reconcileNativeFinalizations(db, [seeded.runId]);
expect(reconciled).toHaveLength(1);
expect(reconciled[0]!.phase).toBe("committed");
} finally {
select.mockRestore();
}
});
it("preserves a later authoritative status during reconciliation", () => {
expect(resolveNativeReconciliationStatus({
facts: { authoritativeStatusChanged: true, policyVersionChanged: true },
facts: { authoritativeStatusChanged: true },
priorIssueStatus: "blocked",
agentId,
})).toMatchObject({
statusAction: "preserve",
toStatus: "blocked",
statusAction: "preserve", toStatus: "blocked",
reasonCode: "prior_status_terminal_preserved",
});
});

View File

@ -1,3 +1,4 @@
import { logger } from "../../middleware/logger.js";
import { createHash, randomUUID } from "node:crypto";
import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, lte, notInArray, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@ -29,6 +30,7 @@ import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { issueService } from "../issues.js";
import { emitAgentTaskRun } from "../agent-task-run-telemetry.js";
import { resumeNativeWorkspaceFinalization } from "./native-workspace-finalizer.js";
import { dismissObsoleteNativePolicyReviews } from "./obsolete-policy-reviews.js";
import {
cleanupNativeWorkspaceSync,
readNativeWorkspaceSyncReference,
@ -56,7 +58,6 @@ export type NativeReconciliationFacts = {
newEvidenceSatisfiesContract?: boolean;
dependencyResolved?: boolean;
authorizedResume?: boolean;
policyVersionChanged?: boolean;
statusVersionAdvanced?: boolean;
};
@ -110,22 +111,6 @@ export function resolveNativeReconciliationStatus(input: {
if (input.facts.authoritativeStatusChanged) {
return preserve("prior_status_terminal_preserved", [{ kind: "append_superseding_assessment" }]);
}
if (input.facts.policyVersionChanged) {
if (["done", "cancelled"].includes(input.priorIssueStatus)) {
return preserve("prior_status_terminal_preserved", [{ kind: "append_superseding_assessment" }]);
}
return {
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "in_review",
toStatus: "in_review",
reasonCode: "completion_review_required",
unblockDescriptor: null,
effects: [
{ kind: "bind_reviewer", prompt: "Review the superseding native policy assessment.", ownerUserId: null },
{ kind: "append_superseding_assessment" },
],
};
}
if (input.facts.statusVersionAdvanced) {
return preserve("arbitration_conflict_reloaded", [
{ kind: "increment_status_version" },
@ -552,6 +537,9 @@ export async function reconcileNativeFinalizations(
}) => Promise<void>;
} = {},
) {
await dismissObsoleteNativePolicyReviews(db, runIds).catch((err) => {
logger.warn({ err }, "Obsolete native policy review lookup failed; continuing native reconciliation");
});
const rows = await db
.select({
runId: heartbeatRuns.id,
@ -643,7 +631,6 @@ export async function reconcileNativeFinalizations(
),
)).limit(1).then((entries) => entries[0] ?? null)
: null;
const policyVersionChanged = assessment?.policyVersion !== NATIVE_STATUS_ARBITER_POLICY_VERSION;
const currentDecision = row.decisionId
? await db.select({
assessmentId: statusDecisions.assessmentId,
@ -715,7 +702,7 @@ export async function reconcileNativeFinalizations(
let reassessment = null;
let resultRow = null;
let contractRow = null;
if (assessment && (policyVersionChanged || authoritativeStatusChanged || changedEvidence)) {
if (assessment && (authoritativeStatusChanged || changedEvidence)) {
[resultRow, contractRow] = await Promise.all([
db.select().from(nativeRunResults).where(and(
eq(nativeRunResults.id, assessment.resultId),
@ -746,11 +733,9 @@ export async function reconcileNativeFinalizations(
&& reassessment.verificationPassed === true;
const facts: NativeReconciliationFacts = authoritativeStatusChanged
? { authoritativeStatusChanged: true }
: policyVersionChanged
? { policyVersionChanged: true }
: newEvidenceSatisfiesContract
? { newEvidenceSatisfiesContract: true }
: {};
: newEvidenceSatisfiesContract
? { newEvidenceSatisfiesContract: true }
: {};
if (Object.keys(facts).length > 0) {
if (!assessment || !reassessment || !resultRow || !contractRow) {
throw new Error("native_reconciliation_reassessment_missing");

View File

@ -0,0 +1,150 @@
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import {
approvals, issueApprovals, issueThreadInteractions, issues,
nativeRunFinalizations, statusDecisionEffects, statusDecisions, workAssessments,
type Db,
} from "@paperclipai/db";
import { logger } from "../../middleware/logger.js";
import { issueService } from "../issues.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { enqueueTerminalIssueInteractionChatPublications } from "../chat-interaction-publications.js";
import { persistActivity, publishActivity, type ActivityPublication } from "../activity-log.js";
const obsoletePrompt = "Review the superseding native policy assessment.";
/** Retire only the old version-change gate, never a real completion review. */
export async function dismissObsoleteNativePolicyReviews(db: Db, runIds?: string[]) {
const candidates = await db.select({
interaction: issueThreadInteractions,
decision: statusDecisions,
priorDecisionId: workAssessments.priorDecisionId,
}).from(issueThreadInteractions)
.innerJoin(statusDecisionEffects, and(
eq(statusDecisionEffects.companyId, issueThreadInteractions.companyId),
eq(statusDecisionEffects.issueId, issueThreadInteractions.issueId),
sql`${statusDecisionEffects.targetId} = ${issueThreadInteractions.id}::text`,
eq(statusDecisionEffects.targetType, "issue_thread_interaction"),
eq(statusDecisionEffects.effectKind, "bind_reviewer"),
))
.innerJoin(statusDecisions, and(
eq(statusDecisions.id, statusDecisionEffects.decisionId),
eq(statusDecisions.companyId, issueThreadInteractions.companyId),
eq(statusDecisions.issueId, issueThreadInteractions.issueId),
eq(statusDecisions.runId, issueThreadInteractions.sourceRunId),
))
.innerJoin(workAssessments, and(
eq(workAssessments.id, statusDecisions.assessmentId),
eq(workAssessments.companyId, statusDecisions.companyId),
eq(workAssessments.issueId, statusDecisions.issueId),
))
.where(and(
eq(issueThreadInteractions.status, "pending"),
eq(issueThreadInteractions.kind, "request_confirmation"),
isNull(issueThreadInteractions.createdByAgentId),
isNull(issueThreadInteractions.createdByUserId),
eq(statusDecisions.applicationState, "applied"),
eq(statusDecisions.reasonCode, "completion_review_required"),
eq(statusDecisions.toStatus, "in_review"),
sql`${issueThreadInteractions.idempotencyKey} = 'native-review:' || ${statusDecisions.id}::text`,
sql`${issueThreadInteractions.payload}->>'prompt' = ${obsoletePrompt}`,
// Match the complete old decision, including its unique assessment-only effect.
sql`${statusDecisions.decisionJson}->'effects' = ${JSON.stringify([
{ kind: "bind_reviewer", prompt: obsoletePrompt, ownerUserId: null },
{ kind: "append_superseding_assessment" },
])}::jsonb`,
...(runIds?.length ? [inArray(statusDecisions.runId, runIds)] : []),
)).limit(100);
for (const { interaction, decision, priorDecisionId } of candidates) {
const publications: ActivityPublication[] = [];
try {
await db.transaction(async (tx) => {
// Same lock order as status commits: coordinator, issue, interaction.
await tx.select({ runId: nativeRunFinalizations.runId }).from(nativeRunFinalizations)
.where(and(eq(nativeRunFinalizations.runId, decision.runId),
eq(nativeRunFinalizations.companyId, decision.companyId)))
.for("update");
const issue = await tx.select().from(issues).where(and(
eq(issues.id, decision.issueId), eq(issues.companyId, decision.companyId),
)).for("update").then((rows) => rows[0]);
if (!issue) return;
const now = new Date();
const [cancelled] = await tx.update(issueThreadInteractions).set({
status: "cancelled",
result: { version: 1, outcome: "withdrawn", reason: "A Paperclip upgrade does not require completion review." },
resolvedAt: now,
updatedAt: now,
}).where(and(
eq(issueThreadInteractions.id, interaction.id),
eq(issueThreadInteractions.companyId, decision.companyId),
eq(issueThreadInteractions.status, "pending"),
)).returning({ id: issueThreadInteractions.id });
if (!cancelled) return;
const terminalInteraction = await issueThreadInteractionService(tx as unknown as Db).getById(cancelled.id);
if (terminalInteraction) {
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, terminalInteraction);
}
const pendingInteraction = await tx.select({ id: issueThreadInteractions.id })
.from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.status, "pending"),
)).limit(1);
const pendingApproval = await tx.select({ id: approvals.id }).from(issueApprovals)
.innerJoin(approvals, and(eq(approvals.id, issueApprovals.approvalId),
eq(approvals.companyId, issue.companyId)))
.where(and(eq(issueApprovals.companyId, issue.companyId), eq(issueApprovals.issueId, issue.id),
inArray(approvals.status, ["pending", "revision_requested"]))).limit(1);
const restoreStatus = issue.status === "in_review"
&& issue.lastStatusDecisionId === decision.id
&& issue.statusVersion === Number(decision.decisionJson.projectedStatusVersion ?? decision.decisionVersion)
&& ["backlog", "todo", "in_progress", "blocked"].includes(decision.fromStatus)
&& pendingInteraction.length === 0 && pendingApproval.length === 0
&& issue.executionState?.status !== "pending";
if (restoreStatus) {
const priorDecision = priorDecisionId ? await tx.select().from(statusDecisions).where(and(
eq(statusDecisions.id, priorDecisionId), eq(statusDecisions.companyId, issue.companyId),
eq(statusDecisions.issueId, issue.id),
)).then((rows) => rows[0]) : null;
await issueService(tx as unknown as Db).update(issue.id, {
status: decision.fromStatus,
// This is an administrative correction, not a replay of the old decision.
lastStatusDecisionId: null,
unblockDescriptor: priorDecision?.decisionJson.unblockDescriptor as typeof issue.unblockDescriptor ?? null,
}, tx, publications);
}
const { publication } = await persistActivity(tx as unknown as Db, {
companyId: issue.companyId,
actorType: "system",
actorId: "native-policy-review-cleanup",
action: restoreStatus ? "issue.updated" : "issue.interaction_cancelled",
entityType: "issue",
entityId: issue.id,
issueId: issue.id,
runId: decision.runId,
details: {
source: "obsolete_native_policy_review",
interactionId: interaction.id,
decisionId: decision.id,
fromStatus: issue.status,
toStatus: restoreStatus ? decision.fromStatus : issue.status,
},
});
publications.push(publication);
});
} catch (err) {
logger.warn({ err, interactionId: interaction.id, issueId: decision.issueId },
"Failed to withdraw obsolete native policy review; will retry on the next pass");
continue;
}
for (const publication of publications) {
try {
publishActivity(publication);
} catch (err) {
logger.warn({ err, interactionId: interaction.id, issueId: decision.issueId },
"Obsolete native policy review cleanup committed; live activity publication failed, history is preserved");
}
}
}
}