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.
- package/_shared/CLAUDE.md +1 -0
- package/_shared/advisor/advisor-protocol.md +19 -9
- package/_shared/pr-loop/audit-contract.md +4 -4
- package/_shared/pr-loop/precatch-rubric.md +2 -2
- package/_shared/process-tree/CLAUDE.md +41 -0
- package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
- package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
- package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
- package/_shared/process-tree/scripts/pyproject.toml +16 -0
- package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
- package/agents/code-quality-agent.md +6 -5
- package/agents/deep-research.md +7 -24
- package/agents/docs-agent.md +1 -27
- package/agents/issue-tracker.md +1 -7
- package/agents/skill-writer-agent.md +1 -2
- package/agents/test_agent_frontmatter.py +309 -12
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/fable_spawn_gate.py +187 -0
- package/hooks/blocking/piped_pytest_blocker.py +1223 -0
- package/hooks/blocking/plain_language_blocker.py +287 -15
- package/hooks/blocking/test_fable_spawn_gate.py +374 -0
- package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
- package/hooks/blocking/test_plain_language_blocker.py +277 -2
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
- package/hooks/git-hooks/CLAUDE.md +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
- package/hooks/git-hooks/pre_push.py +343 -54
- package/hooks/git-hooks/test_pre_push.py +852 -6
- package/hooks/hooks.json +9 -19
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
- package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
- package/hooks/hooks_constants/shell_command_segments.py +1 -1
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/ask-user-question-required.md +26 -0
- package/rules/claims-as-quotes.md +65 -0
- package/scripts/CLAUDE.md +1 -1
- package/scripts/_code_review_test_support.py +6 -0
- package/scripts/check.ps1 +18 -5
- package/scripts/claude_chain_runner.py +203 -31
- package/scripts/codec_forwarding_test_support.py +2 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +28 -24
- package/scripts/grok_headless_runner.py +16 -83
- package/scripts/spawn_grok_batch.py +38 -1
- package/scripts/test_claude_chain_runner.py +358 -0
- package/scripts/test_grok_headless_runner.py +18 -63
- package/scripts/test_invoke_code_review.py +298 -0
- package/scripts/test_resolve_worker_spawn.py +6 -0
- package/scripts/test_spawn_grok_batch.py +101 -0
- package/scripts/tests/CLAUDE.md +1 -0
- package/scripts/tests/test_grok_worker_constants.py +59 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +105 -2
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +34 -1
- package/skills/autoconverge/workflow/converge.mjs +66 -33
- package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
- package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
- package/skills/codex-review/scripts/run_codex_review.py +16 -64
- package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
- package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
- package/skills/e-code-review/SKILL.md +9 -8
- package/skills/e-code-review/reference/fix.md +29 -7
- package/skills/e-code-review/reference/loop.md +230 -14
- package/skills/e-code-review/reference/low.md +33 -15
- package/skills/e-code-review/reference/medium.md +55 -21
- package/skills/e-code-review/reference/xhigh.md +30 -12
- package/skills/fresh-branch/CLAUDE.md +5 -5
- package/skills/fresh-branch/SKILL.md +14 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
- package/skills/grok-spawn/SKILL.md +5 -0
- package/skills/orchestrator/SKILL.md +4 -1
- package/skills/orchestrator-refresh/SKILL.md +5 -1
- 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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
)
|