From 3bafac12f796fbea02e609e1074a9639f872e9c4 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Fri, 11 Sep 2026 15:46:35 -0500
Subject: [PATCH] refactor: remove automatic productivity reviews (#13263)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Its recovery loop keeps assigned work moving after execution
failures.
> - Productivity review used run counts, comment counts, and elapsed
time to create management tasks.
> - Infrastructure failures could satisfy those rules and create more
tasks without evidence that the source work needed management review.
> - This pull request removes that detector and its continuation holds.
> - Bounded recovery, budgets, explicit blockers, and normal review
stages remain in place.
> - Existing task records stay readable and unchanged.
## Linked Issues or Issue Description
Refs #5897. That request describes unwanted automatic productivity
reviews and asks to preserve existing tasks. This change retires the
feature instead of adding another configuration switch.
Related prior approaches: Refs #9191, Refs #12489. Those changes
excluded infrastructure failures or bounded review creation. This
removal replaces the detector rather than tuning its thresholds.
## What Changed
- Delete the scheduled detector, automatic task creation, evidence
refresh, and productivity continuation holds.
- Remove computed productivity fields, special attention items, badges,
and Storybook fixtures.
- Retain historical origin values, decision compatibility, and recovery
recursion exclusions. Add no migration and change no existing task data.
- Update the execution contract. Replace feature tests with regressions
for legacy task reads, ordinary attention, and bounded continuation in
the presence of an old review.
## Verification
- Targeted attention, issue-route, startup, and UI tests: 4 files and
101 tests passed.
- Updated issue-route and UI tests: 2 files and 61 tests passed.
- Bounded continuation regression: 2 cases passed, including a legacy
review plus pre-dispatch cancellation churn.
- `pnpm check:token-gates`: all four gates passed.
- `git diff --check`: passed.
- `pnpm build-storybook`: passed.
- Greptile: 5/5 on `a5a612eea`, with no actionable findings.
- Scheduler and historical recovery regressions: 2 files and 28 tests
passed.
- Repository `pnpm -r typecheck` and `pnpm build`: passed.
- The complete `pnpm test:run` suite passed across the CI server,
serialized-server, and workspace shards on `a5a612eea`. Stopped the
duplicate local monolithic run after the full CI suite passed; no
completed local full-suite result is claimed. The targeted local suites
above passed.
- CI serialized shard 5 initially hit a 10-second timeout in the first
interaction-route test. The complete file passed locally (78 tests),
then the single CI rerun passed.
- All CI gates are green, including the build and end-to-end suites.
- A local merge check against current `master` (`ce09ea40b`) completed
without conflicts.
## Risks
- API responses no longer include the computed `productivityReview`
field. Consumers must stop using it.
- The scheduler no longer creates management work from elapsed time, run
counts, or missing comments. This is the intended behavior change.
- Existing review tasks and explicit dependencies remain in place.
Historical origins still prevent recursive recovery treatment. No task
cleanup or data migration occurs.
- The native review handoff repair is separate from this removal.
## Model Used
OpenAI GPT-6 through Codex, with reasoning, tool use, and code
execution. The exact runtime model identifier and context-window size
are not exposed in this session.
## 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
---
doc/PRODUCT.md | 2 +-
doc/design/COMPONENT-INVENTORY.md | 1 -
doc/execution-semantics.md | 9 +-
packages/shared/src/constants.ts | 1 +
packages/shared/src/index.ts | 2 -
packages/shared/src/types/attention.ts | 1 +
packages/shared/src/types/index.ts | 2 -
packages/shared/src/types/issue.ts | 18 -
scripts/general-server-shard-durations.json | 1 -
.../src/__tests__/attention-service.test.ts | 12 +-
...ompany-portability-import-batching.test.ts | 4 +-
...artbeat-active-run-output-watchdog.test.ts | 4 +-
.../heartbeat-process-recovery.test.ts | 57 +-
...artbeat-responsible-user-invariant.test.ts | 4 +-
.../issues-goal-context-routes.test.ts | 20 +-
.../productivity-review-service.test.ts | 762 -------------
.../server-startup-feedback-export.test.ts | 27 +-
server/src/index.ts | 11 -
server/src/routes/issues.ts | 13 -
server/src/services/attention.ts | 61 --
server/src/services/decision-queues.ts | 1 +
server/src/services/heartbeat.ts | 37 -
server/src/services/index.ts | 4 -
server/src/services/issues.ts | 163 +--
server/src/services/productivity-review.ts | 999 ------------------
server/src/services/recovery/origins.ts | 1 +
ui/src/components/IssueRow.tsx | 19 +-
ui/src/components/ProductivityReviewBadge.tsx | 77 --
ui/src/lib/attention.test.ts | 3 +
ui/src/lib/attention.ts | 3 +-
ui/src/pages/IssueDetail.tsx | 17 -
.../stories/status-language.stories.tsx | 113 --
32 files changed, 123 insertions(+), 2326 deletions(-)
delete mode 100644 server/src/__tests__/productivity-review-service.test.ts
delete mode 100644 server/src/services/productivity-review.ts
delete mode 100644 ui/src/components/ProductivityReviewBadge.tsx
diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md
index f276d3dfbc..3c5207befb 100644
--- a/doc/PRODUCT.md
+++ b/doc/PRODUCT.md
@@ -150,7 +150,7 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**,
Work is not done until the user can see the result: file, document, preview link, screenshot, plan, or PR.
6. **Execution visibility without log worship**
- Active runs, recovery issues, productivity review states, blockers, and work products should be first-class surfaces. Raw transcripts are available when needed, but they are not the primary product surface.
+ Active runs, recovery issues, blockers, and work products should be first-class surfaces. Raw transcripts are available when needed, but they are not the primary product surface.
7. **Local-first, cloud-ready**
The mental model should not change between local solo use and shared/private or public/cloud deployment.
diff --git a/doc/design/COMPONENT-INVENTORY.md b/doc/design/COMPONENT-INVENTORY.md
index 0e5d05b9b0..87803a9d82 100644
--- a/doc/design/COMPONENT-INVENTORY.md
+++ b/doc/design/COMPONENT-INVENTORY.md
@@ -130,7 +130,6 @@ Grouped by rough domain area. One line each; variants column is props-based wher
| `ExternalObjectStatusIcon.tsx` / `ExternalObjectStatusSummary.tsx` / `ExternalObjectPill.tsx` | External-object (linked PR/doc/etc.) status glyph, rollup summary, and inline pill — a third, deliberately separate status-presentation family |
| `BlockedReasonChip.tsx` | Chip explaining why a task is blocked |
| `SourceTrustBadge.tsx` / `SourceResolvedFoldBadge.tsx` / `SourceResolvedFoldCallout.tsx` | Trust/fold badges for external content sources |
-| `ProductivityReviewBadge.tsx` | Review-status badge |
**KNOWN-DUPLICATES.md lead verified:** StatusIcon / inline-mention chips / task chips are intentionally three separate systems (StatusIcon+StatusGlyph = task status glyph family; `ExternalObjectStatusIcon`/`Pill`/`Summary` = a second, external-object-specific family; mention chips in `lib/mention-chips.ts` + markdown CSS = a third, generic "chip in prose" family). **Documented here per instruction, not merged.**
diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md
index e749807753..8040a2f9e0 100644
--- a/doc/execution-semantics.md
+++ b/doc/execution-semantics.md
@@ -601,15 +601,16 @@ Automatic retries that can continue source work use the agent's configured model
Startup recovery and periodic recovery are different from normal wakeup delivery.
-On startup and on the periodic recovery loop, Paperclip now does five things in sequence:
+On startup and on the periodic recovery loop, Paperclip performs the following recovery passes:
1. reap orphaned `running` runs
2. resume persisted `queued` runs
3. reconcile stranded assigned work
4. scan silent active runs only for source-aware terminal folding and legacy cleanup; API reads classify ordinary output silence for the board UI
-5. reconcile productivity reviews
-The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The productivity-review pass is later and separate; it reviews unusual progression patterns on assigned source issues, not stale run handles after a source issue already has a valid disposition.
+The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output.
+
+Automatic productivity reviews are retired. Run counts, missing comments, and elapsed task time do not create review tasks or impose continuation holds. Bounded continuation, provider recovery, budget limits, explicit blockers, and normal review/approval stages remain in force. Existing productivity-review tasks, comments, assignments, and dependencies remain unchanged and readable; their historical origins still identify them as recovery work for recursion suppression.
### Issue-thread interaction resolution
@@ -783,7 +784,7 @@ Do not fold a run only because it is quiet. Keep the informational signal visibl
In the normal non-terminal case, critical silence remains a UI signal and does not block the source issue. In the source-resolved case, a completed source issue does not acquire a new review or blocker merely because an old run handle stayed active. Only real unresolved work should block work.
-This is distinct from productivity review. Productivity review asks whether an assigned source issue has unusual progression patterns, such as no-comment terminal-run streaks, long active duration, or high churn. Source-resolved watchdog folding asks whether a stale active-run signal outlived a source issue that already reached a valid terminal disposition. One does not substitute for the other.
+Source-resolved watchdog folding concerns stale active-run bookkeeping after a valid terminal disposition. It does not infer productivity from run counts, comment frequency, or elapsed task time.
Detached process cleanup is operational hygiene, not source issue liveness. Cleanup should be best-effort and auditable. If cleanup fails but the source issue is already terminal with same-run durable evidence, Paperclip should preserve the cleanup failure on the run/watchdog audit trail and route only the cleanup concern to bounded recovery when a real owner/action remains.
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index ed8aabe7d8..28e18d3dfd 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -358,6 +358,7 @@ export const ISSUE_ORIGIN_KINDS = [
"routine_execution",
"stale_active_run_evaluation",
"harness_liveness_escalation",
+ // Historical origin only; automatic productivity reviews have been retired.
"issue_productivity_review",
"stranded_issue_recovery",
"task_watchdog",
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 9d546d584d..f68d6729fb 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1122,8 +1122,6 @@ export type {
IssueBlockedInboxReason,
IssueBlockedInboxSeverity,
IssueBlockedInboxState,
- IssueProductivityReview,
- IssueProductivityReviewTrigger,
IssueRecoveryAction,
IssueWatchdog,
IssueWatchdogStatus,
diff --git a/packages/shared/src/types/attention.ts b/packages/shared/src/types/attention.ts
index d61c7acc79..8c9318962a 100644
--- a/packages/shared/src/types/attention.ts
+++ b/packages/shared/src/types/attention.ts
@@ -11,6 +11,7 @@ export const ATTENTION_SOURCE_KINDS = [
"issue_thread_interaction",
"join_request",
"recovery_action",
+ // Legacy persisted decision sources remain readable; no feed items are generated.
"productivity_review",
"blocker_attention",
"review",
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index 43aa05909a..c0d1c1d8ee 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -683,8 +683,6 @@ export type {
IssueBlockedInboxReason,
IssueBlockedInboxSeverity,
IssueBlockedInboxState,
- IssueProductivityReview,
- IssueProductivityReviewTrigger,
IssueRecoveryAction,
SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind,
diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts
index 0428bcdea9..c7ef92579d 100644
--- a/packages/shared/src/types/issue.ts
+++ b/packages/shared/src/types/issue.ts
@@ -537,22 +537,6 @@ export interface IssueUnblockDescriptor {
action: string;
}
-export type IssueProductivityReviewTrigger =
- | "no_comment_streak"
- | "long_active_duration"
- | "high_churn";
-
-export interface IssueProductivityReview {
- reviewIssueId: string;
- reviewIdentifier: string | null;
- status: IssueStatus;
- priority: IssuePriority;
- trigger: IssueProductivityReviewTrigger | null;
- noCommentStreak: number | null;
- createdAt: Date;
- updatedAt: Date;
-}
-
export interface IssueRecoveryAction {
id: string;
companyId: string;
@@ -846,7 +830,6 @@ export interface Issue {
unblockDescriptor?: IssueUnblockDescriptor | null;
blockedTransitionAt?: Date | null;
blockedOwnerNotifiedAt?: Date | null;
- productivityReview?: IssueProductivityReview | null;
activeRecoveryAction?: IssueRecoveryAction | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
executionBlocker?: ExecutionBlocker | null;
@@ -919,7 +902,6 @@ export type CompactIssue = Pick<
blockerAttention?: IssueBlockerAttention;
reviewAttention?: IssueReviewAttention;
blockedInboxAttention?: IssueBlockedInboxAttention | null;
- productivityReview?: IssueProductivityReview | null;
scheduledRetry?: IssueScheduledRetry | null;
liveDescendantCount?: number;
myLastTouchAt?: Date | null;
diff --git a/scripts/general-server-shard-durations.json b/scripts/general-server-shard-durations.json
index 115a12669d..d9c55a30ff 100644
--- a/scripts/general-server-shard-durations.json
+++ b/scripts/general-server-shard-durations.json
@@ -274,7 +274,6 @@
"server/src/__tests__/plugin-worker-manager.test.ts": 2669,
"server/src/__tests__/private-hostname-guard.test.ts": 300,
"server/src/__tests__/private-json-etag.test.ts": 271,
- "server/src/__tests__/productivity-review-service.test.ts": 11098,
"server/src/__tests__/project-icon-persistence.test.ts": 3936,
"server/src/__tests__/project-list-metrics.test.ts": 1198,
"server/src/__tests__/project-shortname-resolution.test.ts": 1196,
diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts
index 49f1e17466..930a3dda2a 100644
--- a/server/src/__tests__/attention-service.test.ts
+++ b/server/src/__tests__/attention-service.test.ts
@@ -616,13 +616,13 @@ describeEmbeddedPostgres("attention service", () => {
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
- expect(feed.totalCount).toBe(12);
+ expect(feed.totalCount).toBe(11);
expect(feed.countsBySourceKind).toMatchObject({
approval: 1,
issue_thread_interaction: 1,
join_request: 1,
recovery_action: 1,
- productivity_review: 1,
+ productivity_review: 0,
blocker_attention: 1,
review: 2,
failed_run: 1,
@@ -634,7 +634,6 @@ describeEmbeddedPostgres("attention service", () => {
"issue_thread_interaction",
"join_request",
"recovery_action",
- "productivity_review",
"blocker_attention",
"review",
"failed_run",
@@ -651,7 +650,14 @@ describeEmbeddedPostgres("attention service", () => {
expect(item.rank).toBeGreaterThan(0);
}
expect(feed.items.some((item) => item.subject.title === "Revision requested")).toBe(false);
+ expect(feed.items.some((item) => item.sourceKind === "productivity_review")).toBe(false);
expect(feed.items.some((item) => item.subject.title === "Agent productivity review excluded")).toBe(false);
+ const legacyReviews = await db.select().from(issues).where(eq(issues.originKind, "issue_productivity_review"));
+ expect(legacyReviews).toHaveLength(2);
+ expect(legacyReviews).toEqual(expect.arrayContaining([
+ expect.objectContaining({ title: "Human productivity review", status: "todo", assigneeUserId: "board-user", parentId: productivitySourceIssueId }),
+ expect.objectContaining({ title: "Agent productivity review excluded", status: "todo", assigneeAgentId: workerId, parentId: agentProductivitySourceIssueId }),
+ ]));
expect(feed.items.some((item) => item.subject.title === "Agent review excluded")).toBe(false);
expect(feed.items.some((item) =>
item.sourceKind === "failed_run" && item.subject.metadata?.errorCode === "provider_quota"
diff --git a/server/src/__tests__/company-portability-import-batching.test.ts b/server/src/__tests__/company-portability-import-batching.test.ts
index 0c7226aa74..c713161dee 100644
--- a/server/src/__tests__/company-portability-import-batching.test.ts
+++ b/server/src/__tests__/company-portability-import-batching.test.ts
@@ -418,8 +418,8 @@ describeEmbeddedPostgres("company import batches inserts", () => {
.from(issues)
.where(eq(issues.companyId, companyId));
expect(imported?.status).toBe("in_progress");
- // A fabricated import-time startedAt made carried-over work look hours
- // stale to duration-based sweeps (e.g. the productivity review).
+ // An import-time startedAt would misrepresent carried-over work
+ // as a newly started active episode.
expect(imported?.startedAt).toBeNull();
});
diff --git a/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts b/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
index 8542bd365f..ce42e31fb6 100644
--- a/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
+++ b/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
@@ -230,7 +230,7 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
expect(manager?.status).toBe("idle");
}
- it("keeps blocked and recovery-origin sources artifact-free", async () => {
+ it.each(["stale_active_run_evaluation", "issue_productivity_review"])("keeps blocked and %s sources artifact-free", async (originKind) => {
const now = new Date("2026-04-22T20:00:00.000Z");
const blocked = await seedRunningRun({
now,
@@ -240,7 +240,7 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
const recursive = await seedRunningRun({
now,
ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
- sourceOriginKind: "stale_active_run_evaluation",
+ sourceOriginKind: originKind,
});
const { enqueueWakeup, recovery } = createRecovery();
diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts
index e12e62ddb2..7c2051c519 100644
--- a/server/src/__tests__/heartbeat-process-recovery.test.ts
+++ b/server/src/__tests__/heartbeat-process-recovery.test.ts
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js";
+import { issueService } from "../services/issues.js";
import { getExecutionBlocker } from "../services/execution-blocker.js";
import { adapterExecutionControls, createAdapterExecutionControl } from "../services/adapter-execution-control.js";
import { spawn, type ChildProcess } from "node:child_process";
@@ -10139,20 +10140,48 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(issue?.executionRunId).toBeNull();
});
- it("classifies actionable plan-only recovery and enqueues one liveness continuation", async () => {
- mockAdapterExecute.mockResolvedValueOnce({
- exitCode: 0,
- signal: null,
- timedOut: false,
- errorMessage: null,
- summary: "I will inspect the repo next and then implement the fix.",
- provider: "test",
- model: "test-model",
- });
- const { agentId, issueId, runId } = await seedStrandedIssueFixture({
+ it.each([false, true])("enqueues one bounded plan-only continuation with legacy productivity review present: %s", async (withLegacyReview) => {
+ const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "failed",
});
+ const legacyReviewId = randomUUID();
+ if (withLegacyReview) {
+ await db.insert(issues).values({
+ id: legacyReviewId,
+ companyId,
+ title: "Historical productivity review",
+ description: "Keep this review and its existing ownership unchanged.",
+ status: "todo",
+ assigneeUserId: "responsible-user",
+ parentId: issueId,
+ originKind: "issue_productivity_review",
+ originId: issueId,
+ originFingerprint: `productivity-review:${issueId}`,
+ });
+ }
+ const legacyReviewBefore = withLegacyReview
+ ? await db.select().from(issues).where(eq(issues.id, legacyReviewId))
+ : [];
+ mockAdapterExecute.mockImplementationOnce(async () => {
+ if (withLegacyReview) {
+ // These pre-dispatch cancellations used to satisfy both the no-comment
+ // and churn thresholds and suppress an otherwise valid continuation.
+ await db.insert(heartbeatRuns).values(Array.from({ length: 10 }, (_, index) => ({
+ id: randomUUID(), companyId, agentId,
+ invocationSource: "automation", triggerDetail: "system", status: "cancelled",
+ errorCode: "execution_reconciliation_required",
+ contextSnapshot: { issueId, taskId: issueId },
+ createdAt: new Date(Date.now() - (index + 1) * 60_000),
+ finishedAt: new Date(),
+ })));
+ }
+ return {
+ exitCode: 0, signal: null, timedOut: false, errorMessage: null,
+ summary: "I will inspect the repo next and then implement the fix.",
+ provider: "test", model: "test-model",
+ };
+ });
const heartbeat = heartbeatService(db);
await heartbeat.reconcileStrandedAssignedIssues();
@@ -10189,6 +10218,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}
expect(sourceRun?.id).not.toBe(runId);
expect(sourceRun?.livenessState).toBe("plan_only");
+ if (withLegacyReview) {
+ expect(await db.select().from(issues).where(eq(issues.id, legacyReviewId))).toEqual(legacyReviewBefore);
+ const source = (await issueService(db).list(companyId)).find((issue) => issue.id === issueId);
+ expect(source).toBeDefined();
+ expect(source).not.toHaveProperty("productivityReview");
+ }
});
it("treats a plan document update as progress and does not enqueue liveness continuation", async () => {
diff --git a/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts b/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts
index b882765493..8470b456b5 100644
--- a/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts
+++ b/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts
@@ -267,10 +267,10 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => {
const run = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
- reason: "productivity_review",
+ reason: "scheduled_maintenance",
requestedByActorType: "system",
requestedByActorId: null,
- contextSnapshot: { wakeReason: "productivity_review" },
+ contextSnapshot: { wakeReason: "scheduled_maintenance" },
});
expect(run).not.toBeNull();
diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts
index 81e8bbd9b1..843fd17a37 100644
--- a/server/src/__tests__/issues-goal-context-routes.test.ts
+++ b/server/src/__tests__/issues-goal-context-routes.test.ts
@@ -13,7 +13,6 @@ const mockIssueService = vi.hoisted(() => ({
getComment: vi.fn(),
listBlockerAttention: vi.fn(),
listReviewAttention: vi.fn(),
- listProductivityReviews: vi.fn(),
getCurrentScheduledRetry: vi.fn(),
getActiveInboxArchiveFields: vi.fn(),
listAttachments: vi.fn(),
@@ -212,7 +211,6 @@ describe.sequential("issue goal context routes", () => {
mockIssueService.getComment.mockResolvedValue(null);
mockIssueService.listBlockerAttention.mockResolvedValue(new Map());
mockIssueService.listReviewAttention.mockResolvedValue(new Map());
- mockIssueService.listProductivityReviews.mockResolvedValue(new Map());
mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null);
mockIssueService.getActiveInboxArchiveFields.mockResolvedValue({});
mockIssueService.listAttachments.mockResolvedValue([]);
@@ -270,6 +268,24 @@ describe.sequential("issue goal context routes", () => {
mockGoalService.getDefaultCompanyGoal.mockResolvedValue(null);
});
+ it.each(["", "/heartbeat-context"])("reads historical review tasks without computed productivity fields: %s", async (suffix) => {
+ mockIssueService.getById.mockResolvedValue({
+ ...legacyProjectLinkedIssue,
+ originKind: "issue_productivity_review",
+ originId: "historical-source",
+ });
+ const res = await request(createApp()).get(`/api/issues/${legacyProjectLinkedIssue.id}${suffix}`);
+ expect(res.status).toBe(200);
+ const issue = suffix ? res.body.issue : res.body;
+ expect(issue).toMatchObject({
+ originKind: "issue_productivity_review",
+ originId: "historical-source",
+ assigneeAgentId: legacyProjectLinkedIssue.assigneeAgentId,
+ status: legacyProjectLinkedIssue.status,
+ });
+ expect(issue).not.toHaveProperty("productivityReview");
+ });
+
it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => {
const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
diff --git a/server/src/__tests__/productivity-review-service.test.ts b/server/src/__tests__/productivity-review-service.test.ts
deleted file mode 100644
index 7531b0b6a2..0000000000
--- a/server/src/__tests__/productivity-review-service.test.ts
+++ /dev/null
@@ -1,762 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { and, eq, sql } from "drizzle-orm";
-import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
-import {
- activityLog,
- agents,
- companies,
- createDb,
- heartbeatRuns,
- issueComments,
- issues,
-} from "@paperclipai/db";
-import {
- getEmbeddedPostgresTestSupport,
- startEmbeddedPostgresTestDatabase,
-} from "./helpers/embedded-postgres.js";
-import { MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared";
-import {
- DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS,
- DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
- DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS,
- PRODUCTIVITY_REVIEW_REFRESH_COMMENT_PREFIX,
- PRODUCTIVITY_REVIEW_ORIGIN_KIND,
- productivityReviewService,
-} from "../services/productivity-review.ts";
-
-const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
-const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
-
-if (!embeddedPostgresSupport.supported) {
- console.warn(
- `Skipping embedded Postgres productivity review tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
- );
-}
-
-describeEmbeddedPostgres("productivity review service", () => {
- let tempDb: Awaited> | null = null;
- let db: ReturnType;
-
- beforeAll(async () => {
- tempDb = await startEmbeddedPostgresTestDatabase("paperclip-productivity-review-");
- db = createDb(tempDb.connectionString);
- }, 30_000);
-
- afterEach(async () => {
- await db.execute(sql.raw(`TRUNCATE TABLE "companies" CASCADE`));
- });
-
- afterAll(async () => {
- await tempDb?.cleanup();
- }, 30_000);
-
- async function seedAssignedIssue(opts?: {
- status?: "todo" | "in_progress";
- startedAt?: Date;
- parentId?: string | null;
- originKind?: string;
- }) {
- const companyId = randomUUID();
- const managerId = randomUUID();
- const coderId = randomUUID();
- const issueId = randomUUID();
- const issuePrefix = `PR${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
- const createdAt = new Date("2026-04-28T10:00:00.000Z");
-
- await db.insert(companies).values({
- id: companyId,
- name: "Productivity Review Co",
- issuePrefix,
- requireBoardApprovalForNewAgents: false,
- });
- await db.insert(agents).values([
- {
- id: managerId,
- companyId,
- name: "CTO",
- role: "cto",
- status: "idle",
- adapterType: "codex_local",
- adapterConfig: {},
- runtimeConfig: {},
- permissions: {},
- },
- {
- id: coderId,
- companyId,
- name: "Coder",
- role: "engineer",
- status: "idle",
- reportsTo: managerId,
- adapterType: "codex_local",
- adapterConfig: {},
- runtimeConfig: {},
- permissions: {},
- },
- ]);
- await db.insert(issues).values({
- id: issueId,
- companyId,
- title: "Implement data import",
- status: opts?.status ?? "in_progress",
- priority: "medium",
- assigneeAgentId: coderId,
- parentId: opts?.parentId ?? null,
- originKind: opts?.originKind ?? "manual",
- issueNumber: 1,
- identifier: `${issuePrefix}-1`,
- startedAt: opts?.startedAt ?? createdAt,
- createdAt,
- updatedAt: createdAt,
- });
-
- return { companyId, managerId, coderId, issueId, issuePrefix, createdAt };
- }
-
- async function insertRuns(input: {
- companyId: string;
- agentId: string;
- issueId: string;
- count: number;
- now: Date;
- withRunComments?: boolean;
- }) {
- const runs: Array = [];
- for (let index = 0; index < input.count; index += 1) {
- const runId = randomUUID();
- const createdAt = new Date(input.now.getTime() - index * 60_000);
- runs.push({
- id: runId,
- companyId: input.companyId,
- agentId: input.agentId,
- status: "succeeded",
- invocationSource: "assignment",
- triggerDetail: "system",
- startedAt: createdAt,
- finishedAt: new Date(createdAt.getTime() + 30_000),
- contextSnapshot: { issueId: input.issueId, taskId: input.issueId },
- livenessState: "advanced",
- nextAction: "Continue processing the next batch.",
- createdAt,
- updatedAt: createdAt,
- });
- }
- await db.insert(heartbeatRuns).values(runs);
-
- if (input.withRunComments) {
- await db.insert(issueComments).values(
- runs.map((run, index) => ({
- companyId: input.companyId,
- issueId: input.issueId,
- authorAgentId: input.agentId,
- createdByRunId: run.id,
- body: `Progress update ${index}`,
- createdAt: run.createdAt as Date,
- updatedAt: run.createdAt as Date,
- })),
- );
- }
-
- return runs;
- }
-
- async function listProductivityReviews(companyId: string) {
- return db
- .select()
- .from(issues)
- .where(and(eq(issues.companyId, companyId), eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND)))
- .orderBy(issues.createdAt);
- }
-
- async function listRefreshComments(reviewIssueId: string) {
- return db
- .select()
- .from(issueComments)
- .where(and(
- eq(issueComments.issueId, reviewIssueId),
- sql`${issueComments.body} like ${`${PRODUCTIVITY_REVIEW_REFRESH_COMMENT_PREFIX}%`}`,
- ))
- .orderBy(issueComments.createdAt);
- }
-
- it("creates exactly one manager-assigned review for a no-comment run streak and rate-limits immediate refresh", 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: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
- now,
- });
-
- const service = productivityReviewService(db);
- const first = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
- const second = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
-
- expect(first.created).toBe(1);
- expect(second.updated).toBe(0);
- expect(second.existing).toBe(1);
- const reviews = await listProductivityReviews(seeded.companyId);
- expect(reviews).toHaveLength(1);
- expect(reviews[0]?.parentId).toBe(seeded.issueId);
- expect(reviews[0]?.assigneeAgentId).toBe(seeded.managerId);
- expect(reviews[0]?.assigneeAdapterOverrides).toBeNull();
- expect(reviews[0]?.originId).toBe(seeded.issueId);
- expect(reviews[0]?.originFingerprint).toBe(`productivity-review:${seeded.issueId}`);
- expect(reviews[0]?.description).toContain("Primary trigger: `no_comment_streak`");
- expect(reviews[0]?.description).toContain("No-comment completed-run streak: 10");
-
- expect(await listRefreshComments(reviews[0]!.id)).toHaveLength(0);
- });
-
- it("refreshes open productivity reviews only once per interval and caps refresh comments", 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: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
- now,
- });
-
- const service = productivityReviewService(db);
- await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
- const [review] = await listProductivityReviews(seeded.companyId);
-
- const firstRefreshAt = new Date(now.getTime() + DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS);
- const firstRefresh = await service.reconcileProductivityReviews({
- now: firstRefreshAt,
- companyId: seeded.companyId,
- });
- const tooSoonRefresh = await service.reconcileProductivityReviews({
- now: new Date(firstRefreshAt.getTime() + 30 * 60 * 1000),
- companyId: seeded.companyId,
- });
- await service.reconcileProductivityReviews({
- now: new Date(firstRefreshAt.getTime() + DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS),
- companyId: seeded.companyId,
- });
- await service.reconcileProductivityReviews({
- now: new Date(firstRefreshAt.getTime() + 2 * DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS),
- companyId: seeded.companyId,
- });
- const cappedRefresh = await service.reconcileProductivityReviews({
- now: new Date(firstRefreshAt.getTime() + 3 * DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS),
- companyId: seeded.companyId,
- });
-
- expect(firstRefresh.updated).toBe(1);
- expect(tooSoonRefresh.updated).toBe(0);
- expect(tooSoonRefresh.existing).toBe(1);
- expect(cappedRefresh.updated).toBe(0);
- expect(cappedRefresh.existing).toBe(1);
- expect(await listRefreshComments(review!.id)).toHaveLength(DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS);
- });
-
- it("allows only one productivity review per source issue in 24 hours", 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: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
- now,
- });
- const createdAt = new Date(now.getTime() - 8 * 60 * 60 * 1000);
- await db.insert(issues).values({
- id: randomUUID(),
- companyId: seeded.companyId,
- title: "Completed productivity review",
- status: "done",
- priority: "high",
- originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
- originId: seeded.issueId,
- originFingerprint: `productivity-review:${seeded.issueId}`,
- parentId: seeded.issueId,
- issueNumber: 2,
- identifier: `${seeded.issuePrefix}-2`,
- createdAt,
- updatedAt: createdAt,
- });
-
- const result = await productivityReviewService(db).reconcileProductivityReviews({
- now,
- companyId: seeded.companyId,
- });
-
- expect(result.created).toBe(0);
- expect(result.creationCapped).toBe(1);
- expect(await listProductivityReviews(seeded.companyId)).toHaveLength(1);
- });
-
- it("suppresses creation after three consecutive completed reviews with no source action", 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: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
- now,
- });
- await db.insert(issues).values(
- [96, 72, 48].map((hoursAgo, index) => {
- const createdAt = new Date(now.getTime() - hoursAgo * 60 * 60 * 1000);
- return {
- id: randomUUID(),
- companyId: seeded.companyId,
- title: `No-action productivity review ${index + 1}`,
- status: "done",
- priority: "high",
- originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
- originId: seeded.issueId,
- originFingerprint: `productivity-review:${seeded.issueId}`,
- parentId: seeded.issueId,
- issueNumber: index + 2,
- identifier: `${seeded.issuePrefix}-${index + 2}`,
- createdAt,
- updatedAt: new Date(createdAt.getTime() + 60 * 60 * 1000),
- };
- }),
- );
-
- const result = await productivityReviewService(db).reconcileProductivityReviews({
- now,
- companyId: seeded.companyId,
- });
-
- expect(result.created).toBe(0);
- expect(result.noActionSuppressed).toBe(1);
- expect(await listProductivityReviews(seeded.companyId)).toHaveLength(3);
- });
-
- it("resets no-action suppression for source action after a zero-duration review", 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: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
- now,
- });
- const reviewWindows = [96, 72, 48].map((hoursAgo, index) => {
- const createdAt = new Date(now.getTime() - hoursAgo * 60 * 60 * 1000);
- return {
- id: randomUUID(),
- companyId: seeded.companyId,
- title: `Productivity review ${index + 1}`,
- status: "done" as const,
- priority: "high" as const,
- originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
- originId: seeded.issueId,
- originFingerprint: `productivity-review:${seeded.issueId}`,
- parentId: seeded.issueId,
- issueNumber: index + 2,
- identifier: `${seeded.issuePrefix}-${index + 2}`,
- createdAt,
- updatedAt: new Date(createdAt.getTime() + 60 * 60 * 1000),
- };
- });
- const actedReview = reviewWindows[1]!;
- actedReview.updatedAt = actedReview.createdAt;
- await db.insert(issues).values(reviewWindows);
- await db.insert(activityLog).values({
- companyId: seeded.companyId,
- actorType: "agent",
- actorId: seeded.coderId,
- agentId: seeded.coderId,
- action: "issue.updated",
- entityType: "issue",
- entityId: seeded.issueId,
- createdAt: new Date(actedReview.createdAt.getTime() + 2 * 60 * 60 * 1000),
- });
-
- const result = await productivityReviewService(db).reconcileProductivityReviews({
- now,
- companyId: seeded.companyId,
- });
-
- expect(result.created).toBe(1);
- expect(result.noActionSuppressed).toBe(0);
- expect(await listProductivityReviews(seeded.companyId)).toHaveLength(4);
- });
-
- it("uses review creation order for no-action streak windows", 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: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
- now,
- });
- const reviewWindows = [
- { hoursAgo: 96, updatedAt: new Date(now.getTime() - 95 * 60 * 60 * 1000) },
- { hoursAgo: 72, updatedAt: new Date(now.getTime() - 7 * 60 * 60 * 1000) },
- { hoursAgo: 48, updatedAt: new Date(now.getTime() - 47 * 60 * 60 * 1000) },
- ].map((window, index) => {
- const createdAt = new Date(now.getTime() - window.hoursAgo * 60 * 60 * 1000);
- return {
- id: randomUUID(),
- companyId: seeded.companyId,
- title: `Productivity review ordered window ${index + 1}`,
- status: "done" as const,
- priority: "high" as const,
- originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
- originId: seeded.issueId,
- originFingerprint: `productivity-review:${seeded.issueId}`,
- parentId: seeded.issueId,
- issueNumber: index + 2,
- identifier: `${seeded.issuePrefix}-${index + 2}`,
- createdAt,
- updatedAt: window.updatedAt,
- };
- });
- const middleReviewCreatedAt = reviewWindows[1]!.createdAt;
- await db.insert(issues).values(reviewWindows);
- await db.insert(activityLog).values({
- companyId: seeded.companyId,
- actorType: "agent",
- actorId: seeded.coderId,
- agentId: seeded.coderId,
- action: "issue.updated",
- entityType: "issue",
- entityId: seeded.issueId,
- createdAt: new Date(middleReviewCreatedAt.getTime() + 60_000),
- });
-
- const result = await productivityReviewService(db).reconcileProductivityReviews({
- now,
- companyId: seeded.companyId,
- thresholds: { maxConsecutiveNoActionReviews: 1 },
- });
-
- expect(result.created).toBe(0);
- expect(result.noActionSuppressed).toBe(1);
- expect(await listProductivityReviews(seeded.companyId)).toHaveLength(3);
- });
-
- it("does not count cancelled productivity reviews toward the creation cap", 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: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
- now,
- });
- await db.insert(issues).values(
- [8, 9, 10].map((hoursAgo, index) => {
- const createdAt = new Date(now.getTime() - hoursAgo * 60 * 60 * 1000);
- return {
- id: randomUUID(),
- companyId: seeded.companyId,
- title: `Cancelled productivity review ${index + 1}`,
- status: "cancelled",
- priority: "high",
- originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
- originId: seeded.issueId,
- originFingerprint: `productivity-review:${seeded.issueId}`,
- parentId: seeded.issueId,
- issueNumber: index + 2,
- identifier: `${seeded.issuePrefix}-${index + 2}`,
- createdAt,
- updatedAt: createdAt,
- };
- }),
- );
-
- const result = await productivityReviewService(db).reconcileProductivityReviews({
- now,
- companyId: seeded.companyId,
- });
-
- expect(result.created).toBe(1);
- expect(result.creationCapped).toBe(0);
- expect(await listProductivityReviews(seeded.companyId)).toHaveLength(4);
- });
-
- it("creates a long-active review without enabling a continuation hold", async () => {
- const now = new Date("2026-04-28T12:00:00.000Z");
- const seeded = await seedAssignedIssue({
- status: "in_progress",
- startedAt: new Date(now.getTime() - 7 * 60 * 60 * 1000),
- });
- const service = productivityReviewService(db);
-
- const result = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
- const hold = await service.isProductivityReviewContinuationHoldActive({
- companyId: seeded.companyId,
- issueId: seeded.issueId,
- agentId: seeded.coderId,
- now,
- });
-
- expect(result.created).toBe(1);
- const [review] = await listProductivityReviews(seeded.companyId);
- expect(review?.description).toContain("Primary trigger: `long_active_duration`");
- expect(review?.priority).toBe("medium");
- expect(hold.held).toBe(false);
- });
-
- it("skips a long-active candidate while its assignee is paused and reviews it once unpaused", async () => {
- const now = new Date("2026-04-28T12:00:00.000Z");
- const seeded = await seedAssignedIssue({
- status: "in_progress",
- startedAt: new Date(now.getTime() - 7 * 60 * 60 * 1000),
- });
- await db.update(agents).set({ status: "paused" }).where(eq(agents.id, seeded.coderId));
- const service = productivityReviewService(db);
-
- const pausedResult = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
-
- expect(pausedResult.created).toBe(0);
- expect(pausedResult.skipped).toBe(1);
- expect(await listProductivityReviews(seeded.companyId)).toHaveLength(0);
-
- await db.update(agents).set({ status: "idle" }).where(eq(agents.id, seeded.coderId));
- const unpausedResult = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
-
- expect(unpausedResult.created).toBe(1);
- expect(await listProductivityReviews(seeded.companyId)).toHaveLength(1);
- });
-
- it("creates a high-churn review even when every sampled run has a progress comment", 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,
- withRunComments: true,
- });
-
- const result = await productivityReviewService(db).reconcileProductivityReviews({
- now,
- companyId: seeded.companyId,
- });
-
- expect(result.created).toBe(1);
- const [review] = await listProductivityReviews(seeded.companyId);
- expect(review?.description).toContain("Primary trigger: `high_churn`");
- expect(review?.description).toContain("Runs in rolling windows: 10/1h");
- });
-
- it("ignores non-assignee comments when evaluating high-churn productivity reviews", 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: 9,
- now,
- });
- const managerRuns = await insertRuns({
- companyId: seeded.companyId,
- agentId: seeded.managerId,
- issueId: seeded.issueId,
- count: 10,
- now,
- });
- await db.insert(issueComments).values(
- managerRuns.map((run, index) => ({
- companyId: seeded.companyId,
- issueId: seeded.issueId,
- authorAgentId: seeded.managerId,
- createdByRunId: run.id,
- body: `Manager note ${index}`,
- createdAt: run.createdAt as Date,
- updatedAt: run.createdAt as Date,
- })),
- );
-
- const result = await productivityReviewService(db).reconcileProductivityReviews({
- now,
- companyId: seeded.companyId,
- });
-
- expect(result.created).toBe(0);
- expect(await listProductivityReviews(seeded.companyId)).toHaveLength(0);
- });
-
- it("skips productivity-review descendants so reviews cannot recursively spawn reviews", async () => {
- const now = new Date("2026-04-28T12:00:00.000Z");
- const seeded = await seedAssignedIssue();
- const reviewId = randomUUID();
- const childId = randomUUID();
- await db.insert(issues).values({
- id: reviewId,
- companyId: seeded.companyId,
- title: "Existing productivity review",
- status: "todo",
- priority: "high",
- originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
- originId: seeded.issueId,
- originFingerprint: `productivity-review:${seeded.issueId}`,
- parentId: seeded.issueId,
- issueNumber: 2,
- identifier: `${seeded.issuePrefix}-2`,
- });
- await db.insert(issues).values({
- id: childId,
- companyId: seeded.companyId,
- title: "Review follow-up child",
- status: "in_progress",
- priority: "medium",
- assigneeAgentId: seeded.coderId,
- parentId: reviewId,
- issueNumber: 3,
- identifier: `${seeded.issuePrefix}-3`,
- startedAt: new Date(now.getTime() - 7 * 60 * 60 * 1000),
- });
- await insertRuns({
- companyId: seeded.companyId,
- agentId: seeded.coderId,
- issueId: childId,
- count: 10,
- now,
- });
-
- const result = await productivityReviewService(db).reconcileProductivityReviews({
- now,
- companyId: seeded.companyId,
- });
- const reviews = await listProductivityReviews(seeded.companyId);
-
- expect(result.created).toBe(0);
- expect(reviews).toHaveLength(1);
- });
-
- it("treats a recently completed 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: "done", 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(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();
- const [latestRun] = 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);
-
- const hold = await service.isProductivityReviewContinuationHoldActive({
- companyId: seeded.companyId,
- issueId: seeded.issueId,
- agentId: seeded.coderId,
- now,
- });
- expect(hold.held).toBe(true);
- if (!hold.held) return;
-
- await service.recordContinuationHold({
- companyId: seeded.companyId,
- issueId: seeded.issueId,
- runId: latestRun!.id as string,
- agentId: seeded.coderId,
- reviewIssueId: review!.id,
- trigger: hold.trigger,
- reason: hold.reason,
- });
- const activities = await db
- .select()
- .from(activityLog)
- .where(eq(activityLog.action, "issue.productivity_review_continuation_held"));
- expect(activities).toHaveLength(1);
- expect(activities[0]?.entityId).toBe(seeded.issueId);
- });
-
- it("clamps poisoned requestDepth metadata instead of aborting productivity reconciliation", async () => {
- const now = new Date("2026-04-28T12:00:00.000Z");
- const seeded = await seedAssignedIssue();
-
- await db
- .update(issues)
- .set({ requestDepth: 2_147_483_647 })
- .where(eq(issues.id, seeded.issueId));
-
- await insertRuns({
- companyId: seeded.companyId,
- agentId: seeded.coderId,
- issueId: seeded.issueId,
- count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
- now,
- });
-
- const result = await productivityReviewService(db).reconcileProductivityReviews({
- now,
- companyId: seeded.companyId,
- });
-
- expect(result.failed).toBe(0);
- const [review] = await listProductivityReviews(seeded.companyId);
- expect(review?.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
- });
-});
diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts
index 1175f28e1f..f956c51a58 100644
--- a/server/src/__tests__/server-startup-feedback-export.test.ts
+++ b/server/src/__tests__/server-startup-feedback-export.test.ts
@@ -80,7 +80,6 @@ const {
scanSilentActiveRuns: vi.fn(async () => ({ created: 0, escalated: 0 })),
sweepStaleIssueLocks: vi.fn(async () => ({ cleared: 0 })),
sweepPendingCleanupLeases: vi.fn(async () => ({ swept: 0, destroyed: 0, capped: 0 })),
- reconcileProductivityReviews: vi.fn(async () => ({ created: 0, updated: 0, failed: 0 })),
sweepExpiredRuntimeStatuses: vi.fn(() => 0),
tickTimers: vi.fn(async () => ({ checked: 0, enqueued: 0, skipped: 0 })),
};
@@ -519,6 +518,32 @@ describe("startServer feedback export wiring", () => {
});
});
+ it("never invokes the retired review detector at startup or on periodic recovery", async () => {
+ loadConfigMock.mockReturnValue(buildTestConfig({
+ heartbeatSchedulerEnabled: true,
+ heartbeatSchedulerIntervalMs: 30000,
+ }));
+ const retiredDetector = vi.fn(async () => ({ created: 1, updated: 1, failed: 0 }));
+ const runtime = Object.assign(heartbeatServiceMock, { reconcileProductivityReviews: retiredDetector });
+ let intervalCallback: (() => void) | null = null;
+ const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(((callback: () => void) => {
+ intervalCallback = callback;
+ return 1 as unknown as ReturnType;
+ }) as typeof setInterval);
+ try {
+ await startServer();
+ expect(heartbeatServiceMock.sweepStaleIssueLocks).toHaveBeenCalledTimes(1);
+ expect(intervalCallback).not.toBeNull();
+ intervalCallback?.();
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(heartbeatServiceMock.sweepStaleIssueLocks).toHaveBeenCalledTimes(2);
+ expect(retiredDetector).not.toHaveBeenCalled();
+ } finally {
+ delete (runtime as Partial).reconcileProductivityReviews;
+ setIntervalSpy.mockRestore();
+ }
+ });
+
it("keeps routine ticks and setup cleanup active when heartbeat scheduling is suppressed", async () => {
loadConfigMock.mockReturnValue(buildTestConfig({
heartbeatSchedulerEnabled: true,
diff --git a/server/src/index.ts b/server/src/index.ts
index 723c118f55..4db09369bf 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -1548,11 +1548,6 @@ async function startServerWithDatabaseTeardown(
if (swept.cleared > 0) {
logger.warn({ ...swept }, "startup stale-lock sweeper cleared issue locks");
}
-
- const reviewed = await heartbeat.reconcileProductivityReviews();
- if (reviewed.created > 0 || reviewed.updated > 0 || reviewed.failed > 0) {
- logger.warn({ ...reviewed }, "startup productivity reconciliation created or updated review work");
- }
})().catch((err) => {
logger.error({ err }, "startup heartbeat recovery failed");
throw err;
@@ -1792,12 +1787,6 @@ async function startServerWithDatabaseTeardown(
logger.warn({ ...swept }, "periodic stale-lock sweeper cleared issue locks");
}
})
- .then(async () => {
- const reviewed = await heartbeat.reconcileProductivityReviews();
- if (reviewed.created > 0 || reviewed.updated > 0 || reviewed.failed > 0) {
- logger.warn({ ...reviewed }, "periodic productivity reconciliation created or updated review work");
- }
- })
.catch((err) => {
logger.error({ err }, "periodic heartbeat recovery failed");
}));
diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index e587b51a2e..4331477cd5 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -3013,9 +3013,6 @@ function toCompactIssue(issue: any): CompactIssue {
...(issue.blockedInboxAttention !== undefined
? { blockedInboxAttention: issue.blockedInboxAttention }
: {}),
- ...(issue.productivityReview
- ? { productivityReview: issue.productivityReview }
- : {}),
...(issue.scheduledRetry ? { scheduledRetry: issue.scheduledRetry } : {}),
...(issue.liveDescendantCount !== undefined
? { liveDescendantCount: issue.liveDescendantCount }
@@ -8381,7 +8378,6 @@ export function issueRoutes(
relations,
blockerAttention,
reviewAttention,
- productivityReview,
scheduledRetry,
attachments,
continuationSummary,
@@ -8399,9 +8395,6 @@ export function issueRoutes(
svc
.listReviewAttention(issue.companyId, [issue])
.then((map) => map.get(issue.id) ?? null),
- svc
- .listProductivityReviews(issue.companyId, [issue.id])
- .then((map) => map.get(issue.id) ?? null),
svc.getCurrentScheduledRetry(issue.id),
svc.listAttachments(issue.id),
documentsSvc.getIssueDocumentByKey(
@@ -8465,7 +8458,6 @@ export function issueRoutes(
workMode: issue.workMode,
...(blockerAttention ? { blockerAttention } : {}),
...(reviewAttention ? { reviewAttention } : {}),
- productivityReview,
scheduledRetry,
activeRecoveryAction: revalidatedActiveRecoveryAction,
priority: issue.priority,
@@ -8708,7 +8700,6 @@ export function issueRoutes(
relations,
blockerAttention,
reviewAttention,
- productivityReview,
referenceSummary,
successfulRunHandoffStates,
scheduledRetry,
@@ -8728,9 +8719,6 @@ export function issueRoutes(
svc
.listReviewAttention(issue.companyId, [issue])
.then((map) => map.get(issue.id) ?? null),
- svc
- .listProductivityReviews(issue.companyId, [issue.id])
- .then((map) => map.get(issue.id) ?? null),
issueReferencesSvc.listIssueReferenceSummary(issue.id),
listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]),
svc.getCurrentScheduledRetry(issue.id),
@@ -8774,7 +8762,6 @@ export function issueRoutes(
ancestors,
...(blockerAttention ? { blockerAttention } : {}),
...(reviewAttention ? { reviewAttention } : {}),
- productivityReview,
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
executionBlocker: await getExecutionBlocker(db, issue.companyId, issue.id),
scheduledRetry,
diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts
index a4cd945783..70f896fe9e 100644
--- a/server/src/services/attention.ts
+++ b/server/src/services/attention.ts
@@ -49,7 +49,6 @@ import type {
IssueReviewPolicy,
} from "@paperclipai/shared";
import { badRequest } from "../errors.js";
-import { PRODUCTIVITY_REVIEW_ORIGIN_KIND } from "./productivity-review.js";
import { budgetService } from "./budgets.js";
import {
BLOCKER_ATTENTION_MAX_DEPTH,
@@ -105,7 +104,6 @@ const SOURCE_RANK: Record = {
const PENDING_INTERACTION_STATUSES = ["pending"] as const;
const OPEN_RECOVERY_STATUSES = ["active", "escalated"] as const;
const HUMAN_RECOVERY_OWNER_TYPES = ["user", "board"] as const;
-const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"] as const;
const FAILED_RUN_STATUSES = ["failed", "timed_out"] as const;
const DETAIL_EXCERPT_LENGTH = 160;
const DETAIL_IMAGE_LIMIT = 3;
@@ -1453,65 +1451,6 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
}));
}
- const productivityRows = await db
- .select({
- id: issues.id,
- companyId: issues.companyId,
- identifier: issues.identifier,
- title: issues.title,
- status: issues.status,
- priority: issues.priority,
- originId: issues.originId,
- originFingerprint: issues.originFingerprint,
- assigneeAgentId: issues.assigneeAgentId,
- assigneeUserId: issues.assigneeUserId,
- createdAt: issues.createdAt,
- updatedAt: issues.updatedAt,
- })
- .from(issues)
- .where(and(
- eq(issues.companyId, companyId),
- eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
- isNull(issues.hiddenAt),
- isNotNull(issues.assigneeUserId),
- notInArray(issues.status, [...PRODUCTIVITY_REVIEW_TERMINAL_STATUSES]),
- ))
- .orderBy(desc(issues.updatedAt), desc(issues.id));
- const [productivitySourceMap, productivityReviewMap, productivityImageMap] = await Promise.all([
- issueSummaryMap(db, companyId, productivityRows.map((row) => row.originId)),
- issueSummaryMap(db, companyId, productivityRows.map((row) => row.id)),
- issueImageMap(db, companyId, productivityRows.map((row) => row.id)),
- ]);
-
- for (const review of productivityRows) {
- const reviewIssue = productivityReviewMap.get(review.id);
- if (!reviewIssue) continue;
- const sourceIssue = review.originId ? productivitySourceMap.get(review.originId) ?? null : null;
- const dedupKey = `productivity_review:${review.originFingerprint ?? review.originId ?? review.id}`;
- add(createItem({
- companyId,
- sourceKind: "productivity_review",
- subject: issueSubject(prefix, reviewIssue),
- whyNow: "Productivity review is awaiting a human decision.",
- decisionVerbs: decisionVerbs(
- { id: "resolve", label: "Resolve", description: "Record a productivity review outcome." },
- { id: "dismiss", label: "Dismiss", description: "Dismiss this review for now." },
- { id: "reassign", label: "Reassign", description: "Move the review to another owner." },
- ),
- inlineResolvable: false,
- entryRule: "Open issue_productivity_review issue assigned to a user.",
- exitRule: "Review issue is done/cancelled or no longer assigned to a user.",
- dedupKey,
- severity: review.priority === "critical" ? "critical" : review.priority === "high" ? "high" : "medium",
- activityAt: toIso(review.updatedAt),
- createdAt: toIso(review.createdAt),
- updatedAt: toIso(review.updatedAt),
- relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null,
- ...issueContext(reviewIssue),
- detail: genericDetail(sourceIssue?.title ?? review.title, issueImages(productivityImageMap, review.id)),
- }));
- }
-
const blockedIssues = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true });
type BlockedAttentionIssue = IssueSubjectRow & {
blockerAttention?: {
diff --git a/server/src/services/decision-queues.ts b/server/src/services/decision-queues.ts
index b95625211e..d529e1d0a8 100644
--- a/server/src/services/decision-queues.ts
+++ b/server/src/services/decision-queues.ts
@@ -236,6 +236,7 @@ async function sourceIssueId(
.then((rows) => rows[0] ?? null);
return { exists: Boolean(row), issueId: row?.issueId ?? null };
}
+ // Keep historical decision queue entries accessible after feature retirement.
case "productivity_review":
case "blocker_attention":
case "review": {
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index e9d433d7d0..2dad540282 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -503,7 +503,6 @@ import {
REVIEW_PATH_RECOVERY_INSTRUCTION,
reviewPathConsumedRefFromRun,
} from "./recovery/review-path-recovery.js";
-import { productivityReviewService } from "./productivity-review.js";
import { resolveRequiredSuccessfulRunHandoffOnValidPath } from "./successful-run-handoff-state.js";
import { taskWatchdogService } from "./task-watchdogs.js";
import { withAgentStartLock } from "./agent-start-lock.js";
@@ -9727,7 +9726,6 @@ export function heartbeatService(
return interaction.id;
}
- const productivityReviews = productivityReviewService(db, { enqueueWakeup });
const taskWatchdogs = taskWatchdogService(db, { enqueueWakeup });
let unsafeTextProjectionPromise: Promise | null = null;
@@ -12595,29 +12593,6 @@ export function heartbeatService(
projectId: issue.projectId,
})
: null;
- if (issue) {
- const productivityHold =
- await productivityReviews.isProductivityReviewContinuationHoldActive({
- companyId: issue.companyId,
- issueId: issue.id,
- agentId: run.agentId,
- });
- if (productivityHold.held) {
- await setRunStatus(run.id, run.status, {
- livenessReason: `${run.livenessReason ?? "Run ended without concrete progress"}; continuation held by productivity review ${productivityHold.reviewIdentifier ?? productivityHold.reviewIssueId}`,
- });
- await productivityReviews.recordContinuationHold({
- companyId: issue.companyId,
- issueId: issue.id,
- runId: run.id,
- agentId: run.agentId,
- reviewIssueId: productivityHold.reviewIssueId,
- trigger: productivityHold.trigger,
- reason: productivityHold.reason,
- });
- return;
- }
- }
const nextAttempt = readContinuationAttempt(run.continuationAttempt) + 1;
const idempotencyKey = issue
@@ -18716,16 +18691,6 @@ export function heartbeatService(
});
}
- async function reconcileProductivityReviews(opts?: {
- now?: Date;
- companyId?: string;
- }) {
- return productivityReviews.reconcileProductivityReviews({
- ...opts,
- issueCreatedAtGte: await getWorktreeExecutionCutoff(),
- });
- }
-
async function reconcileTaskWatchdogs(opts?: {
companyId?: string | null;
runId?: string | null;
@@ -28029,8 +27994,6 @@ export function heartbeatService(
scanSilentActiveRuns,
- reconcileProductivityReviews,
-
reconcileTaskWatchdogs,
buildRunOutputSilence,
diff --git a/server/src/services/index.ts b/server/src/services/index.ts
index 3afb6725e3..15ea31a7ad 100644
--- a/server/src/services/index.ts
+++ b/server/src/services/index.ts
@@ -114,10 +114,6 @@ export {
RunnerGoalActionError,
RunnerGoalConflictError,
} from "./runner-goals.js";
-export {
- productivityReviewService,
- PRODUCTIVITY_REVIEW_ORIGIN_KIND,
-} from "./productivity-review.js";
export { classifyIssueGraphLiveness, type IssueLivenessFinding } from "./recovery/index.js";
export { dashboardService } from "./dashboard.js";
export { sidebarBadgeService } from "./sidebar-badges.js";
diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts
index 1197e3fbae..e7e461da89 100644
--- a/server/src/services/issues.ts
+++ b/server/src/services/issues.ts
@@ -79,8 +79,6 @@ import type {
IssueReviewAttentionPath,
IssueBlockedInboxAttention,
IssueBlockedInboxIssueRef,
- IssueProductivityReview,
- IssueProductivityReviewTrigger,
IssueRelationIssueSummary,
IssueWatchdogSummary,
LowTrustBoundary,
@@ -3238,15 +3236,6 @@ const BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES = [
const BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND =
"harness_liveness_escalation";
const BLOCKER_ATTENTION_CHILD_TERMINAL_STATUSES = ["done", "cancelled"];
-const PRODUCTIVITY_REVIEW_ORIGIN_KIND = "issue_productivity_review";
-const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"];
-const PRODUCTIVITY_REVIEW_ACTIVITY_ACTIONS = [
- "issue.productivity_review_created",
- "issue.productivity_review_updated",
-];
-const PRODUCTIVITY_REVIEW_TRIGGERS: readonly IssueProductivityReviewTrigger[] =
- ["no_comment_streak", "long_active_duration", "high_churn"];
-
function lowTrustBoundaryIssueCondition(
companyId: string,
boundary: (LowTrustBoundary & { companyId: string }) | null | undefined,
@@ -3628,132 +3617,6 @@ async function terminalExplicitBlockersByRoot(
return terminalByRoot;
}
-function readProductivityReviewTrigger(
- value: unknown,
-): IssueProductivityReviewTrigger | null {
- if (typeof value !== "string") return null;
- return PRODUCTIVITY_REVIEW_TRIGGERS.includes(
- value as IssueProductivityReviewTrigger,
- )
- ? (value as IssueProductivityReviewTrigger)
- : null;
-}
-
-function readProductivityReviewStreak(value: unknown): number | null {
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
- return null;
- return Math.floor(value);
-}
-
-async function listIssueProductivityReviewMap(
- dbOrTx: any,
- companyId: string,
- sourceIssueIds: string[],
-): Promise
-