claude-dev-env 1.95.0 → 2.0.0
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.
- package/_shared/advisor/CLAUDE.md +2 -2
- package/_shared/advisor/advisor-protocol.md +20 -20
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
- package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
- package/_shared/advisor/scripts/tier_model_ids.py +18 -18
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
- package/_shared/pr-loop/scripts/README.md +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
- package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
- package/_shared/pr-loop/worker-spawn.md +186 -0
- package/agents/code-verifier.md +1 -1
- package/bin/ever-shipped-skills.mjs +3 -0
- package/bin/expand_home_directory_tokens.mjs +1 -1
- package/bin/install.mjs +5 -2
- package/hooks/advisory/refactor_guard.py +3 -4
- package/hooks/blocking/CLAUDE.md +7 -1
- package/hooks/blocking/block_main_commit.py +2 -2
- package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
- package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
- package/hooks/blocking/code_rules_mock_completeness.py +1 -1
- package/hooks/blocking/code_rules_optional_params.py +2 -2
- package/hooks/blocking/code_rules_shared.py +1 -1
- package/hooks/blocking/code_rules_test_assertions.py +1 -1
- package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
- package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
- package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
- package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
- package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
- package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
- package/hooks/blocking/inventory_intent_records/records.py +271 -0
- package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
- package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
- package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
- package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
- package/hooks/blocking/pii_payload_scan.py +138 -42
- package/hooks/blocking/pii_prevention_blocker.py +185 -291
- package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
- package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
- package/hooks/blocking/pr_description_command_parser.py +8 -4
- package/hooks/blocking/precommit_code_rules_gate.py +3 -3
- package/hooks/blocking/tdd_enforcer.py +97 -608
- package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
- package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
- package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
- package/hooks/blocking/test_pii_payload_scan.py +168 -0
- package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
- package/hooks/blocking/tests/conftest.py +10 -0
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
- package/hooks/blocking/tests/test_repository_exemption.py +105 -0
- package/hooks/blocking/tests/test_repository_resolution.py +108 -0
- package/hooks/diagnostic/hook_log_extractor.py +12 -10
- package/hooks/git-hooks/post_commit.py +3 -4
- package/hooks/hooks_constants/CLAUDE.md +2 -2
- package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
- package/hooks/hooks_constants/local_identity.py +59 -8
- package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
- package/hooks/hooks_constants/test_local_identity.py +105 -3
- package/hooks/pyproject.toml +13 -36
- package/hooks/session/plugin_data_dir_cleanup.py +0 -1
- package/hooks/validation/mypy_validator.py +2 -2
- package/hooks/validators/health_check.py +1 -0
- package/hooks/validators/mypy_integration.py +2 -0
- package/hooks/validators/ruff_integration.py +3 -0
- package/hooks/workflow/auto_formatter.py +5 -4
- package/package.json +1 -1
- package/scripts/CLAUDE.md +4 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
- package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
- package/scripts/dev_env_scripts_constants/timing.py +7 -1
- package/scripts/grok_headless_runner.py +294 -0
- package/scripts/grok_worker_preflight.py +410 -0
- package/scripts/invoke_code_review.py +463 -0
- package/scripts/resolve_worker_spawn.py +619 -0
- package/scripts/spawn_grok_batch.py +672 -0
- package/scripts/test_grok_headless_runner.py +626 -0
- package/scripts/test_grok_worker_preflight.py +1054 -0
- package/scripts/test_invoke_code_review.py +672 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -0
- package/scripts/test_spawn_grok_batch.py +1017 -0
- package/skills/CLAUDE.md +5 -3
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
- package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
- package/skills/autoconverge/CLAUDE.md +3 -3
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/CLAUDE.md +2 -2
- package/skills/autoconverge/reference/convergence.md +33 -11
- package/skills/autoconverge/reference/stop-conditions.md +16 -5
- package/skills/autoconverge/workflow/CLAUDE.md +2 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.mjs +200 -16
- package/skills/bugteam/CLAUDE.md +2 -2
- package/skills/bugteam/CONSTRAINTS.md +3 -2
- package/skills/bugteam/PROMPTS.md +7 -6
- package/skills/bugteam/SKILL.md +18 -13
- package/skills/bugteam/reference/audit-and-teammates.md +215 -35
- package/skills/bugteam/reference/design-rationale.md +1 -1
- package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
- package/skills/bugteam/reference/team-setup.md +8 -2
- package/skills/codex-review/CLAUDE.md +46 -0
- package/skills/codex-review/SKILL.md +181 -0
- package/skills/codex-review/reference/CLAUDE.md +15 -0
- package/skills/codex-review/reference/cli-contract.md +253 -0
- package/skills/codex-review/reference/loop-integration.md +118 -0
- package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
- package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
- package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
- package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
- package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
- package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
- package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
- package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
- package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
- package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
- package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
- package/skills/codex-review/scripts/run_codex_review.py +415 -0
- package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
- package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
- package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
- package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
- package/skills/codex-review/test_skill_scaffold.py +192 -0
- package/skills/grok-spawn/CLAUDE.md +28 -0
- package/skills/grok-spawn/SKILL.md +226 -0
- package/skills/grok-spawn/reference/flag-profiles.md +132 -0
- package/skills/grok-spawn/reference/worker-briefs.md +152 -0
- package/skills/grokify/SKILL.md +9 -1
- package/skills/grokify/capability-claims.test.mjs +28 -0
- package/skills/grokify/evals/README.md +72 -0
- package/skills/grokify/evals/parse-payload.test.mjs +171 -0
- package/skills/grokify/evals/run-capability-evals.mjs +545 -0
- package/skills/orchestrator/SKILL.md +15 -11
- package/skills/orchestrator-refresh/SKILL.md +5 -5
- package/skills/pr-converge/SKILL.md +34 -13
- package/skills/pr-converge/reference/convergence-gates.md +42 -15
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +1 -1
- package/skills/pr-converge/reference/per-tick.md +130 -42
- package/skills/pr-converge/reference/state-schema.md +10 -0
- package/skills/pr-converge/scripts/CLAUDE.md +2 -0
- package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
- package/skills/pr-converge/scripts/check_convergence.py +605 -29
- package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
- package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
- package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
- package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
- package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
- package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
- package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
- package/skills/pr-converge/test_step5_host_branch.py +106 -0
- package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
- package/skills/reviewer-gates/SKILL.md +7 -5
- package/skills/team-advisor/SKILL.md +7 -7
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"""Behavioral tests for the headless grok worker runner.
|
|
2
|
+
|
|
3
|
+
Classification fixtures use phrasing observed against grok binary version
|
|
4
|
+
0.2.99 (b1b49ccb71) [stable] — usage-limit and auth-failure text shapes that
|
|
5
|
+
include the signature substrings the constants module lists.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
17
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
19
|
+
|
|
20
|
+
import grok_headless_runner as runner # noqa: E402
|
|
21
|
+
from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
22
|
+
AGENT_FLAG,
|
|
23
|
+
ALWAYS_APPROVE_FLAG,
|
|
24
|
+
CLASSIFICATION_AUTH_FAILURE,
|
|
25
|
+
CLASSIFICATION_ERROR,
|
|
26
|
+
CLASSIFICATION_OK,
|
|
27
|
+
CLASSIFICATION_TIMEOUT,
|
|
28
|
+
CLASSIFICATION_USAGE_LIMIT,
|
|
29
|
+
CWD_FLAG,
|
|
30
|
+
GROK_BINARY_NAME,
|
|
31
|
+
GROK_BINARY_NOT_FOUND_STDERR,
|
|
32
|
+
KILL_GRACE_TIMEOUT_SECONDS,
|
|
33
|
+
LAUNCH_FAILURE_RETURN_CODE,
|
|
34
|
+
LAUNCH_FAILURE_STDERR_PREFIX,
|
|
35
|
+
LEADER_SOCKET_FILENAME_PREFIX,
|
|
36
|
+
LEADER_SOCKET_FILENAME_SUFFIX,
|
|
37
|
+
LEADER_SOCKET_FLAG,
|
|
38
|
+
MAX_TURNS_FLAG,
|
|
39
|
+
MISSING_BINARY_RETURN_CODE,
|
|
40
|
+
OUTPUT_FORMAT_FLAG,
|
|
41
|
+
OUTPUT_FORMAT_JSON,
|
|
42
|
+
PROMPT_FILE_FLAG,
|
|
43
|
+
TIMEOUT_RETURN_CODE,
|
|
44
|
+
UTF8_DECODE_ERRORS,
|
|
45
|
+
UTF8_ENCODING,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
FIXTURE_GROK_BINARY_VERSION = "0.2.99 (b1b49ccb71) [stable]"
|
|
49
|
+
|
|
50
|
+
FIXTURE_USAGE_LIMIT_STDERR = (
|
|
51
|
+
f"grok {FIXTURE_GROK_BINARY_VERSION}: Error: rate limit exceeded "
|
|
52
|
+
"(HTTP 429): quota exceeded, insufficient credit for this request"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
FIXTURE_AUTH_FAILURE_STDERR = (
|
|
56
|
+
f"grok {FIXTURE_GROK_BINARY_VERSION}: Error: unauthorized "
|
|
57
|
+
"(HTTP 401): invalid key — authentication failed"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
FIXTURE_GENERIC_FAILURE_STDERR = (
|
|
61
|
+
f"grok {FIXTURE_GROK_BINARY_VERSION}: Error: internal failure"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
DEFAULT_MAX_TURNS = 8
|
|
65
|
+
DEFAULT_TIMEOUT_SECONDS = 30
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _FakeProcess:
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
returncode: int,
|
|
73
|
+
stdout: str = "",
|
|
74
|
+
stderr: str = "",
|
|
75
|
+
should_timeout: bool = False,
|
|
76
|
+
returncode_after_kill: int | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.returncode = returncode
|
|
79
|
+
self._stdout = stdout
|
|
80
|
+
self._stderr = stderr
|
|
81
|
+
self._should_timeout = should_timeout
|
|
82
|
+
self._returncode_after_kill = returncode_after_kill
|
|
83
|
+
self.was_killed = False
|
|
84
|
+
self.communicate_calls = 0
|
|
85
|
+
self.last_communicate_timeout: float | None = None
|
|
86
|
+
|
|
87
|
+
def communicate(self, timeout: float | None = None) -> tuple[str, str]:
|
|
88
|
+
self.communicate_calls += 1
|
|
89
|
+
self.last_communicate_timeout = timeout
|
|
90
|
+
if self._should_timeout and not self.was_killed:
|
|
91
|
+
raise subprocess.TimeoutExpired(
|
|
92
|
+
cmd=[GROK_BINARY_NAME], timeout=timeout or 0
|
|
93
|
+
)
|
|
94
|
+
return self._stdout, self._stderr
|
|
95
|
+
|
|
96
|
+
def kill(self) -> None:
|
|
97
|
+
self.was_killed = True
|
|
98
|
+
if self._returncode_after_kill is not None:
|
|
99
|
+
self.returncode = self._returncode_after_kill
|
|
100
|
+
return
|
|
101
|
+
self.returncode = -9
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _PopenRecorder:
|
|
105
|
+
def __init__(self, all_processes: list[_FakeProcess]) -> None:
|
|
106
|
+
self.all_processes = list(all_processes)
|
|
107
|
+
self.invocations: list[list[str]] = []
|
|
108
|
+
self.all_keyword_arguments: list[dict[str, object]] = []
|
|
109
|
+
|
|
110
|
+
def __call__(
|
|
111
|
+
self,
|
|
112
|
+
invocation: list[str],
|
|
113
|
+
**keyword_arguments: object,
|
|
114
|
+
) -> _FakeProcess:
|
|
115
|
+
self.invocations.append(list(invocation))
|
|
116
|
+
self.all_keyword_arguments.append(dict(keyword_arguments))
|
|
117
|
+
if not self.all_processes:
|
|
118
|
+
raise AssertionError(f"unexpected invocation: {invocation}")
|
|
119
|
+
return self.all_processes.pop(0)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _run_once(
|
|
123
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
124
|
+
tmp_path: Path,
|
|
125
|
+
fake_process: _FakeProcess,
|
|
126
|
+
*,
|
|
127
|
+
agent_name: str | None = None,
|
|
128
|
+
max_turns: int = DEFAULT_MAX_TURNS,
|
|
129
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
130
|
+
) -> tuple[runner.GrokRunnerOutcome, _PopenRecorder, Path, Path, Path]:
|
|
131
|
+
prompt_file = tmp_path / "prompt.txt"
|
|
132
|
+
prompt_file.write_text("do the work", encoding="utf-8")
|
|
133
|
+
working_directory = tmp_path / "project"
|
|
134
|
+
working_directory.mkdir()
|
|
135
|
+
run_state_directory = tmp_path / "run-state"
|
|
136
|
+
run_state_directory.mkdir()
|
|
137
|
+
recorder = _PopenRecorder([fake_process])
|
|
138
|
+
monkeypatch.setattr(runner, "runner_popen", recorder)
|
|
139
|
+
outcome = runner.run_headless_worker(
|
|
140
|
+
prompt_file=prompt_file,
|
|
141
|
+
working_directory=working_directory,
|
|
142
|
+
run_state_directory=run_state_directory,
|
|
143
|
+
max_turns=max_turns,
|
|
144
|
+
timeout_seconds=timeout_seconds,
|
|
145
|
+
agent_name=agent_name,
|
|
146
|
+
)
|
|
147
|
+
return outcome, recorder, prompt_file, working_directory, run_state_directory
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_argv_assembly_includes_required_flags(
|
|
151
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
152
|
+
) -> None:
|
|
153
|
+
fake_process = _FakeProcess(returncode=0, stdout='{"ok":true}')
|
|
154
|
+
outcome, recorder, prompt_file, working_directory, run_state_directory = _run_once(
|
|
155
|
+
monkeypatch, tmp_path, fake_process
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
assert outcome.is_ok is True
|
|
159
|
+
assert len(recorder.invocations) == 1
|
|
160
|
+
invocation = recorder.invocations[0]
|
|
161
|
+
assert invocation[0] == GROK_BINARY_NAME
|
|
162
|
+
assert PROMPT_FILE_FLAG in invocation
|
|
163
|
+
assert str(prompt_file) in invocation
|
|
164
|
+
assert CWD_FLAG in invocation
|
|
165
|
+
assert str(working_directory) in invocation
|
|
166
|
+
assert OUTPUT_FORMAT_FLAG in invocation
|
|
167
|
+
assert OUTPUT_FORMAT_JSON in invocation
|
|
168
|
+
assert ALWAYS_APPROVE_FLAG in invocation
|
|
169
|
+
assert MAX_TURNS_FLAG in invocation
|
|
170
|
+
assert str(DEFAULT_MAX_TURNS) in invocation
|
|
171
|
+
assert LEADER_SOCKET_FLAG in invocation
|
|
172
|
+
leader_socket_path = Path(invocation[invocation.index(LEADER_SOCKET_FLAG) + 1])
|
|
173
|
+
assert leader_socket_path.parent == run_state_directory
|
|
174
|
+
assert leader_socket_path.name.startswith(LEADER_SOCKET_FILENAME_PREFIX)
|
|
175
|
+
assert leader_socket_path.name.endswith(LEADER_SOCKET_FILENAME_SUFFIX)
|
|
176
|
+
assert AGENT_FLAG not in invocation
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_argv_includes_agent_when_named(
|
|
180
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
181
|
+
) -> None:
|
|
182
|
+
fake_process = _FakeProcess(returncode=0, stdout='{"ok":true}')
|
|
183
|
+
agent_name = "code-quality-agent"
|
|
184
|
+
outcome, recorder, _, _, _ = _run_once(
|
|
185
|
+
monkeypatch, tmp_path, fake_process, agent_name=agent_name
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
assert outcome.is_ok is True
|
|
189
|
+
invocation = recorder.invocations[0]
|
|
190
|
+
assert AGENT_FLAG in invocation
|
|
191
|
+
assert agent_name in invocation
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_unique_leader_socket_path_per_call(
|
|
195
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
196
|
+
) -> None:
|
|
197
|
+
prompt_file = tmp_path / "prompt.txt"
|
|
198
|
+
prompt_file.write_text("do the work", encoding="utf-8")
|
|
199
|
+
working_directory = tmp_path / "project"
|
|
200
|
+
working_directory.mkdir()
|
|
201
|
+
run_state_directory = tmp_path / "run-state"
|
|
202
|
+
run_state_directory.mkdir()
|
|
203
|
+
first_process = _FakeProcess(returncode=0, stdout="first")
|
|
204
|
+
second_process = _FakeProcess(returncode=0, stdout="second")
|
|
205
|
+
recorder = _PopenRecorder([first_process, second_process])
|
|
206
|
+
monkeypatch.setattr(runner, "runner_popen", recorder)
|
|
207
|
+
|
|
208
|
+
runner.run_headless_worker(
|
|
209
|
+
prompt_file=prompt_file,
|
|
210
|
+
working_directory=working_directory,
|
|
211
|
+
run_state_directory=run_state_directory,
|
|
212
|
+
max_turns=DEFAULT_MAX_TURNS,
|
|
213
|
+
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
|
214
|
+
)
|
|
215
|
+
runner.run_headless_worker(
|
|
216
|
+
prompt_file=prompt_file,
|
|
217
|
+
working_directory=working_directory,
|
|
218
|
+
run_state_directory=run_state_directory,
|
|
219
|
+
max_turns=DEFAULT_MAX_TURNS,
|
|
220
|
+
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
first_socket = Path(
|
|
224
|
+
recorder.invocations[0][recorder.invocations[0].index(LEADER_SOCKET_FLAG) + 1]
|
|
225
|
+
)
|
|
226
|
+
second_socket = Path(
|
|
227
|
+
recorder.invocations[1][recorder.invocations[1].index(LEADER_SOCKET_FLAG) + 1]
|
|
228
|
+
)
|
|
229
|
+
assert first_socket != second_socket
|
|
230
|
+
assert first_socket.parent == run_state_directory
|
|
231
|
+
assert second_socket.parent == run_state_directory
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_timeout_kills_process(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
235
|
+
fake_process = _FakeProcess(
|
|
236
|
+
returncode=0, stderr="still running", should_timeout=True
|
|
237
|
+
)
|
|
238
|
+
outcome, recorder, _, _, _ = _run_once(
|
|
239
|
+
monkeypatch, tmp_path, fake_process, timeout_seconds=DEFAULT_TIMEOUT_SECONDS
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
assert fake_process.was_killed is True
|
|
243
|
+
assert fake_process.communicate_calls >= 2
|
|
244
|
+
assert fake_process.last_communicate_timeout == KILL_GRACE_TIMEOUT_SECONDS
|
|
245
|
+
assert outcome.is_ok is False
|
|
246
|
+
assert outcome.classification == CLASSIFICATION_TIMEOUT
|
|
247
|
+
assert recorder.all_keyword_arguments[0].get("stdout") is subprocess.PIPE
|
|
248
|
+
assert recorder.all_keyword_arguments[0].get("stderr") is subprocess.PIPE
|
|
249
|
+
assert recorder.all_keyword_arguments[0].get("encoding") == UTF8_ENCODING
|
|
250
|
+
assert recorder.all_keyword_arguments[0].get("errors") == UTF8_DECODE_ERRORS
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_classifies_usage_limit_from_fixture(
|
|
254
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
255
|
+
) -> None:
|
|
256
|
+
fake_process = _FakeProcess(returncode=1, stderr=FIXTURE_USAGE_LIMIT_STDERR)
|
|
257
|
+
outcome, _, _, _, _ = _run_once(monkeypatch, tmp_path, fake_process)
|
|
258
|
+
|
|
259
|
+
assert outcome.is_ok is False
|
|
260
|
+
assert outcome.returncode == 1
|
|
261
|
+
assert outcome.classification == CLASSIFICATION_USAGE_LIMIT
|
|
262
|
+
assert "429" in outcome.stderr
|
|
263
|
+
assert outcome.stdout == ""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_classifies_auth_failure_from_fixture(
|
|
267
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
268
|
+
) -> None:
|
|
269
|
+
fake_process = _FakeProcess(returncode=1, stderr=FIXTURE_AUTH_FAILURE_STDERR)
|
|
270
|
+
outcome, _, _, _, _ = _run_once(monkeypatch, tmp_path, fake_process)
|
|
271
|
+
|
|
272
|
+
assert outcome.is_ok is False
|
|
273
|
+
assert outcome.returncode == 1
|
|
274
|
+
assert outcome.classification == CLASSIFICATION_AUTH_FAILURE
|
|
275
|
+
assert "401" in outcome.stderr
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_classifies_ok_on_zero_exit(
|
|
279
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
280
|
+
) -> None:
|
|
281
|
+
fake_process = _FakeProcess(returncode=0, stdout='{"status":"done"}')
|
|
282
|
+
outcome, _, _, _, _ = _run_once(monkeypatch, tmp_path, fake_process)
|
|
283
|
+
|
|
284
|
+
assert outcome.is_ok is True
|
|
285
|
+
assert outcome.returncode == 0
|
|
286
|
+
assert outcome.classification == CLASSIFICATION_OK
|
|
287
|
+
assert outcome.stdout == '{"status":"done"}'
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_classifies_error_on_unknown_failure(
|
|
291
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
292
|
+
) -> None:
|
|
293
|
+
fake_process = _FakeProcess(returncode=2, stderr=FIXTURE_GENERIC_FAILURE_STDERR)
|
|
294
|
+
outcome, _, _, _, _ = _run_once(monkeypatch, tmp_path, fake_process)
|
|
295
|
+
|
|
296
|
+
assert outcome.is_ok is False
|
|
297
|
+
assert outcome.returncode == 2
|
|
298
|
+
assert outcome.classification == CLASSIFICATION_ERROR
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_scratch_paths_stay_under_run_state_directory(
|
|
302
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
303
|
+
) -> None:
|
|
304
|
+
repo_root = tmp_path / "repo"
|
|
305
|
+
repo_root.mkdir()
|
|
306
|
+
fake_process = _FakeProcess(returncode=0, stdout="ok")
|
|
307
|
+
outcome, recorder, _, _, run_state_directory = _run_once(
|
|
308
|
+
monkeypatch, tmp_path, fake_process
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
assert outcome.is_ok is True
|
|
312
|
+
invocation = recorder.invocations[0]
|
|
313
|
+
leader_socket_path = Path(invocation[invocation.index(LEADER_SOCKET_FLAG) + 1])
|
|
314
|
+
assert leader_socket_path.is_relative_to(run_state_directory)
|
|
315
|
+
assert not leader_socket_path.is_relative_to(repo_root)
|
|
316
|
+
written_inside_repo = list(repo_root.rglob("*"))
|
|
317
|
+
assert written_inside_repo == []
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_classifies_usage_limit_still_matches_real_fixture(
|
|
321
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
322
|
+
) -> None:
|
|
323
|
+
fake_process = _FakeProcess(
|
|
324
|
+
returncode=1, stderr=FIXTURE_USAGE_LIMIT_STDERR
|
|
325
|
+
)
|
|
326
|
+
outcome, _, _, _, _ = _run_once(monkeypatch, tmp_path, fake_process)
|
|
327
|
+
|
|
328
|
+
assert outcome.classification == CLASSIFICATION_USAGE_LIMIT
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_credit_card_text_is_error_not_usage_limit(
|
|
332
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
333
|
+
) -> None:
|
|
334
|
+
fake_process = _FakeProcess(
|
|
335
|
+
returncode=1,
|
|
336
|
+
stderr="invalid credit card field mapping on invoice line",
|
|
337
|
+
)
|
|
338
|
+
outcome, _, _, _, _ = _run_once(monkeypatch, tmp_path, fake_process)
|
|
339
|
+
|
|
340
|
+
assert outcome.is_ok is False
|
|
341
|
+
assert outcome.classification == CLASSIFICATION_ERROR
|
|
342
|
+
assert outcome.classification != CLASSIFICATION_USAGE_LIMIT
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_bare_near_miss_tokens_are_not_usage_limit(
|
|
346
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
347
|
+
) -> None:
|
|
348
|
+
all_near_miss_stderr = (
|
|
349
|
+
"vendor code 42901 rejected the payload",
|
|
350
|
+
"please verify credit before retry",
|
|
351
|
+
"adjust disk quota on volume",
|
|
352
|
+
"disk quota exceeded on volume",
|
|
353
|
+
)
|
|
354
|
+
for each_index, each_stderr in enumerate(all_near_miss_stderr):
|
|
355
|
+
each_case_directory = tmp_path / f"near-miss-{each_index}"
|
|
356
|
+
each_case_directory.mkdir()
|
|
357
|
+
fake_process = _FakeProcess(returncode=1, stderr=each_stderr)
|
|
358
|
+
outcome, _, _, _, _ = _run_once(
|
|
359
|
+
monkeypatch, each_case_directory, fake_process
|
|
360
|
+
)
|
|
361
|
+
assert outcome.classification == CLASSIFICATION_ERROR, each_stderr
|
|
362
|
+
assert outcome.classification != CLASSIFICATION_USAGE_LIMIT, each_stderr
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_auth_near_miss_text_is_error_not_auth_failure(
|
|
366
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
367
|
+
) -> None:
|
|
368
|
+
all_near_miss_stderr = (
|
|
369
|
+
"invalid keyword argument for helper",
|
|
370
|
+
"invalid key mapping on invoice line",
|
|
371
|
+
"unauthorized resource access by policy engine",
|
|
372
|
+
"unauthorized",
|
|
373
|
+
)
|
|
374
|
+
for each_index, each_stderr in enumerate(all_near_miss_stderr):
|
|
375
|
+
each_case_directory = tmp_path / f"auth-near-miss-{each_index}"
|
|
376
|
+
each_case_directory.mkdir()
|
|
377
|
+
fake_process = _FakeProcess(returncode=1, stderr=each_stderr)
|
|
378
|
+
outcome, _, _, _, _ = _run_once(
|
|
379
|
+
monkeypatch, each_case_directory, fake_process
|
|
380
|
+
)
|
|
381
|
+
assert outcome.classification == CLASSIFICATION_ERROR, each_stderr
|
|
382
|
+
assert outcome.classification != CLASSIFICATION_AUTH_FAILURE, each_stderr
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_tightened_usage_phrases_still_classify_usage_limit(
|
|
386
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
387
|
+
) -> None:
|
|
388
|
+
all_usage_stderr = (
|
|
389
|
+
"api quota exceeded for this request",
|
|
390
|
+
"usage quota exceeded on plan",
|
|
391
|
+
"rate quota exceeded for endpoint",
|
|
392
|
+
)
|
|
393
|
+
for each_index, each_stderr in enumerate(all_usage_stderr):
|
|
394
|
+
each_case_directory = tmp_path / f"usage-phrase-{each_index}"
|
|
395
|
+
each_case_directory.mkdir()
|
|
396
|
+
fake_process = _FakeProcess(returncode=1, stderr=each_stderr)
|
|
397
|
+
outcome, _, _, _, _ = _run_once(
|
|
398
|
+
monkeypatch, each_case_directory, fake_process
|
|
399
|
+
)
|
|
400
|
+
assert outcome.classification == CLASSIFICATION_USAGE_LIMIT, each_stderr
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def test_tightened_auth_phrases_still_classify_auth_failure(
|
|
404
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
405
|
+
) -> None:
|
|
406
|
+
all_auth_stderr = (
|
|
407
|
+
"error: unauthorized — please re-authenticate",
|
|
408
|
+
"invalid api key supplied for this request",
|
|
409
|
+
"client is not authenticated",
|
|
410
|
+
)
|
|
411
|
+
for each_index, each_stderr in enumerate(all_auth_stderr):
|
|
412
|
+
each_case_directory = tmp_path / f"auth-phrase-{each_index}"
|
|
413
|
+
each_case_directory.mkdir()
|
|
414
|
+
fake_process = _FakeProcess(returncode=1, stderr=each_stderr)
|
|
415
|
+
outcome, _, _, _, _ = _run_once(
|
|
416
|
+
monkeypatch, each_case_directory, fake_process
|
|
417
|
+
)
|
|
418
|
+
assert outcome.classification == CLASSIFICATION_AUTH_FAILURE, each_stderr
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def test_dual_match_prefers_auth_failure_over_usage_limit(
|
|
422
|
+
def test_missing_binary_returns_dedicated_error(
|
|
423
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
424
|
+
) -> None:
|
|
425
|
+
prompt_file = tmp_path / "prompt.txt"
|
|
426
|
+
prompt_file.write_text("do the work", encoding="utf-8")
|
|
427
|
+
working_directory = tmp_path / "project"
|
|
428
|
+
working_directory.mkdir()
|
|
429
|
+
run_state_directory = tmp_path / "run-state"
|
|
430
|
+
run_state_directory.mkdir()
|
|
431
|
+
|
|
432
|
+
def _raise_file_not_found(
|
|
433
|
+
invocation: list[str], **keyword_arguments: object
|
|
434
|
+
) -> object:
|
|
435
|
+
del invocation, keyword_arguments
|
|
436
|
+
raise FileNotFoundError(GROK_BINARY_NAME)
|
|
437
|
+
|
|
438
|
+
monkeypatch.setattr(runner, "runner_popen", _raise_file_not_found)
|
|
439
|
+
outcome = runner.run_headless_worker(
|
|
440
|
+
prompt_file=prompt_file,
|
|
441
|
+
working_directory=working_directory,
|
|
442
|
+
run_state_directory=run_state_directory,
|
|
443
|
+
max_turns=DEFAULT_MAX_TURNS,
|
|
444
|
+
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
assert outcome.is_ok is False
|
|
448
|
+
assert outcome.classification == CLASSIFICATION_ERROR
|
|
449
|
+
assert outcome.returncode == MISSING_BINARY_RETURN_CODE
|
|
450
|
+
assert outcome.returncode != TIMEOUT_RETURN_CODE
|
|
451
|
+
assert outcome.stderr != ""
|
|
452
|
+
assert GROK_BINARY_NAME in outcome.stderr
|
|
453
|
+
assert "not found" in outcome.stderr
|
|
454
|
+
assert outcome.stderr == GROK_BINARY_NOT_FOUND_STDERR
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def test_dual_match_prefers_auth_failure_over_usage_limit(
|
|
458
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
459
|
+
) -> None:
|
|
460
|
+
dual_match_stderr = "HTTP 401 rate limit exceeded: unauthorized"
|
|
461
|
+
fake_process = _FakeProcess(returncode=1, stderr=dual_match_stderr)
|
|
462
|
+
outcome, _, _, _, _ = _run_once(monkeypatch, tmp_path, fake_process)
|
|
463
|
+
|
|
464
|
+
assert outcome.is_ok is False
|
|
465
|
+
assert outcome.classification == CLASSIFICATION_AUTH_FAILURE
|
|
466
|
+
assert outcome.returncode == 1
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def test_incidental_credit_word_is_error_not_usage_limit(
|
|
470
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
471
|
+
) -> None:
|
|
472
|
+
fake_process = _FakeProcess(
|
|
473
|
+
returncode=1,
|
|
474
|
+
stdout="Finished auditing credit card picker skill",
|
|
475
|
+
stderr="Error: ENOSPC disk full",
|
|
476
|
+
)
|
|
477
|
+
outcome, _, _, _, _ = _run_once(monkeypatch, tmp_path, fake_process)
|
|
478
|
+
|
|
479
|
+
assert outcome.is_ok is False
|
|
480
|
+
assert outcome.classification == CLASSIFICATION_ERROR
|
|
481
|
+
assert "credit card" in outcome.stdout
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def test_incidental_port_429_is_error_not_usage_limit(
|
|
485
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
486
|
+
) -> None:
|
|
487
|
+
fake_process = _FakeProcess(
|
|
488
|
+
returncode=1,
|
|
489
|
+
stderr="connection refused: port 429 refused by peer",
|
|
490
|
+
)
|
|
491
|
+
outcome, _, _, _, _ = _run_once(monkeypatch, tmp_path, fake_process)
|
|
492
|
+
|
|
493
|
+
assert outcome.is_ok is False
|
|
494
|
+
assert outcome.classification == CLASSIFICATION_ERROR
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def test_incidental_quota_of_tools_is_error_not_usage_limit(
|
|
498
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
499
|
+
) -> None:
|
|
500
|
+
fake_process = _FakeProcess(
|
|
501
|
+
returncode=1,
|
|
502
|
+
stderr="configured quota of tools exceeded for this role",
|
|
503
|
+
)
|
|
504
|
+
outcome, _, _, _, _ = _run_once(monkeypatch, tmp_path, fake_process)
|
|
505
|
+
|
|
506
|
+
assert outcome.is_ok is False
|
|
507
|
+
assert outcome.classification == CLASSIFICATION_ERROR
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def test_incidental_accreditation_failed_is_error_not_usage_limit(
|
|
511
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
512
|
+
) -> None:
|
|
513
|
+
fake_process = _FakeProcess(
|
|
514
|
+
returncode=1,
|
|
515
|
+
stderr="accreditation failed for partner token",
|
|
516
|
+
)
|
|
517
|
+
outcome, _, _, _, _ = _run_once(monkeypatch, tmp_path, fake_process)
|
|
518
|
+
|
|
519
|
+
assert outcome.is_ok is False
|
|
520
|
+
assert outcome.classification == CLASSIFICATION_ERROR
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def test_incidental_room_401_is_error_not_auth_failure(
|
|
524
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
525
|
+
) -> None:
|
|
526
|
+
fake_process = _FakeProcess(
|
|
527
|
+
returncode=1,
|
|
528
|
+
stderr="meeting room 401 is unavailable",
|
|
529
|
+
)
|
|
530
|
+
outcome, _, _, _, _ = _run_once(monkeypatch, tmp_path, fake_process)
|
|
531
|
+
|
|
532
|
+
assert outcome.is_ok is False
|
|
533
|
+
assert outcome.classification == CLASSIFICATION_ERROR
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def test_permission_error_on_launch_returns_structured_error(
|
|
537
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
538
|
+
) -> None:
|
|
539
|
+
prompt_file = tmp_path / "prompt.txt"
|
|
540
|
+
prompt_file.write_text("do the work", encoding="utf-8")
|
|
541
|
+
working_directory = tmp_path / "project"
|
|
542
|
+
working_directory.mkdir()
|
|
543
|
+
run_state_directory = tmp_path / "run-state"
|
|
544
|
+
run_state_directory.mkdir()
|
|
545
|
+
|
|
546
|
+
def _raise_permission_denied(
|
|
547
|
+
invocation: list[str], **keyword_arguments: object
|
|
548
|
+
) -> object:
|
|
549
|
+
del invocation, keyword_arguments
|
|
550
|
+
raise PermissionError(13, "Permission denied", GROK_BINARY_NAME)
|
|
551
|
+
|
|
552
|
+
monkeypatch.setattr(runner, "runner_popen", _raise_permission_denied)
|
|
553
|
+
outcome = runner.run_headless_worker(
|
|
554
|
+
prompt_file=prompt_file,
|
|
555
|
+
working_directory=working_directory,
|
|
556
|
+
run_state_directory=run_state_directory,
|
|
557
|
+
max_turns=DEFAULT_MAX_TURNS,
|
|
558
|
+
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
assert outcome.is_ok is False
|
|
562
|
+
assert outcome.classification == CLASSIFICATION_ERROR
|
|
563
|
+
assert outcome.returncode == LAUNCH_FAILURE_RETURN_CODE
|
|
564
|
+
assert outcome.returncode != TIMEOUT_RETURN_CODE
|
|
565
|
+
assert outcome.returncode != MISSING_BINARY_RETURN_CODE
|
|
566
|
+
assert outcome.stderr != ""
|
|
567
|
+
assert outcome.stderr.startswith(LAUNCH_FAILURE_STDERR_PREFIX)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def test_invalid_utf8_child_stdout_is_replace_decoded(
|
|
571
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
572
|
+
) -> None:
|
|
573
|
+
prompt_file = tmp_path / "prompt.txt"
|
|
574
|
+
prompt_file.write_text("do the work", encoding="utf-8")
|
|
575
|
+
working_directory = tmp_path / "project"
|
|
576
|
+
working_directory.mkdir()
|
|
577
|
+
run_state_directory = tmp_path / "run-state"
|
|
578
|
+
run_state_directory.mkdir()
|
|
579
|
+
invalid_byte_script = (
|
|
580
|
+
"import sys; sys.stdout.buffer.write(b'\\xff\\xfe'); sys.exit(1)"
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
def _spawn_invalid_utf8_child(
|
|
584
|
+
invocation: list[str], **keyword_arguments: object
|
|
585
|
+
) -> subprocess.Popen[str]:
|
|
586
|
+
del invocation
|
|
587
|
+
return subprocess.Popen(
|
|
588
|
+
[sys.executable, "-c", invalid_byte_script],
|
|
589
|
+
**keyword_arguments,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
monkeypatch.setattr(runner, "runner_popen", _spawn_invalid_utf8_child)
|
|
593
|
+
outcome = runner.run_headless_worker(
|
|
594
|
+
prompt_file=prompt_file,
|
|
595
|
+
working_directory=working_directory,
|
|
596
|
+
run_state_directory=run_state_directory,
|
|
597
|
+
max_turns=DEFAULT_MAX_TURNS,
|
|
598
|
+
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
assert outcome.is_ok is False
|
|
602
|
+
assert outcome.stdout is not None
|
|
603
|
+
assert isinstance(outcome.stdout, str)
|
|
604
|
+
assert outcome.stdout != ""
|
|
605
|
+
assert "\ufffd" in outcome.stdout
|
|
606
|
+
assert outcome.classification == CLASSIFICATION_ERROR
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def test_timeout_race_successful_exit_classifies_ok(
|
|
610
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
611
|
+
) -> None:
|
|
612
|
+
fake_process = _FakeProcess(
|
|
613
|
+
returncode=0,
|
|
614
|
+
stdout='{"done":true}',
|
|
615
|
+
should_timeout=True,
|
|
616
|
+
returncode_after_kill=0,
|
|
617
|
+
)
|
|
618
|
+
outcome, _, _, _, _ = _run_once(
|
|
619
|
+
monkeypatch, tmp_path, fake_process, timeout_seconds=DEFAULT_TIMEOUT_SECONDS
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
assert fake_process.was_killed is True
|
|
623
|
+
assert outcome.is_ok is True
|
|
624
|
+
assert outcome.classification == CLASSIFICATION_OK
|
|
625
|
+
assert outcome.returncode == 0
|
|
626
|
+
assert outcome.stdout == '{"done":true}'
|