Compare commits

..
Author SHA1 Message Date
Brent Vatne e4bbd6aa65 Read the aggregate review state even when the cache is off
In "single" comment mode one aggregate comment holds every scope, and its state
was read only when the result cache was live. But cacheAllowed excludes any run
with feedback or stack enabled, so the prior-review block silently vanished for
exactly the repos whose dismissals and replies make it worth having.

It is now read whenever the mode calls for it. Cache reuse stays gated separately
on cacheReadRoot; this only decouples the read from it.
2026-08-08 15:51:49 -07:00
Brent Vatne 451f3fde29 Drop a stale verdict before it can mark a finding answered
Third issue in the same family, again found by the reviewer on #73: the
prior-review status was computed from a record that had not been through
dropStaleVerdict, which both existing merge paths apply first.

A verdict is a claim about SOURCE, and fingerprintFinding deliberately excludes
the line number, so a finding keeps its identity while the code a rebuttal relied
on is edited away. Without the staleness rule, an accepted rebuttal from an
earlier revision keeps marking the finding "answered" — telling a later reviewer a
human had settled something that was judged against code no longer under review.

Both call sites now chain dropStaleVerdict against the reviewed head before
feedbackApplied, matching mergeFeedback and the aggregate merge. Only reachable
under dismiss: "adjudicated", the one path that consults a verdict at all.
2026-08-08 15:51:49 -07:00
Brent Vatne c320c7bc94 Gate "answered" behind feedbackApplied, and wire the routed CI path
Two problems the reviewer caught on #73.

The answered set was built from raw feedback records, so ANY reply marked a
finding as handled — including a third-party commenter's, a quote without the
`id:` token, and a reply to a critical/secrets/security finding. LLP 0011 floors
those in code precisely so no reply can clear them, and a block telling the
reviewer "a human replied to this" hands it a reason to drop the finding —
reinstating through the prompt the suppression the floors refuse. It now uses
feedbackApplied, the same predicate the reporter uses, injected rather than
imported so the two cannot drift into disagreeing about what "answered" means.

The routed CI path never passed priorReview, silently disabling the feature for
any repo with routing.jsonc. It now resolves per scope, following the same rule
feedbackSeam already uses for which comment holds a scope's history and which
fingerprint its records are keyed under: the scope's own comment, or in "single"
mode the aggregate's scopes entry paired with the root's dismissal records under
the scope-namespaced fingerprint. The scoped read is reused by the cache check
rather than fetched twice.
2026-08-08 15:51:49 -07:00
Brent Vatne ab97d0c9d8 Tell a re-review what the last review already said
A re-review started from nothing. Every pass ran stateless, so the reviewer could
not tell a second push from a first look, and a finding a maintainer had already
dismissed came back on the next run in slightly different words — fingerprint
suppression catches a near-identical repeat, not a reworded re-raise.

The state was already in hand. ReviewState round-trips the whole previous
CoordinatorOutput — every finding with file, line, severity and category — plus
dismissed, feedback and pins, through the reviewer's own PR comment on every run.
It was decoded each run and consulted only afterward, for suppression and reply
matching, and never shown to the model. summarizePriorReview reduces it to a
bounded list and priorReviewSection renders one fenced block for the reviewer and
cross-file passes. No new storage, no new fetch: ci.ts now reads that state once
and reuses it for both the cache check and the prompt.

Carrying status is what earns the block. A maintainer's dismissal and an author's
reply both happen AFTER a run ends, so no engine session or replayed transcript
could ever contain them — this channel is the only one that can. A pin outranks
both, because /undismiss is the human's last word that a finding still stands.

Treated as untrusted, because it is: the titles and paths are model output
produced by reading an untrusted PR, which is the same reason stripStateMarkers
exists. Every entry is flattened onto one line and the assembled block is swept
for a forged BEGIN/END PREVIOUS REVIEW fence, exactly as context-file text is.

Two deliberate boundaries. The coordinator never receives it: it merges and
decides, and handing it the previous decision is how a decision drifts by
inheritance rather than by evidence. And the wording frames prior findings as
claims to re-check rather than conclusions to carry forward, and says outright
that absence from the list means nothing — a reviewer that restates last run's
list without re-deriving it has stopped reviewing, and recall is the product.

Deliberately NOT in the review-cache key, against that file's own "every input
joins the hash" rule. The block derives from the previous result, so including it
would change the hash the moment a first review exists and miss on every
re-review, disabling the cache exactly where it pays. A hit already means the
diff, files, config and metadata are byte-identical, so reusing that run's
conclusions is what the block would have told the model to do anyway.
2026-08-08 15:51:49 -07:00
3 changed files with 15 additions and 72 deletions

No files matched your search

+1 -28
View File
@@ -4,12 +4,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import type { LoadedConfig } from "../config/schema.js";
import {
reviewCacheAllowed,
reviewCanBeReused,
reviewInputHash,
reviewMatchesInput,
} from "../core/review-cache.js";
import { reviewCanBeReused, reviewInputHash, reviewMatchesInput } from "../core/review-cache.js";
import type { CoordinatorOutput, DiffEntry } from "../core/schema.js";
const config = (over: Partial<LoadedConfig> = {}): LoadedConfig => ({
@@ -176,25 +171,3 @@ test("file hashing refuses traversal and never follows a PR-controlled symlink",
await rm(outside, { force: true });
}
});
test("reviewCacheAllowed is the one policy both CI paths share", () => {
const base = {
bypassTriggerGate: false,
stack: false,
feedback: false,
hasMetadata: true,
};
expect(reviewCacheAllowed(base)).toBe(true);
// Each flag independently means "this run has an input the key can't represent".
expect(reviewCacheAllowed({ ...base, bypassTriggerGate: true })).toBe(false);
expect(reviewCacheAllowed({ ...base, stack: true })).toBe(false);
expect(reviewCacheAllowed({ ...base, feedback: true })).toBe(false);
expect(reviewCacheAllowed({ ...base, hasMetadata: false })).toBe(false);
// Research is deliberately NOT a gate. It was one only because the offline index
// was an artifact the key could not represent; with the index gone, a researched
// review fetches everything live during the run. The routed path kept this gate
// after the legacy path dropped it, which is the drift this function prevents.
expect(reviewCacheAllowed(base)).toBe(true);
});
+14 -18
View File
@@ -30,12 +30,7 @@ import { summarizePriorReview } from "../core/prior-review.js";
import { dropStaleVerdict, feedbackApplied, feedbackNeedsRunSeam } from "../core/adjudicate.js";
import { runReview } from "../core/review.js";
import type { ReviewRunOptions, ReviewRunResult } from "../core/review.js";
import {
reviewCacheAllowed,
reviewCanBeReused,
reviewInputHash,
reviewMatchesInput,
} from "../core/review-cache.js";
import { reviewCanBeReused, reviewInputHash, reviewMatchesInput } from "../core/review-cache.js";
import { GitHubPRSource } from "../sources/github-pr.js";
import { memoizeSource, stackConfirmFromConfig, stackWalkFromConfig } from "../sources/source.js";
import type { PreparedReadRoot, ReviewSource, StackWalkOptions } from "../sources/source.js";
@@ -581,12 +576,13 @@ async function runLegacyCi(
const stack = resolveStackWalk(config.stack, noStackAware);
const stackConfirm = resolveStackConfirm(config.stack, noStackAware);
const feedback = adjudicationSeam(config, reporter);
const cacheAllowed = reviewCacheAllowed({
bypassTriggerGate,
stack: Boolean(stack),
feedback: Boolean(feedback),
hasMetadata: metadata !== undefined,
});
// Dynamic stack context and model-backed reply adjudication have inputs outside
// the scoped diff. Keep those paths fresh until their inputs join the cache key.
// A maintainer's explicit /review is also always a real rerun.
// Research no longer forces a fresh run: with the local index gone, every result
// is fetched live from an allowlisted host during the run, so there is no mounted
// artifact whose contents could drift out from under the cache key.
const cacheAllowed = !bypassTriggerGate && !stack && !feedback && metadata !== undefined;
let inputHash: string | undefined;
// The previous review's embedded comment state, read ONCE: the cache check below
@@ -909,12 +905,12 @@ async function runRoutedCi(
reporterFor(scopedCommentTag(rootTag, name), true),
);
const cacheAllowed = reviewCacheAllowed({
bypassTriggerGate,
stack: Boolean(stackWalk),
feedback: feedbackNeedsRunSeam(rootConfig.feedback),
hasMetadata: metadata !== undefined,
});
const cacheAllowed =
!bypassTriggerGate &&
!stackWalk &&
!feedbackNeedsRunSeam(rootConfig.feedback) &&
!rootConfig.research.enabled &&
metadata !== undefined;
let cacheReadRoot: string | undefined;
if (cacheAllowed) {
try {
-26
View File
@@ -137,32 +137,6 @@ export async function reviewInputHash(options: ReviewInputHashOptions): Promise<
return createHash("sha256").update(canonicalJson(input)).digest("hex");
}
/**
* Whether a run may reuse a cached result at all — the single definition of that
* policy, shared by the legacy and routed CI paths.
*
* It lives here because it was previously written out twice, once per path, and
* the copies drifted: when the offline index was removed, only the legacy copy
* dropped its research gate, so every routed repo silently kept running fresh
* reviews for a reason that no longer existed. Two expressions of one policy is
* the bug; one function that both paths call is the fix.
*
* Each flag means "this run has an input the cache key does not represent":
* dynamic stack context and model-backed reply adjudication both reach outside
* the scoped diff, and a maintainer's explicit /review is always a real rerun.
*/
export function reviewCacheAllowed(run: {
bypassTriggerGate: boolean;
/** Stack walking is on for this run. */
stack: boolean;
/** A feedback seam runs this pass (adjudication), not merely annotation. */
feedback: boolean;
/** PR metadata resolved; without it there is nothing stable to key on. */
hasMetadata: boolean;
}): boolean {
return !run.bypassTriggerGate && !run.stack && !run.feedback && run.hasMetadata;
}
/** Partial/failed reviews must be retried, never made durable by a cache hit. */
export function reviewCanBeReused(review: CoordinatorOutput): boolean {
return review.couldNotComplete !== true && review.incomplete.length === 0;