Add issue review policy and comment retry

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-06 08:40:38 -05:00
parent 4b39b0cc14
commit b3e0c31239
18 changed files with 1409 additions and 5 deletions

View file

@ -37,6 +37,9 @@ export const heartbeatRuns = pgTable(
onDelete: "set null", onDelete: "set null",
}), }),
processLossRetryCount: integer("process_loss_retry_count").notNull().default(0), processLossRetryCount: integer("process_loss_retry_count").notNull().default(0),
issueCommentStatus: text("issue_comment_status").notNull().default("not_applicable"),
issueCommentSatisfiedByCommentId: uuid("issue_comment_satisfied_by_comment_id"),
issueCommentRetryQueuedAt: timestamp("issue_comment_retry_queued_at", { withTimezone: true }),
contextSnapshot: jsonb("context_snapshot").$type<Record<string, unknown>>(), contextSnapshot: jsonb("context_snapshot").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),

View file

@ -32,6 +32,7 @@ export { labels } from "./labels.js";
export { issueLabels } from "./issue_labels.js"; export { issueLabels } from "./issue_labels.js";
export { issueApprovals } from "./issue_approvals.js"; export { issueApprovals } from "./issue_approvals.js";
export { issueComments } from "./issue_comments.js"; export { issueComments } from "./issue_comments.js";
export { issueExecutionDecisions } from "./issue_execution_decisions.js";
export { issueInboxArchives } from "./issue_inbox_archives.js"; export { issueInboxArchives } from "./issue_inbox_archives.js";
export { feedbackVotes } from "./feedback_votes.js"; export { feedbackVotes } from "./feedback_votes.js";
export { feedbackExports } from "./feedback_exports.js"; export { feedbackExports } from "./feedback_exports.js";

View file

@ -0,0 +1,27 @@
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { agents } from "./agents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
export const issueExecutionDecisions = pgTable(
"issue_execution_decisions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
stageId: uuid("stage_id").notNull(),
stageType: text("stage_type").notNull(),
actorAgentId: uuid("actor_agent_id").references(() => agents.id),
actorUserId: text("actor_user_id"),
outcome: text("outcome").notNull(),
body: text("body").notNull(),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIssueIdx: index("issue_execution_decisions_company_issue_idx").on(table.companyId, table.issueId),
stageIdx: index("issue_execution_decisions_stage_idx").on(table.issueId, table.stageId, table.createdAt),
}),
);

View file

@ -47,6 +47,8 @@ export const issues = pgTable(
requestDepth: integer("request_depth").notNull().default(0), requestDepth: integer("request_depth").notNull().default(0),
billingCode: text("billing_code"), billingCode: text("billing_code"),
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(), assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
executionPolicy: jsonb("execution_policy").$type<Record<string, unknown>>(),
executionState: jsonb("execution_state").$type<Record<string, unknown>>(),
executionWorkspaceId: uuid("execution_workspace_id") executionWorkspaceId: uuid("execution_workspace_id")
.references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }), .references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }),
executionWorkspacePreference: text("execution_workspace_preference"), executionWorkspacePreference: text("execution_workspace_preference"),

View file

@ -138,6 +138,18 @@ export type IssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export const ISSUE_RELATION_TYPES = ["blocks"] as const; export const ISSUE_RELATION_TYPES = ["blocks"] as const;
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number]; export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const;
export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number];
export const ISSUE_EXECUTION_STAGE_TYPES = ["review", "approval"] as const;
export type IssueExecutionStageType = (typeof ISSUE_EXECUTION_STAGE_TYPES)[number];
export const ISSUE_EXECUTION_STATE_STATUSES = ["idle", "pending", "changes_requested", "completed"] as const;
export type IssueExecutionStateStatus = (typeof ISSUE_EXECUTION_STATE_STATUSES)[number];
export const ISSUE_EXECUTION_DECISION_OUTCOMES = ["approved", "changes_requested"] as const;
export type IssueExecutionDecisionOutcome = (typeof ISSUE_EXECUTION_DECISION_OUTCOMES)[number];
export const GOAL_LEVELS = ["company", "team", "agent", "task"] as const; export const GOAL_LEVELS = ["company", "team", "agent", "task"] as const;
export type GoalLevel = (typeof GOAL_LEVELS)[number]; export type GoalLevel = (typeof GOAL_LEVELS)[number];

View file

@ -15,6 +15,10 @@ export {
ISSUE_PRIORITIES, ISSUE_PRIORITIES,
ISSUE_ORIGIN_KINDS, ISSUE_ORIGIN_KINDS,
ISSUE_RELATION_TYPES, ISSUE_RELATION_TYPES,
ISSUE_EXECUTION_POLICY_MODES,
ISSUE_EXECUTION_STAGE_TYPES,
ISSUE_EXECUTION_STATE_STATUSES,
ISSUE_EXECUTION_DECISION_OUTCOMES,
GOAL_LEVELS, GOAL_LEVELS,
GOAL_STATUSES, GOAL_STATUSES,
PROJECT_STATUSES, PROJECT_STATUSES,
@ -84,6 +88,10 @@ export {
type IssuePriority, type IssuePriority,
type IssueOriginKind, type IssueOriginKind,
type IssueRelationType, type IssueRelationType,
type IssueExecutionPolicyMode,
type IssueExecutionStageType,
type IssueExecutionStateStatus,
type IssueExecutionDecisionOutcome,
type GoalLevel, type GoalLevel,
type GoalStatus, type GoalStatus,
type ProjectStatus, type ProjectStatus,
@ -233,6 +241,12 @@ export type {
IssueAssigneeAdapterOverrides, IssueAssigneeAdapterOverrides,
IssueRelation, IssueRelation,
IssueRelationIssueSummary, IssueRelationIssueSummary,
IssueExecutionPolicy,
IssueExecutionState,
IssueExecutionStage,
IssueExecutionStageParticipant,
IssueExecutionStagePrincipal,
IssueExecutionDecision,
IssueComment, IssueComment,
IssueDocument, IssueDocument,
IssueDocumentSummary, IssueDocumentSummary,
@ -425,6 +439,8 @@ export {
createIssueSchema, createIssueSchema,
createIssueLabelSchema, createIssueLabelSchema,
updateIssueSchema, updateIssueSchema,
issueExecutionPolicySchema,
issueExecutionStateSchema,
issueExecutionWorkspaceSettingsSchema, issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema, checkoutIssueSchema,
addIssueCommentSchema, addIssueCommentSchema,

View file

@ -98,6 +98,12 @@ export type {
IssueAssigneeAdapterOverrides, IssueAssigneeAdapterOverrides,
IssueRelation, IssueRelation,
IssueRelationIssueSummary, IssueRelationIssueSummary,
IssueExecutionPolicy,
IssueExecutionState,
IssueExecutionStage,
IssueExecutionStageParticipant,
IssueExecutionStagePrincipal,
IssueExecutionDecision,
IssueComment, IssueComment,
IssueDocument, IssueDocument,
IssueDocumentSummary, IssueDocumentSummary,

View file

@ -1,4 +1,12 @@
import type { IssueOriginKind, IssuePriority, IssueStatus } from "../constants.js"; import type {
IssueExecutionDecisionOutcome,
IssueExecutionPolicyMode,
IssueExecutionStageType,
IssueExecutionStateStatus,
IssueOriginKind,
IssuePriority,
IssueStatus,
} from "../constants.js";
import type { Goal } from "./goal.js"; import type { Goal } from "./goal.js";
import type { Project, ProjectWorkspace } from "./project.js"; import type { Project, ProjectWorkspace } from "./project.js";
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js"; import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
@ -115,6 +123,56 @@ export interface IssueRelation {
relatedIssue: IssueRelationIssueSummary; relatedIssue: IssueRelationIssueSummary;
} }
export interface IssueExecutionStagePrincipal {
type: "agent" | "user";
agentId?: string | null;
userId?: string | null;
}
export interface IssueExecutionStageParticipant extends IssueExecutionStagePrincipal {
id: string;
}
export interface IssueExecutionStage {
id: string;
type: IssueExecutionStageType;
approvalsNeeded: number;
participants: IssueExecutionStageParticipant[];
}
export interface IssueExecutionPolicy {
mode: IssueExecutionPolicyMode;
commentRequired: boolean;
stages: IssueExecutionStage[];
}
export interface IssueExecutionState {
status: IssueExecutionStateStatus;
currentStageId: string | null;
currentStageIndex: number | null;
currentStageType: IssueExecutionStageType | null;
currentParticipant: IssueExecutionStagePrincipal | null;
returnAssignee: IssueExecutionStagePrincipal | null;
completedStageIds: string[];
lastDecisionId: string | null;
lastDecisionOutcome: IssueExecutionDecisionOutcome | null;
}
export interface IssueExecutionDecision {
id: string;
companyId: string;
issueId: string;
stageId: string;
stageType: IssueExecutionStageType;
actorAgentId: string | null;
actorUserId: string | null;
outcome: IssueExecutionDecisionOutcome;
body: string;
createdByRunId: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface Issue { export interface Issue {
id: string; id: string;
companyId: string; companyId: string;
@ -143,6 +201,8 @@ export interface Issue {
requestDepth: number; requestDepth: number;
billingCode: string | null; billingCode: string | null;
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null; assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
executionPolicy?: IssueExecutionPolicy | null;
executionState?: IssueExecutionState | null;
executionWorkspaceId: string | null; executionWorkspaceId: string | null;
executionWorkspacePreference: string | null; executionWorkspacePreference: string | null;
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null; executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;

View file

@ -131,6 +131,8 @@ export {
createIssueSchema, createIssueSchema,
createIssueLabelSchema, createIssueLabelSchema,
updateIssueSchema, updateIssueSchema,
issueExecutionPolicySchema,
issueExecutionStateSchema,
issueExecutionWorkspaceSettingsSchema, issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema, checkoutIssueSchema,
addIssueCommentSchema, addIssueCommentSchema,

View file

@ -1,5 +1,12 @@
import { z } from "zod"; import { z } from "zod";
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js"; import {
ISSUE_EXECUTION_DECISION_OUTCOMES,
ISSUE_EXECUTION_POLICY_MODES,
ISSUE_EXECUTION_STAGE_TYPES,
ISSUE_EXECUTION_STATE_STATUSES,
ISSUE_PRIORITIES,
ISSUE_STATUSES,
} from "../constants.js";
export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [ export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [
"inherit", "inherit",
@ -36,6 +43,76 @@ export const issueAssigneeAdapterOverridesSchema = z
}) })
.strict(); .strict();
const issueExecutionStagePrincipalBaseSchema = z.object({
type: z.enum(["agent", "user"]),
agentId: z.string().uuid().optional().nullable(),
userId: z.string().optional().nullable(),
});
export const issueExecutionStagePrincipalSchema = issueExecutionStagePrincipalBaseSchema
.superRefine((value, ctx) => {
if (value.type === "agent") {
if (!value.agentId) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants require agentId", path: ["agentId"] });
}
if (value.userId) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants cannot set userId", path: ["userId"] });
}
return;
}
if (!value.userId) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants require userId", path: ["userId"] });
}
if (value.agentId) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants cannot set agentId", path: ["agentId"] });
}
});
export const issueExecutionStageParticipantSchema = issueExecutionStagePrincipalBaseSchema.extend({
id: z.string().uuid().optional(),
}).superRefine((value, ctx) => {
if (value.type === "agent") {
if (!value.agentId) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants require agentId", path: ["agentId"] });
}
if (value.userId) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent participants cannot set userId", path: ["userId"] });
}
return;
}
if (!value.userId) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants require userId", path: ["userId"] });
}
if (value.agentId) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "User participants cannot set agentId", path: ["agentId"] });
}
});
export const issueExecutionStageSchema = z.object({
id: z.string().uuid().optional(),
type: z.enum(ISSUE_EXECUTION_STAGE_TYPES),
approvalsNeeded: z.number().int().positive().optional().default(1),
participants: z.array(issueExecutionStageParticipantSchema).default([]),
});
export const issueExecutionPolicySchema = z.object({
mode: z.enum(ISSUE_EXECUTION_POLICY_MODES).optional().default("normal"),
commentRequired: z.boolean().optional().default(true),
stages: z.array(issueExecutionStageSchema).default([]),
});
export const issueExecutionStateSchema = z.object({
status: z.enum(ISSUE_EXECUTION_STATE_STATUSES),
currentStageId: z.string().uuid().nullable(),
currentStageIndex: z.number().int().nonnegative().nullable(),
currentStageType: z.enum(ISSUE_EXECUTION_STAGE_TYPES).nullable(),
currentParticipant: issueExecutionStagePrincipalSchema.nullable(),
returnAssignee: issueExecutionStagePrincipalSchema.nullable(),
completedStageIds: z.array(z.string().uuid()).default([]),
lastDecisionId: z.string().uuid().nullable(),
lastDecisionOutcome: z.enum(ISSUE_EXECUTION_DECISION_OUTCOMES).nullable(),
});
export const createIssueSchema = z.object({ export const createIssueSchema = z.object({
projectId: z.string().uuid().optional().nullable(), projectId: z.string().uuid().optional().nullable(),
projectWorkspaceId: z.string().uuid().optional().nullable(), projectWorkspaceId: z.string().uuid().optional().nullable(),
@ -52,6 +129,7 @@ export const createIssueSchema = z.object({
requestDepth: z.number().int().nonnegative().optional().default(0), requestDepth: z.number().int().nonnegative().optional().default(0),
billingCode: z.string().optional().nullable(), billingCode: z.string().optional().nullable(),
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(), assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
executionPolicy: issueExecutionPolicySchema.optional().nullable(),
executionWorkspaceId: z.string().uuid().optional().nullable(), executionWorkspaceId: z.string().uuid().optional().nullable(),
executionWorkspacePreference: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional().nullable(), executionWorkspacePreference: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional().nullable(),
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(), executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),

View file

@ -4,7 +4,7 @@ import net from "node:net";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { createServer } from "node:http"; import { createServer } from "node:http";
import { and, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { import {
@ -307,6 +307,14 @@ describe("heartbeat comment wake batching", () => {
expect(firstRun).not.toBeNull(); expect(firstRun).not.toBeNull();
await waitFor(() => gateway.getAgentPayloads().length === 1); await waitFor(() => gateway.getAgentPayloads().length === 1);
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
createdByRunId: firstRun?.id ?? null,
body: "Heartbeat acknowledged",
});
const comment2 = await db const comment2 = await db
.insert(issueComments) .insert(issueComments)
.values({ .values({
@ -415,4 +423,114 @@ describe("heartbeat comment wake batching", () => {
await gateway.close(); await gateway.close();
} }
}, 20_000); }, 20_000);
it("queues exactly one follow-up run when an issue-bound run exits without a comment", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const heartbeat = heartbeatService(db);
try {
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Gateway Agent",
role: "engineer",
status: "idle",
adapterType: "openclaw_gateway",
adapterConfig: {
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
payloadTemplate: {
message: "wake now",
},
waitTimeoutMs: 2_000,
},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Require a comment",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
const firstRun = await heartbeat.wakeup(agentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId },
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_assigned",
},
requestedByActorType: "system",
requestedByActorId: null,
});
expect(firstRun).not.toBeNull();
await waitFor(() => gateway.getAgentPayloads().length === 1);
gateway.releaseFirstWait();
await waitFor(async () => {
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId))
.orderBy(asc(heartbeatRuns.createdAt));
return runs.length === 2 && runs.every((run) => run.status === "succeeded");
});
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId))
.orderBy(asc(heartbeatRuns.createdAt));
expect(runs).toHaveLength(2);
expect(runs[0]?.issueCommentStatus).toBe("retry_queued");
expect(runs[1]?.retryOfRunId).toBe(runs[0]?.id);
expect(runs[1]?.issueCommentStatus).toBe("retry_exhausted");
const comments = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
await waitFor(async () => {
const wakeups = await db
.select()
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
return wakeups.length >= 2;
});
const payloads = gateway.getAgentPayloads();
expect(payloads).toHaveLength(2);
expect(runs[1]?.contextSnapshot).toMatchObject({
retryReason: "missing_issue_comment",
});
} finally {
gateway.releaseFirstWait();
await gateway.close();
}
}, 20_000);
}); });

View file

@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
describe("issue execution policy transitions", () => {
const coderAgentId = "11111111-1111-4111-8111-111111111111";
const qaAgentId = "22222222-2222-4222-8222-222222222222";
const ctoUserId = "cto-user";
const policy = normalizeIssueExecutionPolicy({
stages: [
{
type: "review",
participants: [{ type: "agent", agentId: qaAgentId }],
},
{
type: "approval",
participants: [{ type: "user", userId: ctoUserId }],
},
],
});
it("routes executor completion into review", () => {
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_progress",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: policy,
executionState: null,
},
policy,
requestedStatus: "done",
requestedAssigneePatch: {},
actor: { agentId: coderAgentId },
commentBody: "Implemented the feature",
});
expect(result.patch.status).toBe("in_review");
expect(result.patch.assigneeAgentId).toBe(qaAgentId);
expect(result.patch.executionState).toMatchObject({
status: "pending",
currentStageType: "review",
returnAssignee: { type: "agent", agentId: coderAgentId },
});
expect(result.decision).toBeUndefined();
});
it("returns review changes to the prior executor", () => {
const reviewStageId = policy?.stages[0]?.id ?? "review-stage";
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_review",
assigneeAgentId: qaAgentId,
assigneeUserId: null,
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: reviewStageId,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: qaAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
},
policy,
requestedStatus: "in_progress",
requestedAssigneePatch: {},
actor: { agentId: qaAgentId },
commentBody: "Needs another pass on edge cases",
});
expect(result.patch.status).toBe("in_progress");
expect(result.patch.assigneeAgentId).toBe(coderAgentId);
expect(result.patch.executionState).toMatchObject({
status: "changes_requested",
currentStageType: "review",
returnAssignee: { type: "agent", agentId: coderAgentId },
lastDecisionOutcome: "changes_requested",
});
expect(result.decision).toMatchObject({
stageId: reviewStageId,
stageType: "review",
outcome: "changes_requested",
});
});
it("advances approved review work into approval", () => {
const reviewStageId = policy?.stages[0]?.id ?? "review-stage";
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_review",
assigneeAgentId: qaAgentId,
assigneeUserId: null,
executionPolicy: policy,
executionState: {
status: "pending",
currentStageId: reviewStageId,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: qaAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
},
policy,
requestedStatus: "done",
requestedAssigneePatch: {},
actor: { agentId: qaAgentId },
commentBody: "QA signoff complete",
});
expect(result.patch.status).toBe("in_review");
expect(result.patch.assigneeAgentId).toBeNull();
expect(result.patch.assigneeUserId).toBe(ctoUserId);
expect(result.patch.executionState).toMatchObject({
status: "pending",
currentStageType: "approval",
completedStageIds: [reviewStageId],
currentParticipant: { type: "user", userId: ctoUserId },
});
expect(result.decision).toMatchObject({
stageId: reviewStageId,
stageType: "review",
outcome: "approved",
});
});
});

View file

@ -2,6 +2,7 @@ import { Router, type Request, type Response } from "express";
import multer from "multer"; import multer from "multer";
import { z } from "zod"; import { z } from "zod";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { issueExecutionDecisions } from "@paperclipai/db";
import { import {
addIssueCommentSchema, addIssueCommentSchema,
createIssueAttachmentMetadataSchema, createIssueAttachmentMetadataSchema,
@ -54,6 +55,7 @@ import {
SVG_CONTENT_TYPE, SVG_CONTENT_TYPE,
} from "../attachment-types.js"; } from "../attachment-types.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js";
const MAX_ISSUE_COMMENT_LIMIT = 500; const MAX_ISSUE_COMMENT_LIMIT = 500;
const updateIssueRouteSchema = updateIssueSchema.extend({ const updateIssueRouteSchema = updateIssueSchema.extend({
@ -1065,6 +1067,7 @@ export function issueRoutes(
const actor = getActorInfo(req); const actor = getActorInfo(req);
const issue = await svc.create(companyId, { const issue = await svc.create(companyId, {
...req.body, ...req.body,
executionPolicy: normalizeIssueExecutionPolicy(req.body.executionPolicy),
createdByAgentId: actor.agentId, createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null, createdByUserId: actor.actorType === "user" ? actor.actorId : null,
}); });
@ -1184,6 +1187,31 @@ export function issueRoutes(
if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) { if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) {
updateFields.status = "todo"; updateFields.status = "todo";
} }
if (req.body.executionPolicy !== undefined) {
updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
}
const transition = applyIssueExecutionPolicyTransition({
issue: existing,
policy:
updateFields.executionPolicy !== undefined
? (updateFields.executionPolicy as NonNullable<typeof updateFields.executionPolicy> | null)
: normalizeIssueExecutionPolicy(existing.executionPolicy ?? null),
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
requestedAssigneePatch: {
assigneeAgentId:
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
assigneeUserId:
req.body.assigneeUserId === undefined ? undefined : (req.body.assigneeUserId as string | null),
},
actor: {
agentId: actor.agentId ?? null,
userId: actor.actorType === "user" ? actor.actorId : null,
},
commentBody,
});
Object.assign(updateFields, transition.patch);
let issue; let issue;
try { try {
issue = await svc.update(id, { issue = await svc.update(id, {
@ -1338,7 +1366,22 @@ export function issueRoutes(
} }
const assigneeChanged = assigneeWillChange; if (transition.decision) {
await db.insert(issueExecutionDecisions).values({
companyId: issue.companyId,
issueId: issue.id,
stageId: transition.decision.stageId,
stageType: transition.decision.stageType,
actorAgentId: actor.agentId ?? null,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
outcome: transition.decision.outcome,
body: transition.decision.body,
createdByRunId: actor.runId ?? null,
});
}
const assigneeChanged =
issue.assigneeAgentId !== existing.assigneeAgentId || issue.assigneeUserId !== existing.assigneeUserId;
const statusChangedFromBacklog = const statusChangedFromBacklog =
existing.status === "backlog" && existing.status === "backlog" &&
issue.status !== "backlog" && issue.status !== "backlog" &&

View file

@ -1835,6 +1835,210 @@ export function heartbeatService(db: Db) {
return updated; return updated;
} }
async function patchRunIssueCommentStatus(
runId: string,
patch: Partial<Pick<typeof heartbeatRuns.$inferInsert, "issueCommentStatus" | "issueCommentSatisfiedByCommentId" | "issueCommentRetryQueuedAt">>,
) {
return db
.update(heartbeatRuns)
.set({ ...patch, updatedAt: new Date() })
.where(eq(heartbeatRuns.id, runId))
.returning()
.then((rows) => rows[0] ?? null);
}
async function findRunIssueComment(runId: string, companyId: string, issueId: string) {
return db
.select({
id: issueComments.id,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, companyId),
eq(issueComments.issueId, issueId),
eq(issueComments.createdByRunId, runId),
),
)
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
.limit(1)
.then((rows) => rows[0] ?? null);
}
async function enqueueMissingIssueCommentRetry(
run: typeof heartbeatRuns.$inferSelect,
agent: typeof agents.$inferSelect,
issueId: string,
) {
const contextSnapshot = parseObject(run.contextSnapshot);
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const retryContextSnapshot = {
...contextSnapshot,
retryOfRunId: run.id,
wakeReason: "missing_issue_comment",
retryReason: "missing_issue_comment",
missingIssueCommentForRunId: run.id,
};
const now = new Date();
const retryRun = await db.transaction(async (tx) => {
await tx.execute(
sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`,
);
const issue = await tx
.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.companyId, run.companyId), eq(issues.executionRunId, run.id)))
.then((rows) => rows[0] ?? null);
if (!issue) return null;
const wakeupRequest = await tx
.insert(agentWakeupRequests)
.values({
companyId: run.companyId,
agentId: run.agentId,
source: "automation",
triggerDetail: "system",
reason: "missing_issue_comment",
payload: {
issueId,
retryOfRunId: run.id,
retryReason: "missing_issue_comment",
},
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
updatedAt: now,
})
.returning()
.then((rows) => rows[0]);
const queuedRun = await tx
.insert(heartbeatRuns)
.values({
companyId: run.companyId,
agentId: run.agentId,
invocationSource: "automation",
triggerDetail: "system",
status: "queued",
wakeupRequestId: wakeupRequest.id,
contextSnapshot: retryContextSnapshot,
sessionIdBefore: sessionBefore,
retryOfRunId: run.id,
issueCommentStatus: "not_applicable",
updatedAt: now,
})
.returning()
.then((rows) => rows[0]);
await tx
.update(agentWakeupRequests)
.set({
runId: queuedRun.id,
updatedAt: now,
})
.where(eq(agentWakeupRequests.id, wakeupRequest.id));
await tx
.update(issues)
.set({
executionRunId: queuedRun.id,
executionAgentNameKey: normalizeAgentNameKey(agent.name),
executionLockedAt: now,
updatedAt: now,
})
.where(eq(issues.id, issue.id));
await tx
.update(heartbeatRuns)
.set({
issueCommentStatus: "retry_queued",
issueCommentRetryQueuedAt: now,
updatedAt: now,
})
.where(eq(heartbeatRuns.id, run.id));
return queuedRun;
});
if (!retryRun) return null;
publishLiveEvent({
companyId: retryRun.companyId,
type: "heartbeat.run.queued",
payload: {
runId: retryRun.id,
agentId: retryRun.agentId,
invocationSource: retryRun.invocationSource,
triggerDetail: retryRun.triggerDetail,
wakeupRequestId: retryRun.wakeupRequestId,
},
});
return retryRun;
}
async function finalizeIssueCommentPolicy(
run: typeof heartbeatRuns.$inferSelect,
agent: typeof agents.$inferSelect,
) {
const contextSnapshot = parseObject(run.contextSnapshot);
const issueId = readNonEmptyString(contextSnapshot.issueId);
if (!issueId) {
if (run.issueCommentStatus !== "not_applicable") {
await patchRunIssueCommentStatus(run.id, {
issueCommentStatus: "not_applicable",
issueCommentSatisfiedByCommentId: null,
issueCommentRetryQueuedAt: null,
});
}
return { outcome: "not_applicable" as const, queuedRun: null };
}
const postedComment = await findRunIssueComment(run.id, run.companyId, issueId);
if (postedComment) {
await patchRunIssueCommentStatus(run.id, {
issueCommentStatus: "satisfied",
issueCommentSatisfiedByCommentId: postedComment.id,
issueCommentRetryQueuedAt: null,
});
return { outcome: "satisfied" as const, queuedRun: null };
}
if (readNonEmptyString(contextSnapshot.retryReason) === "missing_issue_comment") {
await patchRunIssueCommentStatus(run.id, {
issueCommentStatus: "retry_exhausted",
issueCommentSatisfiedByCommentId: null,
});
await appendRunEvent(run, await nextRunEventSeq(run.id), {
eventType: "lifecycle",
stream: "system",
level: "warn",
message: "Run ended without an issue comment after one retry; no further comment wake will be queued",
});
return { outcome: "retry_exhausted" as const, queuedRun: null };
}
const queuedRun = await enqueueMissingIssueCommentRetry(run, agent, issueId);
if (queuedRun) {
await appendRunEvent(run, await nextRunEventSeq(run.id), {
eventType: "lifecycle",
stream: "system",
level: "warn",
message: "Run ended without an issue comment; queued one follow-up wake to require a comment",
});
return { outcome: "retry_queued" as const, queuedRun };
}
await patchRunIssueCommentStatus(run.id, {
issueCommentStatus: "retry_exhausted",
issueCommentSatisfiedByCommentId: null,
});
return { outcome: "retry_exhausted" as const, queuedRun: null };
}
async function enqueueProcessLossRetry( async function enqueueProcessLossRetry(
run: typeof heartbeatRuns.$inferSelect, run: typeof heartbeatRuns.$inferSelect,
agent: typeof agents.$inferSelect, agent: typeof agents.$inferSelect,
@ -3085,7 +3289,7 @@ export function heartbeatService(db: Db) {
try { try {
const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null); const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null);
if (issueComment) { if (issueComment) {
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id }); await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
} }
} catch (err) { } catch (err) {
await onLog( await onLog(
@ -3094,6 +3298,7 @@ export function heartbeatService(db: Db) {
); );
} }
} }
await finalizeIssueCommentPolicy(finalizedRun, agent);
await releaseIssueExecutionAndPromote(finalizedRun); await releaseIssueExecutionAndPromote(finalizedRun);
} }
@ -3160,6 +3365,7 @@ export function heartbeatService(db: Db) {
level: "error", level: "error",
message, message,
}); });
await finalizeIssueCommentPolicy(failedRun, agent);
await releaseIssueExecutionAndPromote(failedRun); await releaseIssueExecutionAndPromote(failedRun);
await updateRuntimeState(agent, failedRun, { await updateRuntimeState(agent, failedRun, {
@ -3211,6 +3417,10 @@ export function heartbeatService(db: Db) {
level: "error", level: "error",
message, message,
}).catch(() => undefined); }).catch(() => undefined);
const failedAgent = await getAgent(run.agentId).catch(() => null);
if (failedAgent) {
await finalizeIssueCommentPolicy(failedRun, failedAgent).catch(() => undefined);
}
await releaseIssueExecutionAndPromote(failedRun).catch(() => undefined); await releaseIssueExecutionAndPromote(failedRun).catch(() => undefined);
} }
// Ensure the agent is not left stuck in "running" if the inner catch handler's // Ensure the agent is not left stuck in "running" if the inner catch handler's

View file

@ -0,0 +1,347 @@
import { randomUUID } from "node:crypto";
import type { IssueExecutionDecision, IssueExecutionPolicy, IssueExecutionStage, IssueExecutionStagePrincipal, IssueExecutionState } from "@paperclipai/shared";
import { issueExecutionPolicySchema, issueExecutionStateSchema } from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
type AssigneeLike = {
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
};
type IssueLike = AssigneeLike & {
status: string;
executionPolicy?: IssueExecutionPolicy | Record<string, unknown> | null;
executionState?: IssueExecutionState | Record<string, unknown> | null;
};
type ActorLike = {
agentId?: string | null;
userId?: string | null;
};
type RequestedAssigneePatch = {
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
};
type TransitionInput = {
issue: IssueLike;
policy: IssueExecutionPolicy | null;
requestedStatus?: string;
requestedAssigneePatch: RequestedAssigneePatch;
actor: ActorLike;
commentBody?: string | null;
};
type TransitionResult = {
patch: Record<string, unknown>;
decision?: Pick<IssueExecutionDecision, "stageId" | "stageType" | "outcome" | "body">;
};
const COMPLETED_STATUS: IssueExecutionState["status"] = "completed";
const IDLE_STATUS: IssueExecutionState["status"] = "idle";
const PENDING_STATUS: IssueExecutionState["status"] = "pending";
const CHANGES_REQUESTED_STATUS: IssueExecutionState["status"] = "changes_requested";
export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPolicy | null {
if (input == null) return null;
const parsed = issueExecutionPolicySchema.safeParse(input);
if (!parsed.success) {
throw unprocessable("Invalid execution policy", parsed.error.flatten());
}
const stages = parsed.data.stages
.map((stage) => {
const participants: IssueExecutionStage["participants"] = stage.participants
.map((participant) => ({
id: participant.id ?? randomUUID(),
type: participant.type,
agentId: participant.type === "agent" ? participant.agentId ?? null : null,
userId: participant.type === "user" ? participant.userId ?? null : null,
}))
.filter((participant) => (participant.type === "agent" ? Boolean(participant.agentId) : Boolean(participant.userId)));
const dedupedParticipants: IssueExecutionStage["participants"] = [];
const seen = new Set<string>();
for (const participant of participants) {
const key = participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`;
if (seen.has(key)) continue;
seen.add(key);
dedupedParticipants.push(participant);
}
if (dedupedParticipants.length === 0) return null;
return {
id: stage.id ?? randomUUID(),
type: stage.type,
approvalsNeeded: 1,
participants: dedupedParticipants,
};
})
.filter((stage): stage is NonNullable<typeof stage> => stage !== null);
if (stages.length === 0) return null;
return {
mode: parsed.data.mode ?? "normal",
commentRequired: true,
stages,
};
}
export function parseIssueExecutionState(input: unknown): IssueExecutionState | null {
if (input == null) return null;
const parsed = issueExecutionStateSchema.safeParse(input);
if (!parsed.success) return null;
return parsed.data;
}
export function assigneePrincipal(input: AssigneeLike): IssueExecutionStagePrincipal | null {
if (input.assigneeAgentId) {
return { type: "agent", agentId: input.assigneeAgentId, userId: null };
}
if (input.assigneeUserId) {
return { type: "user", userId: input.assigneeUserId, agentId: null };
}
return null;
}
function actorPrincipal(actor: ActorLike): IssueExecutionStagePrincipal | null {
if (actor.agentId) return { type: "agent", agentId: actor.agentId, userId: null };
if (actor.userId) return { type: "user", userId: actor.userId, agentId: null };
return null;
}
function principalsEqual(a: IssueExecutionStagePrincipal | null, b: IssueExecutionStagePrincipal | null): boolean {
if (!a || !b) return false;
if (a.type !== b.type) return false;
return a.type === "agent" ? a.agentId === b.agentId : a.userId === b.userId;
}
function findStageById(policy: IssueExecutionPolicy, stageId: string | null | undefined) {
if (!stageId) return null;
return policy.stages.find((stage) => stage.id === stageId) ?? null;
}
function nextPendingStage(policy: IssueExecutionPolicy, state: IssueExecutionState | null) {
const completed = new Set(state?.completedStageIds ?? []);
return policy.stages.find((stage) => !completed.has(stage.id)) ?? null;
}
function selectStageParticipant(
stage: IssueExecutionStage,
opts?: {
preferred?: IssueExecutionStagePrincipal | null;
exclude?: IssueExecutionStagePrincipal | null;
},
): IssueExecutionStagePrincipal | null {
const participants = stage.participants.filter((participant) => !principalsEqual(participant, opts?.exclude ?? null));
if (participants.length === 0) return null;
if (opts?.preferred) {
const preferred = participants.find((participant) => principalsEqual(participant, opts.preferred ?? null));
if (preferred) return preferred;
}
const first = participants[0];
return first ? { type: first.type, agentId: first.agentId ?? null, userId: first.userId ?? null } : null;
}
function patchForPrincipal(principal: IssueExecutionStagePrincipal | null) {
if (!principal) {
return { assigneeAgentId: null, assigneeUserId: null };
}
return principal.type === "agent"
? { assigneeAgentId: principal.agentId ?? null, assigneeUserId: null }
: { assigneeAgentId: null, assigneeUserId: principal.userId ?? null };
}
function buildCompletedState(previous: IssueExecutionState | null, currentStage: IssueExecutionStage): IssueExecutionState {
const completedStageIds = Array.from(new Set([...(previous?.completedStageIds ?? []), currentStage.id]));
return {
status: COMPLETED_STATUS,
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: previous?.returnAssignee ?? null,
completedStageIds,
lastDecisionId: previous?.lastDecisionId ?? null,
lastDecisionOutcome: "approved",
};
}
function buildPendingState(input: {
previous: IssueExecutionState | null;
stage: IssueExecutionStage;
stageIndex: number;
participant: IssueExecutionStagePrincipal;
returnAssignee: IssueExecutionStagePrincipal | null;
}): IssueExecutionState {
return {
status: PENDING_STATUS,
currentStageId: input.stage.id,
currentStageIndex: input.stageIndex,
currentStageType: input.stage.type,
currentParticipant: input.participant,
returnAssignee: input.returnAssignee,
completedStageIds: input.previous?.completedStageIds ?? [],
lastDecisionId: input.previous?.lastDecisionId ?? null,
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
};
}
function buildChangesRequestedState(previous: IssueExecutionState, currentStage: IssueExecutionStage): IssueExecutionState {
return {
...previous,
status: CHANGES_REQUESTED_STATUS,
currentStageId: currentStage.id,
currentStageType: currentStage.type,
lastDecisionOutcome: "changes_requested",
};
}
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
const patch: Record<string, unknown> = {};
const existingState = parseIssueExecutionState(input.issue.executionState);
const currentAssignee = assigneePrincipal(input.issue);
const actor = actorPrincipal(input.actor);
const explicitAssignee = assigneePrincipal(input.requestedAssigneePatch);
const currentStage = input.policy ? findStageById(input.policy, existingState?.currentStageId) : null;
const requestedStatus = input.requestedStatus;
if (!input.policy) {
if (existingState) {
patch.executionState = null;
if (input.issue.status === "in_review" && existingState.returnAssignee) {
patch.status = "in_progress";
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
}
}
return { patch };
}
if (
(input.issue.status === "done" || input.issue.status === "cancelled") &&
requestedStatus &&
requestedStatus !== "done" &&
requestedStatus !== "cancelled"
) {
patch.executionState = null;
return { patch };
}
if (currentStage && input.issue.status === "in_review") {
if (!principalsEqual(existingState?.currentParticipant ?? null, actor)) {
if (requestedStatus && requestedStatus !== "in_review") {
throw unprocessable("Only the active reviewer or approver can advance the current execution stage");
}
return { patch };
}
if (requestedStatus === "done") {
if (!input.commentBody?.trim()) {
throw unprocessable("Approving a review or approval stage requires a comment");
}
const approvedState = buildCompletedState(existingState, currentStage);
const nextStage = nextPendingStage(
input.policy,
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
);
if (!nextStage) {
patch.executionState = approvedState;
return {
patch,
decision: {
stageId: currentStage.id,
stageType: currentStage.type,
outcome: "approved",
body: input.commentBody.trim(),
},
};
}
const participant = selectStageParticipant(nextStage, {
preferred: explicitAssignee,
exclude: existingState?.returnAssignee ?? null,
});
if (!participant) {
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
}
patch.status = "in_review";
Object.assign(patch, patchForPrincipal(participant));
patch.executionState = buildPendingState({
previous: approvedState,
stage: nextStage,
stageIndex: input.policy.stages.findIndex((stage) => stage.id === nextStage.id),
participant,
returnAssignee: existingState?.returnAssignee ?? currentAssignee,
});
return {
patch,
decision: {
stageId: currentStage.id,
stageType: currentStage.type,
outcome: "approved",
body: input.commentBody.trim(),
},
};
}
if (requestedStatus && requestedStatus !== "in_review") {
if (!input.commentBody?.trim()) {
throw unprocessable("Requesting changes requires a comment");
}
if (!existingState?.returnAssignee) {
throw unprocessable("This execution stage has no return assignee");
}
patch.status = "in_progress";
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
patch.executionState = buildChangesRequestedState(existingState, currentStage);
return {
patch,
decision: {
stageId: currentStage.id,
stageType: currentStage.type,
outcome: "changes_requested",
body: input.commentBody.trim(),
},
};
}
return { patch };
}
if (requestedStatus !== "done") {
return { patch };
}
const pendingStage =
existingState?.status === CHANGES_REQUESTED_STATUS && currentStage
? currentStage
: nextPendingStage(input.policy, existingState);
if (!pendingStage) return { patch };
const returnAssignee = existingState?.returnAssignee ?? currentAssignee;
const participant = selectStageParticipant(pendingStage, {
preferred:
existingState?.status === CHANGES_REQUESTED_STATUS
? explicitAssignee ?? existingState.currentParticipant ?? null
: explicitAssignee,
exclude: returnAssignee,
});
if (!participant) {
throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`);
}
patch.status = "in_review";
Object.assign(patch, patchForPrincipal(participant));
patch.executionState = buildPendingState({
previous: existingState,
stage: pendingStage,
stageIndex: input.policy.stages.findIndex((stage) => stage.id === pendingStage.id),
participant,
returnAssignee,
});
return { patch };
}

View file

@ -12,6 +12,7 @@ import { queryKeys } from "../lib/queryKeys";
import { useProjectOrder } from "../hooks/useProjectOrder"; import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { formatAssigneeUserLabel } from "../lib/assignees"; import { formatAssigneeUserLabel } from "../lib/assignees";
import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy";
import { StatusIcon } from "./StatusIcon"; import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon"; import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
@ -166,6 +167,10 @@ export function IssueProperties({
const [projectSearch, setProjectSearch] = useState(""); const [projectSearch, setProjectSearch] = useState("");
const [blockedByOpen, setBlockedByOpen] = useState(false); const [blockedByOpen, setBlockedByOpen] = useState(false);
const [blockedBySearch, setBlockedBySearch] = useState(""); const [blockedBySearch, setBlockedBySearch] = useState("");
const [reviewersOpen, setReviewersOpen] = useState(false);
const [reviewerSearch, setReviewerSearch] = useState("");
const [approversOpen, setApproversOpen] = useState(false);
const [approverSearch, setApproverSearch] = useState("");
const [labelsOpen, setLabelsOpen] = useState(false); const [labelsOpen, setLabelsOpen] = useState(false);
const [labelSearch, setLabelSearch] = useState(""); const [labelSearch, setLabelSearch] = useState("");
const [newLabelName, setNewLabelName] = useState(""); const [newLabelName, setNewLabelName] = useState("");
@ -265,9 +270,59 @@ export function IssueProperties({
const assignee = issue.assigneeAgentId const assignee = issue.assigneeAgentId
? agents?.find((a) => a.id === issue.assigneeAgentId) ? agents?.find((a) => a.id === issue.assigneeAgentId)
: null; : null;
const reviewerValues = stageParticipantValues(issue.executionPolicy, "review");
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId); const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
const assigneeUserLabel = userLabel(issue.assigneeUserId); const assigneeUserLabel = userLabel(issue.assigneeUserId);
const creatorUserLabel = userLabel(issue.createdByUserId); const creatorUserLabel = userLabel(issue.createdByUserId);
const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => {
onUpdate({
executionPolicy: buildExecutionPolicy({
existingPolicy: issue.executionPolicy ?? null,
reviewerValues: nextReviewers,
approverValues: nextApprovers,
}),
});
};
const toggleExecutionParticipant = (stageType: "review" | "approval", value: string) => {
const currentValues = stageType === "review" ? reviewerValues : approverValues;
const nextValues = currentValues.includes(value)
? currentValues.filter((candidate) => candidate !== value)
: [...currentValues, value];
updateExecutionPolicy(
stageType === "review" ? nextValues : reviewerValues,
stageType === "approval" ? nextValues : approverValues,
);
};
const executionParticipantLabel = (value: string) => {
if (value.startsWith("agent:")) {
return agentName(value.slice("agent:".length)) ?? value.slice("agent:".length, "agent:".length + 8);
}
if (value.startsWith("user:")) {
return userLabel(value.slice("user:".length)) ?? "User";
}
return value;
};
const reviewerTrigger = reviewerValues.length > 0
? <span className="text-sm truncate">{reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
: <span className="text-sm text-muted-foreground">None</span>;
const approverTrigger = approverValues.length > 0
? <span className="text-sm truncate">{approverValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
: <span className="text-sm text-muted-foreground">None</span>;
const currentExecutionLabel = (() => {
if (!issue.executionState?.currentStageType) return null;
const stageLabel = issue.executionState.currentStageType === "review" ? "Review" : "Approval";
const participant = issue.executionState.currentParticipant;
const participantLabel = participant
? (participant.type === "agent"
? agentName(participant.agentId ?? null)
: userLabel(participant.userId ?? null))
: null;
if (issue.executionState.status === "changes_requested") {
return `${stageLabel} requested changes${participantLabel ? ` by ${participantLabel}` : ""}`;
}
return `${stageLabel} pending${participantLabel ? ` with ${participantLabel}` : ""}`;
})();
const labelsTrigger = (issue.labels ?? []).length > 0 ? ( const labelsTrigger = (issue.labels ?? []).length > 0 ? (
<div className="flex items-center gap-1 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
@ -454,6 +509,80 @@ export function IssueProperties({
</> </>
); );
const executionParticipantsContent = (
stageType: "review" | "approval",
values: string[],
search: string,
setSearch: (value: string) => void,
onClear: () => void,
) => (
<>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder={`Search ${stageType === "review" ? "reviewers" : "approvers"}...`}
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus={!inline}
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
values.length === 0 && "bg-accent",
)}
onClick={onClear}
>
No {stageType === "review" ? "reviewers" : "approvers"}
</button>
{currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
values.includes(`user:${currentUserId}`) && "bg-accent",
)}
onClick={() => toggleExecutionParticipant(stageType, `user:${currentUserId}`)}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
Assign to me
</button>
)}
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
values.includes(`user:${issue.createdByUserId}`) && "bg-accent",
)}
onClick={() => toggleExecutionParticipant(stageType, `user:${issue.createdByUserId}`)}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{creatorUserLabel ? creatorUserLabel : "Requester"}
</button>
)}
{sortedAgents
.filter((agent) => {
if (!search.trim()) return true;
return agent.name.toLowerCase().includes(search.toLowerCase());
})
.map((agent) => {
const encoded = `agent:${agent.id}`;
return (
<button
key={`${stageType}:${agent.id}`}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
values.includes(encoded) && "bg-accent",
)}
onClick={() => toggleExecutionParticipant(stageType, encoded)}
>
<AgentIcon icon={agent.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
{agent.name}
</button>
);
})}
</div>
</>
);
const projectTrigger = issue.projectId ? ( const projectTrigger = issue.projectId ? (
<> <>
<span <span
@ -750,6 +879,48 @@ export function IssueProperties({
</div> </div>
</PropertyRow> </PropertyRow>
<PropertyPicker
inline={inline}
label="Reviewers"
open={reviewersOpen}
onOpenChange={(open) => { setReviewersOpen(open); if (!open) setReviewerSearch(""); }}
triggerContent={reviewerTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-56"
>
{executionParticipantsContent(
"review",
reviewerValues,
reviewerSearch,
setReviewerSearch,
() => updateExecutionPolicy([], approverValues),
)}
</PropertyPicker>
<PropertyPicker
inline={inline}
label="Approvers"
open={approversOpen}
onOpenChange={(open) => { setApproversOpen(open); if (!open) setApproverSearch(""); }}
triggerContent={approverTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-56"
>
{executionParticipantsContent(
"approval",
approverValues,
approverSearch,
setApproverSearch,
() => updateExecutionPolicy(reviewerValues, []),
)}
</PropertyPicker>
{currentExecutionLabel && (
<PropertyRow label="Execution">
<span className="text-sm">{currentExecutionLabel}</span>
</PropertyRow>
)}
{issue.parentId && ( {issue.parentId && (
<PropertyRow label="Parent"> <PropertyRow label="Parent">
<Link <Link

View file

@ -13,6 +13,7 @@ import { assetsApi } from "../api/assets";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { useProjectOrder } from "../hooks/useProjectOrder"; import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { buildExecutionPolicy } from "../lib/issue-execution-policy";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
import { import {
assigneeValueFromSelection, assigneeValueFromSelection,
@ -66,6 +67,8 @@ interface IssueDraft {
status: string; status: string;
priority: string; priority: string;
assigneeValue: string; assigneeValue: string;
reviewerValue: string;
approverValue: string;
assigneeId?: string; assigneeId?: string;
projectId: string; projectId: string;
projectWorkspaceId?: string; projectWorkspaceId?: string;
@ -281,6 +284,8 @@ export function NewIssueDialog() {
const [status, setStatus] = useState("todo"); const [status, setStatus] = useState("todo");
const [priority, setPriority] = useState(""); const [priority, setPriority] = useState("");
const [assigneeValue, setAssigneeValue] = useState(""); const [assigneeValue, setAssigneeValue] = useState("");
const [reviewerValue, setReviewerValue] = useState("");
const [approverValue, setApproverValue] = useState("");
const [projectId, setProjectId] = useState(""); const [projectId, setProjectId] = useState("");
const [projectWorkspaceId, setProjectWorkspaceId] = useState(""); const [projectWorkspaceId, setProjectWorkspaceId] = useState("");
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false); const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
@ -484,6 +489,8 @@ export function NewIssueDialog() {
status, status,
priority, priority,
assigneeValue, assigneeValue,
reviewerValue,
approverValue,
projectId, projectId,
projectWorkspaceId, projectWorkspaceId,
assigneeModelOverride, assigneeModelOverride,
@ -498,6 +505,8 @@ export function NewIssueDialog() {
status, status,
priority, priority,
assigneeValue, assigneeValue,
reviewerValue,
approverValue,
projectId, projectId,
projectWorkspaceId, projectWorkspaceId,
assigneeModelOverride, assigneeModelOverride,
@ -547,6 +556,8 @@ export function NewIssueDialog() {
setProjectId(defaultProjectId); setProjectId(defaultProjectId);
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject)); setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults)); setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setReviewerValue("");
setApproverValue("");
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
setAssigneeChrome(false); setAssigneeChrome(false);
@ -565,6 +576,8 @@ export function NewIssueDialog() {
? assigneeValueFromSelection(newIssueDefaults) ? assigneeValueFromSelection(newIssueDefaults)
: (draft.assigneeValue ?? draft.assigneeId ?? ""), : (draft.assigneeValue ?? draft.assigneeId ?? ""),
); );
setReviewerValue(draft.reviewerValue ?? "");
setApproverValue(draft.approverValue ?? "");
setProjectId(restoredProjectId); setProjectId(restoredProjectId);
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject)); setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
setAssigneeModelOverride(draft.assigneeModelOverride ?? ""); setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
@ -584,6 +597,8 @@ export function NewIssueDialog() {
setProjectId(defaultProjectId); setProjectId(defaultProjectId);
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject)); setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults)); setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setReviewerValue("");
setApproverValue("");
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
setAssigneeChrome(false); setAssigneeChrome(false);
@ -626,6 +641,8 @@ export function NewIssueDialog() {
setStatus("todo"); setStatus("todo");
setPriority(""); setPriority("");
setAssigneeValue(""); setAssigneeValue("");
setReviewerValue("");
setApproverValue("");
setProjectId(""); setProjectId("");
setProjectWorkspaceId(""); setProjectWorkspaceId("");
setAssigneeOptionsOpen(false); setAssigneeOptionsOpen(false);
@ -647,6 +664,8 @@ export function NewIssueDialog() {
if (companyId === effectiveCompanyId) return; if (companyId === effectiveCompanyId) return;
setDialogCompanyId(companyId); setDialogCompanyId(companyId);
setAssigneeValue(""); setAssigneeValue("");
setReviewerValue("");
setApproverValue("");
setProjectId(""); setProjectId("");
setProjectWorkspaceId(""); setProjectWorkspaceId("");
setAssigneeModelOverride(""); setAssigneeModelOverride("");
@ -685,6 +704,10 @@ export function NewIssueDialog() {
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
? { mode: requestedExecutionWorkspaceMode } ? { mode: requestedExecutionWorkspaceMode }
: null; : null;
const executionPolicy = buildExecutionPolicy({
reviewerValues: reviewerValue ? [reviewerValue] : [],
approverValues: approverValue ? [approverValue] : [],
});
createIssue.mutate({ createIssue.mutate({
companyId: effectiveCompanyId, companyId: effectiveCompanyId,
stagedFiles, stagedFiles,
@ -704,6 +727,7 @@ export function NewIssueDialog() {
? { executionWorkspaceId: selectedExecutionWorkspaceId } ? { executionWorkspaceId: selectedExecutionWorkspaceId }
: {}), : {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}), ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
...(executionPolicy ? { executionPolicy } : {}),
}); });
} }
@ -1153,6 +1177,64 @@ export function NewIssueDialog() {
); );
}} }}
/> />
<InlineEntitySelector
value={reviewerValue}
options={assigneeOptions}
placeholder="Reviewer"
disablePortal
noneLabel="No reviewer"
searchPlaceholder="Search reviewers..."
emptyMessage="No reviewers found."
onChange={setReviewerValue}
renderTriggerValue={(option) =>
option ? (
<span className="truncate">{`Reviewer: ${option.label}`}</span>
) : (
<span className="text-muted-foreground">Reviewer</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const reviewer = parseAssigneeValue(option.id).assigneeAgentId
? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
: null;
return (
<>
{reviewer ? <AgentIcon icon={reviewer.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
<InlineEntitySelector
value={approverValue}
options={assigneeOptions}
placeholder="Approver"
disablePortal
noneLabel="No approver"
searchPlaceholder="Search approvers..."
emptyMessage="No approvers found."
onChange={setApproverValue}
renderTriggerValue={(option) =>
option ? (
<span className="truncate">{`Approver: ${option.label}`}</span>
) : (
<span className="text-muted-foreground">Approver</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const approver = parseAssigneeValue(option.id).assigneeAgentId
? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
: null;
return (
<>
{approver ? <AgentIcon icon={approver.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,95 @@
import type { IssueExecutionPolicy, IssueExecutionStageParticipant, IssueExecutionStagePrincipal } from "@paperclipai/shared";
import { parseAssigneeValue } from "./assignees";
type StageType = "review" | "approval";
function newId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `stage-${Math.random().toString(36).slice(2)}`;
}
function principalKey(principal: IssueExecutionStagePrincipal | IssueExecutionStageParticipant) {
return principal.type === "agent" ? `agent:${principal.agentId}` : `user:${principal.userId}`;
}
export function principalFromSelectionValue(value: string): IssueExecutionStagePrincipal | null {
const selection = parseAssigneeValue(value);
if (selection.assigneeAgentId) {
return { type: "agent", agentId: selection.assigneeAgentId, userId: null };
}
if (selection.assigneeUserId) {
return { type: "user", userId: selection.assigneeUserId, agentId: null };
}
return null;
}
export function selectionValueFromPrincipal(principal: IssueExecutionStagePrincipal | IssueExecutionStageParticipant): string {
return principal.type === "agent" ? `agent:${principal.agentId}` : `user:${principal.userId}`;
}
export function stageParticipantValues(policy: IssueExecutionPolicy | null | undefined, stageType: StageType): string[] {
const stage = policy?.stages.find((candidate) => candidate.type === stageType);
return stage?.participants.map((participant) => selectionValueFromPrincipal(participant)) ?? [];
}
function mergeParticipants(
existing: IssueExecutionStageParticipant[] | undefined,
values: string[],
): IssueExecutionStageParticipant[] {
const existingByKey = new Map((existing ?? []).map((participant) => [principalKey(participant), participant]));
const participants: IssueExecutionStageParticipant[] = [];
for (const value of values) {
const principal = principalFromSelectionValue(value);
if (!principal) continue;
const key = principalKey(principal);
const previous = existingByKey.get(key);
participants.push({
id: previous?.id ?? newId(),
type: principal.type,
agentId: principal.type === "agent" ? principal.agentId ?? null : null,
userId: principal.type === "user" ? principal.userId ?? null : null,
});
}
return participants;
}
export function buildExecutionPolicy(input: {
existingPolicy?: IssueExecutionPolicy | null;
reviewerValues: string[];
approverValues: string[];
}): IssueExecutionPolicy | null {
const mode = input.existingPolicy?.mode ?? "normal";
const stages = [];
const existingReviewStage = input.existingPolicy?.stages.find((stage) => stage.type === "review");
const reviewParticipants = mergeParticipants(existingReviewStage?.participants, input.reviewerValues);
if (reviewParticipants.length > 0) {
stages.push({
id: existingReviewStage?.id ?? newId(),
type: "review" as const,
approvalsNeeded: 1,
participants: reviewParticipants,
});
}
const existingApprovalStage = input.existingPolicy?.stages.find((stage) => stage.type === "approval");
const approvalParticipants = mergeParticipants(existingApprovalStage?.participants, input.approverValues);
if (approvalParticipants.length > 0) {
stages.push({
id: existingApprovalStage?.id ?? newId(),
type: "approval" as const,
approvalsNeeded: 1,
participants: approvalParticipants,
});
}
if (stages.length === 0) return null;
return {
mode,
commentRequired: true,
stages,
};
}