claude-dev-env 2.7.0 → 2.8.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 (88) hide show
  1. package/_shared/CLAUDE.md +1 -0
  2. package/_shared/advisor/advisor-protocol.md +19 -9
  3. package/_shared/pr-loop/audit-contract.md +4 -4
  4. package/_shared/pr-loop/precatch-rubric.md +2 -2
  5. package/_shared/pr-loop/worker-spawn.md +3 -1
  6. package/_shared/process-tree/CLAUDE.md +41 -0
  7. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
  8. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
  9. package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
  10. package/_shared/process-tree/scripts/pyproject.toml +16 -0
  11. package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
  12. package/agents/code-quality-agent.md +6 -5
  13. package/agents/deep-research.md +7 -24
  14. package/agents/docs-agent.md +1 -27
  15. package/agents/issue-tracker.md +1 -7
  16. package/agents/skill-writer-agent.md +1 -2
  17. package/agents/test_agent_frontmatter.py +309 -12
  18. package/hooks/blocking/CLAUDE.md +2 -0
  19. package/hooks/blocking/fable_spawn_gate.py +187 -0
  20. package/hooks/blocking/piped_pytest_blocker.py +1223 -0
  21. package/hooks/blocking/plain_language_blocker.py +287 -15
  22. package/hooks/blocking/test_fable_spawn_gate.py +374 -0
  23. package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
  24. package/hooks/blocking/test_plain_language_blocker.py +277 -2
  25. package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
  26. package/hooks/git-hooks/CLAUDE.md +2 -2
  27. package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
  28. package/hooks/git-hooks/pre_push.py +343 -54
  29. package/hooks/git-hooks/test_pre_push.py +852 -6
  30. package/hooks/hooks.json +9 -19
  31. package/hooks/hooks_constants/CLAUDE.md +2 -0
  32. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
  33. package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
  34. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
  35. package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
  36. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
  37. package/hooks/hooks_constants/shell_command_segments.py +1 -1
  38. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
  39. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
  40. package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
  41. package/package.json +1 -1
  42. package/rules/CLAUDE.md +1 -0
  43. package/rules/ask-user-question-required.md +26 -0
  44. package/rules/claims-as-quotes.md +65 -0
  45. package/scripts/CLAUDE.md +4 -4
  46. package/scripts/_code_review_test_support.py +6 -0
  47. package/scripts/check.ps1 +18 -5
  48. package/scripts/claude_chain_runner.py +203 -31
  49. package/scripts/codec_forwarding_test_support.py +2 -0
  50. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
  51. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
  52. package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
  53. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +83 -13
  54. package/scripts/grok_headless_runner.py +148 -18
  55. package/scripts/resolve_worker_spawn.py +56 -10
  56. package/scripts/spawn_grok_batch.py +81 -23
  57. package/scripts/test_claude_chain_runner.py +358 -0
  58. package/scripts/test_grok_headless_runner.py +547 -10
  59. package/scripts/test_invoke_code_review.py +298 -0
  60. package/scripts/test_resolve_worker_spawn.py +185 -15
  61. package/scripts/test_spawn_grok_batch.py +326 -22
  62. package/scripts/tests/CLAUDE.md +1 -0
  63. package/scripts/tests/test_grok_worker_constants.py +59 -0
  64. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
  65. package/skills/autoconverge/workflow/converge.contract.test.mjs +133 -8
  66. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +107 -1
  67. package/skills/autoconverge/workflow/converge.mjs +113 -31
  68. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
  69. package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
  70. package/skills/codex-review/scripts/run_codex_review.py +16 -64
  71. package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
  72. package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
  73. package/skills/e-code-review/SKILL.md +9 -8
  74. package/skills/e-code-review/reference/fix.md +29 -7
  75. package/skills/e-code-review/reference/loop.md +230 -14
  76. package/skills/e-code-review/reference/low.md +33 -15
  77. package/skills/e-code-review/reference/medium.md +55 -21
  78. package/skills/e-code-review/reference/xhigh.md +30 -12
  79. package/skills/fresh-branch/CLAUDE.md +5 -5
  80. package/skills/fresh-branch/SKILL.md +14 -6
  81. package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
  82. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
  83. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
  84. package/skills/grok-spawn/SKILL.md +10 -3
  85. package/skills/grok-spawn/reference/flag-profiles.md +3 -1
  86. package/skills/orchestrator/SKILL.md +4 -1
  87. package/skills/orchestrator-refresh/SKILL.md +5 -1
  88. package/skills/team-advisor/SKILL.md +4 -1
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import subprocess
11
11
  import sys
12
+ import time
12
13
  from pathlib import Path
13
14
 
14
15
  import pytest
@@ -18,17 +19,20 @@ if str(_SCRIPTS_DIR) not in sys.path:
18
19
  sys.path.insert(0, str(_SCRIPTS_DIR))
19
20
 
20
21
  import grok_headless_runner as runner # noqa: E402
22
+ import process_tree_kill # noqa: E402
21
23
  from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
22
24
  AGENT_FLAG,
23
25
  ALWAYS_APPROVE_FLAG,
24
26
  CLASSIFICATION_AUTH_FAILURE,
25
27
  CLASSIFICATION_ERROR,
28
+ CLASSIFICATION_KILL_FAILED,
26
29
  CLASSIFICATION_OK,
27
30
  CLASSIFICATION_TIMEOUT,
28
31
  CLASSIFICATION_USAGE_LIMIT,
29
32
  CWD_FLAG,
30
33
  GROK_BINARY_NAME,
31
34
  GROK_BINARY_NOT_FOUND_STDERR,
35
+ KILL_FAILED_RETURN_CODE,
32
36
  KILL_GRACE_TIMEOUT_SECONDS,
33
37
  LAUNCH_FAILURE_RETURN_CODE,
34
38
  LAUNCH_FAILURE_STDERR_PREFIX,
@@ -36,9 +40,12 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
36
40
  LEADER_SOCKET_FILENAME_SUFFIX,
37
41
  LEADER_SOCKET_FLAG,
38
42
  MAX_TURNS_FLAG,
43
+ MAXIMUM_WORKER_TIMEOUT_SECONDS,
44
+ MIN_WORKER_TIMEOUT_SECONDS,
39
45
  MISSING_BINARY_RETURN_CODE,
40
46
  OUTPUT_FORMAT_FLAG,
41
47
  OUTPUT_FORMAT_JSON,
48
+ PROCESS_TREE_KILL_ATTEMPT_LIMIT,
42
49
  PROMPT_FILE_FLAG,
43
50
  TIMEOUT_RETURN_CODE,
44
51
  UTF8_DECODE_ERRORS,
@@ -61,8 +68,37 @@ FIXTURE_GENERIC_FAILURE_STDERR = (
61
68
  f"grok {FIXTURE_GROK_BINARY_VERSION}: Error: internal failure"
62
69
  )
63
70
 
64
- DEFAULT_MAX_TURNS = 8
71
+ FIXTURE_TURN_CAP_CANCELLED_STDERR = (
72
+ f"grok {FIXTURE_GROK_BINARY_VERSION}: run ended with stopReason Cancelled "
73
+ "after the turn cap was reached"
74
+ )
75
+
76
+ FIXTURE_MULTI_TURN_REPORT = '{"turns_used":16,"status":"done"}'
77
+
65
78
  DEFAULT_TIMEOUT_SECONDS = 30
79
+ TINY_TURN_CAP = 8
80
+ FAKE_PROCESS_IDENTIFIER = 424242
81
+ TINY_TIMEOUT_SECONDS = 1
82
+ NON_POSITIVE_TIMEOUT_SECONDS = 0
83
+ SHORT_KILL_GRACE_SECONDS = 2
84
+ GRANDCHILD_QUIET_DEADLINE_SECONDS = 15.0
85
+ GRANDCHILD_QUIET_REQUIRED_SECONDS = 1.5
86
+ GRANDCHILD_POLL_INTERVAL_SECONDS = 0.1
87
+
88
+ GRANDCHILD_HEARTBEAT_SOURCE = (
89
+ "import sys, time\n"
90
+ "heartbeat_path = sys.argv[1]\n"
91
+ "for each_beat in range(600):\n"
92
+ " with open(heartbeat_path, 'a', encoding='utf-8') as heartbeat_handle:\n"
93
+ " heartbeat_handle.write('beat\\n')\n"
94
+ " time.sleep(0.1)\n"
95
+ )
96
+
97
+ PARENT_SPAWNS_GRANDCHILD_SOURCE = (
98
+ "import subprocess, sys, time\n"
99
+ "subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]])\n"
100
+ "time.sleep(600)\n"
101
+ )
66
102
 
67
103
 
68
104
  class _FakeProcess:
@@ -76,6 +112,7 @@ class _FakeProcess:
76
112
  returncode_after_kill: int | None = None,
77
113
  ) -> None:
78
114
  self.returncode = returncode
115
+ self.pid = FAKE_PROCESS_IDENTIFIER
79
116
  self._stdout = stdout
80
117
  self._stderr = stderr
81
118
  self._should_timeout = should_timeout
@@ -93,6 +130,11 @@ class _FakeProcess:
93
130
  )
94
131
  return self._stdout, self._stderr
95
132
 
133
+ def poll(self) -> int | None:
134
+ if not self.was_killed:
135
+ return None
136
+ return self.returncode
137
+
96
138
  def kill(self) -> None:
97
139
  self.was_killed = True
98
140
  if self._returncode_after_kill is not None:
@@ -101,6 +143,118 @@ class _FakeProcess:
101
143
  self.returncode = -9
102
144
 
103
145
 
146
+ class _TreeKillRecorder:
147
+ """Stands in for ``subprocess.run`` so no real taskkill leaves the test."""
148
+
149
+ def __init__(self) -> None:
150
+ self.all_invocations: list[list[str]] = []
151
+ self.all_keyword_arguments: list[dict[str, object]] = []
152
+
153
+ def __call__(
154
+ self, invocation: list[str], **keyword_arguments: object
155
+ ) -> subprocess.CompletedProcess[str]:
156
+ self.all_invocations.append(list(invocation))
157
+ self.all_keyword_arguments.append(dict(keyword_arguments))
158
+ return subprocess.CompletedProcess(args=invocation, returncode=0)
159
+
160
+
161
+ class _KillResistantProcess:
162
+ """Wraps a real process so a chosen number of ``kill()`` calls are dropped.
163
+
164
+ Models a kill that does not take: the call returns, the process lives on.
165
+ """
166
+
167
+ def __init__(
168
+ self,
169
+ real_process: subprocess.Popen[str],
170
+ *,
171
+ should_drop_every_kill: bool,
172
+ ) -> None:
173
+ self._real_process = real_process
174
+ self._should_drop_every_kill = should_drop_every_kill
175
+ self.kill_calls = 0
176
+
177
+ def kill(self) -> None:
178
+ self.kill_calls += 1
179
+ if self._should_drop_every_kill or self.kill_calls == 1:
180
+ return
181
+ self._real_process.kill()
182
+
183
+ def kill_for_real(self) -> None:
184
+ self._real_process.kill()
185
+
186
+ def __getattr__(self, attribute_name: str) -> object:
187
+ return getattr(self._real_process, attribute_name)
188
+
189
+
190
+ class _TreeKillAttemptRecorder:
191
+ """Drops the first tree-kill attempt, or every attempt, and counts them."""
192
+
193
+ def __init__(
194
+ self,
195
+ real_tree_kill: object,
196
+ *,
197
+ should_drop_every_attempt: bool,
198
+ ) -> None:
199
+ self._real_tree_kill = real_tree_kill
200
+ self._should_drop_every_attempt = should_drop_every_attempt
201
+ self.attempt_count = 0
202
+
203
+ def __call__(self, process_identifier: int) -> None:
204
+ self.attempt_count += 1
205
+ if self._should_drop_every_attempt or self.attempt_count == 1:
206
+ return
207
+ assert callable(self._real_tree_kill)
208
+ self._real_tree_kill(process_identifier)
209
+
210
+
211
+ def _wait_for_quiet_heartbeat(heartbeat_path: Path) -> bool:
212
+ """Poll until the heartbeat file stops growing, bounded by a hard deadline.
213
+
214
+ ::
215
+
216
+ file stops growing for the required quiet span ok: True
217
+ deadline passes while it still grows flag: False
218
+
219
+ Args:
220
+ heartbeat_path: File the grandchild appends to while it lives.
221
+
222
+ Returns:
223
+ True when the file went quiet before the deadline.
224
+ """
225
+ hard_deadline = time.monotonic() + GRANDCHILD_QUIET_DEADLINE_SECONDS
226
+ last_observed_size = heartbeat_path.stat().st_size
227
+ last_growth_at = time.monotonic()
228
+ while time.monotonic() < hard_deadline:
229
+ time.sleep(GRANDCHILD_POLL_INTERVAL_SECONDS)
230
+ current_size = heartbeat_path.stat().st_size
231
+ if current_size != last_observed_size:
232
+ last_observed_size = current_size
233
+ last_growth_at = time.monotonic()
234
+ continue
235
+ if time.monotonic() - last_growth_at >= GRANDCHILD_QUIET_REQUIRED_SECONDS:
236
+ return True
237
+ return False
238
+
239
+
240
+ class _TurnCapSensitiveLauncher:
241
+ """Fake grok: cancels when argv carries a turn cap, completes without one."""
242
+
243
+ def __init__(self) -> None:
244
+ self.all_invocations: list[list[str]] = []
245
+
246
+ def __call__(
247
+ self, invocation: list[str], **keyword_arguments: object
248
+ ) -> _FakeProcess:
249
+ del keyword_arguments
250
+ self.all_invocations.append(list(invocation))
251
+ if MAX_TURNS_FLAG in invocation:
252
+ return _FakeProcess(
253
+ returncode=1, stderr=FIXTURE_TURN_CAP_CANCELLED_STDERR
254
+ )
255
+ return _FakeProcess(returncode=0, stdout=FIXTURE_MULTI_TURN_REPORT)
256
+
257
+
104
258
  class _PopenRecorder:
105
259
  def __init__(self, all_processes: list[_FakeProcess]) -> None:
106
260
  self.all_processes = list(all_processes)
@@ -125,8 +279,8 @@ def _run_once(
125
279
  fake_process: _FakeProcess,
126
280
  *,
127
281
  agent_name: str | None = None,
128
- max_turns: int = DEFAULT_MAX_TURNS,
129
282
  timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
283
+ should_install_tree_kill_recorder: bool = True,
130
284
  ) -> tuple[runner.GrokRunnerOutcome, _PopenRecorder, Path, Path, Path]:
131
285
  prompt_file = tmp_path / "prompt.txt"
132
286
  prompt_file.write_text("do the work", encoding="utf-8")
@@ -136,11 +290,14 @@ def _run_once(
136
290
  run_state_directory.mkdir()
137
291
  recorder = _PopenRecorder([fake_process])
138
292
  monkeypatch.setattr(runner, "runner_popen", recorder)
293
+ if should_install_tree_kill_recorder:
294
+ monkeypatch.setattr(
295
+ process_tree_kill, "process_tree_subprocess_run", _TreeKillRecorder()
296
+ )
139
297
  outcome = runner.run_headless_worker(
140
298
  prompt_file=prompt_file,
141
299
  working_directory=working_directory,
142
300
  run_state_directory=run_state_directory,
143
- max_turns=max_turns,
144
301
  timeout_seconds=timeout_seconds,
145
302
  agent_name=agent_name,
146
303
  )
@@ -166,8 +323,7 @@ def test_argv_assembly_includes_required_flags(
166
323
  assert OUTPUT_FORMAT_FLAG in invocation
167
324
  assert OUTPUT_FORMAT_JSON in invocation
168
325
  assert ALWAYS_APPROVE_FLAG in invocation
169
- assert MAX_TURNS_FLAG in invocation
170
- assert str(DEFAULT_MAX_TURNS) in invocation
326
+ assert MAX_TURNS_FLAG not in invocation
171
327
  assert LEADER_SOCKET_FLAG in invocation
172
328
  leader_socket_path = Path(invocation[invocation.index(LEADER_SOCKET_FLAG) + 1])
173
329
  assert leader_socket_path.parent == run_state_directory
@@ -209,14 +365,12 @@ def test_unique_leader_socket_path_per_call(
209
365
  prompt_file=prompt_file,
210
366
  working_directory=working_directory,
211
367
  run_state_directory=run_state_directory,
212
- max_turns=DEFAULT_MAX_TURNS,
213
368
  timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
214
369
  )
215
370
  runner.run_headless_worker(
216
371
  prompt_file=prompt_file,
217
372
  working_directory=working_directory,
218
373
  run_state_directory=run_state_directory,
219
- max_turns=DEFAULT_MAX_TURNS,
220
374
  timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
221
375
  )
222
376
 
@@ -248,6 +402,9 @@ def test_timeout_kills_process(monkeypatch: pytest.MonkeyPatch, tmp_path: Path)
248
402
  assert recorder.all_keyword_arguments[0].get("stderr") is subprocess.PIPE
249
403
  assert recorder.all_keyword_arguments[0].get("encoding") == UTF8_ENCODING
250
404
  assert recorder.all_keyword_arguments[0].get("errors") == UTF8_DECODE_ERRORS
405
+ assert recorder.all_keyword_arguments[0].get("start_new_session") is (
406
+ process_tree_kill.should_start_new_session()
407
+ )
251
408
 
252
409
 
253
410
  def test_classifies_usage_limit_from_fixture(
@@ -439,7 +596,6 @@ def test_missing_binary_returns_dedicated_error(
439
596
  prompt_file=prompt_file,
440
597
  working_directory=working_directory,
441
598
  run_state_directory=run_state_directory,
442
- max_turns=DEFAULT_MAX_TURNS,
443
599
  timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
444
600
  )
445
601
 
@@ -553,7 +709,6 @@ def test_permission_error_on_launch_returns_structured_error(
553
709
  prompt_file=prompt_file,
554
710
  working_directory=working_directory,
555
711
  run_state_directory=run_state_directory,
556
- max_turns=DEFAULT_MAX_TURNS,
557
712
  timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
558
713
  )
559
714
 
@@ -593,7 +748,6 @@ def test_invalid_utf8_child_stdout_is_replace_decoded(
593
748
  prompt_file=prompt_file,
594
749
  working_directory=working_directory,
595
750
  run_state_directory=run_state_directory,
596
- max_turns=DEFAULT_MAX_TURNS,
597
751
  timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
598
752
  )
599
753
 
@@ -623,3 +777,386 @@ def test_timeout_race_successful_exit_classifies_ok(
623
777
  assert outcome.classification == CLASSIFICATION_OK
624
778
  assert outcome.returncode == 0
625
779
  assert outcome.stdout == '{"done":true}'
780
+
781
+
782
+ def test_turn_capped_worker_cancels_and_uncapped_worker_completes(
783
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
784
+ ) -> None:
785
+ """A turn cap cancels the multi-turn task; the same task completes without one.
786
+
787
+ ::
788
+
789
+ argv carries --max-turns 8 flag: stopReason Cancelled, is_ok False
790
+ argv carries no turn cap ok: completes in 16 turns, is_ok True
791
+ """
792
+ prompt_file = tmp_path / "prompt.txt"
793
+ prompt_file.write_text("do the work", encoding=UTF8_ENCODING)
794
+ working_directory = tmp_path / "project"
795
+ working_directory.mkdir()
796
+ run_state_directory = tmp_path / "run-state"
797
+ run_state_directory.mkdir()
798
+ launcher = _TurnCapSensitiveLauncher()
799
+ monkeypatch.setattr(runner, "runner_popen", launcher)
800
+
801
+ capped_outcome = runner.run_headless_worker(
802
+ prompt_file=prompt_file,
803
+ working_directory=working_directory,
804
+ run_state_directory=run_state_directory,
805
+ timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
806
+ all_extra_arguments=(MAX_TURNS_FLAG, str(TINY_TURN_CAP)),
807
+ )
808
+ uncapped_outcome = runner.run_headless_worker(
809
+ prompt_file=prompt_file,
810
+ working_directory=working_directory,
811
+ run_state_directory=run_state_directory,
812
+ timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
813
+ )
814
+
815
+ assert capped_outcome.is_ok is False
816
+ assert capped_outcome.classification == CLASSIFICATION_ERROR
817
+ assert "cancelled" in capped_outcome.stderr.lower()
818
+ assert uncapped_outcome.is_ok is True
819
+ assert uncapped_outcome.classification == CLASSIFICATION_OK
820
+ assert uncapped_outcome.stdout == FIXTURE_MULTI_TURN_REPORT
821
+ assert MAX_TURNS_FLAG not in launcher.all_invocations[1]
822
+
823
+
824
+ def test_tree_kill_falls_back_to_direct_kill_when_taskkill_fails(
825
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
826
+ ) -> None:
827
+ """A failed tree kill still ends the direct child, so no caller waits on it.
828
+
829
+ The platform is pinned to Windows so the taskkill seam is the branch under
830
+ test on every host. Left unpinned on POSIX, the kill would reach the
831
+ process group of the fake process id, which names an unrelated process.
832
+ """
833
+ monkeypatch.setattr(process_tree_kill.sys, "platform", "win32")
834
+
835
+ def raise_on_tree_kill(
836
+ invocation: list[str], **keyword_arguments: object
837
+ ) -> subprocess.CompletedProcess[str]:
838
+ del invocation, keyword_arguments
839
+ raise OSError("taskkill is not on PATH")
840
+
841
+ fake_process = _FakeProcess(
842
+ returncode=0, stderr="still running", should_timeout=True
843
+ )
844
+ monkeypatch.setattr(
845
+ process_tree_kill, "process_tree_subprocess_run", raise_on_tree_kill
846
+ )
847
+ outcome, _, _, _, _ = _run_once(
848
+ monkeypatch,
849
+ tmp_path,
850
+ fake_process,
851
+ timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
852
+ should_install_tree_kill_recorder=False,
853
+ )
854
+
855
+ assert fake_process.was_killed is True
856
+ assert outcome.classification == CLASSIFICATION_TIMEOUT
857
+
858
+
859
+ def test_timed_out_worker_leaves_no_surviving_grandchild(
860
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
861
+ ) -> None:
862
+ """A timed-out worker takes its whole process tree, not just the direct child.
863
+
864
+ ::
865
+
866
+ parent spawns grandchild, runner times out
867
+ ok: heartbeat file stops growing after the kill
868
+ flag: grandchild keeps writing, orphaned by a direct-child-only kill
869
+ """
870
+ prompt_file = tmp_path / "prompt.txt"
871
+ prompt_file.write_text("do the work", encoding=UTF8_ENCODING)
872
+ working_directory = tmp_path / "project"
873
+ working_directory.mkdir()
874
+ run_state_directory = tmp_path / "run-state"
875
+ run_state_directory.mkdir()
876
+ heartbeat_path = tmp_path / "grandchild-heartbeat.txt"
877
+ heartbeat_path.write_text("", encoding=UTF8_ENCODING)
878
+
879
+ def _spawn_parent_with_grandchild(
880
+ invocation: list[str], **keyword_arguments: object
881
+ ) -> subprocess.Popen[str]:
882
+ del invocation
883
+ return subprocess.Popen(
884
+ [
885
+ sys.executable,
886
+ "-c",
887
+ PARENT_SPAWNS_GRANDCHILD_SOURCE,
888
+ GRANDCHILD_HEARTBEAT_SOURCE,
889
+ str(heartbeat_path),
890
+ ],
891
+ **keyword_arguments,
892
+ )
893
+
894
+ monkeypatch.setattr(runner, "runner_popen", _spawn_parent_with_grandchild)
895
+ outcome = runner.run_headless_worker(
896
+ prompt_file=prompt_file,
897
+ working_directory=working_directory,
898
+ run_state_directory=run_state_directory,
899
+ timeout_seconds=TINY_TIMEOUT_SECONDS,
900
+ )
901
+ size_at_kill = heartbeat_path.stat().st_size
902
+ has_gone_quiet = _wait_for_quiet_heartbeat(heartbeat_path)
903
+ size_at_deadline = heartbeat_path.stat().st_size
904
+
905
+ assert outcome.classification == CLASSIFICATION_TIMEOUT
906
+ assert size_at_kill > 0, "grandchild never started; the test proves nothing"
907
+ assert has_gone_quiet, (
908
+ "heartbeat still growing "
909
+ f"{GRANDCHILD_QUIET_DEADLINE_SECONDS}s after the kill: "
910
+ f"{size_at_kill} -> {size_at_deadline} bytes"
911
+ )
912
+
913
+
914
+ def test_non_positive_timeout_is_refused_before_any_launch(
915
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
916
+ ) -> None:
917
+ prompt_file = tmp_path / "prompt.txt"
918
+ prompt_file.write_text("do the work", encoding=UTF8_ENCODING)
919
+ working_directory = tmp_path / "project"
920
+ working_directory.mkdir()
921
+ run_state_directory = tmp_path / "run-state"
922
+ run_state_directory.mkdir()
923
+ recorder = _PopenRecorder([_FakeProcess(returncode=0, stdout="ok")])
924
+ monkeypatch.setattr(runner, "runner_popen", recorder)
925
+
926
+ with pytest.raises(ValueError, match="MIN_WORKER_TIMEOUT_SECONDS"):
927
+ runner.run_headless_worker(
928
+ prompt_file=prompt_file,
929
+ working_directory=working_directory,
930
+ run_state_directory=run_state_directory,
931
+ timeout_seconds=NON_POSITIVE_TIMEOUT_SECONDS,
932
+ )
933
+ with pytest.raises(ValueError, match="MIN_WORKER_TIMEOUT_SECONDS"):
934
+ runner.run_headless_worker(
935
+ prompt_file=prompt_file,
936
+ working_directory=working_directory,
937
+ run_state_directory=run_state_directory,
938
+ timeout_seconds=None, # type: ignore[arg-type] # a JSON null timeout must be refused
939
+ )
940
+ assert recorder.invocations == []
941
+
942
+ surviving_outcome = runner.run_headless_worker(
943
+ prompt_file=prompt_file,
944
+ working_directory=working_directory,
945
+ run_state_directory=run_state_directory,
946
+ timeout_seconds=MIN_WORKER_TIMEOUT_SECONDS,
947
+ )
948
+
949
+ assert surviving_outcome.is_ok is True
950
+ assert len(recorder.invocations) == 1
951
+
952
+
953
+ def test_runner_refuses_over_ceiling_timeout_and_accepts_the_ceiling(
954
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
955
+ ) -> None:
956
+ """The runner is the shared choke point, so the ceiling is enforced here too.
957
+
958
+ ::
959
+
960
+ 5401 flag: WorkerTimeoutOutOfBoundsError, nothing launched
961
+ 5400 ok: launches
962
+ """
963
+ prompt_file = tmp_path / "prompt.txt"
964
+ prompt_file.write_text("do the work", encoding=UTF8_ENCODING)
965
+ working_directory = tmp_path / "project"
966
+ working_directory.mkdir()
967
+ run_state_directory = tmp_path / "run-state"
968
+ run_state_directory.mkdir()
969
+ recorder = _PopenRecorder([_FakeProcess(returncode=0, stdout="ok")])
970
+ monkeypatch.setattr(runner, "runner_popen", recorder)
971
+
972
+ with pytest.raises(
973
+ runner.WorkerTimeoutOutOfBoundsError, match="MAXIMUM_WORKER_TIMEOUT_SECONDS"
974
+ ):
975
+ runner.run_headless_worker(
976
+ prompt_file=prompt_file,
977
+ working_directory=working_directory,
978
+ run_state_directory=run_state_directory,
979
+ timeout_seconds=MAXIMUM_WORKER_TIMEOUT_SECONDS + 1,
980
+ )
981
+ assert recorder.invocations == []
982
+
983
+ at_ceiling_outcome = runner.run_headless_worker(
984
+ prompt_file=prompt_file,
985
+ working_directory=working_directory,
986
+ run_state_directory=run_state_directory,
987
+ timeout_seconds=MAXIMUM_WORKER_TIMEOUT_SECONDS,
988
+ )
989
+
990
+ assert at_ceiling_outcome.is_ok is True
991
+ assert len(recorder.invocations) == 1
992
+
993
+
994
+ def test_below_floor_timeout_is_refused_and_the_floor_is_accepted() -> None:
995
+ """The bound check refuses a sub-floor timeout and passes the floor itself.
996
+
997
+ ::
998
+
999
+ 0 flag: WorkerTimeoutOutOfBoundsError naming MIN_WORKER_TIMEOUT_SECONDS
1000
+ 1 ok: returns None
1001
+ """
1002
+ with pytest.raises(runner.WorkerTimeoutOutOfBoundsError) as raised_below_floor:
1003
+ runner.require_timeout_within_bounds(MIN_WORKER_TIMEOUT_SECONDS - 1)
1004
+
1005
+ assert "MIN_WORKER_TIMEOUT_SECONDS" in str(raised_below_floor.value)
1006
+ assert str(MIN_WORKER_TIMEOUT_SECONDS) in str(raised_below_floor.value)
1007
+ assert runner.require_timeout_within_bounds(MIN_WORKER_TIMEOUT_SECONDS) is None
1008
+
1009
+
1010
+ def test_missing_timeout_is_refused_by_the_floor_bound() -> None:
1011
+ """A JSON null timeout is refused by the same floor bound as a sub-floor value.
1012
+
1013
+ ::
1014
+
1015
+ None flag: WorkerTimeoutOutOfBoundsError naming MIN_WORKER_TIMEOUT_SECONDS
1016
+ """
1017
+ with pytest.raises(runner.WorkerTimeoutOutOfBoundsError) as raised_on_missing:
1018
+ runner.require_timeout_within_bounds(None)
1019
+
1020
+ assert "MIN_WORKER_TIMEOUT_SECONDS" in str(raised_on_missing.value)
1021
+
1022
+
1023
+ def test_above_ceiling_timeout_is_refused_and_the_ceiling_is_accepted() -> None:
1024
+ """The bound check refuses an over-ceiling timeout and passes the ceiling itself.
1025
+
1026
+ ::
1027
+
1028
+ 5401 flag: WorkerTimeoutOutOfBoundsError naming MAXIMUM_WORKER_TIMEOUT_SECONDS
1029
+ 5400 ok: returns None
1030
+ """
1031
+ with pytest.raises(runner.WorkerTimeoutOutOfBoundsError) as raised_above_ceiling:
1032
+ runner.require_timeout_within_bounds(MAXIMUM_WORKER_TIMEOUT_SECONDS + 1)
1033
+
1034
+ assert "MAXIMUM_WORKER_TIMEOUT_SECONDS" in str(raised_above_ceiling.value)
1035
+ assert str(MAXIMUM_WORKER_TIMEOUT_SECONDS) in str(raised_above_ceiling.value)
1036
+ assert runner.require_timeout_within_bounds(MAXIMUM_WORKER_TIMEOUT_SECONDS) is None
1037
+
1038
+
1039
+ def _launch_kill_resistant_tree(
1040
+ monkeypatch: pytest.MonkeyPatch,
1041
+ heartbeat_path: Path,
1042
+ *,
1043
+ should_drop_every_kill: bool,
1044
+ ) -> tuple[_TreeKillAttemptRecorder, list[_KillResistantProcess]]:
1045
+ """Wire a real parent-and-grandchild launch whose kills are dropped."""
1046
+ all_launched_processes: list[_KillResistantProcess] = []
1047
+ tree_kill_recorder = _TreeKillAttemptRecorder(
1048
+ process_tree_kill.kill_process_tree_by_identifier,
1049
+ should_drop_every_attempt=should_drop_every_kill,
1050
+ )
1051
+
1052
+ def _spawn_kill_resistant_tree(
1053
+ invocation: list[str], **keyword_arguments: object
1054
+ ) -> _KillResistantProcess:
1055
+ del invocation
1056
+ real_process = subprocess.Popen(
1057
+ [
1058
+ sys.executable,
1059
+ "-c",
1060
+ PARENT_SPAWNS_GRANDCHILD_SOURCE,
1061
+ GRANDCHILD_HEARTBEAT_SOURCE,
1062
+ str(heartbeat_path),
1063
+ ],
1064
+ **keyword_arguments,
1065
+ )
1066
+ wrapped_process = _KillResistantProcess(
1067
+ real_process, should_drop_every_kill=should_drop_every_kill
1068
+ )
1069
+ all_launched_processes.append(wrapped_process)
1070
+ return wrapped_process
1071
+
1072
+ monkeypatch.setattr(runner, "runner_popen", _spawn_kill_resistant_tree)
1073
+ monkeypatch.setattr(
1074
+ process_tree_kill, "kill_process_tree_by_identifier", tree_kill_recorder
1075
+ )
1076
+ monkeypatch.setattr(
1077
+ runner, "KILL_GRACE_TIMEOUT_SECONDS", SHORT_KILL_GRACE_SECONDS
1078
+ )
1079
+ return tree_kill_recorder, all_launched_processes
1080
+
1081
+
1082
+ def test_second_tree_kill_attempt_clears_a_tree_the_first_attempt_missed(
1083
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1084
+ ) -> None:
1085
+ """A first kill that does not take is retried once before the runner gives up.
1086
+
1087
+ ::
1088
+
1089
+ attempt 1 drops, drain times out
1090
+ attempt 2 lands ok: classification timeout, heartbeat goes quiet
1091
+ """
1092
+ prompt_file = tmp_path / "prompt.txt"
1093
+ prompt_file.write_text("do the work", encoding=UTF8_ENCODING)
1094
+ working_directory = tmp_path / "project"
1095
+ working_directory.mkdir()
1096
+ run_state_directory = tmp_path / "run-state"
1097
+ run_state_directory.mkdir()
1098
+ heartbeat_path = tmp_path / "grandchild-heartbeat.txt"
1099
+ heartbeat_path.write_text("", encoding=UTF8_ENCODING)
1100
+ tree_kill_recorder, _ = _launch_kill_resistant_tree(
1101
+ monkeypatch, heartbeat_path, should_drop_every_kill=False
1102
+ )
1103
+
1104
+ outcome = runner.run_headless_worker(
1105
+ prompt_file=prompt_file,
1106
+ working_directory=working_directory,
1107
+ run_state_directory=run_state_directory,
1108
+ timeout_seconds=TINY_TIMEOUT_SECONDS,
1109
+ )
1110
+ size_at_kill = heartbeat_path.stat().st_size
1111
+ has_gone_quiet = _wait_for_quiet_heartbeat(heartbeat_path)
1112
+ size_at_deadline = heartbeat_path.stat().st_size
1113
+
1114
+ assert size_at_kill > 0, "grandchild never started; the test proves nothing"
1115
+ assert has_gone_quiet, (
1116
+ "the tree outlived the runner: "
1117
+ f"{size_at_kill} -> {size_at_deadline} bytes"
1118
+ )
1119
+ assert outcome.classification == CLASSIFICATION_TIMEOUT
1120
+ assert tree_kill_recorder.attempt_count == PROCESS_TREE_KILL_ATTEMPT_LIMIT
1121
+
1122
+
1123
+ def test_worker_surviving_both_kill_attempts_is_reported_kill_failed(
1124
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1125
+ ) -> None:
1126
+ """A tree that survives both attempts is ledger-distinct from one that died.
1127
+
1128
+ ::
1129
+
1130
+ both attempts drop ok: classification kill_failed, pid in stderr
1131
+ """
1132
+ prompt_file = tmp_path / "prompt.txt"
1133
+ prompt_file.write_text("do the work", encoding=UTF8_ENCODING)
1134
+ working_directory = tmp_path / "project"
1135
+ working_directory.mkdir()
1136
+ run_state_directory = tmp_path / "run-state"
1137
+ run_state_directory.mkdir()
1138
+ heartbeat_path = tmp_path / "grandchild-heartbeat.txt"
1139
+ heartbeat_path.write_text("", encoding=UTF8_ENCODING)
1140
+ real_tree_kill = process_tree_kill.kill_process_tree_by_identifier
1141
+ tree_kill_recorder, all_launched_processes = _launch_kill_resistant_tree(
1142
+ monkeypatch, heartbeat_path, should_drop_every_kill=True
1143
+ )
1144
+
1145
+ try:
1146
+ outcome = runner.run_headless_worker(
1147
+ prompt_file=prompt_file,
1148
+ working_directory=working_directory,
1149
+ run_state_directory=run_state_directory,
1150
+ timeout_seconds=TINY_TIMEOUT_SECONDS,
1151
+ )
1152
+ finally:
1153
+ for each_process in all_launched_processes:
1154
+ real_tree_kill(int(each_process.pid))
1155
+ each_process.kill_for_real()
1156
+
1157
+ assert outcome.classification == CLASSIFICATION_KILL_FAILED
1158
+ assert outcome.classification != CLASSIFICATION_TIMEOUT
1159
+ assert outcome.is_ok is False
1160
+ assert outcome.returncode == KILL_FAILED_RETURN_CODE
1161
+ assert str(all_launched_processes[0].pid) in outcome.stderr
1162
+ assert tree_kill_recorder.attempt_count == PROCESS_TREE_KILL_ATTEMPT_LIMIT