diff --git a/apps/worker/src/temporal/workflows.ts b/apps/worker/src/temporal/workflows.ts index 1cf7676e..eb98b119 100644 --- a/apps/worker/src/temporal/workflows.ts +++ b/apps/worker/src/temporal/workflows.ts @@ -49,6 +49,7 @@ import { type PartialReason, partialReasonFromReduction, projectPartialReasons, + reductionIsTolerable, renderSafeMessage, reportIsAuthored, } from '../types/run-state.js'; @@ -939,28 +940,35 @@ export async function pentestPipeline(input: PipelineInput): Promise !reductionIsTolerable(reduction), + ); state.agenticSast = { status: 'succeeded', findingCount: result.findingCount, sarifSha256: result.sarif.sha256, - coverage: result.coverage, + coverage: nonTolerableReductions.length > 0 ? 'reduced' : 'complete', warnings: [...result.warnings], durationMs: result.durationMs, ...(result.reductions !== undefined && { reductions: result.reductions }), ...(result.recoveredFailure !== undefined && { recoveredFailure: result.recoveredFailure }), }; completeOperation(CAPELLA_OPERATION_KEY, CAPELLA_OPERATION_LABEL, startedAt); - if (result.coverage === 'reduced') { - // One durable reason per reduction (research before export); each renders its own - // bounded safe message. A reduced run with no structured reduction keeps the bare code. - const reasons: PartialReason[] = - result.reductions === undefined || result.reductions.length === 0 - ? [{ code: 'agentic_sast_reduced' }] - : result.reductions.map(partialReasonFromReduction); - for (const reason of reasons) { - addPartialReason(reason); - addNonFatal({ phase: 'agentic-sast', error: projectPartialReasons([reason])[0]?.message ?? '' }); - } + // One durable reason per non-tolerable reduction (research before export); each renders + // its own bounded safe message. A child that reports reduced coverage without any + // structured reduction keeps the bare code, so an unclassified coverage loss is never + // silently accepted. + const coverageReducedWithoutDetail = result.coverage === 'reduced' && (result.reductions ?? []).length === 0; + const reasons = coverageReducedWithoutDetail + ? [{ code: 'agentic_sast_reduced' } satisfies PartialReason] + : nonTolerableReductions.map(partialReasonFromReduction); + for (const reason of reasons) { + addPartialReason(reason); + addNonFatal({ phase: 'agentic-sast', error: projectPartialReasons([reason])[0]?.message ?? '' }); } return result.sarif; } diff --git a/apps/worker/src/types/run-state.ts b/apps/worker/src/types/run-state.ts index 089921bb..05b3d5f2 100644 --- a/apps/worker/src/types/run-state.ts +++ b/apps/worker/src/types/run-state.ts @@ -655,6 +655,42 @@ function renderIncompleteResearchReduction( return `Agentic SAST reviewed ${String(consideredCount)} planned ${fileLabel} during research. ${triageClause}${salvagedClause} The scan continued with reduced static-analysis coverage.`; } +/** + * Whether a Capella reduction is tolerable — recorded as evidence but not cause for a partial + * run. A reduction is tolerable when all of its genuine coverage- or finding-loss counts are + * zero; salvage counts (work recovered after a turn/session limit) and rejection counts + * (duplicate or unexpected verdicts thrown out) are hygiene, never loss. Architecture and plan + * reductions only ever drop malformed model output, so they are always tolerable. A stage that + * failed outright (`failed_stage_fallback`) or an exported finding dropped whole + * (`malformed_findings`) is never tolerable. The switch is exhaustive so a new reduction reason + * fails the type-check until its loss counts are classified here. + */ +export function reductionIsTolerable(reduction: AgenticSastReduction): boolean { + switch (reduction.reason) { + case 'invalid_architecture_items': + case 'invalid_investigations': + return true; + case 'incomplete_research': + return reduction.triageOmittedCount === 0; + case 'incomplete_dedupe': + return reduction.unreadableCount === 0; + case 'incomplete_review': + return reduction.missingCount + reduction.unreadableCount + reduction.quarantinedCount === 0; + case 'incomplete_critic': + case 'incomplete_confirm': + case 'incomplete_calibrate': + return reduction.missingCount + reduction.unreadableCount === 0; + case 'failed_stage_fallback': + case 'malformed_findings': + return false; + default: { + const _exhaustive: never = reduction; + void _exhaustive; + return false; + } + } +} + /** Build the durable partial reason for one Capella reduction. Export keeps bounded omission detail. */ export function partialReasonFromReduction(reduction: AgenticSastReduction): PartialReason { const { reason, ...details } = reduction;