argus-code-review 0.3.2__tar.gz → 0.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/CHANGELOG.md +19 -1
  2. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/PKG-INFO +1 -1
  3. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/gemini_runner.py +293 -57
  4. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/helpers.py +45 -8
  5. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/pipeline_models.py +8 -0
  6. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-cross-cutting.md +5 -6
  7. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-lite.md +1 -1
  8. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/runners.py +19 -36
  9. argus_code_review-0.3.4/tests/test_failure_diagnostics.py +129 -0
  10. argus_code_review-0.3.4/tests/test_gemini_http_retry.py +136 -0
  11. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_gemini_runner.py +247 -1
  12. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/.gitignore +0 -0
  13. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/LICENSE +0 -0
  14. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/README.md +0 -0
  15. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/__init__.py +0 -0
  16. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/bench.py +0 -0
  17. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/bench_default.toml +0 -0
  18. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/cli.py +0 -0
  19. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/config.py +0 -0
  20. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/coverage.py +0 -0
  21. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/dotenv_utils.py +0 -0
  22. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/gemini_cache.py +0 -0
  23. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/github_client.py +0 -0
  24. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/graph.py +0 -0
  25. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/llm/models.py +0 -0
  26. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/llm/output_models.py +0 -0
  27. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/llm/pricing.py +0 -0
  28. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/llm/usage.py +0 -0
  29. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/models.py +0 -0
  30. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/openai_client.py +0 -0
  31. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/openai_runner.py +0 -0
  32. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/__init__.py +0 -0
  33. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/actions_scanner.py +0 -0
  34. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/engine.py +0 -0
  35. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/.gitignore +0 -0
  36. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/README.md +0 -0
  37. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/eslint.config.js +0 -0
  38. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/package-lock.json +0 -0
  39. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/package.json +0 -0
  40. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/js_scanner.py +0 -0
  41. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/migration_scanner.py +0 -0
  42. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/rules/README.md +0 -0
  43. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/sarif.py +0 -0
  44. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/scanner_utils.py +0 -0
  45. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/secrets_scanner.py +0 -0
  46. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/shadow.py +0 -0
  47. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/terraform_scanner.py +0 -0
  48. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/workflow_lint_scanner.py +0 -0
  49. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/__init__.py +0 -0
  50. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-blocking-validator.md +0 -0
  51. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-coverage-check.md +0 -0
  52. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-feedback-verifier.md +0 -0
  53. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-planner.md +0 -0
  54. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-preflight-router.md +0 -0
  55. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-prior-art.md +0 -0
  56. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-scope.md +0 -0
  57. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-deployment.md +0 -0
  58. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-frontend.md +0 -0
  59. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-infra.md +0 -0
  60. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-llm-patterns.md +0 -0
  61. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-observability.md +0 -0
  62. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-orchestration.md +0 -0
  63. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-security.md +0 -0
  64. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-slackbot.md +0 -0
  65. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-sql.md +0 -0
  66. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-subagent.md +0 -0
  67. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-tests-and-docs.md +0 -0
  68. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-writer.md +0 -0
  69. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts_runtime.py +0 -0
  70. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/repo_provision.py +0 -0
  71. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/review_tools.py +0 -0
  72. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/scope.py +0 -0
  73. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/__init__.py +0 -0
  74. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/http.py +0 -0
  75. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/models.py +0 -0
  76. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/precheck.py +0 -0
  77. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/resolver.py +0 -0
  78. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/session.py +0 -0
  79. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/sql.py +0 -0
  80. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/sqlite.py +0 -0
  81. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/pyproject.toml +0 -0
  82. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/008_add_code_reviews.sql +0 -0
  83. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/009_add_reviewer_version.sql +0 -0
  84. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/010_add_review_patterns.sql +0 -0
  85. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/011_add_review_progress_columns.sql +0 -0
  86. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/015_create_agent_runs.sql +0 -0
  87. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/016_add_agent_runs_failure_reason.sql +0 -0
  88. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/017_add_precheck_rules.sql +0 -0
  89. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/018_widen_agent_runs_failure_reason.sql +0 -0
  90. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/__init__.py +0 -0
  91. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/conftest.py +0 -0
  92. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/golden/review_response.schema.json +0 -0
  93. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/__init__.py +0 -0
  94. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/test_backend_contract.py +0 -0
  95. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/test_http.py +0 -0
  96. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/test_models.py +0 -0
  97. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/test_resolver.py +0 -0
  98. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/test_session.py +0 -0
  99. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/test_sql.py +0 -0
  100. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/test_sqlite_backend.py +0 -0
  101. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_actions_scanner.py +0 -0
  102. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_argus_review_local.py +0 -0
  103. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_bench.py +0 -0
  104. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_bench_config_guard.py +0 -0
  105. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_catchup_gate.py +0 -0
  106. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_cli_args.py +0 -0
  107. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_cli_output_contract.py +0 -0
  108. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_cli_post_review.py +0 -0
  109. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_cli_preflight.py +0 -0
  110. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_cli_prompts.py +0 -0
  111. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_config.py +0 -0
  112. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_conftest.py +0 -0
  113. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_dotenv_utils.py +0 -0
  114. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_gemini_cache.py +0 -0
  115. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_github_client_checks_signal.py +0 -0
  116. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_github_client_write.py +0 -0
  117. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_fetch_diff.py +0 -0
  118. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_get_llm_temperature.py +0 -0
  119. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_http_guards.py +0 -0
  120. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_precheck.py +0 -0
  121. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_preflight_image_bump.py +0 -0
  122. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_progress.py +0 -0
  123. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_storage_resolution.py +0 -0
  124. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_timeout_surfacing.py +0 -0
  125. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_js_scanner.py +0 -0
  126. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_llm_models.py +0 -0
  127. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_llm_models_override.py +0 -0
  128. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_llm_pricing.py +0 -0
  129. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_migration_scanner.py +0 -0
  130. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_models.py +0 -0
  131. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_multi_round.py +0 -0
  132. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_openai_client.py +0 -0
  133. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_openai_runner.py +0 -0
  134. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_output_models.py +0 -0
  135. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_packaging.py +0 -0
  136. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_plan_review.py +0 -0
  137. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_precheck_engine.py +0 -0
  138. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_precheck_engine_integration.py +0 -0
  139. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_precheck_sarif.py +0 -0
  140. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_precheck_shadow.py +0 -0
  141. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_precheck_shadow_integration.py +0 -0
  142. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_prompts.py +0 -0
  143. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_repo_provision.py +0 -0
  144. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_review_patterns_integration.py +0 -0
  145. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_review_tools.py +0 -0
  146. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_runners_context7.py +0 -0
  147. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_runners_context_usage.py +0 -0
  148. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_runners_helpers.py +0 -0
  149. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_runners_new.py +0 -0
  150. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_runners_turn_budget.py +0 -0
  151. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_scanner_utils.py +0 -0
  152. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_scope.py +0 -0
  153. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_scope_gate.py +0 -0
  154. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_sdk_bundled_cli_version.py +0 -0
  155. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_secrets_scanner.py +0 -0
  156. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_specialist_validation.py +0 -0
  157. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_stage_costs.py +0 -0
  158. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_storage_precheck.py +0 -0
  159. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_structured_output_methods.py +0 -0
  160. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_terraform_scanner.py +0 -0
  161. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_terraform_scanner_integration.py +0 -0
  162. {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_workflow_lint_scanner.py +0 -0
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.4] - 2026-10-23
11
+
12
+ ### Fixed
13
+
14
+ - Gemini requests now retry HTTP 500/502/503/504 in place at every turn (up to three attempts with exponential jitter), retaining the conversation and without replaying tools. The outer session timeout still bounds the total work. The quota-aware whole-session turn-0 retry remains for 429s and transport errors, but a 5xx the SDK has already retried (three attempts) is not restarted, so a persistent 5xx costs at most three requests per turn; 429s are excluded from the SDK's status-only retry policy so daily-quota errors still fail fast.
15
+ - Failed Gemini sessions preserve cost and tool accounting from completed turns, and include the failed turn index in their short failure detail.
16
+ - Reviewer failure details now reach degraded-coverage reports instead of disappearing after the runner. Only canonical provider codes/statuses, standard HTTP reason phrases (such as `502 Bad Gateway`) and httpx transport error names are rendered; raw provider messages are never included. The cause precedes the reviewer-area name so it remains visible to consumers that truncate descriptions to 200 characters. Existing failure-reason values and database constraints are unchanged.
17
+ - The model-registry check in the cross-cutting and lite prompts now applies to the repository under review instead of naming one specific package's constants. A package that has no registry of its own is no longer told it must use someone else's, and a legitimately overridden model value is not flagged. The em-dash character rule, which was one project's prose convention rather than a general review criterion, is removed from the shared prompt.
18
+
19
+ ## [0.3.3] - 2026-10-22
20
+
21
+ ### Changed
22
+
23
+ - Gemini reviewer sessions now retry ONCE on a transient failure (HTTP 429/500/502/503/504, `RESOURCE_EXHAUSTED`/`UNAVAILABLE`/`DEADLINE_EXCEEDED`/`INTERNAL`, or an httpx timeout/connect/read/remote-protocol error) that happens before the model returned any response. The cooldown depends on the error: 45-60 s jitter for 429/`RESOURCE_EXHAUSTED` (per-minute quota windows), 5-10 s for 5xx and network errors; a server `Retry-After`/`retryDelay` >= 5 s is honored; never more than 60 s. A daily-quota 429 (`PerDay` quota or a retry delay over 120 s) and auth, permission, bad-request and not-found errors are never retried. Overridable via `ARGUS_GEMINI_RETRY_RATE_LIMIT_{MIN,MAX}_S`, `ARGUS_GEMINI_RETRY_COOLDOWN_{MIN,MAX}_S` and `ARGUS_GEMINI_RETRY_MAX_WAIT_S`.
24
+ - A failed Gemini session now logs a WARNING with the exception type, HTTP code/status and a scrubbed, 300-character-truncated message, and `SessionResult.failure_detail` carries a short label such as `429 RESOURCE_EXHAUSTED`. `failure_reason` stays `worker_crashed`; degraded-coverage note text is unchanged. The existing ERROR log for a failed `generate_content` call is also scrubbed of key-shaped strings.
25
+
10
26
  ## [0.3.2] - 2026-10-21
11
27
 
12
28
  ### Added
@@ -441,7 +457,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
441
457
  packaged set.
442
458
  - `argus --version`, `argus prompts list`, and `argus prompts export`.
443
459
 
444
- [Unreleased]: https://github.com/redesignhealth/argus-review/compare/v0.3.1...HEAD
460
+ [Unreleased]: https://github.com/redesignhealth/argus-review/compare/v0.3.4...HEAD
461
+ [0.3.4]: https://github.com/redesignhealth/argus-review/compare/v0.3.3...v0.3.4
462
+ [0.3.3]: https://github.com/redesignhealth/argus-review/compare/v0.3.2...v0.3.3
445
463
  [0.3.2]: https://github.com/redesignhealth/argus-review/compare/v0.3.1...v0.3.2
446
464
  [0.3.1]: https://github.com/redesignhealth/argus-review/compare/v0.3.0...v0.3.1
447
465
  [0.3.0]: https://github.com/redesignhealth/argus-review/compare/v0.2.11...v0.3.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: argus-code-review
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Self-orchestrated PR review agent using LangGraph + Claude Agent SDK
5
5
  Project-URL: Repository, https://github.com/redesignhealth/argus-review
6
6
  Project-URL: Issues, https://github.com/redesignhealth/argus-review/issues
@@ -144,7 +144,10 @@ from __future__ import annotations
144
144
  import asyncio
145
145
  import json
146
146
  import logging
147
+ import os
148
+ import random
147
149
  import re
150
+ from dataclasses import dataclass, field
148
151
  from datetime import datetime, timezone
149
152
  from typing import Any
150
153
 
@@ -208,6 +211,213 @@ _FORCE_TOOL_CONFIG = types.ToolConfig(
208
211
  # uncaught.
209
212
  _HTTP_TIMEOUT_FRACTION = 0.8
210
213
 
214
+ # ---------------------------------------------------------------------------
215
+ # One-shot retry of transient Gemini failures
216
+ # ---------------------------------------------------------------------------
217
+ # A session is retried AT MOST ONCE, and only when it failed with a transient
218
+ # error (HTTP 429/500/502/503/504, RESOURCE_EXHAUSTED/UNAVAILABLE/
219
+ # DEADLINE_EXCEEDED/INTERNAL, or an httpx timeout/connect/read/remote-protocol
220
+ # error) before the model returned ANY response (i.e. on turn 0), so the retry
221
+ # restarts the session cleanly from the beginning. Later failures are not
222
+ # restarted; the SDK retries only individual 5xx requests (see
223
+ # `_HTTP_RETRY_ATTEMPTS`), and a 5xx it already retried is not restarted again,
224
+ # so the restart covers 429 and transport errors only. The cooldown depends on the error kind (see `_retry_decision`):
225
+ # 45-60 s jitter for 429/RESOURCE_EXHAUSTED (per-minute quota windows), 5-10 s
226
+ # for 5xx/network errors; a server Retry-After/retryDelay >= 5 s is honored;
227
+ # never more than 60 s. Daily-quota 429s are not retried. Overridable via
228
+ # ARGUS_GEMINI_RETRY_RATE_LIMIT_{MIN,MAX}_S, ARGUS_GEMINI_RETRY_COOLDOWN_{MIN,MAX}_S
229
+ # and ARGUS_GEMINI_RETRY_MAX_WAIT_S (read per call, so tests can set them to 0).
230
+ # The 60 s worst case is far inside ARGUS_SESSION_TIMEOUT (default 1500 s), and
231
+ # the retry is skipped if <25% of that timeout remains.
232
+ _RETRY_RATE_LIMIT_MIN_S = 45.0 # 429 / RESOURCE_EXHAUSTED: per-minute windows
233
+ _RETRY_RATE_LIMIT_MAX_S = 60.0
234
+ _RETRY_SHORT_MIN_S = 5.0 # 5xx / UNAVAILABLE / DEADLINE_EXCEEDED / network
235
+ _RETRY_SHORT_MAX_S = 10.0
236
+ _RETRY_MAX_WAIT_S = 60.0 # hard cap on any single wait, incl. a server Retry-After
237
+ _RETRY_AFTER_MIN_S = 5.0 # a server delay below this is ignored (jitter used)
238
+ # A server-suggested delay above this means the quota window is not a
239
+ # per-minute one (minutes/hours): fail fast rather than retry.
240
+ _RETRY_GIVEUP_DELAY_S = 120.0
241
+ # Do not retry when less than this fraction of the session timeout remains
242
+ # (e.g. the first attempt already burned its HTTP timeout).
243
+ _RETRY_MIN_REMAINING_FRACTION = 0.25
244
+ _TRANSIENT_HTTP_CODES = frozenset({429, 500, 502, 503, 504})
245
+ _TRANSIENT_STATUSES = frozenset(
246
+ {"RESOURCE_EXHAUSTED", "UNAVAILABLE", "DEADLINE_EXCEEDED", "INTERNAL"}
247
+ )
248
+ _TRANSIENT_HTTPX = (
249
+ httpx.TimeoutException,
250
+ httpx.ConnectError,
251
+ httpx.ReadError,
252
+ httpx.RemoteProtocolError,
253
+ )
254
+
255
+ # SDK-level per-request retry of 5xx at any turn; the request is re-sent
256
+ # unchanged, so no tools are replayed. 429s stay in _retry_decision because the
257
+ # SDK cannot tell daily quota from per-minute limits or honor Retry-After.
258
+ _HTTP_RETRY_ATTEMPTS = 3 # including the initial call
259
+ _HTTP_RETRY_STATUS_CODES = (500, 502, 503, 504)
260
+ _HTTP_RETRY_INITIAL_DELAY_S = 2.0
261
+ _HTTP_RETRY_MAX_DELAY_S = 20.0
262
+ _DETAIL_MAX_CHARS = 300
263
+ _SECRET_RE = re.compile(
264
+ r"(AIza[0-9A-Za-z_\-]{20,}|(?i:(?:api[_-]?key|key|token)=)[^&\s\"']+|"
265
+ r"(?i:bearer\s+)[A-Za-z0-9._\-]+|sk-[A-Za-z0-9_\-]{16,})"
266
+ )
267
+
268
+
269
+ def _env_float(name: str, default: float) -> float:
270
+ raw = os.environ.get(name)
271
+ if raw is None or raw == "":
272
+ return default
273
+ try:
274
+ return max(0.0, float(raw))
275
+ except ValueError:
276
+ return default
277
+
278
+
279
+ @dataclass
280
+ class _Progress:
281
+ """Mutable per-session state shared between attempts and the failure handler."""
282
+
283
+ responses: int = 0 # successful generate_content responses received
284
+ last_turn: int | None = None # 0-based index of the turn that last ran
285
+ retried: bool = False
286
+ retry_wait_s: float = 0.0
287
+ # Usage accrued so far, so a session that dies mid-flight still reports it.
288
+ prompt_tokens: int = 0
289
+ cached_tokens: int = 0
290
+ candidates_tokens: int = 0
291
+ thoughts_tokens: int = 0
292
+ tool_use_prompt_tokens: int = 0
293
+ tool_calls: list[str] = field(default_factory=list)
294
+
295
+ @property
296
+ def input_tokens(self) -> int:
297
+ # Gemini's prompt_token_count already includes the cached portion, so
298
+ # subtract it to avoid billing it at both input and cache-read rates.
299
+ # Tool-result tokens are billed as input.
300
+ return max(self.prompt_tokens - self.cached_tokens, 0) + self.tool_use_prompt_tokens
301
+
302
+ @property
303
+ def output_tokens(self) -> int:
304
+ # Thinking tokens are a separate additive bucket billed as output.
305
+ return self.candidates_tokens + self.thoughts_tokens
306
+
307
+ def cost_usd(self, model: str) -> float:
308
+ if self.responses == 0:
309
+ return 0.0
310
+ return estimate_cost_usd(
311
+ model=model,
312
+ input_tokens=self.input_tokens,
313
+ output_tokens=self.output_tokens,
314
+ cached_input_tokens=self.cached_tokens,
315
+ )
316
+
317
+
318
+ def _scrub(text: str) -> str:
319
+ """Redact anything key-shaped and truncate for logging."""
320
+ text = _SECRET_RE.sub("***", text).replace("\n", " ")
321
+ return text[:_DETAIL_MAX_CHARS]
322
+
323
+
324
+ def _error_code_status(exc: BaseException) -> tuple[int | None, str | None]:
325
+ """Best-effort (HTTP code, status string) for an SDK/transport exception."""
326
+ if isinstance(exc, genai_errors.APIError):
327
+ code = getattr(exc, "code", None)
328
+ status = getattr(exc, "status", None)
329
+ return (
330
+ code if isinstance(code, int) else None,
331
+ status if isinstance(status, str) else None,
332
+ )
333
+ if isinstance(exc, httpx.HTTPStatusError):
334
+ return exc.response.status_code, None
335
+ return None, None
336
+
337
+
338
+ def _is_transient_error(exc: BaseException) -> bool:
339
+ code, status = _error_code_status(exc)
340
+ if code in _TRANSIENT_HTTP_CODES or (status or "").upper() in _TRANSIENT_STATUSES:
341
+ return True
342
+ if isinstance(exc, (genai_errors.APIError, httpx.HTTPStatusError)):
343
+ return False # a definite API answer that is not transient (400/401/403/404...)
344
+ return isinstance(exc, _TRANSIENT_HTTPX)
345
+
346
+
347
+ def _retry_after_seconds(exc: BaseException) -> float | None:
348
+ """Server-suggested delay from a Retry-After header or RetryInfo.retryDelay."""
349
+ response = getattr(exc, "response", None)
350
+ headers = getattr(response, "headers", None)
351
+ try:
352
+ raw = headers.get("retry-after") if headers is not None else None
353
+ if raw is not None:
354
+ return max(0.0, float(raw))
355
+ except (TypeError, ValueError, AttributeError):
356
+ pass
357
+ details = getattr(exc, "details", None)
358
+ err = details.get("error", details) if isinstance(details, dict) else None
359
+ items = err.get("details") if isinstance(err, dict) else None
360
+ for item in items if isinstance(items, list) else []:
361
+ delay = item.get("retryDelay") if isinstance(item, dict) else None
362
+ if isinstance(delay, str) and delay.endswith("s"):
363
+ try:
364
+ return max(0.0, float(delay[:-1]))
365
+ except ValueError:
366
+ pass
367
+ return None
368
+
369
+
370
+ def _is_daily_quota(exc: BaseException, server_delay: float | None) -> bool:
371
+ """True when a 429 reports a per-day quota or a many-minutes retry delay."""
372
+ if server_delay is not None and server_delay > _RETRY_GIVEUP_DELAY_S:
373
+ return True
374
+ return bool(re.search(r"per\s*day|perday", str(exc), re.IGNORECASE))
375
+
376
+
377
+ def _retry_decision(exc: BaseException) -> tuple[str, float] | None:
378
+ """``(kind, wait_seconds)`` if ``exc`` should be retried once, else ``None``.
379
+
380
+ Kinds: ``rate_limit`` (429/RESOURCE_EXHAUSTED, 45-60 s jitter),
381
+ ``server`` (5xx/UNAVAILABLE/DEADLINE_EXCEEDED/INTERNAL) and ``network``
382
+ (5-10 s jitter). A server Retry-After/retryDelay >= 5 s replaces the
383
+ jittered value; the wait never exceeds 60 s. A daily-quota 429 is never
384
+ retried.
385
+ """
386
+ if not _is_transient_error(exc):
387
+ return None
388
+ code, status = _error_code_status(exc)
389
+ if isinstance(exc, genai_errors.ServerError) and code in _HTTP_RETRY_STATUS_CODES:
390
+ return None # the SDK already retried this request; a restart would repeat it
391
+ server_delay = _retry_after_seconds(exc)
392
+ if code == 429 or (status or "").upper() == "RESOURCE_EXHAUSTED":
393
+ if _is_daily_quota(exc, server_delay):
394
+ return None
395
+ kind = "rate_limit"
396
+ lo = _env_float("ARGUS_GEMINI_RETRY_RATE_LIMIT_MIN_S", _RETRY_RATE_LIMIT_MIN_S)
397
+ hi = _env_float("ARGUS_GEMINI_RETRY_RATE_LIMIT_MAX_S", _RETRY_RATE_LIMIT_MAX_S)
398
+ else:
399
+ kind = "server" if code is not None or status else "network"
400
+ lo = _env_float("ARGUS_GEMINI_RETRY_COOLDOWN_MIN_S", _RETRY_SHORT_MIN_S)
401
+ hi = _env_float("ARGUS_GEMINI_RETRY_COOLDOWN_MAX_S", _RETRY_SHORT_MAX_S)
402
+ cap = _env_float("ARGUS_GEMINI_RETRY_MAX_WAIT_S", _RETRY_MAX_WAIT_S)
403
+ wait = random.uniform(lo, max(lo, hi)) # noqa: S311 - jitter, not security
404
+ if server_delay is not None and server_delay >= _RETRY_AFTER_MIN_S:
405
+ wait = server_delay
406
+ return kind, min(wait, cap)
407
+
408
+
409
+ def _failure_detail(exc: BaseException) -> str:
410
+ """Short, key-free label such as ``429 RESOURCE_EXHAUSTED`` or ``ConnectError``."""
411
+ code, status = _error_code_status(exc)
412
+ detail = " ".join(p for p in (str(code) if code is not None else "", status or "") if p)
413
+ detail = detail or type(exc).__name__
414
+ if (code == 429 or (status or "").upper() == "RESOURCE_EXHAUSTED") and _is_daily_quota(
415
+ exc, _retry_after_seconds(exc)
416
+ ):
417
+ detail += " (daily quota)"
418
+ return detail
419
+
420
+
211
421
  # Matches the fenced-json extraction argus.helpers.parse_review_result
212
422
  # already applies for the Claude path.
213
423
  _FENCED_JSON_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
@@ -599,6 +809,7 @@ async def _run_turns(
599
809
  repo_root: str,
600
810
  timeout_s: float,
601
811
  started_at: datetime,
812
+ progress: _Progress,
602
813
  ) -> SessionResult:
603
814
  """The actual tool-calling loop. Returns ``failure_reason="turn_budget_exhausted"``
604
815
  when the loop ran out of turns without a ``finish_review`` call, else
@@ -665,6 +876,16 @@ async def _run_turns(
665
876
  label=label,
666
877
  )
667
878
 
879
+ # Retry only model calls, not detached synchronous cache creation.
880
+ request_http_options = types.HttpOptions(
881
+ retry_options=types.HttpRetryOptions(
882
+ attempts=_HTTP_RETRY_ATTEMPTS,
883
+ initial_delay=_HTTP_RETRY_INITIAL_DELAY_S,
884
+ max_delay=_HTTP_RETRY_MAX_DELAY_S,
885
+ http_status_codes=list(_HTTP_RETRY_STATUS_CODES),
886
+ )
887
+ )
888
+
668
889
  def _build_config(cache_name: str | None) -> types.GenerateContentConfig:
669
890
  # Gemini constraint (confirmed against the live API, not just
670
891
  # documentation): a request using `cached_content=` must NOT
@@ -678,11 +899,14 @@ async def _run_turns(
678
899
  # `CreateCachedContentConfig.tool_config`), so the cached
679
900
  # branch here sends `cached_content` alone.
680
901
  if cache_name:
681
- return types.GenerateContentConfig(cached_content=cache_name)
902
+ return types.GenerateContentConfig(
903
+ cached_content=cache_name, http_options=request_http_options
904
+ )
682
905
  return types.GenerateContentConfig(
683
906
  system_instruction=system_prompt,
684
907
  tools=gemini_tools,
685
908
  tool_config=_FORCE_TOOL_CONFIG,
909
+ http_options=request_http_options,
686
910
  )
687
911
 
688
912
  config = _build_config(cached_content_name)
@@ -691,19 +915,14 @@ async def _run_turns(
691
915
  types.Content(role="user", parts=[types.Part.from_text(text=user_message)])
692
916
  ]
693
917
 
694
- tool_calls: list[str] = []
695
918
  files_explored: list[str] = []
696
919
  exhausted = False
697
- usage_prompt_total = 0
698
- usage_candidates_total = 0
699
- usage_cached_total = 0
700
- usage_thoughts_total = 0
701
- usage_tool_use_prompt_total = 0
702
920
 
703
921
  _mid_budget_nudge_turn = _compute_mid_budget_nudge_turn(_MAX_TURNS_GEMINI)
704
922
 
705
923
  with review_tools.review_session(repo_root) as findings_sink:
706
924
  for _turn in range(_MAX_TURNS_GEMINI):
925
+ progress.last_turn = _turn
707
926
  if (
708
927
  _turn == _MAX_TURNS_GEMINI - _NUDGE_TURNS_BEFORE_BUDGET
709
928
  and contents[-1].parts is not None
@@ -736,8 +955,7 @@ async def _run_turns(
736
955
  entry.role if entry else "unknown",
737
956
  model,
738
957
  _turn,
739
- exc,
740
- exc_info=True,
958
+ _scrub(str(exc)),
741
959
  )
742
960
  raise
743
961
  logger.warning(
@@ -760,21 +978,16 @@ async def _run_turns(
760
978
  model=model, contents=contents, config=config
761
979
  )
762
980
 
981
+ progress.responses += 1
763
982
  usage = response.usage_metadata
764
983
  if usage is not None:
765
- usage_prompt_total += usage.prompt_token_count or 0
766
- usage_candidates_total += usage.candidates_token_count or 0
767
- usage_cached_total += usage.cached_content_token_count or 0
768
- # thoughts_token_count (thinking-mode output) and
769
- # tool_use_prompt_token_count (tool-result tokens fed
770
- # back to the model) are both billable and both
771
- # separate additive buckets, per
772
- # GenerateContentResponseUsageMetadata's own docstring
773
- # (total_token_count = prompt + candidates +
774
- # tool_use_prompt + thoughts) -- NOT already folded
775
- # into prompt_token_count/candidates_token_count.
776
- usage_thoughts_total += usage.thoughts_token_count or 0
777
- usage_tool_use_prompt_total += usage.tool_use_prompt_token_count or 0
984
+ # thoughts and tool-use prompt tokens are billable, separate
985
+ # additive buckets (not folded into prompt/candidates counts).
986
+ progress.prompt_tokens += usage.prompt_token_count or 0
987
+ progress.candidates_tokens += usage.candidates_token_count or 0
988
+ progress.cached_tokens += usage.cached_content_token_count or 0
989
+ progress.thoughts_tokens += usage.thoughts_token_count or 0
990
+ progress.tool_use_prompt_tokens += usage.tool_use_prompt_token_count or 0
778
991
 
779
992
  function_calls = response.function_calls or []
780
993
  if not function_calls:
@@ -828,7 +1041,7 @@ async def _run_turns(
828
1041
  for call in function_calls:
829
1042
  name = call.name or ""
830
1043
  args = dict(call.args or {})
831
- tool_calls.append(name)
1044
+ progress.tool_calls.append(name)
832
1045
  output, is_error = await asyncio.to_thread(_execute_tool_call, name, args)
833
1046
  if is_error and name != "finish_review":
834
1047
  logger.warning("Tool call failed [%s]: %s", name, output[:200])
@@ -860,35 +1073,19 @@ async def _run_turns(
860
1073
  result_text = _build_result_text(findings_sink, files_explored)
861
1074
 
862
1075
  finished_at = datetime.now(timezone.utc)
863
- unique_tool_names = sorted(set(tool_calls))
864
-
865
- # Gemini's prompt_token_count already INCLUDES the cached portion
866
- # (unlike Anthropic's fully-separate input/cache-read counters,
867
- # which is the convention estimate_cost_usd's cached_input_tokens
868
- # documents) -- so the cached amount must be subtracted out of the
869
- # uncached input count here, or it would be billed at both the full
870
- # input rate AND the cache-read rate. tool_use_prompt tokens are
871
- # billed at the input rate (added in); thoughts tokens are billed
872
- # at the output rate (added in) -- see the accumulation loop above.
873
- input_tokens = max(usage_prompt_total - usage_cached_total, 0) + usage_tool_use_prompt_total
874
- output_tokens = usage_candidates_total + usage_thoughts_total
875
- cost_usd = estimate_cost_usd(
876
- model=model,
877
- input_tokens=input_tokens,
878
- output_tokens=output_tokens,
879
- cached_input_tokens=usage_cached_total,
880
- )
1076
+ unique_tool_names = sorted(set(progress.tool_calls))
1077
+ cost_usd = progress.cost_usd(model)
881
1078
 
882
- tools_str = f" (tools: {', '.join(unique_tool_names)})" if tool_calls else ""
1079
+ tools_str = f" (tools: {', '.join(unique_tool_names)})" if progress.tool_calls else ""
883
1080
  logger.info(
884
1081
  "Gemini agent done [%s]: %d tool calls%s, cost=$%.4f input_tokens=%d cached_tokens=%d output_tokens=%d",
885
1082
  label or "unlabeled",
886
- len(tool_calls),
1083
+ len(progress.tool_calls),
887
1084
  tools_str,
888
1085
  cost_usd,
889
- input_tokens,
890
- usage_cached_total,
891
- output_tokens,
1086
+ progress.input_tokens,
1087
+ progress.cached_tokens,
1088
+ progress.output_tokens,
892
1089
  )
893
1090
 
894
1091
  return SessionResult(
@@ -897,7 +1094,7 @@ async def _run_turns(
897
1094
  duration_seconds=(finished_at - started_at).total_seconds(),
898
1095
  started_at=started_at,
899
1096
  finished_at=finished_at,
900
- tool_call_count=len(tool_calls),
1097
+ tool_call_count=len(progress.tool_calls),
901
1098
  tool_names=unique_tool_names,
902
1099
  # No Context7 MCP integration in scope for this runner (see module
903
1100
  # docstring) -- always 0, never derived from tool_calls.
@@ -922,6 +1119,35 @@ async def _run_turns(
922
1119
  )
923
1120
 
924
1121
 
1122
+ async def _run_turns_with_retry(
1123
+ *, progress: _Progress, timeout_s: float, **kwargs: Any
1124
+ ) -> SessionResult:
1125
+ """``_run_turns`` with at most one retry of a transient turn-0 failure."""
1126
+ label = kwargs.get("label") or "unlabeled"
1127
+ t0 = asyncio.get_running_loop().time()
1128
+ try:
1129
+ return await _run_turns(timeout_s=timeout_s, progress=progress, **kwargs)
1130
+ except (genai_errors.APIError, httpx.HTTPError) as exc:
1131
+ remaining = timeout_s - (asyncio.get_running_loop().time() - t0)
1132
+ decision = _retry_decision(exc) if progress.responses == 0 else None
1133
+ if decision is None or remaining < timeout_s * _RETRY_MIN_REMAINING_FRACTION:
1134
+ raise
1135
+ kind, wait = decision
1136
+ if wait >= remaining:
1137
+ raise
1138
+ progress.retried = True
1139
+ progress.retry_wait_s = wait
1140
+ logger.info(
1141
+ "gemini session [%s] retrying after %s (kind=%s) in %.1fs",
1142
+ label,
1143
+ _failure_detail(exc),
1144
+ kind,
1145
+ wait,
1146
+ )
1147
+ await asyncio.sleep(wait)
1148
+ return await _run_turns(timeout_s=timeout_s, progress=progress, **kwargs)
1149
+
1150
+
925
1151
  def _redact_gemini_inputs(inputs: dict[str, Any], **_: Any) -> dict[str, Any]:
926
1152
  """LangSmith ``process_inputs`` hook: redact credentials from Settings."""
927
1153
  if not isinstance(inputs, dict):
@@ -988,9 +1214,11 @@ async def run_session_gemini(
988
1214
  )
989
1215
 
990
1216
  started_at = datetime.now(timezone.utc)
1217
+ progress = _Progress()
991
1218
  try:
992
1219
  return await asyncio.wait_for(
993
- _run_turns(
1220
+ _run_turns_with_retry(
1221
+ progress=progress,
994
1222
  entry=entry,
995
1223
  model=model,
996
1224
  system_prompt=system_prompt,
@@ -1006,19 +1234,21 @@ async def run_session_gemini(
1006
1234
  except (TimeoutError, httpx.TimeoutException) as exc:
1007
1235
  finished_at = datetime.now(timezone.utc)
1008
1236
  logger.warning(
1009
- "Gemini session [%s] timed out after %ss (%s)",
1237
+ "Gemini session [%s] timed out after %ss (%s), %d turn(s) and %d tool call(s) done",
1010
1238
  label or "unlabeled",
1011
1239
  effective_timeout_s,
1012
1240
  type(exc).__name__,
1241
+ progress.responses,
1242
+ len(progress.tool_calls),
1013
1243
  )
1014
1244
  return SessionResult(
1015
1245
  result_text="",
1016
- cost_usd=0.0,
1246
+ cost_usd=progress.cost_usd(model),
1017
1247
  duration_seconds=(finished_at - started_at).total_seconds(),
1018
1248
  started_at=started_at,
1019
1249
  finished_at=finished_at,
1020
- tool_call_count=0,
1021
- tool_names=[],
1250
+ tool_call_count=len(progress.tool_calls),
1251
+ tool_names=sorted(set(progress.tool_calls)),
1022
1252
  context7_call_count=0,
1023
1253
  model=model,
1024
1254
  failure_reason="timeout",
@@ -1057,22 +1287,28 @@ async def run_session_gemini(
1057
1287
  # identically here, even though this runner has no literal
1058
1288
  # subprocess "worker" the way the Claude Agent SDK path does.
1059
1289
  finished_at = datetime.now(timezone.utc)
1290
+ detail = _failure_detail(exc)
1291
+ if progress.last_turn is not None:
1292
+ detail = f"{detail}, turn {progress.last_turn}"
1060
1293
  logger.warning(
1061
- "Gemini session [%s] failed with a non-timeout error after %.1fs (%s: %s)",
1294
+ "Gemini session [%s] failed after %.1fs (%s, %s%s): %s",
1062
1295
  label or "unlabeled",
1063
1296
  (finished_at - started_at).total_seconds(),
1064
1297
  type(exc).__name__,
1065
- exc,
1298
+ detail,
1299
+ ", after one retry" if progress.retried else "",
1300
+ _scrub(str(exc)),
1066
1301
  )
1067
1302
  return SessionResult(
1068
1303
  result_text="",
1069
- cost_usd=0.0,
1304
+ cost_usd=progress.cost_usd(model),
1070
1305
  duration_seconds=(finished_at - started_at).total_seconds(),
1071
1306
  started_at=started_at,
1072
1307
  finished_at=finished_at,
1073
- tool_call_count=0,
1074
- tool_names=[],
1308
+ tool_call_count=len(progress.tool_calls),
1309
+ tool_names=sorted(set(progress.tool_calls)),
1075
1310
  context7_call_count=0,
1076
1311
  model=model,
1077
1312
  failure_reason="worker_crashed",
1313
+ failure_detail=detail,
1078
1314
  )
@@ -11,6 +11,8 @@ import re
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
+ import httpx
15
+
14
16
  from argus.models import Finding, ReviewResponse, RiskLevel, Severity, Verdict
15
17
  from argus.pipeline_models import RawFinding, SystemReviewResult
16
18
 
@@ -145,6 +147,31 @@ def collect_reviewed_files(results: list[SystemReviewResult]) -> set[str]:
145
147
  return reviewed
146
148
 
147
149
 
150
+ def _concrete_subclass_names(base: type[BaseException]) -> set[str]:
151
+ names = {base.__name__}
152
+ for sub in base.__subclasses__():
153
+ names |= _concrete_subclass_names(sub)
154
+ return names
155
+
156
+
157
+ # Only canonical provider codes/statuses, HTTP reason phrases and transport
158
+ # exception names may be rendered into reports, never arbitrary exception text
159
+ # or payloads. Transport names come from httpx itself so they cannot drift.
160
+ _TRANSPORT_ERROR_NAMES = sorted(
161
+ _concrete_subclass_names(httpx.TransportError) | {"HTTPStatusError"}
162
+ )
163
+ _CANONICAL_FAILURE_DETAIL_RE = re.compile(
164
+ r"(?:[1-5][0-9]{2}(?: (?:INTERNAL|UNAVAILABLE|RESOURCE_EXHAUSTED|"
165
+ r"DEADLINE_EXCEEDED|UNAUTHENTICATED|PERMISSION_DENIED|INVALID_ARGUMENT|"
166
+ r"NOT_FOUND|FAILED_PRECONDITION|UNKNOWN|CANCELLED|ABORTED|OUT_OF_RANGE|"
167
+ r"UNIMPLEMENTED|DATA_LOSS|"
168
+ # HTTP reason phrase (non-JSON error body), e.g. "Bad Gateway"; <= 40 chars.
169
+ r"(?=[A-Za-z ]{1,40}(?![A-Za-z ]))[A-Z][a-z]+(?: [A-Z][a-z]+)*))?|"
170
+ r"(?:" + "|".join(_TRANSPORT_ERROR_NAMES) + r"|ServerError|ClientError|APIError))"
171
+ r"(?: \(daily quota\))?(?:, turn [0-9]+)?"
172
+ )
173
+
174
+
148
175
  def failed_reviewer_labels(results: list[SystemReviewResult]) -> list[tuple[str, str]]:
149
176
  """Return ``(system_group, failure_reason)`` pairs for reviewers that did
150
177
  not complete normally, in the order they appear in ``results``.
@@ -159,10 +186,13 @@ def failed_reviewer_labels(results: list[SystemReviewResult]) -> list[tuple[str,
159
186
  """
160
187
  labels: list[tuple[str, str]] = []
161
188
  for result in results:
162
- reason = result.failure_reason
189
+ reason: str | None = result.failure_reason
163
190
  if reason is None and result.timed_out:
164
191
  reason = "timeout"
165
192
  if reason is not None:
193
+ detail = result.failure_detail
194
+ if detail and _CANONICAL_FAILURE_DETAIL_RE.fullmatch(detail):
195
+ reason = f"{reason}: {detail}"
166
196
  labels.append((result.system_group, reason))
167
197
  return labels
168
198
 
@@ -272,15 +302,16 @@ def build_degraded_coverage_findings(
272
302
  """
273
303
  findings: list[Finding] = []
274
304
  for label, reason in failed_labels:
305
+ reason_code, _, detail = reason.partition(": ")
275
306
  is_precheck = label.startswith("precheck:")
276
307
  subject = "Precheck scanner" if is_precheck else "Reviewer session"
277
- is_missing = reason == _SCANNER_NOT_INSTALLED_REASON
308
+ is_missing = reason_code == _SCANNER_NOT_INSTALLED_REASON
278
309
  # Distinct wording from a crash/timeout: the reviewer was alive and
279
310
  # exploring, it just never reached finish_review.
280
- is_exhausted = reason == "turn_budget_exhausted"
311
+ is_exhausted = reason_code == "turn_budget_exhausted"
281
312
  if is_missing:
282
313
  reason_desc = "is not installed"
283
- elif reason == "timeout":
314
+ elif reason_code == "timeout":
284
315
  reason_desc = "timed out"
285
316
  elif is_exhausted:
286
317
  reason_desc = "ran out of turns before finishing"
@@ -302,16 +333,22 @@ def build_degraded_coverage_findings(
302
333
  f"Re-run the review or manually inspect the changes in '{label}' "
303
334
  f"to ensure potential issues were not missed due to {reason}."
304
335
  )
336
+ if detail:
337
+ # Cause first: API/MCP summaries may clip at 200 chars, and the
338
+ # area name is unbounded model output.
339
+ description = f"{subject} {reason_desc}. Area: '{label}'. Coverage is degraded."
340
+ else:
341
+ description = (
342
+ f"{subject} '{label}' {reason_desc} and produced no findings. "
343
+ "Coverage for this area is degraded."
344
+ )
305
345
  findings.append(
306
346
  Finding(
307
347
  severity=Severity.SUGGESTION,
308
348
  category="coverage-gap",
309
349
  file=None,
310
350
  line=None,
311
- description=(
312
- f"{subject} '{label}' {reason_desc} and produced no findings. "
313
- "Coverage for this area is degraded."
314
- ),
351
+ description=description,
315
352
  suggestion=suggestion,
316
353
  )
317
354
  )
@@ -168,6 +168,14 @@ class SystemReviewResult(BaseModel):
168
168
  default=False,
169
169
  description="True when failure_reason == 'timeout'; kept for back-compat.",
170
170
  )
171
+ failure_detail: str | None = Field(
172
+ default=None,
173
+ description=(
174
+ "Short, key-free cause for failure_reason (e.g. '503 UNAVAILABLE, turn 45'), "
175
+ "copied from the runner's SessionResult. Report/degraded-coverage text only -- "
176
+ "not persisted to agent_runs and never parsed for control flow."
177
+ ),
178
+ )
171
179
 
172
180
  @model_validator(mode="before")
173
181
  @classmethod