claude-dev-env 2.7.0 → 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/pr-loop/worker-spawn.md +3 -1
- 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 +4 -4
- 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 +83 -13
- package/scripts/grok_headless_runner.py +148 -18
- package/scripts/resolve_worker_spawn.py +56 -10
- package/scripts/spawn_grok_batch.py +81 -23
- package/scripts/test_claude_chain_runner.py +358 -0
- package/scripts/test_grok_headless_runner.py +547 -10
- package/scripts/test_invoke_code_review.py +298 -0
- package/scripts/test_resolve_worker_spawn.py +185 -15
- package/scripts/test_spawn_grok_batch.py +326 -22
- 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 +133 -8
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +107 -1
- package/skills/autoconverge/workflow/converge.mjs +113 -31
- 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 +10 -3
- package/skills/grok-spawn/reference/flag-profiles.md +3 -1
- package/skills/orchestrator/SKILL.md +4 -1
- package/skills/orchestrator-refresh/SKILL.md +5 -1
- package/skills/team-advisor/SKILL.md +4 -1
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
Builds a headless argv, mints a unique ``--leader-socket`` path under the
|
|
5
5
|
caller-supplied run state directory, captures stdout/stderr/returncode, kills
|
|
6
|
-
the process on timeout, and classifies failures via signature lists in
|
|
6
|
+
the process tree on timeout, and classifies failures via signature lists in
|
|
7
7
|
``dev_env_scripts_constants.grok_worker_constants``.
|
|
8
8
|
|
|
9
|
+
The timeout is the only bound on a worker's length; the argv carries no turn
|
|
10
|
+
cap.
|
|
11
|
+
|
|
9
12
|
Dual-match policy matches preflight: when both usage and auth signatures appear
|
|
10
13
|
in the same streams, auth wins (``CLASSIFICATION_AUTH_FAILURE``).
|
|
11
14
|
|
|
@@ -15,8 +18,7 @@ Import ``run_headless_worker`` for the outcome object::
|
|
|
15
18
|
prompt_file=path,
|
|
16
19
|
working_directory=cwd,
|
|
17
20
|
run_state_directory=run_dir,
|
|
18
|
-
|
|
19
|
-
timeout_seconds=600,
|
|
21
|
+
timeout_seconds=5400,
|
|
20
22
|
agent_name="code-quality-agent",
|
|
21
23
|
)
|
|
22
24
|
"""
|
|
@@ -24,17 +26,30 @@ Import ``run_headless_worker`` for the outcome object::
|
|
|
24
26
|
from __future__ import annotations
|
|
25
27
|
|
|
26
28
|
import subprocess
|
|
29
|
+
import sys
|
|
27
30
|
import uuid
|
|
28
31
|
from dataclasses import dataclass
|
|
29
32
|
from pathlib import Path
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
_shared_process_tree_scripts_directory = (
|
|
35
|
+
Path(__file__).resolve().parents[1] / "_shared" / "process-tree" / "scripts"
|
|
36
|
+
)
|
|
37
|
+
if str(_shared_process_tree_scripts_directory) not in sys.path:
|
|
38
|
+
sys.path.insert(0, str(_shared_process_tree_scripts_directory))
|
|
39
|
+
|
|
40
|
+
from process_tree_kill import ( # noqa: E402
|
|
41
|
+
should_start_new_session,
|
|
42
|
+
terminate_process_tree,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
32
46
|
AGENT_FLAG,
|
|
33
47
|
ALL_AUTH_FAILURE_SIGNATURES,
|
|
34
48
|
ALL_USAGE_LIMIT_SIGNATURES,
|
|
35
49
|
ALWAYS_APPROVE_FLAG,
|
|
36
50
|
CLASSIFICATION_AUTH_FAILURE,
|
|
37
51
|
CLASSIFICATION_ERROR,
|
|
52
|
+
CLASSIFICATION_KILL_FAILED,
|
|
38
53
|
CLASSIFICATION_OK,
|
|
39
54
|
CLASSIFICATION_STREAM_JOIN_SEPARATOR,
|
|
40
55
|
CLASSIFICATION_TIMEOUT,
|
|
@@ -43,26 +58,41 @@ from dev_env_scripts_constants.grok_worker_constants import (
|
|
|
43
58
|
GROK_BINARY_NAME,
|
|
44
59
|
GROK_BINARY_NOT_FOUND_STDERR,
|
|
45
60
|
GROK_MODEL_PIN,
|
|
61
|
+
KILL_FAILED_RETURN_CODE,
|
|
62
|
+
KILL_FAILED_STDERR_TEMPLATE,
|
|
46
63
|
KILL_GRACE_TIMEOUT_SECONDS,
|
|
47
64
|
LAUNCH_FAILURE_RETURN_CODE,
|
|
48
65
|
LAUNCH_FAILURE_STDERR_PREFIX,
|
|
49
66
|
LEADER_SOCKET_FILENAME_PREFIX,
|
|
50
67
|
LEADER_SOCKET_FILENAME_SUFFIX,
|
|
51
68
|
LEADER_SOCKET_FLAG,
|
|
52
|
-
|
|
69
|
+
MAXIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE,
|
|
70
|
+
MAXIMUM_WORKER_TIMEOUT_SECONDS,
|
|
71
|
+
MIN_WORKER_TIMEOUT_SECONDS,
|
|
72
|
+
MINIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE,
|
|
53
73
|
MISSING_BINARY_RETURN_CODE,
|
|
54
74
|
MODEL_FLAG,
|
|
55
75
|
OUTPUT_FORMAT_FLAG,
|
|
56
76
|
OUTPUT_FORMAT_JSON,
|
|
77
|
+
PROCESS_TREE_KILL_ATTEMPT_LIMIT,
|
|
57
78
|
PROMPT_FILE_FLAG,
|
|
58
79
|
TIMEOUT_RETURN_CODE,
|
|
59
80
|
UTF8_DECODE_ERRORS,
|
|
60
81
|
UTF8_ENCODING,
|
|
82
|
+
WORKER_SPEC_TIMEOUT_KEY,
|
|
61
83
|
)
|
|
62
84
|
|
|
63
85
|
runner_popen = subprocess.Popen
|
|
64
86
|
|
|
65
87
|
|
|
88
|
+
class WorkerTimeoutOutOfBoundsError(ValueError):
|
|
89
|
+
"""Raised when a requested worker timeout falls outside the accepted bounds.
|
|
90
|
+
|
|
91
|
+
A ``ValueError`` subclass so existing callers that catch ``ValueError``
|
|
92
|
+
keep working, while a caller that wants only this fault can name it.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
|
|
66
96
|
@dataclass(frozen=True)
|
|
67
97
|
class GrokRunnerOutcome:
|
|
68
98
|
"""Outcome of one headless grok worker invocation.
|
|
@@ -90,7 +120,6 @@ def _build_invocation(
|
|
|
90
120
|
*,
|
|
91
121
|
prompt_file: Path,
|
|
92
122
|
working_directory: Path,
|
|
93
|
-
max_turns: int,
|
|
94
123
|
leader_socket_path: Path,
|
|
95
124
|
agent_name: str | None,
|
|
96
125
|
all_extra_arguments: tuple[str, ...] = (),
|
|
@@ -104,8 +133,6 @@ def _build_invocation(
|
|
|
104
133
|
OUTPUT_FORMAT_FLAG,
|
|
105
134
|
OUTPUT_FORMAT_JSON,
|
|
106
135
|
ALWAYS_APPROVE_FLAG,
|
|
107
|
-
MAX_TURNS_FLAG,
|
|
108
|
-
str(max_turns),
|
|
109
136
|
LEADER_SOCKET_FLAG,
|
|
110
137
|
str(leader_socket_path),
|
|
111
138
|
]
|
|
@@ -180,14 +207,110 @@ def _resolve_returncode(process: subprocess.Popen[str]) -> int:
|
|
|
180
207
|
return TIMEOUT_RETURN_CODE
|
|
181
208
|
|
|
182
209
|
|
|
183
|
-
def
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
210
|
+
def require_timeout_within_bounds(timeout_seconds: int | None) -> None:
|
|
211
|
+
"""Refuse a timeout that is missing, below the floor, or above the ceiling.
|
|
212
|
+
|
|
213
|
+
::
|
|
214
|
+
|
|
215
|
+
None or 0 flag: ValueError naming MIN_WORKER_TIMEOUT_SECONDS
|
|
216
|
+
5401 flag: ValueError naming MAXIMUM_WORKER_TIMEOUT_SECONDS
|
|
217
|
+
1 .. 5400 ok: returns
|
|
218
|
+
|
|
219
|
+
Public so a dispatcher can apply the same bounds on a path that never
|
|
220
|
+
reaches ``run_headless_worker``.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
timeout_seconds: The requested per-worker timeout in seconds.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
WorkerTimeoutOutOfBoundsError: When the value falls outside the bounds.
|
|
227
|
+
"""
|
|
228
|
+
if timeout_seconds is None or timeout_seconds < MIN_WORKER_TIMEOUT_SECONDS:
|
|
229
|
+
raise WorkerTimeoutOutOfBoundsError(
|
|
230
|
+
MINIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE.format(
|
|
231
|
+
field_name=WORKER_SPEC_TIMEOUT_KEY,
|
|
232
|
+
requested_seconds=timeout_seconds,
|
|
233
|
+
minimum_seconds=MIN_WORKER_TIMEOUT_SECONDS,
|
|
234
|
+
)
|
|
188
235
|
)
|
|
236
|
+
if timeout_seconds > MAXIMUM_WORKER_TIMEOUT_SECONDS:
|
|
237
|
+
raise WorkerTimeoutOutOfBoundsError(
|
|
238
|
+
MAXIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE.format(
|
|
239
|
+
field_name=WORKER_SPEC_TIMEOUT_KEY,
|
|
240
|
+
requested_seconds=timeout_seconds,
|
|
241
|
+
maximum_seconds=MAXIMUM_WORKER_TIMEOUT_SECONDS,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _drain_after_kill(process: subprocess.Popen[str]) -> tuple[str, str] | None:
|
|
247
|
+
"""Read the killed process's streams, or None when the grace window expires."""
|
|
248
|
+
try:
|
|
249
|
+
return process.communicate(timeout=KILL_GRACE_TIMEOUT_SECONDS)
|
|
189
250
|
except subprocess.TimeoutExpired:
|
|
190
|
-
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _kill_and_drain_within_attempt_limit(
|
|
255
|
+
process: subprocess.Popen[str],
|
|
256
|
+
) -> tuple[str, str] | None:
|
|
257
|
+
"""Kill the process tree and drain it, retrying up to the attempt limit.
|
|
258
|
+
|
|
259
|
+
::
|
|
260
|
+
|
|
261
|
+
attempt 1 drains ok: streams, one attempt made
|
|
262
|
+
attempt 1 times out,
|
|
263
|
+
attempt 2 drains ok: streams, two attempts made
|
|
264
|
+
every attempt times out flag: None
|
|
265
|
+
|
|
266
|
+
A tree kill that returns without taking leaves the drain waiting on a live
|
|
267
|
+
pipe, so a timed-out drain is followed by another kill-and-drain round.
|
|
268
|
+
``terminate_process_tree`` re-issues the kill only while the worker
|
|
269
|
+
process is still alive; once it has exited, the next round is a second
|
|
270
|
+
drain window for the descendants still holding the pipe open.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
process: The timed-out worker process to kill and read.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
The captured stdout and stderr, or None when every attempt timed out.
|
|
277
|
+
"""
|
|
278
|
+
attempts_made = 0
|
|
279
|
+
while attempts_made < PROCESS_TREE_KILL_ATTEMPT_LIMIT:
|
|
280
|
+
terminate_process_tree(process)
|
|
281
|
+
all_captured_streams = _drain_after_kill(process)
|
|
282
|
+
if all_captured_streams is not None:
|
|
283
|
+
return all_captured_streams
|
|
284
|
+
attempts_made += 1
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _kill_failed_outcome(process: subprocess.Popen[str]) -> GrokRunnerOutcome:
|
|
289
|
+
diagnostic_text = KILL_FAILED_STDERR_TEMPLATE.format(
|
|
290
|
+
attempt_count=PROCESS_TREE_KILL_ATTEMPT_LIMIT,
|
|
291
|
+
process_identifier=process.pid,
|
|
292
|
+
)
|
|
293
|
+
return GrokRunnerOutcome(
|
|
294
|
+
is_ok=False,
|
|
295
|
+
returncode=KILL_FAILED_RETURN_CODE,
|
|
296
|
+
classification=CLASSIFICATION_KILL_FAILED,
|
|
297
|
+
stdout="",
|
|
298
|
+
stderr=diagnostic_text,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _timeout_outcome(process: subprocess.Popen[str]) -> GrokRunnerOutcome:
|
|
303
|
+
"""Kill a timed-out worker's tree, then classify what the kill achieved.
|
|
304
|
+
|
|
305
|
+
::
|
|
306
|
+
|
|
307
|
+
drain clears on attempt 1 or 2 ok: classification timeout
|
|
308
|
+
both attempts leave it draining flag: classification kill_failed
|
|
309
|
+
"""
|
|
310
|
+
all_captured_streams = _kill_and_drain_within_attempt_limit(process)
|
|
311
|
+
if all_captured_streams is None:
|
|
312
|
+
return _kill_failed_outcome(process)
|
|
313
|
+
captured_stdout, captured_stderr = all_captured_streams
|
|
191
314
|
stdout_text = _normalize_stream(captured_stdout)
|
|
192
315
|
stderr_text = _normalize_stream(captured_stderr)
|
|
193
316
|
returncode = _resolve_returncode(process)
|
|
@@ -234,6 +357,7 @@ def _invoke_process(
|
|
|
234
357
|
text=True,
|
|
235
358
|
encoding=UTF8_ENCODING,
|
|
236
359
|
errors=UTF8_DECODE_ERRORS,
|
|
360
|
+
start_new_session=should_start_new_session(),
|
|
237
361
|
)
|
|
238
362
|
except FileNotFoundError:
|
|
239
363
|
return _missing_binary_outcome()
|
|
@@ -254,7 +378,6 @@ def run_headless_worker(
|
|
|
254
378
|
prompt_file: Path,
|
|
255
379
|
working_directory: Path,
|
|
256
380
|
run_state_directory: Path,
|
|
257
|
-
max_turns: int,
|
|
258
381
|
timeout_seconds: int,
|
|
259
382
|
agent_name: str | None = None,
|
|
260
383
|
leader_socket_path: Path | None = None,
|
|
@@ -262,13 +385,16 @@ def run_headless_worker(
|
|
|
262
385
|
) -> GrokRunnerOutcome:
|
|
263
386
|
"""Run one headless grok worker and classify the process outcome.
|
|
264
387
|
|
|
388
|
+
The timeout is the worker's only bound; the argv carries no turn cap.
|
|
389
|
+
|
|
265
390
|
Args:
|
|
266
391
|
prompt_file: Path to the prompt file passed via ``--prompt-file``.
|
|
267
392
|
working_directory: Working directory passed via ``--cwd``.
|
|
268
393
|
run_state_directory: Run-scoped directory the leader socket is minted
|
|
269
394
|
under. Read only when ``leader_socket_path`` is omitted.
|
|
270
|
-
|
|
271
|
-
|
|
395
|
+
timeout_seconds: Seconds before the process tree is killed on expiry.
|
|
396
|
+
Must sit between ``MIN_WORKER_TIMEOUT_SECONDS`` and
|
|
397
|
+
``MAXIMUM_WORKER_TIMEOUT_SECONDS`` inclusive.
|
|
272
398
|
agent_name: Optional role agent name passed via ``--agent``.
|
|
273
399
|
leader_socket_path: Optional pre-minted leader socket path. When omitted,
|
|
274
400
|
a unique path is minted under ``run_state_directory``.
|
|
@@ -277,7 +403,12 @@ def run_headless_worker(
|
|
|
277
403
|
|
|
278
404
|
Returns:
|
|
279
405
|
The classified outcome including return code and captured streams.
|
|
406
|
+
|
|
407
|
+
Raises:
|
|
408
|
+
WorkerTimeoutOutOfBoundsError: When ``timeout_seconds`` is missing,
|
|
409
|
+
below the floor, or above the ceiling.
|
|
280
410
|
"""
|
|
411
|
+
require_timeout_within_bounds(timeout_seconds)
|
|
281
412
|
resolved_leader_socket_path = (
|
|
282
413
|
leader_socket_path
|
|
283
414
|
if leader_socket_path is not None
|
|
@@ -286,7 +417,6 @@ def run_headless_worker(
|
|
|
286
417
|
all_arguments = _build_invocation(
|
|
287
418
|
prompt_file=prompt_file,
|
|
288
419
|
working_directory=working_directory,
|
|
289
|
-
max_turns=max_turns,
|
|
290
420
|
leader_socket_path=resolved_leader_socket_path,
|
|
291
421
|
agent_name=agent_name,
|
|
292
422
|
all_extra_arguments=all_extra_arguments,
|
|
@@ -65,7 +65,6 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
|
65
65
|
CLI_TIMEOUT_FLAG,
|
|
66
66
|
CWD_FLAG,
|
|
67
67
|
DEFAULT_ROLE,
|
|
68
|
-
DEFAULT_SPAWN_MAX_TURNS,
|
|
69
68
|
DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
70
69
|
EMPTY_OUTPUT,
|
|
71
70
|
OUTPUT_FORMAT_FLAG,
|
|
@@ -73,6 +72,7 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
|
73
72
|
PROMPT_FILE_FLAG,
|
|
74
73
|
REASON_CLAUDE_AGENT_REQUIRED,
|
|
75
74
|
REASON_PROMPT_FILE_MISSING,
|
|
75
|
+
REASON_TIMEOUT_OUT_OF_BOUNDS,
|
|
76
76
|
RESULT_KEY_ATTEMPTS,
|
|
77
77
|
RESULT_KEY_OK,
|
|
78
78
|
RESULT_KEY_OUTPUT,
|
|
@@ -87,7 +87,12 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
|
87
87
|
TIER_GROK,
|
|
88
88
|
UTF8_ENCODING,
|
|
89
89
|
)
|
|
90
|
-
from grok_headless_runner import
|
|
90
|
+
from grok_headless_runner import ( # noqa: E402
|
|
91
|
+
GrokRunnerOutcome,
|
|
92
|
+
WorkerTimeoutOutOfBoundsError,
|
|
93
|
+
require_timeout_within_bounds,
|
|
94
|
+
run_headless_worker,
|
|
95
|
+
)
|
|
91
96
|
from grok_worker_preflight import PreflightOutcome, run_preflight # noqa: E402
|
|
92
97
|
from tier_model_ids import detect_host_profile # noqa: E402
|
|
93
98
|
|
|
@@ -336,7 +341,6 @@ def _run_tier_grok(
|
|
|
336
341
|
prompt_file: Path,
|
|
337
342
|
working_directory: Path,
|
|
338
343
|
run_state_directory: Path,
|
|
339
|
-
max_turns: int,
|
|
340
344
|
timeout_seconds: int,
|
|
341
345
|
) -> GrokRunnerOutcome:
|
|
342
346
|
agent_name = _primary_agent_name_for_role(role)
|
|
@@ -344,7 +348,6 @@ def _run_tier_grok(
|
|
|
344
348
|
prompt_file=prompt_file,
|
|
345
349
|
working_directory=working_directory,
|
|
346
350
|
run_state_directory=run_state_directory,
|
|
347
|
-
max_turns=max_turns,
|
|
348
351
|
timeout_seconds=timeout_seconds,
|
|
349
352
|
agent_name=agent_name,
|
|
350
353
|
)
|
|
@@ -418,10 +421,13 @@ def resolve_worker_spawn(
|
|
|
418
421
|
timeout_seconds: int,
|
|
419
422
|
is_claude_tier_enabled: bool,
|
|
420
423
|
run_state_directory: Path,
|
|
421
|
-
max_turns: int,
|
|
422
424
|
) -> SpawnOutcome:
|
|
423
425
|
"""Walk the worker-spawn tiers and return the structured outcome.
|
|
424
426
|
|
|
427
|
+
The timeout bounds each tier; no turn cap reaches the headless grok worker.
|
|
428
|
+
The bounds check runs before the preflight, so a refused timeout is refused
|
|
429
|
+
on the claude tier too, not only on the path that reaches the grok runner.
|
|
430
|
+
|
|
425
431
|
Args:
|
|
426
432
|
role: Worker role name for preflight; mapped to a primary agent stem.
|
|
427
433
|
prompt_file: Path to the prompt file for headless workers.
|
|
@@ -429,11 +435,16 @@ def resolve_worker_spawn(
|
|
|
429
435
|
timeout_seconds: Timeout applied to each tier invocation.
|
|
430
436
|
is_claude_tier_enabled: When True, allow tier 3 on a Claude host.
|
|
431
437
|
run_state_directory: Run-scoped directory for leader sockets and cache.
|
|
432
|
-
max_turns: Maximum agent turns for the headless grok worker.
|
|
433
438
|
|
|
434
439
|
Returns:
|
|
435
440
|
The dispatcher outcome including the ordered attempts trail.
|
|
441
|
+
|
|
442
|
+
Raises:
|
|
443
|
+
WorkerTimeoutOutOfBoundsError: When ``timeout_seconds`` is missing,
|
|
444
|
+
below the floor, or above the ceiling. The CLI maps this to the
|
|
445
|
+
config-error exit code.
|
|
436
446
|
"""
|
|
447
|
+
require_timeout_within_bounds(timeout_seconds)
|
|
437
448
|
preflight_outcome: PreflightOutcome = spawn_preflight_runner(
|
|
438
449
|
role=role,
|
|
439
450
|
should_ping=False,
|
|
@@ -454,7 +465,6 @@ def resolve_worker_spawn(
|
|
|
454
465
|
prompt_file=prompt_file,
|
|
455
466
|
working_directory=working_directory,
|
|
456
467
|
run_state_directory=run_state_directory,
|
|
457
|
-
max_turns=max_turns,
|
|
458
468
|
timeout_seconds=timeout_seconds,
|
|
459
469
|
)
|
|
460
470
|
if grok_outcome.is_ok:
|
|
@@ -552,7 +562,7 @@ def _exit_code_for_outcome(
|
|
|
552
562
|
return SPAWN_SERVED_EXIT_CODE
|
|
553
563
|
|
|
554
564
|
|
|
555
|
-
def _config_error_outcome(configuration_error:
|
|
565
|
+
def _config_error_outcome(configuration_error: Exception) -> SpawnOutcome:
|
|
556
566
|
return SpawnOutcome(
|
|
557
567
|
tier_used=None,
|
|
558
568
|
is_ok=False,
|
|
@@ -574,6 +584,34 @@ def _missing_prompt_file_outcome() -> SpawnOutcome:
|
|
|
574
584
|
)
|
|
575
585
|
|
|
576
586
|
|
|
587
|
+
def _timeout_out_of_bounds_outcome(
|
|
588
|
+
bounds_error: WorkerTimeoutOutOfBoundsError,
|
|
589
|
+
) -> SpawnOutcome:
|
|
590
|
+
"""Report a refused timeout as a config fault rather than a worker failure.
|
|
591
|
+
|
|
592
|
+
::
|
|
593
|
+
|
|
594
|
+
--timeout-seconds 0 ok: attempts[0].reason timeout_out_of_bounds
|
|
595
|
+
--timeout-seconds 5401 ok: attempts[0].reason timeout_out_of_bounds
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
bounds_error: The refusal raised by the headless runner.
|
|
599
|
+
|
|
600
|
+
Returns:
|
|
601
|
+
A config-error outcome whose attempt reason names the violation.
|
|
602
|
+
"""
|
|
603
|
+
return SpawnOutcome(
|
|
604
|
+
tier_used=None,
|
|
605
|
+
is_ok=False,
|
|
606
|
+
all_attempts=(
|
|
607
|
+
_attempt(TIER_GROK, is_ok=False, reason=REASON_TIMEOUT_OUT_OF_BOUNDS),
|
|
608
|
+
),
|
|
609
|
+
captured_stdout=str(bounds_error),
|
|
610
|
+
returncode=SPAWN_CONFIG_ERROR_EXIT_CODE,
|
|
611
|
+
is_config_error=True,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
577
615
|
def _write_spawn_outcome_and_exit_code(
|
|
578
616
|
spawn_outcome: SpawnOutcome, *, is_config_error: bool
|
|
579
617
|
) -> int:
|
|
@@ -588,6 +626,12 @@ def _write_spawn_outcome_and_exit_code(
|
|
|
588
626
|
def main(all_command_arguments: list[str]) -> int:
|
|
589
627
|
"""Run the dispatcher for CLI arguments and print the JSON outcome.
|
|
590
628
|
|
|
629
|
+
A timeout outside the accepted bounds is a config fault: the run prints a
|
|
630
|
+
structured outcome whose attempt reason is ``timeout_out_of_bounds`` and
|
|
631
|
+
exits ``3``, rather than raising out of the CLI. A chain misconfiguration,
|
|
632
|
+
or an unknown host profile refused by ``detect_host_profile``, prints the
|
|
633
|
+
same exit code with the refusal text as its output.
|
|
634
|
+
|
|
591
635
|
Args:
|
|
592
636
|
all_command_arguments: The argument vector after the program name.
|
|
593
637
|
|
|
@@ -612,9 +656,11 @@ def main(all_command_arguments: list[str]) -> int:
|
|
|
612
656
|
timeout_seconds=parsed_arguments.timeout_seconds,
|
|
613
657
|
is_claude_tier_enabled=parsed_arguments.is_claude_tier_enabled,
|
|
614
658
|
run_state_directory=run_state_directory,
|
|
615
|
-
max_turns=DEFAULT_SPAWN_MAX_TURNS,
|
|
616
659
|
)
|
|
617
|
-
except
|
|
660
|
+
except WorkerTimeoutOutOfBoundsError as bounds_error:
|
|
661
|
+
is_config_error = True
|
|
662
|
+
spawn_outcome = _timeout_out_of_bounds_outcome(bounds_error)
|
|
663
|
+
except (ChainConfigurationError, ValueError) as configuration_error:
|
|
618
664
|
is_config_error = True
|
|
619
665
|
spawn_outcome = _config_error_outcome(configuration_error)
|
|
620
666
|
return _write_spawn_outcome_and_exit_code(
|
|
@@ -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,
|
|
@@ -38,14 +39,15 @@ from dev_env_scripts_constants.grok_worker_constants import (
|
|
|
38
39
|
DEBUG_FILENAME_PREFIX,
|
|
39
40
|
DEBUG_FILENAME_SUFFIX,
|
|
40
41
|
DEFAULT_ROLE,
|
|
41
|
-
DEFAULT_WORKER_MAX_TURNS,
|
|
42
42
|
DEFAULT_WORKER_TIMEOUT_SECONDS,
|
|
43
43
|
DISABLE_WEB_SEARCH_FLAG,
|
|
44
44
|
DISALLOWED_TOOLS_FLAG,
|
|
45
45
|
LEADER_SOCKET_FILENAME_PREFIX,
|
|
46
46
|
LEADER_SOCKET_FILENAME_SUFFIX,
|
|
47
|
-
|
|
47
|
+
MAXIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE,
|
|
48
|
+
MAXIMUM_WORKER_TIMEOUT_SECONDS,
|
|
48
49
|
MIN_WORKER_TIMEOUT_SECONDS,
|
|
50
|
+
MINIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE,
|
|
49
51
|
OUTPUT_FILENAME_PREFIX,
|
|
50
52
|
OUTPUT_FILENAME_SUFFIX,
|
|
51
53
|
PROMPT_FILENAME_PREFIX,
|
|
@@ -69,12 +71,13 @@ from dev_env_scripts_constants.grok_worker_constants import (
|
|
|
69
71
|
SUMMARY_WORKERS_KEY,
|
|
70
72
|
TOOL_PROFILE_BUILD,
|
|
71
73
|
TOOL_PROFILE_READONLY,
|
|
74
|
+
UNKNOWN_WORKER_KEY_ERROR_TEMPLATE,
|
|
72
75
|
UTF8_ENCODING,
|
|
73
76
|
WORKER_EXCEPTION_RETURN_CODE,
|
|
74
77
|
WORKER_SPEC_AGENT_NAME_KEY,
|
|
75
78
|
WORKER_SPEC_CWD_KEY,
|
|
76
79
|
WORKER_SPEC_IS_REPO_ONLY_KEY,
|
|
77
|
-
|
|
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."""
|
|
@@ -99,7 +134,6 @@ class WorkerSpec:
|
|
|
99
134
|
tool_profile: str
|
|
100
135
|
timeout_seconds: int
|
|
101
136
|
is_repo_only: bool = False
|
|
102
|
-
max_turns: int = DEFAULT_WORKER_MAX_TURNS
|
|
103
137
|
agent_name: str | None = None
|
|
104
138
|
|
|
105
139
|
|
|
@@ -248,18 +282,50 @@ def _require_worker_field(
|
|
|
248
282
|
return all_worker_fields[field_name]
|
|
249
283
|
|
|
250
284
|
|
|
251
|
-
def
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
285
|
+
def _require_timeout_within_bounds(raw_field: object) -> int:
|
|
286
|
+
"""Accept a worker timeout inside the bounds; refuse anything outside them.
|
|
287
|
+
|
|
288
|
+
::
|
|
289
|
+
|
|
290
|
+
0 flag: ValueError naming MIN_WORKER_TIMEOUT_SECONDS
|
|
291
|
+
5401 flag: ValueError naming MAXIMUM_WORKER_TIMEOUT_SECONDS
|
|
292
|
+
5400 ok: returned untouched
|
|
293
|
+
30 ok: returned untouched
|
|
294
|
+
|
|
295
|
+
Rejecting here rather than clamping keeps the fault where the operator
|
|
296
|
+
wrote it. The runner repeats the check for callers that skip the parse.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
raw_field: The parsed ``timeout_seconds`` value from the specification.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
The accepted timeout in seconds, unchanged.
|
|
303
|
+
|
|
304
|
+
Raises:
|
|
305
|
+
ValueError: When the value is not an int, or falls outside the bounds.
|
|
306
|
+
"""
|
|
307
|
+
timeout_seconds = _require_int(raw_field, WORKER_SPEC_TIMEOUT_KEY)
|
|
308
|
+
if timeout_seconds < MIN_WORKER_TIMEOUT_SECONDS:
|
|
309
|
+
raise ValueError(
|
|
310
|
+
MINIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE.format(
|
|
311
|
+
field_name=WORKER_SPEC_TIMEOUT_KEY,
|
|
312
|
+
requested_seconds=timeout_seconds,
|
|
313
|
+
minimum_seconds=MIN_WORKER_TIMEOUT_SECONDS,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
if timeout_seconds > MAXIMUM_WORKER_TIMEOUT_SECONDS:
|
|
256
317
|
raise ValueError(
|
|
257
|
-
|
|
318
|
+
MAXIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE.format(
|
|
319
|
+
field_name=WORKER_SPEC_TIMEOUT_KEY,
|
|
320
|
+
requested_seconds=timeout_seconds,
|
|
321
|
+
maximum_seconds=MAXIMUM_WORKER_TIMEOUT_SECONDS,
|
|
322
|
+
)
|
|
258
323
|
)
|
|
259
|
-
return
|
|
324
|
+
return timeout_seconds
|
|
260
325
|
|
|
261
326
|
|
|
262
327
|
def _parse_worker_entry(all_worker_fields: dict[str, object]) -> WorkerSpec:
|
|
328
|
+
_require_known_worker_keys(all_worker_fields)
|
|
263
329
|
role_name = _require_string(
|
|
264
330
|
_require_worker_field(all_worker_fields, WORKER_SPEC_ROLE_NAME_KEY),
|
|
265
331
|
WORKER_SPEC_ROLE_NAME_KEY,
|
|
@@ -275,22 +341,15 @@ def _parse_worker_entry(all_worker_fields: dict[str, object]) -> WorkerSpec:
|
|
|
275
341
|
_require_worker_field(all_worker_fields, WORKER_SPEC_TOOL_PROFILE_KEY),
|
|
276
342
|
WORKER_SPEC_TOOL_PROFILE_KEY,
|
|
277
343
|
)
|
|
278
|
-
timeout_seconds =
|
|
344
|
+
timeout_seconds = _require_timeout_within_bounds(
|
|
279
345
|
all_worker_fields.get(
|
|
280
346
|
WORKER_SPEC_TIMEOUT_KEY, DEFAULT_WORKER_TIMEOUT_SECONDS
|
|
281
|
-
)
|
|
282
|
-
WORKER_SPEC_TIMEOUT_KEY,
|
|
283
|
-
MIN_WORKER_TIMEOUT_SECONDS,
|
|
347
|
+
)
|
|
284
348
|
)
|
|
285
349
|
is_repo_only = _require_bool(
|
|
286
350
|
all_worker_fields.get(WORKER_SPEC_IS_REPO_ONLY_KEY, False),
|
|
287
351
|
WORKER_SPEC_IS_REPO_ONLY_KEY,
|
|
288
352
|
)
|
|
289
|
-
max_turns = _require_int_at_least(
|
|
290
|
-
all_worker_fields.get(WORKER_SPEC_MAX_TURNS_KEY, DEFAULT_WORKER_MAX_TURNS),
|
|
291
|
-
WORKER_SPEC_MAX_TURNS_KEY,
|
|
292
|
-
MIN_WORKER_MAX_TURNS,
|
|
293
|
-
)
|
|
294
353
|
agent_name = all_worker_fields.get(WORKER_SPEC_AGENT_NAME_KEY)
|
|
295
354
|
if not isinstance(all_prompt_parts, list) or not all_prompt_parts:
|
|
296
355
|
raise ValueError("worker prompt_parts must be a non-empty list")
|
|
@@ -311,7 +370,6 @@ def _parse_worker_entry(all_worker_fields: dict[str, object]) -> WorkerSpec:
|
|
|
311
370
|
tool_profile=tool_profile,
|
|
312
371
|
timeout_seconds=timeout_seconds,
|
|
313
372
|
is_repo_only=is_repo_only,
|
|
314
|
-
max_turns=max_turns,
|
|
315
373
|
agent_name=agent_name,
|
|
316
374
|
)
|
|
317
375
|
|
|
@@ -326,7 +384,8 @@ def load_batch_spec(specification_path: Path) -> BatchSpec:
|
|
|
326
384
|
The validated batch specification.
|
|
327
385
|
|
|
328
386
|
Raises:
|
|
329
|
-
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.
|
|
330
389
|
OSError: When the specification file cannot be read.
|
|
331
390
|
json.JSONDecodeError: When the file is not valid JSON.
|
|
332
391
|
"""
|
|
@@ -423,7 +482,6 @@ def _invoke_worker(
|
|
|
423
482
|
prompt_file=scratch_paths.prompt_path,
|
|
424
483
|
working_directory=worker_spec.working_directory,
|
|
425
484
|
run_state_directory=run_state_directory,
|
|
426
|
-
max_turns=worker_spec.max_turns,
|
|
427
485
|
timeout_seconds=worker_spec.timeout_seconds,
|
|
428
486
|
agent_name=worker_spec.agent_name,
|
|
429
487
|
leader_socket_path=scratch_paths.leader_socket_path,
|