From e9cf782081cadadc5ec639a2771464d3f0a72f04 Mon Sep 17 00:00:00 2001 From: ajmallesh Date: Sun, 30 Aug 2026 18:51:23 -0700 Subject: [PATCH] fix: attribute a reconciliation failure to exploitation only - Stop marking a class's vulnerability-analysis agent failed when that agent succeeded and only reconciliation failed; the status tree now renders the analysis row completed and the exploitation row failed - Consume the worker's failedReconciliations signal in the CLI, which the mirrored PipelineState already declared but never read - Correct the class_reconciliation_failed message, which claimed the class's analysis results were still in the report when the class is excluded from it --- apps/cli/src/scan/derive.ts | 26 +++++++++++++++++++++----- apps/cli/src/scan/safe-fields.ts | 2 +- apps/worker/src/types/run-state.ts | 2 +- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/scan/derive.ts b/apps/cli/src/scan/derive.ts index 0c1007e5..eccb2968 100644 --- a/apps/cli/src/scan/derive.ts +++ b/apps/cli/src/scan/derive.ts @@ -66,8 +66,25 @@ export function isTerminal(status: string): boolean { return status !== 'RUNNING' && status !== 'UNSPECIFIED'; } +/** + * Whether the class-level failure recorded for this agent's class applies to this agent. + * + * A class failure is recorded against the class as a whole, so it matches both of that class's + * agents. A reconciliation failure, though, happens only after the analysis agent has already + * succeeded, so it belongs to the exploitation lane: attributing it to the analysis row as well + * would report an agent that completed as failed. + */ +function classFailureApplies(name: string, state: PipelineState | null): boolean { + if (!state) return false; + const vulnClass = agentClass(name); + if (!state.failedPipelines.some((f) => f.vulnType === vulnClass)) return false; + const reconciliationFailed = (state.failedReconciliations ?? []).some((r) => r.vulnerabilityClass === vulnClass); + const isAnalysisAgent = name.endsWith('-vuln'); + return !(reconciliationFailed && isAnalysisAgent); +} + function isFailedAgent(name: string, state: PipelineState | null): boolean { - return !!state && (state.failedAgent === name || state.failedPipelines.some((f) => f.vulnType === agentClass(name))); + return !!state && (state.failedAgent === name || classFailureApplies(name, state)); } /** An agent has entered play once it is running, has metrics, or has failed. */ @@ -91,14 +108,13 @@ function agentState(name: string, state: PipelineState | null, running: Set): string | undefined { - const failed = state?.failedPipelines.find((f) => f.vulnType === agentClass(name)); const hasFailure = - failed !== undefined || byAgent.get(name)?.lastFailure !== undefined || state?.failedAgent === name; + classFailureApplies(name, state) || byAgent.get(name)?.lastFailure !== undefined || state?.failedAgent === name; return safeFailureDetail(hasFailure); } diff --git a/apps/cli/src/scan/safe-fields.ts b/apps/cli/src/scan/safe-fields.ts index 57b3111a..85081804 100644 --- a/apps/cli/src/scan/safe-fields.ts +++ b/apps/cli/src/scan/safe-fields.ts @@ -141,7 +141,7 @@ function reasonMessage(reason: PartialReasonView): string | undefined { case 'class_reconciliation_failed': return className === undefined ? undefined - : `${className} findings could not be grouped into test cases, so that class was not exploited. Its analysis results are still in the report.`; + : `${className} findings could not be grouped into test cases, so that class was not exploited and its findings are not in the report.`; case 'report_renumber_failed': return className === undefined ? undefined diff --git a/apps/worker/src/types/run-state.ts b/apps/worker/src/types/run-state.ts index 05b3d5f2..939a0c16 100644 --- a/apps/worker/src/types/run-state.ts +++ b/apps/worker/src/types/run-state.ts @@ -228,7 +228,7 @@ export const PARTIAL_REASON_SAFE_MESSAGES: Readonly