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.
Files changed (36) hide show
  1. package/_shared/advisor/advisor-protocol.md +2 -2
  2. package/_shared/advisor/reference/cli-chain.md +9 -12
  3. package/_shared/advisor/reference/lifecycle.md +1 -1
  4. package/_shared/advisor/reference/third-party-bind.md +3 -3
  5. package/bin/install.mjs +39 -3
  6. package/bin/install.shared-settings.test.mjs +46 -0
  7. package/bin/install.test.mjs +1 -1
  8. package/docs/second-claude-account.md +11 -13
  9. package/package.json +1 -1
  10. package/scripts/_code_review_test_support.py +29 -39
  11. package/scripts/claude_account_worker.py +46 -297
  12. package/scripts/claude_account_worker_process.py +18 -154
  13. package/scripts/claude_account_worker_report.py +8 -4
  14. package/scripts/claude_chain_usage.py +7 -271
  15. package/scripts/codec_forwarding_test_support.py +11 -65
  16. package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +6 -24
  17. package/scripts/invoke_code_review.py +34 -56
  18. package/scripts/policy_lint/config/approved_test_pairs.py +1 -0
  19. package/scripts/resolve_worker_spawn.py +40 -72
  20. package/scripts/test_account_broker_guard.py +0 -4
  21. package/scripts/test_claude_account_worker.py +105 -350
  22. package/scripts/test_claude_chain_usage.py +50 -510
  23. package/scripts/test_dispatcher_profile_import.py +5 -9
  24. package/scripts/test_invoke_code_review.py +7 -6
  25. package/scripts/test_invoke_code_review_chain.py +26 -0
  26. package/scripts/test_invoke_code_review_cli.py +8 -8
  27. package/scripts/test_invoke_code_review_codec.py +3 -11
  28. package/scripts/test_invoke_code_review_contract.py +5 -1
  29. package/scripts/test_resolve_worker_spawn.py +118 -265
  30. package/scripts/test_resolve_worker_spawn_codec.py +9 -21
  31. package/scripts/tests/test_code_review_constants.py +2 -2
  32. package/scripts/tests/test_policy_lint_rules_pairing.py +13 -0
  33. package/scripts/claude_account_choice.py +0 -422
  34. package/scripts/claude_chain_runner.py +0 -1289
  35. package/scripts/test_claude_account_choice.py +0 -355
  36. package/scripts/test_claude_chain_runner.py +0 -1839
@@ -1,54 +1,24 @@
1
- """Run a headless Claude worker on the account chosen by the usage picker."""
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 claude_account_choice import (
19
- choose_account_from_extras,
20
- config_directory_for_decision,
21
- decision_payload,
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
- CREDENTIALS_FILE_NAME,
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
- Raises:
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
- PERMISSION_MODE_FLAG,
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 _child_environment(
196
- selection: AccountSelection,
197
- all_environment_variables: Mapping[str, str],
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
- all_arguments.extend([MODEL_FLAG, model])
227
- return all_arguments
66
+ arguments.extend([MODEL_FLAG, model])
67
+ return arguments
228
68
 
229
69
 
230
- def _prepared_launch(
231
- selection: AccountSelection,
232
- prompt_file: Path,
233
- dependencies: WorkerDependencies,
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
- """Pick an account and run one headless Claude worker on it.
340
-
341
- Args:
342
- prompt_file, report_file: The prompt and report file paths.
343
- decision_selector, dependencies: The picker and process controls.
344
- Returns:
345
- The exit code: 3 on wait, 124 on timeout, 127 on a missing binary,
346
- or the child's own exit code.
347
- """
348
- report = _report_for_selection(
349
- decision_selector(),
350
- prompt_file=prompt_file,
351
- cwd=cwd,
352
- model=model,
353
- permission_mode=permission_mode,
354
- timeout_minutes=timeout_minutes,
355
- dependencies=dependencies,
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
- """Launch a headless Claude worker process and wait for it to finish."""
1
+ """Run a headless Claude worker through the account broker."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import importlib
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.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)
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
- 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
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 exit_code, controls.monotonic_clock() - process_start, stdout_text
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 picker chose to wait.
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=True,
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: Why the picker chose that account.
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",