claude-dev-env 1.94.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 (280) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +41 -33
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +17 -13
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +27 -18
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +95 -25
  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/CLAUDE.md +10 -1
  42. package/bin/ever-shipped-skills.mjs +73 -0
  43. package/bin/expand_home_directory_tokens.mjs +1 -1
  44. package/bin/install.mjs +140 -7
  45. package/bin/install.prune.test.mjs +457 -0
  46. package/docs/CODE_RULES.md +1 -1
  47. package/hooks/advisory/refactor_guard.py +3 -4
  48. package/hooks/blocking/CLAUDE.md +7 -1
  49. package/hooks/blocking/block_main_commit.py +2 -2
  50. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  61. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  62. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  63. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  64. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  65. package/hooks/blocking/code_rules_enforcer.py +4 -0
  66. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  67. package/hooks/blocking/code_rules_optional_params.py +2 -2
  68. package/hooks/blocking/code_rules_shared.py +83 -1
  69. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  70. package/hooks/blocking/code_rules_test_layout.py +9 -3
  71. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  72. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  73. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  74. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  75. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  76. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  77. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  78. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  79. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  80. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  85. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  86. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  87. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  88. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  89. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  90. package/hooks/blocking/pii_payload_scan.py +138 -42
  91. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  92. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  93. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  94. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  95. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  96. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  97. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  98. package/hooks/blocking/plain_language_blocker.py +138 -4
  99. package/hooks/blocking/pr_description_command_parser.py +8 -4
  100. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  101. package/hooks/blocking/sensitive_file_protector.py +114 -48
  102. package/hooks/blocking/tdd_enforcer.py +97 -601
  103. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  104. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  105. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  106. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  107. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  108. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  109. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  110. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  111. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  112. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  113. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  114. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  115. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  116. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  117. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  118. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  119. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  120. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
  121. package/hooks/blocking/test_code_rules_shared.py +181 -0
  122. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  123. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  124. package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
  125. package/hooks/blocking/test_sensitive_file_protector.py +185 -0
  126. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  127. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
  128. package/hooks/blocking/tests/conftest.py +10 -0
  129. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  130. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  131. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  132. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  133. package/hooks/git-hooks/post_commit.py +3 -4
  134. package/hooks/hooks_constants/CLAUDE.md +4 -2
  135. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  136. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  137. package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
  138. package/hooks/hooks_constants/local_identity.py +59 -8
  139. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  140. package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
  141. package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
  142. package/hooks/hooks_constants/test_local_identity.py +105 -3
  143. package/hooks/pyproject.toml +52 -4
  144. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  145. package/hooks/validation/mypy_validator.py +2 -2
  146. package/hooks/validators/CLAUDE.md +1 -1
  147. package/hooks/validators/README.md +2 -0
  148. package/hooks/validators/health_check.py +1 -0
  149. package/hooks/validators/mypy_integration.py +2 -0
  150. package/hooks/validators/python_style_checks.py +114 -136
  151. package/hooks/validators/python_style_helpers.py +95 -0
  152. package/hooks/validators/ruff_integration.py +3 -0
  153. package/hooks/validators/test_python_style_checks.py +0 -164
  154. package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
  155. package/hooks/validators/test_python_style_fixes.py +251 -0
  156. package/hooks/validators/test_python_style_helpers.py +125 -0
  157. package/hooks/workflow/auto_formatter.py +5 -4
  158. package/package.json +1 -1
  159. package/rules/CLAUDE.md +1 -0
  160. package/rules/anti-corollary-tests.md +69 -0
  161. package/rules/bdd.md +1 -3
  162. package/rules/code-reviews.md +1 -1
  163. package/rules/gh-paginate.md +1 -1
  164. package/rules/plain-language.md +2 -0
  165. package/scripts/CLAUDE.md +4 -0
  166. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  167. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  168. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  169. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  170. package/scripts/grok_headless_runner.py +294 -0
  171. package/scripts/grok_worker_preflight.py +410 -0
  172. package/scripts/invoke_code_review.py +463 -0
  173. package/scripts/resolve_worker_spawn.py +619 -0
  174. package/scripts/spawn_grok_batch.py +672 -0
  175. package/scripts/test_grok_headless_runner.py +626 -0
  176. package/scripts/test_grok_worker_preflight.py +1054 -0
  177. package/scripts/test_invoke_code_review.py +672 -0
  178. package/scripts/test_resolve_worker_spawn.py +1014 -0
  179. package/scripts/test_spawn_grok_batch.py +1017 -0
  180. package/skills/CLAUDE.md +6 -3
  181. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  182. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  183. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  184. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  185. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  186. package/skills/autoconverge/CLAUDE.md +3 -3
  187. package/skills/autoconverge/SKILL.md +9 -3
  188. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  189. package/skills/autoconverge/reference/convergence.md +33 -11
  190. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  191. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  192. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  193. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  194. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  195. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  196. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  197. package/skills/autoconverge/workflow/converge.mjs +202 -18
  198. package/skills/bugteam/CLAUDE.md +2 -2
  199. package/skills/bugteam/CONSTRAINTS.md +3 -2
  200. package/skills/bugteam/PROMPTS.md +7 -6
  201. package/skills/bugteam/SKILL.md +18 -13
  202. package/skills/bugteam/reference/README.md +2 -3
  203. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  204. package/skills/bugteam/reference/design-rationale.md +1 -1
  205. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  206. package/skills/bugteam/reference/team-setup.md +8 -2
  207. package/skills/closeout/SKILL.md +153 -0
  208. package/skills/closeout/reference/handoff-prompt-template.md +72 -0
  209. package/skills/closeout/reference/issue-body-templates.md +108 -0
  210. package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
  211. package/skills/codex-review/CLAUDE.md +46 -0
  212. package/skills/codex-review/SKILL.md +181 -0
  213. package/skills/codex-review/reference/CLAUDE.md +15 -0
  214. package/skills/codex-review/reference/cli-contract.md +253 -0
  215. package/skills/codex-review/reference/loop-integration.md +118 -0
  216. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  217. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  218. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  219. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  220. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  221. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  222. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  223. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  224. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  225. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  226. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  227. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  228. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  229. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  230. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  231. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  232. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  233. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  234. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  235. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  236. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  237. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  238. package/skills/codex-review/test_skill_scaffold.py +192 -0
  239. package/skills/grok-spawn/CLAUDE.md +28 -0
  240. package/skills/grok-spawn/SKILL.md +226 -0
  241. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  242. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  243. package/skills/grokify/SKILL.md +9 -1
  244. package/skills/grokify/capability-claims.test.mjs +28 -0
  245. package/skills/grokify/evals/README.md +72 -0
  246. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  247. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  248. package/skills/orchestrator/SKILL.md +32 -22
  249. package/skills/orchestrator-refresh/SKILL.md +12 -8
  250. package/skills/pr-converge/CLAUDE.md +1 -1
  251. package/skills/pr-converge/SKILL.md +34 -13
  252. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  253. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  254. package/skills/pr-converge/reference/ground-rules.md +1 -1
  255. package/skills/pr-converge/reference/per-tick.md +130 -42
  256. package/skills/pr-converge/reference/state-schema.md +10 -0
  257. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  258. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  259. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  260. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  261. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  262. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  263. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  264. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  265. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  266. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  267. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  268. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  269. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  270. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  271. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  272. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  273. package/skills/pr-fix-protocol/SKILL.md +65 -0
  274. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  275. package/skills/reviewer-gates/SKILL.md +7 -5
  276. package/skills/skill-builder/references/skill-modularity.md +1 -1
  277. package/skills/team-advisor/SKILL.md +16 -12
  278. package/system-prompts/software-engineer.xml +7 -6
  279. package/hooks/validators/test_verify_paths.py +0 -32
  280. package/hooks/validators/verify_paths.py +0 -57
@@ -0,0 +1,1017 @@
1
+ """Behavioral tests for the grok batch launcher and report collector.
2
+
3
+ Subprocess seams are swapped; no live grok calls run in this suite.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+ import threading
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 spawn_grok_batch as batch # noqa: E402
20
+ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
21
+ BATCH_LAUNCH_ERROR_STDERR_PREFIX,
22
+ BUILD_PROFILE_PROMPT_HEADER,
23
+ CLASSIFICATION_ERROR,
24
+ CLASSIFICATION_OK,
25
+ CLASSIFICATION_USAGE_LIMIT,
26
+ DEBUG_FILE_FLAG,
27
+ DEFAULT_ROLE,
28
+ DEFAULT_WORKER_MAX_TURNS,
29
+ DEFAULT_WORKER_TIMEOUT_SECONDS,
30
+ DISABLE_WEB_SEARCH_FLAG,
31
+ DISALLOWED_TOOLS_FLAG,
32
+ LEADER_SOCKET_FILENAME_PREFIX,
33
+ LEADER_SOCKET_FILENAME_SUFFIX,
34
+ OUTPUT_FILENAME_PREFIX,
35
+ PROMPT_FILENAME_PREFIX,
36
+ READONLY_DISALLOWED_TOOLS_VALUE,
37
+ READONLY_PROFILE_PROMPT_HEADER,
38
+ REASON_GROK_BINARY_MISSING,
39
+ SUMMARY_CLASSIFICATION_KEY,
40
+ SUMMARY_DEBUG_FILE_KEY,
41
+ SUMMARY_IS_OK_KEY,
42
+ SUMMARY_IS_PREFLIGHT_USABLE_KEY,
43
+ SUMMARY_LEADER_SOCKET_KEY,
44
+ SUMMARY_OUTPUT_FILE_KEY,
45
+ SUMMARY_PREFLIGHT_REASON_KEY,
46
+ SUMMARY_PROMPT_FILE_KEY,
47
+ SUMMARY_REPORT_TEXT_KEY,
48
+ SUMMARY_RETURNCODE_KEY,
49
+ SUMMARY_ROLE_NAME_KEY,
50
+ SUMMARY_TOOL_PROFILE_KEY,
51
+ SUMMARY_WORKERS_KEY,
52
+ TOOL_PROFILE_BUILD,
53
+ TOOL_PROFILE_READONLY,
54
+ UTF8_ENCODING,
55
+ WORKER_SPEC_AGENT_NAME_KEY,
56
+ WORKER_SPEC_MAX_TURNS_KEY,
57
+ WORKER_SPEC_PROMPT_PARTS_KEY,
58
+ WORKER_SPEC_TIMEOUT_KEY,
59
+ )
60
+ from dev_env_scripts_constants.timing import WORKER_STAGGER_SECONDS # noqa: E402
61
+ from grok_headless_runner import GrokRunnerOutcome # noqa: E402
62
+ from grok_worker_preflight import PreflightOutcome # noqa: E402
63
+
64
+ FIXTURE_REPORT_TEXT = '{"status":"done","role":"investigator"}'
65
+ FIXTURE_USAGE_LIMIT_TEXT = "rate limit exceeded (HTTP 429): quota exceeded"
66
+
67
+
68
+ def _write_prompt_parts(
69
+ tmp_path: Path, *, role_marker: str = "investigator"
70
+ ) -> tuple[Path, Path]:
71
+ header_part = tmp_path / f"role-header-{role_marker}.txt"
72
+ body_part = tmp_path / f"assignment-body-{role_marker}.txt"
73
+ header_part.write_text(
74
+ f"Role: investigator\nrole-marker:{role_marker}",
75
+ encoding=UTF8_ENCODING,
76
+ )
77
+ body_part.write_text("Inspect the package layout.", encoding=UTF8_ENCODING)
78
+ return header_part, body_part
79
+
80
+
81
+ def _worker_payload(
82
+ *,
83
+ role_name: str,
84
+ all_prompt_parts: list[str],
85
+ working_directory: Path,
86
+ tool_profile: str,
87
+ timeout_seconds: int = 30,
88
+ is_repo_only: bool = False,
89
+ ) -> dict[str, object]:
90
+ return {
91
+ "role_name": role_name,
92
+ "prompt_parts": all_prompt_parts,
93
+ "cwd": str(working_directory),
94
+ "tool_profile": tool_profile,
95
+ "timeout_seconds": timeout_seconds,
96
+ "is_repo_only": is_repo_only,
97
+ }
98
+
99
+
100
+ def _write_batch_spec(
101
+ tmp_path: Path,
102
+ *,
103
+ all_worker_payloads: list[dict[str, object]],
104
+ role: str = DEFAULT_ROLE,
105
+ should_ping: bool = False,
106
+ ) -> Path:
107
+ specification_path = tmp_path / "batch-spec.json"
108
+ specification_path.write_text(
109
+ json.dumps(
110
+ {
111
+ "role": role,
112
+ "should_ping": should_ping,
113
+ "workers": all_worker_payloads,
114
+ }
115
+ ),
116
+ encoding=UTF8_ENCODING,
117
+ )
118
+ return specification_path
119
+
120
+
121
+ class _RunnerRecorder:
122
+ def __init__(self, outcome_by_role_name: dict[str, GrokRunnerOutcome]) -> None:
123
+ self.outcome_by_role_name = dict(outcome_by_role_name)
124
+ self.all_keyword_arguments: list[dict[str, object]] = []
125
+ self._lock = threading.Lock()
126
+
127
+ def __call__(self, **keyword_arguments: object) -> GrokRunnerOutcome:
128
+ with self._lock:
129
+ self.all_keyword_arguments.append(dict(keyword_arguments))
130
+ prompt_file = keyword_arguments["prompt_file"]
131
+ assert isinstance(prompt_file, Path)
132
+ prompt_text = prompt_file.read_text(encoding=UTF8_ENCODING)
133
+ for each_role_name, each_outcome in self.outcome_by_role_name.items():
134
+ marker = f"role-marker:{each_role_name}"
135
+ if marker in prompt_text:
136
+ return each_outcome
137
+ raise AssertionError(f"no outcome mapped for prompt: {prompt_text!r}")
138
+
139
+
140
+ def _ok_outcome(report_text: str = FIXTURE_REPORT_TEXT) -> GrokRunnerOutcome:
141
+ return GrokRunnerOutcome(
142
+ is_ok=True,
143
+ returncode=0,
144
+ classification=CLASSIFICATION_OK,
145
+ stdout=report_text,
146
+ stderr="",
147
+ )
148
+
149
+
150
+ def _usage_limit_outcome() -> GrokRunnerOutcome:
151
+ return GrokRunnerOutcome(
152
+ is_ok=False,
153
+ returncode=1,
154
+ classification=CLASSIFICATION_USAGE_LIMIT,
155
+ stdout="",
156
+ stderr=FIXTURE_USAGE_LIMIT_TEXT,
157
+ )
158
+
159
+
160
+ def test_assemble_worker_prompt_joins_parts_with_profile_header(
161
+ tmp_path: Path,
162
+ ) -> None:
163
+ header_part, body_part = _write_prompt_parts(tmp_path)
164
+
165
+ build_prompt = batch.assemble_worker_prompt(
166
+ all_prompt_part_paths=(header_part, body_part),
167
+ tool_profile=TOOL_PROFILE_BUILD,
168
+ )
169
+ readonly_prompt = batch.assemble_worker_prompt(
170
+ all_prompt_part_paths=(header_part, body_part),
171
+ tool_profile=TOOL_PROFILE_READONLY,
172
+ )
173
+
174
+ assert build_prompt.startswith(BUILD_PROFILE_PROMPT_HEADER)
175
+ assert "Role: investigator" in build_prompt
176
+ assert "Inspect the package layout." in build_prompt
177
+ assert "Never commit, push, or call gh." in build_prompt
178
+ assert readonly_prompt.startswith(READONLY_PROFILE_PROMPT_HEADER)
179
+ assert "Do not write, edit, or run shell commands." in readonly_prompt
180
+
181
+
182
+ def test_tool_profile_arguments_differ_for_readonly_and_build(
183
+ tmp_path: Path,
184
+ ) -> None:
185
+ debug_file = tmp_path / "debug.log"
186
+
187
+ readonly_arguments = batch.build_tool_profile_arguments(
188
+ tool_profile=TOOL_PROFILE_READONLY,
189
+ is_repo_only=True,
190
+ debug_file=debug_file,
191
+ )
192
+ build_arguments = batch.build_tool_profile_arguments(
193
+ tool_profile=TOOL_PROFILE_BUILD,
194
+ is_repo_only=False,
195
+ debug_file=debug_file,
196
+ )
197
+
198
+ assert DISALLOWED_TOOLS_FLAG in readonly_arguments
199
+ assert READONLY_DISALLOWED_TOOLS_VALUE in readonly_arguments
200
+ assert DISABLE_WEB_SEARCH_FLAG in readonly_arguments
201
+ assert DEBUG_FILE_FLAG in readonly_arguments
202
+ assert str(debug_file) in readonly_arguments
203
+ assert DISALLOWED_TOOLS_FLAG not in build_arguments
204
+ assert DISABLE_WEB_SEARCH_FLAG not in build_arguments
205
+ assert DEBUG_FILE_FLAG in build_arguments
206
+
207
+
208
+ def test_unique_socket_and_output_paths_per_worker(
209
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
210
+ ) -> None:
211
+ header_a, body_a = _write_prompt_parts(tmp_path, role_marker="worker-a")
212
+ header_b, body_b = _write_prompt_parts(tmp_path, role_marker="worker-b")
213
+ working_directory = tmp_path / "project"
214
+ working_directory.mkdir()
215
+ run_state_directory = tmp_path / "run-state"
216
+ specification_path = _write_batch_spec(
217
+ tmp_path,
218
+ all_worker_payloads=[
219
+ _worker_payload(
220
+ role_name="worker-a",
221
+ all_prompt_parts=[str(header_a), str(body_a)],
222
+ working_directory=working_directory,
223
+ tool_profile=TOOL_PROFILE_READONLY,
224
+ ),
225
+ _worker_payload(
226
+ role_name="worker-b",
227
+ all_prompt_parts=[str(header_b), str(body_b)],
228
+ working_directory=working_directory,
229
+ tool_profile=TOOL_PROFILE_BUILD,
230
+ ),
231
+ ],
232
+ )
233
+ batch_spec = batch.load_batch_spec(specification_path)
234
+ recorder = _RunnerRecorder(
235
+ {
236
+ "worker-a": _ok_outcome("a"),
237
+ "worker-b": _ok_outcome("b"),
238
+ }
239
+ )
240
+ monkeypatch.setattr(
241
+ batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
242
+ )
243
+ monkeypatch.setattr(batch, "batch_headless_runner", recorder)
244
+ monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
245
+
246
+ batch_summary = batch.run_grok_batch(
247
+ batch_spec=batch_spec,
248
+ run_state_directory=run_state_directory,
249
+ )
250
+
251
+ assert len(batch_summary.all_worker_reports) == 2
252
+ first_report = batch_summary.all_worker_reports[0]
253
+ second_report = batch_summary.all_worker_reports[1]
254
+ assert first_report.leader_socket != second_report.leader_socket
255
+ assert first_report.report_path != second_report.report_path
256
+ assert first_report.prompt_path != second_report.prompt_path
257
+ assert Path(first_report.leader_socket).parent == run_state_directory
258
+ assert Path(first_report.report_path).parent == run_state_directory
259
+ assert Path(first_report.leader_socket).name.startswith(
260
+ LEADER_SOCKET_FILENAME_PREFIX
261
+ )
262
+ assert Path(first_report.leader_socket).name.endswith(
263
+ LEADER_SOCKET_FILENAME_SUFFIX
264
+ )
265
+ assert Path(first_report.report_path).name.startswith(OUTPUT_FILENAME_PREFIX)
266
+ assert Path(first_report.prompt_path).name.startswith(PROMPT_FILENAME_PREFIX)
267
+ all_socket_arguments = {
268
+ each_kwargs["leader_socket_path"]
269
+ for each_kwargs in recorder.all_keyword_arguments
270
+ }
271
+ assert len(all_socket_arguments) == 2
272
+
273
+
274
+ def test_stagger_ordering_uses_index_times_stagger_seconds(
275
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
276
+ ) -> None:
277
+ working_directory = tmp_path / "project"
278
+ working_directory.mkdir()
279
+ run_state_directory = tmp_path / "run-state"
280
+ all_worker_payloads: list[dict[str, object]] = []
281
+ outcome_by_role_name: dict[str, GrokRunnerOutcome] = {}
282
+ for each_index in range(3):
283
+ role_name = f"worker-{each_index}"
284
+ header_part, body_part = _write_prompt_parts(
285
+ tmp_path, role_marker=role_name
286
+ )
287
+ all_worker_payloads.append(
288
+ _worker_payload(
289
+ role_name=role_name,
290
+ all_prompt_parts=[str(header_part), str(body_part)],
291
+ working_directory=working_directory,
292
+ tool_profile=TOOL_PROFILE_BUILD,
293
+ )
294
+ )
295
+ outcome_by_role_name[role_name] = _ok_outcome(str(each_index))
296
+ specification_path = _write_batch_spec(
297
+ tmp_path, all_worker_payloads=all_worker_payloads
298
+ )
299
+ batch_spec = batch.load_batch_spec(specification_path)
300
+ recorder = _RunnerRecorder(outcome_by_role_name)
301
+ all_sleep_seconds: list[int] = []
302
+ sleep_lock = threading.Lock()
303
+
304
+ def _record_sleep(delay_seconds: float) -> None:
305
+ with sleep_lock:
306
+ all_sleep_seconds.append(int(delay_seconds))
307
+
308
+ monkeypatch.setattr(
309
+ batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
310
+ )
311
+ monkeypatch.setattr(batch, "batch_headless_runner", recorder)
312
+ monkeypatch.setattr(batch, "batch_sleep", _record_sleep)
313
+
314
+ batch.run_grok_batch(
315
+ batch_spec=batch_spec,
316
+ run_state_directory=run_state_directory,
317
+ )
318
+
319
+ assert sorted(all_sleep_seconds) == [
320
+ 0,
321
+ WORKER_STAGGER_SECONDS,
322
+ WORKER_STAGGER_SECONDS * 2,
323
+ ]
324
+ assert WORKER_STAGGER_SECONDS == 15
325
+
326
+
327
+ def test_tool_profile_argv_passed_to_runner(
328
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
329
+ ) -> None:
330
+ header_ro, body_ro = _write_prompt_parts(tmp_path, role_marker="readonly-worker")
331
+ header_build, body_build = _write_prompt_parts(
332
+ tmp_path, role_marker="build-worker"
333
+ )
334
+ working_directory = tmp_path / "project"
335
+ working_directory.mkdir()
336
+ run_state_directory = tmp_path / "run-state"
337
+ specification_path = _write_batch_spec(
338
+ tmp_path,
339
+ all_worker_payloads=[
340
+ _worker_payload(
341
+ role_name="readonly-worker",
342
+ all_prompt_parts=[str(header_ro), str(body_ro)],
343
+ working_directory=working_directory,
344
+ tool_profile=TOOL_PROFILE_READONLY,
345
+ is_repo_only=True,
346
+ ),
347
+ _worker_payload(
348
+ role_name="build-worker",
349
+ all_prompt_parts=[str(header_build), str(body_build)],
350
+ working_directory=working_directory,
351
+ tool_profile=TOOL_PROFILE_BUILD,
352
+ ),
353
+ ],
354
+ )
355
+ batch_spec = batch.load_batch_spec(specification_path)
356
+ recorder = _RunnerRecorder(
357
+ {
358
+ "readonly-worker": _ok_outcome("ro"),
359
+ "build-worker": _ok_outcome("build"),
360
+ }
361
+ )
362
+ monkeypatch.setattr(
363
+ batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
364
+ )
365
+ monkeypatch.setattr(batch, "batch_headless_runner", recorder)
366
+ monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
367
+
368
+ batch.run_grok_batch(
369
+ batch_spec=batch_spec,
370
+ run_state_directory=run_state_directory,
371
+ )
372
+
373
+ all_extra_arguments = [
374
+ each_kwargs["all_extra_arguments"]
375
+ for each_kwargs in recorder.all_keyword_arguments
376
+ ]
377
+ readonly_extra = next(
378
+ each_extra
379
+ for each_extra in all_extra_arguments
380
+ if isinstance(each_extra, tuple) and DISALLOWED_TOOLS_FLAG in each_extra
381
+ )
382
+ build_extra = next(
383
+ each_extra
384
+ for each_extra in all_extra_arguments
385
+ if isinstance(each_extra, tuple) and DISALLOWED_TOOLS_FLAG not in each_extra
386
+ )
387
+ assert READONLY_DISALLOWED_TOOLS_VALUE in readonly_extra
388
+ assert DISABLE_WEB_SEARCH_FLAG in readonly_extra
389
+ assert DISABLE_WEB_SEARCH_FLAG not in build_extra
390
+ assert DEBUG_FILE_FLAG in readonly_extra
391
+ assert DEBUG_FILE_FLAG in build_extra
392
+
393
+
394
+ def test_batch_summary_as_dict_shape_and_report_collection(
395
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
396
+ ) -> None:
397
+ header_ok, body_ok = _write_prompt_parts(tmp_path, role_marker="ok-worker")
398
+ header_limited, body_limited = _write_prompt_parts(
399
+ tmp_path, role_marker="limited-worker"
400
+ )
401
+ working_directory = tmp_path / "project"
402
+ working_directory.mkdir()
403
+ run_state_directory = tmp_path / "run-state"
404
+ specification_path = _write_batch_spec(
405
+ tmp_path,
406
+ all_worker_payloads=[
407
+ _worker_payload(
408
+ role_name="ok-worker",
409
+ all_prompt_parts=[str(header_ok), str(body_ok)],
410
+ working_directory=working_directory,
411
+ tool_profile=TOOL_PROFILE_BUILD,
412
+ ),
413
+ _worker_payload(
414
+ role_name="limited-worker",
415
+ all_prompt_parts=[str(header_limited), str(body_limited)],
416
+ working_directory=working_directory,
417
+ tool_profile=TOOL_PROFILE_READONLY,
418
+ ),
419
+ ],
420
+ )
421
+ batch_spec = batch.load_batch_spec(specification_path)
422
+ recorder = _RunnerRecorder(
423
+ {
424
+ "ok-worker": _ok_outcome(),
425
+ "limited-worker": _usage_limit_outcome(),
426
+ }
427
+ )
428
+ monkeypatch.setattr(
429
+ batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
430
+ )
431
+ monkeypatch.setattr(batch, "batch_headless_runner", recorder)
432
+ monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
433
+
434
+ batch_summary = batch.run_grok_batch(
435
+ batch_spec=batch_spec,
436
+ run_state_directory=run_state_directory,
437
+ )
438
+ summary_payload = batch.batch_summary_as_dict(batch_summary)
439
+
440
+ assert summary_payload[SUMMARY_IS_PREFLIGHT_USABLE_KEY] is True
441
+ assert summary_payload[SUMMARY_PREFLIGHT_REASON_KEY] is None
442
+ all_worker_payloads = summary_payload[SUMMARY_WORKERS_KEY]
443
+ assert isinstance(all_worker_payloads, list)
444
+ assert len(all_worker_payloads) == 2
445
+ first_payload = all_worker_payloads[0]
446
+ second_payload = all_worker_payloads[1]
447
+ assert first_payload[SUMMARY_ROLE_NAME_KEY] == "ok-worker"
448
+ assert first_payload[SUMMARY_RETURNCODE_KEY] == 0
449
+ assert first_payload[SUMMARY_CLASSIFICATION_KEY] == CLASSIFICATION_OK
450
+ assert first_payload[SUMMARY_IS_OK_KEY] is True
451
+ assert first_payload[SUMMARY_REPORT_TEXT_KEY] == FIXTURE_REPORT_TEXT
452
+ assert first_payload[SUMMARY_TOOL_PROFILE_KEY] == TOOL_PROFILE_BUILD
453
+ assert SUMMARY_OUTPUT_FILE_KEY in first_payload
454
+ assert SUMMARY_LEADER_SOCKET_KEY in first_payload
455
+ assert SUMMARY_PROMPT_FILE_KEY in first_payload
456
+ assert second_payload[SUMMARY_ROLE_NAME_KEY] == "limited-worker"
457
+ assert second_payload[SUMMARY_CLASSIFICATION_KEY] == CLASSIFICATION_USAGE_LIMIT
458
+ assert second_payload[SUMMARY_IS_OK_KEY] is False
459
+ assert second_payload[SUMMARY_REPORT_TEXT_KEY] == FIXTURE_USAGE_LIMIT_TEXT
460
+ assert Path(str(first_payload[SUMMARY_OUTPUT_FILE_KEY])).is_file()
461
+ assert (
462
+ Path(str(first_payload[SUMMARY_OUTPUT_FILE_KEY])).read_text(
463
+ encoding=UTF8_ENCODING
464
+ )
465
+ == FIXTURE_REPORT_TEXT
466
+ )
467
+
468
+
469
+ def test_summary_debug_file_matches_the_debug_flag_passed_to_the_runner(
470
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
471
+ ) -> None:
472
+ header_path, body_path = _write_prompt_parts(tmp_path, role_marker="worker")
473
+ working_directory = tmp_path / "project"
474
+ working_directory.mkdir()
475
+ run_state_directory = tmp_path / "run-state"
476
+ specification_path = _write_batch_spec(
477
+ tmp_path,
478
+ all_worker_payloads=[
479
+ _worker_payload(
480
+ role_name="worker",
481
+ all_prompt_parts=[str(header_path), str(body_path)],
482
+ working_directory=working_directory,
483
+ tool_profile=TOOL_PROFILE_READONLY,
484
+ )
485
+ ],
486
+ )
487
+ recorder = _RunnerRecorder({"worker": _ok_outcome()})
488
+ monkeypatch.setattr(
489
+ batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
490
+ )
491
+ monkeypatch.setattr(batch, "batch_headless_runner", recorder)
492
+ monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
493
+
494
+ batch_summary = batch.run_grok_batch(
495
+ batch_spec=batch.load_batch_spec(specification_path),
496
+ run_state_directory=run_state_directory,
497
+ )
498
+ summary_payload = batch.batch_summary_as_dict(batch_summary)
499
+
500
+ all_extra_arguments = recorder.all_keyword_arguments[0]["all_extra_arguments"]
501
+ assert isinstance(all_extra_arguments, tuple)
502
+ debug_flag_index = all_extra_arguments.index(DEBUG_FILE_FLAG)
503
+ launched_debug_file = all_extra_arguments[debug_flag_index + 1]
504
+ all_worker_payloads = summary_payload[SUMMARY_WORKERS_KEY]
505
+ assert isinstance(all_worker_payloads, list)
506
+ reported_debug_file = all_worker_payloads[0][SUMMARY_DEBUG_FILE_KEY]
507
+ assert reported_debug_file == launched_debug_file
508
+ assert Path(str(reported_debug_file)).parent == run_state_directory
509
+
510
+
511
+ def test_preflight_fallthrough_skips_workers(
512
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
513
+ ) -> None:
514
+ header_part, body_part = _write_prompt_parts(
515
+ tmp_path, role_marker="never-launched"
516
+ )
517
+ working_directory = tmp_path / "project"
518
+ working_directory.mkdir()
519
+ run_state_directory = tmp_path / "run-state"
520
+ specification_path = _write_batch_spec(
521
+ tmp_path,
522
+ all_worker_payloads=[
523
+ _worker_payload(
524
+ role_name="never-launched",
525
+ all_prompt_parts=[str(header_part), str(body_part)],
526
+ working_directory=working_directory,
527
+ tool_profile=TOOL_PROFILE_BUILD,
528
+ )
529
+ ],
530
+ )
531
+ batch_spec = batch.load_batch_spec(specification_path)
532
+ recorder = _RunnerRecorder({})
533
+ monkeypatch.setattr(
534
+ batch,
535
+ "batch_preflight",
536
+ lambda **_kwargs: PreflightOutcome(False, REASON_GROK_BINARY_MISSING),
537
+ )
538
+ monkeypatch.setattr(batch, "batch_headless_runner", recorder)
539
+ monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
540
+
541
+ batch_summary = batch.run_grok_batch(
542
+ batch_spec=batch_spec,
543
+ run_state_directory=run_state_directory,
544
+ )
545
+ summary_payload = batch.batch_summary_as_dict(batch_summary)
546
+
547
+ assert batch_summary.is_preflight_usable is False
548
+ assert batch_summary.preflight_reason == REASON_GROK_BINARY_MISSING
549
+ assert batch_summary.all_worker_reports == ()
550
+ assert recorder.all_keyword_arguments == []
551
+ assert summary_payload[SUMMARY_IS_PREFLIGHT_USABLE_KEY] is False
552
+ assert summary_payload[SUMMARY_PREFLIGHT_REASON_KEY] == REASON_GROK_BINARY_MISSING
553
+ assert summary_payload[SUMMARY_WORKERS_KEY] == []
554
+
555
+
556
+ def test_main_prints_summary_json(
557
+ monkeypatch: pytest.MonkeyPatch,
558
+ tmp_path: Path,
559
+ capsys: pytest.CaptureFixture[str],
560
+ ) -> None:
561
+ header_part, body_part = _write_prompt_parts(tmp_path, role_marker="cli-worker")
562
+ working_directory = tmp_path / "project"
563
+ working_directory.mkdir()
564
+ run_state_directory = tmp_path / "run-state"
565
+ specification_path = _write_batch_spec(
566
+ tmp_path,
567
+ all_worker_payloads=[
568
+ _worker_payload(
569
+ role_name="cli-worker",
570
+ all_prompt_parts=[str(header_part), str(body_part)],
571
+ working_directory=working_directory,
572
+ tool_profile=TOOL_PROFILE_BUILD,
573
+ )
574
+ ],
575
+ )
576
+ recorder = _RunnerRecorder({"cli-worker": _ok_outcome()})
577
+ monkeypatch.setattr(
578
+ batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
579
+ )
580
+ monkeypatch.setattr(batch, "batch_headless_runner", recorder)
581
+ monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
582
+
583
+ exit_code = batch.main(
584
+ [
585
+ "--spec",
586
+ str(specification_path),
587
+ "--run-temp-dir",
588
+ str(run_state_directory),
589
+ ]
590
+ )
591
+
592
+ captured = capsys.readouterr()
593
+ summary_payload = json.loads(captured.out)
594
+ assert exit_code == 0
595
+ assert summary_payload[SUMMARY_IS_PREFLIGHT_USABLE_KEY] is True
596
+ assert len(summary_payload[SUMMARY_WORKERS_KEY]) == 1
597
+ assert (
598
+ summary_payload[SUMMARY_WORKERS_KEY][0][SUMMARY_ROLE_NAME_KEY] == "cli-worker"
599
+ )
600
+
601
+
602
+ def test_worker_exception_preserves_partial_summary_json(
603
+ monkeypatch: pytest.MonkeyPatch,
604
+ tmp_path: Path,
605
+ capsys: pytest.CaptureFixture[str],
606
+ ) -> None:
607
+ header_good, body_good = _write_prompt_parts(tmp_path, role_marker="good-worker")
608
+ working_directory = tmp_path / "project"
609
+ working_directory.mkdir()
610
+ run_state_directory = tmp_path / "run-state"
611
+ missing_prompt_part = tmp_path / "does-not-exist-prompt.txt"
612
+ specification_path = _write_batch_spec(
613
+ tmp_path,
614
+ all_worker_payloads=[
615
+ _worker_payload(
616
+ role_name="good-worker",
617
+ all_prompt_parts=[str(header_good), str(body_good)],
618
+ working_directory=working_directory,
619
+ tool_profile=TOOL_PROFILE_BUILD,
620
+ ),
621
+ _worker_payload(
622
+ role_name="broken-worker",
623
+ all_prompt_parts=[str(missing_prompt_part)],
624
+ working_directory=working_directory,
625
+ tool_profile=TOOL_PROFILE_BUILD,
626
+ ),
627
+ ],
628
+ )
629
+ recorder = _RunnerRecorder({"good-worker": _ok_outcome()})
630
+ monkeypatch.setattr(
631
+ batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
632
+ )
633
+ monkeypatch.setattr(batch, "batch_headless_runner", recorder)
634
+ monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
635
+
636
+ exit_code = batch.main(
637
+ [
638
+ "--spec",
639
+ str(specification_path),
640
+ "--run-temp-dir",
641
+ str(run_state_directory),
642
+ ]
643
+ )
644
+
645
+ captured = capsys.readouterr()
646
+ summary_payload = json.loads(captured.out)
647
+ all_worker_payloads = summary_payload[SUMMARY_WORKERS_KEY]
648
+ assert exit_code == 1
649
+ assert isinstance(all_worker_payloads, list)
650
+ assert len(all_worker_payloads) == 2
651
+ report_by_role_name = {
652
+ each_payload[SUMMARY_ROLE_NAME_KEY]: each_payload
653
+ for each_payload in all_worker_payloads
654
+ }
655
+ good_payload = report_by_role_name["good-worker"]
656
+ broken_payload = report_by_role_name["broken-worker"]
657
+ assert good_payload[SUMMARY_IS_OK_KEY] is True
658
+ assert good_payload[SUMMARY_CLASSIFICATION_KEY] == CLASSIFICATION_OK
659
+ assert good_payload[SUMMARY_REPORT_TEXT_KEY] == FIXTURE_REPORT_TEXT
660
+ assert broken_payload[SUMMARY_IS_OK_KEY] is False
661
+ assert broken_payload[SUMMARY_CLASSIFICATION_KEY] == CLASSIFICATION_ERROR
662
+ assert broken_payload[SUMMARY_REPORT_TEXT_KEY]
663
+ assert len(recorder.all_keyword_arguments) == 1
664
+
665
+
666
+ def test_failed_worker_report_keeps_stderr_diagnostic_beside_stdout(
667
+ monkeypatch: pytest.MonkeyPatch,
668
+ tmp_path: Path,
669
+ ) -> None:
670
+ header_part, body_part = _write_prompt_parts(tmp_path, role_marker="dying-worker")
671
+ working_directory = tmp_path / "project"
672
+ working_directory.mkdir()
673
+ run_state_directory = tmp_path / "run-state"
674
+ partial_stdout = "Finished auditing credit card picker skill"
675
+ fatal_stderr = "Error: ENOSPC disk full"
676
+ batch_spec = batch.load_batch_spec(
677
+ _write_batch_spec(
678
+ tmp_path,
679
+ all_worker_payloads=[
680
+ _worker_payload(
681
+ role_name="dying-worker",
682
+ all_prompt_parts=[str(header_part), str(body_part)],
683
+ working_directory=working_directory,
684
+ tool_profile=TOOL_PROFILE_BUILD,
685
+ )
686
+ ],
687
+ )
688
+ )
689
+ recorder = _RunnerRecorder(
690
+ {
691
+ "dying-worker": GrokRunnerOutcome(
692
+ is_ok=False,
693
+ returncode=1,
694
+ classification=CLASSIFICATION_ERROR,
695
+ stdout=partial_stdout,
696
+ stderr=fatal_stderr,
697
+ )
698
+ }
699
+ )
700
+ monkeypatch.setattr(
701
+ batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
702
+ )
703
+ monkeypatch.setattr(batch, "batch_headless_runner", recorder)
704
+ monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
705
+
706
+ batch_summary = batch.run_grok_batch(
707
+ batch_spec=batch_spec,
708
+ run_state_directory=run_state_directory,
709
+ )
710
+
711
+ worker_report = batch_summary.all_worker_reports[0]
712
+ written_report = Path(worker_report.report_path).read_text(encoding=UTF8_ENCODING)
713
+ assert partial_stdout in worker_report.report_text
714
+ assert fatal_stderr in worker_report.report_text
715
+ assert fatal_stderr in written_report
716
+
717
+
718
+ def test_successful_worker_report_stays_stdout_only(
719
+ monkeypatch: pytest.MonkeyPatch,
720
+ tmp_path: Path,
721
+ ) -> None:
722
+ header_part, body_part = _write_prompt_parts(tmp_path, role_marker="noisy-worker")
723
+ working_directory = tmp_path / "project"
724
+ working_directory.mkdir()
725
+ run_state_directory = tmp_path / "run-state"
726
+ batch_spec = batch.load_batch_spec(
727
+ _write_batch_spec(
728
+ tmp_path,
729
+ all_worker_payloads=[
730
+ _worker_payload(
731
+ role_name="noisy-worker",
732
+ all_prompt_parts=[str(header_part), str(body_part)],
733
+ working_directory=working_directory,
734
+ tool_profile=TOOL_PROFILE_BUILD,
735
+ )
736
+ ],
737
+ )
738
+ )
739
+ recorder = _RunnerRecorder(
740
+ {
741
+ "noisy-worker": GrokRunnerOutcome(
742
+ is_ok=True,
743
+ returncode=0,
744
+ classification=CLASSIFICATION_OK,
745
+ stdout=FIXTURE_REPORT_TEXT,
746
+ stderr="warning: telemetry disabled",
747
+ )
748
+ }
749
+ )
750
+ monkeypatch.setattr(
751
+ batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
752
+ )
753
+ monkeypatch.setattr(batch, "batch_headless_runner", recorder)
754
+ monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
755
+
756
+ batch_summary = batch.run_grok_batch(
757
+ batch_spec=batch_spec,
758
+ run_state_directory=run_state_directory,
759
+ )
760
+
761
+ assert batch_summary.all_worker_reports[0].report_text == FIXTURE_REPORT_TEXT
762
+
763
+
764
+ def test_load_batch_spec_rejects_non_string_prompt_part(tmp_path: Path) -> None:
765
+ header_part, _ = _write_prompt_parts(tmp_path)
766
+ working_directory = tmp_path / "project"
767
+ working_directory.mkdir()
768
+ worker_payload = _worker_payload(
769
+ role_name="null-part-worker",
770
+ all_prompt_parts=[str(header_part)],
771
+ working_directory=working_directory,
772
+ tool_profile=TOOL_PROFILE_BUILD,
773
+ )
774
+ worker_payload[WORKER_SPEC_PROMPT_PARTS_KEY] = [str(header_part), None]
775
+ specification_path = _write_batch_spec(
776
+ tmp_path, all_worker_payloads=[worker_payload]
777
+ )
778
+
779
+ with pytest.raises(ValueError, match=WORKER_SPEC_PROMPT_PARTS_KEY):
780
+ batch.load_batch_spec(specification_path)
781
+
782
+
783
+ def test_malformed_spec_exits_one_with_stderr_diagnostic(
784
+ tmp_path: Path,
785
+ capsys: pytest.CaptureFixture[str],
786
+ ) -> None:
787
+ specification_path = tmp_path / "broken-spec.json"
788
+ specification_path.write_text('{"workers": [', encoding=UTF8_ENCODING)
789
+ run_state_directory = tmp_path / "run-state"
790
+
791
+ exit_code = batch.main(
792
+ [
793
+ "--spec",
794
+ str(specification_path),
795
+ "--run-temp-dir",
796
+ str(run_state_directory),
797
+ ]
798
+ )
799
+
800
+ captured = capsys.readouterr()
801
+ assert exit_code == 1
802
+ assert captured.out == ""
803
+ assert BATCH_LAUNCH_ERROR_STDERR_PREFIX in captured.err
804
+
805
+
806
+ def test_missing_spec_file_exits_one_with_stderr_diagnostic(
807
+ tmp_path: Path,
808
+ capsys: pytest.CaptureFixture[str],
809
+ ) -> None:
810
+ exit_code = batch.main(
811
+ [
812
+ "--spec",
813
+ str(tmp_path / "absent-spec.json"),
814
+ "--run-temp-dir",
815
+ str(tmp_path / "run-state"),
816
+ ]
817
+ )
818
+
819
+ captured = capsys.readouterr()
820
+ assert exit_code == 1
821
+ assert captured.out == ""
822
+ assert BATCH_LAUNCH_ERROR_STDERR_PREFIX in captured.err
823
+
824
+
825
+ def test_require_int_rejects_bool() -> None:
826
+ with pytest.raises(ValueError, match="timeout_seconds"):
827
+ batch._require_int(True, WORKER_SPEC_TIMEOUT_KEY)
828
+ assert batch._require_int(600, WORKER_SPEC_TIMEOUT_KEY) == 600
829
+
830
+
831
+ def test_load_batch_spec_rejects_boolean_timeout(tmp_path: Path) -> None:
832
+ header_part, body_part = _write_prompt_parts(tmp_path)
833
+ working_directory = tmp_path / "project"
834
+ working_directory.mkdir()
835
+ worker_payload = _worker_payload(
836
+ role_name="bool-timeout-worker",
837
+ all_prompt_parts=[str(header_part), str(body_part)],
838
+ working_directory=working_directory,
839
+ tool_profile=TOOL_PROFILE_BUILD,
840
+ )
841
+ worker_payload[WORKER_SPEC_TIMEOUT_KEY] = True
842
+ specification_path = _write_batch_spec(
843
+ tmp_path, all_worker_payloads=[worker_payload]
844
+ )
845
+
846
+ with pytest.raises(ValueError, match=WORKER_SPEC_TIMEOUT_KEY):
847
+ batch.load_batch_spec(specification_path)
848
+
849
+
850
+ def test_load_batch_spec_missing_worker_keys_raise_value_error(
851
+ tmp_path: Path,
852
+ ) -> None:
853
+ specification_path = tmp_path / "empty-worker-spec.json"
854
+ specification_path.write_text(
855
+ json.dumps(
856
+ {
857
+ "role": "x",
858
+ "should_ping": False,
859
+ "workers": [{}],
860
+ }
861
+ ),
862
+ encoding=UTF8_ENCODING,
863
+ )
864
+
865
+ with pytest.raises(ValueError) as raised_error:
866
+ batch.load_batch_spec(specification_path)
867
+
868
+ assert not isinstance(raised_error.value, KeyError)
869
+ assert "role_name" in str(raised_error.value).lower() or "missing" in str(
870
+ raised_error.value
871
+ ).lower() or "must be" in str(raised_error.value).lower()
872
+
873
+
874
+ def test_load_batch_spec_rejects_non_positive_timeout_and_max_turns(
875
+ tmp_path: Path,
876
+ ) -> None:
877
+ header_part, body_part = _write_prompt_parts(tmp_path)
878
+ working_directory = tmp_path / "project"
879
+ working_directory.mkdir()
880
+
881
+ zero_timeout_dir = tmp_path / "zero-timeout"
882
+ zero_timeout_dir.mkdir()
883
+ zero_timeout_payload = _worker_payload(
884
+ role_name="zero-timeout",
885
+ all_prompt_parts=[str(header_part), str(body_part)],
886
+ working_directory=working_directory,
887
+ tool_profile=TOOL_PROFILE_BUILD,
888
+ timeout_seconds=0,
889
+ )
890
+ zero_timeout_path = _write_batch_spec(
891
+ zero_timeout_dir,
892
+ all_worker_payloads=[zero_timeout_payload],
893
+ )
894
+ with pytest.raises(ValueError, match=WORKER_SPEC_TIMEOUT_KEY):
895
+ batch.load_batch_spec(zero_timeout_path)
896
+
897
+ negative_turns_dir = tmp_path / "negative-turns"
898
+ negative_turns_dir.mkdir()
899
+ negative_turns_payload = _worker_payload(
900
+ role_name="negative-turns",
901
+ all_prompt_parts=[str(header_part), str(body_part)],
902
+ working_directory=working_directory,
903
+ tool_profile=TOOL_PROFILE_BUILD,
904
+ )
905
+ negative_turns_payload[WORKER_SPEC_MAX_TURNS_KEY] = -1
906
+ negative_turns_path = _write_batch_spec(
907
+ negative_turns_dir,
908
+ all_worker_payloads=[negative_turns_payload],
909
+ )
910
+ with pytest.raises(ValueError, match=WORKER_SPEC_MAX_TURNS_KEY):
911
+ batch.load_batch_spec(negative_turns_path)
912
+
913
+
914
+ def test_load_batch_spec_accepts_default_timeout_and_max_turns(
915
+ tmp_path: Path,
916
+ ) -> None:
917
+ header_part, body_part = _write_prompt_parts(tmp_path)
918
+ working_directory = tmp_path / "project"
919
+ working_directory.mkdir()
920
+ worker_payload = {
921
+ "role_name": "defaults-worker",
922
+ "prompt_parts": [str(header_part), str(body_part)],
923
+ "cwd": str(working_directory),
924
+ "tool_profile": TOOL_PROFILE_BUILD,
925
+ }
926
+ specification_path = _write_batch_spec(
927
+ tmp_path, all_worker_payloads=[worker_payload]
928
+ )
929
+
930
+ batch_spec = batch.load_batch_spec(specification_path)
931
+
932
+ assert len(batch_spec.all_workers) == 1
933
+ assert batch_spec.all_workers[0].timeout_seconds == DEFAULT_WORKER_TIMEOUT_SECONDS
934
+ assert batch_spec.all_workers[0].max_turns == DEFAULT_WORKER_MAX_TURNS
935
+
936
+
937
+ def test_unwritable_report_file_keeps_the_worker_outcome(
938
+ monkeypatch: pytest.MonkeyPatch,
939
+ tmp_path: Path,
940
+ ) -> None:
941
+ header_part, body_part = _write_prompt_parts(tmp_path, role_marker="disk-full")
942
+ working_directory = tmp_path / "project"
943
+ working_directory.mkdir()
944
+ run_state_directory = tmp_path / "run-state"
945
+ batch_spec = batch.load_batch_spec(
946
+ _write_batch_spec(
947
+ tmp_path,
948
+ all_worker_payloads=[
949
+ _worker_payload(
950
+ role_name="disk-full",
951
+ all_prompt_parts=[str(header_part), str(body_part)],
952
+ working_directory=working_directory,
953
+ tool_profile=TOOL_PROFILE_READONLY,
954
+ )
955
+ ],
956
+ )
957
+ )
958
+ recorder = _RunnerRecorder({"disk-full": _ok_outcome()})
959
+
960
+ def _unwritable_report(_report_path: Path, _report_text: str) -> None:
961
+ raise OSError("no space left on device")
962
+
963
+ monkeypatch.setattr(
964
+ batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
965
+ )
966
+ monkeypatch.setattr(batch, "batch_headless_runner", recorder)
967
+ monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
968
+ monkeypatch.setattr(batch, "_write_report_file", _unwritable_report)
969
+
970
+ batch_summary = batch.run_grok_batch(
971
+ batch_spec=batch_spec,
972
+ run_state_directory=run_state_directory,
973
+ )
974
+
975
+ worker_report = batch_summary.all_worker_reports[0]
976
+ assert worker_report.is_ok is True
977
+ assert worker_report.classification == CLASSIFICATION_OK
978
+ assert worker_report.returncode == 0
979
+ assert worker_report.report_text == FIXTURE_REPORT_TEXT
980
+
981
+
982
+ def test_batch_with_no_workers_returns_an_empty_summary(
983
+ monkeypatch: pytest.MonkeyPatch,
984
+ tmp_path: Path,
985
+ ) -> None:
986
+ monkeypatch.setattr(
987
+ batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
988
+ )
989
+
990
+ batch_summary = batch.run_grok_batch(
991
+ batch_spec=batch.BatchSpec(
992
+ role=DEFAULT_ROLE, should_ping=False, all_workers=()
993
+ ),
994
+ run_state_directory=tmp_path / "run-state",
995
+ )
996
+
997
+ assert batch_summary.is_preflight_usable is True
998
+ assert batch_summary.all_worker_reports == ()
999
+
1000
+
1001
+ def test_load_batch_spec_rejects_empty_agent_name(tmp_path: Path) -> None:
1002
+ header_part, body_part = _write_prompt_parts(tmp_path)
1003
+ working_directory = tmp_path / "project"
1004
+ working_directory.mkdir()
1005
+ worker_payload = _worker_payload(
1006
+ role_name="blank-agent-worker",
1007
+ all_prompt_parts=[str(header_part), str(body_part)],
1008
+ working_directory=working_directory,
1009
+ tool_profile=TOOL_PROFILE_BUILD,
1010
+ )
1011
+ worker_payload[WORKER_SPEC_AGENT_NAME_KEY] = ""
1012
+ specification_path = _write_batch_spec(
1013
+ tmp_path, all_worker_payloads=[worker_payload]
1014
+ )
1015
+
1016
+ with pytest.raises(ValueError, match=WORKER_SPEC_AGENT_NAME_KEY):
1017
+ batch.load_batch_spec(specification_path)