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
-