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 <yanis.ismail@emissive.fr>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
yismail 2026-07-15 18:38:37 +02:00 committed by GitHub
parent 0ecae2cd7e
commit 24bd860280
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 32 additions and 3 deletions

View File

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

View File

@ -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;
}