Skip to content

fix: report what is wrong instead of a false all-clear or a traceback - #1029

Merged
tirth8205 merged 6 commits into
stagingfrom
fix/cli-failure-honesty
Sep 18, 2026
Merged

tirth8205 merged 6 commits into
stagingfrom
fix/cli-failure-honesty

Conversation

@tirth8205

Copy link
Copy Markdown
Owner

Five failures where the tool lied about its own state or crashed instead of saying what it could not do. Each one is recorded as a xfail(strict=True) in tests/test_cli_surface.py (from #1018, brought in unchanged in the first commit); every marker is flipped off in the second.

Before: 218 passed, 119 xfailed. After: 350 passed, 3 xfailed — the three left are the bugs this branch does not claim (postprocess with no graph, an unstatable file during build, embed's non-house-style dependency message).


1. detect-changes reported a clean tree it never looked at

Bug. With no git on PATH, or a git diff that timed out, every change-discovery call returned an empty list and the command printed No changes detected. and exited 0 while the working tree had changed. A CI review gate keyed on that exit code passed pull requests nobody reviewed. The same false all-clear reached detect_changes_tool ("No changed files detected", status: ok) and the GitHub Action, whose renderer turned any non-JSON input into "No analyzable code changes detected".

Cause. get_changed_files, get_staged_and_unstaged, _get_svn_changed_files and parse_*_diff_ranges all caught FileNotFoundError / TimeoutExpired and returned [] / {}. Their callers could not tell "nothing changed" from "could not look", because the type system gave them no way to.

Fix. A require_vcs flag on the discovery path. A VCS that cannot be run at all raises ChangeDiscoveryError; a base ref that simply does not resolve keeps the documented --cached fallback, so a repository with no commits still works. build and update stay lenient on purpose — they recover by re-parsing the tree and reconciling by content hash, which is not a lie.

Every caller propagates the distinction rather than flattening it:

Caller Before After
detect-changes No changes detected., exit 0 Error: could not determine the changes: git could not be run (...). Install git and make sure it is on PATH., exit 1
detect_changes_tool (MCP) {"status": "ok", "summary": "No changed files detected."} {"status": "error", "error": "could not determine the changes: ..."}
scripts/render_pr_comment.py "No analyzable code changes detected" for any non-JSON only No changes detected. is a clean tree; anything else renders "this pull request has not been reviewed" and exits 4
action.yml comment posted, job green comment says it was not analyzed, job fails — outside fail-on-risk, because an unknown risk is not a low one

A partial failure degrades instead of failing: when the changed-file list is known but the line-level diff is not, analyze_changes scores whole files and records diff_ranges_unavailable plus a summary line, rather than presenting a file-level answer as a line-level one.

Proof. test_absent_git_binary_is_not_a_false_all_clear, test_git_timeout_is_not_a_false_all_clear (both xfail flipped, both now assert exit 1, Error: on stderr, the cause named, and an exit code different from the genuine no-changes run); test_detect_changes_tool_reports_an_undiscoverable_diff_as_an_error, test_detect_changes_tool_asks_for_a_working_vcs; test_main_not_analyzed_returns_4_and_says_so, test_not_analyzed_beats_fail_on_risk_none, test_empty_detect_changes_output_is_not_an_all_clear; test_require_vcs_turns_an_unrunnable_git_into_an_error, test_a_base_ref_that_does_not_resolve_is_not_an_unrunnable_git.

One existing assertion changed with the behaviour: test_absent_git_binary_does_not_traceback looped over status, detect-changes, update and build asserting exit 0 for each. detect-changes now owes exit 1 there — that is the fix, and the loop carries a table saying which commands owe which code and why. status, update and build still exit 0 with no git.

2. A corrupt graph.db tracebacked on every command

Bug. sqlite3.DatabaseError('file is not a database') straight out of GraphStore.__init__, on status, detect-changes, visualize, wiki, dead-code, update, search and query alike.

Cause. __init__ ran connect, PRAGMA journal_mode=WAL and _init_schema with nothing catching sqlite3.Error, and no command had a handler for it either. Two further kinds of bad database were not detected at all: a valid SQLite file belonging to something else (CREATE TABLE IF NOT EXISTS grafted our schema onto it and every query then answered "nothing found" about a graph that was never there), and one from a newer schema version (run_migrations is a no-op at or above LATEST_VERSION, so the mismatch surfaced much later as a missing column in an unrelated query).

Fix. GraphStore.__init__ raises GraphStoreError with one actionable line, and cli.main reports it in the house style. All four kinds tested:

Error: the graph database at <path> is unreadable (file is not a database). Delete it and run `code-review-graph build` to rebuild the graph.
Error: the graph database at <path> is unreadable (database disk image is malformed). ...
Error: <path> is a SQLite database but not a code-review-graph graph (it holds invoices). Point --data-dir somewhere else, or delete the file and run `code-review-graph build`.
Error: the graph database at <path> was written by a newer code-review-graph (schema v99; this build understands v10). Upgrade code-review-graph, or delete the file and run `code-review-graph build`.

A zero-byte file is deliberately still a new database — that is SQLite's own rule and the one build relies on.

Proof. test_corrupt_database_is_reported_cleanly (xfail flipped, 8 commands) and a new test_every_kind_of_corrupt_database_is_reported_cleanly covering garbage bytes, truncation, a foreign SQLite file and a newer schema. In the ordinary suite: test_garbage_bytes_are_reported_not_raised, test_a_truncated_database_is_reported, test_a_foreign_sqlite_file_is_not_adopted (which also asserts the foreign file is left exactly as found), test_a_newer_schema_is_refused_with_both_versions, test_an_empty_file_is_still_a_new_database, test_a_failed_open_leaves_no_connection_behind.

3. An unwritable data directory tracebacked on every command

Bug. sqlite3.OperationalError('attempt to write a readonly database') from the same PRAGMA, including on build.

Fix. Same handler, opposite message, because it needs the opposite action:

Error: cannot open the graph database for writing at <path> (attempt to write a readonly database). Check the permissions on <dir>, or set CRG_DATA_DIR to a writable directory.

Proof. test_read_only_data_dir_is_reported_cleanly (xfail flipped, 5 commands including build), now also asserting the message names the directory; test_unwritable_data_directory_names_the_directory.

4. Twenty-two numeric environment variables aborted the import (#912)

Bug. CRG_MAX_IMPACT_NODES= killed status, build and daemon status with ValueError: invalid literal for int() with base 10: '' — a traceback that never named the variable at fault, from commands that do not use the setting.

Cause. Twenty-two bare int(os.environ.get("CRG_...", "...")) at module scope across constants.py, incremental.py, changes.py, daemon.py, graph.py, main.py and communities.py.

Fix. One shared helper, not twenty-two try blocks. constants.env_int / env_float return the documented default and warn once, naming the variable and the value. env_float also rejects nan and inf, which float() accepts and which then poison every comparison they reach.

WARNING: Ignoring invalid CRG_MAX_IMPACT_NODES='' (not a number); using the default 500.

Proof. UNGUARDED_AT_RUNTIME and UNGUARDED_BASELINE are now empty, which flips 80 test_invalid_numeric_env_var_does_not_traceback cases and 20 test_every_scanned_env_var_survives_a_nonsense_value cases off xfail, and makes test_no_new_unguarded_numeric_env_parse fail on the first new bare parse. tests/test_env_config.py pins the in-process half: which values count as invalid, whitespace tolerance, the warning naming the variable, and warn-once.

Two test-side updates go with this. The source scan and the AST walk in test_cli_surface.py learned the helper spelling, or the module would have gone blind to every setting the fix moved behind it — that surfaced CRG_IMPACT_DEPTH_DECAY and CRG_IMPACT_SCORE_FLOOR, already guarded but never covered, which are now in the table. And test_canary_harness_detects_a_traceback used CRG_MAX_IMPACT_NODES= to prove the traceback detector fires; with that fixed it needs a crash of its own, so it shadows sqlite3 with a module that raises. Without that canary every assert not result.traceback in the file would be vacuous.

5. A graph.db built for another repository was accepted in silence

Bug. Dropping one repository's graph.db into another (a copied file, a restored CI cache) made dead-code list the donor's symbols with the donor's absolute paths, and exit 0.

Cause. The root check from #909 exists — _assert_graph_matches_root — but only ever ran on the write side, during incremental reconciliation, where a wrong root would purge files. No read-only consumer asked.

Fix. assert_graph_serves_root asks at every store open: in cli._run and in the MCP tools' _get_store. It is deliberately narrower than the write-side check, because refusing to answer is not destructive the way purging is. It fires only when no file marker lies under the requested root, and four things pass: a graph with no markers, a graph of repo-relative paths (which carry no root identity), an equivalent spelling of the same root (/var against /private/var, a symlinked checkout — resolved before comparing), and a graph whose files are not on this machine at all, which is stale rather than another live checkout being served.

Proof. test_foreign_repository_database_is_rejected (xfail flipped), now also asserting the house-style failure and that the message says "different repository root"; test_a_foreign_repository_graph_is_refused plus the four negatives — test_the_graph_of_this_repository_is_served, test_an_equivalent_spelling_of_the_same_root_is_served, test_a_relative_path_graph_carries_no_root_identity, test_a_graph_of_files_not_on_this_machine_is_stale_not_foreign.


Gates

uv run --python 3.13 python -m pytest tests/ -q
  3430 passed, 363 skipped, 2 xpassed        (2 xpassed are pre-existing, tests/test_notebook.py)

uv run --python 3.13 python -m pytest tests/test_cli_surface.py -m cli_surface -q
  350 passed, 3 xfailed                       (was 218 passed, 119 xfailed)

uv run ruff check code_review_graph/           All checks passed!
uv run --with mypy mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
  Success: no issues found in 73 source files
bandit -r code_review_graph/ -c pyproject.toml  No issues identified.

docs/TROUBLESHOOTING.md gains sections for each new message and docs/GITHUB_ACTION.md documents exit 4.

🤖 Generated with Claude Code

tirth8205 and others added 3 commits September 16, 2026 23:39
Brings in tests/test_cli_surface.py from test/cli-and-failure-modes
(PR #1018) unchanged, with the pytest marker and conftest gate it needs.
It enumerates the command tree from the live argparse parser and the
environment variables from the package source, so neither list can drift
away from the code, and it records the failure modes that do not yet meet
the house style as strict xfails.

The fixes in the following commits flip those markers off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five failures where the tool lied about its own state or crashed rather
than saying what it could not do. All five are recorded as strict xfails in
tests/test_cli_surface.py; every marker is flipped off here.

detect-changes reported a clean tree it never looked at. With no git on
PATH, or a `git diff` that timed out, every discovery call returned an
empty list and the command printed "No changes detected." and exited 0
while the working tree had changed. A CI review gate keyed on that exit
code passed pull requests nobody reviewed.

    Error: could not determine the changes: git could not be run
    ([Errno 2] No such file or directory: 'git'). Install git and make
    sure it is on PATH.

get_changed_files, get_staged_and_unstaged and the diff-range parsers take
require_vcs: a VCS that cannot be run at all raises ChangeDiscoveryError
instead of returning empty. A base ref that simply does not resolve keeps
the documented --cached fallback, so a repository with no commits still
works. build and update stay lenient on purpose: they recover by
re-parsing the tree and reconciling by content hash, which is not a lie.

Every caller propagates the distinction rather than flattening it. The CLI
turns it into one Error line and exit 1; detect_changes_tool returns
{"status": "error"} instead of its "No changed files detected" all-clear;
render_pr_comment.py now recognises only detect-changes' own
"No changes detected." line as a clean tree and exits 4 for anything else,
and the composite action posts a comment saying the pull request was not
analyzed and fails the job. Exit 4 is outside fail-on-risk: an unknown
risk is not a low one. When only the line-level diff is unreadable while
the file list is not, analyze_changes degrades to whole-file scoring and
records it, rather than failing or pretending to be line-precise.

A corrupt graph.db tracebacked out of GraphStore.__init__ on every
command. Four kinds of damage now report one actionable line: garbage
bytes and a truncated file (SQLite's own read errors), a valid SQLite file
belonging to something else (CREATE TABLE IF NOT EXISTS used to graft our
schema onto it), and a database from a newer schema version
(run_migrations is a no-op above LATEST_VERSION, so the mismatch only
surfaced later as a missing column).

An unwritable data directory did the same. It is told apart from a corrupt
file and gets the opposite advice:

    Error: cannot open the graph database for writing at <path> (attempt
    to write a readonly database). Check the permissions on <dir>, or set
    CRG_DATA_DIR to a writable directory.

A graph.db built for another repository was accepted in silence, so
dead-code in one repository listed the other's symbols with the other's
absolute paths and exited 0. The root check from #909 only ever ran on the
write side, during incremental reconciliation, where a wrong root would
purge files; nothing asked on the read side. assert_graph_serves_root asks
at every store open, in the CLI and in the MCP tools' _get_store. It is
narrower than the write-side check because refusing to answer is not
destructive: it fires only when no file marker lies under the root, and
lets through a graph with no markers, a graph of relative paths, an
equivalent spelling of the same root (/var against /private/var, a
symlinked checkout), and a graph whose files are not on this machine.

cli.main now wraps the dispatch and reports any CodeReviewGraphError in
the house style already set by update and watch: one `Error: ...` line on
stderr, exit 1. Anything else really is a bug and keeps its traceback.

Two existing tests pinned the old call signature of get_changed_files and
are updated for require_vcs=True. tests/test_failure_reporting.py and
tests/test_env_config.py carry the same contracts in the ordinary suite,
which does not collect the opt-in cli_surface gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six files collided. Resolutions:

pyproject.toml: the pytest markers list. Union of both sides; the
cli_surface entry was identical on each.

tests/conftest.py: three pytest_collection_modifyitems definitions would
have met here (staging already carried two, so its own packaging gate was
dead: pytest resolves the hook by name and only the last definition runs).
Folded into staging's single _OPT_IN_MARKERS hook, with packaging added to
the set and its CRG_RUN_PACKAGING_TESTS escape hatch preserved as a
per-marker override. tests/test_conftest_gates.py now fails if a second
hook is ever added.

code_review_graph/changes.py: both sides kept. The require_vcs path and the
diff_ranges_unavailable summary line sit alongside staging's churn cache,
churn_status and is_test_file-based test-gap detection. Staging's two new
numeric settings (CRG_CHURN_TIMEOUT, CRG_CHURN_MAX_COMMITS) were bare
int()/float() of os.environ.get, which is exactly what #912 removed, so
they now read through constants.env_float / env_int and are covered by the
numeric env var table in tests/test_cli_surface.py.

code_review_graph/graph.py: both sides kept. The GraphStore.__init__ guard
(_assert_usable_database plus the GraphStoreError wrapper) wraps staging's
schema, which now reaches v13; `import os` is gone because env_int replaced
its only use, and `import re` came in for staging's identifier tokenizer.

code_review_graph/cli.py: staging split main() into build_parser() + main();
this branch split it into main() + _run(). Merged as
build_parser() + main() (the CodeReviewGraphError reporter) + _run() (the
dispatch body), so the parser is still importable for tests and every
self-explaining failure still becomes one Error: line and exit 1.

tests/test_cli_surface.py: add/add. Staging's copy is byte-identical to this
branch's own first commit, so this branch's later version supersedes it.

All five fixes re-probed end to end against the merged tree: absent git and
CRG_GIT_TIMEOUT=0 both exit 1 with a named cause instead of "No changes
detected"; four kinds of unusable graph.db each report one line; an
unwritable data directory reports one line for status, detect-changes, wiki
and build; eight numeric variables set to "" and "abc" exit 0 with a warning
naming the variable; a donor repository's graph.db is refused by both the
CLI and _get_store.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.55 (MEDIUM) — 116 changed function(s)/class(es), 5 affected flow(s), 34 test gap(s)

Risk-scored changes

Risk Level Symbol Location Tested
0.55 medium code_review_graph/tools/_common.py::_get_store code_review_graph/tools/_common.py:208 no
0.50 medium code_review_graph/communities.py::_split_oversized code_review_graph/communities.py:565 yes
0.40 medium code_review_graph/changes.py::_vcs_unavailable code_review_graph/changes.py:48 no
0.40 medium code_review_graph/cli.py::_open_graph_store code_review_graph/cli.py:699 no
0.40 medium code_review_graph/constants.py::_warn_invalid_env code_review_graph/constants.py:26 no
0.40 medium code_review_graph/constants.py::env_int code_review_graph/constants.py:39 no
0.40 medium code_review_graph/incremental.py::_create_data_dir code_review_graph/incremental.py:417 no
0.40 medium code_review_graph/incremental.py::_vcs_unavailable code_review_graph/incremental.py:983 no
0.40 medium code_review_graph/incremental.py::_get_svn_changed_files code_review_graph/incremental.py:1114 no
0.40 medium tests/test_conftest_gates.py::_conftest_tree tests/test_conftest_gates.py:26 no

Affected execution flows

  • main — criticality 0.65, 49 node(s) across 6 file(s)
  • run — criticality 0.47, 26 node(s) across 3 file(s)
  • _get_store — criticality 0.45, 3 node(s) across 1 file(s)
  • with_provenance — criticality 0.40, 5 node(s) across 1 file(s)
  • _resolve_graph_file_paths — criticality 0.36, 2 node(s) across 1 file(s)

Test gaps

  • code_review_graph/changes.py::_vcs_unavailable (code_review_graph/changes.py:48)
  • code_review_graph/changes.py::parse_svn_diff_ranges (code_review_graph/changes.py:113)
  • code_review_graph/changes.py::parse_diff_ranges (code_review_graph/changes.py:159)
  • code_review_graph/cli.py::_open_graph_store (code_review_graph/cli.py:699)
  • code_review_graph/cli.py::main (code_review_graph/cli.py:738)
  • ...and 29 more without direct tests

Token savings: this graph-backed report used ~231,713 fewer tokens (~91%) than reading every changed file in full (estimated, chars/4 approximation).


Powered by code-review-graph — local-first analysis; no code leaves the CI runner.

tirth8205 and others added 3 commits September 18, 2026 12:38
Both sides wrapped the CLI's dispatch function to report a class of failure
as one line instead of a traceback, and both named their wrapper main().

cli.py: one main() catching both. A CodeReviewGraphError is printed in the
house style (one Error line, exit 1); lock contention keeps its own message
and its own advice. The dispatch function keeps staging's _dispatch name, so
the branch's _run is gone and its tests follow.

graph.py: the two failures meet inside GraphStore.__init__, where the branch
turns any sqlite3.Error into a GraphStoreError. run_migrations re-raises a
held write lock once its retries are spent, so a contended open was being
described as an unreadable database and answered with "delete it and
rebuild". Lock errors are now re-raised untouched and reach main()'s
contention report. The classifier moved to errors.py so cli.py and graph.py
cannot drift apart on what counts as contention.

incremental.py: the import conflict keeps both sides. Staging's new
CRG_MAX_UNWATCHED_TRACKED was parsed with a bare int(os.environ.get(...)),
which is the import-time crash the branch removed from 22 other settings; it
reads through env_int now and joins the coverage table.

Also fixed while probing the branch's five cases: a data directory that
cannot be created still tracebacked out of pathlib.mkdir, because
get_data_dir creates it while the path is being resolved, before any
GraphStore exists. It is now the same one-line GraphStoreError as a
directory that cannot be written to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both sides handled a corrupt graph.db and they had to become one thing.

This branch turned every failure the tool understands about itself into a
single `Error: ...` line: a corrupt or foreign database, a data directory it
may not write to, a graph built for another repository root, a git it could
not run, and 22 numeric environment variables that used to raise at import.
Staging added `_open_graph_store`, which lets `build` discard a database it
cannot open and rebuild it, so a restored CI cache stops failing every run.

Resolution:

* `CorruptGraphDatabaseError` now subclasses `GraphStoreError` instead of
  `sqlite3.DatabaseError`. It keeps staging's job (naming the one unusable
  shape a rebuild can fix by itself) and gains this branch's contract
  (nothing the tool can explain reaches a caller as a raw SQLite error). Its
  message is the house line, so `main` reports it and no second message in
  `_open_graph_store` can drift from it.
* `GraphStore.__init__` keeps this branch's structure (mkdir inside the try,
  the connection closed on every failure path, `_assert_usable_database`
  refusing a foreign file or a newer schema before any DDL runs) and gains
  staging's classification step, in an order that protects a healthy graph:
  an already-classified `GraphStoreError` first, contention second,
  corruption only then, and the described failure last.
* A foreign SQLite file and a newer-schema graph stay plain
  `GraphStoreError`s, so `build` never deletes somebody else's data or a
  graph this build is merely too old to read. `_assert_usable_database`
  catches the foreign file before staging's classifiers see it; a readable
  graph.db whose own tables are the wrong shape still reaches them and is
  still recovered.
* cli.py drops staging's second copy of the lock classifier; `is_lock_error`
  in errors.py is the single one.
* Docs say what `build` now does by itself, and that contention is never in
  the discardable list.

Also fixes a canary that was stale on this branch: action.yml's "Fail when
the analysis did not run" step was missing from the expected step list.

Probed, not just read: detect-changes with no git on PATH exits 1 naming the
cause; a read command reports a corrupt database in one line while `build`
discards and rebuilds it; a wrong-shape schema recovers the same way; an
invoices database survives `build` untouched; an unwritable data directory
gives one line; an invalid numeric env var warns and falls back; a donor
repository's graph is refused; and a held write lock is reported as
contention with the database present and byte-identical afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One conflict, in GraphStore.__init__. Staging (via #1027) registers the
SQLite function crg_parent_dir after the open/migrate guard; this branch
ends that guard with a described GraphStoreError fallback. Kept both: the
fallback stays the last arm of the except, the registration stays after
it, so a failure registering the helper is still not mistaken for a
corrupt database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tirth8205
tirth8205 merged commit f218e16 into staging Sep 18, 2026
22 checks passed
@tirth8205
tirth8205 deleted the fix/cli-failure-honesty branch September 18, 2026 12:39
tirth8205 added a commit to gowrishacv/code-review-graph that referenced this pull request Sep 18, 2026
Merges origin/staging (PR tirth8205#1029, which this branch predated and no longer
applied over), then fixes what three reviewers found. Reproduced each one
first; each fix is verified with the reporter's own reproducer.

C is withdrawn, not repaired.

`get_staged_and_unstaged(untracked=)` and the `--untracked-files=normal`
discovery walk are gone; the walk is back to `all`. Under `normal`, git
collapses a wholly-untracked directory to one `dir/` record, which is not a
path any caller can open, and the code dropped it -- so the first commit of
any new package vanished from all four review tools and both CLI review
commands, with `status: ok` and no warning at any log level. Reproduced:
a repo whose only change is `feature/newmod/{a.py,b.py}` reported
"No changes detected"; it now reports both files. The measured prize was
0.377s -> 0.006s of git time on 250,000 untracked files. Being slow is a
bug; confidently reviewing nothing is a worse one, and the task's own
instruction was to say so rather than trade one for the other.

The 5s budget stays, because it no longer lies.

staging landed `ChangeDiscoveryError` and `require_vcs` while this branch
was out. `discover_review_changes` now passes `require_vcs=True` to all
three steps, so exhausting the short budget raises instead of returning
`[]`. That is what licenses shortening it at all: the reviewers were right
that cutting 30s -> 5s on a path whose timeout returns an empty list makes
tirth8205#913's false all-clear several times easier to hit, and extends it to
`resolve_review_base`, where a timed-out merge base silently degrades a
three-dot diff to two-dot. tirth8205#913 itself is still untouched --
`get_changed_files`'s default is unchanged and `require_vcs` is staging's
own flag, so PR tirth8205#922 keeps its ground.

Verified with a `git` shim that sleeps 7s: before, `detect_changes_tool`
answered `status: ok` with `changed_files: ["scratch_note.txt"]` -- an
unrelated file. Now all four tools answer `status: error` naming the budget
and `CRG_DISCOVERY_TIMEOUT`, and `detect-changes` exits 1 rather than 0.

`discovery_timeout()` no longer clamps to `min(5, GIT_TIMEOUT)`: an
explicitly set `CRG_GIT_TIMEOUT` is honoured verbatim. It predates this
variable and is what tirth8205#262's reporters were already told to raise; a
documented knob that silently stops working is worse than one that never
existed. `CRG_GIT_TIMEOUT=120` with the same 7s git returns the correct
file list again.

`CRG_TOOL_TIMEOUT` keeps its scope and its guard.

`_offload` gained `bounded=`, and the five writing tools pass False:
build, postprocess, embed, wiki and apply_refactor. `asyncio.wait_for`
cancels the await, never the worker, so bounding these reported failure to
the client while the build kept writing graph.db (verified: the "failed"
run left 52,000 nodes behind) and while apply_refactor's rename landed on
disk, whose retry then failed again with "not found or expired". They were
also unbounded before the shared helper existed, and `CRG_TOOL_TIMEOUT` is
what tirth8205#262 tells a user to set -- the documented remedy must not break
builds.

Parsing goes through staging's `env_int`, so `CRG_TOOL_TIMEOUT=2.5` (or
"", or "abc") no longer raises `ValueError` out of every tool. This also
satisfies staging's `test_no_new_unguarded_numeric_env_parse` gate, which
the pre-merge branch failed in both directions.

The offload runs on anyio's limiter, not asyncio's executor.

`anyio.to_thread.run_sync(abandon_on_cancel=True)` replaces
`asyncio.to_thread`. Making these tools `async def` had moved them off the
40-slot limiter FastMCP dispatches sync bodies through and onto asyncio's
default executor, capped at `min(32, cpu+4)` -- 18 here, 8 on a 4-core
Windows box. At the reviewer's N=19 burst a cheap tool waited 10.15s and
got a false "timed out"; it now answers in 0.01s, and still does at N=30.
anyio is declared in pyproject rather than borrowed from fastmcp.

`get_minimal_context_tool` is on the budget too.

The entry point CLAUDE.md tells agents to call first was the one tool the
previous commit skipped: `resolve_review_base` (30s) + two hardcoded 10s
probes + `get_changed_files` (30s). One `discover_review_changes` call
replaces all five subprocesses. Measured with a 6s git: 31.2s -> 6.0s. A
discovery failure appends a "Degraded:" note, following the churn note's
existing contract, rather than reporting risk it could not compute.

Also: the timeout hint no longer names `changed_files` / `max_depth` /
`max_results` as though every bounded tool accepted them; `_vcs_unavailable`
names the budget that actually expired and the knob that governs it; and the
CHANGELOG and all five README env-var tables say which tools
`CRG_TOOL_TIMEOUT` bounds and that a discovery timeout is an error rather
than an all-clear.

Tests: the two that pinned the collapsed-directory drop are replaced by ones
that pin the opposite, including an end-to-end check that a new package
reaches all four review tools against real git (verified to fail when the
drop is reintroduced). New coverage for per-step require_vcs, the budget
precedence, the unbounded writing tools, malformed CRG_TOOL_TIMEOUT, and the
anyio limiter.

Gates: 4163 passed, 848 skipped, 2 xfailed, 2 xpassed (both xpassed are
pre-existing R-notebook tests). ruff and mypy clean.

Co-Authored-By: Claude Opus 5 (1M context) <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