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.
- package/_shared/pr-loop/worker-spawn.md +3 -1
- package/package.json +1 -1
- package/scripts/CLAUDE.md +3 -3
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +79 -13
- package/scripts/grok_headless_runner.py +213 -16
- package/scripts/resolve_worker_spawn.py +56 -10
- package/scripts/spawn_grok_batch.py +43 -22
- package/scripts/test_grok_headless_runner.py +592 -10
- package/scripts/test_resolve_worker_spawn.py +179 -15
- package/scripts/test_spawn_grok_batch.py +225 -22
- package/skills/autoconverge/workflow/converge.contract.test.mjs +28 -6
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +73 -0
- package/skills/autoconverge/workflow/converge.mjs +63 -14
- package/skills/grok-spawn/SKILL.md +5 -3
- package/skills/grok-spawn/reference/flag-profiles.md +3 -1
|
@@ -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
|
-
|
|
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
|