diff --git a/apps/backend/runners/github/services/parallel_orchestrator_reviewer.py b/apps/backend/runners/github/services/parallel_orchestrator_reviewer.py index d17beea337..ce73464a27 100644 --- a/apps/backend/runners/github/services/parallel_orchestrator_reviewer.py +++ b/apps/backend/runners/github/services/parallel_orchestrator_reviewer.py @@ -1289,12 +1289,30 @@ async def review(self, context: PRContext) -> PRReviewResult: f"{len(filtered_findings)} filtered" ) - # No confidence routing - validation is binary via finding-validator - unique_findings = validated_findings - logger.info(f"[PRReview] Final findings: {len(unique_findings)} validated") + # Separate active findings (drive verdict) from dismissed (shown in UI only) + active_findings = [] + dismissed_findings = [] + for f in validated_findings: + if f.validation_status == "dismissed_false_positive": + dismissed_findings.append(f) + else: + active_findings.append(f) + safe_print( + f"[ParallelOrchestrator] Final: {len(active_findings)} active, " + f"{len(dismissed_findings)} disputed by validator", + flush=True, + ) + logger.info( + f"[PRReview] Final findings: {len(active_findings)} active, " + f"{len(dismissed_findings)} disputed" + ) + + # All findings (active + dismissed) go in the result for UI display + all_review_findings = validated_findings logger.info( - f"[ParallelOrchestrator] Review complete: {len(unique_findings)} findings" + f"[ParallelOrchestrator] Review complete: {len(all_review_findings)} findings " + f"({len(active_findings)} active, {len(dismissed_findings)} disputed)" ) # Fetch CI status for verdict consideration @@ -1304,9 +1322,9 @@ async def review(self, context: PRContext) -> PRReviewResult: f"{ci_status.get('failing', 0)} failing, {ci_status.get('pending', 0)} pending" ) - # Generate verdict (includes merge conflict check, branch-behind check, and CI status) + # Generate verdict from ACTIVE findings only (dismissed don't affect verdict) verdict, verdict_reasoning, blockers = self._generate_verdict( - unique_findings, + active_findings, has_merge_conflicts=context.has_merge_conflicts, merge_state_status=context.merge_state_status, ci_status=ci_status, @@ -1317,7 +1335,7 @@ async def review(self, context: PRContext) -> PRReviewResult: verdict=verdict, verdict_reasoning=verdict_reasoning, blockers=blockers, - findings=unique_findings, + findings=all_review_findings, agents_invoked=agents_invoked, ) @@ -1362,7 +1380,7 @@ async def review(self, context: PRContext) -> PRReviewResult: pr_number=context.pr_number, repo=self.config.repo, success=True, - findings=unique_findings, + findings=all_review_findings, summary=summary, overall_status=overall_status, verdict=verdict, @@ -1937,12 +1955,38 @@ async def _validate_findings( validated_findings.append(finding) elif validation.validation_status == "dismissed_false_positive": - # Dismiss - do not include - dismissed_count += 1 - logger.info( - f"[PRReview] Dismissed {finding.id} as false positive: " - f"{validation.explanation[:100]}" - ) + # Protect cross-validated findings from dismissal — + # if multiple specialists independently found the same issue, + # a single validator should not override that consensus + if finding.cross_validated: + finding.validation_status = "confirmed_valid" + finding.validation_evidence = validation.code_evidence + finding.validation_explanation = ( + f"[Auto-kept: cross-validated by {len(finding.source_agents)} agents] " + f"{validation.explanation}" + ) + validated_findings.append(finding) + safe_print( + f"[FindingValidator] Kept cross-validated finding '{finding.title}' " + f"despite dismissal (agents={finding.source_agents})", + flush=True, + ) + else: + # Keep finding but mark as dismissed (user can see it in UI) + finding.validation_status = "dismissed_false_positive" + finding.validation_evidence = validation.code_evidence + finding.validation_explanation = validation.explanation + validated_findings.append(finding) + dismissed_count += 1 + safe_print( + f"[FindingValidator] Disputed '{finding.title}': " + f"{validation.explanation} (file={finding.file}:{finding.line})", + flush=True, + ) + logger.info( + f"[PRReview] Disputed {finding.id}: " + f"{validation.explanation[:200]}" + ) elif validation.validation_status == "needs_human_review": # Keep but flag @@ -2127,11 +2171,16 @@ def _generate_summary( sev = f.severity.value emoji = severity_emoji.get(sev, "⚪") + is_disputed = f.validation_status == "dismissed_false_positive" + # Finding header with location line_range = f"L{f.line}" if f.end_line and f.end_line != f.line: line_range = f"L{f.line}-L{f.end_line}" - lines.append(f"#### {emoji} [{sev.upper()}] {f.title}") + if is_disputed: + lines.append(f"#### ⚪ [DISPUTED] ~~{f.title}~~") + else: + lines.append(f"#### {emoji} [{sev.upper()}] {f.title}") lines.append(f"**File:** `{f.file}` ({line_range})") # Cross-validation badge @@ -2161,6 +2210,7 @@ def _generate_summary( status_label = { "confirmed_valid": "Confirmed", "needs_human_review": "Needs human review", + "dismissed_false_positive": "Disputed by validator", }.get(f.validation_status, f.validation_status) lines.append("") lines.append(f"**Validation:** {status_label}") @@ -2182,18 +2232,27 @@ def _generate_summary( lines.append("") - # Findings count summary + # Findings count summary (exclude dismissed from active count) + active_count = 0 + dismissed_count = 0 by_severity: dict[str, int] = {} for f in findings: + if f.validation_status == "dismissed_false_positive": + dismissed_count += 1 + continue + active_count += 1 sev = f.severity.value by_severity[sev] = by_severity.get(sev, 0) + 1 summary_parts = [] for sev in ["critical", "high", "medium", "low"]: if sev in by_severity: summary_parts.append(f"{by_severity[sev]} {sev}") - lines.append( - f"**Total:** {len(findings)} finding(s) ({', '.join(summary_parts)})" + count_text = ( + f"**Total:** {active_count} finding(s) ({', '.join(summary_parts)})" ) + if dismissed_count > 0: + count_text += f" + {dismissed_count} disputed" + lines.append(count_text) lines.append("") lines.append("---") diff --git a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts index 61200666dc..1f63b6c4ab 100644 --- a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts @@ -268,6 +268,10 @@ export interface PRReviewFinding { endLine?: number; suggestedFix?: string; fixable: boolean; + validationStatus?: "confirmed_valid" | "dismissed_false_positive" | "needs_human_review" | null; + validationExplanation?: string; + sourceAgents?: string[]; + crossValidated?: boolean; } /** @@ -1341,6 +1345,10 @@ function getReviewResult(project: Project, prNumber: number): PRReviewResult | n endLine: f.end_line, suggestedFix: f.suggested_fix, fixable: f.fixable ?? false, + validationStatus: f.validation_status ?? null, + validationExplanation: f.validation_explanation ?? undefined, + sourceAgents: f.source_agents ?? [], + crossValidated: f.cross_validated ?? false, })) ?? [], summary: data.summary ?? "", overallStatus: data.overall_status ?? "comment", diff --git a/apps/frontend/src/preload/api/modules/github-api.ts b/apps/frontend/src/preload/api/modules/github-api.ts index c27da235b3..bca2a8f129 100644 --- a/apps/frontend/src/preload/api/modules/github-api.ts +++ b/apps/frontend/src/preload/api/modules/github-api.ts @@ -376,6 +376,10 @@ export interface PRReviewFinding { endLine?: number; suggestedFix?: string; fixable: boolean; + validationStatus?: 'confirmed_valid' | 'dismissed_false_positive' | 'needs_human_review' | null; + validationExplanation?: string; + sourceAgents?: string[]; + crossValidated?: boolean; } /** diff --git a/apps/frontend/src/renderer/components/github-prs/components/FindingItem.tsx b/apps/frontend/src/renderer/components/github-prs/components/FindingItem.tsx index f3be2e4243..47dcde99c5 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/FindingItem.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/FindingItem.tsx @@ -14,6 +14,7 @@ interface FindingItemProps { finding: PRReviewFinding; selected: boolean; posted?: boolean; + disputed?: boolean; onToggle: () => void; } @@ -33,7 +34,7 @@ function getCategoryTranslationKey(category: string): string { return categoryMap[category.toLowerCase()] || category; } -export function FindingItem({ finding, selected, posted = false, onToggle }: FindingItemProps) { +export function FindingItem({ finding, selected, posted = false, disputed = false, onToggle }: FindingItemProps) { const { t } = useTranslation('common'); const CategoryIcon = getCategoryIcon(finding.category); @@ -45,8 +46,9 @@ export function FindingItem({ finding, selected, posted = false, onToggle }: Fin
{finding.description}
+ {disputed && finding.validationExplanation && ( ++ {finding.validationExplanation} +
+ )}
{finding.file}:{finding.line}
diff --git a/apps/frontend/src/renderer/components/github-prs/components/FindingsSummary.tsx b/apps/frontend/src/renderer/components/github-prs/components/FindingsSummary.tsx
index 2259b35aad..eb28a0e06e 100644
--- a/apps/frontend/src/renderer/components/github-prs/components/FindingsSummary.tsx
+++ b/apps/frontend/src/renderer/components/github-prs/components/FindingsSummary.tsx
@@ -9,9 +9,10 @@ import type { PRReviewFinding } from '../hooks/useGitHubPRs';
interface FindingsSummaryProps {
findings: PRReviewFinding[];
selectedCount: number;
+ disputedCount?: number;
}
-export function FindingsSummary({ findings, selectedCount }: FindingsSummaryProps) {
+export function FindingsSummary({ findings, selectedCount, disputedCount = 0 }: FindingsSummaryProps) {
const { t } = useTranslation('common');
// Count findings by severity
@@ -46,6 +47,11 @@ export function FindingsSummary({ findings, selectedCount }: FindingsSummaryProp
{counts.low} {t('prReview.severity.low')}
)}
+ {disputedCount > 0 && (
+
+ {disputedCount} {t('prReview.disputed')}
+
+ )}
+ {t('prReview.disputedSectionHint')} +
+ {disputedFindings.map((finding) => ( +