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.
Files changed (88) 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/pr-loop/worker-spawn.md +3 -1
  6. package/_shared/process-tree/CLAUDE.md +41 -0
  7. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
  8. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
  9. package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
  10. package/_shared/process-tree/scripts/pyproject.toml +16 -0
  11. package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
  12. package/agents/code-quality-agent.md +6 -5
  13. package/agents/deep-research.md +7 -24
  14. package/agents/docs-agent.md +1 -27
  15. package/agents/issue-tracker.md +1 -7
  16. package/agents/skill-writer-agent.md +1 -2
  17. package/agents/test_agent_frontmatter.py +309 -12
  18. package/hooks/blocking/CLAUDE.md +2 -0
  19. package/hooks/blocking/fable_spawn_gate.py +187 -0
  20. package/hooks/blocking/piped_pytest_blocker.py +1223 -0
  21. package/hooks/blocking/plain_language_blocker.py +287 -15
  22. package/hooks/blocking/test_fable_spawn_gate.py +374 -0
  23. package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
  24. package/hooks/blocking/test_plain_language_blocker.py +277 -2
  25. package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
  26. package/hooks/git-hooks/CLAUDE.md +2 -2
  27. package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
  28. package/hooks/git-hooks/pre_push.py +343 -54
  29. package/hooks/git-hooks/test_pre_push.py +852 -6
  30. package/hooks/hooks.json +9 -19
  31. package/hooks/hooks_constants/CLAUDE.md +2 -0
  32. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
  33. package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
  34. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
  35. package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
  36. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
  37. package/hooks/hooks_constants/shell_command_segments.py +1 -1
  38. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
  39. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
  40. package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
  41. package/package.json +1 -1
  42. package/rules/CLAUDE.md +1 -0
  43. package/rules/ask-user-question-required.md +26 -0
  44. package/rules/claims-as-quotes.md +65 -0
  45. package/scripts/CLAUDE.md +4 -4
  46. package/scripts/_code_review_test_support.py +6 -0
  47. package/scripts/check.ps1 +18 -5
  48. package/scripts/claude_chain_runner.py +203 -31
  49. package/scripts/codec_forwarding_test_support.py +2 -0
  50. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
  51. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
  52. package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
  53. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +83 -13
  54. package/scripts/grok_headless_runner.py +148 -18
  55. package/scripts/resolve_worker_spawn.py +56 -10
  56. package/scripts/spawn_grok_batch.py +81 -23
  57. package/scripts/test_claude_chain_runner.py +358 -0
  58. package/scripts/test_grok_headless_runner.py +547 -10
  59. package/scripts/test_invoke_code_review.py +298 -0
  60. package/scripts/test_resolve_worker_spawn.py +185 -15
  61. package/scripts/test_spawn_grok_batch.py +326 -22
  62. package/scripts/tests/CLAUDE.md +1 -0
  63. package/scripts/tests/test_grok_worker_constants.py +59 -0
  64. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
  65. package/skills/autoconverge/workflow/converge.contract.test.mjs +133 -8
  66. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +107 -1
  67. package/skills/autoconverge/workflow/converge.mjs +113 -31
  68. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
  69. package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
  70. package/skills/codex-review/scripts/run_codex_review.py +16 -64
  71. package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
  72. package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
  73. package/skills/e-code-review/SKILL.md +9 -8
  74. package/skills/e-code-review/reference/fix.md +29 -7
  75. package/skills/e-code-review/reference/loop.md +230 -14
  76. package/skills/e-code-review/reference/low.md +33 -15
  77. package/skills/e-code-review/reference/medium.md +55 -21
  78. package/skills/e-code-review/reference/xhigh.md +30 -12
  79. package/skills/fresh-branch/CLAUDE.md +5 -5
  80. package/skills/fresh-branch/SKILL.md +14 -6
  81. package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
  82. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
  83. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
  84. package/skills/grok-spawn/SKILL.md +10 -3
  85. package/skills/grok-spawn/reference/flag-profiles.md +3 -1
  86. package/skills/orchestrator/SKILL.md +4 -1
  87. package/skills/orchestrator-refresh/SKILL.md +5 -1
  88. 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
- max_turns=8,
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
- from dev_env_scripts_constants.grok_worker_constants import (
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
- MAX_TURNS_FLAG,
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 _timeout_outcome(process: subprocess.Popen[str]) -> GrokRunnerOutcome:
184
- process.kill()
185
- try:
186
- captured_stdout, captured_stderr = process.communicate(
187
- timeout=KILL_GRACE_TIMEOUT_SECONDS
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
- captured_stdout, captured_stderr = "", ""
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
- max_turns: Maximum agent turns passed via ``--max-turns``.
271
- timeout_seconds: Seconds before the process is killed on expiry.
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 GrokRunnerOutcome, run_headless_worker # noqa: E402
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: ChainConfigurationError) -> SpawnOutcome:
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 ChainConfigurationError as configuration_error:
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
- MIN_WORKER_MAX_TURNS,
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
- WORKER_SPEC_MAX_TURNS_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."""
@@ -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 _require_int_at_least(
252
- raw_field: object, field_name: str, minimum_accepted: int
253
- ) -> int:
254
- parsed_integer = _require_int(raw_field, field_name)
255
- if parsed_integer < minimum_accepted:
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
- f"worker {field_name} must be >= {minimum_accepted}"
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 parsed_integer
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 = _require_int_at_least(
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 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.
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,