Skip to content

Stop Review Lens losing files and nodes without saying so - #393

Merged
dovvnloading merged 1 commit into
mainfrom
fix/review-lens-data-integrity
Sep 4, 2026
Merged

dovvnloading merged 1 commit into
mainfrom
fix/review-lens-data-integrity

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Problem

Two ways a Review Lens node could quietly hold less than it claimed. Both were found by executing the code, and both are reproduced by the tests added here.

A pull request of more than 100 changed files was silently truncated. files_truncated was only ever set from inside the row loop, and the while len(files) < MAX_PR_FILES header above it ended the loop first whenever a page filled the list exactly to the cap:

PR has  99 files -> kept= 99  changed_files= 99  files_truncated=False
PR has 100 files -> kept=100  changed_files=100  files_truncated=False
PR has 150 files -> kept=100  changed_files=150  files_truncated=False   <-- wrong
PR has 250 files -> kept=100  changed_files=250  files_truncated=False   <-- wrong

_build_overview_markdown gates its "File list truncated" line on that flag, so a 150-file PR rendered a header reading "Files changed: 150" above a walkthrough covering 100 of them, with nothing saying so. The walkthrough grouping and every fallback heuristic saw the partial set too. diff_fetch.py's own module docstring promises the opposite ("rather than silently reviewing half a diff"), and CODE_REVIEW_DIFF_TIMEOUT_SECONDS' justification in backend/agents.py describes "up to two pages of file-listing GETs" — a second page the loop could never reach.

One corrupt number deleted an entire node on load. _restore_code_review_payload read eight numeric fields with a bare int():

_restore_code_review_payload({"review": {"scores": {"correctness": "n/a"}}})
  -> ValueError: invalid literal for int() with base 10: 'n/a'

_restore_node catches every restorer exception and returns None, so that ValueError did not surface as an error — the node was dropped from the canvas, taking the PR URL, the stored diff, every finding, the scorecard and the Q&A history with it, and logging nothing.

The plan restorer has the same gap. It already guards activity elapsedMs against exactly this failure, with a comment saying so, and then reads six builder budget fields with a bare int() six lines further down.

Change

files_truncated is now derived from two independent signals rather than set mid-loop: whether the cap cut a page short, and whether the PR's own changed_files count exceeds what was kept. Either alone has a blind spot — the first misses a page that ends exactly on the cap, the second misses a listing that disagrees with its own metadata. Distinguishing "exactly 100 files" from "the first 100 of more" costs one extra page request, which comes back empty; there is no way to tell them apart without asking.

Both restorers now use _non_negative_int, added here as the integer sibling of the _finite_float helper already in this module and written for the same stated reason — a saved chat row is hostile data on disk. or 0 was never a substitute: it guards None and "", then passes "n/a" straight into int(). The plan node's caps still fall back to their real defaults rather than to zero, since a restored plan with max_steps=0 could never run again.

The silent drop is now logged, matching the warning the neighbouring "no restorer registered" branch already emits. One bad row still must not fail the whole load — but a restorer bug should not be able to erase a node with no trace anywhere.

Test plan

  • 4 new diff_fetch cases: both sides of the 100-file boundary, a full page landing exactly on the cap, and rows dropped as unusable. The pre-existing truncation test (120 rows on one page) still passes — it covered the mid-page cut, which always worked.
  • 4 new session_load cases: code_review survives a non-numeric score and non-numeric counts, plan survives non-numeric budget fields, and a raising restorer is logged while the rest of the canvas still loads.
  • Full suite: 3145 passed, 19 skipped (up from 3138). ruff check . clean.

🤖 Generated with Claude Code

Two ways a Review Lens node could quietly hold less than it claimed.

A pull request of more than 100 changed files was truncated to its first
100 with files_truncated left False. The flag was only ever set from
inside the row loop, and the `while len(files) < MAX_PR_FILES` header
above it ended the loop first whenever a page filled the list exactly to
the cap. Since _build_overview_markdown gates its "File list truncated"
line on that flag, a 150-file PR rendered a header reading "Files
changed: 150" over a walkthrough that covered 100 of them. The loop now
runs to the end of the listing and the flag is derived from two
independent signals: whether the cap cut a page short, and whether the
PR's own changed_files count exceeds what we kept. Telling "exactly 100"
apart from "the first 100 of more" costs one extra, empty page request.

Separately, _restore_code_review_payload read eight numeric fields with a
bare int(). One non-numeric value - "n/a" in a saved scorecard - raised
ValueError, and _restore_node catches every restorer exception and drops
the node, so the PR URL, stored diff, findings, scorecard and Q&A history
vanished from the canvas with nothing logged. The plan restorer had the
same gap: it already guarded activity elapsedMs against exactly this,
then read six budget fields with a bare int() six lines further down.

Both now use _non_negative_int, the integer sibling of the _finite_float
helper already in this module and written for the same reason - a saved
chat row is hostile data on disk. `or 0` was never a substitute: it
guards None and "" but passes "n/a" straight into int(). The plan node's
caps keep falling back to their real defaults rather than zero, since a
restored plan with max_steps=0 could never run again.

The silent drop itself is now logged, matching the warning the
neighbouring "no restorer registered" branch already emits. One bad row
still must not fail the whole load, but a restorer bug should not erase a
node without a trace.

Test plan:
- 4 new diff_fetch cases covering both sides of the 100-file boundary,
  a full page landing exactly on the cap, and rows dropped as unusable.
- 4 new session_load cases: code_review survives a non-numeric score and
  non-numeric counts, plan survives non-numeric budget fields, and a
  raising restorer is logged while the rest of the canvas still loads.
- Full suite: 3145 passed, 19 skipped. ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dovvnloading
dovvnloading merged commit a2f59d3 into main Sep 4, 2026
7 of 8 checks passed
@dovvnloading
dovvnloading deleted the fix/review-lens-data-integrity branch September 4, 2026 11:16
dovvnloading added a commit that referenced this pull request Sep 4, 2026
Both shipped earlier today with the defect still reachable. A verification
pass measured them; neither was theoretical.

Budget caps could still restore unusable. #393's commit message claimed
the plan's caps "keep falling back to their real defaults rather than
zero, since a restored plan with max_steps=0 could never run again". They
did not - _non_negative_int is `max(0, int(value))` with a fallback only
when int() RAISES, so every numeric route to zero went straight through,
and the outer `or default` only catches Python-falsy values:

    max_steps=-5  -> 0     max_steps='0'  -> 0
    max_steps='-5'-> 0     max_steps=0.5  -> 0

The harness restorer was worse: its local `_int` helper passed negatives
through unchanged, so a saved -5 restored as -5 and a saved '0' as 0.
builder._spend_breach then rejects the node on its first tick - it sits on
the canvas and can never run again.

_positive_int is the right helper for a cap, where zero is not a value,
and both restorers now use it. Counters keep _non_negative_int, because 0
is a real count. The harness's local helper is gone in favour of the two
shared ones.

The field-comment gate could not see the modules it was written to cover.
#400 corrected `content`'s comment to "TEN kinds" and added a gate to pin
it. Both were wrong: backend/domain/groups.py builds `kind="frame"` and
`kind="container"` nodes with `content=`, and the gate hard-coded
graph.py and session_load.py while its own docstring called them "the two
modules that create nodes". Twelve kinds, not ten - and a thirteenth
landing in groups.py would have left the gate green.

That is the failure the gate exists to prevent, reproduced inside the
gate. It now DISCOVERS every module under backend/ that constructs a
SceneNode instead of naming them, with a test asserting discovery finds
more than the original two. The kind sets stay hand-authored on purpose:
a human deciding "yes, a thirteenth kind should write content" is the
checkpoint.

Test plan:
- 9 parametrised plan-cap cases and 6 harness-cap cases covering every
  numeric and non-numeric route to zero or negative, plus a case pinning
  that a real cap survives and a counter of 0 is not rewritten.
- A discovery test that fails if the module scan narrows back.
- tests/test_node_state_migration.py caught the first draft of these
  tests aliasing node.state; rewritten to the required X.state.<field>
  chain rather than weakening the gate.
- Full suite: 3181 passed, 19 skipped. ruff clean.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant