claude-dev-env 2.7.0 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +7,10 @@ include the signature substrings the constants module lists.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import os
10
11
  import subprocess
11
12
  import sys
13
+ import time
12
14
  from pathlib import Path
13
15
 
14
16
  import pytest
@@ -23,12 +25,14 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
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,13 +40,22 @@ 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,
49
+ PROCESS_TREE_KILL_TIMEOUT_SECONDS,
42
50
  PROMPT_FILE_FLAG,
43
51
  TIMEOUT_RETURN_CODE,
44
52
  UTF8_DECODE_ERRORS,
45
53
  UTF8_ENCODING,
54
+ WINDOWS_OS_NAME,
55
+ WINDOWS_TASKKILL_COMMAND,
56
+ WINDOWS_TASKKILL_FORCE_FLAG,
57
+ WINDOWS_TASKKILL_PID_FLAG,
58
+ WINDOWS_TASKKILL_TREE_FLAG,
46
59
  )
47
60
 
48
61
  FIXTURE_GROK_BINARY_VERSION = "0.2.99 (b1b49ccb71) [stable]"
@@ -61,8 +74,37 @@ FIXTURE_GENERIC_FAILURE_STDERR = (
61
74
  f"grok {FIXTURE_GROK_BINARY_VERSION}: Error: internal failure"
62
75
  )
63
76
 
64
- DEFAULT_MAX_TURNS = 8
77
+ FIXTURE_TURN_CAP_CANCELLED_STDERR = (
78
+ f"grok {FIXTURE_GROK_BINARY_VERSION}: run ended with stopReason Cancelled "
79
+ "after the turn cap was reached"
80
+ )
81
+
82
+ FIXTURE_MULTI_TURN_REPORT = '{"turns_used":16,"status":"done"}'
83
+
65
84
  DEFAULT_TIMEOUT_SECONDS = 30
85
+ TINY_TURN_CAP = 8
86
+ FAKE_PROCESS_IDENTIFIER = 424242
87
+ TINY_TIMEOUT_SECONDS = 1
88
+ NON_POSITIVE_TIMEOUT_SECONDS = 0
89
+ SHORT_KILL_GRACE_SECONDS = 2
90
+ GRANDCHILD_QUIET_DEADLINE_SECONDS = 15.0
91
+ GRANDCHILD_QUIET_REQUIRED_SECONDS = 1.5
92
+ GRANDCHILD_POLL_INTERVAL_SECONDS = 0.1
93
+
94
+ GRANDCHILD_HEARTBEAT_SOURCE = (
95
+ "import sys, time\n"
96
+ "heartbeat_path = sys.argv[1]\n"
97
+ "for each_beat in range(600):\n"
98
+ " with open(heartbeat_path, 'a', encoding='utf-8') as heartbeat_handle:\n"
99
+ " heartbeat_handle.write('beat\\n')\n"
100
+ " time.sleep(0.1)\n"
101
+ )
102
+
103
+ PARENT_SPAWNS_GRANDCHILD_SOURCE = (
104
+ "import subprocess, sys, time\n"
105
+ "subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]])\n"
106
+ "time.sleep(600)\n"
107
+ )
66
108
 
67
109
 
68
110
  class _FakeProcess:
@@ -76,6 +118,7 @@ class _FakeProcess:
76
118
  returncode_after_kill: int | None = None,
77
119
  ) -> None:
78
120
  self.returncode = returncode
121
+ self.pid = FAKE_PROCESS_IDENTIFIER
79
122
  self._stdout = stdout
80
123
  self._stderr = stderr
81
124
  self._should_timeout = should_timeout
@@ -93,6 +136,11 @@ class _FakeProcess:
93
136
  )
94
137
  return self._stdout, self._stderr
95
138
 
139
+ def poll(self) -> int | None:
140
+ if not self.was_killed:
141
+ return None
142
+ return self.returncode
143
+
96
144
  def kill(self) -> None:
97
145
  self.was_killed = True
98
146
  if self._returncode_after_kill is not None:
@@ -101,6 +149,118 @@ class _FakeProcess:
101
149
  self.returncode = -9
102
150
 
103
151
 
152
+ class _TreeKillRecorder:
153
+ """Stands in for ``subprocess.run`` so no real taskkill leaves the test."""
154
+
155
+ def __init__(self) -> None:
156
+ self.all_invocations: list[list[str]] = []
157
+ self.all_keyword_arguments: list[dict[str, object]] = []
158
+
159
+ def __call__(
160
+ self, invocation: list[str], **keyword_arguments: object
161
+ ) -> subprocess.CompletedProcess[str]:
162
+ self.all_invocations.append(list(invocation))
163
+ self.all_keyword_arguments.append(dict(keyword_arguments))
164
+ return subprocess.CompletedProcess(args=invocation, returncode=0)
165
+
166
+
167
+ class _KillResistantProcess:
168
+ """Wraps a real process so a chosen number of ``kill()`` calls are dropped.
169
+
170
+ Models a kill that does not take: the call returns, the process lives on.
171
+ """
172
+
173
+ def __init__(
174
+ self,
175
+ real_process: subprocess.Popen[str],
176
+ *,
177
+ should_drop_every_kill: bool,
178
+ ) -> None:
179
+ self._real_process = real_process
180
+ self._should_drop_every_kill = should_drop_every_kill
181
+ self.kill_calls = 0
182
+
183
+ def kill(self) -> None:
184
+ self.kill_calls += 1
185
+ if self._should_drop_every_kill or self.kill_calls == 1:
186
+ return
187
+ self._real_process.kill()
188
+
189
+ def kill_for_real(self) -> None:
190
+ self._real_process.kill()
191
+
192
+ def __getattr__(self, attribute_name: str) -> object:
193
+ return getattr(self._real_process, attribute_name)
194
+
195
+
196
+ class _TreeKillAttemptRecorder:
197
+ """Drops the first tree-kill attempt, or every attempt, and counts them."""
198
+
199
+ def __init__(
200
+ self,
201
+ real_tree_kill: object,
202
+ *,
203
+ should_drop_every_attempt: bool,
204
+ ) -> None:
205
+ self._real_tree_kill = real_tree_kill
206
+ self._should_drop_every_attempt = should_drop_every_attempt
207
+ self.attempt_count = 0
208
+
209
+ def __call__(self, process_identifier: int) -> None:
210
+ self.attempt_count += 1
211
+ if self._should_drop_every_attempt or self.attempt_count == 1:
212
+ return
213
+ assert callable(self._real_tree_kill)
214
+ self._real_tree_kill(process_identifier)
215
+
216
+
217
+ def _wait_for_quiet_heartbeat(heartbeat_path: Path) -> bool:
218
+ """Poll until the heartbeat file stops growing, bounded by a hard deadline.
219
+
220
+ ::
221
+
222
+ file stops growing for the required quiet span ok: True
223
+ deadline passes while it still grows flag: False
224
+
225
+ Args:
226
+ heartbeat_path: File the grandchild appends to while it lives.
227
+
228
+ Returns:
229
+ True when the file went quiet before the deadline.
230
+ """
231
+ hard_deadline = time.monotonic() + GRANDCHILD_QUIET_DEADLINE_SECONDS
232
+ last_observed_size = heartbeat_path.stat().st_size
233
+ last_growth_at = time.monotonic()
234
+ while time.monotonic() < hard_deadline:
235
+ time.sleep(GRANDCHILD_POLL_INTERVAL_SECONDS)
236
+ current_size = heartbeat_path.stat().st_size
237
+ if current_size != last_observed_size:
238
+ last_observed_size = current_size
239
+ last_growth_at = time.monotonic()
240
+ continue
241
+ if time.monotonic() - last_growth_at >= GRANDCHILD_QUIET_REQUIRED_SECONDS:
242
+ return True
243
+ return False
244
+
245
+
246
+ class _TurnCapSensitiveLauncher:
247
+ """Fake grok: cancels when argv carries a turn cap, completes without one."""
248
+
249
+ def __init__(self) -> None:
250
+ self.all_invocations: list[list[str]] = []
251
+
252
+ def __call__(
253
+ self, invocation: list[str], **keyword_arguments: object
254
+ ) -> _FakeProcess:
255
+ del keyword_arguments
256
+ self.all_invocations.append(list(invocation))
257
+ if MAX_TURNS_FLAG in invocation:
258
+ return _FakeProcess(
259
+ returncode=1, stderr=FIXTURE_TURN_CAP_CANCELLED_STDERR
260
+ )
261
+ return _FakeProcess(returncode=0, stdout=FIXTURE_MULTI_TURN_REPORT)
262
+
263
+
104
264
  class _PopenRecorder:
105
265
  def __init__(self, all_processes: list[_FakeProcess]) -> None:
106
266
  self.all_processes = list(all_processes)
@@ -125,8 +285,8 @@ def _run_once(
125
285
  fake_process: _FakeProcess,
126
286
  *,
127
287
  agent_name: str | None = None,
128
- max_turns: int = DEFAULT_MAX_TURNS,
129
288
  timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
289
+ should_install_tree_kill_recorder: bool = True,
130
290
  ) -> tuple[runner.GrokRunnerOutcome, _PopenRecorder, Path, Path, Path]:
131
291
  prompt_file = tmp_path / "prompt.txt"
132
292
  prompt_file.write_text("do the work", encoding="utf-8")
@@ -136,11 +296,12 @@ def _run_once(
136
296
  run_state_directory.mkdir()
137
297
  recorder = _PopenRecorder([fake_process])
138
298
  monkeypatch.setattr(runner, "runner_popen", recorder)
299
+ if should_install_tree_kill_recorder:
300
+ monkeypatch.setattr(runner, "runner_subprocess_run", _TreeKillRecorder())
139
301
  outcome = runner.run_headless_worker(
140
302
  prompt_file=prompt_file,
141
303
  working_directory=working_directory,
142
304
  run_state_directory=run_state_directory,
143
- max_turns=max_turns,
144
305
  timeout_seconds=timeout_seconds,
145
306
  agent_name=agent_name,
146
307
  )
@@ -166,8 +327,7 @@ def test_argv_assembly_includes_required_flags(
166
327
  assert OUTPUT_FORMAT_FLAG in invocation
167
328
  assert OUTPUT_FORMAT_JSON in invocation
168
329
  assert ALWAYS_APPROVE_FLAG in invocation
169
- assert MAX_TURNS_FLAG in invocation
170
- assert str(DEFAULT_MAX_TURNS) in invocation
330
+ assert MAX_TURNS_FLAG not in invocation
171
331
  assert LEADER_SOCKET_FLAG in invocation
172
332
  leader_socket_path = Path(invocation[invocation.index(LEADER_SOCKET_FLAG) + 1])
173
333
  assert leader_socket_path.parent == run_state_directory
@@ -209,14 +369,12 @@ def test_unique_leader_socket_path_per_call(
209
369
  prompt_file=prompt_file,
210
370
  working_directory=working_directory,
211
371
  run_state_directory=run_state_directory,
212
- max_turns=DEFAULT_MAX_TURNS,
213
372
  timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
214
373
  )
215
374
  runner.run_headless_worker(
216
375
  prompt_file=prompt_file,
217
376
  working_directory=working_directory,
218
377
  run_state_directory=run_state_directory,
219
- max_turns=DEFAULT_MAX_TURNS,
220
378
  timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
221
379
  )
222
380
 
@@ -248,6 +406,9 @@ def test_timeout_kills_process(monkeypatch: pytest.MonkeyPatch, tmp_path: Path)
248
406
  assert recorder.all_keyword_arguments[0].get("stderr") is subprocess.PIPE
249
407
  assert recorder.all_keyword_arguments[0].get("encoding") == UTF8_ENCODING
250
408
  assert recorder.all_keyword_arguments[0].get("errors") == UTF8_DECODE_ERRORS
409
+ assert recorder.all_keyword_arguments[0].get("start_new_session") is (
410
+ os.name != WINDOWS_OS_NAME
411
+ )
251
412
 
252
413
 
253
414
  def test_classifies_usage_limit_from_fixture(
@@ -439,7 +600,6 @@ def test_missing_binary_returns_dedicated_error(
439
600
  prompt_file=prompt_file,
440
601
  working_directory=working_directory,
441
602
  run_state_directory=run_state_directory,
442
- max_turns=DEFAULT_MAX_TURNS,
443
603
  timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
444
604
  )
445
605
 
@@ -553,7 +713,6 @@ def test_permission_error_on_launch_returns_structured_error(
553
713
  prompt_file=prompt_file,
554
714
  working_directory=working_directory,
555
715
  run_state_directory=run_state_directory,
556
- max_turns=DEFAULT_MAX_TURNS,
557
716
  timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
558
717
  )
559
718
 
@@ -593,7 +752,6 @@ def test_invalid_utf8_child_stdout_is_replace_decoded(
593
752
  prompt_file=prompt_file,
594
753
  working_directory=working_directory,
595
754
  run_state_directory=run_state_directory,
596
- max_turns=DEFAULT_MAX_TURNS,
597
755
  timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
598
756
  )
599
757
 
@@ -623,3 +781,427 @@ def test_timeout_race_successful_exit_classifies_ok(
623
781
  assert outcome.classification == CLASSIFICATION_OK
624
782
  assert outcome.returncode == 0
625
783
  assert outcome.stdout == '{"done":true}'
784
+
785
+
786
+ def test_turn_capped_worker_cancels_and_uncapped_worker_completes(
787
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
788
+ ) -> None:
789
+ """A turn cap cancels the multi-turn task; the same task completes without one.
790
+
791
+ ::
792
+
793
+ argv carries --max-turns 8 flag: stopReason Cancelled, is_ok False
794
+ argv carries no turn cap ok: completes in 16 turns, is_ok True
795
+ """
796
+ prompt_file = tmp_path / "prompt.txt"
797
+ prompt_file.write_text("do the work", encoding=UTF8_ENCODING)
798
+ working_directory = tmp_path / "project"
799
+ working_directory.mkdir()
800
+ run_state_directory = tmp_path / "run-state"
801
+ run_state_directory.mkdir()
802
+ launcher = _TurnCapSensitiveLauncher()
803
+ monkeypatch.setattr(runner, "runner_popen", launcher)
804
+
805
+ capped_outcome = runner.run_headless_worker(
806
+ prompt_file=prompt_file,
807
+ working_directory=working_directory,
808
+ run_state_directory=run_state_directory,
809
+ timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
810
+ all_extra_arguments=(MAX_TURNS_FLAG, str(TINY_TURN_CAP)),
811
+ )
812
+ uncapped_outcome = runner.run_headless_worker(
813
+ prompt_file=prompt_file,
814
+ working_directory=working_directory,
815
+ run_state_directory=run_state_directory,
816
+ timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
817
+ )
818
+
819
+ assert capped_outcome.is_ok is False
820
+ assert capped_outcome.classification == CLASSIFICATION_ERROR
821
+ assert "cancelled" in capped_outcome.stderr.lower()
822
+ assert uncapped_outcome.is_ok is True
823
+ assert uncapped_outcome.classification == CLASSIFICATION_OK
824
+ assert uncapped_outcome.stdout == FIXTURE_MULTI_TURN_REPORT
825
+ assert MAX_TURNS_FLAG not in launcher.all_invocations[1]
826
+
827
+
828
+ def test_windows_timeout_kill_issues_the_taskkill_tree_argv(
829
+ monkeypatch: pytest.MonkeyPatch,
830
+ ) -> None:
831
+ tree_kill_recorder = _TreeKillRecorder()
832
+ monkeypatch.setattr(runner, "runner_subprocess_run", tree_kill_recorder)
833
+
834
+ runner._kill_windows_process_tree(FAKE_PROCESS_IDENTIFIER)
835
+
836
+ assert tree_kill_recorder.all_invocations == [
837
+ [
838
+ WINDOWS_TASKKILL_COMMAND,
839
+ WINDOWS_TASKKILL_TREE_FLAG,
840
+ WINDOWS_TASKKILL_FORCE_FLAG,
841
+ WINDOWS_TASKKILL_PID_FLAG,
842
+ str(FAKE_PROCESS_IDENTIFIER),
843
+ ]
844
+ ]
845
+ assert (
846
+ tree_kill_recorder.all_keyword_arguments[0].get("timeout")
847
+ == PROCESS_TREE_KILL_TIMEOUT_SECONDS
848
+ )
849
+
850
+
851
+ @pytest.mark.parametrize(
852
+ "raised_error",
853
+ [
854
+ subprocess.TimeoutExpired(
855
+ cmd=[WINDOWS_TASKKILL_COMMAND], timeout=PROCESS_TREE_KILL_TIMEOUT_SECONDS
856
+ ),
857
+ OSError("taskkill is not on PATH"),
858
+ ],
859
+ ids=["taskkill_times_out", "taskkill_cannot_launch"],
860
+ )
861
+ def test_windows_tree_kill_absorbs_a_failing_taskkill(
862
+ monkeypatch: pytest.MonkeyPatch, raised_error: Exception
863
+ ) -> None:
864
+ """A taskkill that never completes leaves the caller free to fall back."""
865
+
866
+ def raise_on_tree_kill(
867
+ invocation: list[str], **keyword_arguments: object
868
+ ) -> subprocess.CompletedProcess[str]:
869
+ del invocation, keyword_arguments
870
+ raise raised_error
871
+
872
+ monkeypatch.setattr(runner, "runner_subprocess_run", raise_on_tree_kill)
873
+
874
+ runner._kill_windows_process_tree(FAKE_PROCESS_IDENTIFIER)
875
+
876
+
877
+ def test_tree_kill_falls_back_to_direct_kill_when_taskkill_fails(
878
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
879
+ ) -> None:
880
+ """A failed tree kill still ends the direct child, so no caller waits on it."""
881
+
882
+ def raise_on_tree_kill(
883
+ invocation: list[str], **keyword_arguments: object
884
+ ) -> subprocess.CompletedProcess[str]:
885
+ del invocation, keyword_arguments
886
+ raise OSError("taskkill is not on PATH")
887
+
888
+ fake_process = _FakeProcess(
889
+ returncode=0, stderr="still running", should_timeout=True
890
+ )
891
+ monkeypatch.setattr(runner, "runner_subprocess_run", raise_on_tree_kill)
892
+ outcome, _, _, _, _ = _run_once(
893
+ monkeypatch,
894
+ tmp_path,
895
+ fake_process,
896
+ timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
897
+ should_install_tree_kill_recorder=False,
898
+ )
899
+
900
+ assert fake_process.was_killed is True
901
+ assert outcome.classification == CLASSIFICATION_TIMEOUT
902
+
903
+
904
+ def test_timed_out_worker_leaves_no_surviving_grandchild(
905
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
906
+ ) -> None:
907
+ """A timed-out worker takes its whole process tree, not just the direct child.
908
+
909
+ ::
910
+
911
+ parent spawns grandchild, runner times out
912
+ ok: heartbeat file stops growing after the kill
913
+ flag: grandchild keeps writing, orphaned by a direct-child-only kill
914
+ """
915
+ prompt_file = tmp_path / "prompt.txt"
916
+ prompt_file.write_text("do the work", encoding=UTF8_ENCODING)
917
+ working_directory = tmp_path / "project"
918
+ working_directory.mkdir()
919
+ run_state_directory = tmp_path / "run-state"
920
+ run_state_directory.mkdir()
921
+ heartbeat_path = tmp_path / "grandchild-heartbeat.txt"
922
+ heartbeat_path.write_text("", encoding=UTF8_ENCODING)
923
+
924
+ def _spawn_parent_with_grandchild(
925
+ invocation: list[str], **keyword_arguments: object
926
+ ) -> subprocess.Popen[str]:
927
+ del invocation
928
+ return subprocess.Popen(
929
+ [
930
+ sys.executable,
931
+ "-c",
932
+ PARENT_SPAWNS_GRANDCHILD_SOURCE,
933
+ GRANDCHILD_HEARTBEAT_SOURCE,
934
+ str(heartbeat_path),
935
+ ],
936
+ **keyword_arguments,
937
+ )
938
+
939
+ monkeypatch.setattr(runner, "runner_popen", _spawn_parent_with_grandchild)
940
+ outcome = runner.run_headless_worker(
941
+ prompt_file=prompt_file,
942
+ working_directory=working_directory,
943
+ run_state_directory=run_state_directory,
944
+ timeout_seconds=TINY_TIMEOUT_SECONDS,
945
+ )
946
+ size_at_kill = heartbeat_path.stat().st_size
947
+ has_gone_quiet = _wait_for_quiet_heartbeat(heartbeat_path)
948
+ size_at_deadline = heartbeat_path.stat().st_size
949
+
950
+ assert outcome.classification == CLASSIFICATION_TIMEOUT
951
+ assert size_at_kill > 0, "grandchild never started; the test proves nothing"
952
+ assert has_gone_quiet, (
953
+ "heartbeat still growing "
954
+ f"{GRANDCHILD_QUIET_DEADLINE_SECONDS}s after the kill: "
955
+ f"{size_at_kill} -> {size_at_deadline} bytes"
956
+ )
957
+
958
+
959
+ def test_non_positive_timeout_is_refused_before_any_launch(
960
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
961
+ ) -> None:
962
+ prompt_file = tmp_path / "prompt.txt"
963
+ prompt_file.write_text("do the work", encoding=UTF8_ENCODING)
964
+ working_directory = tmp_path / "project"
965
+ working_directory.mkdir()
966
+ run_state_directory = tmp_path / "run-state"
967
+ run_state_directory.mkdir()
968
+ recorder = _PopenRecorder([_FakeProcess(returncode=0, stdout="ok")])
969
+ monkeypatch.setattr(runner, "runner_popen", recorder)
970
+
971
+ with pytest.raises(ValueError, match="MIN_WORKER_TIMEOUT_SECONDS"):
972
+ runner.run_headless_worker(
973
+ prompt_file=prompt_file,
974
+ working_directory=working_directory,
975
+ run_state_directory=run_state_directory,
976
+ timeout_seconds=NON_POSITIVE_TIMEOUT_SECONDS,
977
+ )
978
+ with pytest.raises(ValueError, match="MIN_WORKER_TIMEOUT_SECONDS"):
979
+ runner.run_headless_worker(
980
+ prompt_file=prompt_file,
981
+ working_directory=working_directory,
982
+ run_state_directory=run_state_directory,
983
+ timeout_seconds=None, # type: ignore[arg-type] # a JSON null timeout must be refused
984
+ )
985
+ assert recorder.invocations == []
986
+
987
+ surviving_outcome = runner.run_headless_worker(
988
+ prompt_file=prompt_file,
989
+ working_directory=working_directory,
990
+ run_state_directory=run_state_directory,
991
+ timeout_seconds=MIN_WORKER_TIMEOUT_SECONDS,
992
+ )
993
+
994
+ assert surviving_outcome.is_ok is True
995
+ assert len(recorder.invocations) == 1
996
+
997
+
998
+ def test_runner_refuses_over_ceiling_timeout_and_accepts_the_ceiling(
999
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1000
+ ) -> None:
1001
+ """The runner is the shared choke point, so the ceiling is enforced here too.
1002
+
1003
+ ::
1004
+
1005
+ 5401 flag: WorkerTimeoutOutOfBoundsError, nothing launched
1006
+ 5400 ok: launches
1007
+ """
1008
+ prompt_file = tmp_path / "prompt.txt"
1009
+ prompt_file.write_text("do the work", encoding=UTF8_ENCODING)
1010
+ working_directory = tmp_path / "project"
1011
+ working_directory.mkdir()
1012
+ run_state_directory = tmp_path / "run-state"
1013
+ run_state_directory.mkdir()
1014
+ recorder = _PopenRecorder([_FakeProcess(returncode=0, stdout="ok")])
1015
+ monkeypatch.setattr(runner, "runner_popen", recorder)
1016
+
1017
+ with pytest.raises(
1018
+ runner.WorkerTimeoutOutOfBoundsError, match="MAXIMUM_WORKER_TIMEOUT_SECONDS"
1019
+ ):
1020
+ runner.run_headless_worker(
1021
+ prompt_file=prompt_file,
1022
+ working_directory=working_directory,
1023
+ run_state_directory=run_state_directory,
1024
+ timeout_seconds=MAXIMUM_WORKER_TIMEOUT_SECONDS + 1,
1025
+ )
1026
+ assert recorder.invocations == []
1027
+
1028
+ at_ceiling_outcome = runner.run_headless_worker(
1029
+ prompt_file=prompt_file,
1030
+ working_directory=working_directory,
1031
+ run_state_directory=run_state_directory,
1032
+ timeout_seconds=MAXIMUM_WORKER_TIMEOUT_SECONDS,
1033
+ )
1034
+
1035
+ assert at_ceiling_outcome.is_ok is True
1036
+ assert len(recorder.invocations) == 1
1037
+
1038
+
1039
+ def test_below_floor_timeout_is_refused_and_the_floor_is_accepted() -> None:
1040
+ """The bound check refuses a sub-floor timeout and passes the floor itself.
1041
+
1042
+ ::
1043
+
1044
+ 0 flag: WorkerTimeoutOutOfBoundsError naming MIN_WORKER_TIMEOUT_SECONDS
1045
+ 1 ok: returns None
1046
+ """
1047
+ with pytest.raises(runner.WorkerTimeoutOutOfBoundsError) as raised_below_floor:
1048
+ runner.require_timeout_within_bounds(MIN_WORKER_TIMEOUT_SECONDS - 1)
1049
+
1050
+ assert "MIN_WORKER_TIMEOUT_SECONDS" in str(raised_below_floor.value)
1051
+ assert str(MIN_WORKER_TIMEOUT_SECONDS) in str(raised_below_floor.value)
1052
+ assert runner.require_timeout_within_bounds(MIN_WORKER_TIMEOUT_SECONDS) is None
1053
+
1054
+
1055
+ def test_missing_timeout_is_refused_by_the_floor_bound() -> None:
1056
+ """A JSON null timeout is refused by the same floor bound as a sub-floor value.
1057
+
1058
+ ::
1059
+
1060
+ None flag: WorkerTimeoutOutOfBoundsError naming MIN_WORKER_TIMEOUT_SECONDS
1061
+ """
1062
+ with pytest.raises(runner.WorkerTimeoutOutOfBoundsError) as raised_on_missing:
1063
+ runner.require_timeout_within_bounds(None)
1064
+
1065
+ assert "MIN_WORKER_TIMEOUT_SECONDS" in str(raised_on_missing.value)
1066
+
1067
+
1068
+ def test_above_ceiling_timeout_is_refused_and_the_ceiling_is_accepted() -> None:
1069
+ """The bound check refuses an over-ceiling timeout and passes the ceiling itself.
1070
+
1071
+ ::
1072
+
1073
+ 5401 flag: WorkerTimeoutOutOfBoundsError naming MAXIMUM_WORKER_TIMEOUT_SECONDS
1074
+ 5400 ok: returns None
1075
+ """
1076
+ with pytest.raises(runner.WorkerTimeoutOutOfBoundsError) as raised_above_ceiling:
1077
+ runner.require_timeout_within_bounds(MAXIMUM_WORKER_TIMEOUT_SECONDS + 1)
1078
+
1079
+ assert "MAXIMUM_WORKER_TIMEOUT_SECONDS" in str(raised_above_ceiling.value)
1080
+ assert str(MAXIMUM_WORKER_TIMEOUT_SECONDS) in str(raised_above_ceiling.value)
1081
+ assert runner.require_timeout_within_bounds(MAXIMUM_WORKER_TIMEOUT_SECONDS) is None
1082
+
1083
+
1084
+ def _launch_kill_resistant_tree(
1085
+ monkeypatch: pytest.MonkeyPatch,
1086
+ heartbeat_path: Path,
1087
+ *,
1088
+ should_drop_every_kill: bool,
1089
+ ) -> tuple[_TreeKillAttemptRecorder, list[_KillResistantProcess]]:
1090
+ """Wire a real parent-and-grandchild launch whose kills are dropped."""
1091
+ all_launched_processes: list[_KillResistantProcess] = []
1092
+ tree_kill_recorder = _TreeKillAttemptRecorder(
1093
+ runner._kill_process_tree_by_identifier,
1094
+ should_drop_every_attempt=should_drop_every_kill,
1095
+ )
1096
+
1097
+ def _spawn_kill_resistant_tree(
1098
+ invocation: list[str], **keyword_arguments: object
1099
+ ) -> _KillResistantProcess:
1100
+ del invocation
1101
+ real_process = subprocess.Popen(
1102
+ [
1103
+ sys.executable,
1104
+ "-c",
1105
+ PARENT_SPAWNS_GRANDCHILD_SOURCE,
1106
+ GRANDCHILD_HEARTBEAT_SOURCE,
1107
+ str(heartbeat_path),
1108
+ ],
1109
+ **keyword_arguments,
1110
+ )
1111
+ wrapped_process = _KillResistantProcess(
1112
+ real_process, should_drop_every_kill=should_drop_every_kill
1113
+ )
1114
+ all_launched_processes.append(wrapped_process)
1115
+ return wrapped_process
1116
+
1117
+ monkeypatch.setattr(runner, "runner_popen", _spawn_kill_resistant_tree)
1118
+ monkeypatch.setattr(
1119
+ runner, "_kill_process_tree_by_identifier", tree_kill_recorder
1120
+ )
1121
+ monkeypatch.setattr(
1122
+ runner, "KILL_GRACE_TIMEOUT_SECONDS", SHORT_KILL_GRACE_SECONDS
1123
+ )
1124
+ return tree_kill_recorder, all_launched_processes
1125
+
1126
+
1127
+ def test_second_tree_kill_attempt_clears_a_tree_the_first_attempt_missed(
1128
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1129
+ ) -> None:
1130
+ """A first kill that does not take is retried once before the runner gives up.
1131
+
1132
+ ::
1133
+
1134
+ attempt 1 drops, drain times out
1135
+ attempt 2 lands ok: classification timeout, heartbeat goes quiet
1136
+ """
1137
+ prompt_file = tmp_path / "prompt.txt"
1138
+ prompt_file.write_text("do the work", encoding=UTF8_ENCODING)
1139
+ working_directory = tmp_path / "project"
1140
+ working_directory.mkdir()
1141
+ run_state_directory = tmp_path / "run-state"
1142
+ run_state_directory.mkdir()
1143
+ heartbeat_path = tmp_path / "grandchild-heartbeat.txt"
1144
+ heartbeat_path.write_text("", encoding=UTF8_ENCODING)
1145
+ tree_kill_recorder, _ = _launch_kill_resistant_tree(
1146
+ monkeypatch, heartbeat_path, should_drop_every_kill=False
1147
+ )
1148
+
1149
+ outcome = runner.run_headless_worker(
1150
+ prompt_file=prompt_file,
1151
+ working_directory=working_directory,
1152
+ run_state_directory=run_state_directory,
1153
+ timeout_seconds=TINY_TIMEOUT_SECONDS,
1154
+ )
1155
+ size_at_kill = heartbeat_path.stat().st_size
1156
+ has_gone_quiet = _wait_for_quiet_heartbeat(heartbeat_path)
1157
+ size_at_deadline = heartbeat_path.stat().st_size
1158
+
1159
+ assert size_at_kill > 0, "grandchild never started; the test proves nothing"
1160
+ assert has_gone_quiet, (
1161
+ "the tree outlived the runner: "
1162
+ f"{size_at_kill} -> {size_at_deadline} bytes"
1163
+ )
1164
+ assert outcome.classification == CLASSIFICATION_TIMEOUT
1165
+ assert tree_kill_recorder.attempt_count == PROCESS_TREE_KILL_ATTEMPT_LIMIT
1166
+
1167
+
1168
+ def test_worker_surviving_both_kill_attempts_is_reported_kill_failed(
1169
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1170
+ ) -> None:
1171
+ """A tree that survives both attempts is ledger-distinct from one that died.
1172
+
1173
+ ::
1174
+
1175
+ both attempts drop ok: classification kill_failed, pid in stderr
1176
+ """
1177
+ prompt_file = tmp_path / "prompt.txt"
1178
+ prompt_file.write_text("do the work", encoding=UTF8_ENCODING)
1179
+ working_directory = tmp_path / "project"
1180
+ working_directory.mkdir()
1181
+ run_state_directory = tmp_path / "run-state"
1182
+ run_state_directory.mkdir()
1183
+ heartbeat_path = tmp_path / "grandchild-heartbeat.txt"
1184
+ heartbeat_path.write_text("", encoding=UTF8_ENCODING)
1185
+ real_tree_kill = runner._kill_process_tree_by_identifier
1186
+ tree_kill_recorder, all_launched_processes = _launch_kill_resistant_tree(
1187
+ monkeypatch, heartbeat_path, should_drop_every_kill=True
1188
+ )
1189
+
1190
+ try:
1191
+ outcome = runner.run_headless_worker(
1192
+ prompt_file=prompt_file,
1193
+ working_directory=working_directory,
1194
+ run_state_directory=run_state_directory,
1195
+ timeout_seconds=TINY_TIMEOUT_SECONDS,
1196
+ )
1197
+ finally:
1198
+ for each_process in all_launched_processes:
1199
+ real_tree_kill(int(each_process.pid))
1200
+ each_process.kill_for_real()
1201
+
1202
+ assert outcome.classification == CLASSIFICATION_KILL_FAILED
1203
+ assert outcome.classification != CLASSIFICATION_TIMEOUT
1204
+ assert outcome.is_ok is False
1205
+ assert outcome.returncode == KILL_FAILED_RETURN_CODE
1206
+ assert str(all_launched_processes[0].pid) in outcome.stderr
1207
+ assert tree_kill_recorder.attempt_count == PROCESS_TREE_KILL_ATTEMPT_LIMIT