claude-dev-env 8.43.4 → 8.43.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/advisor/advisor-protocol.md +2 -2
- package/_shared/advisor/reference/cli-chain.md +9 -12
- package/_shared/advisor/reference/lifecycle.md +1 -1
- package/_shared/advisor/reference/third-party-bind.md +3 -3
- package/bin/install.mjs +39 -3
- package/bin/install.shared-settings.test.mjs +46 -0
- package/bin/install.test.mjs +1 -1
- package/docs/second-claude-account.md +11 -13
- package/package.json +1 -1
- package/scripts/_code_review_test_support.py +29 -39
- package/scripts/claude_account_worker.py +46 -297
- package/scripts/claude_account_worker_process.py +18 -154
- package/scripts/claude_account_worker_report.py +8 -4
- package/scripts/claude_chain_usage.py +7 -271
- package/scripts/codec_forwarding_test_support.py +11 -65
- package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +6 -24
- package/scripts/invoke_code_review.py +34 -56
- package/scripts/policy_lint/config/approved_test_pairs.py +1 -0
- package/scripts/resolve_worker_spawn.py +40 -72
- package/scripts/test_account_broker_guard.py +0 -4
- package/scripts/test_claude_account_worker.py +105 -350
- package/scripts/test_claude_chain_usage.py +50 -510
- package/scripts/test_dispatcher_profile_import.py +5 -9
- package/scripts/test_invoke_code_review.py +7 -6
- package/scripts/test_invoke_code_review_chain.py +26 -0
- package/scripts/test_invoke_code_review_cli.py +8 -8
- package/scripts/test_invoke_code_review_codec.py +3 -11
- package/scripts/test_invoke_code_review_contract.py +5 -1
- package/scripts/test_resolve_worker_spawn.py +118 -265
- package/scripts/test_resolve_worker_spawn_codec.py +9 -21
- package/scripts/tests/test_code_review_constants.py +2 -2
- package/scripts/tests/test_policy_lint_rules_pairing.py +13 -0
- package/scripts/claude_account_choice.py +0 -422
- package/scripts/claude_chain_runner.py +0 -1289
- package/scripts/test_claude_account_choice.py +0 -355
- package/scripts/test_claude_chain_runner.py +0 -1839
|
@@ -1,54 +1,24 @@
|
|
|
1
|
-
"""Run a headless Claude worker
|
|
1
|
+
"""Run a headless Claude worker through the account broker."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
-
import importlib
|
|
7
|
-
import json
|
|
8
|
-
import os
|
|
9
|
-
import shutil
|
|
10
|
-
import subprocess
|
|
11
6
|
import sys
|
|
12
|
-
import time
|
|
13
|
-
from collections.abc import Callable, Mapping
|
|
14
|
-
from dataclasses import dataclass
|
|
15
|
-
from datetime import datetime
|
|
16
7
|
from pathlib import Path
|
|
17
8
|
|
|
18
|
-
from
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
read_account_meters,
|
|
23
|
-
read_extra_accounts,
|
|
24
|
-
)
|
|
25
|
-
from claude_account_profile import default_profile_home
|
|
26
|
-
from claude_account_worker_process import WorkerProcess, invoke_worker
|
|
27
|
-
from claude_account_worker_report import (
|
|
28
|
-
WorkerReport,
|
|
29
|
-
finalize_report,
|
|
30
|
-
make_report,
|
|
31
|
-
pre_launch_failure_report,
|
|
32
|
-
wait_report,
|
|
33
|
-
)
|
|
9
|
+
from account_broker import run_job
|
|
10
|
+
from claude_account_worker_process import invoke_worker
|
|
11
|
+
from claude_account_worker_report import finalize_report, make_report, pre_launch_failure_report, wait_report
|
|
12
|
+
from dev_env_scripts_constants.account_broker_constants import BrokerConfigurationError, JobOutcome
|
|
34
13
|
from dev_env_scripts_constants.claude_account_worker_constants import (
|
|
35
|
-
CHOICE_MAIN,
|
|
36
|
-
CHOICE_WAIT,
|
|
37
14
|
CLAUDE_BINARY_NAME,
|
|
38
|
-
CLAUDE_CONFIG_DIR_ENV_VAR,
|
|
39
15
|
CLI_DESCRIPTION,
|
|
40
|
-
|
|
16
|
+
CONFIGURATION_FAILURE_EXIT_CODE,
|
|
41
17
|
CWD_FLAG,
|
|
42
18
|
DEFAULT_PERMISSION_MODE,
|
|
43
19
|
DEFAULT_TIMEOUT_MINUTES,
|
|
44
|
-
EXTRA_PROFILES_FILE_NAME,
|
|
45
20
|
INVALID_TIMEOUT_MESSAGE,
|
|
46
|
-
JSON_ACCOUNT_KEY,
|
|
47
|
-
JSON_CONFIG_DIRECTORY_KEY,
|
|
48
|
-
JSON_REASON_KEY,
|
|
49
21
|
LAUNCH_FAILURE_EXIT_CODE,
|
|
50
|
-
MAIN_CLAUDE_HOME_DIRECTORY_NAME,
|
|
51
|
-
MISSING_BINARY_EXIT_CODE,
|
|
52
22
|
MINIMUM_TIMEOUT_MINUTES,
|
|
53
23
|
MODEL_FLAG,
|
|
54
24
|
OUTPUT_FORMAT_FLAG,
|
|
@@ -60,106 +30,8 @@ from dev_env_scripts_constants.claude_account_worker_constants import (
|
|
|
60
30
|
TIMEOUT_MINUTES_FLAG,
|
|
61
31
|
UTF8_ENCODING,
|
|
62
32
|
)
|
|
63
|
-
from shared_tree_paths import resolve_shared_process_tree_scripts_directory
|
|
64
|
-
|
|
65
|
-
_process_tree_scripts_directory = resolve_shared_process_tree_scripts_directory(
|
|
66
|
-
__file__, all_environment=os.environ
|
|
67
|
-
)
|
|
68
|
-
if str(_process_tree_scripts_directory) not in sys.path:
|
|
69
|
-
sys.path.insert(0, str(_process_tree_scripts_directory))
|
|
70
|
-
|
|
71
|
-
_process_tree_kill = importlib.import_module("process_tree_kill")
|
|
72
|
-
_terminate_process_tree = _process_tree_kill.terminate_process_tree
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
@dataclass(frozen=True)
|
|
76
|
-
class AccountSelection:
|
|
77
|
-
account: str
|
|
78
|
-
config_dir: Path | None
|
|
79
|
-
reason: str
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
@dataclass(frozen=True)
|
|
83
|
-
class WorkerDependencies:
|
|
84
|
-
process_factory: Callable[..., WorkerProcess]
|
|
85
|
-
all_parent_environment_variables: Mapping[str, str]
|
|
86
|
-
binary_resolver: Callable[[str], str | None]
|
|
87
|
-
process_terminator: Callable[[WorkerProcess], None]
|
|
88
|
-
monotonic_clock: Callable[[], float]
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def _default_dependencies() -> WorkerDependencies:
|
|
92
|
-
return WorkerDependencies(
|
|
93
|
-
process_factory=subprocess.Popen,
|
|
94
|
-
all_parent_environment_variables=os.environ,
|
|
95
|
-
binary_resolver=shutil.which,
|
|
96
|
-
process_terminator=_terminate_process_tree,
|
|
97
|
-
monotonic_clock=time.monotonic,
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _selection_from_payload(all_selection_payload: Mapping[str, str | None]) -> AccountSelection:
|
|
102
|
-
account = all_selection_payload[JSON_ACCOUNT_KEY]
|
|
103
|
-
reason = all_selection_payload[JSON_REASON_KEY]
|
|
104
|
-
config_dir_text = all_selection_payload[JSON_CONFIG_DIRECTORY_KEY]
|
|
105
|
-
if account is None or reason is None:
|
|
106
|
-
raise ValueError("account picker returned an incomplete decision")
|
|
107
|
-
return AccountSelection(
|
|
108
|
-
account=account,
|
|
109
|
-
config_dir=Path(config_dir_text) if config_dir_text is not None else None,
|
|
110
|
-
reason=reason,
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def extra_config_directories(main_config_dir: Path) -> tuple[Path, ...]:
|
|
115
|
-
"""Read the ordered local profile list or use the legacy default.
|
|
116
|
-
|
|
117
|
-
Args:
|
|
118
|
-
main_config_dir: The main Claude home that may hold the list file.
|
|
119
|
-
|
|
120
|
-
Returns:
|
|
121
|
-
The extra profile homes in the order they are tried.
|
|
122
33
|
|
|
123
|
-
|
|
124
|
-
ValueError: When the list is empty, holds a non-name, or repeats a name.
|
|
125
|
-
"""
|
|
126
|
-
profiles_file = main_config_dir / EXTRA_PROFILES_FILE_NAME
|
|
127
|
-
if not profiles_file.exists():
|
|
128
|
-
return (default_profile_home(),)
|
|
129
|
-
profile_names = json.loads(profiles_file.read_text(encoding=UTF8_ENCODING))
|
|
130
|
-
if (
|
|
131
|
-
not isinstance(profile_names, list)
|
|
132
|
-
or not profile_names
|
|
133
|
-
or any(not isinstance(name, str) for name in profile_names)
|
|
134
|
-
):
|
|
135
|
-
raise ValueError("extra profile list must be a nonempty JSON list of names")
|
|
136
|
-
if len({name.casefold() for name in profile_names}) != len(profile_names):
|
|
137
|
-
raise ValueError("extra profile names must be distinct")
|
|
138
|
-
return tuple(default_profile_home(name) for name in profile_names)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def select_account() -> AccountSelection:
|
|
142
|
-
"""Pick the account this worker runs on and its Claude home directory.
|
|
143
|
-
|
|
144
|
-
Returns:
|
|
145
|
-
The chosen account, its Claude config directory, and the reason.
|
|
146
|
-
|
|
147
|
-
Raises:
|
|
148
|
-
ValueError: When the profile list or account decision is invalid.
|
|
149
|
-
"""
|
|
150
|
-
main_config_dir = Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME
|
|
151
|
-
extra_accounts = read_extra_accounts(extra_config_directories(main_config_dir))
|
|
152
|
-
main_meters = read_account_meters(main_config_dir / CREDENTIALS_FILE_NAME)
|
|
153
|
-
decision = choose_account_from_extras(
|
|
154
|
-
main_meters=main_meters,
|
|
155
|
-
all_extra_accounts=extra_accounts,
|
|
156
|
-
now=datetime.now().astimezone(),
|
|
157
|
-
)
|
|
158
|
-
selected_config_dir = config_directory_for_decision(
|
|
159
|
-
decision, main_config_dir=main_config_dir, all_extra_accounts=extra_accounts
|
|
160
|
-
)
|
|
161
|
-
payload = decision_payload(decision, config_directory=selected_config_dir)
|
|
162
|
-
return _selection_from_payload(payload)
|
|
34
|
+
worker_job_runner = run_job
|
|
163
35
|
|
|
164
36
|
|
|
165
37
|
def _positive_timeout_minutes(candidate_text: str) -> int:
|
|
@@ -170,52 +42,20 @@ def _positive_timeout_minutes(candidate_text: str) -> int:
|
|
|
170
42
|
|
|
171
43
|
|
|
172
44
|
def build_argument_parser() -> argparse.ArgumentParser:
|
|
173
|
-
"""Build the CLI parser for one headless worker invocation.
|
|
174
|
-
|
|
175
|
-
Returns:
|
|
176
|
-
The configured argument parser.
|
|
177
|
-
"""
|
|
45
|
+
"""Build the CLI parser for one headless worker invocation."""
|
|
178
46
|
parser = argparse.ArgumentParser(description=CLI_DESCRIPTION)
|
|
179
47
|
parser.add_argument(PROMPT_FILE_FLAG, type=Path, required=True)
|
|
180
48
|
parser.add_argument(CWD_FLAG, type=Path, default=Path.cwd())
|
|
181
49
|
parser.add_argument(REPORT_FILE_FLAG, type=Path, required=True)
|
|
182
50
|
parser.add_argument(MODEL_FLAG)
|
|
183
|
-
parser.add_argument(
|
|
184
|
-
|
|
185
|
-
default=DEFAULT_PERMISSION_MODE,
|
|
186
|
-
)
|
|
187
|
-
parser.add_argument(
|
|
188
|
-
TIMEOUT_MINUTES_FLAG,
|
|
189
|
-
type=_positive_timeout_minutes,
|
|
190
|
-
default=DEFAULT_TIMEOUT_MINUTES,
|
|
191
|
-
)
|
|
51
|
+
parser.add_argument(PERMISSION_MODE_FLAG, default=DEFAULT_PERMISSION_MODE)
|
|
52
|
+
parser.add_argument(TIMEOUT_MINUTES_FLAG, type=_positive_timeout_minutes, default=DEFAULT_TIMEOUT_MINUTES)
|
|
192
53
|
return parser
|
|
193
54
|
|
|
194
55
|
|
|
195
|
-
def
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
) -> dict[str, str]:
|
|
199
|
-
all_child_environment_variables = dict(all_environment_variables)
|
|
200
|
-
if selection.account == CHOICE_MAIN:
|
|
201
|
-
all_child_environment_variables.pop(CLAUDE_CONFIG_DIR_ENV_VAR, None)
|
|
202
|
-
return all_child_environment_variables
|
|
203
|
-
if selection.account == CHOICE_WAIT:
|
|
204
|
-
raise ValueError("wait selection cannot launch a worker")
|
|
205
|
-
if selection.config_dir is None:
|
|
206
|
-
raise ValueError("extra account selection needs a config directory")
|
|
207
|
-
all_child_environment_variables[CLAUDE_CONFIG_DIR_ENV_VAR] = str(selection.config_dir)
|
|
208
|
-
return all_child_environment_variables
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def _invocation(
|
|
212
|
-
executable: str,
|
|
213
|
-
*,
|
|
214
|
-
model: str | None,
|
|
215
|
-
permission_mode: str,
|
|
216
|
-
) -> list[str]:
|
|
217
|
-
all_arguments = [
|
|
218
|
-
executable,
|
|
56
|
+
def _invocation(*, model: str | None, permission_mode: str) -> list[str]:
|
|
57
|
+
arguments = [
|
|
58
|
+
CLAUDE_BINARY_NAME,
|
|
219
59
|
SINGLE_PROMPT_FLAG,
|
|
220
60
|
OUTPUT_FORMAT_FLAG,
|
|
221
61
|
OUTPUT_FORMAT_JSON,
|
|
@@ -223,106 +63,14 @@ def _invocation(
|
|
|
223
63
|
permission_mode,
|
|
224
64
|
]
|
|
225
65
|
if model is not None:
|
|
226
|
-
|
|
227
|
-
return
|
|
66
|
+
arguments.extend([MODEL_FLAG, model])
|
|
67
|
+
return arguments
|
|
228
68
|
|
|
229
69
|
|
|
230
|
-
def
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
) -> tuple[dict[str, str], str, str] | WorkerReport:
|
|
235
|
-
try:
|
|
236
|
-
all_child_environment_variables = _child_environment(
|
|
237
|
-
selection, dependencies.all_parent_environment_variables
|
|
238
|
-
)
|
|
239
|
-
prompt_text = prompt_file.read_text(encoding=UTF8_ENCODING)
|
|
240
|
-
except (OSError, ValueError):
|
|
241
|
-
return pre_launch_failure_report(
|
|
242
|
-
selection.account, selection.reason, LAUNCH_FAILURE_EXIT_CODE
|
|
243
|
-
)
|
|
244
|
-
claude_binary = dependencies.binary_resolver(CLAUDE_BINARY_NAME)
|
|
245
|
-
if claude_binary is None:
|
|
246
|
-
return pre_launch_failure_report(
|
|
247
|
-
selection.account, selection.reason, MISSING_BINARY_EXIT_CODE
|
|
248
|
-
)
|
|
249
|
-
return all_child_environment_variables, prompt_text, claude_binary
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def _invoked_report(
|
|
253
|
-
selection: AccountSelection,
|
|
254
|
-
*,
|
|
255
|
-
all_arguments: list[str],
|
|
256
|
-
cwd: Path,
|
|
257
|
-
all_child_environment_variables: dict[str, str],
|
|
258
|
-
prompt_text: str,
|
|
259
|
-
timeout_minutes: int,
|
|
260
|
-
dependencies: WorkerDependencies,
|
|
261
|
-
) -> WorkerReport:
|
|
262
|
-
exit_code, duration_seconds, stdout_text = invoke_worker(
|
|
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
|
-
controls=dependencies,
|
|
269
|
-
)
|
|
270
|
-
return make_report(
|
|
271
|
-
selection.account,
|
|
272
|
-
selection.reason,
|
|
273
|
-
exit_code=exit_code,
|
|
274
|
-
duration_seconds=duration_seconds,
|
|
275
|
-
stdout_text=stdout_text,
|
|
276
|
-
)
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def _worker_report(
|
|
280
|
-
selection: AccountSelection,
|
|
281
|
-
*,
|
|
282
|
-
prompt_file: Path,
|
|
283
|
-
cwd: Path,
|
|
284
|
-
model: str | None,
|
|
285
|
-
permission_mode: str,
|
|
286
|
-
timeout_minutes: int,
|
|
287
|
-
dependencies: WorkerDependencies,
|
|
288
|
-
) -> WorkerReport:
|
|
289
|
-
prepared = _prepared_launch(selection, prompt_file, dependencies)
|
|
290
|
-
if isinstance(prepared, WorkerReport):
|
|
291
|
-
return prepared
|
|
292
|
-
all_child_environment_variables, prompt_text, claude_binary = prepared
|
|
293
|
-
all_arguments = _invocation(claude_binary, model=model, permission_mode=permission_mode)
|
|
294
|
-
return _invoked_report(
|
|
295
|
-
selection,
|
|
296
|
-
all_arguments=all_arguments,
|
|
297
|
-
cwd=cwd,
|
|
298
|
-
all_child_environment_variables=all_child_environment_variables,
|
|
299
|
-
prompt_text=prompt_text,
|
|
300
|
-
timeout_minutes=timeout_minutes,
|
|
301
|
-
dependencies=dependencies,
|
|
302
|
-
)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def _report_for_selection(
|
|
306
|
-
selection: AccountSelection,
|
|
307
|
-
*,
|
|
308
|
-
prompt_file: Path,
|
|
309
|
-
cwd: Path,
|
|
310
|
-
model: str | None,
|
|
311
|
-
permission_mode: str,
|
|
312
|
-
timeout_minutes: int,
|
|
313
|
-
dependencies: WorkerDependencies,
|
|
314
|
-
) -> WorkerReport:
|
|
315
|
-
if selection.account == CHOICE_WAIT:
|
|
316
|
-
return wait_report(selection.account, selection.reason)
|
|
317
|
-
return _worker_report(
|
|
318
|
-
selection,
|
|
319
|
-
prompt_file=prompt_file,
|
|
320
|
-
cwd=cwd,
|
|
321
|
-
model=model,
|
|
322
|
-
permission_mode=permission_mode,
|
|
323
|
-
timeout_minutes=timeout_minutes,
|
|
324
|
-
dependencies=dependencies,
|
|
325
|
-
)
|
|
70
|
+
def _wait_reason(outcome: JobOutcome) -> str:
|
|
71
|
+
if outcome.wait_reset_at is None:
|
|
72
|
+
return "no account has room"
|
|
73
|
+
return f"no account has room; next reset at {outcome.wait_reset_at.isoformat()}"
|
|
326
74
|
|
|
327
75
|
|
|
328
76
|
def run_worker(
|
|
@@ -333,36 +81,39 @@ def run_worker(
|
|
|
333
81
|
model: str | None,
|
|
334
82
|
permission_mode: str,
|
|
335
83
|
timeout_minutes: int,
|
|
336
|
-
decision_selector: Callable[[], AccountSelection],
|
|
337
|
-
dependencies: WorkerDependencies,
|
|
338
84
|
) -> int:
|
|
339
|
-
"""
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
85
|
+
"""Run one worker and write its structured report."""
|
|
86
|
+
try:
|
|
87
|
+
prompt_text = prompt_file.read_text(encoding=UTF8_ENCODING)
|
|
88
|
+
except (OSError, UnicodeError):
|
|
89
|
+
report = pre_launch_failure_report("none", "prompt file is unreadable", LAUNCH_FAILURE_EXIT_CODE)
|
|
90
|
+
return finalize_report(report_file, report)
|
|
91
|
+
try:
|
|
92
|
+
outcome, duration_seconds = invoke_worker(
|
|
93
|
+
all_arguments=_invocation(model=model, permission_mode=permission_mode),
|
|
94
|
+
cwd=cwd,
|
|
95
|
+
prompt_text=prompt_text,
|
|
96
|
+
timeout_minutes=timeout_minutes,
|
|
97
|
+
runner=worker_job_runner,
|
|
98
|
+
)
|
|
99
|
+
except BrokerConfigurationError as error:
|
|
100
|
+
report = pre_launch_failure_report("none", str(error), CONFIGURATION_FAILURE_EXIT_CODE)
|
|
101
|
+
return finalize_report(report_file, report)
|
|
102
|
+
if outcome.status in {"wait", "exhausted"}:
|
|
103
|
+
report = wait_report("wait", _wait_reason(outcome), outcome.wait_reset_at)
|
|
104
|
+
return finalize_report(report_file, report)
|
|
105
|
+
report = make_report(
|
|
106
|
+
outcome.account_name or "none",
|
|
107
|
+
outcome.status,
|
|
108
|
+
exit_code=outcome.returncode,
|
|
109
|
+
duration_seconds=duration_seconds,
|
|
110
|
+
stdout_text=outcome.stdout,
|
|
356
111
|
)
|
|
357
112
|
return finalize_report(report_file, report)
|
|
358
113
|
|
|
359
114
|
|
|
360
115
|
def main() -> int:
|
|
361
|
-
"""Parse CLI arguments and run one headless worker.
|
|
362
|
-
|
|
363
|
-
Returns:
|
|
364
|
-
The worker's exit code.
|
|
365
|
-
"""
|
|
116
|
+
"""Parse CLI arguments and run one headless worker."""
|
|
366
117
|
arguments = build_argument_parser().parse_args()
|
|
367
118
|
return run_worker(
|
|
368
119
|
prompt_file=arguments.prompt_file,
|
|
@@ -371,8 +122,6 @@ def main() -> int:
|
|
|
371
122
|
model=arguments.model,
|
|
372
123
|
permission_mode=arguments.permission_mode,
|
|
373
124
|
timeout_minutes=arguments.timeout_minutes,
|
|
374
|
-
decision_selector=select_account,
|
|
375
|
-
dependencies=_default_dependencies(),
|
|
376
125
|
)
|
|
377
126
|
|
|
378
127
|
|
|
@@ -1,169 +1,33 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Run a headless Claude worker through the account broker."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
import os
|
|
7
|
-
import subprocess
|
|
8
|
-
import sys
|
|
5
|
+
import time
|
|
9
6
|
from collections.abc import Callable
|
|
10
7
|
from pathlib import Path
|
|
11
|
-
from typing import Protocol
|
|
12
8
|
|
|
13
|
-
from dev_env_scripts_constants.
|
|
14
|
-
|
|
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)
|
|
9
|
+
from dev_env_scripts_constants.account_broker_constants import JobOutcome, Product
|
|
10
|
+
from dev_env_scripts_constants.claude_account_worker_constants import SECONDS_PER_MINUTE
|
|
138
11
|
|
|
139
12
|
|
|
140
13
|
def invoke_worker(
|
|
141
14
|
*,
|
|
142
15
|
all_arguments: list[str],
|
|
143
16
|
cwd: Path,
|
|
144
|
-
all_child_environment_variables: dict[str, str],
|
|
145
17
|
prompt_text: str,
|
|
146
18
|
timeout_minutes: int,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
19
|
+
runner: Callable[..., JobOutcome],
|
|
20
|
+
monotonic_clock: Callable[[], float] = time.monotonic,
|
|
21
|
+
) -> tuple[JobOutcome, float]:
|
|
22
|
+
"""Run one broker job and measure its elapsed time."""
|
|
23
|
+
started_at = monotonic_clock()
|
|
24
|
+
outcome = runner(
|
|
25
|
+
Product.CLAUDE,
|
|
26
|
+
all_arguments,
|
|
27
|
+
timeout_seconds=timeout_minutes * SECONDS_PER_MINUTE,
|
|
28
|
+
stdin_text=prompt_text,
|
|
29
|
+
cwd=cwd,
|
|
30
|
+
encoding="utf-8",
|
|
31
|
+
errors="replace",
|
|
168
32
|
)
|
|
169
|
-
return
|
|
33
|
+
return outcome, monotonic_clock() - started_at
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
from dev_env_scripts_constants.claude_account_worker_constants import (
|
|
@@ -33,6 +34,7 @@ class WorkerReport:
|
|
|
33
34
|
duration_seconds: float
|
|
34
35
|
payload: object
|
|
35
36
|
is_error: bool
|
|
37
|
+
wait_reset_at: datetime | None = None
|
|
36
38
|
|
|
37
39
|
|
|
38
40
|
def _bounded_stdout_tail(stdout_text: str) -> str:
|
|
@@ -49,12 +51,12 @@ def _extract_payload(stdout_text: str) -> tuple[object, bool]:
|
|
|
49
51
|
return parsed_stdout[JSON_RESULT_KEY], parsed_stdout.get(JSON_IS_ERROR_KEY) is True
|
|
50
52
|
|
|
51
53
|
|
|
52
|
-
def wait_report(account: str, reason: str) -> WorkerReport:
|
|
54
|
+
def wait_report(account: str, reason: str, wait_reset_at: datetime | None) -> WorkerReport:
|
|
53
55
|
"""Build the report for an account decision that says to wait.
|
|
54
56
|
|
|
55
57
|
Args:
|
|
56
58
|
account: The chosen account slot.
|
|
57
|
-
reason: Why the
|
|
59
|
+
reason: Why the broker returned a wait decision.
|
|
58
60
|
|
|
59
61
|
Returns:
|
|
60
62
|
A report carrying the wait exit code and no payload.
|
|
@@ -65,7 +67,8 @@ def wait_report(account: str, reason: str) -> WorkerReport:
|
|
|
65
67
|
exit_code=WAIT_EXIT_CODE,
|
|
66
68
|
duration_seconds=WAIT_DURATION_SECONDS,
|
|
67
69
|
payload=None,
|
|
68
|
-
is_error=
|
|
70
|
+
is_error=False,
|
|
71
|
+
wait_reset_at=wait_reset_at,
|
|
69
72
|
)
|
|
70
73
|
|
|
71
74
|
|
|
@@ -81,7 +84,7 @@ def make_report(
|
|
|
81
84
|
|
|
82
85
|
Args:
|
|
83
86
|
account: The chosen account slot.
|
|
84
|
-
reason:
|
|
87
|
+
reason: Broker status for the account.
|
|
85
88
|
exit_code: The worker process's exit code.
|
|
86
89
|
duration_seconds: How long the worker ran.
|
|
87
90
|
stdout_text: The worker's captured stdout text.
|
|
@@ -138,6 +141,7 @@ def write_report(report_file: Path, report: WorkerReport) -> None:
|
|
|
138
141
|
REPORT_DURATION_SECONDS_KEY: report.duration_seconds,
|
|
139
142
|
REPORT_RESULT_KEY: report.payload,
|
|
140
143
|
REPORT_IS_ERROR_KEY: report.is_error,
|
|
144
|
+
"wait_reset_at": report.wait_reset_at.isoformat() if report.wait_reset_at else None,
|
|
141
145
|
}
|
|
142
146
|
report_file.write_text(
|
|
143
147
|
json.dumps(report_payload, ensure_ascii=False, indent=REPORT_INDENT) + "\n",
|