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
+
+