Skip to content

Commit 880fad6

Browse files
committed
fix(code-review): key signal cache on the diff
- ISS-8961: the signals/ namespace key was (diff_tip, taxonomy_hash, prompt_hash), and diff_tip is a ref name -- literally "HEAD" for a local branch review -- so no component varied with the diff. The cache dir is ~/.claude/cr-cache-global-repo-<worktree basename>, so two reviews from one pooled directory shared directory and key, and the second was served the first's extracted signals. - Add signal_input_hash: SHA-256 over the canonical serialization of the agent input bundle extract-signals-prepare builds, which is exactly what the extraction agent reads. Key is now (diff_tip, input_hash, taxonomy_hash, prompt_hash). - Build the bundle before computing the key rather than only on a miss. - A bundle with no changed files has no diff identity: emit an empty cache_key plus cache_bypass_reason "no-changed-files", which suppresses the read and (via consolidate's empty-key guard) the write. - Correct the --diff-tip help, which claimed the flag was a SHA. Testing: pytest plugins/ (3 pre-existing failures unrelated to this change: TestOverrideCache/TestPR114ReviewFixes fail on clean main because their override fixture is dated 2026-05-29 and the overrides TTL is 90 days). Counterfactual: reverting code_review_helpers.py turns TestPooledWorktreeSignalCacheIsolation red with lane 2's manifest reading status "cache_hit". ruff check; pyright. Risks: cached extractions written under the old key scheme are misses after this lands, so the first review in each cache directory re-runs one Haiku extraction.
1 parent 1370b6a commit 880fad6

7 files changed

Lines changed: 322 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to the claude-plugins project will be documented in this fil
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`.
66

7+
### code-review v3.7.1
8+
9+
#### Fixed
10+
- **Signal extraction no longer serves one review's signals to a different review that shares a cache directory (ISS-8961).** The `signals/` namespace key was `(diff_tip, taxonomy_hash, prompt_hash)`, and `diff_tip` is a ref *name* — `resolve-scope` leaves it as the literal `"HEAD"` for every local branch review and sets it to `origin/<head_ref>` for a PR — so no component of the key varied with the diff. The cache directory is `~/.claude/cr-cache-global-repo-<basename of the git toplevel>`, which is the worktree directory name, so any two reviews run from the same directory shared both the directory and the key: the second review was served the first review's extracted signals and `extract_signals.json` described a diff that was never under review. The key now includes `signal_input_hash` — a SHA-256 over the canonical serialization of the agent input bundle `extract-signals-prepare` builds (file list, per-file added/removed excerpts, intent hint), which is exactly what the extraction agent reads — so a different diff produces a different key even at an identical path, branch, and tip. `extract-signals-prepare` builds that bundle before computing the key rather than only on a miss. When the bundle carries no changed files there is no diff identity to key on, so the run bypasses the cache in both directions: it emits an empty `cache_key` plus `cache_bypass_reason: "no-changed-files"` in the manifest, which suppresses the read and, through `extract-signals-consolidate`'s existing empty-key guard, the write. A miss re-runs a single Haiku extraction; a wrong hit silently misdescribes the change under review.
11+
712
### code v1.14.10
813

914
#### Added

plugins/code-review/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "code-review",
33
"description": "Code review plugin",
4-
"version": "3.7.0",
4+
"version": "3.7.1",
55
"author": {
66
"name": "ClosedLoop",
77
"email": "support@closedloop.ai"

plugins/code-review/SCHEMA.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,7 @@ A MAJOR `schema_version` bump invalidates every cache namespace at once.
585585
| Namespace | Path | Key inputs | TTL |
586586
| ------------------- | ------------------------------------------------- | ------------------------------------------------------------- | ------ |
587587
| BHA findings | `<CACHE_DIR>/bha/<file_hash>.json` | file_content_hash + prompt_hash + model_id + schema_version | 30 d |
588-
| Signal extraction | `<CACHE_DIR>/signals/<diff_tip>.json` | diff_tip + taxonomy_hash + signal_prompt_hash | 7 d |
588+
| Signal extraction | `<CACHE_DIR>/signals/<key>.json` | diff_tip + agent_input_hash + taxonomy_hash + signal_prompt_hash | 7 d |
589589
| Coverage critic | `<CACHE_DIR>/coverage_critic/<diff_tip>.json` | coverage_plan_initial_hash + signals_hash + critic_prompt_hash | 7 d |
590590
| Verification | `<CACHE_DIR>/verifications/<finding_id>.json` | finding_id + file_content_hash + verifier_model + verifier_prompt_hash | 30 d |
591591
| Overrides | `<CACHE_DIR>/overrides/<finding_id>.json` | finding_id (file content change invalidates) | 90 d |

plugins/code-review/tools/python/code_review_helpers.py

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6223,7 +6223,8 @@ def cmd_detect_injection(args: argparse.Namespace) -> int:
62236223
# ---------------------------------------------------------------------------
62246224
# Two-step LLM stage modelled on PLN-722's verifier:
62256225
# 1. ``extract-signals-prepare`` — read diff_data.json + intent + taxonomy,
6226-
# compute the cache key, check the cache. On hit: write the final
6226+
# build the agent input bundle, compute the cache key from that
6227+
# bundle's content hash, check the cache. On hit: write the final
62276228
# ``extract_signals.json`` immediately. On miss: write the agent input
62286229
# bundle (diff summary + taxonomy reference) and the manifest the
62296230
# orchestrator uses to spawn a single Haiku agent.
@@ -6240,6 +6241,10 @@ def cmd_detect_injection(args: argparse.Namespace) -> int:
62406241
SIGNAL_EXTRACTION_MARKER = "signal-extraction-failed"
62416242
SIGNAL_TAXONOMY_FILENAME = "signal_taxonomy.json"
62426243
SIGNAL_EXTRACTION_PROMPT_FILENAME = "signal_extraction_prompt.txt"
6244+
# Recorded in the manifest when the agent input carries no changed files:
6245+
# there is no diff identity to key on, so the run neither reads nor writes
6246+
# the signals cache (ISS-8961 — a miss is always cheaper than a wrong hit).
6247+
SIGNAL_CACHE_BYPASS_NO_FILES = "no-changed-files"
62436248

62446249
# Cap on per-file excerpt size injected into the agent input. The taxonomy
62456250
# is the agent's reference — the diff context is the evidence. We need
@@ -6331,21 +6336,47 @@ def _signal_extraction_prompt_hash(path: Path) -> str:
63316336
return hashlib.sha256(path.read_bytes()).hexdigest()
63326337

63336338

6339+
def signal_input_hash(agent_input: dict[str, Any]) -> str:
6340+
"""Content fingerprint of the exact bundle the extraction agent reads.
6341+
6342+
``_build_signal_input`` is what the agent consumes — file list, per-file
6343+
excerpts, and the intent hint — so hashing its canonical serialization
6344+
fingerprints every input the extraction is a function of. Sorted keys
6345+
make the serialization order-independent; the builder already emits
6346+
files and excerpts in a deterministic order.
6347+
6348+
ISS-8961: this is the component that makes the key diff-derived. The
6349+
ref-name component (``diff_tip``) is ``"HEAD"`` for every local branch
6350+
review, so before this hash existed the key was constant across every
6351+
review that shared a cache directory — and a pooled worktree makes the
6352+
cache directory shared by construction.
6353+
"""
6354+
canonical = json.dumps(
6355+
agent_input, sort_keys=True, separators=(",", ":"), ensure_ascii=True,
6356+
)
6357+
return hashlib.sha256(canonical.encode("utf-8", "replace")).hexdigest()
6358+
6359+
63346360
def signal_extraction_cache_key(
6335-
diff_tip: str, taxonomy_hash: str, prompt_hash: str,
6361+
diff_tip: str, input_hash: str, taxonomy_hash: str, prompt_hash: str,
63366362
) -> str:
63376363
"""Cache key for the ``signals`` namespace (PLN-725).
63386364

6339-
Tuple ``(diff_tip, taxonomy_hash, prompt_hash)`` is the complete set
6340-
of inputs the extraction is a pure function of. Both
6341-
``taxonomy_hash`` and ``prompt_hash`` are content-addressed hashes of
6342-
the on-disk asset bytes (``_taxonomy_hash`` and
6343-
``_signal_extraction_prompt_hash``), computed inside
6365+
Tuple ``(diff_tip, input_hash, taxonomy_hash, prompt_hash)`` is the
6366+
complete set of inputs the extraction is a pure function of.
6367+
``input_hash`` (``signal_input_hash``), ``taxonomy_hash`` and
6368+
``prompt_hash`` are content-addressed hashes of, respectively, the
6369+
agent input bundle and the on-disk asset bytes, all computed inside
63446370
``cmd_extract_signals_prepare`` rather than taken on faith from
6345-
caller-supplied flags. Editing either asset flips the key for real.
6371+
caller-supplied flags. Editing any of them flips the key for real.
6372+
6373+
``diff_tip`` is only a ref *name* (``"HEAD"``, ``origin/<branch>``),
6374+
never a commit id, so it discriminates nothing on its own — keep it as
6375+
a coarse extra component, never as the diff identity.
63466376
"""
63476377
payload = (
63486378
(diff_tip or "") + "\0"
6379+
+ (input_hash or "") + "\0"
63496380
+ (taxonomy_hash or "") + "\0"
63506381
+ (prompt_hash or "")
63516382
)
@@ -6535,8 +6566,10 @@ def fail_closed_signal_set(taxonomy: dict[str, Any]) -> list[dict[str, Any]]:
65356566
def cmd_extract_signals_prepare(args: argparse.Namespace) -> int:
65366567
"""PLN-725 Stage 1a: prep the signal-extraction agent input + check cache.
65376568

6538-
Reads ``diff_data.json`` and (optionally) an intent summary, computes
6539-
the ``(diff_tip, taxonomy_hash, prompt_hash)`` cache key, and either:
6569+
Reads ``diff_data.json`` and (optionally) an intent summary, builds the
6570+
agent input bundle, computes the
6571+
``(diff_tip, input_hash, taxonomy_hash, prompt_hash)`` cache key, and
6572+
either:
65406573

65416574
- **Cache hit** — writes the cached extraction directly to
65426575
``<cr_dir>/extract_signals.json`` and emits a manifest with
@@ -6548,6 +6581,13 @@ def cmd_extract_signals_prepare(args: argparse.Namespace) -> int:
65486581
``taxonomy_path``, ``prompt_path`` so the orchestrator can spawn
65496582
a single Haiku agent.
65506583

6584+
ISS-8961: the bundle is built *before* the key so the key is derived
6585+
from the diff the agent will actually read. A degenerate bundle (no
6586+
changed files) has no diff identity to key on, so the cache is bypassed
6587+
in both directions — miss now, no cache write later — rather than
6588+
keyed on the components that remain, which are identical across every
6589+
review sharing a cache directory.
6590+
65516591
Always exits 0; structural failures (no diff_data, malformed
65526592
taxonomy) print to stderr and return 1.
65536593
"""
@@ -6599,12 +6639,31 @@ def cmd_extract_signals_prepare(args: argparse.Namespace) -> int:
65996639
return 1
66006640

66016641
taxonomy_hash = _taxonomy_hash(taxonomy_bytes)
6602-
key = signal_extraction_cache_key(diff_tip, taxonomy_hash, prompt_hash)
6642+
6643+
intent_summary: dict[str, Any] | None = None
6644+
if intent_path is not None:
6645+
try:
6646+
with open(intent_path) as f:
6647+
intent_summary = json.load(f)
6648+
except (OSError, json.JSONDecodeError):
6649+
intent_summary = None
6650+
if not isinstance(intent_summary, dict):
6651+
intent_summary = None
6652+
6653+
agent_input = _build_signal_input(diff_data, intent_summary)
6654+
keyable = bool(agent_input.get("files"))
6655+
key = (
6656+
signal_extraction_cache_key(
6657+
diff_tip, signal_input_hash(agent_input), taxonomy_hash, prompt_hash,
6658+
)
6659+
if keyable
6660+
else ""
6661+
)
66036662

66046663
output_path = cr_dir / "extract_signals.json"
66056664

66066665
manifest_path = cr_dir / "extract_signals_manifest.json"
6607-
cached = _read_cached_signals(cache_dir, key)
6666+
cached = _read_cached_signals(cache_dir, key) if key else None
66086667
if cached is not None:
66096668
# Strip cache-only metadata before writing the canonical output.
66106669
canonical = {k: v for k, v in cached.items() if k != "written_at"}
@@ -6623,17 +6682,6 @@ def cmd_extract_signals_prepare(args: argparse.Namespace) -> int:
66236682
"model": model,
66246683
})
66256684

6626-
intent_summary: dict[str, Any] | None = None
6627-
if intent_path is not None:
6628-
try:
6629-
with open(intent_path) as f:
6630-
intent_summary = json.load(f)
6631-
except (OSError, json.JSONDecodeError):
6632-
intent_summary = None
6633-
if not isinstance(intent_summary, dict):
6634-
intent_summary = None
6635-
6636-
agent_input = _build_signal_input(diff_data, intent_summary)
66376685
input_path = cr_dir / "extract_signals_input.json"
66386686
with open(input_path, "w") as f:
66396687
json.dump(agent_input, f, indent=2)
@@ -6643,7 +6691,7 @@ def cmd_extract_signals_prepare(args: argparse.Namespace) -> int:
66436691
taxonomy_snapshot_path = cr_dir / "extract_signals_taxonomy.json"
66446692
taxonomy_snapshot_path.write_bytes(taxonomy_bytes)
66456693

6646-
return _write_and_emit_manifest(manifest_path, {
6694+
manifest: dict[str, Any] = {
66476695
"status": "needs_agent",
66486696
"cache_key": key,
66496697
"taxonomy_hash": taxonomy_hash,
@@ -6653,7 +6701,13 @@ def cmd_extract_signals_prepare(args: argparse.Namespace) -> int:
66536701
"prompt_path": str(prompt_path),
66546702
"output_path": str(output_path),
66556703
"model": model,
6656-
})
6704+
}
6705+
if not keyable:
6706+
# An empty cache_key also stops consolidate writing this run into
6707+
# the cache, so a bundle with no diff identity is neither served
6708+
# from the cache nor served to a later lane.
6709+
manifest["cache_bypass_reason"] = SIGNAL_CACHE_BYPASS_NO_FILES
6710+
return _write_and_emit_manifest(manifest_path, manifest)
66576711

66586712

66596713
def cmd_extract_signals_consolidate(args: argparse.Namespace) -> int:

plugins/code-review/tools/python/config/cli.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@
284284
"--diff-tip"
285285
],
286286
"required": true,
287-
"help": "Diff tip SHA for cache key"
287+
"help": "Diff tip ref name; a coarse cache-key component, not the diff identity."
288288
},
289289
{
290290
"flags": [
@@ -297,7 +297,7 @@
297297
"flags": [
298298
"--cache-dir"
299299
],
300-
"help": "Optional cache directory; fresh (diff_tip, taxonomy_hash, prompt_hash) tuples are served from the signals/ namespace."
300+
"help": "Optional cache directory; fresh (diff_tip, input_hash, taxonomy_hash, prompt_hash) tuples are served from the signals/ namespace."
301301
},
302302
{
303303
"flags": [

plugins/code-review/tools/python/prefix_fixtures/golden_prefix_empty_diff/expected/extract_signals_manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"cache_bypass_reason": "no-changed-files",
23
"cache_key": "<CACHE_KEY>",
34
"input_path": "<CR_DIR>/extract_signals_input.json",
45
"model": "haiku",

0 commit comments

Comments
 (0)