argus-code-review 0.3.1__tar.gz → 0.3.3__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.
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/CHANGELOG.md +17 -1
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/PKG-INFO +3 -1
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/README.md +2 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/cli.py +19 -2
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/config.py +21 -1
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/dotenv_utils.py +20 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/gemini_runner.py +210 -5
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/graph.py +10 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/runners.py +3 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/scope.py +7 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_cli_args.py +23 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_cli_preflight.py +1 -0
- argus_code_review-0.3.3/tests/test_dotenv_utils.py +93 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_gemini_runner.py +240 -1
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_scope_gate.py +16 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/.gitignore +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/LICENSE +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/__init__.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/bench.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/bench_default.toml +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/coverage.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/gemini_cache.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/github_client.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/helpers.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/llm/models.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/llm/output_models.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/llm/pricing.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/llm/usage.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/models.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/openai_client.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/openai_runner.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/pipeline_models.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/__init__.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/actions_scanner.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/engine.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/.gitignore +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/README.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/eslint.config.js +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/package-lock.json +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/eslint_bundle/package.json +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/js_scanner.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/migration_scanner.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/rules/README.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/sarif.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/scanner_utils.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/secrets_scanner.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/shadow.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/terraform_scanner.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/precheck/workflow_lint_scanner.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/__init__.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-blocking-validator.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-coverage-check.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-cross-cutting.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-feedback-verifier.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-lite.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-planner.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-preflight-router.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-prior-art.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-scope.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-deployment.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-frontend.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-infra.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-llm-patterns.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-observability.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-orchestration.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-security.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-slackbot.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-specialist-sql.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-subagent.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-tests-and-docs.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts/pr-review-writer.md +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/prompts_runtime.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/repo_provision.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/review_tools.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/storage/__init__.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/storage/http.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/storage/models.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/storage/precheck.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/storage/resolver.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/storage/session.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/storage/sql.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/argus/storage/sqlite.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/pyproject.toml +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/schema/008_add_code_reviews.sql +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/schema/009_add_reviewer_version.sql +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/schema/010_add_review_patterns.sql +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/schema/011_add_review_progress_columns.sql +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/schema/015_create_agent_runs.sql +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/schema/016_add_agent_runs_failure_reason.sql +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/schema/017_add_precheck_rules.sql +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/schema/018_widen_agent_runs_failure_reason.sql +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/__init__.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/conftest.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/golden/review_response.schema.json +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/storage/__init__.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/storage/test_backend_contract.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/storage/test_http.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/storage/test_models.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/storage/test_resolver.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/storage/test_session.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/storage/test_sql.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/storage/test_sqlite_backend.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_actions_scanner.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_argus_review_local.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_bench.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_bench_config_guard.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_catchup_gate.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_cli_output_contract.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_cli_post_review.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_cli_prompts.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_config.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_conftest.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_gemini_cache.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_github_client_checks_signal.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_github_client_write.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_graph_fetch_diff.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_graph_get_llm_temperature.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_graph_http_guards.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_graph_precheck.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_graph_preflight_image_bump.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_graph_progress.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_graph_storage_resolution.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_graph_timeout_surfacing.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_js_scanner.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_llm_models.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_llm_models_override.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_llm_pricing.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_migration_scanner.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_models.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_multi_round.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_openai_client.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_openai_runner.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_output_models.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_packaging.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_plan_review.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_precheck_engine.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_precheck_engine_integration.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_precheck_sarif.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_precheck_shadow.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_precheck_shadow_integration.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_prompts.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_repo_provision.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_review_patterns_integration.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_review_tools.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_runners_context7.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_runners_context_usage.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_runners_helpers.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_runners_new.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_runners_turn_budget.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_scanner_utils.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_scope.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_sdk_bundled_cli_version.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_secrets_scanner.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_specialist_validation.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_stage_costs.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_storage_precheck.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_structured_output_methods.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_terraform_scanner.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_terraform_scanner_integration.py +0 -0
- {argus_code_review-0.3.1 → argus_code_review-0.3.3}/tests/test_workflow_lint_scanner.py +0 -0
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.3] - 2026-10-22
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- 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`.
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
## [0.3.2] - 2026-10-21
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- CLI `--expect-head-sha <sha>`: the review fails before any LLM spend unless the resolved head commit equals this 40-character lowercase SHA, so a service that reviews a specific commit never silently reviews a newer one if the PR head moves (TECH-7789). Also available as `run_review(..., expect_head_sha=...)`.
|
|
22
|
+
- `ARGUS_NO_DOTENV` (truthy): stop all `.env` loading. It disables both `load_dotenv_early` and the `Settings` `env_file`, so a `.env` in the working directory cannot configure the process. For service workers that review untrusted checkouts, alongside the prompt-override and bench-override switches. Enforced on `Settings` itself, so every `Settings()` construction honors it.
|
|
23
|
+
|
|
10
24
|
## [0.3.1] - 2026-10-19
|
|
11
25
|
|
|
12
26
|
### Fixed
|
|
@@ -434,7 +448,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
434
448
|
packaged set.
|
|
435
449
|
- `argus --version`, `argus prompts list`, and `argus prompts export`.
|
|
436
450
|
|
|
437
|
-
[Unreleased]: https://github.com/redesignhealth/argus-review/compare/v0.3.
|
|
451
|
+
[Unreleased]: https://github.com/redesignhealth/argus-review/compare/v0.3.3...HEAD
|
|
452
|
+
[0.3.3]: https://github.com/redesignhealth/argus-review/compare/v0.3.2...v0.3.3
|
|
453
|
+
[0.3.2]: https://github.com/redesignhealth/argus-review/compare/v0.3.1...v0.3.2
|
|
438
454
|
[0.3.1]: https://github.com/redesignhealth/argus-review/compare/v0.3.0...v0.3.1
|
|
439
455
|
[0.3.0]: https://github.com/redesignhealth/argus-review/compare/v0.2.11...v0.3.0
|
|
440
456
|
[0.2.11]: https://github.com/redesignhealth/argus-review/compare/v0.2.10...v0.2.11
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: argus-code-review
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
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
|
|
@@ -280,6 +280,8 @@ Argus reads configuration from environment variables, or from a `.env` file
|
|
|
280
280
|
in the working directory (add it to `.gitignore` — never commit it).
|
|
281
281
|
[`.env.example`](.env.example) lists every variable Argus reads, required
|
|
282
282
|
and optional, with comments — copy it to `.env` and fill in what you need.
|
|
283
|
+
Set `ARGUS_NO_DOTENV=1` in the real environment to ignore `.env` files
|
|
284
|
+
entirely (for service workers that run in a directory they do not control).
|
|
283
285
|
|
|
284
286
|
Three keys are required for a first run:
|
|
285
287
|
|
|
@@ -229,6 +229,8 @@ Argus reads configuration from environment variables, or from a `.env` file
|
|
|
229
229
|
in the working directory (add it to `.gitignore` — never commit it).
|
|
230
230
|
[`.env.example`](.env.example) lists every variable Argus reads, required
|
|
231
231
|
and optional, with comments — copy it to `.env` and fill in what you need.
|
|
232
|
+
Set `ARGUS_NO_DOTENV=1` in the real environment to ignore `.env` files
|
|
233
|
+
entirely (for service workers that run in a directory they do not control).
|
|
232
234
|
|
|
233
235
|
Three keys are required for a first run:
|
|
234
236
|
|
|
@@ -70,6 +70,8 @@ import time
|
|
|
70
70
|
from pathlib import Path
|
|
71
71
|
from typing import TYPE_CHECKING, Final
|
|
72
72
|
|
|
73
|
+
from argus.dotenv_utils import TRUTHY_STRINGS
|
|
74
|
+
|
|
73
75
|
if TYPE_CHECKING:
|
|
74
76
|
from argus.config import Settings
|
|
75
77
|
from argus.models import ReviewRequest, ReviewResponse
|
|
@@ -79,6 +81,7 @@ logging.basicConfig(
|
|
|
79
81
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
80
82
|
datefmt="%H:%M:%S",
|
|
81
83
|
)
|
|
84
|
+
|
|
82
85
|
logger = logging.getLogger("argus_review_local")
|
|
83
86
|
|
|
84
87
|
# Hard wall-clock backstop: if the entire review has not completed within this
|
|
@@ -229,6 +232,7 @@ async def run(
|
|
|
229
232
|
request: "ReviewRequest",
|
|
230
233
|
review_override_sha: str | None = None,
|
|
231
234
|
review_override_reason: str | None = None,
|
|
235
|
+
expect_head_sha: str | None = None,
|
|
232
236
|
) -> "ReviewResponse":
|
|
233
237
|
"""Run the review pipeline (storage backend resolved automatically:
|
|
234
238
|
Postgres if ``ARGUS_DB_URL`` is set, HTTP shim if the storage URLs are
|
|
@@ -240,6 +244,7 @@ async def run(
|
|
|
240
244
|
flow_run_id=None,
|
|
241
245
|
review_override_sha=review_override_sha,
|
|
242
246
|
review_override_reason=review_override_reason,
|
|
247
|
+
expect_head_sha=expect_head_sha,
|
|
243
248
|
)
|
|
244
249
|
|
|
245
250
|
|
|
@@ -363,6 +368,16 @@ def _add_review_args(parser: argparse.ArgumentParser) -> None:
|
|
|
363
368
|
default=None,
|
|
364
369
|
help="Reason for --review-override-sha (1-500 characters); recorded in the scope receipt.",
|
|
365
370
|
)
|
|
371
|
+
parser.add_argument(
|
|
372
|
+
"--expect-head-sha",
|
|
373
|
+
dest="expect_head_sha",
|
|
374
|
+
default=None,
|
|
375
|
+
help=(
|
|
376
|
+
"Fail before any review spend unless the resolved head commit equals this "
|
|
377
|
+
"40-character lowercase SHA. For services that review a specific commit and "
|
|
378
|
+
"must not silently review a newer one if the PR head moved."
|
|
379
|
+
),
|
|
380
|
+
)
|
|
366
381
|
parser.add_argument(
|
|
367
382
|
"--no-prompt-overrides",
|
|
368
383
|
action="store_true",
|
|
@@ -483,10 +498,11 @@ def _validate_review_args(parser: argparse.ArgumentParser, args: argparse.Namesp
|
|
|
483
498
|
"""Cross-flag validation that argparse's own declarative options can't express."""
|
|
484
499
|
if args.post and args.sha and not args.pr:
|
|
485
500
|
parser.error("--post requires --pr (a PR to comment on); --sha alone has nowhere to post")
|
|
486
|
-
from argus.scope import validate_override
|
|
501
|
+
from argus.scope import validate_expect_head_sha, validate_override
|
|
487
502
|
|
|
488
503
|
try:
|
|
489
504
|
validate_override(args.review_override_sha, args.review_override_reason)
|
|
505
|
+
validate_expect_head_sha(args.expect_head_sha)
|
|
490
506
|
except ValueError as exc:
|
|
491
507
|
parser.error(str(exc))
|
|
492
508
|
|
|
@@ -676,6 +692,7 @@ def _run_review(parser: argparse.ArgumentParser, args: argparse.Namespace) -> No
|
|
|
676
692
|
request,
|
|
677
693
|
review_override_sha=args.review_override_sha,
|
|
678
694
|
review_override_reason=args.review_override_reason,
|
|
695
|
+
expect_head_sha=args.expect_head_sha,
|
|
679
696
|
)
|
|
680
697
|
)
|
|
681
698
|
except HistoryBackendConnectivityError as exc:
|
|
@@ -720,7 +737,7 @@ def _packaged_prompt_names() -> list[str]:
|
|
|
720
737
|
# argus.config.Settings). Kept in sync here so `argus prompts list`/`export`
|
|
721
738
|
# (deliberately Settings-free, see below) treats the env var identically to
|
|
722
739
|
# `argus review`, which resolves it through Settings.
|
|
723
|
-
_PYDANTIC_TRUTHY_STRINGS =
|
|
740
|
+
_PYDANTIC_TRUTHY_STRINGS = TRUTHY_STRINGS
|
|
724
741
|
|
|
725
742
|
|
|
726
743
|
def _is_truthy_env_value(value: str) -> bool:
|
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
from functools import lru_cache
|
|
11
11
|
|
|
12
12
|
from pydantic import Field
|
|
13
|
-
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
|
+
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
|
|
14
14
|
|
|
15
15
|
# Single source of truth for the reviewer-subprocess wall-clock budget.
|
|
16
16
|
# ``argus.runners._SUBPROCESS_TIMEOUT_S`` (the fallback used when no Settings
|
|
@@ -209,6 +209,26 @@ class Settings(BaseSettings):
|
|
|
209
209
|
env_file_encoding="utf-8",
|
|
210
210
|
)
|
|
211
211
|
|
|
212
|
+
@classmethod
|
|
213
|
+
def settings_customise_sources(
|
|
214
|
+
cls,
|
|
215
|
+
settings_cls: type[BaseSettings],
|
|
216
|
+
init_settings: PydanticBaseSettingsSource,
|
|
217
|
+
env_settings: PydanticBaseSettingsSource,
|
|
218
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
219
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
220
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
221
|
+
"""Drop the ``.env`` source when ``ARGUS_NO_DOTENV`` is truthy.
|
|
222
|
+
|
|
223
|
+
Enforced here, on the class, so every ``Settings()`` construction honors the
|
|
224
|
+
kill switch, not just the cached accessor.
|
|
225
|
+
"""
|
|
226
|
+
from argus.dotenv_utils import dotenv_disabled
|
|
227
|
+
|
|
228
|
+
if dotenv_disabled():
|
|
229
|
+
return (init_settings, env_settings, file_secret_settings)
|
|
230
|
+
return (init_settings, env_settings, dotenv_settings, file_secret_settings)
|
|
231
|
+
|
|
212
232
|
@property
|
|
213
233
|
def db_url(self) -> str | None:
|
|
214
234
|
"""Resolved Postgres URL: ``ARGUS_DB_URL`` wins over ``SUPABASE_DB_URL``."""
|
|
@@ -13,6 +13,22 @@ import os
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
# The strings pydantic treats as True for a bool setting; shared with the CLI so the
|
|
17
|
+
# two truthy checks cannot drift.
|
|
18
|
+
TRUTHY_STRINGS = frozenset({"1", "true", "t", "yes", "y", "on"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def dotenv_disabled() -> bool:
|
|
22
|
+
"""True when ``ARGUS_NO_DOTENV`` is truthy.
|
|
23
|
+
|
|
24
|
+
Official/service runs review untrusted checkouts from a working directory
|
|
25
|
+
that may contain a ``.env``; this switch stops every ``.env`` load (both
|
|
26
|
+
:func:`load_dotenv_early` and the pydantic ``Settings`` env_file) so the
|
|
27
|
+
process environment is the only configuration source.
|
|
28
|
+
"""
|
|
29
|
+
return os.environ.get("ARGUS_NO_DOTENV", "").strip().lower() in TRUTHY_STRINGS
|
|
30
|
+
|
|
31
|
+
|
|
16
32
|
def _preferred_env_filename() -> str:
|
|
17
33
|
"""Pick the default env filename based on ENVIRONMENT."""
|
|
18
34
|
env = (os.environ.get("ENVIRONMENT") or "").lower()
|
|
@@ -61,11 +77,15 @@ def load_dotenv_early(
|
|
|
61
77
|
"""Load dotenv file early (before importing settings/clients).
|
|
62
78
|
|
|
63
79
|
This is a no-op when:
|
|
80
|
+
- ``ARGUS_NO_DOTENV`` is truthy, or
|
|
64
81
|
- running in AWS Lambda (secrets are expected to come from SSM/env), or
|
|
65
82
|
- running in pytest and `skip_in_test=True`, or
|
|
66
83
|
- python-dotenv isn't installed, or
|
|
67
84
|
- no env file can be found.
|
|
68
85
|
"""
|
|
86
|
+
if dotenv_disabled():
|
|
87
|
+
return None
|
|
88
|
+
|
|
69
89
|
# Never load local dotenv inside Lambda.
|
|
70
90
|
if os.environ.get("AWS_LAMBDA_FUNCTION_NAME"):
|
|
71
91
|
return None
|
|
@@ -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
|
|
148
151
|
from datetime import datetime, timezone
|
|
149
152
|
from typing import Any
|
|
150
153
|
|
|
@@ -208,6 +211,171 @@ _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
|
+
# retried. The cooldown depends on the error kind (see `_retry_decision`):
|
|
223
|
+
# 45-60 s jitter for 429/RESOURCE_EXHAUSTED (per-minute quota windows), 5-10 s
|
|
224
|
+
# for 5xx/network errors; a server Retry-After/retryDelay >= 5 s is honored;
|
|
225
|
+
# never more than 60 s. Daily-quota 429s are not retried. Overridable via
|
|
226
|
+
# ARGUS_GEMINI_RETRY_RATE_LIMIT_{MIN,MAX}_S, ARGUS_GEMINI_RETRY_COOLDOWN_{MIN,MAX}_S
|
|
227
|
+
# and ARGUS_GEMINI_RETRY_MAX_WAIT_S (read per call, so tests can set them to 0).
|
|
228
|
+
# The 60 s worst case is far inside ARGUS_SESSION_TIMEOUT (default 1500 s), and
|
|
229
|
+
# the retry is skipped if <25% of that timeout remains.
|
|
230
|
+
_RETRY_RATE_LIMIT_MIN_S = 45.0 # 429 / RESOURCE_EXHAUSTED: per-minute windows
|
|
231
|
+
_RETRY_RATE_LIMIT_MAX_S = 60.0
|
|
232
|
+
_RETRY_SHORT_MIN_S = 5.0 # 5xx / UNAVAILABLE / DEADLINE_EXCEEDED / network
|
|
233
|
+
_RETRY_SHORT_MAX_S = 10.0
|
|
234
|
+
_RETRY_MAX_WAIT_S = 60.0 # hard cap on any single wait, incl. a server Retry-After
|
|
235
|
+
_RETRY_AFTER_MIN_S = 5.0 # a server delay below this is ignored (jitter used)
|
|
236
|
+
# A server-suggested delay above this means the quota window is not a
|
|
237
|
+
# per-minute one (minutes/hours): fail fast rather than retry.
|
|
238
|
+
_RETRY_GIVEUP_DELAY_S = 120.0
|
|
239
|
+
# Do not retry when less than this fraction of the session timeout remains
|
|
240
|
+
# (e.g. the first attempt already burned its HTTP timeout).
|
|
241
|
+
_RETRY_MIN_REMAINING_FRACTION = 0.25
|
|
242
|
+
_TRANSIENT_HTTP_CODES = frozenset({429, 500, 502, 503, 504})
|
|
243
|
+
_TRANSIENT_STATUSES = frozenset(
|
|
244
|
+
{"RESOURCE_EXHAUSTED", "UNAVAILABLE", "DEADLINE_EXCEEDED", "INTERNAL"}
|
|
245
|
+
)
|
|
246
|
+
_TRANSIENT_HTTPX = (
|
|
247
|
+
httpx.TimeoutException,
|
|
248
|
+
httpx.ConnectError,
|
|
249
|
+
httpx.ReadError,
|
|
250
|
+
httpx.RemoteProtocolError,
|
|
251
|
+
)
|
|
252
|
+
_DETAIL_MAX_CHARS = 300
|
|
253
|
+
_SECRET_RE = re.compile(
|
|
254
|
+
r"(AIza[0-9A-Za-z_\-]{20,}|(?i:(?:api[_-]?key|key|token)=)[^&\s\"']+|"
|
|
255
|
+
r"(?i:bearer\s+)[A-Za-z0-9._\-]+|sk-[A-Za-z0-9_\-]{16,})"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _env_float(name: str, default: float) -> float:
|
|
260
|
+
raw = os.environ.get(name)
|
|
261
|
+
if raw is None or raw == "":
|
|
262
|
+
return default
|
|
263
|
+
try:
|
|
264
|
+
return max(0.0, float(raw))
|
|
265
|
+
except ValueError:
|
|
266
|
+
return default
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass
|
|
270
|
+
class _Progress:
|
|
271
|
+
"""Mutable per-session state shared between attempts and the failure handler."""
|
|
272
|
+
|
|
273
|
+
responses: int = 0 # successful generate_content responses received
|
|
274
|
+
retried: bool = False
|
|
275
|
+
retry_wait_s: float = 0.0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _scrub(text: str) -> str:
|
|
279
|
+
"""Redact anything key-shaped and truncate for logging."""
|
|
280
|
+
text = _SECRET_RE.sub("***", text).replace("\n", " ")
|
|
281
|
+
return text[:_DETAIL_MAX_CHARS]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _error_code_status(exc: BaseException) -> tuple[int | None, str | None]:
|
|
285
|
+
"""Best-effort (HTTP code, status string) for an SDK/transport exception."""
|
|
286
|
+
if isinstance(exc, genai_errors.APIError):
|
|
287
|
+
code = getattr(exc, "code", None)
|
|
288
|
+
status = getattr(exc, "status", None)
|
|
289
|
+
return (
|
|
290
|
+
code if isinstance(code, int) else None,
|
|
291
|
+
status if isinstance(status, str) else None,
|
|
292
|
+
)
|
|
293
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
294
|
+
return exc.response.status_code, None
|
|
295
|
+
return None, None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _is_transient_error(exc: BaseException) -> bool:
|
|
299
|
+
code, status = _error_code_status(exc)
|
|
300
|
+
if code in _TRANSIENT_HTTP_CODES or (status or "").upper() in _TRANSIENT_STATUSES:
|
|
301
|
+
return True
|
|
302
|
+
if isinstance(exc, (genai_errors.APIError, httpx.HTTPStatusError)):
|
|
303
|
+
return False # a definite API answer that is not transient (400/401/403/404...)
|
|
304
|
+
return isinstance(exc, _TRANSIENT_HTTPX)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _retry_after_seconds(exc: BaseException) -> float | None:
|
|
308
|
+
"""Server-suggested delay from a Retry-After header or RetryInfo.retryDelay."""
|
|
309
|
+
response = getattr(exc, "response", None)
|
|
310
|
+
headers = getattr(response, "headers", None)
|
|
311
|
+
try:
|
|
312
|
+
raw = headers.get("retry-after") if headers is not None else None
|
|
313
|
+
if raw is not None:
|
|
314
|
+
return max(0.0, float(raw))
|
|
315
|
+
except (TypeError, ValueError, AttributeError):
|
|
316
|
+
pass
|
|
317
|
+
details = getattr(exc, "details", None)
|
|
318
|
+
err = details.get("error", details) if isinstance(details, dict) else None
|
|
319
|
+
items = err.get("details") if isinstance(err, dict) else None
|
|
320
|
+
for item in items if isinstance(items, list) else []:
|
|
321
|
+
delay = item.get("retryDelay") if isinstance(item, dict) else None
|
|
322
|
+
if isinstance(delay, str) and delay.endswith("s"):
|
|
323
|
+
try:
|
|
324
|
+
return max(0.0, float(delay[:-1]))
|
|
325
|
+
except ValueError:
|
|
326
|
+
pass
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _is_daily_quota(exc: BaseException, server_delay: float | None) -> bool:
|
|
331
|
+
"""True when a 429 reports a per-day quota or a many-minutes retry delay."""
|
|
332
|
+
if server_delay is not None and server_delay > _RETRY_GIVEUP_DELAY_S:
|
|
333
|
+
return True
|
|
334
|
+
return bool(re.search(r"per\s*day|perday", str(exc), re.IGNORECASE))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _retry_decision(exc: BaseException) -> tuple[str, float] | None:
|
|
338
|
+
"""``(kind, wait_seconds)`` if ``exc`` should be retried once, else ``None``.
|
|
339
|
+
|
|
340
|
+
Kinds: ``rate_limit`` (429/RESOURCE_EXHAUSTED, 45-60 s jitter),
|
|
341
|
+
``server`` (5xx/UNAVAILABLE/DEADLINE_EXCEEDED/INTERNAL) and ``network``
|
|
342
|
+
(5-10 s jitter). A server Retry-After/retryDelay >= 5 s replaces the
|
|
343
|
+
jittered value; the wait never exceeds 60 s. A daily-quota 429 is never
|
|
344
|
+
retried.
|
|
345
|
+
"""
|
|
346
|
+
if not _is_transient_error(exc):
|
|
347
|
+
return None
|
|
348
|
+
code, status = _error_code_status(exc)
|
|
349
|
+
server_delay = _retry_after_seconds(exc)
|
|
350
|
+
if code == 429 or (status or "").upper() == "RESOURCE_EXHAUSTED":
|
|
351
|
+
if _is_daily_quota(exc, server_delay):
|
|
352
|
+
return None
|
|
353
|
+
kind = "rate_limit"
|
|
354
|
+
lo = _env_float("ARGUS_GEMINI_RETRY_RATE_LIMIT_MIN_S", _RETRY_RATE_LIMIT_MIN_S)
|
|
355
|
+
hi = _env_float("ARGUS_GEMINI_RETRY_RATE_LIMIT_MAX_S", _RETRY_RATE_LIMIT_MAX_S)
|
|
356
|
+
else:
|
|
357
|
+
kind = "server" if code is not None or status else "network"
|
|
358
|
+
lo = _env_float("ARGUS_GEMINI_RETRY_COOLDOWN_MIN_S", _RETRY_SHORT_MIN_S)
|
|
359
|
+
hi = _env_float("ARGUS_GEMINI_RETRY_COOLDOWN_MAX_S", _RETRY_SHORT_MAX_S)
|
|
360
|
+
cap = _env_float("ARGUS_GEMINI_RETRY_MAX_WAIT_S", _RETRY_MAX_WAIT_S)
|
|
361
|
+
wait = random.uniform(lo, max(lo, hi)) # noqa: S311 - jitter, not security
|
|
362
|
+
if server_delay is not None and server_delay >= _RETRY_AFTER_MIN_S:
|
|
363
|
+
wait = server_delay
|
|
364
|
+
return kind, min(wait, cap)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _failure_detail(exc: BaseException) -> str:
|
|
368
|
+
"""Short, key-free label such as ``429 RESOURCE_EXHAUSTED`` or ``ConnectError``."""
|
|
369
|
+
code, status = _error_code_status(exc)
|
|
370
|
+
detail = " ".join(p for p in (str(code) if code is not None else "", status or "") if p)
|
|
371
|
+
detail = detail or type(exc).__name__
|
|
372
|
+
if (code == 429 or (status or "").upper() == "RESOURCE_EXHAUSTED") and _is_daily_quota(
|
|
373
|
+
exc, _retry_after_seconds(exc)
|
|
374
|
+
):
|
|
375
|
+
detail += " (daily quota)"
|
|
376
|
+
return detail
|
|
377
|
+
|
|
378
|
+
|
|
211
379
|
# Matches the fenced-json extraction argus.helpers.parse_review_result
|
|
212
380
|
# already applies for the Claude path.
|
|
213
381
|
_FENCED_JSON_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
|
|
@@ -599,6 +767,7 @@ async def _run_turns(
|
|
|
599
767
|
repo_root: str,
|
|
600
768
|
timeout_s: float,
|
|
601
769
|
started_at: datetime,
|
|
770
|
+
progress: _Progress | None = None,
|
|
602
771
|
) -> SessionResult:
|
|
603
772
|
"""The actual tool-calling loop. Returns ``failure_reason="turn_budget_exhausted"``
|
|
604
773
|
when the loop ran out of turns without a ``finish_review`` call, else
|
|
@@ -736,8 +905,7 @@ async def _run_turns(
|
|
|
736
905
|
entry.role if entry else "unknown",
|
|
737
906
|
model,
|
|
738
907
|
_turn,
|
|
739
|
-
exc,
|
|
740
|
-
exc_info=True,
|
|
908
|
+
_scrub(str(exc)),
|
|
741
909
|
)
|
|
742
910
|
raise
|
|
743
911
|
logger.warning(
|
|
@@ -760,6 +928,8 @@ async def _run_turns(
|
|
|
760
928
|
model=model, contents=contents, config=config
|
|
761
929
|
)
|
|
762
930
|
|
|
931
|
+
if progress is not None:
|
|
932
|
+
progress.responses += 1
|
|
763
933
|
usage = response.usage_metadata
|
|
764
934
|
if usage is not None:
|
|
765
935
|
usage_prompt_total += usage.prompt_token_count or 0
|
|
@@ -922,6 +1092,35 @@ async def _run_turns(
|
|
|
922
1092
|
)
|
|
923
1093
|
|
|
924
1094
|
|
|
1095
|
+
async def _run_turns_with_retry(
|
|
1096
|
+
*, progress: _Progress, timeout_s: float, **kwargs: Any
|
|
1097
|
+
) -> SessionResult:
|
|
1098
|
+
"""``_run_turns`` with at most one retry of a transient turn-0 failure."""
|
|
1099
|
+
label = kwargs.get("label") or "unlabeled"
|
|
1100
|
+
t0 = asyncio.get_running_loop().time()
|
|
1101
|
+
try:
|
|
1102
|
+
return await _run_turns(timeout_s=timeout_s, progress=progress, **kwargs)
|
|
1103
|
+
except (genai_errors.APIError, httpx.HTTPError) as exc:
|
|
1104
|
+
remaining = timeout_s - (asyncio.get_running_loop().time() - t0)
|
|
1105
|
+
decision = _retry_decision(exc) if progress.responses == 0 else None
|
|
1106
|
+
if decision is None or remaining < timeout_s * _RETRY_MIN_REMAINING_FRACTION:
|
|
1107
|
+
raise
|
|
1108
|
+
kind, wait = decision
|
|
1109
|
+
if wait >= remaining:
|
|
1110
|
+
raise
|
|
1111
|
+
progress.retried = True
|
|
1112
|
+
progress.retry_wait_s = wait
|
|
1113
|
+
logger.info(
|
|
1114
|
+
"gemini session [%s] retrying after %s (kind=%s) in %.1fs",
|
|
1115
|
+
label,
|
|
1116
|
+
_failure_detail(exc),
|
|
1117
|
+
kind,
|
|
1118
|
+
wait,
|
|
1119
|
+
)
|
|
1120
|
+
await asyncio.sleep(wait)
|
|
1121
|
+
return await _run_turns(timeout_s=timeout_s, progress=progress, **kwargs)
|
|
1122
|
+
|
|
1123
|
+
|
|
925
1124
|
def _redact_gemini_inputs(inputs: dict[str, Any], **_: Any) -> dict[str, Any]:
|
|
926
1125
|
"""LangSmith ``process_inputs`` hook: redact credentials from Settings."""
|
|
927
1126
|
if not isinstance(inputs, dict):
|
|
@@ -988,9 +1187,11 @@ async def run_session_gemini(
|
|
|
988
1187
|
)
|
|
989
1188
|
|
|
990
1189
|
started_at = datetime.now(timezone.utc)
|
|
1190
|
+
progress = _Progress()
|
|
991
1191
|
try:
|
|
992
1192
|
return await asyncio.wait_for(
|
|
993
|
-
|
|
1193
|
+
_run_turns_with_retry(
|
|
1194
|
+
progress=progress,
|
|
994
1195
|
entry=entry,
|
|
995
1196
|
model=model,
|
|
996
1197
|
system_prompt=system_prompt,
|
|
@@ -1057,12 +1258,15 @@ async def run_session_gemini(
|
|
|
1057
1258
|
# identically here, even though this runner has no literal
|
|
1058
1259
|
# subprocess "worker" the way the Claude Agent SDK path does.
|
|
1059
1260
|
finished_at = datetime.now(timezone.utc)
|
|
1261
|
+
detail = _failure_detail(exc)
|
|
1060
1262
|
logger.warning(
|
|
1061
|
-
"Gemini session [%s] failed
|
|
1263
|
+
"Gemini session [%s] failed after %.1fs (%s, %s%s): %s",
|
|
1062
1264
|
label or "unlabeled",
|
|
1063
1265
|
(finished_at - started_at).total_seconds(),
|
|
1064
1266
|
type(exc).__name__,
|
|
1065
|
-
|
|
1267
|
+
detail,
|
|
1268
|
+
", after one retry" if progress.retried else "",
|
|
1269
|
+
_scrub(str(exc)),
|
|
1066
1270
|
)
|
|
1067
1271
|
return SessionResult(
|
|
1068
1272
|
result_text="",
|
|
@@ -1075,4 +1279,5 @@ async def run_session_gemini(
|
|
|
1075
1279
|
context7_call_count=0,
|
|
1076
1280
|
model=model,
|
|
1077
1281
|
failure_reason="worker_crashed",
|
|
1282
|
+
failure_detail=detail,
|
|
1078
1283
|
)
|
|
@@ -85,6 +85,7 @@ from argus.models import (
|
|
|
85
85
|
from argus.scope import (
|
|
86
86
|
SCOPE_PROMPT,
|
|
87
87
|
SHA40,
|
|
88
|
+
validate_expect_head_sha,
|
|
88
89
|
ScopeAssessment,
|
|
89
90
|
ScopeUnverified,
|
|
90
91
|
build_scope_input,
|
|
@@ -3605,6 +3606,7 @@ async def run_review(
|
|
|
3605
3606
|
*,
|
|
3606
3607
|
review_override_sha: str | None = None,
|
|
3607
3608
|
review_override_reason: str | None = None,
|
|
3609
|
+
expect_head_sha: str | None = None,
|
|
3608
3610
|
) -> ReviewResponse:
|
|
3609
3611
|
"""Execute the v3 review pipeline via LangGraph StateGraph.
|
|
3610
3612
|
|
|
@@ -3620,6 +3622,7 @@ async def run_review(
|
|
|
3620
3622
|
import uuid
|
|
3621
3623
|
|
|
3622
3624
|
override = validate_override(review_override_sha, review_override_reason)
|
|
3625
|
+
validate_expect_head_sha(expect_head_sha)
|
|
3623
3626
|
|
|
3624
3627
|
# Fail fast on a bad ARGUS_DB_URL / ARGUS_HISTORY_DB_PATH, before any of
|
|
3625
3628
|
# the pipeline's LLM cost runs. Both history-backend implementations
|
|
@@ -3690,6 +3693,13 @@ async def run_review(
|
|
|
3690
3693
|
else:
|
|
3691
3694
|
head_sha_for_worktree = None
|
|
3692
3695
|
|
|
3696
|
+
if expect_head_sha is not None and expect_head_sha != head_sha_for_worktree:
|
|
3697
|
+
raise ValueError(
|
|
3698
|
+
f"expect_head_sha {expect_head_sha[:12]} does not match the resolved head "
|
|
3699
|
+
f"{(head_sha_for_worktree or 'none')[:12]}; the PR head moved, refusing to review "
|
|
3700
|
+
"a different commit"
|
|
3701
|
+
)
|
|
3702
|
+
|
|
3693
3703
|
if override and override[0] != head_sha_for_worktree:
|
|
3694
3704
|
raise ValueError(
|
|
3695
3705
|
f"--review-override-sha {override[0][:12]} does not match the resolved head "
|
|
@@ -572,6 +572,9 @@ class SessionResult:
|
|
|
572
572
|
model: str | None = None
|
|
573
573
|
failure_reason: Literal["timeout", "worker_crashed", "turn_budget_exhausted"] | None = None
|
|
574
574
|
timed_out: bool = False
|
|
575
|
+
# Short, key-free reason detail (e.g. "429 RESOURCE_EXHAUSTED") for logs and
|
|
576
|
+
# diagnostics; not persisted and does not affect failure_reason semantics.
|
|
577
|
+
failure_detail: str | None = None
|
|
575
578
|
|
|
576
579
|
def __post_init__(self) -> None:
|
|
577
580
|
self.timed_out, self.failure_reason = _sync_timed_out_and_failure_reason(
|
|
@@ -50,6 +50,13 @@ class ScopeAssessment(BaseModel):
|
|
|
50
50
|
rationale: str
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def validate_expect_head_sha(sha: str | None) -> str | None:
|
|
54
|
+
"""Return ``sha`` unchanged if it is ``None`` or a 40-char lowercase hex SHA, else raise."""
|
|
55
|
+
if sha is not None and not SHA40.fullmatch(sha):
|
|
56
|
+
raise ValueError("expect_head_sha must be a 40-character lowercase hex SHA")
|
|
57
|
+
return sha
|
|
58
|
+
|
|
59
|
+
|
|
53
60
|
def validate_override(sha: str | None, reason: str | None) -> tuple[str, str] | None:
|
|
54
61
|
"""Return ``(sha, reason)`` for a valid human override pair, ``None`` if neither is set."""
|
|
55
62
|
if sha is None and reason is None:
|
|
@@ -343,3 +343,26 @@ class TestPromptsSubcommand:
|
|
|
343
343
|
def test_prompts_export_force_defaults_off(self) -> None:
|
|
344
344
|
args = _parse(["prompts", "export", "/tmp/out"])
|
|
345
345
|
assert args.force is False
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class TestExpectHeadShaFlag:
|
|
349
|
+
def test_defaults_to_none(self) -> None:
|
|
350
|
+
assert _parse(["review", "owner/repo", "--pr", "1"]).expect_head_sha is None
|
|
351
|
+
|
|
352
|
+
def test_flag_parses(self) -> None:
|
|
353
|
+
sha = "a" * 40
|
|
354
|
+
args = _parse(["review", "owner/repo", "--pr", "1", "--expect-head-sha", sha])
|
|
355
|
+
assert args.expect_head_sha == sha
|
|
356
|
+
|
|
357
|
+
def test_malformed_sha_is_a_parser_error(self) -> None:
|
|
358
|
+
parser = _build_parser()
|
|
359
|
+
for bad in ("a" * 39, "A" * 40, "g" * 40, ""):
|
|
360
|
+
args = _parse(["review", "owner/repo", "--pr", "1", "--expect-head-sha", bad])
|
|
361
|
+
with pytest.raises(SystemExit) as exc:
|
|
362
|
+
_validate_review_args(parser, args)
|
|
363
|
+
assert exc.value.code == 2
|
|
364
|
+
|
|
365
|
+
def test_valid_sha_passes_validation(self) -> None:
|
|
366
|
+
parser = _build_parser()
|
|
367
|
+
args = _parse(["review", "owner/repo", "--pr", "1", "--expect-head-sha", "a" * 40])
|
|
368
|
+
_validate_review_args(parser, args)
|