claude-dev-env 1.95.0 → 2.0.1
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/test_verifier_verdict_minter.py +55 -158
- 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 +42 -29
- package/skills/orchestrator-refresh/SKILL.md +17 -9
- 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,1014 @@
|
|
|
1
|
+
"""Behavioral tests for the worker-spawn tier dispatcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
16
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
17
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
18
|
+
|
|
19
|
+
import claude_chain_runner as chain_runner # noqa: E402
|
|
20
|
+
import resolve_worker_spawn as dispatcher # noqa: E402
|
|
21
|
+
from claude_chain_runner import ( # noqa: E402
|
|
22
|
+
ChainAttempt,
|
|
23
|
+
ChainConfigurationError,
|
|
24
|
+
ChainInvocationOutcome,
|
|
25
|
+
)
|
|
26
|
+
from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
27
|
+
AGENT_FLAG,
|
|
28
|
+
ALL_AGENT_FILENAMES_BY_ROLE,
|
|
29
|
+
ATTEMPT_KEY_OK,
|
|
30
|
+
ATTEMPT_KEY_REASON,
|
|
31
|
+
ATTEMPT_KEY_TIER,
|
|
32
|
+
CLI_ENABLE_CLAUDE_TIER_FLAG,
|
|
33
|
+
CLI_ROLE_FLAG,
|
|
34
|
+
CLI_RUN_STATE_DIR_FLAG,
|
|
35
|
+
CLI_TIMEOUT_FLAG,
|
|
36
|
+
CLASSIFICATION_AUTH_FAILURE,
|
|
37
|
+
CLASSIFICATION_OK,
|
|
38
|
+
CLASSIFICATION_USAGE_LIMIT,
|
|
39
|
+
CWD_FLAG,
|
|
40
|
+
DEFAULT_ROLE,
|
|
41
|
+
DEFAULT_SPAWN_MAX_TURNS,
|
|
42
|
+
DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
43
|
+
OUTPUT_FORMAT_FLAG,
|
|
44
|
+
OUTPUT_FORMAT_JSON,
|
|
45
|
+
PROMPT_FILE_FLAG,
|
|
46
|
+
REASON_CLAUDE_AGENT_REQUIRED,
|
|
47
|
+
REASON_GROK_AUTH_FAILED,
|
|
48
|
+
REASON_PROMPT_FILE_MISSING,
|
|
49
|
+
RESULT_KEY_ATTEMPTS,
|
|
50
|
+
RESULT_KEY_OK,
|
|
51
|
+
RESULT_KEY_OUTPUT,
|
|
52
|
+
RESULT_KEY_RETURNCODE,
|
|
53
|
+
RESULT_KEY_TIER_USED,
|
|
54
|
+
SINGLE_TURN_FLAG,
|
|
55
|
+
SPAWN_CONFIG_ERROR_EXIT_CODE,
|
|
56
|
+
SPAWN_EXHAUSTED_EXIT_CODE,
|
|
57
|
+
SPAWN_SERVED_EXIT_CODE,
|
|
58
|
+
TIER_CLAUDE_AGENT,
|
|
59
|
+
TIER_CLAUDE_HEADLESS,
|
|
60
|
+
TIER_GROK,
|
|
61
|
+
)
|
|
62
|
+
from grok_headless_runner import GrokRunnerOutcome # noqa: E402
|
|
63
|
+
from grok_worker_preflight import PreflightOutcome # noqa: E402
|
|
64
|
+
|
|
65
|
+
HOST_PROFILE_CLAUDE = "Claude"
|
|
66
|
+
HOST_PROFILE_THIRD_PARTY = "ThirdParty"
|
|
67
|
+
|
|
68
|
+
FIXTURE_GROK_STDOUT = '{"tier":"grok","status":"done"}'
|
|
69
|
+
FIXTURE_CLAUDE_STDOUT = '{"tier":"claude","status":"done"}'
|
|
70
|
+
FIXTURE_PROMPT_TEXT = "do the work"
|
|
71
|
+
FIXTURE_GROK_RETURNCODE = 0
|
|
72
|
+
FIXTURE_CLAUDE_RETURNCODE = 0
|
|
73
|
+
FIXTURE_FAILED_RETURNCODE = 1
|
|
74
|
+
FIXTURE_ROLE = "code-quality-agent"
|
|
75
|
+
LARGE_PROMPT_CHARACTER_COUNT = 40000
|
|
76
|
+
WINDOWS_SAFE_ARGV_ELEMENT_CEILING = 8192
|
|
77
|
+
EXPECTED_PRIMARY_AGENT_FOR_DEFAULT_ROLE = Path(
|
|
78
|
+
ALL_AGENT_FILENAMES_BY_ROLE[DEFAULT_ROLE][0]
|
|
79
|
+
).stem
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _usable_preflight() -> PreflightOutcome:
|
|
83
|
+
return PreflightOutcome(is_usable=True, reason=None)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fallthrough_preflight(reason: str) -> PreflightOutcome:
|
|
87
|
+
return PreflightOutcome(is_usable=False, reason=reason)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _grok_ok(stdout: str = FIXTURE_GROK_STDOUT) -> GrokRunnerOutcome:
|
|
91
|
+
return GrokRunnerOutcome(
|
|
92
|
+
is_ok=True,
|
|
93
|
+
returncode=FIXTURE_GROK_RETURNCODE,
|
|
94
|
+
classification=CLASSIFICATION_OK,
|
|
95
|
+
stdout=stdout,
|
|
96
|
+
stderr="",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _grok_failure(
|
|
101
|
+
classification: str,
|
|
102
|
+
*,
|
|
103
|
+
returncode: int = FIXTURE_FAILED_RETURNCODE,
|
|
104
|
+
stdout: str = "",
|
|
105
|
+
stderr: str = "",
|
|
106
|
+
) -> GrokRunnerOutcome:
|
|
107
|
+
return GrokRunnerOutcome(
|
|
108
|
+
is_ok=False,
|
|
109
|
+
returncode=returncode,
|
|
110
|
+
classification=classification,
|
|
111
|
+
stdout=stdout,
|
|
112
|
+
stderr=stderr,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _claude_served(
|
|
117
|
+
stdout: str = FIXTURE_CLAUDE_STDOUT,
|
|
118
|
+
*,
|
|
119
|
+
returncode: int = FIXTURE_CLAUDE_RETURNCODE,
|
|
120
|
+
) -> ChainInvocationOutcome:
|
|
121
|
+
return ChainInvocationOutcome(
|
|
122
|
+
served_command="claude",
|
|
123
|
+
returncode=returncode,
|
|
124
|
+
stdout=stdout,
|
|
125
|
+
stderr="",
|
|
126
|
+
attempts=(ChainAttempt(command="claude", status="served"),),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _claude_exhausted() -> ChainInvocationOutcome:
|
|
131
|
+
return ChainInvocationOutcome(
|
|
132
|
+
served_command=None,
|
|
133
|
+
returncode=FIXTURE_FAILED_RETURNCODE,
|
|
134
|
+
stdout="",
|
|
135
|
+
stderr="usage limit reached",
|
|
136
|
+
attempts=(ChainAttempt(command="claude", status="usage_limited"),),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _paths(tmp_path: Path) -> tuple[Path, Path, Path]:
|
|
141
|
+
prompt_file = tmp_path / "prompt.txt"
|
|
142
|
+
prompt_file.write_text(FIXTURE_PROMPT_TEXT, encoding="utf-8")
|
|
143
|
+
working_directory = tmp_path / "project"
|
|
144
|
+
working_directory.mkdir()
|
|
145
|
+
run_state_directory = tmp_path / "run-state"
|
|
146
|
+
run_state_directory.mkdir()
|
|
147
|
+
return prompt_file, working_directory, run_state_directory
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class SeamCallLog:
|
|
152
|
+
preflight_calls: int = 0
|
|
153
|
+
grok_calls: int = 0
|
|
154
|
+
claude_calls: int = 0
|
|
155
|
+
claude_arguments: list[str] | None = None
|
|
156
|
+
host_profile_calls: int = 0
|
|
157
|
+
is_stdin_from_prompt_file: bool = False
|
|
158
|
+
claude_stdin_path: Path | None = None
|
|
159
|
+
claude_working_directory: Path | None = None
|
|
160
|
+
grok_keyword_arguments: dict[str, object] | None = None
|
|
161
|
+
max_argv_element_length: int = 0
|
|
162
|
+
all_observed_working_directories: list[Path] = field(default_factory=list)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _install_seams(
|
|
166
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
167
|
+
*,
|
|
168
|
+
preflight_outcome: PreflightOutcome = _usable_preflight(),
|
|
169
|
+
grok_outcome: GrokRunnerOutcome | None = None,
|
|
170
|
+
claude_outcome: ChainInvocationOutcome | BaseException | None = None,
|
|
171
|
+
host_profile: str = HOST_PROFILE_CLAUDE,
|
|
172
|
+
) -> SeamCallLog:
|
|
173
|
+
call_log = SeamCallLog()
|
|
174
|
+
|
|
175
|
+
def fake_preflight(**_keyword_arguments: object) -> PreflightOutcome:
|
|
176
|
+
call_log.preflight_calls += 1
|
|
177
|
+
return preflight_outcome
|
|
178
|
+
|
|
179
|
+
def fake_grok(**keyword_arguments: object) -> GrokRunnerOutcome:
|
|
180
|
+
call_log.grok_calls += 1
|
|
181
|
+
call_log.grok_keyword_arguments = dict(keyword_arguments)
|
|
182
|
+
assert grok_outcome is not None
|
|
183
|
+
return grok_outcome
|
|
184
|
+
|
|
185
|
+
def fake_claude(
|
|
186
|
+
all_claude_arguments: list[str], *, timeout_seconds: int
|
|
187
|
+
) -> ChainInvocationOutcome:
|
|
188
|
+
call_log.claude_calls += 1
|
|
189
|
+
call_log.claude_arguments = list(all_claude_arguments)
|
|
190
|
+
chain_runner.chain_subprocess_runner(
|
|
191
|
+
["claude", *all_claude_arguments],
|
|
192
|
+
capture_output=True,
|
|
193
|
+
text=True,
|
|
194
|
+
timeout=timeout_seconds,
|
|
195
|
+
check=False,
|
|
196
|
+
)
|
|
197
|
+
if isinstance(claude_outcome, BaseException):
|
|
198
|
+
raise claude_outcome
|
|
199
|
+
assert isinstance(claude_outcome, ChainInvocationOutcome)
|
|
200
|
+
return claude_outcome
|
|
201
|
+
|
|
202
|
+
def fake_host_profile(
|
|
203
|
+
setting_by_name: object | None = None,
|
|
204
|
+
) -> str:
|
|
205
|
+
del setting_by_name
|
|
206
|
+
call_log.host_profile_calls += 1
|
|
207
|
+
return host_profile
|
|
208
|
+
|
|
209
|
+
monkeypatch.setattr(dispatcher, "spawn_preflight_runner", fake_preflight)
|
|
210
|
+
monkeypatch.setattr(dispatcher, "spawn_grok_runner", fake_grok)
|
|
211
|
+
monkeypatch.setattr(dispatcher, "spawn_claude_runner", fake_claude)
|
|
212
|
+
monkeypatch.setattr(dispatcher, "spawn_host_profile_detector", fake_host_profile)
|
|
213
|
+
|
|
214
|
+
def _tracking_subprocess_runner(
|
|
215
|
+
all_invocation_tokens: Sequence[str],
|
|
216
|
+
*all_positionals: object,
|
|
217
|
+
**all_keywords: object,
|
|
218
|
+
) -> subprocess.CompletedProcess[str]:
|
|
219
|
+
del all_positionals
|
|
220
|
+
for each_token in all_invocation_tokens:
|
|
221
|
+
call_log.max_argv_element_length = max(
|
|
222
|
+
call_log.max_argv_element_length, len(str(each_token))
|
|
223
|
+
)
|
|
224
|
+
maybe_stdin = all_keywords.get("stdin")
|
|
225
|
+
if maybe_stdin is not None and maybe_stdin is not subprocess.DEVNULL:
|
|
226
|
+
call_log.is_stdin_from_prompt_file = True
|
|
227
|
+
maybe_name = getattr(maybe_stdin, "name", None)
|
|
228
|
+
if maybe_name is not None:
|
|
229
|
+
call_log.claude_stdin_path = Path(str(maybe_name))
|
|
230
|
+
maybe_cwd = all_keywords.get("cwd")
|
|
231
|
+
if maybe_cwd is not None:
|
|
232
|
+
working_directory = Path(str(maybe_cwd))
|
|
233
|
+
call_log.claude_working_directory = working_directory
|
|
234
|
+
call_log.all_observed_working_directories.append(working_directory)
|
|
235
|
+
return subprocess.CompletedProcess(
|
|
236
|
+
args=list(all_invocation_tokens),
|
|
237
|
+
returncode=0,
|
|
238
|
+
stdout="{}",
|
|
239
|
+
stderr="",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
monkeypatch.setattr(
|
|
243
|
+
chain_runner, "chain_subprocess_runner", _tracking_subprocess_runner
|
|
244
|
+
)
|
|
245
|
+
return call_log
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_grok_ok_serves_tier_one(
|
|
249
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
250
|
+
) -> None:
|
|
251
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
252
|
+
call_log = _install_seams(
|
|
253
|
+
monkeypatch,
|
|
254
|
+
grok_outcome=_grok_ok(),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
spawn_outcome = dispatcher.resolve_worker_spawn(
|
|
258
|
+
role=FIXTURE_ROLE,
|
|
259
|
+
prompt_file=prompt_file,
|
|
260
|
+
working_directory=working_directory,
|
|
261
|
+
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
262
|
+
is_claude_tier_enabled=False,
|
|
263
|
+
run_state_directory=run_state_directory,
|
|
264
|
+
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
assert spawn_outcome.is_ok is True
|
|
268
|
+
assert spawn_outcome.tier_used == TIER_GROK
|
|
269
|
+
assert spawn_outcome.captured_stdout == FIXTURE_GROK_STDOUT
|
|
270
|
+
assert spawn_outcome.returncode == FIXTURE_GROK_RETURNCODE
|
|
271
|
+
assert call_log.preflight_calls == 1
|
|
272
|
+
assert call_log.grok_calls == 1
|
|
273
|
+
assert call_log.claude_calls == 0
|
|
274
|
+
assert call_log.host_profile_calls == 0
|
|
275
|
+
assert call_log.grok_keyword_arguments is not None
|
|
276
|
+
assert call_log.grok_keyword_arguments["agent_name"] == FIXTURE_ROLE
|
|
277
|
+
assert len(spawn_outcome.all_attempts) == 1
|
|
278
|
+
assert spawn_outcome.all_attempts[0].tier == TIER_GROK
|
|
279
|
+
assert spawn_outcome.all_attempts[0].is_ok is True
|
|
280
|
+
assert spawn_outcome.all_attempts[0].reason is None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_grok_usage_limited_on_claude_host_requires_agent(
|
|
284
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
285
|
+
) -> None:
|
|
286
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
287
|
+
call_log = _install_seams(
|
|
288
|
+
monkeypatch,
|
|
289
|
+
grok_outcome=_grok_failure(CLASSIFICATION_USAGE_LIMIT),
|
|
290
|
+
host_profile=HOST_PROFILE_CLAUDE,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
spawn_outcome = dispatcher.resolve_worker_spawn(
|
|
294
|
+
role=FIXTURE_ROLE,
|
|
295
|
+
prompt_file=prompt_file,
|
|
296
|
+
working_directory=working_directory,
|
|
297
|
+
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
298
|
+
is_claude_tier_enabled=False,
|
|
299
|
+
run_state_directory=run_state_directory,
|
|
300
|
+
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
assert spawn_outcome.is_ok is False
|
|
304
|
+
assert spawn_outcome.tier_used is None
|
|
305
|
+
assert call_log.claude_calls == 0
|
|
306
|
+
assert call_log.host_profile_calls == 1
|
|
307
|
+
all_reasons = [each_attempt.reason for each_attempt in spawn_outcome.all_attempts]
|
|
308
|
+
assert REASON_CLAUDE_AGENT_REQUIRED in all_reasons
|
|
309
|
+
assert spawn_outcome.all_attempts[0].tier == TIER_GROK
|
|
310
|
+
assert spawn_outcome.all_attempts[0].is_ok is False
|
|
311
|
+
assert spawn_outcome.all_attempts[0].reason == CLASSIFICATION_USAGE_LIMIT
|
|
312
|
+
assert spawn_outcome.all_attempts[1].tier == TIER_CLAUDE_AGENT
|
|
313
|
+
assert spawn_outcome.all_attempts[1].reason == REASON_CLAUDE_AGENT_REQUIRED
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_grok_auth_failed_on_third_party_runs_tier_three(
|
|
317
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
318
|
+
) -> None:
|
|
319
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
320
|
+
call_log = _install_seams(
|
|
321
|
+
monkeypatch,
|
|
322
|
+
grok_outcome=_grok_failure(CLASSIFICATION_AUTH_FAILURE),
|
|
323
|
+
claude_outcome=_claude_served(),
|
|
324
|
+
host_profile=HOST_PROFILE_THIRD_PARTY,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
spawn_outcome = dispatcher.resolve_worker_spawn(
|
|
328
|
+
role=FIXTURE_ROLE,
|
|
329
|
+
prompt_file=prompt_file,
|
|
330
|
+
working_directory=working_directory,
|
|
331
|
+
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
332
|
+
is_claude_tier_enabled=False,
|
|
333
|
+
run_state_directory=run_state_directory,
|
|
334
|
+
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
assert spawn_outcome.is_ok is True
|
|
338
|
+
assert spawn_outcome.tier_used == TIER_CLAUDE_HEADLESS
|
|
339
|
+
assert spawn_outcome.captured_stdout == FIXTURE_CLAUDE_STDOUT
|
|
340
|
+
assert call_log.claude_calls == 1
|
|
341
|
+
assert call_log.claude_arguments is not None
|
|
342
|
+
assert PROMPT_FILE_FLAG not in call_log.claude_arguments
|
|
343
|
+
assert FIXTURE_PROMPT_TEXT not in call_log.claude_arguments
|
|
344
|
+
assert call_log.claude_arguments == [
|
|
345
|
+
SINGLE_TURN_FLAG,
|
|
346
|
+
OUTPUT_FORMAT_FLAG,
|
|
347
|
+
OUTPUT_FORMAT_JSON,
|
|
348
|
+
AGENT_FLAG,
|
|
349
|
+
FIXTURE_ROLE,
|
|
350
|
+
]
|
|
351
|
+
assert call_log.is_stdin_from_prompt_file is True
|
|
352
|
+
assert call_log.claude_stdin_path == prompt_file
|
|
353
|
+
assert call_log.claude_working_directory == working_directory
|
|
354
|
+
assert spawn_outcome.all_attempts[0].tier == TIER_GROK
|
|
355
|
+
assert spawn_outcome.all_attempts[0].reason == CLASSIFICATION_AUTH_FAILURE
|
|
356
|
+
assert spawn_outcome.all_attempts[1].tier == TIER_CLAUDE_HEADLESS
|
|
357
|
+
assert spawn_outcome.all_attempts[1].is_ok is True
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_tier_three_exhausted_returns_exit_two(
|
|
361
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
362
|
+
) -> None:
|
|
363
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
364
|
+
_install_seams(
|
|
365
|
+
monkeypatch,
|
|
366
|
+
grok_outcome=_grok_failure(CLASSIFICATION_AUTH_FAILURE),
|
|
367
|
+
claude_outcome=_claude_exhausted(),
|
|
368
|
+
host_profile=HOST_PROFILE_THIRD_PARTY,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
exit_code = dispatcher.main(
|
|
372
|
+
[
|
|
373
|
+
CLI_ROLE_FLAG,
|
|
374
|
+
FIXTURE_ROLE,
|
|
375
|
+
PROMPT_FILE_FLAG,
|
|
376
|
+
str(prompt_file),
|
|
377
|
+
CWD_FLAG,
|
|
378
|
+
str(working_directory),
|
|
379
|
+
CLI_TIMEOUT_FLAG,
|
|
380
|
+
str(DEFAULT_WORKER_TIMEOUT_SECONDS),
|
|
381
|
+
CLI_RUN_STATE_DIR_FLAG,
|
|
382
|
+
str(run_state_directory),
|
|
383
|
+
]
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
assert exit_code == SPAWN_EXHAUSTED_EXIT_CODE
|
|
387
|
+
captured = capsys.readouterr()
|
|
388
|
+
parsed_payload = json.loads(captured.out)
|
|
389
|
+
assert parsed_payload[RESULT_KEY_OK] is False
|
|
390
|
+
assert parsed_payload[RESULT_KEY_TIER_USED] is None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_config_error_returns_exit_three(
|
|
394
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
395
|
+
) -> None:
|
|
396
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
397
|
+
_install_seams(
|
|
398
|
+
monkeypatch,
|
|
399
|
+
grok_outcome=_grok_failure(CLASSIFICATION_AUTH_FAILURE),
|
|
400
|
+
claude_outcome=ChainConfigurationError("chain config missing"),
|
|
401
|
+
host_profile=HOST_PROFILE_THIRD_PARTY,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
exit_code = dispatcher.main(
|
|
405
|
+
[
|
|
406
|
+
CLI_ROLE_FLAG,
|
|
407
|
+
FIXTURE_ROLE,
|
|
408
|
+
PROMPT_FILE_FLAG,
|
|
409
|
+
str(prompt_file),
|
|
410
|
+
CWD_FLAG,
|
|
411
|
+
str(working_directory),
|
|
412
|
+
CLI_TIMEOUT_FLAG,
|
|
413
|
+
str(DEFAULT_WORKER_TIMEOUT_SECONDS),
|
|
414
|
+
CLI_RUN_STATE_DIR_FLAG,
|
|
415
|
+
str(run_state_directory),
|
|
416
|
+
]
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
assert exit_code == SPAWN_CONFIG_ERROR_EXIT_CODE
|
|
420
|
+
captured = capsys.readouterr()
|
|
421
|
+
parsed_payload = json.loads(captured.out)
|
|
422
|
+
assert parsed_payload[RESULT_KEY_OK] is False
|
|
423
|
+
assert parsed_payload[RESULT_KEY_RETURNCODE] == SPAWN_CONFIG_ERROR_EXIT_CODE
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def test_attempts_array_ordering_across_tiers(
|
|
427
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
428
|
+
) -> None:
|
|
429
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
430
|
+
_install_seams(
|
|
431
|
+
monkeypatch,
|
|
432
|
+
grok_outcome=_grok_failure(CLASSIFICATION_USAGE_LIMIT),
|
|
433
|
+
claude_outcome=_claude_served(),
|
|
434
|
+
host_profile=HOST_PROFILE_CLAUDE,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
spawn_outcome = dispatcher.resolve_worker_spawn(
|
|
438
|
+
role=FIXTURE_ROLE,
|
|
439
|
+
prompt_file=prompt_file,
|
|
440
|
+
working_directory=working_directory,
|
|
441
|
+
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
442
|
+
is_claude_tier_enabled=True,
|
|
443
|
+
run_state_directory=run_state_directory,
|
|
444
|
+
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
all_tiers = [each_attempt.tier for each_attempt in spawn_outcome.all_attempts]
|
|
448
|
+
assert all_tiers == [TIER_GROK, TIER_CLAUDE_HEADLESS]
|
|
449
|
+
assert spawn_outcome.tier_used == TIER_CLAUDE_HEADLESS
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_cli_stdout_carries_only_json_result(
|
|
453
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
454
|
+
) -> None:
|
|
455
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
456
|
+
_install_seams(
|
|
457
|
+
monkeypatch,
|
|
458
|
+
grok_outcome=_grok_ok(),
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
exit_code = dispatcher.main(
|
|
462
|
+
[
|
|
463
|
+
CLI_ROLE_FLAG,
|
|
464
|
+
FIXTURE_ROLE,
|
|
465
|
+
PROMPT_FILE_FLAG,
|
|
466
|
+
str(prompt_file),
|
|
467
|
+
CWD_FLAG,
|
|
468
|
+
str(working_directory),
|
|
469
|
+
CLI_TIMEOUT_FLAG,
|
|
470
|
+
str(DEFAULT_WORKER_TIMEOUT_SECONDS),
|
|
471
|
+
CLI_RUN_STATE_DIR_FLAG,
|
|
472
|
+
str(run_state_directory),
|
|
473
|
+
]
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
assert exit_code == SPAWN_SERVED_EXIT_CODE
|
|
477
|
+
captured = capsys.readouterr()
|
|
478
|
+
assert captured.err == ""
|
|
479
|
+
parsed_payload = json.loads(captured.out)
|
|
480
|
+
assert captured.out == json.dumps(
|
|
481
|
+
parsed_payload
|
|
482
|
+
) + "\n" or captured.out.strip() == json.dumps(parsed_payload)
|
|
483
|
+
reparsed = json.loads(captured.out.strip())
|
|
484
|
+
assert reparsed is not None
|
|
485
|
+
assert set(reparsed.keys()) == {
|
|
486
|
+
RESULT_KEY_TIER_USED,
|
|
487
|
+
RESULT_KEY_OK,
|
|
488
|
+
RESULT_KEY_ATTEMPTS,
|
|
489
|
+
RESULT_KEY_OUTPUT,
|
|
490
|
+
RESULT_KEY_RETURNCODE,
|
|
491
|
+
}
|
|
492
|
+
assert reparsed[RESULT_KEY_TIER_USED] == TIER_GROK
|
|
493
|
+
assert reparsed[RESULT_KEY_OK] is True
|
|
494
|
+
assert reparsed[RESULT_KEY_OUTPUT] == FIXTURE_GROK_STDOUT
|
|
495
|
+
first_attempt = reparsed[RESULT_KEY_ATTEMPTS][0]
|
|
496
|
+
assert first_attempt[ATTEMPT_KEY_TIER] == TIER_GROK
|
|
497
|
+
assert first_attempt[ATTEMPT_KEY_OK] is True
|
|
498
|
+
assert first_attempt[ATTEMPT_KEY_REASON] is None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def test_preflight_fallthrough_skips_grok_runner(
|
|
502
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
503
|
+
) -> None:
|
|
504
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
505
|
+
call_log = _install_seams(
|
|
506
|
+
monkeypatch,
|
|
507
|
+
preflight_outcome=_fallthrough_preflight(REASON_GROK_AUTH_FAILED),
|
|
508
|
+
host_profile=HOST_PROFILE_CLAUDE,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
spawn_outcome = dispatcher.resolve_worker_spawn(
|
|
512
|
+
role=DEFAULT_ROLE,
|
|
513
|
+
prompt_file=prompt_file,
|
|
514
|
+
working_directory=working_directory,
|
|
515
|
+
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
516
|
+
is_claude_tier_enabled=False,
|
|
517
|
+
run_state_directory=run_state_directory,
|
|
518
|
+
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
assert call_log.grok_calls == 0
|
|
522
|
+
assert spawn_outcome.all_attempts[0].reason == REASON_GROK_AUTH_FAILED
|
|
523
|
+
assert REASON_CLAUDE_AGENT_REQUIRED in [
|
|
524
|
+
each_attempt.reason for each_attempt in spawn_outcome.all_attempts
|
|
525
|
+
]
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def test_detect_host_profile_is_consumed_not_reimplemented() -> None:
|
|
529
|
+
source_text = Path(dispatcher.__file__).read_text(encoding="utf-8")
|
|
530
|
+
assert "spawn_host_profile_detector" in source_text
|
|
531
|
+
assert "detect_host_profile" in source_text
|
|
532
|
+
assert "ADVISOR_HOST_PROFILE" not in source_text
|
|
533
|
+
assert "THIRD_PARTY" not in source_text
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def test_default_max_turns_reaches_grok_kwargs(
|
|
537
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
538
|
+
) -> None:
|
|
539
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
540
|
+
call_log = _install_seams(
|
|
541
|
+
monkeypatch,
|
|
542
|
+
grok_outcome=_grok_ok(),
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
exit_code = dispatcher.main(
|
|
546
|
+
[
|
|
547
|
+
CLI_ROLE_FLAG,
|
|
548
|
+
FIXTURE_ROLE,
|
|
549
|
+
PROMPT_FILE_FLAG,
|
|
550
|
+
str(prompt_file),
|
|
551
|
+
CWD_FLAG,
|
|
552
|
+
str(working_directory),
|
|
553
|
+
CLI_TIMEOUT_FLAG,
|
|
554
|
+
str(DEFAULT_WORKER_TIMEOUT_SECONDS),
|
|
555
|
+
CLI_RUN_STATE_DIR_FLAG,
|
|
556
|
+
str(run_state_directory),
|
|
557
|
+
]
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
assert exit_code == SPAWN_SERVED_EXIT_CODE
|
|
561
|
+
assert call_log.grok_keyword_arguments is not None
|
|
562
|
+
assert call_log.grok_keyword_arguments["max_turns"] == DEFAULT_SPAWN_MAX_TURNS
|
|
563
|
+
parsed_payload = json.loads(capsys.readouterr().out)
|
|
564
|
+
assert parsed_payload[RESULT_KEY_OK] is True
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def test_encode_spawn_outcome_shape() -> None:
|
|
568
|
+
spawn_outcome = dispatcher.SpawnOutcome(
|
|
569
|
+
tier_used=TIER_GROK,
|
|
570
|
+
is_ok=True,
|
|
571
|
+
all_attempts=(
|
|
572
|
+
dispatcher.SpawnAttempt(tier=TIER_GROK, is_ok=True, reason=None),
|
|
573
|
+
),
|
|
574
|
+
captured_stdout=FIXTURE_GROK_STDOUT,
|
|
575
|
+
returncode=FIXTURE_GROK_RETURNCODE,
|
|
576
|
+
)
|
|
577
|
+
encoded_payload = dispatcher.encode_spawn_outcome(spawn_outcome)
|
|
578
|
+
assert encoded_payload[RESULT_KEY_TIER_USED] == TIER_GROK
|
|
579
|
+
assert encoded_payload[RESULT_KEY_OK] is True
|
|
580
|
+
assert encoded_payload[RESULT_KEY_OUTPUT] == FIXTURE_GROK_STDOUT
|
|
581
|
+
assert encoded_payload[RESULT_KEY_RETURNCODE] == FIXTURE_GROK_RETURNCODE
|
|
582
|
+
assert encoded_payload[RESULT_KEY_ATTEMPTS] == [
|
|
583
|
+
{
|
|
584
|
+
ATTEMPT_KEY_TIER: TIER_GROK,
|
|
585
|
+
ATTEMPT_KEY_OK: True,
|
|
586
|
+
ATTEMPT_KEY_REASON: None,
|
|
587
|
+
}
|
|
588
|
+
]
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def test_enable_claude_tier_flag_reaches_tier_three_on_claude_host(
|
|
592
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
593
|
+
) -> None:
|
|
594
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
595
|
+
call_log = _install_seams(
|
|
596
|
+
monkeypatch,
|
|
597
|
+
grok_outcome=_grok_failure(CLASSIFICATION_USAGE_LIMIT),
|
|
598
|
+
claude_outcome=_claude_served(),
|
|
599
|
+
host_profile=HOST_PROFILE_CLAUDE,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
exit_code = dispatcher.main(
|
|
603
|
+
[
|
|
604
|
+
CLI_ROLE_FLAG,
|
|
605
|
+
FIXTURE_ROLE,
|
|
606
|
+
PROMPT_FILE_FLAG,
|
|
607
|
+
str(prompt_file),
|
|
608
|
+
CWD_FLAG,
|
|
609
|
+
str(working_directory),
|
|
610
|
+
CLI_TIMEOUT_FLAG,
|
|
611
|
+
str(DEFAULT_WORKER_TIMEOUT_SECONDS),
|
|
612
|
+
CLI_ENABLE_CLAUDE_TIER_FLAG,
|
|
613
|
+
CLI_RUN_STATE_DIR_FLAG,
|
|
614
|
+
str(run_state_directory),
|
|
615
|
+
]
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
assert exit_code == SPAWN_SERVED_EXIT_CODE
|
|
619
|
+
assert call_log.claude_calls == 1
|
|
620
|
+
assert call_log.is_stdin_from_prompt_file is True
|
|
621
|
+
assert call_log.claude_stdin_path == prompt_file
|
|
622
|
+
assert call_log.claude_working_directory == working_directory
|
|
623
|
+
assert call_log.claude_arguments is not None
|
|
624
|
+
assert PROMPT_FILE_FLAG not in call_log.claude_arguments
|
|
625
|
+
assert SINGLE_TURN_FLAG in call_log.claude_arguments
|
|
626
|
+
assert OUTPUT_FORMAT_JSON in call_log.claude_arguments
|
|
627
|
+
assert AGENT_FLAG in call_log.claude_arguments
|
|
628
|
+
parsed_payload = json.loads(capsys.readouterr().out)
|
|
629
|
+
assert parsed_payload[RESULT_KEY_TIER_USED] == TIER_CLAUDE_HEADLESS
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def test_default_role_maps_to_primary_agent_stem(
|
|
633
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
634
|
+
) -> None:
|
|
635
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
636
|
+
call_log = _install_seams(
|
|
637
|
+
monkeypatch,
|
|
638
|
+
grok_outcome=_grok_ok(),
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
spawn_outcome = dispatcher.resolve_worker_spawn(
|
|
642
|
+
role=DEFAULT_ROLE,
|
|
643
|
+
prompt_file=prompt_file,
|
|
644
|
+
working_directory=working_directory,
|
|
645
|
+
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
646
|
+
is_claude_tier_enabled=False,
|
|
647
|
+
run_state_directory=run_state_directory,
|
|
648
|
+
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
assert spawn_outcome.is_ok is True
|
|
652
|
+
assert call_log.grok_keyword_arguments is not None
|
|
653
|
+
agent_name = call_log.grok_keyword_arguments["agent_name"]
|
|
654
|
+
assert agent_name == EXPECTED_PRIMARY_AGENT_FOR_DEFAULT_ROLE
|
|
655
|
+
assert agent_name != DEFAULT_ROLE
|
|
656
|
+
assert agent_name != "bugteam"
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def test_tier_three_argv_includes_agent_for_default_role(
|
|
660
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
661
|
+
) -> None:
|
|
662
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
663
|
+
call_log = _install_seams(
|
|
664
|
+
monkeypatch,
|
|
665
|
+
grok_outcome=_grok_failure(CLASSIFICATION_AUTH_FAILURE),
|
|
666
|
+
claude_outcome=_claude_served(),
|
|
667
|
+
host_profile=HOST_PROFILE_THIRD_PARTY,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
spawn_outcome = dispatcher.resolve_worker_spawn(
|
|
671
|
+
role=DEFAULT_ROLE,
|
|
672
|
+
prompt_file=prompt_file,
|
|
673
|
+
working_directory=working_directory,
|
|
674
|
+
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
675
|
+
is_claude_tier_enabled=False,
|
|
676
|
+
run_state_directory=run_state_directory,
|
|
677
|
+
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
assert spawn_outcome.tier_used == TIER_CLAUDE_HEADLESS
|
|
681
|
+
assert call_log.claude_arguments is not None
|
|
682
|
+
assert AGENT_FLAG in call_log.claude_arguments
|
|
683
|
+
assert EXPECTED_PRIMARY_AGENT_FOR_DEFAULT_ROLE in call_log.claude_arguments
|
|
684
|
+
agent_flag_index = call_log.claude_arguments.index(AGENT_FLAG)
|
|
685
|
+
assert (
|
|
686
|
+
call_log.claude_arguments[agent_flag_index + 1]
|
|
687
|
+
== EXPECTED_PRIMARY_AGENT_FOR_DEFAULT_ROLE
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def test_large_prompt_stays_out_of_claude_argv(
|
|
692
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
693
|
+
) -> None:
|
|
694
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
695
|
+
large_prompt_text = "x" * LARGE_PROMPT_CHARACTER_COUNT
|
|
696
|
+
prompt_file.write_text(large_prompt_text, encoding="utf-8")
|
|
697
|
+
call_log = _install_seams(
|
|
698
|
+
monkeypatch,
|
|
699
|
+
grok_outcome=_grok_failure(CLASSIFICATION_AUTH_FAILURE),
|
|
700
|
+
claude_outcome=_claude_served(),
|
|
701
|
+
host_profile=HOST_PROFILE_THIRD_PARTY,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
spawn_outcome = dispatcher.resolve_worker_spawn(
|
|
705
|
+
role=DEFAULT_ROLE,
|
|
706
|
+
prompt_file=prompt_file,
|
|
707
|
+
working_directory=working_directory,
|
|
708
|
+
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
709
|
+
is_claude_tier_enabled=False,
|
|
710
|
+
run_state_directory=run_state_directory,
|
|
711
|
+
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
assert spawn_outcome.tier_used == TIER_CLAUDE_HEADLESS
|
|
715
|
+
assert call_log.claude_arguments is not None
|
|
716
|
+
assert large_prompt_text not in call_log.claude_arguments
|
|
717
|
+
assert call_log.max_argv_element_length < WINDOWS_SAFE_ARGV_ELEMENT_CEILING
|
|
718
|
+
assert call_log.is_stdin_from_prompt_file is True
|
|
719
|
+
assert call_log.claude_stdin_path == prompt_file
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def test_missing_prompt_file_returns_json_config_exit(
|
|
723
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
724
|
+
) -> None:
|
|
725
|
+
working_directory = tmp_path / "project"
|
|
726
|
+
working_directory.mkdir()
|
|
727
|
+
run_state_directory = tmp_path / "run-state"
|
|
728
|
+
run_state_directory.mkdir()
|
|
729
|
+
missing_prompt_file = tmp_path / "absent-prompt.txt"
|
|
730
|
+
_install_seams(monkeypatch, grok_outcome=_grok_ok())
|
|
731
|
+
|
|
732
|
+
exit_code = dispatcher.main(
|
|
733
|
+
[
|
|
734
|
+
CLI_ROLE_FLAG,
|
|
735
|
+
FIXTURE_ROLE,
|
|
736
|
+
PROMPT_FILE_FLAG,
|
|
737
|
+
str(missing_prompt_file),
|
|
738
|
+
CWD_FLAG,
|
|
739
|
+
str(working_directory),
|
|
740
|
+
CLI_TIMEOUT_FLAG,
|
|
741
|
+
str(DEFAULT_WORKER_TIMEOUT_SECONDS),
|
|
742
|
+
CLI_RUN_STATE_DIR_FLAG,
|
|
743
|
+
str(run_state_directory),
|
|
744
|
+
]
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
assert exit_code == SPAWN_CONFIG_ERROR_EXIT_CODE
|
|
748
|
+
captured = capsys.readouterr()
|
|
749
|
+
assert captured.err == ""
|
|
750
|
+
parsed_payload = json.loads(captured.out)
|
|
751
|
+
assert parsed_payload[RESULT_KEY_OK] is False
|
|
752
|
+
assert parsed_payload[RESULT_KEY_TIER_USED] is None
|
|
753
|
+
assert parsed_payload[RESULT_KEY_OUTPUT] == REASON_PROMPT_FILE_MISSING
|
|
754
|
+
assert parsed_payload[RESULT_KEY_RETURNCODE] == SPAWN_CONFIG_ERROR_EXIT_CODE
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _deny_prompt_open(
|
|
758
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
759
|
+
prompt_file: Path,
|
|
760
|
+
open_error: OSError,
|
|
761
|
+
) -> None:
|
|
762
|
+
real_open = Path.open
|
|
763
|
+
resolved_prompt = prompt_file.resolve()
|
|
764
|
+
|
|
765
|
+
def open_with_denied_prompt(
|
|
766
|
+
self: Path,
|
|
767
|
+
*all_positionals: object,
|
|
768
|
+
**all_keywords: object,
|
|
769
|
+
) -> object:
|
|
770
|
+
if self.resolve() == resolved_prompt:
|
|
771
|
+
raise open_error
|
|
772
|
+
return real_open(self, *all_positionals, **all_keywords)
|
|
773
|
+
|
|
774
|
+
monkeypatch.setattr(Path, "open", open_with_denied_prompt)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _main_arguments_for_paths(
|
|
778
|
+
*,
|
|
779
|
+
prompt_file: Path,
|
|
780
|
+
working_directory: Path,
|
|
781
|
+
run_state_directory: Path,
|
|
782
|
+
) -> list[str]:
|
|
783
|
+
return [
|
|
784
|
+
CLI_ROLE_FLAG,
|
|
785
|
+
FIXTURE_ROLE,
|
|
786
|
+
PROMPT_FILE_FLAG,
|
|
787
|
+
str(prompt_file),
|
|
788
|
+
CWD_FLAG,
|
|
789
|
+
str(working_directory),
|
|
790
|
+
CLI_TIMEOUT_FLAG,
|
|
791
|
+
str(DEFAULT_WORKER_TIMEOUT_SECONDS),
|
|
792
|
+
CLI_RUN_STATE_DIR_FLAG,
|
|
793
|
+
str(run_state_directory),
|
|
794
|
+
]
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def test_unreadable_prompt_file_returns_json_config_exit(
|
|
798
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
799
|
+
) -> None:
|
|
800
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
801
|
+
_install_seams(
|
|
802
|
+
monkeypatch,
|
|
803
|
+
grok_outcome=_grok_failure(CLASSIFICATION_AUTH_FAILURE),
|
|
804
|
+
claude_outcome=_claude_served(),
|
|
805
|
+
host_profile=HOST_PROFILE_THIRD_PARTY,
|
|
806
|
+
)
|
|
807
|
+
_deny_prompt_open(
|
|
808
|
+
monkeypatch,
|
|
809
|
+
prompt_file,
|
|
810
|
+
PermissionError("Access is denied"),
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
exit_code = dispatcher.main(
|
|
814
|
+
_main_arguments_for_paths(
|
|
815
|
+
prompt_file=prompt_file,
|
|
816
|
+
working_directory=working_directory,
|
|
817
|
+
run_state_directory=run_state_directory,
|
|
818
|
+
)
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
assert exit_code == SPAWN_CONFIG_ERROR_EXIT_CODE
|
|
822
|
+
captured = capsys.readouterr()
|
|
823
|
+
assert captured.err == ""
|
|
824
|
+
parsed_payload = json.loads(captured.out)
|
|
825
|
+
assert parsed_payload[RESULT_KEY_OK] is False
|
|
826
|
+
assert parsed_payload[RESULT_KEY_TIER_USED] is None
|
|
827
|
+
assert parsed_payload[RESULT_KEY_OUTPUT] == REASON_PROMPT_FILE_MISSING
|
|
828
|
+
assert parsed_payload[RESULT_KEY_RETURNCODE] == SPAWN_CONFIG_ERROR_EXIT_CODE
|
|
829
|
+
assert "executable_not_found" not in json.dumps(parsed_payload)
|
|
830
|
+
all_attempt_reasons = [
|
|
831
|
+
each_attempt.get(ATTEMPT_KEY_REASON)
|
|
832
|
+
for each_attempt in parsed_payload[RESULT_KEY_ATTEMPTS]
|
|
833
|
+
]
|
|
834
|
+
assert REASON_PROMPT_FILE_MISSING in all_attempt_reasons
|
|
835
|
+
assert "executable_not_found" not in all_attempt_reasons
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def test_prompt_open_failure_not_classified_as_executable_not_found(
|
|
839
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
840
|
+
) -> None:
|
|
841
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
842
|
+
monkeypatch.setattr(
|
|
843
|
+
dispatcher,
|
|
844
|
+
"spawn_preflight_runner",
|
|
845
|
+
lambda **_keyword_arguments: _fallthrough_preflight(REASON_GROK_AUTH_FAILED),
|
|
846
|
+
)
|
|
847
|
+
monkeypatch.setattr(
|
|
848
|
+
dispatcher,
|
|
849
|
+
"spawn_host_profile_detector",
|
|
850
|
+
lambda *all_positionals, **all_keywords: HOST_PROFILE_THIRD_PARTY,
|
|
851
|
+
)
|
|
852
|
+
monkeypatch.setattr(
|
|
853
|
+
chain_runner,
|
|
854
|
+
"load_chain",
|
|
855
|
+
lambda _config_path: [chain_runner.ChainEntry(command="claude", extra_args=())],
|
|
856
|
+
)
|
|
857
|
+
monkeypatch.setattr(
|
|
858
|
+
chain_runner,
|
|
859
|
+
"chain_subprocess_runner",
|
|
860
|
+
lambda *all_positionals, **all_keywords: subprocess.CompletedProcess(
|
|
861
|
+
args=["claude"],
|
|
862
|
+
returncode=0,
|
|
863
|
+
stdout="{}",
|
|
864
|
+
stderr="",
|
|
865
|
+
),
|
|
866
|
+
)
|
|
867
|
+
_deny_prompt_open(
|
|
868
|
+
monkeypatch,
|
|
869
|
+
prompt_file,
|
|
870
|
+
FileNotFoundError(2, "No such file or directory", str(prompt_file)),
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
exit_code = dispatcher.main(
|
|
874
|
+
_main_arguments_for_paths(
|
|
875
|
+
prompt_file=prompt_file,
|
|
876
|
+
working_directory=working_directory,
|
|
877
|
+
run_state_directory=run_state_directory,
|
|
878
|
+
)
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
assert exit_code == SPAWN_CONFIG_ERROR_EXIT_CODE
|
|
882
|
+
captured = capsys.readouterr()
|
|
883
|
+
assert captured.err == ""
|
|
884
|
+
parsed_payload = json.loads(captured.out)
|
|
885
|
+
assert parsed_payload[RESULT_KEY_OK] is False
|
|
886
|
+
assert parsed_payload[RESULT_KEY_TIER_USED] is None
|
|
887
|
+
assert parsed_payload[RESULT_KEY_OUTPUT] == REASON_PROMPT_FILE_MISSING
|
|
888
|
+
assert parsed_payload[RESULT_KEY_RETURNCODE] == SPAWN_CONFIG_ERROR_EXIT_CODE
|
|
889
|
+
serialized_payload = json.dumps(parsed_payload)
|
|
890
|
+
assert "executable_not_found" not in serialized_payload
|
|
891
|
+
all_attempt_reasons = [
|
|
892
|
+
each_attempt.get(ATTEMPT_KEY_REASON)
|
|
893
|
+
for each_attempt in parsed_payload[RESULT_KEY_ATTEMPTS]
|
|
894
|
+
]
|
|
895
|
+
assert REASON_PROMPT_FILE_MISSING in all_attempt_reasons
|
|
896
|
+
assert "executable_not_found" not in all_attempt_reasons
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def test_headless_chain_runner_lock_serializes_distinct_cwds(
|
|
900
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
901
|
+
) -> None:
|
|
902
|
+
prompt_file = tmp_path / "prompt.txt"
|
|
903
|
+
prompt_file.write_text(FIXTURE_PROMPT_TEXT, encoding="utf-8")
|
|
904
|
+
first_working_directory = tmp_path / "project-a"
|
|
905
|
+
second_working_directory = tmp_path / "project-b"
|
|
906
|
+
first_working_directory.mkdir()
|
|
907
|
+
second_working_directory.mkdir()
|
|
908
|
+
run_state_directory = tmp_path / "run-state"
|
|
909
|
+
run_state_directory.mkdir()
|
|
910
|
+
call_log = _install_seams(
|
|
911
|
+
monkeypatch,
|
|
912
|
+
grok_outcome=_grok_failure(CLASSIFICATION_AUTH_FAILURE),
|
|
913
|
+
claude_outcome=_claude_served(),
|
|
914
|
+
host_profile=HOST_PROFILE_THIRD_PARTY,
|
|
915
|
+
)
|
|
916
|
+
all_errors: list[BaseException] = []
|
|
917
|
+
barrier = threading.Barrier(2)
|
|
918
|
+
|
|
919
|
+
def _run_with_working_directory(working_directory: Path) -> None:
|
|
920
|
+
try:
|
|
921
|
+
barrier.wait(timeout=5)
|
|
922
|
+
dispatcher.resolve_worker_spawn(
|
|
923
|
+
role=DEFAULT_ROLE,
|
|
924
|
+
prompt_file=prompt_file,
|
|
925
|
+
working_directory=working_directory,
|
|
926
|
+
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
927
|
+
is_claude_tier_enabled=False,
|
|
928
|
+
run_state_directory=run_state_directory,
|
|
929
|
+
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
930
|
+
)
|
|
931
|
+
except (OSError, RuntimeError, ValueError, AssertionError) as raised_error:
|
|
932
|
+
all_errors.append(raised_error)
|
|
933
|
+
|
|
934
|
+
first_thread = threading.Thread(
|
|
935
|
+
target=_run_with_working_directory, args=(first_working_directory,)
|
|
936
|
+
)
|
|
937
|
+
second_thread = threading.Thread(
|
|
938
|
+
target=_run_with_working_directory, args=(second_working_directory,)
|
|
939
|
+
)
|
|
940
|
+
first_thread.start()
|
|
941
|
+
second_thread.start()
|
|
942
|
+
first_thread.join(timeout=10)
|
|
943
|
+
second_thread.join(timeout=10)
|
|
944
|
+
|
|
945
|
+
assert all_errors == []
|
|
946
|
+
assert call_log.claude_calls == 2
|
|
947
|
+
assert set(call_log.all_observed_working_directories) == {
|
|
948
|
+
first_working_directory,
|
|
949
|
+
second_working_directory,
|
|
950
|
+
}
|
|
951
|
+
assert chain_runner.chain_subprocess_runner is not None
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def test_usage_limit_fallover_delivers_full_prompt_to_each_binary(
|
|
955
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
956
|
+
) -> None:
|
|
957
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
958
|
+
monkeypatch.setattr(
|
|
959
|
+
dispatcher,
|
|
960
|
+
"spawn_preflight_runner",
|
|
961
|
+
lambda **_keyword_arguments: _fallthrough_preflight(REASON_GROK_AUTH_FAILED),
|
|
962
|
+
)
|
|
963
|
+
monkeypatch.setattr(
|
|
964
|
+
dispatcher,
|
|
965
|
+
"spawn_host_profile_detector",
|
|
966
|
+
lambda *all_positionals, **all_keywords: HOST_PROFILE_THIRD_PARTY,
|
|
967
|
+
)
|
|
968
|
+
monkeypatch.setattr(
|
|
969
|
+
chain_runner,
|
|
970
|
+
"load_chain",
|
|
971
|
+
lambda _config_path: [
|
|
972
|
+
chain_runner.ChainEntry(command="claude", extra_args=()),
|
|
973
|
+
chain_runner.ChainEntry(command="claude-ev", extra_args=()),
|
|
974
|
+
],
|
|
975
|
+
)
|
|
976
|
+
prompt_text_by_command: dict[str, str] = {}
|
|
977
|
+
|
|
978
|
+
def _reading_subprocess_runner(
|
|
979
|
+
all_invocation_tokens: Sequence[str],
|
|
980
|
+
*all_positionals: object,
|
|
981
|
+
**all_keywords: object,
|
|
982
|
+
) -> subprocess.CompletedProcess[str]:
|
|
983
|
+
del all_positionals
|
|
984
|
+
command_name = str(all_invocation_tokens[0])
|
|
985
|
+
read_prompt = getattr(all_keywords.get("stdin"), "read", None)
|
|
986
|
+
prompt_text_by_command[command_name] = read_prompt() if read_prompt else ""
|
|
987
|
+
is_primary = command_name == "claude"
|
|
988
|
+
return subprocess.CompletedProcess(
|
|
989
|
+
args=list(all_invocation_tokens),
|
|
990
|
+
returncode=(
|
|
991
|
+
FIXTURE_FAILED_RETURNCODE if is_primary else SPAWN_SERVED_EXIT_CODE
|
|
992
|
+
),
|
|
993
|
+
stdout="usage limit reached" if is_primary else FIXTURE_CLAUDE_STDOUT,
|
|
994
|
+
stderr="",
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
monkeypatch.setattr(
|
|
998
|
+
chain_runner, "chain_subprocess_runner", _reading_subprocess_runner
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
outcome = dispatcher.resolve_worker_spawn(
|
|
1002
|
+
role=DEFAULT_ROLE,
|
|
1003
|
+
prompt_file=prompt_file,
|
|
1004
|
+
working_directory=working_directory,
|
|
1005
|
+
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
1006
|
+
is_claude_tier_enabled=False,
|
|
1007
|
+
run_state_directory=run_state_directory,
|
|
1008
|
+
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
assert prompt_text_by_command["claude"] == FIXTURE_PROMPT_TEXT
|
|
1012
|
+
assert prompt_text_by_command["claude-ev"] == FIXTURE_PROMPT_TEXT
|
|
1013
|
+
assert outcome.tier_used == TIER_CLAUDE_HEADLESS
|
|
1014
|
+
assert outcome.is_ok is True
|