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.
Files changed (240) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +20 -20
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
  6. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
  7. package/_shared/advisor/scripts/tier_model_ids.py +18 -18
  8. package/_shared/pr-loop/CLAUDE.md +1 -0
  9. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  10. package/_shared/pr-loop/scripts/README.md +1 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
  13. package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
  14. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
  15. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
  16. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
  23. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
  24. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
  26. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
  27. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
  28. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
  29. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
  33. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  35. package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
  36. package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
  37. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
  38. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
  39. package/_shared/pr-loop/worker-spawn.md +186 -0
  40. package/agents/code-verifier.md +1 -1
  41. package/bin/ever-shipped-skills.mjs +3 -0
  42. package/bin/expand_home_directory_tokens.mjs +1 -1
  43. package/bin/install.mjs +5 -2
  44. package/hooks/advisory/refactor_guard.py +3 -4
  45. package/hooks/blocking/CLAUDE.md +7 -1
  46. package/hooks/blocking/block_main_commit.py +2 -2
  47. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  48. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  49. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  50. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  61. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  62. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  63. package/hooks/blocking/code_rules_optional_params.py +2 -2
  64. package/hooks/blocking/code_rules_shared.py +1 -1
  65. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  66. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  67. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  68. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  69. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  70. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  71. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  72. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  73. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  74. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  75. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  76. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  77. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  78. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  79. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  80. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  85. package/hooks/blocking/pii_payload_scan.py +138 -42
  86. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  87. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  88. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  89. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  90. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  91. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  92. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  93. package/hooks/blocking/pr_description_command_parser.py +8 -4
  94. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  95. package/hooks/blocking/tdd_enforcer.py +97 -608
  96. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  97. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  98. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  99. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  100. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  101. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  102. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  103. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  104. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  105. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  106. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  107. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  108. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  109. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  110. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  111. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  112. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  113. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  114. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  115. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  116. package/hooks/blocking/tests/conftest.py +10 -0
  117. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  118. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  119. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  120. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  121. package/hooks/git-hooks/post_commit.py +3 -4
  122. package/hooks/hooks_constants/CLAUDE.md +2 -2
  123. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  124. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  125. package/hooks/hooks_constants/local_identity.py +59 -8
  126. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  127. package/hooks/hooks_constants/test_local_identity.py +105 -3
  128. package/hooks/pyproject.toml +13 -36
  129. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  130. package/hooks/validation/mypy_validator.py +2 -2
  131. package/hooks/validators/health_check.py +1 -0
  132. package/hooks/validators/mypy_integration.py +2 -0
  133. package/hooks/validators/ruff_integration.py +3 -0
  134. package/hooks/workflow/auto_formatter.py +5 -4
  135. package/package.json +1 -1
  136. package/scripts/CLAUDE.md +4 -0
  137. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  138. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  139. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  140. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  141. package/scripts/grok_headless_runner.py +294 -0
  142. package/scripts/grok_worker_preflight.py +410 -0
  143. package/scripts/invoke_code_review.py +463 -0
  144. package/scripts/resolve_worker_spawn.py +619 -0
  145. package/scripts/spawn_grok_batch.py +672 -0
  146. package/scripts/test_grok_headless_runner.py +626 -0
  147. package/scripts/test_grok_worker_preflight.py +1054 -0
  148. package/scripts/test_invoke_code_review.py +672 -0
  149. package/scripts/test_resolve_worker_spawn.py +1014 -0
  150. package/scripts/test_spawn_grok_batch.py +1017 -0
  151. package/skills/CLAUDE.md +5 -3
  152. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  153. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  154. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  155. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  156. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  157. package/skills/autoconverge/CLAUDE.md +3 -3
  158. package/skills/autoconverge/SKILL.md +9 -3
  159. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  160. package/skills/autoconverge/reference/convergence.md +33 -11
  161. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  162. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  163. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  164. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  165. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  166. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  167. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  168. package/skills/autoconverge/workflow/converge.mjs +200 -16
  169. package/skills/bugteam/CLAUDE.md +2 -2
  170. package/skills/bugteam/CONSTRAINTS.md +3 -2
  171. package/skills/bugteam/PROMPTS.md +7 -6
  172. package/skills/bugteam/SKILL.md +18 -13
  173. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  174. package/skills/bugteam/reference/design-rationale.md +1 -1
  175. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  176. package/skills/bugteam/reference/team-setup.md +8 -2
  177. package/skills/codex-review/CLAUDE.md +46 -0
  178. package/skills/codex-review/SKILL.md +181 -0
  179. package/skills/codex-review/reference/CLAUDE.md +15 -0
  180. package/skills/codex-review/reference/cli-contract.md +253 -0
  181. package/skills/codex-review/reference/loop-integration.md +118 -0
  182. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  183. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  184. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  185. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  186. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  187. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  188. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  189. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  190. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  191. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  192. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  193. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  194. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  195. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  196. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  197. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  198. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  199. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  200. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  201. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  202. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  203. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  204. package/skills/codex-review/test_skill_scaffold.py +192 -0
  205. package/skills/grok-spawn/CLAUDE.md +28 -0
  206. package/skills/grok-spawn/SKILL.md +226 -0
  207. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  208. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  209. package/skills/grokify/SKILL.md +9 -1
  210. package/skills/grokify/capability-claims.test.mjs +28 -0
  211. package/skills/grokify/evals/README.md +72 -0
  212. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  213. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  214. package/skills/orchestrator/SKILL.md +15 -11
  215. package/skills/orchestrator-refresh/SKILL.md +5 -5
  216. package/skills/pr-converge/SKILL.md +34 -13
  217. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  218. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  219. package/skills/pr-converge/reference/ground-rules.md +1 -1
  220. package/skills/pr-converge/reference/per-tick.md +130 -42
  221. package/skills/pr-converge/reference/state-schema.md +10 -0
  222. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  223. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  224. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  225. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  226. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  227. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  228. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  229. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  230. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  231. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  232. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  233. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  234. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  235. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  236. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  237. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  238. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  239. package/skills/reviewer-gates/SKILL.md +7 -5
  240. 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}'