claude-dev-env 8.14.0 → 8.15.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.
Files changed (123) hide show
  1. package/.agents/agents/test_agent_frontmatter.py +0 -72
  2. package/.agents/agents-archived/code-quality-agent.md +16 -16
  3. package/.agents/skills/_shared/end-of-run-gotcha-recommendations.md +5 -21
  4. package/.agents/skills/e-code-review/SKILL.md +1 -3
  5. package/.agents/skills/e-simplify/SKILL.md +6 -6
  6. package/.agents/skills/eli5/SKILL.md +4 -14
  7. package/.agents/skills/eli5/reference/task-seeds.md +3 -4
  8. package/.agents/skills/everything-search/SKILL.md +3 -3
  9. package/.agents/skills/fresh-branch/SKILL.md +2 -3
  10. package/.agents/skills/grok-spawn/SKILL.md +3 -8
  11. package/.agents/skills/issue-tracker/SKILL.md +4 -6
  12. package/.agents/skills/orchestrator-refresh/SKILL.md +7 -7
  13. package/.agents/skills/plugin-eval-standalone-skill/SKILL.md +3 -3
  14. package/.agents/skills/privacy-hygiene/SKILL.md +6 -10
  15. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  16. package/.agents/skills/pull-request/SKILL.md +11 -14
  17. package/.agents/skills/pull-request/reference/publication-tasks.md +2 -4
  18. package/.agents/skills/recovering-codex-startup/SKILL.md +3 -6
  19. package/.agents/skills/recovering-codex-startup/reference/task-seeds.md +1 -1
  20. package/.agents/skills/repairing-hook-boundaries/SKILL.md +4 -7
  21. package/.agents/skills/repairing-hook-boundaries/reference/task-seeds.md +1 -2
  22. package/.agents/skills/second-account-workers/.claude/CLAUDE.md +1 -0
  23. package/.agents/skills/second-account-workers/AGENTS.md +1 -0
  24. package/.agents/skills/second-account-workers/SKILL.md +65 -0
  25. package/.agents/skills/syncing-submodules/SKILL.md +7 -17
  26. package/.agents/skills/syncing-submodules/reference/run-tasks.md +1 -1
  27. package/.agents/skills/task-build/SKILL.md +1 -1
  28. package/.agents/skills/task-build/reference/tool-routing.md +1 -1
  29. package/.agents/skills/team-advisor/SKILL.md +2 -4
  30. package/.agents/skills/usage-pause/SKILL.md +1 -1
  31. package/.agents/skills/windows-scheduled-task/SKILL.md +1 -1
  32. package/AGENTS.md +1 -1
  33. package/_shared/advisor/advisor-protocol.md +4 -4
  34. package/_shared/advisor/reference/consult-format.md +1 -1
  35. package/_shared/advisor/reference/identity.md +2 -2
  36. package/_shared/advisor/reference/warm-up.md +1 -1
  37. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  38. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -16
  39. package/bin/ever-shipped-skills.mjs +1 -0
  40. package/bin/install.mjs +1 -0
  41. package/bin/install.prompt-context.test.mjs +46 -0
  42. package/bin/install.test.mjs +3 -2
  43. package/commands/sr-loop.md +2 -2
  44. package/docs/CODE_RULES.md +2 -2
  45. package/docs/DJANGO_PATTERNS.md +5 -5
  46. package/docs/REACT_PATTERNS.md +6 -6
  47. package/docs/TEST_QUALITY.md +10 -28
  48. package/docs/agent-spawn-protocol.md +4 -4
  49. package/docs/codex-compatibility.md +4 -33
  50. package/docs/references/team-advisor-skill.md +0 -1
  51. package/docs/second-claude-account.md +4 -0
  52. package/docs/worker-completion-gate.md +0 -1
  53. package/hooks/advisory/migration_safety_advisor.py +3 -2
  54. package/hooks/advisory/refactor_guard.py +3 -3
  55. package/hooks/hooks.json +0 -5
  56. package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +1 -2
  57. package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +1 -2
  58. package/package.json +1 -1
  59. package/rules/explore-thoroughly.md +0 -1
  60. package/rules/filesystem-search.md +1 -1
  61. package/rules/git-workflow.md +5 -5
  62. package/rules/long-horizon-autonomy.md +1 -5
  63. package/rules/re-stage-before-commit.md +0 -2
  64. package/rules/research-mode.md +1 -1
  65. package/rules/shell-invocation.md +1 -1
  66. package/scripts/claude_account_worker.py +347 -0
  67. package/scripts/claude_account_worker_process.py +169 -0
  68. package/scripts/claude_account_worker_report.py +170 -0
  69. package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +139 -0
  70. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +8 -2
  71. package/scripts/grok_headless_runner.py +2 -5
  72. package/scripts/policy_lint/config/constants.py +1 -0
  73. package/scripts/policy_lint/engine.py +5 -2
  74. package/scripts/repository_checks/config/constants.py +1 -1
  75. package/scripts/spawn_grok_batch.py +12 -1
  76. package/scripts/subprocess_stream_text.py +28 -0
  77. package/scripts/task_usage_events.py +387 -0
  78. package/scripts/task_usage_report.py +391 -0
  79. package/scripts/test_claude_account_worker.py +331 -0
  80. package/scripts/test_spawn_grok_batch.py +22 -0
  81. package/scripts/test_task_usage_events.py +47 -0
  82. package/scripts/test_task_usage_report.py +603 -0
  83. package/scripts/tests/test_banned_prose_words.py +1 -1
  84. package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
  85. package/scripts/tests/test_policy_lint_rules_engine.py +2 -2
  86. package/scripts/tests/test_rules.py +9 -9
  87. package/system-prompts/software-engineer.xml +15 -30
  88. package/audit-rubrics/audit-categories.json +0 -700
  89. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +0 -90
  90. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +0 -36
  91. package/audit-rubrics/category_rubrics/category-c-resource-cleanup.md +0 -35
  92. package/audit-rubrics/category_rubrics/category-d-scoping-and-ordering.md +0 -35
  93. package/audit-rubrics/category_rubrics/category-e-dead-code.md +0 -40
  94. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +0 -41
  95. package/audit-rubrics/category_rubrics/category-g-bounds-and-overflow.md +0 -38
  96. package/audit-rubrics/category_rubrics/category-h-security-boundaries.md +0 -40
  97. package/audit-rubrics/category_rubrics/category-i-concurrency.md +0 -38
  98. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +0 -59
  99. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +0 -66
  100. package/audit-rubrics/category_rubrics/category-l-behavior-equivalence.md +0 -48
  101. package/audit-rubrics/category_rubrics/category-m-producer-consumer-cardinality.md +0 -44
  102. package/audit-rubrics/category_rubrics/category-n-test-name-scenario-verifier.md +0 -54
  103. package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +0 -59
  104. package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +0 -46
  105. package/audit-rubrics/prompts/category-a-api-contracts.md +0 -407
  106. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +0 -401
  107. package/audit-rubrics/prompts/category-c-resource-cleanup.md +0 -420
  108. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +0 -414
  109. package/audit-rubrics/prompts/category-e-dead-code.md +0 -434
  110. package/audit-rubrics/prompts/category-f-silent-failures.md +0 -432
  111. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +0 -383
  112. package/audit-rubrics/prompts/category-h-security-boundaries.md +0 -423
  113. package/audit-rubrics/prompts/category-i-concurrency.md +0 -429
  114. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +0 -458
  115. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +0 -338
  116. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +0 -131
  117. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +0 -129
  118. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +0 -139
  119. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +0 -89
  120. package/audit-rubrics/prompts/category-q-cross-surface-claims.md +0 -51
  121. package/hooks/hooks_constants/style_reminder_prompt_constants.py +0 -9
  122. package/hooks/session/style_reminder_prompt.py +0 -41
  123. package/hooks/session/test_style_reminder_prompt.py +0 -32
@@ -0,0 +1,347 @@
1
+ """Run a headless Claude worker on the account chosen by the usage picker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ from collections.abc import Callable, Mapping
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ from claude_account_choice import choose_account, decision_payload, read_account_meters
18
+ from claude_account_profile import default_profile_home
19
+ from claude_account_worker_process import WorkerProcess, invoke_worker
20
+ from claude_account_worker_report import (
21
+ WorkerReport,
22
+ finalize_report,
23
+ make_report,
24
+ pre_launch_failure_report,
25
+ wait_report,
26
+ )
27
+ from dev_env_scripts_constants.claude_account_worker_constants import (
28
+ CHOICE_MAIN,
29
+ CHOICE_SECOND,
30
+ CHOICE_WAIT,
31
+ CLAUDE_BINARY_NAME,
32
+ CLAUDE_CONFIG_DIR_ENV_VAR,
33
+ CLI_DESCRIPTION,
34
+ CREDENTIALS_FILE_NAME,
35
+ CWD_FLAG,
36
+ DEFAULT_PERMISSION_MODE,
37
+ DEFAULT_TIMEOUT_MINUTES,
38
+ INVALID_TIMEOUT_MESSAGE,
39
+ JSON_ACCOUNT_KEY,
40
+ JSON_CONFIG_DIRECTORY_KEY,
41
+ JSON_REASON_KEY,
42
+ LAUNCH_FAILURE_EXIT_CODE,
43
+ MAIN_CLAUDE_HOME_DIRECTORY_NAME,
44
+ MISSING_BINARY_EXIT_CODE,
45
+ MINIMUM_TIMEOUT_MINUTES,
46
+ MODEL_FLAG,
47
+ OUTPUT_FORMAT_FLAG,
48
+ OUTPUT_FORMAT_JSON,
49
+ PERMISSION_MODE_FLAG,
50
+ PROMPT_FILE_FLAG,
51
+ REPORT_FILE_FLAG,
52
+ SINGLE_PROMPT_FLAG,
53
+ TIMEOUT_MINUTES_FLAG,
54
+ UTF8_ENCODING,
55
+ )
56
+ from shared_tree_paths import resolve_shared_process_tree_scripts_directory
57
+
58
+ _process_tree_scripts_directory = resolve_shared_process_tree_scripts_directory(
59
+ __file__, all_environment=os.environ
60
+ )
61
+ if str(_process_tree_scripts_directory) not in sys.path:
62
+ sys.path.insert(0, str(_process_tree_scripts_directory))
63
+
64
+ _process_tree_kill = importlib.import_module("process_tree_kill")
65
+ _terminate_process_tree = _process_tree_kill.terminate_process_tree
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class AccountSelection:
70
+ account: str
71
+ config_dir: Path | None
72
+ reason: str
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class WorkerDependencies:
77
+ process_factory: Callable[..., WorkerProcess]
78
+ all_parent_environment_variables: Mapping[str, str]
79
+ binary_resolver: Callable[[str], str | None]
80
+ process_terminator: Callable[[WorkerProcess], None]
81
+ monotonic_clock: Callable[[], float]
82
+
83
+
84
+ def _default_dependencies() -> WorkerDependencies:
85
+ return WorkerDependencies(
86
+ process_factory=subprocess.Popen,
87
+ all_parent_environment_variables=os.environ,
88
+ binary_resolver=shutil.which,
89
+ process_terminator=_terminate_process_tree,
90
+ monotonic_clock=time.monotonic,
91
+ )
92
+
93
+
94
+ def _selection_from_payload(all_selection_payload: Mapping[str, str | None]) -> AccountSelection:
95
+ account = all_selection_payload[JSON_ACCOUNT_KEY]
96
+ reason = all_selection_payload[JSON_REASON_KEY]
97
+ config_dir_text = all_selection_payload[JSON_CONFIG_DIRECTORY_KEY]
98
+ if account is None or reason is None:
99
+ raise ValueError("account picker returned an incomplete decision")
100
+ return AccountSelection(
101
+ account=account,
102
+ config_dir=Path(config_dir_text) if config_dir_text is not None else None,
103
+ reason=reason,
104
+ )
105
+
106
+
107
+ def select_account() -> AccountSelection:
108
+ """Pick the account this worker runs on and its Claude home directory.
109
+
110
+ Returns:
111
+ The chosen account, its Claude config directory, and the reason.
112
+
113
+ Raises:
114
+ ValueError: When the account picker returns an incomplete decision.
115
+ """
116
+ main_config_dir = Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME
117
+ second_config_dir = default_profile_home()
118
+ config_directory_by_account = {
119
+ CHOICE_MAIN: main_config_dir,
120
+ CHOICE_SECOND: second_config_dir,
121
+ }
122
+ main_meters = read_account_meters(main_config_dir / CREDENTIALS_FILE_NAME)
123
+ second_meters = read_account_meters(second_config_dir / CREDENTIALS_FILE_NAME)
124
+ decision = choose_account(
125
+ main_meters=main_meters, second_meters=second_meters, now=datetime.now().astimezone()
126
+ )
127
+ selected_config_dir = config_directory_by_account.get(decision.account)
128
+ payload = decision_payload(decision, config_directory=selected_config_dir)
129
+ return _selection_from_payload(payload)
130
+
131
+
132
+ def _positive_timeout_minutes(candidate_text: str) -> int:
133
+ timeout_minutes = int(candidate_text)
134
+ if timeout_minutes < MINIMUM_TIMEOUT_MINUTES:
135
+ raise argparse.ArgumentTypeError(INVALID_TIMEOUT_MESSAGE)
136
+ return timeout_minutes
137
+
138
+
139
+ def build_argument_parser() -> argparse.ArgumentParser:
140
+ """Build the CLI parser for one headless worker invocation.
141
+
142
+ Returns:
143
+ The configured argument parser.
144
+ """
145
+ parser = argparse.ArgumentParser(description=CLI_DESCRIPTION)
146
+ parser.add_argument(PROMPT_FILE_FLAG, type=Path, required=True)
147
+ parser.add_argument(CWD_FLAG, type=Path, default=Path.cwd())
148
+ parser.add_argument(REPORT_FILE_FLAG, type=Path, required=True)
149
+ parser.add_argument(MODEL_FLAG)
150
+ parser.add_argument(
151
+ PERMISSION_MODE_FLAG,
152
+ default=DEFAULT_PERMISSION_MODE,
153
+ )
154
+ parser.add_argument(
155
+ TIMEOUT_MINUTES_FLAG,
156
+ type=_positive_timeout_minutes,
157
+ default=DEFAULT_TIMEOUT_MINUTES,
158
+ )
159
+ return parser
160
+
161
+
162
+ def _child_environment(
163
+ selection: AccountSelection,
164
+ all_environment_variables: Mapping[str, str],
165
+ ) -> dict[str, str]:
166
+ if selection.account not in (CHOICE_MAIN, CHOICE_SECOND):
167
+ raise ValueError(f"unsupported account selection: {selection.account}")
168
+ all_child_environment_variables = dict(all_environment_variables)
169
+ if selection.account == CHOICE_MAIN:
170
+ all_child_environment_variables.pop(CLAUDE_CONFIG_DIR_ENV_VAR, None)
171
+ return all_child_environment_variables
172
+ if selection.config_dir is None:
173
+ raise ValueError("second account selection needs a config directory")
174
+ all_child_environment_variables[CLAUDE_CONFIG_DIR_ENV_VAR] = str(selection.config_dir)
175
+ return all_child_environment_variables
176
+
177
+
178
+ def _invocation(
179
+ executable: str,
180
+ *,
181
+ model: str | None,
182
+ permission_mode: str,
183
+ ) -> list[str]:
184
+ all_arguments = [
185
+ executable,
186
+ SINGLE_PROMPT_FLAG,
187
+ OUTPUT_FORMAT_FLAG,
188
+ OUTPUT_FORMAT_JSON,
189
+ PERMISSION_MODE_FLAG,
190
+ permission_mode,
191
+ ]
192
+ if model is not None:
193
+ all_arguments.extend([MODEL_FLAG, model])
194
+ return all_arguments
195
+
196
+
197
+ def _prepared_launch(
198
+ selection: AccountSelection,
199
+ prompt_file: Path,
200
+ dependencies: WorkerDependencies,
201
+ ) -> tuple[dict[str, str], str, str] | WorkerReport:
202
+ try:
203
+ all_child_environment_variables = _child_environment(
204
+ selection, dependencies.all_parent_environment_variables
205
+ )
206
+ prompt_text = prompt_file.read_text(encoding=UTF8_ENCODING)
207
+ except (OSError, ValueError):
208
+ return pre_launch_failure_report(
209
+ selection.account, selection.reason, LAUNCH_FAILURE_EXIT_CODE
210
+ )
211
+ claude_binary = dependencies.binary_resolver(CLAUDE_BINARY_NAME)
212
+ if claude_binary is None:
213
+ return pre_launch_failure_report(
214
+ selection.account, selection.reason, MISSING_BINARY_EXIT_CODE
215
+ )
216
+ return all_child_environment_variables, prompt_text, claude_binary
217
+
218
+
219
+ def _invoked_report(
220
+ selection: AccountSelection,
221
+ *,
222
+ all_arguments: list[str],
223
+ cwd: Path,
224
+ all_child_environment_variables: dict[str, str],
225
+ prompt_text: str,
226
+ timeout_minutes: int,
227
+ dependencies: WorkerDependencies,
228
+ ) -> WorkerReport:
229
+ exit_code, duration_seconds, stdout_text = invoke_worker(
230
+ all_arguments=all_arguments,
231
+ cwd=cwd,
232
+ all_child_environment_variables=all_child_environment_variables,
233
+ prompt_text=prompt_text,
234
+ timeout_minutes=timeout_minutes,
235
+ controls=dependencies,
236
+ )
237
+ return make_report(
238
+ selection.account,
239
+ selection.reason,
240
+ exit_code=exit_code,
241
+ duration_seconds=duration_seconds,
242
+ stdout_text=stdout_text,
243
+ )
244
+
245
+
246
+ def _worker_report(
247
+ selection: AccountSelection,
248
+ *,
249
+ prompt_file: Path,
250
+ cwd: Path,
251
+ model: str | None,
252
+ permission_mode: str,
253
+ timeout_minutes: int,
254
+ dependencies: WorkerDependencies,
255
+ ) -> WorkerReport:
256
+ prepared = _prepared_launch(selection, prompt_file, dependencies)
257
+ if isinstance(prepared, WorkerReport):
258
+ return prepared
259
+ all_child_environment_variables, prompt_text, claude_binary = prepared
260
+ all_arguments = _invocation(claude_binary, model=model, permission_mode=permission_mode)
261
+ return _invoked_report(
262
+ selection,
263
+ all_arguments=all_arguments,
264
+ cwd=cwd,
265
+ all_child_environment_variables=all_child_environment_variables,
266
+ prompt_text=prompt_text,
267
+ timeout_minutes=timeout_minutes,
268
+ dependencies=dependencies,
269
+ )
270
+
271
+
272
+ def _report_for_selection(
273
+ selection: AccountSelection,
274
+ *,
275
+ prompt_file: Path,
276
+ cwd: Path,
277
+ model: str | None,
278
+ permission_mode: str,
279
+ timeout_minutes: int,
280
+ dependencies: WorkerDependencies,
281
+ ) -> WorkerReport:
282
+ if selection.account == CHOICE_WAIT:
283
+ return wait_report(selection.account, selection.reason)
284
+ return _worker_report(
285
+ selection,
286
+ prompt_file=prompt_file,
287
+ cwd=cwd,
288
+ model=model,
289
+ permission_mode=permission_mode,
290
+ timeout_minutes=timeout_minutes,
291
+ dependencies=dependencies,
292
+ )
293
+
294
+
295
+ def run_worker(
296
+ *,
297
+ prompt_file: Path,
298
+ cwd: Path,
299
+ report_file: Path,
300
+ model: str | None,
301
+ permission_mode: str,
302
+ timeout_minutes: int,
303
+ decision_selector: Callable[[], AccountSelection],
304
+ dependencies: WorkerDependencies,
305
+ ) -> int:
306
+ """Pick an account and run one headless Claude worker on it.
307
+
308
+ Args:
309
+ prompt_file, report_file: The prompt and report file paths.
310
+ decision_selector, dependencies: The picker and process controls.
311
+ Returns:
312
+ The exit code: 3 on wait, 124 on timeout, 127 on a missing binary,
313
+ or the child's own exit code.
314
+ """
315
+ report = _report_for_selection(
316
+ decision_selector(),
317
+ prompt_file=prompt_file,
318
+ cwd=cwd,
319
+ model=model,
320
+ permission_mode=permission_mode,
321
+ timeout_minutes=timeout_minutes,
322
+ dependencies=dependencies,
323
+ )
324
+ return finalize_report(report_file, report)
325
+
326
+
327
+ def main() -> int:
328
+ """Parse CLI arguments and run one headless worker.
329
+
330
+ Returns:
331
+ The worker's exit code.
332
+ """
333
+ arguments = build_argument_parser().parse_args()
334
+ return run_worker(
335
+ prompt_file=arguments.prompt_file,
336
+ cwd=arguments.cwd,
337
+ report_file=arguments.report_file,
338
+ model=arguments.model,
339
+ permission_mode=arguments.permission_mode,
340
+ timeout_minutes=arguments.timeout_minutes,
341
+ decision_selector=select_account,
342
+ dependencies=_default_dependencies(),
343
+ )
344
+
345
+
346
+ if __name__ == "__main__":
347
+ sys.exit(main())
@@ -0,0 +1,169 @@
1
+ """Launch a headless Claude worker process and wait for it to finish."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Protocol
12
+
13
+ from dev_env_scripts_constants.claude_account_worker_constants import (
14
+ DRAIN_ATTEMPT_LIMIT,
15
+ DRAIN_GRACE_TIMEOUT_SECONDS,
16
+ LAUNCH_FAILURE_EXIT_CODE,
17
+ SECONDS_PER_MINUTE,
18
+ TIMEOUT_EXIT_CODE,
19
+ UTF8_DECODE_ERRORS,
20
+ UTF8_ENCODING,
21
+ )
22
+ from shared_tree_paths import resolve_shared_process_tree_scripts_directory
23
+ from subprocess_stream_text import decode_stream_text
24
+ from subprocess_window_access import hidden_window_creation_flags
25
+
26
+ _process_tree_scripts_directory = resolve_shared_process_tree_scripts_directory(
27
+ __file__, all_environment=os.environ
28
+ )
29
+ if str(_process_tree_scripts_directory) not in sys.path:
30
+ sys.path.insert(0, str(_process_tree_scripts_directory))
31
+
32
+ _process_tree_kill = importlib.import_module("process_tree_kill")
33
+ _should_start_new_session = _process_tree_kill.should_start_new_session
34
+
35
+
36
+ class WorkerProcess(Protocol):
37
+ returncode: int | None
38
+
39
+ pid: int
40
+
41
+ def communicate(
42
+ self,
43
+ input: str | None = None,
44
+ timeout: float | None = None,
45
+ ) -> tuple[str | bytes | None, str | bytes | None]: ...
46
+
47
+ def poll(self) -> int | None: ...
48
+
49
+ def kill(self) -> None: ...
50
+
51
+
52
+ def _decoded_text(stream_payload: str | bytes | None) -> str:
53
+ return decode_stream_text(
54
+ stream_payload, encoding=UTF8_ENCODING, errors=UTF8_DECODE_ERRORS
55
+ )
56
+
57
+
58
+ def _launch_process(
59
+ process_factory: Callable[..., WorkerProcess],
60
+ all_arguments: list[str],
61
+ cwd: Path,
62
+ all_child_environment_variables: dict[str, str],
63
+ ) -> WorkerProcess | None:
64
+ try:
65
+ return process_factory(
66
+ all_arguments,
67
+ cwd=str(cwd),
68
+ env=all_child_environment_variables,
69
+ stdin=subprocess.PIPE,
70
+ stdout=subprocess.PIPE,
71
+ stderr=subprocess.PIPE,
72
+ text=True,
73
+ encoding=UTF8_ENCODING,
74
+ errors=UTF8_DECODE_ERRORS,
75
+ start_new_session=_should_start_new_session(),
76
+ creationflags=hidden_window_creation_flags(),
77
+ )
78
+ except OSError:
79
+ return None
80
+
81
+
82
+ def _drain_attempt_outcome(process: WorkerProcess) -> tuple[bool, str]:
83
+ try:
84
+ captured_stdout, _ = process.communicate(timeout=DRAIN_GRACE_TIMEOUT_SECONDS)
85
+ except subprocess.TimeoutExpired as timeout_error:
86
+ return False, _decoded_text(timeout_error.output)
87
+ return True, _decoded_text(captured_stdout)
88
+
89
+
90
+ def _drain_after_termination(
91
+ process: WorkerProcess,
92
+ process_terminator: Callable[[WorkerProcess], None],
93
+ initial_stdout_text: str,
94
+ ) -> str:
95
+ latest_stdout_text = initial_stdout_text
96
+ for _ in range(DRAIN_ATTEMPT_LIMIT):
97
+ process_terminator(process)
98
+ did_drain, attempt_stdout_text = _drain_attempt_outcome(process)
99
+ if attempt_stdout_text:
100
+ latest_stdout_text = attempt_stdout_text
101
+ if did_drain:
102
+ return latest_stdout_text
103
+ return latest_stdout_text
104
+
105
+
106
+ class ProcessControls(Protocol):
107
+ """The process factory, terminator, and clock a worker run uses."""
108
+
109
+ process_factory: Callable[..., WorkerProcess]
110
+ process_terminator: Callable[[WorkerProcess], None]
111
+ monotonic_clock: Callable[[], float]
112
+
113
+
114
+ def _await_completion(
115
+ process: WorkerProcess | None,
116
+ prompt_text: str,
117
+ timeout_minutes: int,
118
+ process_terminator: Callable[[WorkerProcess], None],
119
+ ) -> tuple[int, str]:
120
+ if process is None:
121
+ return LAUNCH_FAILURE_EXIT_CODE, ""
122
+ try:
123
+ captured_stdout, _ = process.communicate(
124
+ input=prompt_text,
125
+ timeout=timeout_minutes * SECONDS_PER_MINUTE,
126
+ )
127
+ except subprocess.TimeoutExpired as timeout_error:
128
+ stdout_text = _drain_after_termination(
129
+ process, process_terminator, _decoded_text(timeout_error.output)
130
+ )
131
+ return TIMEOUT_EXIT_CODE, stdout_text
132
+ process_exit_code = (
133
+ process.returncode
134
+ if process.returncode is not None
135
+ else LAUNCH_FAILURE_EXIT_CODE
136
+ )
137
+ return process_exit_code, _decoded_text(captured_stdout)
138
+
139
+
140
+ def invoke_worker(
141
+ *,
142
+ all_arguments: list[str],
143
+ cwd: Path,
144
+ all_child_environment_variables: dict[str, str],
145
+ prompt_text: str,
146
+ timeout_minutes: int,
147
+ controls: ProcessControls,
148
+ ) -> tuple[int, float, str]:
149
+ """Launch the worker process and return its exit code, duration, and stdout.
150
+
151
+ Args:
152
+ all_arguments: The full command line, executable first.
153
+ cwd: Working directory the child process runs in.
154
+ all_child_environment_variables: Environment passed to the child.
155
+ prompt_text: The prompt piped to the child on stdin.
156
+ timeout_minutes: Minutes to wait before the child is timed out.
157
+ controls: The process factory, terminator, and clock.
158
+
159
+ Returns:
160
+ The exit code, the duration in seconds, and the decoded stdout.
161
+ """
162
+ process_start = controls.monotonic_clock()
163
+ process = _launch_process(
164
+ controls.process_factory, all_arguments, cwd, all_child_environment_variables
165
+ )
166
+ exit_code, stdout_text = _await_completion(
167
+ process, prompt_text, timeout_minutes, controls.process_terminator
168
+ )
169
+ return exit_code, controls.monotonic_clock() - process_start, stdout_text
@@ -0,0 +1,170 @@
1
+ """Build and write a headless Claude worker's JSON report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from dev_env_scripts_constants.claude_account_worker_constants import (
10
+ DURATION_DECIMAL_PLACES,
11
+ JSON_IS_ERROR_KEY,
12
+ JSON_RESULT_KEY,
13
+ REPORT_ACCOUNT_KEY,
14
+ REPORT_DURATION_SECONDS_KEY,
15
+ REPORT_EXIT_CODE_KEY,
16
+ REPORT_INDENT,
17
+ REPORT_IS_ERROR_KEY,
18
+ REPORT_REASON_KEY,
19
+ REPORT_RESULT_KEY,
20
+ STDOUT_TAIL_CHARACTER_LIMIT,
21
+ SUMMARY_LINE_TEMPLATE,
22
+ UTF8_ENCODING,
23
+ WAIT_DURATION_SECONDS,
24
+ WAIT_EXIT_CODE,
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class WorkerReport:
30
+ account: str
31
+ reason: str
32
+ exit_code: int
33
+ duration_seconds: float
34
+ payload: object
35
+ is_error: bool
36
+
37
+
38
+ def _bounded_stdout_tail(stdout_text: str) -> str:
39
+ return stdout_text[-STDOUT_TAIL_CHARACTER_LIMIT:]
40
+
41
+
42
+ def _extract_payload(stdout_text: str) -> tuple[object, bool]:
43
+ try:
44
+ parsed_stdout = json.loads(stdout_text)
45
+ except json.JSONDecodeError:
46
+ return _bounded_stdout_tail(stdout_text), True
47
+ if not isinstance(parsed_stdout, dict) or JSON_RESULT_KEY not in parsed_stdout:
48
+ return None, True
49
+ return parsed_stdout[JSON_RESULT_KEY], parsed_stdout.get(JSON_IS_ERROR_KEY) is True
50
+
51
+
52
+ def wait_report(account: str, reason: str) -> WorkerReport:
53
+ """Build the report for an account decision that says to wait.
54
+
55
+ Args:
56
+ account: The chosen account slot.
57
+ reason: Why the picker chose to wait.
58
+
59
+ Returns:
60
+ A report carrying the wait exit code and no payload.
61
+ """
62
+ return WorkerReport(
63
+ account=account,
64
+ reason=reason,
65
+ exit_code=WAIT_EXIT_CODE,
66
+ duration_seconds=WAIT_DURATION_SECONDS,
67
+ payload=None,
68
+ is_error=True,
69
+ )
70
+
71
+
72
+ def make_report(
73
+ account: str,
74
+ reason: str,
75
+ *,
76
+ exit_code: int,
77
+ duration_seconds: float,
78
+ stdout_text: str,
79
+ ) -> WorkerReport:
80
+ """Build the report for a worker that ran, from its captured stdout.
81
+
82
+ Args:
83
+ account: The chosen account slot.
84
+ reason: Why the picker chose that account.
85
+ exit_code: The worker process's exit code.
86
+ duration_seconds: How long the worker ran.
87
+ stdout_text: The worker's captured stdout text.
88
+
89
+ Returns:
90
+ A report carrying the parsed payload, or the stdout tail when the
91
+ payload does not parse as the expected JSON shape.
92
+ """
93
+ payload, has_payload_error = _extract_payload(stdout_text)
94
+ return WorkerReport(
95
+ account=account,
96
+ reason=reason,
97
+ exit_code=exit_code,
98
+ duration_seconds=round(duration_seconds, DURATION_DECIMAL_PLACES),
99
+ payload=payload,
100
+ is_error=(exit_code != 0 or has_payload_error),
101
+ )
102
+
103
+
104
+ def pre_launch_failure_report(
105
+ account: str, reason: str, exit_code: int
106
+ ) -> WorkerReport:
107
+ """Build the report for a failure that happened before the worker launched.
108
+
109
+ Args:
110
+ account: The chosen account slot.
111
+ reason: Why the picker chose that account.
112
+ exit_code: The failure's exit code.
113
+
114
+ Returns:
115
+ A report with no payload and a zero duration.
116
+ """
117
+ return make_report(
118
+ account,
119
+ reason,
120
+ exit_code=exit_code,
121
+ duration_seconds=WAIT_DURATION_SECONDS,
122
+ stdout_text="",
123
+ )
124
+
125
+
126
+ def write_report(report_file: Path, report: WorkerReport) -> None:
127
+ """Write a worker report to disk as indented JSON.
128
+
129
+ Args:
130
+ report_file: Where the JSON report is written.
131
+ report: The report to serialize.
132
+ """
133
+ report_file.parent.mkdir(parents=True, exist_ok=True)
134
+ report_payload = {
135
+ REPORT_ACCOUNT_KEY: report.account,
136
+ REPORT_REASON_KEY: report.reason,
137
+ REPORT_EXIT_CODE_KEY: report.exit_code,
138
+ REPORT_DURATION_SECONDS_KEY: report.duration_seconds,
139
+ REPORT_RESULT_KEY: report.payload,
140
+ REPORT_IS_ERROR_KEY: report.is_error,
141
+ }
142
+ report_file.write_text(
143
+ json.dumps(report_payload, ensure_ascii=False, indent=REPORT_INDENT) + "\n",
144
+ encoding=UTF8_ENCODING,
145
+ )
146
+
147
+
148
+ def _print_summary(report: WorkerReport, report_file: Path) -> None:
149
+ print(
150
+ SUMMARY_LINE_TEMPLATE.format(
151
+ account=report.account,
152
+ exit_code=report.exit_code,
153
+ report_file=report_file,
154
+ )
155
+ )
156
+
157
+
158
+ def finalize_report(report_file: Path, report: WorkerReport) -> int:
159
+ """Write a worker report to disk and print its one-line summary.
160
+
161
+ Args:
162
+ report_file: Where the JSON report is written.
163
+ report: The report to write and summarize.
164
+
165
+ Returns:
166
+ The report's exit code.
167
+ """
168
+ write_report(report_file, report)
169
+ _print_summary(report, report_file)
170
+ return report.exit_code