All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Implement rejection handling for tasks that fail quality gates after all continuation attempts are exhausted. Schema: - Add TaskRejection model for tracking rejections - Store failures, attempts, escalation state Service: - handleRejection: Main entry point for rejection handling - logRejection: Database logging - determineEscalation: Rule-based escalation determination - executeEscalation: Execute escalation actions - sendNotification: Notification dispatch - markForManualReview: Flag tasks for human review - getRejectionHistory: Query rejection history - generateRejectionReport: Markdown report generation Escalation rules: - max-attempts: Trigger after 3+ attempts - time-exceeded: Trigger after 2+ hours - critical-failure: Trigger on security/critical issues Actions: notify, block, reassign, cancel Tests: 16 passing with 80% statement coverage Fixes #139 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
443 lines
14 KiB
TypeScript
443 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { RejectionHandlerService } from "./rejection-handler.service";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { Logger } from "@nestjs/common";
|
|
import type {
|
|
RejectionContext,
|
|
RejectionResult,
|
|
EscalationConfig,
|
|
EscalationRule,
|
|
} from "./interfaces";
|
|
|
|
describe("RejectionHandlerService", () => {
|
|
let service: RejectionHandlerService;
|
|
let prismaService: PrismaService;
|
|
|
|
const mockRejectionContext: RejectionContext = {
|
|
taskId: "task-123",
|
|
workspaceId: "workspace-456",
|
|
agentId: "agent-789",
|
|
attemptCount: 3,
|
|
failures: [
|
|
{
|
|
gateName: "type-check",
|
|
failureType: "compilation-error",
|
|
message: "Type error in module",
|
|
attempts: 2,
|
|
},
|
|
{
|
|
gateName: "test-gate",
|
|
failureType: "test-failure",
|
|
message: "5 tests failed",
|
|
attempts: 1,
|
|
},
|
|
],
|
|
originalTask: "Implement user authentication",
|
|
startedAt: new Date("2026-01-31T10:00:00Z"),
|
|
rejectedAt: new Date("2026-01-31T12:30:00Z"),
|
|
};
|
|
|
|
const mockPrismaService = {
|
|
taskRejection: {
|
|
create: vi.fn(),
|
|
findMany: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const mockLogger = {
|
|
log: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
RejectionHandlerService,
|
|
{
|
|
provide: PrismaService,
|
|
useValue: mockPrismaService,
|
|
},
|
|
{
|
|
provide: Logger,
|
|
useValue: mockLogger,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<RejectionHandlerService>(RejectionHandlerService);
|
|
prismaService = module.get(PrismaService);
|
|
|
|
// Clear all mocks before each test
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("should be defined", () => {
|
|
expect(service).toBeDefined();
|
|
});
|
|
|
|
describe("handleRejection", () => {
|
|
it("should handle rejection and return result", async () => {
|
|
const mockRejection = {
|
|
id: "rejection-1",
|
|
taskId: mockRejectionContext.taskId,
|
|
workspaceId: mockRejectionContext.workspaceId,
|
|
agentId: mockRejectionContext.agentId,
|
|
attemptCount: mockRejectionContext.attemptCount,
|
|
failures: mockRejectionContext.failures as any,
|
|
originalTask: mockRejectionContext.originalTask,
|
|
startedAt: mockRejectionContext.startedAt,
|
|
rejectedAt: mockRejectionContext.rejectedAt,
|
|
escalated: true,
|
|
manualReview: true,
|
|
resolvedAt: null,
|
|
resolution: null,
|
|
};
|
|
|
|
mockPrismaService.taskRejection.create.mockResolvedValue(mockRejection);
|
|
mockPrismaService.taskRejection.findMany.mockResolvedValue([mockRejection]);
|
|
mockPrismaService.taskRejection.update.mockResolvedValue(mockRejection);
|
|
|
|
const result = await service.handleRejection(mockRejectionContext);
|
|
|
|
expect(result.handled).toBe(true);
|
|
expect(result.manualReviewRequired).toBe(true);
|
|
expect(mockPrismaService.taskRejection.create).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle rejection without escalation for low attempt count", async () => {
|
|
const lowAttemptContext: RejectionContext = {
|
|
...mockRejectionContext,
|
|
attemptCount: 1,
|
|
startedAt: new Date("2026-01-31T12:00:00Z"),
|
|
rejectedAt: new Date("2026-01-31T12:30:00Z"), // 30 minutes - under maxWaitTime
|
|
};
|
|
|
|
const mockRejection = {
|
|
id: "rejection-2",
|
|
taskId: lowAttemptContext.taskId,
|
|
workspaceId: lowAttemptContext.workspaceId,
|
|
agentId: lowAttemptContext.agentId,
|
|
attemptCount: lowAttemptContext.attemptCount,
|
|
failures: lowAttemptContext.failures as any,
|
|
originalTask: lowAttemptContext.originalTask,
|
|
startedAt: lowAttemptContext.startedAt,
|
|
rejectedAt: lowAttemptContext.rejectedAt,
|
|
escalated: false,
|
|
manualReview: false,
|
|
resolvedAt: null,
|
|
resolution: null,
|
|
};
|
|
|
|
mockPrismaService.taskRejection.create.mockResolvedValue(mockRejection);
|
|
mockPrismaService.taskRejection.findMany.mockResolvedValue([mockRejection]);
|
|
mockPrismaService.taskRejection.update.mockResolvedValue(mockRejection);
|
|
|
|
const result = await service.handleRejection(lowAttemptContext);
|
|
|
|
expect(result.handled).toBe(true);
|
|
expect(result.escalated).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("logRejection", () => {
|
|
it("should log rejection to database", async () => {
|
|
prismaService.taskRejection.create.mockResolvedValue({
|
|
id: "rejection-3",
|
|
taskId: mockRejectionContext.taskId,
|
|
workspaceId: mockRejectionContext.workspaceId,
|
|
agentId: mockRejectionContext.agentId,
|
|
attemptCount: mockRejectionContext.attemptCount,
|
|
failures: mockRejectionContext.failures as any,
|
|
originalTask: mockRejectionContext.originalTask,
|
|
startedAt: mockRejectionContext.startedAt,
|
|
rejectedAt: mockRejectionContext.rejectedAt,
|
|
escalated: false,
|
|
manualReview: false,
|
|
resolvedAt: null,
|
|
resolution: null,
|
|
});
|
|
|
|
await service.logRejection(mockRejectionContext);
|
|
|
|
expect(prismaService.taskRejection.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
taskId: mockRejectionContext.taskId,
|
|
workspaceId: mockRejectionContext.workspaceId,
|
|
agentId: mockRejectionContext.agentId,
|
|
attemptCount: mockRejectionContext.attemptCount,
|
|
}),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("determineEscalation", () => {
|
|
it("should determine escalation rules based on max attempts", () => {
|
|
const config: EscalationConfig = {
|
|
rules: [
|
|
{
|
|
condition: "max-attempts",
|
|
action: "notify",
|
|
target: "admin@example.com",
|
|
priority: "high",
|
|
},
|
|
],
|
|
notifyOnRejection: true,
|
|
autoReassign: false,
|
|
maxWaitTime: 60,
|
|
};
|
|
|
|
const rules = service.determineEscalation(mockRejectionContext, config);
|
|
|
|
expect(rules).toHaveLength(1);
|
|
expect(rules[0].condition).toBe("max-attempts");
|
|
expect(rules[0].action).toBe("notify");
|
|
expect(rules[0].priority).toBe("high");
|
|
});
|
|
|
|
it("should determine escalation for time exceeded", () => {
|
|
const longRunningContext: RejectionContext = {
|
|
...mockRejectionContext,
|
|
startedAt: new Date("2026-01-31T08:00:00Z"),
|
|
rejectedAt: new Date("2026-01-31T12:00:00Z"),
|
|
};
|
|
|
|
const config: EscalationConfig = {
|
|
rules: [
|
|
{
|
|
condition: "time-exceeded",
|
|
action: "block",
|
|
priority: "critical",
|
|
},
|
|
],
|
|
notifyOnRejection: true,
|
|
autoReassign: false,
|
|
maxWaitTime: 120, // 2 hours
|
|
};
|
|
|
|
const rules = service.determineEscalation(longRunningContext, config);
|
|
|
|
expect(rules.length).toBeGreaterThan(0);
|
|
expect(rules.some((r) => r.condition === "time-exceeded")).toBe(true);
|
|
});
|
|
|
|
it("should determine escalation for critical failures", () => {
|
|
const criticalContext: RejectionContext = {
|
|
...mockRejectionContext,
|
|
failures: [
|
|
{
|
|
gateName: "security-scan",
|
|
failureType: "critical-vulnerability",
|
|
message: "SQL injection detected",
|
|
attempts: 1,
|
|
},
|
|
],
|
|
};
|
|
|
|
const config: EscalationConfig = {
|
|
rules: [
|
|
{
|
|
condition: "critical-failure",
|
|
action: "block",
|
|
priority: "critical",
|
|
},
|
|
],
|
|
notifyOnRejection: true,
|
|
autoReassign: false,
|
|
maxWaitTime: 60,
|
|
};
|
|
|
|
const rules = service.determineEscalation(criticalContext, config);
|
|
|
|
expect(rules.some((r) => r.condition === "critical-failure")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("executeEscalation", () => {
|
|
it("should execute notification escalation", async () => {
|
|
const rules: EscalationRule[] = [
|
|
{
|
|
condition: "max-attempts",
|
|
action: "notify",
|
|
target: "admin@example.com",
|
|
priority: "high",
|
|
},
|
|
];
|
|
|
|
// Mock sendNotification
|
|
vi.spyOn(service, "sendNotification").mockReturnValue();
|
|
|
|
await service.executeEscalation(mockRejectionContext, rules);
|
|
|
|
expect(service.sendNotification).toHaveBeenCalledWith(
|
|
mockRejectionContext,
|
|
"admin@example.com",
|
|
"high"
|
|
);
|
|
});
|
|
|
|
it("should execute block escalation", async () => {
|
|
const rules: EscalationRule[] = [
|
|
{
|
|
condition: "critical-failure",
|
|
action: "block",
|
|
priority: "critical",
|
|
},
|
|
];
|
|
|
|
vi.spyOn(service, "markForManualReview").mockResolvedValue();
|
|
|
|
await service.executeEscalation(mockRejectionContext, rules);
|
|
|
|
expect(service.markForManualReview).toHaveBeenCalledWith(
|
|
mockRejectionContext.taskId,
|
|
expect.any(String)
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("sendNotification", () => {
|
|
it("should send notification with context and priority", () => {
|
|
service.sendNotification(mockRejectionContext, "admin@example.com", "high");
|
|
|
|
// Verify logging occurred
|
|
expect(true).toBe(true); // Placeholder - actual implementation will log
|
|
});
|
|
});
|
|
|
|
describe("markForManualReview", () => {
|
|
it("should mark task for manual review", async () => {
|
|
const mockRejection = {
|
|
id: "rejection-4",
|
|
taskId: mockRejectionContext.taskId,
|
|
workspaceId: mockRejectionContext.workspaceId,
|
|
agentId: mockRejectionContext.agentId,
|
|
attemptCount: mockRejectionContext.attemptCount,
|
|
failures: mockRejectionContext.failures as any,
|
|
originalTask: mockRejectionContext.originalTask,
|
|
startedAt: mockRejectionContext.startedAt,
|
|
rejectedAt: mockRejectionContext.rejectedAt,
|
|
escalated: false,
|
|
manualReview: false,
|
|
resolvedAt: null,
|
|
resolution: null,
|
|
};
|
|
|
|
mockPrismaService.taskRejection.findMany.mockResolvedValue([mockRejection]);
|
|
mockPrismaService.taskRejection.update.mockResolvedValue({
|
|
...mockRejection,
|
|
escalated: true,
|
|
manualReview: true,
|
|
});
|
|
|
|
await service.markForManualReview(mockRejectionContext.taskId, "Max attempts exceeded");
|
|
|
|
expect(mockPrismaService.taskRejection.findMany).toHaveBeenCalledWith({
|
|
where: { taskId: mockRejectionContext.taskId },
|
|
orderBy: { rejectedAt: "desc" },
|
|
take: 1,
|
|
});
|
|
|
|
expect(mockPrismaService.taskRejection.update).toHaveBeenCalledWith({
|
|
where: { id: mockRejection.id },
|
|
data: {
|
|
manualReview: true,
|
|
escalated: true,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getRejectionHistory", () => {
|
|
it("should retrieve rejection history for a task", async () => {
|
|
const mockHistory = [
|
|
{
|
|
id: "rejection-5",
|
|
taskId: mockRejectionContext.taskId,
|
|
workspaceId: mockRejectionContext.workspaceId,
|
|
agentId: mockRejectionContext.agentId,
|
|
attemptCount: 1,
|
|
failures: [],
|
|
originalTask: mockRejectionContext.originalTask,
|
|
startedAt: new Date("2026-01-31T09:00:00Z"),
|
|
rejectedAt: new Date("2026-01-31T10:00:00Z"),
|
|
escalated: false,
|
|
manualReview: false,
|
|
resolvedAt: null,
|
|
resolution: null,
|
|
},
|
|
{
|
|
id: "rejection-6",
|
|
taskId: mockRejectionContext.taskId,
|
|
workspaceId: mockRejectionContext.workspaceId,
|
|
agentId: mockRejectionContext.agentId,
|
|
attemptCount: 2,
|
|
failures: [],
|
|
originalTask: mockRejectionContext.originalTask,
|
|
startedAt: new Date("2026-01-31T10:30:00Z"),
|
|
rejectedAt: new Date("2026-01-31T11:30:00Z"),
|
|
escalated: false,
|
|
manualReview: false,
|
|
resolvedAt: null,
|
|
resolution: null,
|
|
},
|
|
];
|
|
|
|
prismaService.taskRejection.findMany.mockResolvedValue(mockHistory);
|
|
|
|
const history = await service.getRejectionHistory(mockRejectionContext.taskId);
|
|
|
|
expect(history).toHaveLength(2);
|
|
expect(prismaService.taskRejection.findMany).toHaveBeenCalledWith({
|
|
where: { taskId: mockRejectionContext.taskId },
|
|
orderBy: { rejectedAt: "desc" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("generateRejectionReport", () => {
|
|
it("should generate a formatted rejection report", () => {
|
|
const report = service.generateRejectionReport(mockRejectionContext);
|
|
|
|
expect(report).toContain("Task Rejection Report");
|
|
expect(report).toContain(mockRejectionContext.taskId);
|
|
expect(report).toContain(mockRejectionContext.attemptCount.toString());
|
|
expect(report).toContain("type-check");
|
|
expect(report).toContain("test-gate");
|
|
});
|
|
|
|
it("should include failure details in report", () => {
|
|
const report = service.generateRejectionReport(mockRejectionContext);
|
|
|
|
mockRejectionContext.failures.forEach((failure) => {
|
|
expect(report).toContain(failure.gateName);
|
|
expect(report).toContain(failure.message);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getDefaultEscalationConfig", () => {
|
|
it("should return default escalation configuration", () => {
|
|
const config = service.getDefaultEscalationConfig();
|
|
|
|
expect(config.rules).toBeDefined();
|
|
expect(config.rules.length).toBeGreaterThan(0);
|
|
expect(config.notifyOnRejection).toBeDefined();
|
|
expect(config.autoReassign).toBeDefined();
|
|
expect(config.maxWaitTime).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should include all escalation conditions in default config", () => {
|
|
const config = service.getDefaultEscalationConfig();
|
|
|
|
const conditions = config.rules.map((r) => r.condition);
|
|
expect(conditions).toContain("max-attempts");
|
|
expect(conditions).toContain("critical-failure");
|
|
expect(conditions).toContain("time-exceeded");
|
|
});
|
|
});
|
|
});
|