feat(#139): build Gate Rejection Response Handler
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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>
This commit is contained in:
408
apps/api/src/rejection-handler/rejection-handler.service.ts
Normal file
408
apps/api/src/rejection-handler/rejection-handler.service.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type {
|
||||
RejectionContext,
|
||||
RejectionResult,
|
||||
EscalationConfig,
|
||||
EscalationRule,
|
||||
FailureSummary,
|
||||
} from "./interfaces";
|
||||
|
||||
@Injectable()
|
||||
export class RejectionHandlerService {
|
||||
private readonly logger = new Logger(RejectionHandlerService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Handle a rejected task
|
||||
*/
|
||||
async handleRejection(context: RejectionContext): Promise<RejectionResult> {
|
||||
this.logger.warn(
|
||||
`Handling rejection for task ${context.taskId} after ${String(context.attemptCount)} attempts`
|
||||
);
|
||||
|
||||
// Log rejection to database
|
||||
await this.logRejection(context);
|
||||
|
||||
// Get escalation config
|
||||
const config = this.getDefaultEscalationConfig();
|
||||
|
||||
// Determine escalation actions
|
||||
const escalationRules = this.determineEscalation(context, config);
|
||||
|
||||
// Execute escalation
|
||||
const notificationsSent: string[] = [];
|
||||
if (escalationRules.length > 0) {
|
||||
await this.executeEscalation(context, escalationRules);
|
||||
// Collect notification targets
|
||||
escalationRules.forEach((rule) => {
|
||||
if (rule.action === "notify" && rule.target) {
|
||||
notificationsSent.push(rule.target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Determine task state based on escalation
|
||||
const taskState = this.determineTaskState(escalationRules);
|
||||
|
||||
// Check if manual review is required
|
||||
const manualReviewRequired =
|
||||
context.attemptCount >= 3 ||
|
||||
escalationRules.some((r) => r.action === "block" || r.priority === "critical");
|
||||
|
||||
if (manualReviewRequired) {
|
||||
await this.markForManualReview(
|
||||
context.taskId,
|
||||
`Max attempts (${String(context.attemptCount)}) exceeded or critical failure detected`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
escalated: escalationRules.length > 0,
|
||||
notificationsSent,
|
||||
taskState,
|
||||
manualReviewRequired,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log rejection to database
|
||||
*/
|
||||
async logRejection(context: RejectionContext): Promise<void> {
|
||||
await this.prisma.taskRejection.create({
|
||||
data: {
|
||||
taskId: context.taskId,
|
||||
workspaceId: context.workspaceId,
|
||||
agentId: context.agentId,
|
||||
attemptCount: context.attemptCount,
|
||||
failures: context.failures as unknown as Prisma.InputJsonValue,
|
||||
originalTask: context.originalTask,
|
||||
startedAt: context.startedAt,
|
||||
rejectedAt: context.rejectedAt,
|
||||
escalated: false,
|
||||
manualReview: false,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Logged rejection for task ${context.taskId} to database`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine escalation actions
|
||||
*/
|
||||
determineEscalation(context: RejectionContext, config: EscalationConfig): EscalationRule[] {
|
||||
const applicableRules: EscalationRule[] = [];
|
||||
|
||||
// Check each rule condition
|
||||
for (const rule of config.rules) {
|
||||
if (this.checkRuleCondition(context, rule, config)) {
|
||||
applicableRules.push(rule);
|
||||
}
|
||||
}
|
||||
|
||||
return applicableRules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a rule condition is met
|
||||
*/
|
||||
private checkRuleCondition(
|
||||
context: RejectionContext,
|
||||
rule: EscalationRule,
|
||||
config: EscalationConfig
|
||||
): boolean {
|
||||
switch (rule.condition) {
|
||||
case "max-attempts":
|
||||
return context.attemptCount >= 3;
|
||||
|
||||
case "time-exceeded": {
|
||||
const durationMinutes =
|
||||
(context.rejectedAt.getTime() - context.startedAt.getTime()) / (1000 * 60);
|
||||
return durationMinutes > config.maxWaitTime;
|
||||
}
|
||||
|
||||
case "critical-failure":
|
||||
return context.failures.some(
|
||||
(f) =>
|
||||
f.failureType.includes("critical") ||
|
||||
f.failureType.includes("security") ||
|
||||
f.failureType.includes("vulnerability")
|
||||
);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute escalation rules
|
||||
*/
|
||||
async executeEscalation(context: RejectionContext, rules: EscalationRule[]): Promise<void> {
|
||||
for (const rule of rules) {
|
||||
this.logger.warn(
|
||||
`Executing escalation: ${rule.action} for ${rule.condition} (priority: ${rule.priority})`
|
||||
);
|
||||
|
||||
switch (rule.action) {
|
||||
case "notify":
|
||||
if (rule.target) {
|
||||
this.sendNotification(context, rule.target, rule.priority);
|
||||
}
|
||||
break;
|
||||
|
||||
case "block":
|
||||
await this.markForManualReview(context.taskId, `Task blocked due to ${rule.condition}`);
|
||||
break;
|
||||
|
||||
case "reassign":
|
||||
this.logger.warn(`Task ${context.taskId} marked for reassignment`);
|
||||
// Future: implement reassignment logic
|
||||
break;
|
||||
|
||||
case "cancel":
|
||||
this.logger.warn(`Task ${context.taskId} marked for cancellation`);
|
||||
// Future: implement cancellation logic
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send rejection notification
|
||||
*/
|
||||
sendNotification(context: RejectionContext, target: string, priority: string): void {
|
||||
const report = this.generateRejectionReport(context);
|
||||
|
||||
this.logger.warn(
|
||||
`[${priority.toUpperCase()}] Sending rejection notification to ${target} for task ${context.taskId}`
|
||||
);
|
||||
this.logger.debug(`Notification content:\n${report}`);
|
||||
|
||||
// Future: integrate with notification service (email, Slack, etc.)
|
||||
// For now, just log the notification
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark task as requiring manual review
|
||||
*/
|
||||
async markForManualReview(taskId: string, reason: string): Promise<void> {
|
||||
// Update the most recent rejection record for this task
|
||||
const rejections = await this.prisma.taskRejection.findMany({
|
||||
where: { taskId },
|
||||
orderBy: { rejectedAt: "desc" },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (rejections.length > 0 && rejections[0]) {
|
||||
await this.prisma.taskRejection.update({
|
||||
where: { id: rejections[0].id },
|
||||
data: {
|
||||
manualReview: true,
|
||||
escalated: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.warn(`Task ${taskId} marked for manual review: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rejection history for a task
|
||||
*/
|
||||
async getRejectionHistory(taskId: string): Promise<RejectionContext[]> {
|
||||
const rejections = await this.prisma.taskRejection.findMany({
|
||||
where: { taskId },
|
||||
orderBy: { rejectedAt: "desc" },
|
||||
});
|
||||
|
||||
return rejections.map((r) => ({
|
||||
taskId: r.taskId,
|
||||
workspaceId: r.workspaceId,
|
||||
agentId: r.agentId,
|
||||
attemptCount: r.attemptCount,
|
||||
failures: r.failures as unknown as FailureSummary[],
|
||||
originalTask: r.originalTask,
|
||||
startedAt: r.startedAt,
|
||||
rejectedAt: r.rejectedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate rejection report
|
||||
*/
|
||||
generateRejectionReport(context: RejectionContext): string {
|
||||
const duration = this.formatDuration(context.startedAt, context.rejectedAt);
|
||||
|
||||
const failureList = context.failures
|
||||
.map((f) => `- **${f.gateName}**: ${f.message} (${String(f.attempts)} attempts)`)
|
||||
.join("\n");
|
||||
|
||||
const recommendations = this.generateRecommendations(context.failures);
|
||||
|
||||
return `
|
||||
## Task Rejection Report
|
||||
|
||||
**Task ID:** ${context.taskId}
|
||||
**Workspace:** ${context.workspaceId}
|
||||
**Agent:** ${context.agentId}
|
||||
**Attempts:** ${String(context.attemptCount)}
|
||||
**Duration:** ${duration}
|
||||
**Started:** ${context.startedAt.toISOString()}
|
||||
**Rejected:** ${context.rejectedAt.toISOString()}
|
||||
|
||||
### Original Task
|
||||
${context.originalTask}
|
||||
|
||||
### Failures
|
||||
${failureList}
|
||||
|
||||
### Required Actions
|
||||
- Manual code review required
|
||||
- Fix the following issues before reassigning
|
||||
- Review agent output and error logs
|
||||
|
||||
### Recommendations
|
||||
${recommendations}
|
||||
|
||||
---
|
||||
*This report was generated automatically by the Quality Rails rejection handler.*
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default escalation config
|
||||
*/
|
||||
getDefaultEscalationConfig(): EscalationConfig {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
condition: "max-attempts",
|
||||
action: "notify",
|
||||
target: "admin@mosaicstack.dev",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
condition: "max-attempts",
|
||||
action: "block",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
condition: "critical-failure",
|
||||
action: "notify",
|
||||
target: "security@mosaicstack.dev",
|
||||
priority: "critical",
|
||||
},
|
||||
{
|
||||
condition: "critical-failure",
|
||||
action: "block",
|
||||
priority: "critical",
|
||||
},
|
||||
{
|
||||
condition: "time-exceeded",
|
||||
action: "notify",
|
||||
target: "admin@mosaicstack.dev",
|
||||
priority: "medium",
|
||||
},
|
||||
],
|
||||
notifyOnRejection: true,
|
||||
autoReassign: false,
|
||||
maxWaitTime: 120, // 2 hours
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine task state based on escalation rules
|
||||
*/
|
||||
private determineTaskState(rules: EscalationRule[]): "blocked" | "reassigned" | "cancelled" {
|
||||
// Check for explicit state changes
|
||||
if (rules.some((r) => r.action === "cancel")) {
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
if (rules.some((r) => r.action === "reassign")) {
|
||||
return "reassigned";
|
||||
}
|
||||
|
||||
if (rules.some((r) => r.action === "block")) {
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
// Default to blocked if any escalation occurred
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration between two dates
|
||||
*/
|
||||
private formatDuration(start: Date, end: Date): string {
|
||||
const durationMs = end.getTime() - start.getTime();
|
||||
const hours = Math.floor(durationMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 0) {
|
||||
return `${String(hours)}h ${String(minutes)}m`;
|
||||
}
|
||||
return `${String(minutes)}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate recommendations based on failure types
|
||||
*/
|
||||
private generateRecommendations(failures: FailureSummary[]): string {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
failures.forEach((failure) => {
|
||||
switch (failure.gateName) {
|
||||
case "type-check":
|
||||
recommendations.push(
|
||||
"- Review TypeScript errors and ensure all types are properly defined"
|
||||
);
|
||||
recommendations.push(
|
||||
"- Check for missing type definitions or incorrect type annotations"
|
||||
);
|
||||
break;
|
||||
|
||||
case "test-gate":
|
||||
recommendations.push(
|
||||
"- Review failing tests and update implementation to meet test expectations"
|
||||
);
|
||||
recommendations.push("- Verify test mocks and fixtures are correctly configured");
|
||||
break;
|
||||
|
||||
case "lint-gate":
|
||||
recommendations.push("- Run ESLint and fix all reported issues");
|
||||
recommendations.push(
|
||||
"- Consider adding ESLint disable comments only for false positives"
|
||||
);
|
||||
break;
|
||||
|
||||
case "security-scan":
|
||||
recommendations.push(
|
||||
"- **CRITICAL**: Review and fix security vulnerabilities immediately"
|
||||
);
|
||||
recommendations.push("- Do not proceed until security issues are resolved");
|
||||
break;
|
||||
|
||||
case "coverage-gate":
|
||||
recommendations.push(
|
||||
"- Add additional tests to increase coverage above minimum threshold"
|
||||
);
|
||||
recommendations.push("- Focus on untested edge cases and error paths");
|
||||
break;
|
||||
|
||||
default:
|
||||
recommendations.push(`- Review ${failure.gateName} failures and address root causes`);
|
||||
}
|
||||
});
|
||||
|
||||
// Deduplicate recommendations
|
||||
const uniqueRecommendations = [...new Set(recommendations)];
|
||||
|
||||
return uniqueRecommendations.length > 0
|
||||
? uniqueRecommendations.join("\n")
|
||||
: "- Review error logs and agent output for additional context";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user