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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 15:46:35 -05:00 committed by GitHub
parent 663c44cb2b
commit 3bafac12f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 123 additions and 2326 deletions

View File

@ -150,7 +150,7 @@ Paperclips 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.

View File

@ -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.**

View File

@ -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.

View File

@ -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",

View File

@ -1122,8 +1122,6 @@ export type {
IssueBlockedInboxReason,
IssueBlockedInboxSeverity,
IssueBlockedInboxState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
IssueRecoveryAction,
IssueWatchdog,
IssueWatchdogStatus,

View File

@ -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",

View File

@ -683,8 +683,6 @@ export type {
IssueBlockedInboxReason,
IssueBlockedInboxSeverity,
IssueBlockedInboxState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
IssueRecoveryAction,
SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind,

View File

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

View File

@ -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,

View File

@ -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"

View File

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

View File

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

View File

@ -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 () => {

View File

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

View File

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

View File

@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: ReturnType<typeof createDb>;
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<typeof heartbeatRuns.$inferInsert> = [];
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);
});
});

View File

@ -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<typeof setInterval>;
}) as typeof setInterval);
try {
await startServer();
expect(heartbeatServiceMock.sweepStaleIssueLocks).toHaveBeenCalledTimes(1);
expect(intervalCallback).not.toBeNull();
intervalCallback?.();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(heartbeatServiceMock.sweepStaleIssueLocks).toHaveBeenCalledTimes(2);
expect(retiredDetector).not.toHaveBeenCalled();
} finally {
delete (runtime as Partial<typeof runtime>).reconcileProductivityReviews;
setIntervalSpy.mockRestore();
}
});
it("keeps routine ticks and setup cleanup active when heartbeat scheduling is suppressed", async () => {
loadConfigMock.mockReturnValue(buildTestConfig({
heartbeatSchedulerEnabled: true,

View File

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

View File

@ -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,

View File

@ -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<AttentionSourceKind, number> = {
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?: {

View File

@ -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": {

View File

@ -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<boolean> | 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,

View File

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

View File

@ -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<Map<string, IssueProductivityReview>> {
const map = new Map<string, IssueProductivityReview>();
if (sourceIssueIds.length === 0) return map;
const reviewRows: Array<{
sourceIssueId: string | null;
reviewIssueId: string;
reviewIdentifier: string | null;
status: string;
priority: string;
createdAt: Date;
updatedAt: Date;
}> = [];
for (const chunk of chunkList(
[...new Set(sourceIssueIds)],
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const rows = await dbOrTx
.select({
sourceIssueId: issues.originId,
reviewIssueId: issues.id,
reviewIdentifier: issues.identifier,
status: issues.status,
priority: issues.priority,
createdAt: issues.createdAt,
updatedAt: issues.updatedAt,
})
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
inArray(issues.originId, chunk),
visibleIssueCondition(),
notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES),
),
)
.orderBy(desc(issues.createdAt), desc(issues.id));
reviewRows.push(...rows);
}
if (reviewRows.length === 0) return map;
const reviewIssueIds = reviewRows.map((row) => row.reviewIssueId);
const triggerByReviewIssueId = new Map<
string,
{
trigger: IssueProductivityReviewTrigger | null;
noCommentStreak: number | null;
}
>();
for (const chunk of chunkList(
reviewIssueIds,
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const detailRows = await dbOrTx
.select({
entityId: activityLog.entityId,
details: activityLog.details,
createdAt: activityLog.createdAt,
})
.from(activityLog)
.where(
and(
eq(activityLog.companyId, companyId),
eq(activityLog.entityType, "issue"),
inArray(activityLog.entityId, chunk),
inArray(activityLog.action, PRODUCTIVITY_REVIEW_ACTIVITY_ACTIONS),
),
)
.orderBy(desc(activityLog.createdAt));
for (const row of detailRows as Array<{
entityId: string;
details: Record<string, unknown> | null;
createdAt: Date;
}>) {
if (triggerByReviewIssueId.has(row.entityId)) continue;
triggerByReviewIssueId.set(row.entityId, {
trigger: readProductivityReviewTrigger(row.details?.trigger),
noCommentStreak: readProductivityReviewStreak(
row.details?.noCommentStreak,
),
});
}
}
for (const row of reviewRows) {
if (!row.sourceIssueId) continue;
if (map.has(row.sourceIssueId)) continue;
const detail = triggerByReviewIssueId.get(row.reviewIssueId);
map.set(row.sourceIssueId, {
reviewIssueId: row.reviewIssueId,
reviewIdentifier: row.reviewIdentifier,
status: row.status as IssueProductivityReview["status"],
priority: row.priority as IssueProductivityReview["priority"],
trigger: detail?.trigger ?? null,
noCommentStreak: detail?.noCommentStreak ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
}
return map;
}
async function listIssueBlockerAttentionMap(
dbOrTx: any,
companyId: string,
@ -6369,7 +6232,6 @@ async function listBlockedInboxIssues(
blockerAttention?: IssueBlockerAttention;
reviewAttention?: IssueReviewAttention;
blockedInboxAttention: IssueBlockedInboxAttention;
productivityReview?: IssueProductivityReview | null;
liveDescendantCount?: number;
lastActivityAt: Date;
myLastTouchAt?: Date | null;
@ -6418,7 +6280,6 @@ async function listBlockedInboxIssues(
blockedByMap,
blockerAttentionByIssueId,
reviewAttentionByIssueId,
productivityReviewByIssueId,
blockedInboxAttentionByIssueId,
liveDescendantCountByIssueId,
] = await Promise.all([
@ -6432,7 +6293,6 @@ async function listBlockedInboxIssues(
blockedByMapForIssues(dbOrTx, companyId, issueIds),
listIssueBlockerAttentionMap(dbOrTx, companyId, withRuns),
listIssueReviewAttentionMap(dbOrTx, companyId, withRuns),
listIssueProductivityReviewMap(dbOrTx, companyId, issueIds),
listIssueBlockedInboxAttentionMap(dbOrTx, companyId, withRuns),
includeLiveDescendantSummary
? liveDescendantCountMapForIssues(dbOrTx, companyId, issueIds)
@ -6506,9 +6366,6 @@ async function listBlockedInboxIssues(
reviewAttention:
reviewAttentionByIssueId.get(row.id) ?? reviewAttentionNone(),
blockedInboxAttention,
...(productivityReviewByIssueId.has(row.id)
? { productivityReview: productivityReviewByIssueId.get(row.id) }
: {}),
...(includeLiveDescendantSummary
? {
liveDescendantCount:
@ -8160,12 +8017,10 @@ export function issueService(db: Db) {
const [
blockerAttentionByIssueId,
reviewAttentionByIssueId,
productivityReviewByIssueId,
blockedInboxAttentionByIssueId,
] = await Promise.all([
listIssueBlockerAttentionMap(db, companyId, withRuns),
listIssueReviewAttentionMap(db, companyId, withRuns),
listIssueProductivityReviewMap(db, companyId, issueIds),
includeBlockedInboxAttention
? listIssueBlockedInboxAttentionMap(db, companyId, withRuns)
: Promise.resolve(new Map<string, IssueBlockedInboxAttention>()),
@ -8203,9 +8058,6 @@ export function issueService(db: Db) {
liveDescendantCountByIssueId.get(row.id) ?? 0,
}
: {}),
...(productivityReviewByIssueId.has(row.id)
? { productivityReview: productivityReviewByIssueId.get(row.id) }
: {}),
};
});
}
@ -8249,9 +8101,6 @@ export function issueService(db: Db) {
liveDescendantCountByIssueId.get(row.id) ?? 0,
}
: {}),
...(productivityReviewByIssueId.has(row.id)
? { productivityReview: productivityReviewByIssueId.get(row.id) }
: {}),
...deriveIssueUserContext(row, contextUserId, {
myLastCommentAt:
statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
@ -9119,14 +8968,6 @@ export function issueService(db: Db) {
return listIssueReviewAttentionMap(dbOrTx, companyId, issueRows);
},
listProductivityReviews: async (
companyId: string,
sourceIssueIds: string[],
dbOrTx: any = db,
) => {
return listIssueProductivityReviewMap(dbOrTx, companyId, sourceIssueIds);
},
listWakeableBlockedDependents: async (blockerIssueId: string) => {
const blockerIssue = await db
.select({ id: issues.id, companyId: issues.companyId })
@ -10474,8 +10315,8 @@ export function issueService(db: Db) {
createdAt: row.createdAt ?? new Date(),
updatedAt: row.updatedAt ?? new Date(),
// Imported in-progress work did not start at import time; fabricating
// startedAt here trips duration-based sweeps (e.g. productivity
// review). Only a bundle-carried startedAt is written.
// startedAt here would misrepresent its active episode.
// Only a bundle-carried startedAt is written.
startedAt: row.startedAt ?? null,
completedAt:
row.completedAt ?? (row.status === "done" ? new Date() : null),

View File

@ -1,999 +0,0 @@
import { and, asc, desc, eq, gt, gte, inArray, isNull, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { clampIssueRequestDepth } from "@paperclipai/shared";
import {
activityLog,
agents,
companies,
costEvents,
heartbeatRuns,
issueComments,
issues,
projects,
} from "@paperclipai/db";
import { logger } from "../middleware/logger.js";
import { logActivity } from "./activity-log.js";
import { budgetService } from "./budgets.js";
import { issueService } from "./issues.js";
import { visibleIssueCondition } from "./issue-visibility.js";
import { withRecoveryContext } from "./recovery/status-only-context.js";
import { RECOVERY_ORIGIN_KINDS } from "./recovery/origins.js";
export const PRODUCTIVITY_REVIEW_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.issueProductivityReview;
export const DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS = 10;
export const DEFAULT_PRODUCTIVITY_REVIEW_LONG_ACTIVE_HOURS = 6;
export const DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_HOURLY = 10;
export const DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_SIX_HOURS = 30;
export const DEFAULT_PRODUCTIVITY_REVIEW_RESOLVED_SNOOZE_MS = 6 * 60 * 60 * 1000;
export const DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
export const DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS = 3;
export const DEFAULT_PRODUCTIVITY_REVIEW_CREATION_WINDOW_MS = 24 * 60 * 60 * 1000;
export const DEFAULT_PRODUCTIVITY_REVIEW_MAX_CREATIONS_PER_WINDOW = 1;
export const DEFAULT_PRODUCTIVITY_REVIEW_MAX_CONSECUTIVE_NO_ACTION_REVIEWS = 3;
const TERMINAL_RUN_STATUSES = ["succeeded", "interrupted", "failed", "cancelled", "timed_out"] as const;
const ACTIVE_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
const MAX_CANDIDATE_ISSUES = 250;
const MAX_RUNS_FOR_STREAK = 100;
const MAX_PARENT_WALK_DEPTH = 25;
export const PRODUCTIVITY_REVIEW_REFRESH_COMMENT_PREFIX = "Productivity review evidence refreshed.";
type IssueRow = typeof issues.$inferSelect;
type AgentRow = typeof agents.$inferSelect;
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
// Evidence only reads these run fields; selecting the full row detoasts
// result_json/context_snapshot for up to MAX_RUNS_FOR_STREAK runs per issue.
type ProductivityRunSample = Pick<
HeartbeatRunRow,
"id" | "agentId" | "status" | "livenessState" | "createdAt" | "nextAction" | "usageJson"
>;
type ProductivityReviewTrigger = "no_comment_streak" | "long_active_duration" | "high_churn";
type ProductivityReviewThresholds = {
noCommentStreakRuns: number;
longActiveMs: number;
highChurnHourly: number;
highChurnSixHours: number;
resolvedSnoozeMs: number;
refreshIntervalMs: number;
maxRefreshComments: number;
creationWindowMs: number;
maxCreationsPerWindow: number;
maxConsecutiveNoActionReviews: number;
};
type ProductivityReviewEvidence = {
trigger: ProductivityReviewTrigger;
triggerReasons: string[];
sourceIssue: IssueRow;
sourceAgent: AgentRow;
noCommentStreak: number;
totalRunCount: number;
terminalRunCount: number;
activeRunCount: number;
runCountLastHour: number;
runCountLastSixHours: number;
commentCount: number;
commentCountLastHour: number;
commentCountLastSixHours: number;
elapsedMs: number | null;
latestRuns: ProductivityRunSample[];
latestComments: Array<typeof issueComments.$inferSelect>;
costCents: number;
usageSamples: Array<{ runId: string; usageJson: Record<string, unknown> | null }>;
nextAction: string | null;
thresholds: ProductivityReviewThresholds;
generatedAt: Date;
};
type EnqueueWakeup = (
agentId: string,
opts?: {
source?: "timer" | "assignment" | "on_demand" | "automation";
triggerDetail?: "manual" | "ping" | "callback" | "system";
reason?: string | null;
payload?: Record<string, unknown> | null;
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
contextSnapshot?: Record<string, unknown>;
},
) => Promise<unknown | null>;
function productivityReviewFingerprint(sourceIssueId: string) {
return `productivity-review:${sourceIssueId}`;
}
function issueRunScopeSql(issueId: string) {
return sql`(
${heartbeatRuns.contextSnapshot}->>'issueId' = ${issueId}
or ${heartbeatRuns.contextSnapshot}->>'taskId' = ${issueId}
or ${heartbeatRuns.contextSnapshot}->>'taskKey' = ${issueId}
)`;
}
function msToHuman(ms: number | null) {
if (ms === null) return "unknown";
const minutes = Math.floor(ms / 60_000);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
return `${hours}h ${minutes % 60}m`;
}
function issueUiLink(issue: { identifier: string | null; id: string }, prefix: string) {
const label = issue.identifier ?? issue.id;
return `[${label}](/${prefix}/issues/${label})`;
}
function runUiLink(run: { id: string; agentId: string }, prefix: string) {
return `[${run.id}](/${prefix}/agents/${run.agentId}/runs/${run.id})`;
}
function truncateInline(value: string | null | undefined, max = 260) {
if (!value) return "";
const compact = value.replace(/\s+/g, " ").trim();
return compact.length <= max ? compact : `${compact.slice(0, max - 3)}...`;
}
function readPositiveInteger(value: number, fallback: number) {
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
}
function coerceDate(value: Date | string | null | undefined) {
if (!value) return null;
return value instanceof Date ? value : new Date(value);
}
function buildThresholds(overrides?: Partial<ProductivityReviewThresholds>): ProductivityReviewThresholds {
return {
noCommentStreakRuns: readPositiveInteger(
overrides?.noCommentStreakRuns ?? DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
),
longActiveMs: readPositiveInteger(
overrides?.longActiveMs ?? DEFAULT_PRODUCTIVITY_REVIEW_LONG_ACTIVE_HOURS * 60 * 60 * 1000,
DEFAULT_PRODUCTIVITY_REVIEW_LONG_ACTIVE_HOURS * 60 * 60 * 1000,
),
highChurnHourly: readPositiveInteger(
overrides?.highChurnHourly ?? DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_HOURLY,
DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_HOURLY,
),
highChurnSixHours: readPositiveInteger(
overrides?.highChurnSixHours ?? DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_SIX_HOURS,
DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_SIX_HOURS,
),
resolvedSnoozeMs: readPositiveInteger(
overrides?.resolvedSnoozeMs ?? DEFAULT_PRODUCTIVITY_REVIEW_RESOLVED_SNOOZE_MS,
DEFAULT_PRODUCTIVITY_REVIEW_RESOLVED_SNOOZE_MS,
),
refreshIntervalMs: readPositiveInteger(
overrides?.refreshIntervalMs ?? DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS,
DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS,
),
maxRefreshComments: readPositiveInteger(
overrides?.maxRefreshComments ?? DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS,
DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS,
),
creationWindowMs: readPositiveInteger(
overrides?.creationWindowMs ?? DEFAULT_PRODUCTIVITY_REVIEW_CREATION_WINDOW_MS,
DEFAULT_PRODUCTIVITY_REVIEW_CREATION_WINDOW_MS,
),
maxCreationsPerWindow: readPositiveInteger(
overrides?.maxCreationsPerWindow ?? DEFAULT_PRODUCTIVITY_REVIEW_MAX_CREATIONS_PER_WINDOW,
DEFAULT_PRODUCTIVITY_REVIEW_MAX_CREATIONS_PER_WINDOW,
),
maxConsecutiveNoActionReviews: readPositiveInteger(
overrides?.maxConsecutiveNoActionReviews ?? DEFAULT_PRODUCTIVITY_REVIEW_MAX_CONSECUTIVE_NO_ACTION_REVIEWS,
DEFAULT_PRODUCTIVITY_REVIEW_MAX_CONSECUTIVE_NO_ACTION_REVIEWS,
),
};
}
function choosePrimaryTrigger(input: {
noComment: boolean;
longActive: boolean;
highChurn: boolean;
}): ProductivityReviewTrigger | null {
if (input.noComment) return "no_comment_streak";
if (input.highChurn) return "high_churn";
if (input.longActive) return "long_active_duration";
return null;
}
function isSoftStopTrigger(trigger: ProductivityReviewTrigger) {
return trigger === "no_comment_streak" || trigger === "high_churn";
}
function formatTrigger(trigger: ProductivityReviewTrigger) {
if (trigger === "no_comment_streak") return "No-comment streak";
if (trigger === "high_churn") return "High churn";
return "Long active duration";
}
export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: EnqueueWakeup }) {
const issuesSvc = issueService(db);
const budgets = budgetService(db);
async function getCompanyIssuePrefix(companyId: string) {
return db
.select({ issuePrefix: companies.issuePrefix })
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0]?.issuePrefix ?? "PAP");
}
async function getAgent(agentId: string) {
return db
.select()
.from(agents)
.where(eq(agents.id, agentId))
.then((rows) => rows[0] ?? null);
}
function isAgentInvokable(agent: AgentRow | null | undefined) {
return Boolean(agent && !["paused", "terminated", "pending_approval"].includes(agent.status));
}
async function isProductivityReviewDescendant(issue: Pick<IssueRow, "companyId" | "parentId">) {
let parentId = issue.parentId;
let depth = 0;
while (parentId && depth < MAX_PARENT_WALK_DEPTH) {
const parent = await db
.select({ id: issues.id, parentId: issues.parentId, originKind: issues.originKind })
.from(issues)
.where(and(eq(issues.companyId, issue.companyId), eq(issues.id, parentId)))
.then((rows) => rows[0] ?? null);
if (!parent) return false;
if (parent.originKind === PRODUCTIVITY_REVIEW_ORIGIN_KIND) return true;
parentId = parent.parentId;
depth += 1;
}
return false;
}
async function findOpenProductivityReview(companyId: string, sourceIssueId: string) {
return db
.select()
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
eq(issues.originId, sourceIssueId),
visibleIssueCondition(),
notInArray(issues.status, ["done", "cancelled"]),
),
)
.orderBy(desc(issues.updatedAt))
.limit(1)
.then((rows) => rows[0] ?? null);
}
async function findRecentTerminalProductivityReview(
companyId: string,
sourceIssueId: string,
thresholds: ProductivityReviewThresholds,
now: Date,
) {
const cutoff = new Date(now.getTime() - thresholds.resolvedSnoozeMs);
return db
.select({ id: issues.id, identifier: issues.identifier, status: issues.status, updatedAt: issues.updatedAt })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
eq(issues.originId, sourceIssueId),
inArray(issues.status, ["done", "cancelled"]),
gt(issues.updatedAt, cutoff),
),
)
.orderBy(desc(issues.updatedAt))
.limit(1)
.then((rows) => rows[0] ?? null);
}
async function countRecentProductivityReviews(
companyId: string,
sourceIssueId: string,
thresholds: ProductivityReviewThresholds,
now: Date,
) {
const cutoff = new Date(now.getTime() - thresholds.creationWindowMs);
return db
.select({ count: sql<number>`count(*)::int` })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
eq(issues.originId, sourceIssueId),
visibleIssueCondition(),
sql`${issues.status} <> 'cancelled'`,
sql`${issues.createdAt} >= ${cutoff.toISOString()}::timestamptz`,
),
)
.then((rows) => Number(rows[0]?.count ?? 0));
}
async function countConsecutiveNoActionProductivityReviews(
companyId: string,
sourceIssueId: string,
thresholds: ProductivityReviewThresholds,
) {
const completedReviews = await db
.select({
createdAt: issues.createdAt,
})
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
eq(issues.originId, sourceIssueId),
eq(issues.status, "done"),
visibleIssueCondition(),
),
)
.orderBy(desc(issues.createdAt), desc(issues.id))
.limit(thresholds.maxConsecutiveNoActionReviews);
const earliestReviewCreatedAt = completedReviews.at(-1)?.createdAt;
if (!earliestReviewCreatedAt) return 0;
const sourceActions = await db
.select({ createdAt: activityLog.createdAt })
.from(activityLog)
.where(
and(
eq(activityLog.companyId, companyId),
eq(activityLog.entityType, "issue"),
eq(activityLog.entityId, sourceIssueId),
gte(activityLog.createdAt, earliestReviewCreatedAt),
),
);
let streak = 0;
for (const [index, review] of completedReviews.entries()) {
const nextNewerReviewCreatedAt = completedReviews[index - 1]?.createdAt ?? null;
const sourceAction = sourceActions.some((activity) => {
if (activity.createdAt < review.createdAt) return false;
return !nextNewerReviewCreatedAt || activity.createdAt < nextNewerReviewCreatedAt;
});
if (sourceAction) break;
streak += 1;
}
return streak;
}
async function getRefreshCommentState(companyId: string, reviewIssueId: string) {
return db
.select({
count: sql<number>`count(*)::int`,
latestCreatedAt: sql<Date | null>`max(${issueComments.createdAt})`,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, companyId),
eq(issueComments.issueId, reviewIssueId),
sql`${issueComments.body} like ${`${PRODUCTIVITY_REVIEW_REFRESH_COMMENT_PREFIX}%`}`,
),
)
.then((rows) => {
const row = rows[0];
return {
count: Number(row?.count ?? 0),
latestCreatedAt: coerceDate(row?.latestCreatedAt),
};
});
}
async function addRefreshComment(
reviewIssueId: string,
body: string,
generatedAt: Date,
) {
const comment = await issuesSvc.addComment(reviewIssueId, body, {});
await db
.update(issueComments)
.set({ createdAt: generatedAt, updatedAt: generatedAt })
.where(eq(issueComments.id, comment.id));
await db
.update(issues)
.set({ updatedAt: generatedAt })
.where(eq(issues.id, reviewIssueId));
return comment;
}
async function countIssueRunsSince(companyId: string, agentId: string, issueId: string, since: Date) {
return db
.select({ count: sql<number>`count(*)::int` })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.agentId, agentId),
issueRunScopeSql(issueId),
sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) >= ${since.toISOString()}::timestamptz`,
),
)
.then((rows) => rows[0]?.count ?? 0);
}
async function countIssueCommentsSince(companyId: string, issueId: string, agentId: string, since?: Date) {
return db
.select({ count: sql<number>`count(*)::int` })
.from(issueComments)
.innerJoin(heartbeatRuns, eq(heartbeatRuns.id, issueComments.createdByRunId))
.where(
and(
eq(issueComments.companyId, companyId),
eq(issueComments.issueId, issueId),
eq(issueComments.authorAgentId, agentId),
eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.agentId, agentId),
issueRunScopeSql(issueId),
since ? sql`${issueComments.createdAt} >= ${since.toISOString()}::timestamptz` : undefined,
),
)
.then((rows) => rows[0]?.count ?? 0);
}
async function collectEvidence(
sourceIssue: IssueRow,
sourceAgent: AgentRow,
thresholds: ProductivityReviewThresholds,
now: Date,
): Promise<ProductivityReviewEvidence | null> {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const sixHoursAgo = new Date(now.getTime() - 6 * 60 * 60 * 1000);
const latestRuns = await db
.select({
id: heartbeatRuns.id,
agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status,
livenessState: heartbeatRuns.livenessState,
createdAt: heartbeatRuns.createdAt,
nextAction: heartbeatRuns.nextAction,
usageJson: heartbeatRuns.usageJson,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, sourceIssue.companyId),
eq(heartbeatRuns.agentId, sourceAgent.id),
issueRunScopeSql(sourceIssue.id),
),
)
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id))
.limit(MAX_RUNS_FOR_STREAK);
const runIds = latestRuns.map((run) => run.id);
const commentRunIds = new Set<string>();
if (runIds.length > 0) {
const commentRows = await db
.select({ createdByRunId: issueComments.createdByRunId })
.from(issueComments)
.where(
and(
eq(issueComments.companyId, sourceIssue.companyId),
eq(issueComments.issueId, sourceIssue.id),
inArray(issueComments.createdByRunId, runIds),
),
);
for (const row of commentRows) {
if (row.createdByRunId) commentRunIds.add(row.createdByRunId);
}
}
const terminalRuns = latestRuns.filter((run) =>
TERMINAL_RUN_STATUSES.includes(run.status as (typeof TERMINAL_RUN_STATUSES)[number]),
);
let noCommentStreak = 0;
for (const run of terminalRuns) {
if (commentRunIds.has(run.id)) break;
noCommentStreak += 1;
}
const [
runCountLastHour,
runCountLastSixHours,
assigneeRunCommentCount,
assigneeRunCommentCountLastHour,
assigneeRunCommentCountLastSixHours,
latestComments,
costRow,
] = await Promise.all([
countIssueRunsSince(sourceIssue.companyId, sourceAgent.id, sourceIssue.id, oneHourAgo),
countIssueRunsSince(sourceIssue.companyId, sourceAgent.id, sourceIssue.id, sixHoursAgo),
countIssueCommentsSince(sourceIssue.companyId, sourceIssue.id, sourceAgent.id),
countIssueCommentsSince(sourceIssue.companyId, sourceIssue.id, sourceAgent.id, oneHourAgo),
countIssueCommentsSince(sourceIssue.companyId, sourceIssue.id, sourceAgent.id, sixHoursAgo),
db
.select({ comment: issueComments })
.from(issueComments)
.innerJoin(heartbeatRuns, eq(heartbeatRuns.id, issueComments.createdByRunId))
.where(
and(
eq(issueComments.companyId, sourceIssue.companyId),
eq(issueComments.issueId, sourceIssue.id),
eq(issueComments.authorAgentId, sourceAgent.id),
eq(heartbeatRuns.companyId, sourceIssue.companyId),
eq(heartbeatRuns.agentId, sourceAgent.id),
issueRunScopeSql(sourceIssue.id),
),
)
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
.limit(5)
.then((rows) => rows.map((row) => row.comment)),
db
.select({ costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int` })
.from(costEvents)
.where(and(eq(costEvents.companyId, sourceIssue.companyId), eq(costEvents.issueId, sourceIssue.id)))
.then((rows) => rows[0] ?? { costCents: 0 }),
]);
const activeRunCount = latestRuns.filter((run) =>
ACTIVE_RUN_STATUSES.includes(run.status as (typeof ACTIVE_RUN_STATUSES)[number]),
).length;
const activeStartedAt = sourceIssue.startedAt ?? sourceIssue.executionLockedAt ?? null;
const elapsedMs = sourceIssue.status === "in_progress" && activeStartedAt
? Math.max(0, now.getTime() - activeStartedAt.getTime())
: null;
const noComment = noCommentStreak >= thresholds.noCommentStreakRuns;
const longActive = elapsedMs !== null && elapsedMs >= thresholds.longActiveMs;
const highChurn =
runCountLastHour >= thresholds.highChurnHourly ||
assigneeRunCommentCountLastHour >= thresholds.highChurnHourly ||
runCountLastSixHours >= thresholds.highChurnSixHours ||
assigneeRunCommentCountLastSixHours >= thresholds.highChurnSixHours;
const trigger = choosePrimaryTrigger({ noComment, longActive, highChurn });
if (!trigger) return null;
const triggerReasons: string[] = [];
if (noComment) triggerReasons.push(`${noCommentStreak} consecutive completed issue-linked runs had no run-created issue comment`);
if (longActive) triggerReasons.push(`current active episode has lasted ${msToHuman(elapsedMs)}`);
if (highChurn) {
triggerReasons.push(
`${runCountLastHour} runs/${assigneeRunCommentCountLastHour} assignee-run comments in 1h; ${runCountLastSixHours} runs/${assigneeRunCommentCountLastSixHours} assignee-run comments in 6h`,
);
}
return {
trigger,
triggerReasons,
sourceIssue,
sourceAgent,
noCommentStreak,
totalRunCount: latestRuns.length,
terminalRunCount: terminalRuns.length,
activeRunCount,
runCountLastHour,
runCountLastSixHours,
commentCount: assigneeRunCommentCount,
commentCountLastHour: assigneeRunCommentCountLastHour,
commentCountLastSixHours: assigneeRunCommentCountLastSixHours,
elapsedMs,
latestRuns: latestRuns.slice(0, 5),
latestComments,
costCents: costRow.costCents,
usageSamples: latestRuns
.filter((run) => run.usageJson)
.slice(0, 3)
.map((run) => ({ runId: run.id, usageJson: run.usageJson ?? null })),
nextAction: latestRuns.find((run) => run.nextAction)?.nextAction ?? null,
thresholds,
generatedAt: now,
};
}
async function resolveReviewOwnerAgentId(sourceIssue: IssueRow, sourceAgent: AgentRow) {
const candidateIds: string[] = [];
if (sourceAgent.reportsTo) candidateIds.push(sourceAgent.reportsTo);
if (sourceIssue.createdByAgentId) candidateIds.push(sourceIssue.createdByAgentId);
if (sourceIssue.projectId) {
const project = await db
.select({ leadAgentId: projects.leadAgentId })
.from(projects)
.where(and(eq(projects.companyId, sourceIssue.companyId), eq(projects.id, sourceIssue.projectId)))
.then((rows) => rows[0] ?? null);
if (project?.leadAgentId) candidateIds.push(project.leadAgentId);
}
const roleCandidates = await db
.select({ id: agents.id })
.from(agents)
.where(and(eq(agents.companyId, sourceIssue.companyId), inArray(agents.role, ["cto", "ceo"])))
.orderBy(sql`case when ${agents.role} = 'cto' then 0 else 1 end`, asc(agents.createdAt), asc(agents.id));
candidateIds.push(...roleCandidates.map((agent) => agent.id));
const seen = new Set<string>();
for (const agentId of candidateIds) {
if (seen.has(agentId)) continue;
seen.add(agentId);
const candidate = await getAgent(agentId);
if (!candidate || candidate.companyId !== sourceIssue.companyId || !isAgentInvokable(candidate)) continue;
const budgetBlock = await budgets.getInvocationBlock(sourceIssue.companyId, candidate.id, {
issueId: sourceIssue.id,
projectId: sourceIssue.projectId ?? null,
});
if (!budgetBlock) return candidate.id;
}
return null;
}
function buildReviewMarkdown(evidence: ProductivityReviewEvidence, prefix: string) {
const latestRuns = evidence.latestRuns.length > 0
? evidence.latestRuns.map((run) =>
`- ${runUiLink(run, prefix)} \`${run.status}\` liveness \`${run.livenessState ?? "unknown"}\`, created ${run.createdAt.toISOString()}${run.nextAction ? `, next action: ${truncateInline(run.nextAction, 160)}` : ""}`,
).join("\n")
: "- none";
const latestComments = evidence.latestComments.length > 0
? evidence.latestComments.map((comment) =>
`- ${comment.createdAt.toISOString()}${comment.createdByRunId ? ` run \`${comment.createdByRunId}\`` : ""}: ${truncateInline(comment.body)}`,
).join("\n")
: "- none";
const usage = evidence.usageSamples.length > 0
? evidence.usageSamples.map((sample) => `- \`${sample.runId}\`: \`${JSON.stringify(sample.usageJson).slice(0, 500)}\``).join("\n")
: "- no usage payloads on sampled runs";
return [
"Paperclip detected an unusual productivity/progression pattern on an assigned issue.",
"",
"## Source",
"",
`- Source issue: ${issueUiLink(evidence.sourceIssue, prefix)}`,
`- Assigned agent: ${evidence.sourceAgent.name} (${evidence.sourceAgent.role})`,
`- Primary trigger: \`${evidence.trigger}\` (${formatTrigger(evidence.trigger)})`,
`- Trigger reasons: ${evidence.triggerReasons.join("; ")}`,
`- Generated at: ${evidence.generatedAt.toISOString()}`,
"",
"## Evidence",
"",
`- Total sampled issue-linked runs: ${evidence.totalRunCount}`,
`- Terminal sampled runs: ${evidence.terminalRunCount}`,
`- Active queued/running/scheduled runs: ${evidence.activeRunCount}`,
`- No-comment completed-run streak: ${evidence.noCommentStreak}`,
`- Current active elapsed time: ${msToHuman(evidence.elapsedMs)}`,
`- Runs in rolling windows: ${evidence.runCountLastHour}/1h, ${evidence.runCountLastSixHours}/6h`,
`- Assignee run-linked comments total/window: ${evidence.commentCount} total, ${evidence.commentCountLastHour}/1h, ${evidence.commentCountLastSixHours}/6h`,
`- Cost events total: ${evidence.costCents} cents`,
`- Current next action: ${evidence.nextAction ? truncateInline(evidence.nextAction, 500) : "none recorded"}`,
"",
"## Thresholds",
"",
`- No-comment streak: ${evidence.thresholds.noCommentStreakRuns} completed runs`,
`- Long active duration: ${msToHuman(evidence.thresholds.longActiveMs)}`,
`- High churn: ${evidence.thresholds.highChurnHourly}/1h or ${evidence.thresholds.highChurnSixHours}/6h runs/assignee-run comments`,
`- Resolved-review snooze: ${msToHuman(evidence.thresholds.resolvedSnoozeMs)}`,
"",
"## Latest Runs",
"",
latestRuns,
"",
"## Latest Assignee Run Comments",
"",
latestComments,
"",
"## Usage Samples",
"",
usage,
"",
"## Manager Decision",
"",
"- Close as productive if this pattern is expected.",
"- Continue with a snooze window if the current work should keep running without repeat review spam.",
"- Request decomposition, reroute, block with an unblock owner, or stop/cancel the source work if the work is inefficient.",
].join("\n");
}
function buildRefreshComment(evidence: ProductivityReviewEvidence, prefix: string) {
return [
"Productivity review evidence refreshed.",
"",
`- Source issue: ${issueUiLink(evidence.sourceIssue, prefix)}`,
`- Trigger: \`${evidence.trigger}\` (${formatTrigger(evidence.trigger)})`,
`- Reasons: ${evidence.triggerReasons.join("; ")}`,
`- No-comment streak: ${evidence.noCommentStreak}`,
`- Runs/assignee comments: ${evidence.runCountLastHour}/${evidence.commentCountLastHour} in 1h, ${evidence.runCountLastSixHours}/${evidence.commentCountLastSixHours} in 6h`,
`- Next action: ${evidence.nextAction ? truncateInline(evidence.nextAction, 300) : "none recorded"}`,
].join("\n");
}
async function createOrUpdateReview(
evidence: ProductivityReviewEvidence,
opts: { prefix: string; thresholds: ProductivityReviewThresholds },
) {
const existing = await findOpenProductivityReview(evidence.sourceIssue.companyId, evidence.sourceIssue.id);
if (existing) {
const refreshState = await getRefreshCommentState(evidence.sourceIssue.companyId, existing.id);
const lastRefreshOrCreationAt = refreshState.latestCreatedAt ?? existing.createdAt;
if (
refreshState.count >= opts.thresholds.maxRefreshComments ||
evidence.generatedAt.getTime() - lastRefreshOrCreationAt.getTime() < opts.thresholds.refreshIntervalMs
) {
return { kind: "existing" as const, reviewIssueId: existing.id };
}
await addRefreshComment(existing.id, buildRefreshComment(evidence, opts.prefix), evidence.generatedAt);
await logActivity(db, {
companyId: evidence.sourceIssue.companyId,
actorType: "system",
actorId: "system",
action: "issue.productivity_review_updated",
entityType: "issue",
entityId: existing.id,
agentId: existing.assigneeAgentId,
details: {
source: "productivity_review.reconcile",
sourceIssueId: evidence.sourceIssue.id,
trigger: evidence.trigger,
noCommentStreak: evidence.noCommentStreak,
runCountLastHour: evidence.runCountLastHour,
commentCountLastHour: evidence.commentCountLastHour,
},
});
return { kind: "updated" as const, reviewIssueId: existing.id };
}
const recentCreationCount = await countRecentProductivityReviews(
evidence.sourceIssue.companyId,
evidence.sourceIssue.id,
opts.thresholds,
evidence.generatedAt,
);
if (recentCreationCount >= opts.thresholds.maxCreationsPerWindow) {
return { kind: "creation_capped" as const, reviewIssueId: null };
}
const consecutiveNoActionReviews = await countConsecutiveNoActionProductivityReviews(
evidence.sourceIssue.companyId,
evidence.sourceIssue.id,
opts.thresholds,
);
if (consecutiveNoActionReviews >= opts.thresholds.maxConsecutiveNoActionReviews) {
return { kind: "no_action_suppressed" as const, reviewIssueId: null };
}
const ownerAgentId = await resolveReviewOwnerAgentId(evidence.sourceIssue, evidence.sourceAgent);
let review: Awaited<ReturnType<typeof issuesSvc.create>>;
try {
review = await issuesSvc.create(evidence.sourceIssue.companyId, {
title: `Review productivity for ${evidence.sourceIssue.identifier ?? evidence.sourceIssue.title}`,
description: buildReviewMarkdown(evidence, opts.prefix),
status: "todo",
priority: evidence.trigger === "long_active_duration" ? "medium" : "high",
parentId: evidence.sourceIssue.id,
projectId: evidence.sourceIssue.projectId,
goalId: evidence.sourceIssue.goalId,
billingCode: evidence.sourceIssue.billingCode,
assigneeAgentId: ownerAgentId,
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
originId: evidence.sourceIssue.id,
originFingerprint: productivityReviewFingerprint(evidence.sourceIssue.id),
requestDepth: clampIssueRequestDepth(evidence.sourceIssue.requestDepth + 1),
});
} catch (error) {
const maybe = error as { code?: string; constraint?: string; message?: string };
const uniqueConflict = maybe.code === "23505" &&
(
maybe.constraint === "issues_active_productivity_review_uq" ||
typeof maybe.message === "string" && maybe.message.includes("issues_active_productivity_review_uq")
);
if (!uniqueConflict) throw error;
const raced = await findOpenProductivityReview(evidence.sourceIssue.companyId, evidence.sourceIssue.id);
if (!raced) throw error;
return { kind: "existing" as const, reviewIssueId: raced.id };
}
await db
.update(issues)
.set({ createdAt: evidence.generatedAt, updatedAt: evidence.generatedAt })
.where(eq(issues.id, review.id));
await logActivity(db, {
companyId: evidence.sourceIssue.companyId,
actorType: "system",
actorId: "system",
action: "issue.productivity_review_created",
entityType: "issue",
entityId: review.id,
agentId: ownerAgentId,
details: {
source: "productivity_review.reconcile",
sourceIssueId: evidence.sourceIssue.id,
trigger: evidence.trigger,
noCommentStreak: evidence.noCommentStreak,
runCountLastHour: evidence.runCountLastHour,
commentCountLastHour: evidence.commentCountLastHour,
},
});
if (ownerAgentId && deps?.enqueueWakeup) {
await deps.enqueueWakeup(ownerAgentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: withRecoveryContext({
issueId: review.id,
sourceIssueId: evidence.sourceIssue.id,
trigger: evidence.trigger,
}, "status_only"),
requestedByActorType: "system",
requestedByActorId: "productivity_review",
contextSnapshot: withRecoveryContext({
issueId: review.id,
taskId: review.id,
wakeReason: "issue_assigned",
source: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
sourceIssueId: evidence.sourceIssue.id,
productivityReviewTrigger: evidence.trigger,
}, "status_only"),
});
}
return { kind: "created" as const, reviewIssueId: review.id };
}
async function reconcileProductivityReviews(opts?: {
now?: Date;
companyId?: string;
thresholds?: Partial<ProductivityReviewThresholds>;
issueCreatedAtGte?: Date | null;
}) {
const now = opts?.now ?? new Date();
const thresholds = buildThresholds(opts?.thresholds);
const candidates = await db
.select()
.from(issues)
.where(
and(
opts?.companyId ? eq(issues.companyId, opts.companyId) : undefined,
visibleIssueCondition(),
isNull(issues.assigneeUserId),
inArray(issues.status, ["todo", "in_progress"]),
sql`${issues.assigneeAgentId} is not null`,
sql`${issues.originKind} <> ${PRODUCTIVITY_REVIEW_ORIGIN_KIND}`,
opts?.issueCreatedAtGte ? gte(issues.createdAt, opts.issueCreatedAtGte) : undefined,
),
)
.orderBy(asc(issues.updatedAt), asc(issues.id))
.limit(MAX_CANDIDATE_ISSUES);
const result = {
scanned: candidates.length,
created: 0,
updated: 0,
existing: 0,
snoozed: 0,
creationCapped: 0,
noActionSuppressed: 0,
skipped: 0,
failed: 0,
reviewIssueIds: [] as string[],
failedIssueIds: [] as string[],
};
const prefixCache = new Map<string, string>();
for (const candidate of candidates) {
if (!candidate.assigneeAgentId) {
result.skipped += 1;
continue;
}
if (await isProductivityReviewDescendant(candidate)) {
result.skipped += 1;
continue;
}
if (await findRecentTerminalProductivityReview(candidate.companyId, candidate.id, thresholds, now)) {
result.snoozed += 1;
continue;
}
const sourceAgent = await getAgent(candidate.assigneeAgentId);
if (!sourceAgent || sourceAgent.companyId !== candidate.companyId) {
result.skipped += 1;
continue;
}
// A paused assignee cannot act on a review, so raising one only creates noise.
if (sourceAgent.status === "paused") {
result.skipped += 1;
continue;
}
const evidence = await collectEvidence(candidate, sourceAgent, thresholds, now);
if (!evidence) {
result.skipped += 1;
continue;
}
let prefix = prefixCache.get(candidate.companyId);
if (!prefix) {
prefix = await getCompanyIssuePrefix(candidate.companyId);
prefixCache.set(candidate.companyId, prefix);
}
try {
const outcome = await createOrUpdateReview(evidence, { prefix, thresholds });
if (outcome.kind === "created") result.created += 1;
else if (outcome.kind === "updated") result.updated += 1;
else if (outcome.kind === "creation_capped") result.creationCapped += 1;
else if (outcome.kind === "no_action_suppressed") result.noActionSuppressed += 1;
else result.existing += 1;
if (outcome.reviewIssueId) result.reviewIssueIds.push(outcome.reviewIssueId);
} catch (err) {
result.failed += 1;
result.failedIssueIds.push(candidate.id);
logger.warn(
{
err,
companyId: candidate.companyId,
issueId: candidate.id,
requestDepth: candidate.requestDepth,
},
"productivity review reconciliation skipped malformed candidate",
);
}
}
return result;
}
async function isProductivityReviewContinuationHoldActive(input: {
companyId: string;
issueId: string;
agentId: string;
now?: Date;
thresholds?: Partial<ProductivityReviewThresholds>;
}) {
const now = input.now ?? new Date();
const thresholds = buildThresholds(input.thresholds);
const [sourceIssue, sourceAgent, openReview] = await Promise.all([
db
.select()
.from(issues)
.where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.issueId)))
.then((rows) => rows[0] ?? null),
getAgent(input.agentId),
findOpenProductivityReview(input.companyId, input.issueId),
]);
if (!sourceIssue || !sourceAgent || !openReview) return { held: false as const };
if (sourceAgent.companyId !== input.companyId) return { held: false as const };
const evidence = await collectEvidence(sourceIssue, sourceAgent, thresholds, now);
if (!evidence || !isSoftStopTrigger(evidence.trigger)) return { held: false as const };
return {
held: true as const,
reviewIssueId: openReview.id,
reviewIdentifier: openReview.identifier,
trigger: evidence.trigger,
reason: evidence.triggerReasons.join("; "),
};
}
async function recordContinuationHold(input: {
companyId: string;
issueId: string;
runId: string;
agentId: string;
reviewIssueId: string;
trigger: ProductivityReviewTrigger;
reason: string;
}) {
await logActivity(db, {
companyId: input.companyId,
actorType: "system",
actorId: "system",
agentId: input.agentId,
runId: input.runId,
action: "issue.productivity_review_continuation_held",
entityType: "issue",
entityId: input.issueId,
details: {
source: "productivity_review.continuation_hold",
reviewIssueId: input.reviewIssueId,
trigger: input.trigger,
reason: input.reason,
},
});
}
return {
reconcileProductivityReviews,
isProductivityReviewContinuationHoldActive,
recordContinuationHold,
};
}

View File

@ -1,5 +1,6 @@
export const RECOVERY_ORIGIN_KINDS = {
issueGraphLivenessEscalation: "harness_liveness_escalation",
// Historical tasks retain their origin and recovery-recursion exclusion.
issueProductivityReview: "issue_productivity_review",
strandedIssueRecovery: "stranded_issue_recovery",
staleActiveRunEvaluation: "stale_active_run_evaluation",

View File

@ -2,7 +2,7 @@ import { requiresExecutionReconciliation } from "@paperclipai/shared";
import type { ReactNode } from "react";
import type { ExternalObjectSummary, Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Archive, Eye, Flag } from "lucide-react";
import { Archive, Flag } from "lucide-react";
import {
createIssueDetailPath,
rememberIssueDetailLocationState,
@ -20,7 +20,6 @@ import {
type RecoveryLivenessContext,
} from "../lib/recovery-lineage";
import { StatusIcon } from "./StatusIcon";
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary";
import { Badge } from "@/components/ui/badge";
@ -192,19 +191,6 @@ export function IssueRow({
);
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
const productivityReview = issue.productivityReview ?? null;
const productivityReviewIndicator = productivityReview ? (
<span
className={cn(
"inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-300",
selected ? "border-muted-foreground text-muted-foreground" : null,
)}
title={`Productivity review: ${productivityReviewTriggerLabel(productivityReview.trigger)}`}
aria-label="Productivity review open"
>
<Eye className="h-2.5 w-2.5" aria-hidden />
</span>
) : null;
const hasChecklistStep = checklistStepNumber !== null;
const checklistStep = hasChecklistStep ? (
<span className="shrink-0 font-mono text-xs text-muted-foreground" aria-hidden="true">
@ -305,7 +291,6 @@ export function IssueRow({
className={selectedStatusClass}
/>
)}
{productivityReviewIndicator}
{parkedBlockerIndicator}
</span>
@ -401,7 +386,6 @@ export function IssueRow({
</Link>
<span className="flex shrink-0 items-center gap-1 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />}
{productivityReviewIndicator}
{parkedBlockerIndicator}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
@ -475,7 +459,6 @@ export function IssueRow({
<>
<span className="hidden shrink-0 items-center gap-1 sm:inline-flex">
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />
{productivityReviewIndicator}
</span>
{checklistStep}
<span className="shrink-0 font-mono text-xs text-muted-foreground">

View File

@ -1,77 +0,0 @@
import { Eye } from "lucide-react";
import type { IssueProductivityReview } from "@paperclipai/shared";
import { Link } from "../lib/router";
import { cn } from "../lib/utils";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const TRIGGER_LABELS: Record<string, string> = {
no_comment_streak: "No-comment streak",
long_active_duration: "Long active duration",
high_churn: "High churn",
};
const REVIEW_STATUS_LABELS: Record<string, string> = {
todo: "Open",
in_progress: "In progress",
in_review: "In review",
blocked: "Blocked",
backlog: "Open",
};
export function productivityReviewTriggerLabel(
trigger: IssueProductivityReview["trigger"],
): string {
if (!trigger) return "Productivity review";
return TRIGGER_LABELS[trigger] ?? "Productivity review";
}
export function ProductivityReviewBadge({
review,
className,
hideLabel = false,
}: {
review: IssueProductivityReview;
className?: string;
hideLabel?: boolean;
}) {
const label = productivityReviewTriggerLabel(review.trigger);
const reviewIdentifier = review.reviewIdentifier ?? review.reviewIssueId.slice(0, 8);
const reviewPath = createIssueDetailPath(review.reviewIdentifier ?? review.reviewIssueId);
const statusLabel = REVIEW_STATUS_LABELS[review.status] ?? review.status.replace(/_/g, " ");
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
to={reviewPath}
className={cn(
"inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-(length:--text-nano) font-medium text-amber-700 dark:text-amber-300 shrink-0 hover:bg-amber-500/20 transition-colors",
className,
)}
aria-label={`Under review · productivity review ${reviewIdentifier} (${label})`}
>
<Eye className="h-3 w-3" aria-hidden />
{hideLabel ? null : <span>Under review</span>}
</Link>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1 text-xs">
<div className="font-semibold">Productivity review open</div>
<div>
<span className="text-muted-foreground">Trigger:</span> {label}
</div>
{typeof review.noCommentStreak === "number" && review.noCommentStreak > 0 ? (
<div>
<span className="text-muted-foreground">No-comment streak:</span>{" "}
{review.noCommentStreak} runs
</div>
) : null}
<div>
<span className="text-muted-foreground">Review:</span> {reviewIdentifier} ({statusLabel})
</div>
</div>
</TooltipContent>
</Tooltip>
);
}

View File

@ -185,6 +185,9 @@ describe("attentionIsNewToday", () => {
});
describe("sourceMeta + severityStyle", () => {
it("uses a generic task label for persisted legacy productivity decisions", () => {
expect(sourceMeta("productivity_review").label).toBe("Task");
});
it("labels every catalog source kind", () => {
const kinds: AttentionSourceKind[] = [
"approval",

View File

@ -53,7 +53,8 @@ const SOURCE_META: Record<AttentionSourceKind, SourceMeta> = {
issue_thread_interaction: { label: "Decision requested" },
join_request: { label: "Join request" },
recovery_action: { label: "Recovery" },
productivity_review: { label: "Productivity review" },
// Read compatibility for persisted decisions from the retired feature.
productivity_review: { label: "Task" },
blocker_attention: { label: "Blocked dependency" },
review: { label: "Review" },
failed_run: { label: "Failed run" },

View File

@ -232,7 +232,6 @@ import { ScrollToBottom } from "../components/ScrollToBottom";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags";
import { ProductivityReviewBadge } from "../components/ProductivityReviewBadge";
import { Identity } from "../components/Identity";
import {
PluginSlotMount,
@ -305,7 +304,6 @@ import {
Check,
ChevronRight,
Copy,
Eye,
EyeOff,
ScanEye,
Flag,
@ -6908,21 +6906,6 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
</Link>
)}
{issue.productivityReview ? (
<ProductivityReviewBadge review={issue.productivityReview} />
) : null}
{issue.originKind === "issue_productivity_review" ? (
<Badge
variant="outline"
className="border-amber-500/40 bg-amber-500/10 text-(length:--text-nano) text-amber-700 dark:text-amber-300"
title="This task is a productivity review."
>
<Eye className="h-3 w-3" />
Productivity review
</Badge>
) : null}
{issue.originKind === "task_watchdog" ? (
<Badge
variant="outline"

View File

@ -3,7 +3,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { AGENT_STATUSES, ISSUE_PRIORITIES, ISSUE_STATUSES } from "@paperclipai/shared";
import type {
IssueBlockerAttention,
IssueProductivityReview,
IssueRelationIssueSummary,
} from "@paperclipai/shared";
import { Bot, CheckCircle2, Clock3, DollarSign, FolderKanban, Inbox, MessageSquare, Users } from "lucide-react";
@ -14,7 +13,6 @@ import { IssueBlockedNotice } from "@/components/IssueBlockedNotice";
import { IssueRow } from "@/components/IssueRow";
import { MetricCard } from "@/components/MetricCard";
import { PriorityIcon } from "@/components/PriorityIcon";
import { ProductivityReviewBadge } from "@/components/ProductivityReviewBadge";
import { QuotaBar } from "@/components/QuotaBar";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
@ -375,114 +373,6 @@ function CoveredBlockedSurface({ mode, size }: { mode: "light" | "dark"; size: "
);
}
type ProductivityReviewFixture = {
label: string;
description: string;
review: IssueProductivityReview;
};
const productivityReviewFixtures: ProductivityReviewFixture[] = [
{
label: "No-comment streak",
description: "Source issue has had 12 completed runs without a run-created comment.",
review: {
reviewIssueId: "review-issue-1",
reviewIdentifier: "PAP-2702",
status: "todo",
priority: "high",
trigger: "no_comment_streak",
noCommentStreak: 12,
createdAt: new Date("2026-04-28T13:30:00.000Z"),
updatedAt: new Date("2026-04-28T13:55:00.000Z"),
},
},
{
label: "Long active duration",
description: "Source issue has been actively running for over 6 hours.",
review: {
reviewIssueId: "review-issue-2",
reviewIdentifier: "PAP-2703",
status: "in_progress",
priority: "medium",
trigger: "long_active_duration",
noCommentStreak: null,
createdAt: new Date("2026-04-28T08:30:00.000Z"),
updatedAt: new Date("2026-04-28T13:00:00.000Z"),
},
},
{
label: "High churn",
description: "Source issue is producing >10 runs/comments per hour.",
review: {
reviewIssueId: "review-issue-3",
reviewIdentifier: "PAP-2704",
status: "todo",
priority: "high",
trigger: "high_churn",
noCommentStreak: 4,
createdAt: new Date("2026-04-28T13:45:00.000Z"),
updatedAt: new Date("2026-04-28T13:55:00.000Z"),
},
},
];
const productivityReviewIssueRowFixtures = productivityReviewFixtures.map((fixture, index) =>
createIssue({
id: `issue-productivity-source-${index + 1}`,
identifier: `PAP-${2710 + index}`,
issueNumber: 2710 + index,
title: `Source issue under review · ${fixture.label}`,
status: index === 1 ? "in_progress" : "in_progress",
priority: fixture.review.priority,
productivityReview: fixture.review,
lastActivityAt: fixture.review.updatedAt,
updatedAt: fixture.review.updatedAt,
}),
);
function ProductivityReviewMatrix() {
return (
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-3">
{productivityReviewFixtures.map((fixture) => (
<div
key={fixture.label}
className="flex flex-col gap-3 rounded-lg border border-border bg-background/70 p-4"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{fixture.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{fixture.description}</div>
</div>
<ProductivityReviewBadge review={fixture.review} />
</div>
<div className="rounded-md bg-muted/45 px-2.5 py-2 font-mono text-[11px] leading-5 text-muted-foreground">
Trigger {fixture.review.trigger ?? "unknown"} · review {fixture.review.reviewIdentifier}
</div>
</div>
))}
</div>
<div className="rounded-lg border border-border">
<div className="border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">
IssueRow with productivity-review indicator
</div>
<div>
{productivityReviewIssueRowFixtures.map((issue) => (
<IssueRow key={issue.id} issue={issue} mobileMeta={<StatusBadge status={issue.status} />} />
))}
</div>
</div>
<p className="text-xs text-muted-foreground">
On the source issue header the amber pill reads <strong>Under review</strong> and links to the open
productivity-review child describing the state the task is in. The productivity-review issue itself
carries a static <strong>Productivity review</strong> pill identifying what kind of issue it is.
List rows get a smaller eye glyph next to the status icon so operators can spot yellow tasks without
the clickable label.
</p>
</div>
);
}
function StatusLanguage() {
const [priority, setPriority] = useState("high");
@ -579,9 +469,6 @@ function StatusLanguage() {
</p>
</Section>
<Section eyebrow="Productivity review" title="Yellow accountability state on source issues">
<ProductivityReviewMatrix />
</Section>
<Section eyebrow="Priority" title="Static labels and editable popover trigger">
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">