From 24bd860280b5a51315a421ad773a9e2352c94ce3 Mon Sep 17 00:00:00 2001 From: yismail Date: Wed, 15 Jul 2026 18:38:37 +0200 Subject: [PATCH] Stop cancelled productivity review loops (#5210) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Productivity review reconciliation creates manager-owned review issues when assigned work shows no-comment, long-active, or high-churn patterns. > - SplatImmo hit a loop because productivity-review issues were auto-cancelled while the source issue still matched the same trigger. > - The service already snoozed recently completed reviews, but cancelled reviews were ignored for that snooze check. > - This pull request treats recently cancelled productivity reviews as terminal snooze evidence. > - The benefit is that cancelling a review now suppresses immediate recreation without disabling useful future productivity reviews. ## What Changed - Renamed the recent-review lookup to terminal-review semantics and included `cancelled` alongside `done`. - Added a regression test proving a recently cancelled productivity review produces `snoozed` instead of creating another review. ## Verification - `pnpm exec vitest run server/src/__tests__/productivity-review-service.test.ts` passes: 1 file, 12 tests. - Queried the SplatImmo Paperclip instance for existing `Review productivity` issues: 500 `issue_productivity_review` issues found, all already `cancelled`, 0 active. ## Risks - Low risk: this only affects the reconciliation branch after a terminal productivity-review issue exists. - Operators who cancel a productivity review now get the same default 6-hour quiet window as completed reviews; after that window, persistent evidence can still create a fresh review. ## Model Used - OpenAI Codex coding agent, GPT-5 class model, tool-enabled code editing and local command execution. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Yanis Ismail Co-authored-by: Paperclip --- .../productivity-review-service.test.ts | 29 +++++++++++++++++++ server/src/services/productivity-review.ts | 6 ++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/productivity-review-service.test.ts b/server/src/__tests__/productivity-review-service.test.ts index 602267d44e..0c6ed8b413 100644 --- a/server/src/__tests__/productivity-review-service.test.ts +++ b/server/src/__tests__/productivity-review-service.test.ts @@ -497,6 +497,35 @@ describeEmbeddedPostgres("productivity review service", () => { expect(reviews).toHaveLength(1); }); + it("treats a recently cancelled review as a snooze window", async () => { + const now = new Date("2026-04-28T12:00:00.000Z"); + const seeded = await seedAssignedIssue(); + await insertRuns({ + companyId: seeded.companyId, + agentId: seeded.coderId, + issueId: seeded.issueId, + count: 10, + now, + }); + const service = productivityReviewService(db); + await service.reconcileProductivityReviews({ now, companyId: seeded.companyId }); + const [review] = await listProductivityReviews(seeded.companyId); + await db + .update(issues) + .set({ status: "cancelled", updatedAt: now }) + .where(eq(issues.id, review!.id)); + + const result = await service.reconcileProductivityReviews({ + now: new Date(now.getTime() + 30 * 60 * 1000), + companyId: seeded.companyId, + }); + const reviews = await listProductivityReviews(seeded.companyId); + + expect(result.snoozed).toBe(1); + expect(result.created).toBe(0); + expect(reviews).toHaveLength(1); + }); + it("reports and logs soft-stop holds for open no-comment reviews", async () => { const now = new Date("2026-04-28T12:00:00.000Z"); const seeded = await seedAssignedIssue(); diff --git a/server/src/services/productivity-review.ts b/server/src/services/productivity-review.ts index cae4e37668..8441c5c675 100644 --- a/server/src/services/productivity-review.ts +++ b/server/src/services/productivity-review.ts @@ -260,7 +260,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque .then((rows) => rows[0] ?? null); } - async function findRecentResolvedProductivityReview( + async function findRecentTerminalProductivityReview( companyId: string, sourceIssueId: string, thresholds: ProductivityReviewThresholds, @@ -275,7 +275,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque eq(issues.companyId, companyId), eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND), eq(issues.originId, sourceIssueId), - eq(issues.status, "done"), + inArray(issues.status, ["done", "cancelled"]), gt(issues.updatedAt, cutoff), ), ) @@ -807,7 +807,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque result.skipped += 1; continue; } - if (await findRecentResolvedProductivityReview(candidate.companyId, candidate.id, thresholds, now)) { + if (await findRecentTerminalProductivityReview(candidate.companyId, candidate.id, thresholds, now)) { result.snoozed += 1; continue; }