diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 45102990dc6..84ca86cac39 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -141,6 +141,12 @@ export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join("," export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const; export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; +export const MAX_ISSUE_REQUEST_DEPTH = 1024; + +export function clampIssueRequestDepth(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 0; + return Math.min(MAX_ISSUE_REQUEST_DEPTH, Math.max(0, Math.floor(value))); +} export const ISSUE_THREAD_INTERACTION_KINDS = [ "suggest_tasks", @@ -167,7 +173,14 @@ export const ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES = [ export type IssueThreadInteractionContinuationPolicy = (typeof ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES)[number]; -export const ISSUE_ORIGIN_KINDS = ["manual", "routine_execution", "stale_active_run_evaluation"] as const; +export const ISSUE_ORIGIN_KINDS = [ + "manual", + "routine_execution", + "stale_active_run_evaluation", + "harness_liveness_escalation", + "issue_productivity_review", + "stranded_issue_recovery", +] as const; export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number]; export type PluginIssueOriginKind = `plugin:${string}`; export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 09274d2ec80..72d8ca73ab4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -18,6 +18,8 @@ export { INBOX_MINE_ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUS_FILTER, ISSUE_PRIORITIES, + MAX_ISSUE_REQUEST_DEPTH, + clampIssueRequestDepth, ISSUE_THREAD_INTERACTION_KINDS, ISSUE_THREAD_INTERACTION_STATUSES, ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, @@ -331,6 +333,8 @@ export type { IssueBlockerAttention, IssueBlockerAttentionReason, IssueBlockerAttentionState, + IssueProductivityReview, + IssueProductivityReviewTrigger, IssueReferenceSource, IssueRelatedWorkItem, IssueRelatedWorkSummary, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 54e19f389cf..a574f8541e8 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -136,6 +136,8 @@ export type { IssueBlockerAttention, IssueBlockerAttentionReason, IssueBlockerAttentionState, + IssueProductivityReview, + IssueProductivityReviewTrigger, IssueReferenceSource, IssueRelatedWorkItem, IssueRelatedWorkSummary, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 64b3b96d65f..8ee1821d6f7 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -139,6 +139,22 @@ export interface IssueBlockerAttention { sampleStalledBlockerIdentifier: string | null; } +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 IssueRelation { id: string; companyId: string; @@ -264,6 +280,7 @@ export interface Issue { blockedBy?: IssueRelationIssueSummary[]; blocks?: IssueRelationIssueSummary[]; blockerAttention?: IssueBlockerAttention; + productivityReview?: IssueProductivityReview | null; relatedWork?: IssueRelatedWorkSummary; referencedIssueIdentifiers?: string[]; planDocument?: IssueDocument | null; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 7de449a10a7..09c5c24c5cd 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -603,23 +603,39 @@ export function issueRoutes( res.status(403).json({ error: "Agent authentication required" }); return false; } - if (issue.status !== "in_progress" || issue.assigneeAgentId === null) { + if (issue.assigneeAgentId === null) { return true; } if (issue.assigneeAgentId !== actorAgentId) { if (await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)) { return true; } - res.status(409).json({ - error: "Issue is checked out by another agent", - details: { - issueId: issue.id, - assigneeAgentId: issue.assigneeAgentId, - actorAgentId, - }, - }); + if (issue.status === "in_progress") { + res.status(409).json({ + error: "Issue is checked out by another agent", + details: { + issueId: issue.id, + assigneeAgentId: issue.assigneeAgentId, + actorAgentId, + }, + }); + } else { + res.status(403).json({ + error: "Agent cannot mutate another agent's issue", + details: { + issueId: issue.id, + assigneeAgentId: issue.assigneeAgentId, + actorAgentId, + status: issue.status, + securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"], + }, + }); + } return false; } + if (issue.status !== "in_progress") { + return true; + } const runId = requireAgentRunId(req, res); if (!runId) return false; const ownership = await svc.assertCheckoutOwner(issue.id, actorAgentId, runId); @@ -1035,6 +1051,7 @@ export function issueRoutes( wakeComment, relations, blockerAttention, + productivityReview, attachments, continuationSummary, currentExecutionWorkspace, @@ -1046,6 +1063,7 @@ export function issueRoutes( wakeCommentId ? svc.getComment(wakeCommentId) : null, svc.getRelationSummaries(issue.id), svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null), + svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null), svc.listAttachments(issue.id), documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY), currentExecutionWorkspacePromise, @@ -1059,6 +1077,7 @@ export function issueRoutes( description: issue.description, status: issue.status, ...(blockerAttention ? { blockerAttention } : {}), + productivityReview, priority: issue.priority, projectId: issue.projectId, goalId: goal?.id ?? issue.goalId, @@ -1067,6 +1086,8 @@ export function issueRoutes( blocks: relations.blocks, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, + originKind: issue.originKind, + originId: issue.originId, updatedAt: issue.updatedAt, }, ancestors: ancestors.map((ancestor) => ({ @@ -1128,13 +1149,23 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); - const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations, blockerAttention, referenceSummary] = await Promise.all([ + const [ + { project, goal }, + ancestors, + mentionedProjectIds, + documentPayload, + relations, + blockerAttention, + productivityReview, + referenceSummary, + ] = await Promise.all([ resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }), documentsSvc.getIssueDocumentPayload(issue), svc.getRelationSummaries(issue.id), svc.listBlockerAttention(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), ]); const mentionedProjects = mentionedProjectIds.length > 0 @@ -1149,6 +1180,7 @@ export function issueRoutes( goalId: goal?.id ?? issue.goalId, ancestors, ...(blockerAttention ? { blockerAttention } : {}), + productivityReview, blockedBy: relations.blockedBy, blocks: relations.blocks, relatedWork: referenceSummary, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e2d6df0dd63..f93633f4156 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -27,8 +27,13 @@ import { projectWorkspaces, projects, } from "@paperclipai/db"; -import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared"; -import { extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared"; +import type { + IssueBlockerAttention, + IssueProductivityReview, + IssueProductivityReviewTrigger, + IssueRelationIssueSummary, +} from "@paperclipai/shared"; +import { clampIssueRequestDepth, extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { defaultIssueExecutionWorkspaceSettingsForProject, @@ -666,6 +671,17 @@ const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execut const BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES = ["pending"]; const BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"]; const BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND = "harness_liveness_escalation"; +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", +]; const BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"]; const BLOCKER_ATTENTION_MAX_DEPTH = 8; const BLOCKER_ATTENTION_MAX_NODES = 2000; @@ -876,6 +892,114 @@ 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> { + const map = new Map(); + 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), + isNull(issues.hiddenAt), + notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES), + ), + ); + 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 | 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; + 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, @@ -2108,7 +2232,10 @@ export function issueService(db: Db) { ]); const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); - const blockerAttentionByIssueId = await listIssueBlockerAttentionMap(db, companyId, withRuns); + const [blockerAttentionByIssueId, productivityReviewByIssueId] = await Promise.all([ + listIssueBlockerAttentionMap(db, companyId, withRuns), + listIssueProductivityReviewMap(db, companyId, issueIds), + ]); if (!contextUserId) { return withRuns.map((row) => { @@ -2123,6 +2250,9 @@ export function issueService(db: Db) { ...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}), lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), + ...(productivityReviewByIssueId.has(row.id) + ? { productivityReview: productivityReviewByIssueId.get(row.id) } + : {}), }; }); } @@ -2141,6 +2271,9 @@ export function issueService(db: Db) { ...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}), lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), + ...(productivityReviewByIssueId.has(row.id) + ? { productivityReview: productivityReviewByIssueId.get(row.id) } + : {}), ...deriveIssueUserContext(row, contextUserId, { myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null, myLastReadAt: readByIssueId.get(row.id) ?? null, @@ -2292,6 +2425,14 @@ export function issueService(db: Db) { return listIssueBlockerAttentionMap(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 }) @@ -2458,7 +2599,9 @@ export function issueService(db: Db) { parentId: parent.id, projectId: issueData.projectId ?? parent.projectId, goalId: issueData.goalId ?? parent.goalId, - requestDepth: Math.max(parent.requestDepth + 1, issueData.requestDepth ?? 0), + requestDepth: clampIssueRequestDepth( + Math.max(clampIssueRequestDepth(parent.requestDepth) + 1, issueData.requestDepth ?? 0), + ), description: appendAcceptanceCriteriaToDescription(issueData.description, acceptanceCriteria), inheritExecutionWorkspaceFromIssueId: parent.id, }); @@ -2615,6 +2758,7 @@ export function issueService(db: Db) { const values = { ...issueData, + requestDepth: clampIssueRequestDepth(issueData.requestDepth), originKind: issueData.originKind ?? "manual", goalId: resolveIssueGoalId({ projectId: issueData.projectId, @@ -2700,6 +2844,9 @@ export function issueService(db: Db) { ...issueData, updatedAt: new Date(), }; + if (issueData.requestDepth !== undefined) { + patch.requestDepth = clampIssueRequestDepth(issueData.requestDepth); + } const nextAssigneeAgentId = issueData.assigneeAgentId !== undefined ? issueData.assigneeAgentId : existing.assigneeAgentId; diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index d6969d23323..664990b1e17 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; -import { X } from "lucide-react"; +import { Eye, X } from "lucide-react"; import { createIssueDetailPath, rememberIssueDetailLocationState, @@ -9,6 +9,7 @@ import { } from "../lib/issueDetailBreadcrumb"; import { cn } from "../lib/utils"; import { StatusIcon } from "./StatusIcon"; +import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge"; type UnreadState = "hidden" | "visible" | "fading"; @@ -63,6 +64,19 @@ export function IssueRow({ const showUnreadDot = unreadState === "visible" || unreadState === "fading"; const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined; const detailState = withIssueDetailHeaderSeed(issueLinkState, issue); + const productivityReview = issue.productivityReview ?? null; + const productivityReviewIndicator = productivityReview ? ( + + + + ) : null; const hasChecklistStep = checklistStepNumber !== null; const checklistStep = hasChecklistStep ? (