Stop Review Lens losing files and nodes without saying so - #393
Merged
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_truncatedwas only ever set from inside the row loop, and thewhile len(files) < MAX_PR_FILESheader above it ended the loop first whenever a page filled the list exactly to the cap:_build_overview_markdowngates 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"), andCODE_REVIEW_DIFF_TIMEOUT_SECONDS' justification inbackend/agents.pydescribes "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_payloadread eight numeric fields with a bareint():_restore_nodecatches every restorer exception and returnsNone, so thatValueErrordid 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
elapsedMsagainst exactly this failure, with a comment saying so, and then reads six builder budget fields with a bareint()six lines further down.Change
files_truncatedis now derived from two independent signals rather than set mid-loop: whether the cap cut a page short, and whether the PR's ownchanged_filescount 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_floathelper already in this module and written for the same stated reason — a saved chat row is hostile data on disk.or 0was never a substitute: it guardsNoneand"", then passes"n/a"straight intoint(). The plan node's caps still fall back to their real defaults rather than to zero, since a restored plan withmax_steps=0could 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
diff_fetchcases: 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.session_loadcases:code_reviewsurvives a non-numeric score and non-numeric counts,plansurvives non-numeric budget fields, and a raising restorer is logged while the rest of the canvas still loads.ruff check .clean.🤖 Generated with Claude Code