claude-dev-env 2.7.0 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -73,10 +73,12 @@ plus the process exit code.
73
73
 
74
74
  `resolve_worker_spawn(...)` takes keyword-only arguments:
75
75
  `role`, `prompt_file`, `working_directory`, `timeout_seconds`,
76
- `is_claude_tier_enabled`, `run_state_directory`, `max_turns`.
76
+ `is_claude_tier_enabled`, `run_state_directory`.
77
77
  `encode_spawn_outcome` turns a `SpawnOutcome` into the same JSON shape the CLI
78
78
  prints.
79
79
 
80
+ The grok tier runs with no turn cap; `timeout_seconds` is its only bound.
81
+
80
82
  ## JSON result shape
81
83
 
82
84
  Stdout is one JSON object:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.7.0",
3
+ "version": "2.7.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/CLAUDE.md CHANGED
@@ -9,13 +9,13 @@ Utility scripts installed into `~/.claude/scripts/` by `bin/install.mjs`. Each s
9
9
  | `claude_chain_runner.py` | Runs a `claude` invocation through a usage-ranked fallback chain (`~/.claude/claude-chain.json`): probes weekly remaining once via `claude_chain_usage` / the usage-pause OAuth probe, tries the highest-remaining account first, and falls over to the next ranked binary only on a usage-limit failure; usable as an imported module (`run_claude`) or a CLI. Copy `claude-chain.example.json` to `~/.claude/claude-chain.json` and list your account binaries. Optional per-entry `credentials_path` names that account's OAuth credentials file for the usage probe |
10
10
  | `claude_chain_usage.py` | Reports remaining weekly usage for every account in `~/.claude/claude-chain.json` via the usage-pause OAuth probe; prints JSON (`accounts` with `weekly_remaining_percent` or null plus `error`); importable `report_chain_weekly_usage` and `rank_accounts_by_weekly_remaining` (highest remaining first, ties keep config order, unmeasurable last). The chain runner consumes this ranking for try order |
11
11
  | `gh_artifact_upload.py` | Uploads a file to a repo's durable `artifacts` prerelease under a timestamped asset name and prints the permanent download URL a GitHub post can link |
12
- | `grok_headless_runner.py` | Runs one worker as headless `grok`: builds argv, mints a unique leader socket, captures streams, kills on timeout with grace, classifies ok/usage_limit/auth_failure/timeout/error; imported by `spawn_grok_batch.py` |
12
+ | `grok_headless_runner.py` | Runs one worker as headless `grok`: builds argv with no turn cap (the timeout is the only bound), mints a unique leader socket, captures streams, refuses a timeout that is missing, below `MIN_WORKER_TIMEOUT_SECONDS`, or above the `MAXIMUM_WORKER_TIMEOUT_SECONDS` (5400) ceiling, kills the whole process tree on timeout with grace and retries the kill-and-drain round once, classifies ok/usage_limit/auth_failure/timeout/kill_failed/error; exports `require_timeout_within_bounds` so a dispatcher can apply the same bounds without launching; imported by `spawn_grok_batch.py` and `resolve_worker_spawn.py` |
13
13
  | `grok_worker_preflight.py` | Soft gate for the headless grok tier: binary on PATH, `grok models` auth, install manifest + role agents, opt-in cached live ping; non-zero exit is fallthrough, not failure |
14
14
  | `setup_project_paths.py` | One-time bootstrap: discovers git repos via `es.exe` (Everything) and writes `~/.claude/project-paths.json`; never hardcodes scan roots |
15
- | `spawn_grok_batch.py` | Launches a fleet of headless grok workers from a JSON batch spec: gates once through the preflight, assembles each prompt from part files, staggers starts, runs each through `grok_headless_runner.py`, and emits one batch summary JSON |
15
+ | `spawn_grok_batch.py` | Launches a fleet of headless grok workers from a JSON batch spec: gates once through the preflight, refuses a spec whose `timeout_seconds` exceeds `MAXIMUM_WORKER_TIMEOUT_SECONDS` (5400) rather than clamping it, assembles each prompt from part files, staggers starts, runs each through `grok_headless_runner.py`, and emits one batch summary JSON |
16
16
  | `sweep_empty_dirs.py` | Deletes empty directories older than a configurable age under a given root; runs once (`--once`) or in continuous-watch mode |
17
17
  | `sync_to_cursor.py` | Entry point for syncing Claude rules to Cursor `.mdc` files; delegates to the `sync_to_cursor/` package |
18
- | `resolve_worker_spawn.py` | Dispatches a worker role through grok then claude fallback tiers (preflight, headless grok, `claude_agent_required` handoff, optional claude headless); protocol: [`../_shared/pr-loop/worker-spawn.md`](../_shared/pr-loop/worker-spawn.md) |
18
+ | `resolve_worker_spawn.py` | Dispatches a worker role through grok then claude fallback tiers (preflight, headless grok, `claude_agent_required` handoff, optional claude headless); applies `require_timeout_within_bounds` before the preflight, so an out-of-bounds `--timeout-seconds` prints a `timeout_out_of_bounds` outcome and exits 3 on every tier; protocol: [`../_shared/pr-loop/worker-spawn.md`](../_shared/pr-loop/worker-spawn.md) |
19
19
 
20
20
  ## PowerShell scripts
21
21
 
@@ -10,7 +10,7 @@ Named constants for scripts in `scripts/`. Follows the project convention that t
10
10
  | `gh_artifact_upload_constants.py` | `gh_artifact_upload.py` - the `artifacts` release tag, title, and notes body, the GitHub CLI binary name, the asset-name timestamp format and template, the asset download URL template, the notes-file suffix, and the text encoding |
11
11
  | `claude_chain_constants.py` | `claude_chain_runner.py` - the chain config filename and home subdirectory, the usage-limit signature text, the per-binary status labels, the default timeout, CLI flag and separator tokens, config JSON keys (including optional `credentials_path`), invalid-shape reason text, config-error and exhausted-chain message templates, and CLI exit codes; `invoke_code_review.py` and `resolve_worker_spawn.py` - the text-codec keyword names and the `collect_forwarded_text_codec` helper that forwards them to the subprocess runner for text-mode capture |
12
12
  | `claude_chain_usage_constants.py` | `claude_chain_usage.py` - full weekly percent scale, usage-pause skill path segments, CLI config-path flag, JSON report keys, and probe error message templates |
13
- | `grok_worker_constants.py` | `grok_worker_preflight.py`, `grok_headless_runner.py`, and `spawn_grok_batch.py` - the `grok` binary name and CLI flags, model and subcommand tokens, leader-socket and scratch-file name parts, auth and usage-limit signature lists, outcome classifications, fallthrough reasons, tool-profile names and prompt headers, timeouts and turn caps (including launch-failure return code and post-kill grace), ping-cache keys and TTL, batch-spec and summary JSON keys, the prompt-part and report-stream join separators, and the CLI launch-error stderr prefix |
13
+ | `grok_worker_constants.py` | `grok_worker_preflight.py`, `grok_headless_runner.py`, `spawn_grok_batch.py`, and `resolve_worker_spawn.py` - the `grok` binary name and CLI flags, model and subcommand tokens, leader-socket and scratch-file name parts, auth and usage-limit signature lists, outcome classifications, fallthrough reasons, tool-profile names and prompt headers, worker timeouts (default, plus the floor and the `MAXIMUM_WORKER_TIMEOUT_SECONDS` ceiling that the batch spec parse, the headless runner, and the spawn dispatcher all enforce, each bound carrying one rejection template shared by those sites, plus launch-failure return code and post-kill grace), the process-tree kill command, its flags, its attempt limit and kill-failed classification, the preflight ping's single-turn cap, ping-cache keys and TTL, batch-spec and summary JSON keys, the prompt-part and report-stream join separators, and the CLI launch-error stderr prefix |
14
14
  | `code_review_constants.py` | `invoke_code_review.py` - the `/code-review ultra --fix` prompt, opus model alias, permission-mode flag and value, result mode and JSON keys, session-model CLI flag, git dirty-check tokens, and in-session return markers |
15
15
  | `__init__.py` | Empty package marker |
16
16
 
@@ -203,9 +203,6 @@ CLASSIFICATION_STREAM_JOIN_SEPARATOR: str = "\n"
203
203
  DEFAULT_WORKER_TIMEOUT_SECONDS: int = 600
204
204
  """Default timeout applied to one headless worker invocation, in seconds."""
205
205
 
206
- DEFAULT_WORKER_MAX_TURNS: int = 8
207
- """Default max-turns value applied to one headless worker invocation."""
208
-
209
206
  TIMEOUT_RETURN_CODE: int = -1
210
207
  """Return code recorded on the outcome when a timed-out process leaves no return code."""
211
208
 
@@ -216,13 +213,88 @@ LAUNCH_FAILURE_STDERR_PREFIX: str = "failed to launch: "
216
213
  """Prefix for the non-empty stderr diagnostic returned on a launch OSError."""
217
214
 
218
215
  KILL_GRACE_TIMEOUT_SECONDS: int = 10
219
- """Seconds to wait for a killed process to reap its pipes before giving up on its streams."""
216
+ """Seconds to wait for a killed process to reap its pipes before giving up on its streams.
217
+
218
+ Gates the drain that follows a kill. ``PROCESS_TREE_KILL_TIMEOUT_SECONDS``
219
+ gates the kill command itself, so the two bound different operations.
220
+ """
221
+
222
+ MAXIMUM_WORKER_TIMEOUT_SECONDS: int = 5400
223
+ """Ceiling the batch launcher puts on a worker's ``timeout_seconds`` (90 minutes).
224
+
225
+ Enforced at three sites: the batch launcher refuses an over-ceiling
226
+ specification while parsing it, ``run_headless_worker`` refuses one from any
227
+ caller, and ``resolve_worker_spawn`` refuses one before its preflight so the
228
+ bound holds on every tier. A worker that reaches this ceiling is killed and
229
+ classified ``CLASSIFICATION_TIMEOUT``.
230
+ """
231
+
232
+ MAXIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE: str = (
233
+ "worker {field_name} {requested_seconds} exceeds "
234
+ "MAXIMUM_WORKER_TIMEOUT_SECONDS ({maximum_seconds})"
235
+ )
236
+ """Rejection message for an over-ceiling timeout, shared by the batch parse and the runner."""
237
+
238
+ WINDOWS_OS_NAME: str = "nt"
239
+ """``os.name`` value that selects the Windows branch of the process-tree kill."""
240
+
241
+ WINDOWS_TASKKILL_COMMAND: str = "taskkill"
242
+ """Windows command that ends a process by id."""
243
+
244
+ WINDOWS_TASKKILL_TREE_FLAG: str = "/T"
245
+ """``taskkill`` flag that extends the kill to every descendant process."""
246
+
247
+ WINDOWS_TASKKILL_FORCE_FLAG: str = "/F"
248
+ """``taskkill`` flag that forces termination rather than requesting it."""
249
+
250
+ WINDOWS_TASKKILL_PID_FLAG: str = "/PID"
251
+ """``taskkill`` flag that names the target process id."""
252
+
253
+ PROCESS_TREE_KILL_TIMEOUT_SECONDS: int = 10
254
+ """Seconds allowed for the tree-kill command itself before it is abandoned.
255
+
256
+ Gates the kill command. ``KILL_GRACE_TIMEOUT_SECONDS`` gates the drain that
257
+ follows a kill, so the two bound different operations.
258
+ """
220
259
 
221
260
  MIN_WORKER_TIMEOUT_SECONDS: int = 1
222
- """Minimum accepted worker timeout_seconds in a batch specification."""
261
+ """Minimum accepted worker timeout_seconds, in seconds.
223
262
 
224
- MIN_WORKER_MAX_TURNS: int = 1
225
- """Minimum accepted worker max_turns in a batch specification."""
263
+ Enforced at the same three sites as ``MAXIMUM_WORKER_TIMEOUT_SECONDS``.
264
+ """
265
+
266
+ MINIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE: str = (
267
+ "worker {field_name} {requested_seconds} is below "
268
+ "MIN_WORKER_TIMEOUT_SECONDS ({minimum_seconds})"
269
+ )
270
+ """Rejection message for a below-floor timeout, shared by the batch parse and the runner."""
271
+
272
+ CLASSIFICATION_KILL_FAILED: str = "kill_failed"
273
+ """Outcome classification when a timed-out worker's streams never drain.
274
+
275
+ Distinct from ``CLASSIFICATION_TIMEOUT``: that worker's tree was cleared, this
276
+ one's was not, so a caller can tell an abandoned process tree from a stopped
277
+ one.
278
+ """
279
+
280
+ KILL_FAILED_RETURN_CODE: int = -4
281
+ """Return code recorded when a timed-out worker's streams never drain."""
282
+
283
+ KILL_FAILED_STDERR_TEMPLATE: str = (
284
+ "worker timed out and its streams never drained across {attempt_count} "
285
+ "kill-and-drain attempts; process {process_identifier} may still be running"
286
+ )
287
+ """Stderr diagnostic naming the surviving process id after every attempt fails."""
288
+
289
+ PROCESS_TREE_KILL_ATTEMPT_LIMIT: int = 2
290
+ """Kill-and-drain rounds run on a timed-out worker before the kill is reported failed.
291
+
292
+ Each round issues the tree kill while the worker process is still alive, then
293
+ waits ``KILL_GRACE_TIMEOUT_SECONDS`` for the streams to drain.
294
+ """
295
+
296
+ REASON_TIMEOUT_OUT_OF_BOUNDS: str = "timeout_out_of_bounds"
297
+ """Dispatcher attempt reason when the requested timeout is below the floor or above the ceiling."""
226
298
 
227
299
  WORKER_EXCEPTION_RETURN_CODE: int = -3
228
300
  """Return code recorded on a WorkerReport when the worker body raises before a process runs.
@@ -321,9 +393,6 @@ WORKER_SPEC_TIMEOUT_KEY: str = "timeout_seconds"
321
393
  WORKER_SPEC_IS_REPO_ONLY_KEY: str = "is_repo_only"
322
394
  """JSON key for whether a readonly worker also disables web search."""
323
395
 
324
- WORKER_SPEC_MAX_TURNS_KEY: str = "max_turns"
325
- """JSON key for one worker's max-turns cap."""
326
-
327
396
  WORKER_SPEC_AGENT_NAME_KEY: str = "agent_name"
328
397
  """JSON key for one worker's optional agent definition name."""
329
398
 
@@ -389,9 +458,6 @@ REASON_CLAUDE_AGENT_REQUIRED: str = "claude_agent_required"
389
458
  REASON_PROMPT_FILE_MISSING: str = "prompt_file_missing"
390
459
  """Config reason when the dispatcher CLI prompt file path is absent or unreadable."""
391
460
 
392
- DEFAULT_SPAWN_MAX_TURNS: int = 8
393
- """Default max-turns applied to the headless grok worker when the caller names none."""
394
-
395
461
  SPAWN_SERVED_EXIT_CODE: int = 0
396
462
  """CLI exit code when a dispatcher tier served the call."""
397
463
 
@@ -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,14 +18,15 @@ 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
  """
23
25
 
24
26
  from __future__ import annotations
25
27
 
28
+ import os
29
+ import signal
26
30
  import subprocess
27
31
  import uuid
28
32
  from dataclasses import dataclass
@@ -35,6 +39,7 @@ from dev_env_scripts_constants.grok_worker_constants import (
35
39
  ALWAYS_APPROVE_FLAG,
36
40
  CLASSIFICATION_AUTH_FAILURE,
37
41
  CLASSIFICATION_ERROR,
42
+ CLASSIFICATION_KILL_FAILED,
38
43
  CLASSIFICATION_OK,
39
44
  CLASSIFICATION_STREAM_JOIN_SEPARATOR,
40
45
  CLASSIFICATION_TIMEOUT,
@@ -43,24 +48,46 @@ from dev_env_scripts_constants.grok_worker_constants import (
43
48
  GROK_BINARY_NAME,
44
49
  GROK_BINARY_NOT_FOUND_STDERR,
45
50
  GROK_MODEL_PIN,
51
+ KILL_FAILED_RETURN_CODE,
52
+ KILL_FAILED_STDERR_TEMPLATE,
46
53
  KILL_GRACE_TIMEOUT_SECONDS,
47
54
  LAUNCH_FAILURE_RETURN_CODE,
48
55
  LAUNCH_FAILURE_STDERR_PREFIX,
49
56
  LEADER_SOCKET_FILENAME_PREFIX,
50
57
  LEADER_SOCKET_FILENAME_SUFFIX,
51
58
  LEADER_SOCKET_FLAG,
52
- MAX_TURNS_FLAG,
59
+ MAXIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE,
60
+ MAXIMUM_WORKER_TIMEOUT_SECONDS,
61
+ MIN_WORKER_TIMEOUT_SECONDS,
62
+ MINIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE,
53
63
  MISSING_BINARY_RETURN_CODE,
54
64
  MODEL_FLAG,
55
65
  OUTPUT_FORMAT_FLAG,
56
66
  OUTPUT_FORMAT_JSON,
67
+ PROCESS_TREE_KILL_ATTEMPT_LIMIT,
68
+ PROCESS_TREE_KILL_TIMEOUT_SECONDS,
57
69
  PROMPT_FILE_FLAG,
58
70
  TIMEOUT_RETURN_CODE,
59
71
  UTF8_DECODE_ERRORS,
60
72
  UTF8_ENCODING,
73
+ WINDOWS_OS_NAME,
74
+ WINDOWS_TASKKILL_COMMAND,
75
+ WINDOWS_TASKKILL_FORCE_FLAG,
76
+ WINDOWS_TASKKILL_PID_FLAG,
77
+ WINDOWS_TASKKILL_TREE_FLAG,
78
+ WORKER_SPEC_TIMEOUT_KEY,
61
79
  )
62
80
 
63
81
  runner_popen = subprocess.Popen
82
+ runner_subprocess_run = subprocess.run
83
+
84
+
85
+ class WorkerTimeoutOutOfBoundsError(ValueError):
86
+ """Raised when a requested worker timeout falls outside the accepted bounds.
87
+
88
+ A ``ValueError`` subclass so existing callers that catch ``ValueError``
89
+ keep working, while a caller that wants only this fault can name it.
90
+ """
64
91
 
65
92
 
66
93
  @dataclass(frozen=True)
@@ -90,7 +117,6 @@ def _build_invocation(
90
117
  *,
91
118
  prompt_file: Path,
92
119
  working_directory: Path,
93
- max_turns: int,
94
120
  leader_socket_path: Path,
95
121
  agent_name: str | None,
96
122
  all_extra_arguments: tuple[str, ...] = (),
@@ -104,8 +130,6 @@ def _build_invocation(
104
130
  OUTPUT_FORMAT_FLAG,
105
131
  OUTPUT_FORMAT_JSON,
106
132
  ALWAYS_APPROVE_FLAG,
107
- MAX_TURNS_FLAG,
108
- str(max_turns),
109
133
  LEADER_SOCKET_FLAG,
110
134
  str(leader_socket_path),
111
135
  ]
@@ -180,14 +204,180 @@ def _resolve_returncode(process: subprocess.Popen[str]) -> int:
180
204
  return TIMEOUT_RETURN_CODE
181
205
 
182
206
 
183
- def _timeout_outcome(process: subprocess.Popen[str]) -> GrokRunnerOutcome:
184
- process.kill()
207
+ def _kill_windows_process_tree(process_identifier: int) -> None:
208
+ """End a Windows process and every descendant it started, by process id.
209
+
210
+ Swallows taskkill failures so the caller still falls back to
211
+ ``Popen.kill()`` and a timed drain.
212
+ """
185
213
  try:
186
- captured_stdout, captured_stderr = process.communicate(
187
- timeout=KILL_GRACE_TIMEOUT_SECONDS
214
+ runner_subprocess_run(
215
+ [
216
+ WINDOWS_TASKKILL_COMMAND,
217
+ WINDOWS_TASKKILL_TREE_FLAG,
218
+ WINDOWS_TASKKILL_FORCE_FLAG,
219
+ WINDOWS_TASKKILL_PID_FLAG,
220
+ str(process_identifier),
221
+ ],
222
+ stdout=subprocess.DEVNULL,
223
+ stderr=subprocess.DEVNULL,
224
+ check=False,
225
+ timeout=PROCESS_TREE_KILL_TIMEOUT_SECONDS,
188
226
  )
227
+ except (subprocess.TimeoutExpired, OSError):
228
+ return
229
+
230
+
231
+ def _kill_posix_process_group(process_identifier: int) -> None:
232
+ """End a POSIX process group so no grandchild keeps the capture pipe open.
233
+
234
+ Reached only through the caller's ``os.name`` branch, so the process-group
235
+ calls run on the platforms that define them.
236
+
237
+ A process id that is already reaped raises ``OSError``; this swallows it and
238
+ returns, so the caller falls back to ``Popen.kill()``.
239
+ """
240
+ try:
241
+ process_group_identifier = os.getpgid(process_identifier) # type: ignore[attr-defined] # POSIX-only, reached via the caller's os.name branch
242
+ os.killpg(process_group_identifier, signal.SIGKILL) # type: ignore[attr-defined] # POSIX-only, reached via the caller's os.name branch
243
+ except OSError:
244
+ return
245
+
246
+
247
+ def _kill_process_tree_by_identifier(process_identifier: int) -> None:
248
+ """Issue the platform's tree kill for one process id, with no liveness check."""
249
+ if os.name == WINDOWS_OS_NAME:
250
+ _kill_windows_process_tree(process_identifier)
251
+ return
252
+ _kill_posix_process_group(process_identifier)
253
+
254
+
255
+ def _terminate_process_tree(process: subprocess.Popen[str]) -> None:
256
+ """End the worker process and every descendant it spawned.
257
+
258
+ ::
259
+
260
+ tree kill (taskkill /T or killpg) ok: grandchildren die, pipes close
261
+ Popen.kill() alone flag: grandchildren outlive the worker
262
+
263
+ Falls back to ``Popen.kill()`` when the direct child survives the tree kill,
264
+ so the caller never waits on a live process.
265
+ """
266
+ if process.poll() is not None:
267
+ return
268
+ _kill_process_tree_by_identifier(process.pid)
269
+ if process.poll() is not None:
270
+ return
271
+ try:
272
+ process.kill()
273
+ except ProcessLookupError:
274
+ return
275
+
276
+
277
+ def require_timeout_within_bounds(timeout_seconds: int | None) -> None:
278
+ """Refuse a timeout that is missing, below the floor, or above the ceiling.
279
+
280
+ ::
281
+
282
+ None or 0 flag: ValueError naming MIN_WORKER_TIMEOUT_SECONDS
283
+ 5401 flag: ValueError naming MAXIMUM_WORKER_TIMEOUT_SECONDS
284
+ 1 .. 5400 ok: returns
285
+
286
+ Public so a dispatcher can apply the same bounds on a path that never
287
+ reaches ``run_headless_worker``.
288
+
289
+ Args:
290
+ timeout_seconds: The requested per-worker timeout in seconds.
291
+
292
+ Raises:
293
+ WorkerTimeoutOutOfBoundsError: When the value falls outside the bounds.
294
+ """
295
+ if timeout_seconds is None or timeout_seconds < MIN_WORKER_TIMEOUT_SECONDS:
296
+ raise WorkerTimeoutOutOfBoundsError(
297
+ MINIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE.format(
298
+ field_name=WORKER_SPEC_TIMEOUT_KEY,
299
+ requested_seconds=timeout_seconds,
300
+ minimum_seconds=MIN_WORKER_TIMEOUT_SECONDS,
301
+ )
302
+ )
303
+ if timeout_seconds > MAXIMUM_WORKER_TIMEOUT_SECONDS:
304
+ raise WorkerTimeoutOutOfBoundsError(
305
+ MAXIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE.format(
306
+ field_name=WORKER_SPEC_TIMEOUT_KEY,
307
+ requested_seconds=timeout_seconds,
308
+ maximum_seconds=MAXIMUM_WORKER_TIMEOUT_SECONDS,
309
+ )
310
+ )
311
+
312
+
313
+ def _drain_after_kill(process: subprocess.Popen[str]) -> tuple[str, str] | None:
314
+ """Read the killed process's streams, or None when the grace window expires."""
315
+ try:
316
+ return process.communicate(timeout=KILL_GRACE_TIMEOUT_SECONDS)
189
317
  except subprocess.TimeoutExpired:
190
- captured_stdout, captured_stderr = "", ""
318
+ return None
319
+
320
+
321
+ def _kill_and_drain_within_attempt_limit(
322
+ process: subprocess.Popen[str],
323
+ ) -> tuple[str, str] | None:
324
+ """Kill the process tree and drain it, retrying up to the attempt limit.
325
+
326
+ ::
327
+
328
+ attempt 1 drains ok: streams, one attempt made
329
+ attempt 1 times out,
330
+ attempt 2 drains ok: streams, two attempts made
331
+ every attempt times out flag: None
332
+
333
+ A tree kill that returns without taking leaves the drain waiting on a live
334
+ pipe, so a timed-out drain is followed by another kill-and-drain round.
335
+ ``_terminate_process_tree`` re-issues the kill only while the worker
336
+ process is still alive; once it has exited, the next round is a second
337
+ drain window for the descendants still holding the pipe open.
338
+
339
+ Args:
340
+ process: The timed-out worker process to kill and read.
341
+
342
+ Returns:
343
+ The captured stdout and stderr, or None when every attempt timed out.
344
+ """
345
+ attempts_made = 0
346
+ while attempts_made < PROCESS_TREE_KILL_ATTEMPT_LIMIT:
347
+ _terminate_process_tree(process)
348
+ all_captured_streams = _drain_after_kill(process)
349
+ if all_captured_streams is not None:
350
+ return all_captured_streams
351
+ attempts_made += 1
352
+ return None
353
+
354
+
355
+ def _kill_failed_outcome(process: subprocess.Popen[str]) -> GrokRunnerOutcome:
356
+ diagnostic_text = KILL_FAILED_STDERR_TEMPLATE.format(
357
+ attempt_count=PROCESS_TREE_KILL_ATTEMPT_LIMIT,
358
+ process_identifier=process.pid,
359
+ )
360
+ return GrokRunnerOutcome(
361
+ is_ok=False,
362
+ returncode=KILL_FAILED_RETURN_CODE,
363
+ classification=CLASSIFICATION_KILL_FAILED,
364
+ stdout="",
365
+ stderr=diagnostic_text,
366
+ )
367
+
368
+
369
+ def _timeout_outcome(process: subprocess.Popen[str]) -> GrokRunnerOutcome:
370
+ """Kill a timed-out worker's tree, then classify what the kill achieved.
371
+
372
+ ::
373
+
374
+ drain clears on attempt 1 or 2 ok: classification timeout
375
+ both attempts leave it draining flag: classification kill_failed
376
+ """
377
+ all_captured_streams = _kill_and_drain_within_attempt_limit(process)
378
+ if all_captured_streams is None:
379
+ return _kill_failed_outcome(process)
380
+ captured_stdout, captured_stderr = all_captured_streams
191
381
  stdout_text = _normalize_stream(captured_stdout)
192
382
  stderr_text = _normalize_stream(captured_stderr)
193
383
  returncode = _resolve_returncode(process)
@@ -234,6 +424,7 @@ def _invoke_process(
234
424
  text=True,
235
425
  encoding=UTF8_ENCODING,
236
426
  errors=UTF8_DECODE_ERRORS,
427
+ start_new_session=os.name != WINDOWS_OS_NAME,
237
428
  )
238
429
  except FileNotFoundError:
239
430
  return _missing_binary_outcome()
@@ -254,7 +445,6 @@ def run_headless_worker(
254
445
  prompt_file: Path,
255
446
  working_directory: Path,
256
447
  run_state_directory: Path,
257
- max_turns: int,
258
448
  timeout_seconds: int,
259
449
  agent_name: str | None = None,
260
450
  leader_socket_path: Path | None = None,
@@ -262,13 +452,16 @@ def run_headless_worker(
262
452
  ) -> GrokRunnerOutcome:
263
453
  """Run one headless grok worker and classify the process outcome.
264
454
 
455
+ The timeout is the worker's only bound; the argv carries no turn cap.
456
+
265
457
  Args:
266
458
  prompt_file: Path to the prompt file passed via ``--prompt-file``.
267
459
  working_directory: Working directory passed via ``--cwd``.
268
460
  run_state_directory: Run-scoped directory the leader socket is minted
269
461
  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.
462
+ timeout_seconds: Seconds before the process tree is killed on expiry.
463
+ Must sit between ``MIN_WORKER_TIMEOUT_SECONDS`` and
464
+ ``MAXIMUM_WORKER_TIMEOUT_SECONDS`` inclusive.
272
465
  agent_name: Optional role agent name passed via ``--agent``.
273
466
  leader_socket_path: Optional pre-minted leader socket path. When omitted,
274
467
  a unique path is minted under ``run_state_directory``.
@@ -277,7 +470,12 @@ def run_headless_worker(
277
470
 
278
471
  Returns:
279
472
  The classified outcome including return code and captured streams.
473
+
474
+ Raises:
475
+ WorkerTimeoutOutOfBoundsError: When ``timeout_seconds`` is missing,
476
+ below the floor, or above the ceiling.
280
477
  """
478
+ require_timeout_within_bounds(timeout_seconds)
281
479
  resolved_leader_socket_path = (
282
480
  leader_socket_path
283
481
  if leader_socket_path is not None
@@ -286,7 +484,6 @@ def run_headless_worker(
286
484
  all_arguments = _build_invocation(
287
485
  prompt_file=prompt_file,
288
486
  working_directory=working_directory,
289
- max_turns=max_turns,
290
487
  leader_socket_path=resolved_leader_socket_path,
291
488
  agent_name=agent_name,
292
489
  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(