claude-dev-env 2.7.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/_shared/CLAUDE.md +1 -0
  2. package/_shared/advisor/advisor-protocol.md +19 -9
  3. package/_shared/pr-loop/audit-contract.md +4 -4
  4. package/_shared/pr-loop/precatch-rubric.md +2 -2
  5. package/_shared/process-tree/CLAUDE.md +41 -0
  6. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
  7. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
  8. package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
  9. package/_shared/process-tree/scripts/pyproject.toml +16 -0
  10. package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
  11. package/agents/code-quality-agent.md +6 -5
  12. package/agents/deep-research.md +7 -24
  13. package/agents/docs-agent.md +1 -27
  14. package/agents/issue-tracker.md +1 -7
  15. package/agents/skill-writer-agent.md +1 -2
  16. package/agents/test_agent_frontmatter.py +309 -12
  17. package/hooks/blocking/CLAUDE.md +2 -0
  18. package/hooks/blocking/fable_spawn_gate.py +187 -0
  19. package/hooks/blocking/piped_pytest_blocker.py +1223 -0
  20. package/hooks/blocking/plain_language_blocker.py +287 -15
  21. package/hooks/blocking/test_fable_spawn_gate.py +374 -0
  22. package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
  23. package/hooks/blocking/test_plain_language_blocker.py +277 -2
  24. package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
  25. package/hooks/git-hooks/CLAUDE.md +2 -2
  26. package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
  27. package/hooks/git-hooks/pre_push.py +343 -54
  28. package/hooks/git-hooks/test_pre_push.py +852 -6
  29. package/hooks/hooks.json +9 -19
  30. package/hooks/hooks_constants/CLAUDE.md +2 -0
  31. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
  32. package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
  33. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
  34. package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
  35. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
  36. package/hooks/hooks_constants/shell_command_segments.py +1 -1
  37. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
  38. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
  39. package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
  40. package/package.json +1 -1
  41. package/rules/CLAUDE.md +1 -0
  42. package/rules/ask-user-question-required.md +26 -0
  43. package/rules/claims-as-quotes.md +65 -0
  44. package/scripts/CLAUDE.md +1 -1
  45. package/scripts/_code_review_test_support.py +6 -0
  46. package/scripts/check.ps1 +18 -5
  47. package/scripts/claude_chain_runner.py +203 -31
  48. package/scripts/codec_forwarding_test_support.py +2 -0
  49. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
  50. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
  51. package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
  52. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +28 -24
  53. package/scripts/grok_headless_runner.py +16 -83
  54. package/scripts/spawn_grok_batch.py +38 -1
  55. package/scripts/test_claude_chain_runner.py +358 -0
  56. package/scripts/test_grok_headless_runner.py +18 -63
  57. package/scripts/test_invoke_code_review.py +298 -0
  58. package/scripts/test_resolve_worker_spawn.py +6 -0
  59. package/scripts/test_spawn_grok_batch.py +101 -0
  60. package/scripts/tests/CLAUDE.md +1 -0
  61. package/scripts/tests/test_grok_worker_constants.py +59 -0
  62. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
  63. package/skills/autoconverge/workflow/converge.contract.test.mjs +105 -2
  64. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +34 -1
  65. package/skills/autoconverge/workflow/converge.mjs +66 -33
  66. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
  67. package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
  68. package/skills/codex-review/scripts/run_codex_review.py +16 -64
  69. package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
  70. package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
  71. package/skills/e-code-review/SKILL.md +9 -8
  72. package/skills/e-code-review/reference/fix.md +29 -7
  73. package/skills/e-code-review/reference/loop.md +230 -14
  74. package/skills/e-code-review/reference/low.md +33 -15
  75. package/skills/e-code-review/reference/medium.md +55 -21
  76. package/skills/e-code-review/reference/xhigh.md +30 -12
  77. package/skills/fresh-branch/CLAUDE.md +5 -5
  78. package/skills/fresh-branch/SKILL.md +14 -6
  79. package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
  80. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
  81. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
  82. package/skills/grok-spawn/SKILL.md +5 -0
  83. package/skills/orchestrator/SKILL.md +4 -1
  84. package/skills/orchestrator-refresh/SKILL.md +5 -1
  85. package/skills/team-advisor/SKILL.md +4 -1
@@ -26,6 +26,7 @@ from pathlib import Path
26
26
 
27
27
  from dev_env_scripts_constants.grok_worker_constants import (
28
28
  ALL_KNOWN_TOOL_PROFILES,
29
+ ALL_KNOWN_WORKER_SPEC_KEYS,
29
30
  BATCH_LAUNCH_ERROR_STDERR_PREFIX,
30
31
  BATCH_SPEC_ROLE_KEY,
31
32
  BATCH_SPEC_SHOULD_PING_KEY,
@@ -70,11 +71,13 @@ from dev_env_scripts_constants.grok_worker_constants import (
70
71
  SUMMARY_WORKERS_KEY,
71
72
  TOOL_PROFILE_BUILD,
72
73
  TOOL_PROFILE_READONLY,
74
+ UNKNOWN_WORKER_KEY_ERROR_TEMPLATE,
73
75
  UTF8_ENCODING,
74
76
  WORKER_EXCEPTION_RETURN_CODE,
75
77
  WORKER_SPEC_AGENT_NAME_KEY,
76
78
  WORKER_SPEC_CWD_KEY,
77
79
  WORKER_SPEC_IS_REPO_ONLY_KEY,
80
+ WORKER_SPEC_KEY_JOIN_SEPARATOR,
78
81
  WORKER_SPEC_PROMPT_PARTS_KEY,
79
82
  WORKER_SPEC_ROLE_NAME_KEY,
80
83
  WORKER_SPEC_TIMEOUT_KEY,
@@ -89,6 +92,38 @@ batch_headless_runner = run_headless_worker
89
92
  batch_preflight = run_preflight
90
93
 
91
94
 
95
+ def _require_known_worker_keys(all_worker_fields: dict[str, object]) -> None:
96
+ """Reject a worker entry carrying a key the launcher does not accept.
97
+
98
+ ::
99
+
100
+ {"role_name": "lens", ..., "timeout_second": <seconds>}
101
+ flag: unknown worker key(s): timeout_second; accepted keys: agent_name, ...
102
+
103
+ A dropped key reads as a setting that took effect. Naming it here makes
104
+ that a startup error rather than a silent gap.
105
+
106
+ Args:
107
+ all_worker_fields: One raw worker entry straight from the JSON spec.
108
+
109
+ Raises:
110
+ ValueError: When the entry carries any key outside the accepted set.
111
+ """
112
+ all_unknown_keys = set(all_worker_fields) - ALL_KNOWN_WORKER_SPEC_KEYS
113
+ if not all_unknown_keys:
114
+ return
115
+ joined_unknown = WORKER_SPEC_KEY_JOIN_SEPARATOR.join(sorted(all_unknown_keys))
116
+ joined_accepted = WORKER_SPEC_KEY_JOIN_SEPARATOR.join(
117
+ sorted(ALL_KNOWN_WORKER_SPEC_KEYS)
118
+ )
119
+ raise ValueError(
120
+ UNKNOWN_WORKER_KEY_ERROR_TEMPLATE.format(
121
+ unknown_keys=joined_unknown,
122
+ accepted_keys=joined_accepted,
123
+ )
124
+ )
125
+
126
+
92
127
  @dataclass(frozen=True)
93
128
  class WorkerSpec:
94
129
  """One worker entry from a batch specification."""
@@ -290,6 +325,7 @@ def _require_timeout_within_bounds(raw_field: object) -> int:
290
325
 
291
326
 
292
327
  def _parse_worker_entry(all_worker_fields: dict[str, object]) -> WorkerSpec:
328
+ _require_known_worker_keys(all_worker_fields)
293
329
  role_name = _require_string(
294
330
  _require_worker_field(all_worker_fields, WORKER_SPEC_ROLE_NAME_KEY),
295
331
  WORKER_SPEC_ROLE_NAME_KEY,
@@ -348,7 +384,8 @@ def load_batch_spec(specification_path: Path) -> BatchSpec:
348
384
  The validated batch specification.
349
385
 
350
386
  Raises:
351
- ValueError: When the JSON shape is invalid or a required field is wrong.
387
+ ValueError: When the JSON shape is invalid, a required field is wrong,
388
+ or a worker entry carries a key outside the accepted set.
352
389
  OSError: When the specification file cannot be read.
353
390
  json.JSONDecodeError: When the file is not valid JSON.
354
391
  """
@@ -24,11 +24,13 @@ from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
24
24
  ATTEMPT_STATUS_USAGE_LIMITED,
25
25
  ATTEMPT_SUMMARY_ENTRY_TEMPLATE,
26
26
  ATTEMPT_SUMMARY_JOIN_SEPARATOR,
27
+ CHAIN_ADVISOR_BLOCKED_EXIT_CODE,
27
28
  CHAIN_CONFIG_ERROR_EXIT_CODE,
28
29
  CHAIN_EXHAUSTED_EXIT_CODE,
29
30
  CHAIN_EXHAUSTED_MESSAGE_TEMPLATE,
30
31
  CLAUDE_HOME_SUBDIRECTORY,
31
32
  CLI_ARGUMENTS_SEPARATOR,
33
+ CLI_ROUTING_MODE_FLAG,
32
34
  CLI_TIMEOUT_FLAG,
33
35
  CODEC_ERROR_STRATEGY,
34
36
  CONFIG_CHAIN_EMPTY_REASON,
@@ -48,6 +50,11 @@ from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
48
50
  DEFAULT_TIMEOUT_SECONDS,
49
51
  EXAMPLE_CONFIG_FILENAME,
50
52
  NO_COMPLETED_PROCESS_RETURN_CODE,
53
+ ROUTING_MODE_ORDERED_ACCOUNT,
54
+ SESSION_ID_JSON_KEY,
55
+ TERMINAL_STATUS_ADVISOR_BLOCKED,
56
+ TERMINAL_STATUS_SERVED,
57
+ TERMINAL_STATUS_TIMEOUT,
51
58
  UTF8_ENCODING,
52
59
  )
53
60
 
@@ -65,6 +72,15 @@ _EQUAL_WEEKLY_REMAINING_PERCENT = 50.0
65
72
  _HIGH_WEEKLY_REMAINING_PERCENT = 90.0
66
73
  _MID_WEEKLY_REMAINING_PERCENT = 50.0
67
74
  _LOW_WEEKLY_REMAINING_PERCENT = 10.0
75
+ _PRIMARY_LAUNCHER = "primary-launcher"
76
+ _SECONDARY_LAUNCHER = "secondary-launcher"
77
+ _BOUND_SESSION_ID = "session-bound-abc-123"
78
+ _AUTHENTICATION_ERROR_STDERR = "authentication_error: invalid API key"
79
+ _GENERIC_PROCESS_ERROR_STDERR = "unknown flag: --not-a-real-flag"
80
+ _JSON_BIND_STDOUT_WITH_SESSION = (
81
+ f'{{"type":"result","{SESSION_ID_JSON_KEY}":"{_BOUND_SESSION_ID}",'
82
+ f'"result":"ENDORSE"}}\n'
83
+ )
68
84
 
69
85
 
70
86
  def _completed(command, returncode, stdout="", stderr=""):
@@ -567,6 +583,7 @@ def test_timeout_on_primary_does_not_fall_over(
567
583
  chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
568
584
  assert chain_result.served_command is None
569
585
  assert chain_result.returncode == NO_COMPLETED_PROCESS_RETURN_CODE
586
+ assert chain_result.terminal_status == TERMINAL_STATUS_TIMEOUT
570
587
  assert len(chain_result.attempts) == 1
571
588
  assert chain_result.attempts[0].status == ATTEMPT_STATUS_TIMEOUT
572
589
 
@@ -1119,3 +1136,344 @@ def test_cli_emits_utf8_when_console_encoding_is_legacy(tmp_path: Path) -> None:
1119
1136
  assert completed.returncode == 0
1120
1137
  assert b"Traceback" not in completed.stderr
1121
1138
  assert "report ✅" in completed.stdout.decode(UTF8_ENCODING)
1139
+
1140
+
1141
+ def test_usage_limit_classifier_keys_on_exact_signature_constants() -> None:
1142
+ assert ALL_USAGE_LIMIT_SIGNATURES == (
1143
+ "hit your session limit",
1144
+ "usage limit reached",
1145
+ "out of usage",
1146
+ "usage quota exceeded",
1147
+ )
1148
+ assert _A_SIGNATURE in ALL_USAGE_LIMIT_SIGNATURES
1149
+ assert runner._is_usage_limit_failure(
1150
+ _completed("primary-launcher", 1, stderr=_A_SIGNATURE)
1151
+ )
1152
+ assert not runner._is_usage_limit_failure(
1153
+ _completed("primary-launcher", 1, stderr=_GENERIC_PROCESS_ERROR_STDERR)
1154
+ )
1155
+
1156
+
1157
+ def test_extract_session_id_from_stdout_reads_json_object_and_ndjson() -> None:
1158
+ single_object = (
1159
+ f'{{"type":"result","{SESSION_ID_JSON_KEY}":"{_BOUND_SESSION_ID}",'
1160
+ f'"result":"ENDORSE"}}'
1161
+ )
1162
+ assert runner.extract_session_id_from_stdout(single_object) == _BOUND_SESSION_ID
1163
+ ndjson_stream = (
1164
+ f'{{"type":"system","{SESSION_ID_JSON_KEY}":"{_BOUND_SESSION_ID}"}}\n'
1165
+ f'{{"type":"result","{SESSION_ID_JSON_KEY}":"{_BOUND_SESSION_ID}",'
1166
+ f'"result":"ok"}}\n'
1167
+ )
1168
+ assert runner.extract_session_id_from_stdout(ndjson_stream) == _BOUND_SESSION_ID
1169
+ assert runner.extract_session_id_from_stdout("not json at all") is None
1170
+ assert runner.extract_session_id_from_stdout("") is None
1171
+
1172
+
1173
+ def test_ordered_account_mode_tries_primary_launcher_first(
1174
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1175
+ ) -> None:
1176
+ config_file = _write_chain_config(
1177
+ tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1178
+ )
1179
+ usage_reporter = _usage_reporter_from_remaining(
1180
+ {
1181
+ _PRIMARY_LAUNCHER: _LOW_WEEKLY_REMAINING_PERCENT,
1182
+ _SECONDARY_LAUNCHER: _HIGH_WEEKLY_REMAINING_PERCENT,
1183
+ }
1184
+ )
1185
+ recorder = _install(
1186
+ monkeypatch,
1187
+ config_file,
1188
+ {
1189
+ _PRIMARY_LAUNCHER: _completed(
1190
+ _PRIMARY_LAUNCHER, 0, stdout=_JSON_BIND_STDOUT_WITH_SESSION
1191
+ ),
1192
+ _SECONDARY_LAUNCHER: _completed(
1193
+ _SECONDARY_LAUNCHER, 0, stdout="should-not-run"
1194
+ ),
1195
+ },
1196
+ weekly_usage_reporter=usage_reporter,
1197
+ )
1198
+ chain_result = runner.run_claude(
1199
+ _PROMPT_ARGUMENTS,
1200
+ timeout_seconds=5,
1201
+ routing_mode=ROUTING_MODE_ORDERED_ACCOUNT,
1202
+ )
1203
+ assert chain_result.served_command == _PRIMARY_LAUNCHER
1204
+ assert chain_result.terminal_status == TERMINAL_STATUS_SERVED
1205
+ assert recorder.invocations[0][0] == _PRIMARY_LAUNCHER
1206
+ assert usage_reporter.call_count["count"] == 0
1207
+ assert [each_attempt.command for each_attempt in chain_result.attempts] == [
1208
+ _PRIMARY_LAUNCHER
1209
+ ]
1210
+
1211
+
1212
+ def test_ordered_account_mode_usage_limit_falls_over_to_secondary(
1213
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1214
+ ) -> None:
1215
+ config_file = _write_chain_config(
1216
+ tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1217
+ )
1218
+ _install(
1219
+ monkeypatch,
1220
+ config_file,
1221
+ {
1222
+ _PRIMARY_LAUNCHER: _completed(
1223
+ _PRIMARY_LAUNCHER, 1, stderr=_A_SIGNATURE
1224
+ ),
1225
+ _SECONDARY_LAUNCHER: _completed(
1226
+ _SECONDARY_LAUNCHER, 0, stdout=_JSON_BIND_STDOUT_WITH_SESSION
1227
+ ),
1228
+ },
1229
+ )
1230
+ chain_result = runner.run_claude(
1231
+ _PROMPT_ARGUMENTS,
1232
+ timeout_seconds=5,
1233
+ routing_mode=ROUTING_MODE_ORDERED_ACCOUNT,
1234
+ )
1235
+ assert chain_result.served_command == _SECONDARY_LAUNCHER
1236
+ assert chain_result.returncode == 0
1237
+ assert chain_result.terminal_status == TERMINAL_STATUS_SERVED
1238
+ assert [each_attempt.command for each_attempt in chain_result.attempts] == [
1239
+ _PRIMARY_LAUNCHER,
1240
+ _SECONDARY_LAUNCHER,
1241
+ ]
1242
+ assert chain_result.attempts[0].status == ATTEMPT_STATUS_USAGE_LIMITED
1243
+ assert chain_result.attempts[1].status == ATTEMPT_STATUS_SERVED
1244
+
1245
+
1246
+ def test_ordered_account_mode_authentication_error_is_advisor_blocked(
1247
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1248
+ ) -> None:
1249
+ config_file = _write_chain_config(
1250
+ tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1251
+ )
1252
+ recorder = _install(
1253
+ monkeypatch,
1254
+ config_file,
1255
+ {
1256
+ _PRIMARY_LAUNCHER: _completed(
1257
+ _PRIMARY_LAUNCHER, 1, stderr=_AUTHENTICATION_ERROR_STDERR
1258
+ ),
1259
+ _SECONDARY_LAUNCHER: _completed(
1260
+ _SECONDARY_LAUNCHER, 0, stdout="should-not-run"
1261
+ ),
1262
+ },
1263
+ )
1264
+ chain_result = runner.run_claude(
1265
+ _PROMPT_ARGUMENTS,
1266
+ timeout_seconds=5,
1267
+ routing_mode=ROUTING_MODE_ORDERED_ACCOUNT,
1268
+ )
1269
+ assert chain_result.terminal_status == TERMINAL_STATUS_ADVISOR_BLOCKED
1270
+ assert chain_result.served_command is None
1271
+ assert chain_result.returncode == 1
1272
+ assert _AUTHENTICATION_ERROR_STDERR in chain_result.stderr
1273
+ assert len(recorder.invocations) == 1
1274
+ assert chain_result.attempts[0].status == ATTEMPT_STATUS_NONZERO_EXIT
1275
+
1276
+
1277
+ def test_ordered_account_mode_timeout_is_advisor_blocked(
1278
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1279
+ ) -> None:
1280
+ config_file = _write_chain_config(
1281
+ tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1282
+ )
1283
+ recorder = _install(
1284
+ monkeypatch,
1285
+ config_file,
1286
+ {
1287
+ _PRIMARY_LAUNCHER: subprocess.TimeoutExpired(
1288
+ cmd=[_PRIMARY_LAUNCHER], timeout=5
1289
+ ),
1290
+ _SECONDARY_LAUNCHER: _completed(_SECONDARY_LAUNCHER, 0),
1291
+ },
1292
+ )
1293
+ chain_result = runner.run_claude(
1294
+ _PROMPT_ARGUMENTS,
1295
+ timeout_seconds=5,
1296
+ routing_mode=ROUTING_MODE_ORDERED_ACCOUNT,
1297
+ )
1298
+ assert chain_result.terminal_status == TERMINAL_STATUS_ADVISOR_BLOCKED
1299
+ assert chain_result.served_command is None
1300
+ assert chain_result.returncode == NO_COMPLETED_PROCESS_RETURN_CODE
1301
+ assert len(recorder.invocations) == 1
1302
+ assert chain_result.attempts[0].status == ATTEMPT_STATUS_TIMEOUT
1303
+
1304
+
1305
+ def test_ordered_account_mode_generic_process_error_is_advisor_blocked(
1306
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1307
+ ) -> None:
1308
+ config_file = _write_chain_config(
1309
+ tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1310
+ )
1311
+ recorder = _install(
1312
+ monkeypatch,
1313
+ config_file,
1314
+ {
1315
+ _PRIMARY_LAUNCHER: _completed(
1316
+ _PRIMARY_LAUNCHER, 2, stderr=_GENERIC_PROCESS_ERROR_STDERR
1317
+ ),
1318
+ _SECONDARY_LAUNCHER: _completed(
1319
+ _SECONDARY_LAUNCHER, 0, stdout="should-not-run"
1320
+ ),
1321
+ },
1322
+ )
1323
+ chain_result = runner.run_claude(
1324
+ _PROMPT_ARGUMENTS,
1325
+ timeout_seconds=5,
1326
+ routing_mode=ROUTING_MODE_ORDERED_ACCOUNT,
1327
+ )
1328
+ assert chain_result.terminal_status == TERMINAL_STATUS_ADVISOR_BLOCKED
1329
+ assert chain_result.served_command is None
1330
+ assert chain_result.returncode == 2
1331
+ assert len(recorder.invocations) == 1
1332
+ assert chain_result.attempts[0].status == ATTEMPT_STATUS_NONZERO_EXIT
1333
+
1334
+
1335
+ def test_ordered_account_mode_configuration_error_does_not_fall_over(
1336
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
1337
+ ) -> None:
1338
+ missing_config = tmp_path / CONFIG_FILENAME
1339
+ monkeypatch.setattr(runner, "chain_config_path", lambda: missing_config)
1340
+ _install_tty_stdin(monkeypatch)
1341
+ exit_code = runner.main(
1342
+ [
1343
+ CLI_ROUTING_MODE_FLAG,
1344
+ ROUTING_MODE_ORDERED_ACCOUNT,
1345
+ CLI_ARGUMENTS_SEPARATOR,
1346
+ "-p",
1347
+ "hi",
1348
+ ]
1349
+ )
1350
+ captured = capsys.readouterr()
1351
+ assert exit_code == CHAIN_CONFIG_ERROR_EXIT_CODE
1352
+ assert EXAMPLE_CONFIG_FILENAME in captured.err
1353
+
1354
+
1355
+ def test_ordered_account_mode_preserves_session_id_on_success(
1356
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1357
+ ) -> None:
1358
+ config_file = _write_chain_config(
1359
+ tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1360
+ )
1361
+ _install(
1362
+ monkeypatch,
1363
+ config_file,
1364
+ {
1365
+ _PRIMARY_LAUNCHER: _completed(
1366
+ _PRIMARY_LAUNCHER, 0, stdout=_JSON_BIND_STDOUT_WITH_SESSION
1367
+ ),
1368
+ _SECONDARY_LAUNCHER: _completed(_SECONDARY_LAUNCHER, 0),
1369
+ },
1370
+ )
1371
+ chain_result = runner.run_claude(
1372
+ _PROMPT_ARGUMENTS,
1373
+ timeout_seconds=5,
1374
+ routing_mode=ROUTING_MODE_ORDERED_ACCOUNT,
1375
+ )
1376
+ assert chain_result.terminal_status == TERMINAL_STATUS_SERVED
1377
+ assert chain_result.session_id == _BOUND_SESSION_ID
1378
+ assert chain_result.served_command == _PRIMARY_LAUNCHER
1379
+
1380
+
1381
+ def test_default_routing_mode_remains_usage_ranked(
1382
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1383
+ ) -> None:
1384
+ config_file = _write_chain_config(
1385
+ tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1386
+ )
1387
+ usage_reporter = _usage_reporter_from_remaining(
1388
+ {
1389
+ _PRIMARY_LAUNCHER: _LOW_WEEKLY_REMAINING_PERCENT,
1390
+ _SECONDARY_LAUNCHER: _HIGH_WEEKLY_REMAINING_PERCENT,
1391
+ }
1392
+ )
1393
+ recorder = _install(
1394
+ monkeypatch,
1395
+ config_file,
1396
+ {
1397
+ _PRIMARY_LAUNCHER: _completed(
1398
+ _PRIMARY_LAUNCHER, 0, stdout="from-primary"
1399
+ ),
1400
+ _SECONDARY_LAUNCHER: _completed(
1401
+ _SECONDARY_LAUNCHER, 0, stdout="from-secondary"
1402
+ ),
1403
+ },
1404
+ weekly_usage_reporter=usage_reporter,
1405
+ )
1406
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
1407
+ assert chain_result.served_command == _SECONDARY_LAUNCHER
1408
+ assert chain_result.terminal_status == TERMINAL_STATUS_SERVED
1409
+ assert recorder.invocations[0][0] == _SECONDARY_LAUNCHER
1410
+ assert usage_reporter.call_count["count"] == 1
1411
+
1412
+
1413
+ def test_cli_ordered_account_routing_mode_flag(
1414
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1415
+ ) -> None:
1416
+ config_file = _write_chain_config(
1417
+ tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1418
+ )
1419
+ usage_reporter = _usage_reporter_from_remaining(
1420
+ {
1421
+ _PRIMARY_LAUNCHER: _LOW_WEEKLY_REMAINING_PERCENT,
1422
+ _SECONDARY_LAUNCHER: _HIGH_WEEKLY_REMAINING_PERCENT,
1423
+ }
1424
+ )
1425
+ recorder = _install(
1426
+ monkeypatch,
1427
+ config_file,
1428
+ {
1429
+ _PRIMARY_LAUNCHER: _completed(
1430
+ _PRIMARY_LAUNCHER, 0, stdout=_JSON_BIND_STDOUT_WITH_SESSION
1431
+ ),
1432
+ _SECONDARY_LAUNCHER: _completed(
1433
+ _SECONDARY_LAUNCHER, 0, stdout="should-not-run"
1434
+ ),
1435
+ },
1436
+ weekly_usage_reporter=usage_reporter,
1437
+ )
1438
+ exit_code = runner.main(
1439
+ [
1440
+ CLI_ROUTING_MODE_FLAG,
1441
+ ROUTING_MODE_ORDERED_ACCOUNT,
1442
+ CLI_ARGUMENTS_SEPARATOR,
1443
+ "-p",
1444
+ "hi",
1445
+ ]
1446
+ )
1447
+ assert exit_code == 0
1448
+ assert recorder.invocations[0][0] == _PRIMARY_LAUNCHER
1449
+ assert usage_reporter.call_count["count"] == 0
1450
+
1451
+
1452
+ def test_cli_ordered_account_advisor_blocked_exit_code(
1453
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1454
+ ) -> None:
1455
+ config_file = _write_chain_config(
1456
+ tmp_path, [_entry(_PRIMARY_LAUNCHER), _entry(_SECONDARY_LAUNCHER)]
1457
+ )
1458
+ _install(
1459
+ monkeypatch,
1460
+ config_file,
1461
+ {
1462
+ _PRIMARY_LAUNCHER: _completed(
1463
+ _PRIMARY_LAUNCHER, 2, stderr=_GENERIC_PROCESS_ERROR_STDERR
1464
+ ),
1465
+ _SECONDARY_LAUNCHER: _completed(_SECONDARY_LAUNCHER, 0),
1466
+ },
1467
+ )
1468
+ exit_code = runner.main(
1469
+ [
1470
+ CLI_ROUTING_MODE_FLAG,
1471
+ ROUTING_MODE_ORDERED_ACCOUNT,
1472
+ CLI_ARGUMENTS_SEPARATOR,
1473
+ "-p",
1474
+ "hi",
1475
+ ]
1476
+ )
1477
+ assert exit_code == CHAIN_ADVISOR_BLOCKED_EXIT_CODE
1478
+
1479
+
@@ -7,7 +7,6 @@ include the signature substrings the constants module lists.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import os
11
10
  import subprocess
12
11
  import sys
13
12
  import time
@@ -20,6 +19,7 @@ if str(_SCRIPTS_DIR) not in sys.path:
20
19
  sys.path.insert(0, str(_SCRIPTS_DIR))
21
20
 
22
21
  import grok_headless_runner as runner # noqa: E402
22
+ import process_tree_kill # noqa: E402
23
23
  from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
24
24
  AGENT_FLAG,
25
25
  ALWAYS_APPROVE_FLAG,
@@ -46,16 +46,10 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
46
46
  OUTPUT_FORMAT_FLAG,
47
47
  OUTPUT_FORMAT_JSON,
48
48
  PROCESS_TREE_KILL_ATTEMPT_LIMIT,
49
- PROCESS_TREE_KILL_TIMEOUT_SECONDS,
50
49
  PROMPT_FILE_FLAG,
51
50
  TIMEOUT_RETURN_CODE,
52
51
  UTF8_DECODE_ERRORS,
53
52
  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,
59
53
  )
60
54
 
61
55
  FIXTURE_GROK_BINARY_VERSION = "0.2.99 (b1b49ccb71) [stable]"
@@ -297,7 +291,9 @@ def _run_once(
297
291
  recorder = _PopenRecorder([fake_process])
298
292
  monkeypatch.setattr(runner, "runner_popen", recorder)
299
293
  if should_install_tree_kill_recorder:
300
- monkeypatch.setattr(runner, "runner_subprocess_run", _TreeKillRecorder())
294
+ monkeypatch.setattr(
295
+ process_tree_kill, "process_tree_subprocess_run", _TreeKillRecorder()
296
+ )
301
297
  outcome = runner.run_headless_worker(
302
298
  prompt_file=prompt_file,
303
299
  working_directory=working_directory,
@@ -407,7 +403,7 @@ def test_timeout_kills_process(monkeypatch: pytest.MonkeyPatch, tmp_path: Path)
407
403
  assert recorder.all_keyword_arguments[0].get("encoding") == UTF8_ENCODING
408
404
  assert recorder.all_keyword_arguments[0].get("errors") == UTF8_DECODE_ERRORS
409
405
  assert recorder.all_keyword_arguments[0].get("start_new_session") is (
410
- os.name != WINDOWS_OS_NAME
406
+ process_tree_kill.should_start_new_session()
411
407
  )
412
408
 
413
409
 
@@ -825,59 +821,16 @@ def test_turn_capped_worker_cancels_and_uncapped_worker_completes(
825
821
  assert MAX_TURNS_FLAG not in launcher.all_invocations[1]
826
822
 
827
823
 
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
824
  def test_tree_kill_falls_back_to_direct_kill_when_taskkill_fails(
878
825
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
879
826
  ) -> None:
880
- """A failed tree kill still ends the direct child, so no caller waits on it."""
827
+ """A failed tree kill still ends the direct child, so no caller waits on it.
828
+
829
+ The platform is pinned to Windows so the taskkill seam is the branch under
830
+ test on every host. Left unpinned on POSIX, the kill would reach the
831
+ process group of the fake process id, which names an unrelated process.
832
+ """
833
+ monkeypatch.setattr(process_tree_kill.sys, "platform", "win32")
881
834
 
882
835
  def raise_on_tree_kill(
883
836
  invocation: list[str], **keyword_arguments: object
@@ -888,7 +841,9 @@ def test_tree_kill_falls_back_to_direct_kill_when_taskkill_fails(
888
841
  fake_process = _FakeProcess(
889
842
  returncode=0, stderr="still running", should_timeout=True
890
843
  )
891
- monkeypatch.setattr(runner, "runner_subprocess_run", raise_on_tree_kill)
844
+ monkeypatch.setattr(
845
+ process_tree_kill, "process_tree_subprocess_run", raise_on_tree_kill
846
+ )
892
847
  outcome, _, _, _, _ = _run_once(
893
848
  monkeypatch,
894
849
  tmp_path,
@@ -1090,7 +1045,7 @@ def _launch_kill_resistant_tree(
1090
1045
  """Wire a real parent-and-grandchild launch whose kills are dropped."""
1091
1046
  all_launched_processes: list[_KillResistantProcess] = []
1092
1047
  tree_kill_recorder = _TreeKillAttemptRecorder(
1093
- runner._kill_process_tree_by_identifier,
1048
+ process_tree_kill.kill_process_tree_by_identifier,
1094
1049
  should_drop_every_attempt=should_drop_every_kill,
1095
1050
  )
1096
1051
 
@@ -1116,7 +1071,7 @@ def _launch_kill_resistant_tree(
1116
1071
 
1117
1072
  monkeypatch.setattr(runner, "runner_popen", _spawn_kill_resistant_tree)
1118
1073
  monkeypatch.setattr(
1119
- runner, "_kill_process_tree_by_identifier", tree_kill_recorder
1074
+ process_tree_kill, "kill_process_tree_by_identifier", tree_kill_recorder
1120
1075
  )
1121
1076
  monkeypatch.setattr(
1122
1077
  runner, "KILL_GRACE_TIMEOUT_SECONDS", SHORT_KILL_GRACE_SECONDS
@@ -1182,7 +1137,7 @@ def test_worker_surviving_both_kill_attempts_is_reported_kill_failed(
1182
1137
  run_state_directory.mkdir()
1183
1138
  heartbeat_path = tmp_path / "grandchild-heartbeat.txt"
1184
1139
  heartbeat_path.write_text("", encoding=UTF8_ENCODING)
1185
- real_tree_kill = runner._kill_process_tree_by_identifier
1140
+ real_tree_kill = process_tree_kill.kill_process_tree_by_identifier
1186
1141
  tree_kill_recorder, all_launched_processes = _launch_kill_resistant_tree(
1187
1142
  monkeypatch, heartbeat_path, should_drop_every_kill=True
1188
1143
  )