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
|
@@ -35,17 +35,20 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
|
35
35
|
CLI_TIMEOUT_FLAG,
|
|
36
36
|
CLASSIFICATION_AUTH_FAILURE,
|
|
37
37
|
CLASSIFICATION_OK,
|
|
38
|
+
CLASSIFICATION_TIMEOUT,
|
|
38
39
|
CLASSIFICATION_USAGE_LIMIT,
|
|
39
40
|
CWD_FLAG,
|
|
40
41
|
DEFAULT_ROLE,
|
|
41
|
-
DEFAULT_SPAWN_MAX_TURNS,
|
|
42
42
|
DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
43
|
+
MAXIMUM_WORKER_TIMEOUT_SECONDS,
|
|
44
|
+
MIN_WORKER_TIMEOUT_SECONDS,
|
|
43
45
|
OUTPUT_FORMAT_FLAG,
|
|
44
46
|
OUTPUT_FORMAT_JSON,
|
|
45
47
|
PROMPT_FILE_FLAG,
|
|
46
48
|
REASON_CLAUDE_AGENT_REQUIRED,
|
|
47
49
|
REASON_GROK_AUTH_FAILED,
|
|
48
50
|
REASON_PROMPT_FILE_MISSING,
|
|
51
|
+
REASON_TIMEOUT_OUT_OF_BOUNDS,
|
|
49
52
|
RESULT_KEY_ATTEMPTS,
|
|
50
53
|
RESULT_KEY_OK,
|
|
51
54
|
RESULT_KEY_OUTPUT,
|
|
@@ -59,11 +62,16 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
|
59
62
|
TIER_CLAUDE_HEADLESS,
|
|
60
63
|
TIER_GROK,
|
|
61
64
|
)
|
|
62
|
-
|
|
65
|
+
import grok_headless_runner # noqa: E402
|
|
66
|
+
from grok_headless_runner import ( # noqa: E402
|
|
67
|
+
GrokRunnerOutcome,
|
|
68
|
+
run_headless_worker,
|
|
69
|
+
)
|
|
63
70
|
from grok_worker_preflight import PreflightOutcome # noqa: E402
|
|
64
71
|
|
|
65
72
|
HOST_PROFILE_CLAUDE = "Claude"
|
|
66
73
|
HOST_PROFILE_THIRD_PARTY = "ThirdParty"
|
|
74
|
+
NON_POSITIVE_TIMEOUT_SECONDS = 0
|
|
67
75
|
|
|
68
76
|
FIXTURE_GROK_STDOUT = '{"tier":"grok","status":"done"}'
|
|
69
77
|
FIXTURE_CLAUDE_STDOUT = '{"tier":"claude","status":"done"}'
|
|
@@ -72,6 +80,7 @@ FIXTURE_GROK_RETURNCODE = 0
|
|
|
72
80
|
FIXTURE_CLAUDE_RETURNCODE = 0
|
|
73
81
|
FIXTURE_FAILED_RETURNCODE = 1
|
|
74
82
|
FIXTURE_ROLE = "code-quality-agent"
|
|
83
|
+
MIN_WORKER_TIMEOUT_SECONDS_CONSTANT_NAME = "MIN_WORKER_TIMEOUT_SECONDS"
|
|
75
84
|
LARGE_PROMPT_CHARACTER_COUNT = 40000
|
|
76
85
|
WINDOWS_SAFE_ARGV_ELEMENT_CEILING = 8192
|
|
77
86
|
EXPECTED_PRIMARY_AGENT_FOR_DEFAULT_ROLE = Path(
|
|
@@ -261,7 +270,6 @@ def test_grok_ok_serves_tier_one(
|
|
|
261
270
|
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
262
271
|
is_claude_tier_enabled=False,
|
|
263
272
|
run_state_directory=run_state_directory,
|
|
264
|
-
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
265
273
|
)
|
|
266
274
|
|
|
267
275
|
assert spawn_outcome.is_ok is True
|
|
@@ -297,7 +305,6 @@ def test_grok_usage_limited_on_claude_host_requires_agent(
|
|
|
297
305
|
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
298
306
|
is_claude_tier_enabled=False,
|
|
299
307
|
run_state_directory=run_state_directory,
|
|
300
|
-
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
301
308
|
)
|
|
302
309
|
|
|
303
310
|
assert spawn_outcome.is_ok is False
|
|
@@ -313,6 +320,128 @@ def test_grok_usage_limited_on_claude_host_requires_agent(
|
|
|
313
320
|
assert spawn_outcome.all_attempts[1].reason == REASON_CLAUDE_AGENT_REQUIRED
|
|
314
321
|
|
|
315
322
|
|
|
323
|
+
def test_out_of_bounds_timeout_is_reported_as_config_not_worker_failure(
|
|
324
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
325
|
+
) -> None:
|
|
326
|
+
"""A refused timeout prints a structured outcome and exits 3, never a traceback.
|
|
327
|
+
|
|
328
|
+
::
|
|
329
|
+
|
|
330
|
+
--timeout-seconds 0 ok: reason timeout_out_of_bounds, exit 3
|
|
331
|
+
--timeout-seconds 5401 ok: reason timeout_out_of_bounds, exit 3
|
|
332
|
+
"""
|
|
333
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
334
|
+
_install_seams(monkeypatch, grok_outcome=_grok_ok())
|
|
335
|
+
monkeypatch.setattr(
|
|
336
|
+
dispatcher, "spawn_grok_runner", grok_headless_runner.run_headless_worker
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
for each_refused_timeout in (
|
|
340
|
+
NON_POSITIVE_TIMEOUT_SECONDS,
|
|
341
|
+
MAXIMUM_WORKER_TIMEOUT_SECONDS + 1,
|
|
342
|
+
):
|
|
343
|
+
exit_code = dispatcher.main(
|
|
344
|
+
[
|
|
345
|
+
CLI_ROLE_FLAG,
|
|
346
|
+
FIXTURE_ROLE,
|
|
347
|
+
PROMPT_FILE_FLAG,
|
|
348
|
+
str(prompt_file),
|
|
349
|
+
CWD_FLAG,
|
|
350
|
+
str(working_directory),
|
|
351
|
+
CLI_TIMEOUT_FLAG,
|
|
352
|
+
str(each_refused_timeout),
|
|
353
|
+
CLI_RUN_STATE_DIR_FLAG,
|
|
354
|
+
str(run_state_directory),
|
|
355
|
+
]
|
|
356
|
+
)
|
|
357
|
+
parsed_payload = json.loads(capsys.readouterr().out)
|
|
358
|
+
all_attempt_reasons = [
|
|
359
|
+
each_attempt[ATTEMPT_KEY_REASON]
|
|
360
|
+
for each_attempt in parsed_payload[RESULT_KEY_ATTEMPTS]
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
assert exit_code == SPAWN_CONFIG_ERROR_EXIT_CODE, each_refused_timeout
|
|
364
|
+
assert parsed_payload[RESULT_KEY_OK] is False, each_refused_timeout
|
|
365
|
+
assert parsed_payload[RESULT_KEY_TIER_USED] is None, each_refused_timeout
|
|
366
|
+
assert REASON_TIMEOUT_OUT_OF_BOUNDS in all_attempt_reasons, each_refused_timeout
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_out_of_bounds_timeout_is_refused_when_the_grok_tier_is_unreachable(
|
|
370
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
371
|
+
) -> None:
|
|
372
|
+
"""The bounds hold on a host whose preflight never reaches the grok runner.
|
|
373
|
+
|
|
374
|
+
::
|
|
375
|
+
|
|
376
|
+
preflight unusable, --timeout-seconds 5401
|
|
377
|
+
ok: reason timeout_out_of_bounds, exit 3, no tier invoked
|
|
378
|
+
"""
|
|
379
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
380
|
+
call_log = _install_seams(
|
|
381
|
+
monkeypatch,
|
|
382
|
+
preflight_outcome=_fallthrough_preflight(REASON_GROK_AUTH_FAILED),
|
|
383
|
+
claude_outcome=_claude_served(),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
exit_code = dispatcher.main(
|
|
387
|
+
[
|
|
388
|
+
CLI_ROLE_FLAG,
|
|
389
|
+
FIXTURE_ROLE,
|
|
390
|
+
PROMPT_FILE_FLAG,
|
|
391
|
+
str(prompt_file),
|
|
392
|
+
CWD_FLAG,
|
|
393
|
+
str(working_directory),
|
|
394
|
+
CLI_TIMEOUT_FLAG,
|
|
395
|
+
str(MAXIMUM_WORKER_TIMEOUT_SECONDS + 1),
|
|
396
|
+
CLI_RUN_STATE_DIR_FLAG,
|
|
397
|
+
str(run_state_directory),
|
|
398
|
+
]
|
|
399
|
+
)
|
|
400
|
+
parsed_payload = json.loads(capsys.readouterr().out)
|
|
401
|
+
all_attempt_reasons = [
|
|
402
|
+
each_attempt[ATTEMPT_KEY_REASON]
|
|
403
|
+
for each_attempt in parsed_payload[RESULT_KEY_ATTEMPTS]
|
|
404
|
+
]
|
|
405
|
+
|
|
406
|
+
assert exit_code == SPAWN_CONFIG_ERROR_EXIT_CODE
|
|
407
|
+
assert REASON_TIMEOUT_OUT_OF_BOUNDS in all_attempt_reasons
|
|
408
|
+
assert call_log.preflight_calls == 0
|
|
409
|
+
assert call_log.claude_calls == 0
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_timed_out_grok_worker_is_recorded_as_timeout_not_served(
|
|
413
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
414
|
+
) -> None:
|
|
415
|
+
"""A killed worker reaches the dispatcher's trail as timeout, never as served.
|
|
416
|
+
|
|
417
|
+
::
|
|
418
|
+
|
|
419
|
+
grok tier times out ok: attempt reason timeout, tier_used moves on
|
|
420
|
+
"""
|
|
421
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
422
|
+
call_log = _install_seams(
|
|
423
|
+
monkeypatch,
|
|
424
|
+
grok_outcome=_grok_failure(CLASSIFICATION_TIMEOUT),
|
|
425
|
+
claude_outcome=_claude_served(),
|
|
426
|
+
host_profile=HOST_PROFILE_THIRD_PARTY,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
spawn_outcome = dispatcher.resolve_worker_spawn(
|
|
430
|
+
role=FIXTURE_ROLE,
|
|
431
|
+
prompt_file=prompt_file,
|
|
432
|
+
working_directory=working_directory,
|
|
433
|
+
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
434
|
+
is_claude_tier_enabled=False,
|
|
435
|
+
run_state_directory=run_state_directory,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
assert call_log.grok_calls == 1
|
|
439
|
+
assert spawn_outcome.all_attempts[0].tier == TIER_GROK
|
|
440
|
+
assert spawn_outcome.all_attempts[0].is_ok is False
|
|
441
|
+
assert spawn_outcome.all_attempts[0].reason == CLASSIFICATION_TIMEOUT
|
|
442
|
+
assert spawn_outcome.tier_used != TIER_GROK
|
|
443
|
+
|
|
444
|
+
|
|
316
445
|
def test_grok_auth_failed_on_third_party_runs_tier_three(
|
|
317
446
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
318
447
|
) -> None:
|
|
@@ -331,7 +460,6 @@ def test_grok_auth_failed_on_third_party_runs_tier_three(
|
|
|
331
460
|
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
332
461
|
is_claude_tier_enabled=False,
|
|
333
462
|
run_state_directory=run_state_directory,
|
|
334
|
-
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
335
463
|
)
|
|
336
464
|
|
|
337
465
|
assert spawn_outcome.is_ok is True
|
|
@@ -423,6 +551,40 @@ def test_config_error_returns_exit_three(
|
|
|
423
551
|
assert parsed_payload[RESULT_KEY_RETURNCODE] == SPAWN_CONFIG_ERROR_EXIT_CODE
|
|
424
552
|
|
|
425
553
|
|
|
554
|
+
def test_below_floor_timeout_returns_json_config_exit(
|
|
555
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
556
|
+
) -> None:
|
|
557
|
+
"""A below-floor timeout reads as a config error, not an escaping traceback."""
|
|
558
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
559
|
+
_install_seams(monkeypatch, grok_outcome=_grok_ok())
|
|
560
|
+
monkeypatch.setattr(dispatcher, "spawn_grok_runner", run_headless_worker)
|
|
561
|
+
below_floor_timeout_seconds = MIN_WORKER_TIMEOUT_SECONDS - 1
|
|
562
|
+
|
|
563
|
+
exit_code = dispatcher.main(
|
|
564
|
+
[
|
|
565
|
+
CLI_ROLE_FLAG,
|
|
566
|
+
FIXTURE_ROLE,
|
|
567
|
+
PROMPT_FILE_FLAG,
|
|
568
|
+
str(prompt_file),
|
|
569
|
+
CWD_FLAG,
|
|
570
|
+
str(working_directory),
|
|
571
|
+
CLI_TIMEOUT_FLAG,
|
|
572
|
+
str(below_floor_timeout_seconds),
|
|
573
|
+
CLI_RUN_STATE_DIR_FLAG,
|
|
574
|
+
str(run_state_directory),
|
|
575
|
+
]
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
assert exit_code == SPAWN_CONFIG_ERROR_EXIT_CODE
|
|
579
|
+
captured = capsys.readouterr()
|
|
580
|
+
assert captured.err == ""
|
|
581
|
+
parsed_payload = json.loads(captured.out)
|
|
582
|
+
assert parsed_payload[RESULT_KEY_OK] is False
|
|
583
|
+
assert parsed_payload[RESULT_KEY_TIER_USED] is None
|
|
584
|
+
assert parsed_payload[RESULT_KEY_RETURNCODE] == SPAWN_CONFIG_ERROR_EXIT_CODE
|
|
585
|
+
assert MIN_WORKER_TIMEOUT_SECONDS_CONSTANT_NAME in parsed_payload[RESULT_KEY_OUTPUT]
|
|
586
|
+
|
|
587
|
+
|
|
426
588
|
def test_attempts_array_ordering_across_tiers(
|
|
427
589
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
428
590
|
) -> None:
|
|
@@ -441,7 +603,6 @@ def test_attempts_array_ordering_across_tiers(
|
|
|
441
603
|
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
442
604
|
is_claude_tier_enabled=True,
|
|
443
605
|
run_state_directory=run_state_directory,
|
|
444
|
-
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
445
606
|
)
|
|
446
607
|
|
|
447
608
|
all_tiers = [each_attempt.tier for each_attempt in spawn_outcome.all_attempts]
|
|
@@ -515,7 +676,6 @@ def test_preflight_fallthrough_skips_grok_runner(
|
|
|
515
676
|
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
516
677
|
is_claude_tier_enabled=False,
|
|
517
678
|
run_state_directory=run_state_directory,
|
|
518
|
-
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
519
679
|
)
|
|
520
680
|
|
|
521
681
|
assert call_log.grok_calls == 0
|
|
@@ -533,9 +693,15 @@ def test_detect_host_profile_is_consumed_not_reimplemented() -> None:
|
|
|
533
693
|
assert "THIRD_PARTY" not in source_text
|
|
534
694
|
|
|
535
695
|
|
|
536
|
-
def
|
|
696
|
+
def test_dispatcher_grok_invocation_carries_no_turn_cap(
|
|
537
697
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
538
698
|
) -> None:
|
|
699
|
+
"""The dispatcher hands the runner no turn cap; the timeout is the only bound.
|
|
700
|
+
|
|
701
|
+
::
|
|
702
|
+
|
|
703
|
+
grok tier kwargs ok: timeout_seconds present, max_turns absent
|
|
704
|
+
"""
|
|
539
705
|
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
540
706
|
call_log = _install_seams(
|
|
541
707
|
monkeypatch,
|
|
@@ -559,7 +725,10 @@ def test_default_max_turns_reaches_grok_kwargs(
|
|
|
559
725
|
|
|
560
726
|
assert exit_code == SPAWN_SERVED_EXIT_CODE
|
|
561
727
|
assert call_log.grok_keyword_arguments is not None
|
|
562
|
-
assert
|
|
728
|
+
assert "max_turns" not in call_log.grok_keyword_arguments
|
|
729
|
+
assert call_log.grok_keyword_arguments["timeout_seconds"] == (
|
|
730
|
+
DEFAULT_WORKER_TIMEOUT_SECONDS
|
|
731
|
+
)
|
|
563
732
|
parsed_payload = json.loads(capsys.readouterr().out)
|
|
564
733
|
assert parsed_payload[RESULT_KEY_OK] is True
|
|
565
734
|
|
|
@@ -645,7 +814,6 @@ def test_default_role_maps_to_primary_agent_stem(
|
|
|
645
814
|
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
646
815
|
is_claude_tier_enabled=False,
|
|
647
816
|
run_state_directory=run_state_directory,
|
|
648
|
-
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
649
817
|
)
|
|
650
818
|
|
|
651
819
|
assert spawn_outcome.is_ok is True
|
|
@@ -674,7 +842,6 @@ def test_tier_three_argv_includes_agent_for_default_role(
|
|
|
674
842
|
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
675
843
|
is_claude_tier_enabled=False,
|
|
676
844
|
run_state_directory=run_state_directory,
|
|
677
|
-
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
678
845
|
)
|
|
679
846
|
|
|
680
847
|
assert spawn_outcome.tier_used == TIER_CLAUDE_HEADLESS
|
|
@@ -708,7 +875,6 @@ def test_large_prompt_stays_out_of_claude_argv(
|
|
|
708
875
|
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
709
876
|
is_claude_tier_enabled=False,
|
|
710
877
|
run_state_directory=run_state_directory,
|
|
711
|
-
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
712
878
|
)
|
|
713
879
|
|
|
714
880
|
assert spawn_outcome.tier_used == TIER_CLAUDE_HEADLESS
|
|
@@ -926,8 +1092,7 @@ def test_headless_chain_runner_lock_serializes_distinct_cwds(
|
|
|
926
1092
|
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
927
1093
|
is_claude_tier_enabled=False,
|
|
928
1094
|
run_state_directory=run_state_directory,
|
|
929
|
-
|
|
930
|
-
)
|
|
1095
|
+
)
|
|
931
1096
|
except (OSError, RuntimeError, ValueError, AssertionError) as raised_error:
|
|
932
1097
|
all_errors.append(raised_error)
|
|
933
1098
|
|
|
@@ -1005,7 +1170,6 @@ def test_usage_limit_fallover_delivers_full_prompt_to_each_binary(
|
|
|
1005
1170
|
timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
1006
1171
|
is_claude_tier_enabled=False,
|
|
1007
1172
|
run_state_directory=run_state_directory,
|
|
1008
|
-
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
1009
1173
|
)
|
|
1010
1174
|
|
|
1011
1175
|
assert prompt_text_by_command["claude"] == FIXTURE_PROMPT_TEXT
|
|
@@ -22,15 +22,17 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
|
22
22
|
BUILD_PROFILE_PROMPT_HEADER,
|
|
23
23
|
CLASSIFICATION_ERROR,
|
|
24
24
|
CLASSIFICATION_OK,
|
|
25
|
+
CLASSIFICATION_TIMEOUT,
|
|
25
26
|
CLASSIFICATION_USAGE_LIMIT,
|
|
26
27
|
DEBUG_FILE_FLAG,
|
|
27
28
|
DEFAULT_ROLE,
|
|
28
|
-
DEFAULT_WORKER_MAX_TURNS,
|
|
29
29
|
DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
30
30
|
DISABLE_WEB_SEARCH_FLAG,
|
|
31
31
|
DISALLOWED_TOOLS_FLAG,
|
|
32
32
|
LEADER_SOCKET_FILENAME_PREFIX,
|
|
33
33
|
LEADER_SOCKET_FILENAME_SUFFIX,
|
|
34
|
+
MAX_TURNS_FLAG,
|
|
35
|
+
MAXIMUM_WORKER_TIMEOUT_SECONDS,
|
|
34
36
|
OUTPUT_FILENAME_PREFIX,
|
|
35
37
|
PROMPT_FILENAME_PREFIX,
|
|
36
38
|
READONLY_DISALLOWED_TOOLS_VALUE,
|
|
@@ -53,7 +55,6 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
|
53
55
|
TOOL_PROFILE_READONLY,
|
|
54
56
|
UTF8_ENCODING,
|
|
55
57
|
WORKER_SPEC_AGENT_NAME_KEY,
|
|
56
|
-
WORKER_SPEC_MAX_TURNS_KEY,
|
|
57
58
|
WORKER_SPEC_PROMPT_PARTS_KEY,
|
|
58
59
|
WORKER_SPEC_TIMEOUT_KEY,
|
|
59
60
|
)
|
|
@@ -61,8 +62,10 @@ from dev_env_scripts_constants.timing import WORKER_STAGGER_SECONDS # noqa: E40
|
|
|
61
62
|
from grok_headless_runner import GrokRunnerOutcome # noqa: E402
|
|
62
63
|
from grok_worker_preflight import PreflightOutcome # noqa: E402
|
|
63
64
|
|
|
65
|
+
RETIRED_MAX_TURNS_KEYWORD = "max_turns"
|
|
64
66
|
FIXTURE_REPORT_TEXT = '{"status":"done","role":"investigator"}'
|
|
65
67
|
FIXTURE_USAGE_LIMIT_TEXT = "rate limit exceeded (HTTP 429): quota exceeded"
|
|
68
|
+
FIXTURE_TIMEOUT_KILL_TEXT = "worker exceeded its timeout and was killed"
|
|
66
69
|
|
|
67
70
|
|
|
68
71
|
def _write_prompt_parts(
|
|
@@ -871,7 +874,7 @@ def test_load_batch_spec_missing_worker_keys_raise_value_error(
|
|
|
871
874
|
).lower() or "must be" in str(raised_error.value).lower()
|
|
872
875
|
|
|
873
876
|
|
|
874
|
-
def
|
|
877
|
+
def test_load_batch_spec_rejects_non_positive_timeout(
|
|
875
878
|
tmp_path: Path,
|
|
876
879
|
) -> None:
|
|
877
880
|
header_part, body_part = _write_prompt_parts(tmp_path)
|
|
@@ -891,27 +894,11 @@ def test_load_batch_spec_rejects_non_positive_timeout_and_max_turns(
|
|
|
891
894
|
zero_timeout_dir,
|
|
892
895
|
all_worker_payloads=[zero_timeout_payload],
|
|
893
896
|
)
|
|
894
|
-
with pytest.raises(ValueError, match=
|
|
897
|
+
with pytest.raises(ValueError, match="MIN_WORKER_TIMEOUT_SECONDS"):
|
|
895
898
|
batch.load_batch_spec(zero_timeout_path)
|
|
896
899
|
|
|
897
|
-
negative_turns_dir = tmp_path / "negative-turns"
|
|
898
|
-
negative_turns_dir.mkdir()
|
|
899
|
-
negative_turns_payload = _worker_payload(
|
|
900
|
-
role_name="negative-turns",
|
|
901
|
-
all_prompt_parts=[str(header_part), str(body_part)],
|
|
902
|
-
working_directory=working_directory,
|
|
903
|
-
tool_profile=TOOL_PROFILE_BUILD,
|
|
904
|
-
)
|
|
905
|
-
negative_turns_payload[WORKER_SPEC_MAX_TURNS_KEY] = -1
|
|
906
|
-
negative_turns_path = _write_batch_spec(
|
|
907
|
-
negative_turns_dir,
|
|
908
|
-
all_worker_payloads=[negative_turns_payload],
|
|
909
|
-
)
|
|
910
|
-
with pytest.raises(ValueError, match=WORKER_SPEC_MAX_TURNS_KEY):
|
|
911
|
-
batch.load_batch_spec(negative_turns_path)
|
|
912
|
-
|
|
913
900
|
|
|
914
|
-
def
|
|
901
|
+
def test_load_batch_spec_accepts_the_default_timeout(
|
|
915
902
|
tmp_path: Path,
|
|
916
903
|
) -> None:
|
|
917
904
|
header_part, body_part = _write_prompt_parts(tmp_path)
|
|
@@ -931,7 +918,223 @@ def test_load_batch_spec_accepts_default_timeout_and_max_turns(
|
|
|
931
918
|
|
|
932
919
|
assert len(batch_spec.all_workers) == 1
|
|
933
920
|
assert batch_spec.all_workers[0].timeout_seconds == DEFAULT_WORKER_TIMEOUT_SECONDS
|
|
934
|
-
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def test_timeout_over_the_ceiling_is_refused_and_at_the_ceiling_passes(
|
|
924
|
+
tmp_path: Path,
|
|
925
|
+
) -> None:
|
|
926
|
+
"""The launcher refuses a spec past the 90-minute ceiling; it never clamps.
|
|
927
|
+
|
|
928
|
+
::
|
|
929
|
+
|
|
930
|
+
timeout_seconds 5401 flag: ValueError naming MAXIMUM_WORKER_TIMEOUT_SECONDS
|
|
931
|
+
timeout_seconds 5400 ok: loads, value untouched
|
|
932
|
+
timeout_seconds 30 ok: loads, value untouched
|
|
933
|
+
"""
|
|
934
|
+
header_part, body_part = _write_prompt_parts(tmp_path)
|
|
935
|
+
working_directory = tmp_path / "project"
|
|
936
|
+
working_directory.mkdir()
|
|
937
|
+
|
|
938
|
+
over_ceiling_directory = tmp_path / "over-ceiling"
|
|
939
|
+
over_ceiling_directory.mkdir()
|
|
940
|
+
over_ceiling_path = _write_batch_spec(
|
|
941
|
+
over_ceiling_directory,
|
|
942
|
+
all_worker_payloads=[
|
|
943
|
+
_worker_payload(
|
|
944
|
+
role_name="over-ceiling",
|
|
945
|
+
all_prompt_parts=[str(header_part), str(body_part)],
|
|
946
|
+
working_directory=working_directory,
|
|
947
|
+
tool_profile=TOOL_PROFILE_BUILD,
|
|
948
|
+
timeout_seconds=MAXIMUM_WORKER_TIMEOUT_SECONDS + 1,
|
|
949
|
+
)
|
|
950
|
+
],
|
|
951
|
+
)
|
|
952
|
+
with pytest.raises(ValueError, match="MAXIMUM_WORKER_TIMEOUT_SECONDS"):
|
|
953
|
+
batch.load_batch_spec(over_ceiling_path)
|
|
954
|
+
|
|
955
|
+
at_ceiling_directory = tmp_path / "at-ceiling"
|
|
956
|
+
at_ceiling_directory.mkdir()
|
|
957
|
+
at_ceiling_path = _write_batch_spec(
|
|
958
|
+
at_ceiling_directory,
|
|
959
|
+
all_worker_payloads=[
|
|
960
|
+
_worker_payload(
|
|
961
|
+
role_name="at-ceiling",
|
|
962
|
+
all_prompt_parts=[str(header_part), str(body_part)],
|
|
963
|
+
working_directory=working_directory,
|
|
964
|
+
tool_profile=TOOL_PROFILE_BUILD,
|
|
965
|
+
timeout_seconds=MAXIMUM_WORKER_TIMEOUT_SECONDS,
|
|
966
|
+
),
|
|
967
|
+
_worker_payload(
|
|
968
|
+
role_name="well-under-ceiling",
|
|
969
|
+
all_prompt_parts=[str(header_part), str(body_part)],
|
|
970
|
+
working_directory=working_directory,
|
|
971
|
+
tool_profile=TOOL_PROFILE_BUILD,
|
|
972
|
+
timeout_seconds=30,
|
|
973
|
+
),
|
|
974
|
+
],
|
|
975
|
+
)
|
|
976
|
+
at_ceiling_spec = batch.load_batch_spec(at_ceiling_path)
|
|
977
|
+
|
|
978
|
+
assert at_ceiling_spec.all_workers[0].timeout_seconds == (
|
|
979
|
+
MAXIMUM_WORKER_TIMEOUT_SECONDS
|
|
980
|
+
)
|
|
981
|
+
assert at_ceiling_spec.all_workers[1].timeout_seconds == 30
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def test_ceiling_timeout_reaches_the_runner_untouched(
|
|
985
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
986
|
+
) -> None:
|
|
987
|
+
header_part, body_part = _write_prompt_parts(tmp_path, role_marker="long-worker")
|
|
988
|
+
working_directory = tmp_path / "project"
|
|
989
|
+
working_directory.mkdir()
|
|
990
|
+
run_state_directory = tmp_path / "run-state"
|
|
991
|
+
batch_spec = batch.load_batch_spec(
|
|
992
|
+
_write_batch_spec(
|
|
993
|
+
tmp_path,
|
|
994
|
+
all_worker_payloads=[
|
|
995
|
+
_worker_payload(
|
|
996
|
+
role_name="long-worker",
|
|
997
|
+
all_prompt_parts=[str(header_part), str(body_part)],
|
|
998
|
+
working_directory=working_directory,
|
|
999
|
+
tool_profile=TOOL_PROFILE_READONLY,
|
|
1000
|
+
timeout_seconds=MAXIMUM_WORKER_TIMEOUT_SECONDS,
|
|
1001
|
+
)
|
|
1002
|
+
],
|
|
1003
|
+
)
|
|
1004
|
+
)
|
|
1005
|
+
recorder = _RunnerRecorder({"long-worker": _ok_outcome()})
|
|
1006
|
+
monkeypatch.setattr(
|
|
1007
|
+
batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
|
|
1008
|
+
)
|
|
1009
|
+
monkeypatch.setattr(batch, "batch_headless_runner", recorder)
|
|
1010
|
+
monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
|
|
1011
|
+
|
|
1012
|
+
batch.run_grok_batch(
|
|
1013
|
+
batch_spec=batch_spec,
|
|
1014
|
+
run_state_directory=run_state_directory,
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
assert recorder.all_keyword_arguments[0]["timeout_seconds"] == (
|
|
1018
|
+
MAXIMUM_WORKER_TIMEOUT_SECONDS
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def test_worker_invocations_carry_no_turn_cap(
|
|
1023
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
1024
|
+
) -> None:
|
|
1025
|
+
header_part, body_part = _write_prompt_parts(tmp_path, role_marker="uncapped")
|
|
1026
|
+
working_directory = tmp_path / "project"
|
|
1027
|
+
working_directory.mkdir()
|
|
1028
|
+
run_state_directory = tmp_path / "run-state"
|
|
1029
|
+
batch_spec = batch.load_batch_spec(
|
|
1030
|
+
_write_batch_spec(
|
|
1031
|
+
tmp_path,
|
|
1032
|
+
all_worker_payloads=[
|
|
1033
|
+
_worker_payload(
|
|
1034
|
+
role_name="uncapped",
|
|
1035
|
+
all_prompt_parts=[str(header_part), str(body_part)],
|
|
1036
|
+
working_directory=working_directory,
|
|
1037
|
+
tool_profile=TOOL_PROFILE_READONLY,
|
|
1038
|
+
)
|
|
1039
|
+
],
|
|
1040
|
+
)
|
|
1041
|
+
)
|
|
1042
|
+
recorder = _RunnerRecorder({"uncapped": _ok_outcome()})
|
|
1043
|
+
monkeypatch.setattr(
|
|
1044
|
+
batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
|
|
1045
|
+
)
|
|
1046
|
+
monkeypatch.setattr(batch, "batch_headless_runner", recorder)
|
|
1047
|
+
monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
|
|
1048
|
+
|
|
1049
|
+
batch.run_grok_batch(
|
|
1050
|
+
batch_spec=batch_spec,
|
|
1051
|
+
run_state_directory=run_state_directory,
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
launched_keyword_arguments = recorder.all_keyword_arguments[0]
|
|
1055
|
+
all_extra_arguments = launched_keyword_arguments["all_extra_arguments"]
|
|
1056
|
+
assert RETIRED_MAX_TURNS_KEYWORD not in launched_keyword_arguments
|
|
1057
|
+
assert isinstance(all_extra_arguments, tuple)
|
|
1058
|
+
assert MAX_TURNS_FLAG not in all_extra_arguments
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def test_timed_out_worker_reads_as_timeout_beside_a_completed_worker(
|
|
1062
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1063
|
+
tmp_path: Path,
|
|
1064
|
+
capsys: pytest.CaptureFixture[str],
|
|
1065
|
+
) -> None:
|
|
1066
|
+
"""A killed worker stays distinguishable from a completed one in the summary.
|
|
1067
|
+
|
|
1068
|
+
::
|
|
1069
|
+
|
|
1070
|
+
killed worker ok: classification timeout, is_ok False, exit code 1
|
|
1071
|
+
completed worker ok: classification ok, is_ok True
|
|
1072
|
+
"""
|
|
1073
|
+
header_done, body_done = _write_prompt_parts(tmp_path, role_marker="done-worker")
|
|
1074
|
+
header_killed, body_killed = _write_prompt_parts(
|
|
1075
|
+
tmp_path, role_marker="killed-worker"
|
|
1076
|
+
)
|
|
1077
|
+
working_directory = tmp_path / "project"
|
|
1078
|
+
working_directory.mkdir()
|
|
1079
|
+
run_state_directory = tmp_path / "run-state"
|
|
1080
|
+
specification_path = _write_batch_spec(
|
|
1081
|
+
tmp_path,
|
|
1082
|
+
all_worker_payloads=[
|
|
1083
|
+
_worker_payload(
|
|
1084
|
+
role_name="done-worker",
|
|
1085
|
+
all_prompt_parts=[str(header_done), str(body_done)],
|
|
1086
|
+
working_directory=working_directory,
|
|
1087
|
+
tool_profile=TOOL_PROFILE_BUILD,
|
|
1088
|
+
),
|
|
1089
|
+
_worker_payload(
|
|
1090
|
+
role_name="killed-worker",
|
|
1091
|
+
all_prompt_parts=[str(header_killed), str(body_killed)],
|
|
1092
|
+
working_directory=working_directory,
|
|
1093
|
+
tool_profile=TOOL_PROFILE_BUILD,
|
|
1094
|
+
),
|
|
1095
|
+
],
|
|
1096
|
+
)
|
|
1097
|
+
recorder = _RunnerRecorder(
|
|
1098
|
+
{
|
|
1099
|
+
"done-worker": _ok_outcome(),
|
|
1100
|
+
"killed-worker": GrokRunnerOutcome(
|
|
1101
|
+
is_ok=False,
|
|
1102
|
+
returncode=-9,
|
|
1103
|
+
classification=CLASSIFICATION_TIMEOUT,
|
|
1104
|
+
stdout="",
|
|
1105
|
+
stderr=FIXTURE_TIMEOUT_KILL_TEXT,
|
|
1106
|
+
),
|
|
1107
|
+
}
|
|
1108
|
+
)
|
|
1109
|
+
monkeypatch.setattr(
|
|
1110
|
+
batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
|
|
1111
|
+
)
|
|
1112
|
+
monkeypatch.setattr(batch, "batch_headless_runner", recorder)
|
|
1113
|
+
monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
|
|
1114
|
+
|
|
1115
|
+
exit_code = batch.main(
|
|
1116
|
+
[
|
|
1117
|
+
"--spec",
|
|
1118
|
+
str(specification_path),
|
|
1119
|
+
"--run-temp-dir",
|
|
1120
|
+
str(run_state_directory),
|
|
1121
|
+
]
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
summary_payload = json.loads(capsys.readouterr().out)
|
|
1125
|
+
payload_by_role_name = {
|
|
1126
|
+
each_payload[SUMMARY_ROLE_NAME_KEY]: each_payload
|
|
1127
|
+
for each_payload in summary_payload[SUMMARY_WORKERS_KEY]
|
|
1128
|
+
}
|
|
1129
|
+
killed_payload = payload_by_role_name["killed-worker"]
|
|
1130
|
+
done_payload = payload_by_role_name["done-worker"]
|
|
1131
|
+
|
|
1132
|
+
assert exit_code == 1
|
|
1133
|
+
assert killed_payload[SUMMARY_CLASSIFICATION_KEY] == CLASSIFICATION_TIMEOUT
|
|
1134
|
+
assert killed_payload[SUMMARY_IS_OK_KEY] is False
|
|
1135
|
+
assert killed_payload[SUMMARY_CLASSIFICATION_KEY] != CLASSIFICATION_OK
|
|
1136
|
+
assert done_payload[SUMMARY_CLASSIFICATION_KEY] == CLASSIFICATION_OK
|
|
1137
|
+
assert done_payload[SUMMARY_IS_OK_KEY] is True
|
|
935
1138
|
|
|
936
1139
|
|
|
937
1140
|
def test_unwritable_report_file_keeps_the_worker_outcome(
|
|
@@ -434,7 +434,7 @@ test('the verdict-fence binding does not self-resolve a cwd via git rev-parse fo
|
|
|
434
434
|
);
|
|
435
435
|
});
|
|
436
436
|
|
|
437
|
-
test('every verify step calls buildVerdictFenceSteps, uses code-verifier, and forbids
|
|
437
|
+
test('every verify step calls buildVerdictFenceSteps, uses code-verifier, and forbids editing the tree under verification', () => {
|
|
438
438
|
for (const verifyFunctionName of ['runVerifierTask']) {
|
|
439
439
|
const verifyBody = lensPromptBody(verifyFunctionName);
|
|
440
440
|
assert.match(
|
|
@@ -454,8 +454,8 @@ test('every verify step calls buildVerdictFenceSteps, uses code-verifier, and fo
|
|
|
454
454
|
);
|
|
455
455
|
assert.match(
|
|
456
456
|
verifyBody,
|
|
457
|
-
/
|
|
458
|
-
`expected ${verifyFunctionName} to
|
|
457
|
+
/(?:no|not|never)[^.\n]{0,30}edit[^.\n]{0,40}tree[^.\n]{0,30}(?:under|being)\s+verif/i,
|
|
458
|
+
`expected ${verifyFunctionName} to forbid editing the tree under verification (a deliberate break off that tree stays allowed)`,
|
|
459
459
|
);
|
|
460
460
|
}
|
|
461
461
|
});
|
|
@@ -467,7 +467,7 @@ test('runFixerTask never verifies — verification belongs to the separate verif
|
|
|
467
467
|
assert.match(fixerBody, /agentType:\s*'clean-coder'/, 'expected the fixer to use clean-coder for its commit and recovery edits');
|
|
468
468
|
});
|
|
469
469
|
|
|
470
|
-
test('runVerifierTask uses --manifest-hash-for-branch with the hardening branch and forbids
|
|
470
|
+
test('runVerifierTask uses --manifest-hash-for-branch with the hardening branch and forbids editing the tree under verification', () => {
|
|
471
471
|
const verifyBody = lensPromptBody('runVerifierTask');
|
|
472
472
|
assert.match(
|
|
473
473
|
verifyBody,
|
|
@@ -476,8 +476,8 @@ test('runVerifierTask uses --manifest-hash-for-branch with the hardening branch
|
|
|
476
476
|
);
|
|
477
477
|
assert.match(
|
|
478
478
|
verifyBody,
|
|
479
|
-
/
|
|
480
|
-
'expected the verifier to
|
|
479
|
+
/(?:no|not|never)[^.\n]{0,30}edit[^.\n]{0,40}tree[^.\n]{0,30}(?:under|being)\s+verif/i,
|
|
480
|
+
'expected the verifier to forbid editing the tree under verification (a deliberate break off that tree stays allowed)',
|
|
481
481
|
);
|
|
482
482
|
});
|
|
483
483
|
|
|
@@ -901,6 +901,28 @@ test('convergeReadOnlyAgent prepends HEADLESS_READONLY_PREAMBLE and the worktree
|
|
|
901
901
|
);
|
|
902
902
|
});
|
|
903
903
|
|
|
904
|
+
test('the read-only destructive pointer scopes its no-edit clause to the tree it reads', () => {
|
|
905
|
+
const destructivePointer = convergeSource
|
|
906
|
+
.split('\n')
|
|
907
|
+
.find((eachLine) => eachLine.includes('Never run a destructive command'));
|
|
908
|
+
assert.ok(destructivePointer, 'expected the read-only destructive pointer to be declared');
|
|
909
|
+
assert.match(
|
|
910
|
+
destructivePointer,
|
|
911
|
+
/Never run a destructive command/,
|
|
912
|
+
'expected the destructive-command prohibition to stay absolute',
|
|
913
|
+
);
|
|
914
|
+
assert.match(
|
|
915
|
+
destructivePointer,
|
|
916
|
+
/no edit to the tree it reads/,
|
|
917
|
+
'expected the no-edit clause to be scoped to the tree under verification',
|
|
918
|
+
);
|
|
919
|
+
assert.doesNotMatch(
|
|
920
|
+
destructivePointer,
|
|
921
|
+
/edits nothing/,
|
|
922
|
+
'expected no blanket edits-nothing wording, which forbids the deliberate break off that tree',
|
|
923
|
+
);
|
|
924
|
+
});
|
|
925
|
+
|
|
904
926
|
const taskDispatchers = [
|
|
905
927
|
{ name: 'runGitTask', isAsync: false },
|
|
906
928
|
{ name: 'runFixerTask', isAsync: false },
|