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

@ -4,7 +4,7 @@ import net from "node:net";
import os from "node:os";
import path from "node:path";
import { createServer } from "node:http";
import { and, eq } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import { WebSocketServer } from "ws";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
@ -307,6 +307,14 @@ describe("heartbeat comment wake batching", () => {
expect(firstRun).not.toBeNull();
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
.insert(issueComments)
.values({
@ -415,4 +423,114 @@ describe("heartbeat comment wake batching", () => {
await gateway.close();
}
}, 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",
});
});
});