claude-dev-env 8.38.3 → 8.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,26 +14,26 @@ A Windows `setx` write only updates the persisted user environment; only a proce
14
14
 
15
15
  `ADVISOR_EFFORT` selects Codex `model_reasoning_effort` for Astra: `low`, `medium`, `high`, `xhigh`, or `max`. The policy default is `medium`, which sends `model_reasoning_effort="medium"`. Astra Xhigh and Max requests route to Medium. Pass `--effort <level>` on the helper to set effort for that Astra run without changing the environment. An unset value uses the policy default. An unknown value blocks advisor routing.
16
16
 
17
- Every fallback reply carries a `fallback_kind` field. `declined` means policy closed the rung (flag off, or the account picker named no Codex account on the `normal` tier). `broken` means the Astra path itself failed (missing executable, spawn error, timeout, malformed reply). A `broken` fallback is a defect to report.
17
+ Every fallback reply carries a `fallback_kind` field. `declined` means policy closed the rung (flag off, or the account broker named no Codex account on the `normal` tier). `broken` means the Astra path itself failed (missing executable, spawn error, timeout, malformed reply). A `broken` fallback is a defect to report.
18
18
 
19
19
  ## Preflight
20
20
 
21
- Flag on: the helper asks the Codex account picker for an account first.
21
+ Flag on: the helper asks the account broker for a Codex account first.
22
22
 
23
23
  ```
24
- python ~/.claude/scripts/codex_account_choice.py choose
24
+ python ~/.claude/scripts/account_broker.py choose --product codex
25
25
  ```
26
26
 
27
- **GOTCHA. Picker path.** `codex_astra_advisor.resolve_account_picker_path` builds the picker path from the helper's own location: two directories above `_shared/advisor/scripts`, then `scripts/codex_account_choice.py`. Installed, that is `~/.claude/scripts/codex_account_choice.py`. Do not hunt other copies. Repo home: `packages/claude-dev-env/scripts/`. The accounts, their order, and sign-in steps are in [`codex-accounts.md`](../../../docs/codex-accounts.md).
27
+ **Broker path.** `codex_astra_advisor.resolve_account_broker_path` builds the broker path from the helper's own location: two directories above `_shared/advisor/scripts`, then `scripts/account_broker.py`. Installed, that is `~/.claude/scripts/account_broker.py`. Repo home: `packages/claude-dev-env/scripts/`. The accounts, their order, and sign-in steps are in [`codex-accounts.md`](../../../docs/codex-accounts.md).
28
28
 
29
- The shared entry point is `~/.claude/_shared/advisor/scripts/codex_astra_advisor.py`. It runs the picker and owns Astra bind or resume parsing. Bind with `python ~/.claude/_shared/advisor/scripts/codex_astra_advisor.py --bind --enable-astra --cwd <repo-root>` and pipe the charter on stdin. Resume with `--resume <session_id>` and pipe the delta consult on stdin.
29
+ The shared entry point is `~/.claude/_shared/advisor/scripts/codex_astra_advisor.py`. It runs the broker and owns Astra bind or resume parsing. Bind with `python ~/.claude/_shared/advisor/scripts/codex_astra_advisor.py --bind --enable-astra --cwd <repo-root>` and pipe the charter on stdin. Resume with `--resume <session_id>` and pipe the delta consult on stdin.
30
30
 
31
- The gate passes only when the picker exits 0 and answers `tier: normal` with a `codex_home` and a finite `percent_left`. The helper then runs Codex with `CODEX_HOME` set to that `codex_home`. A `luna` or `wait` answer fails closed when Opus did not bind.
31
+ The gate passes only when the broker exits 0 and answers `decision.tier: normal` with a `decision.home` and finite session or weekly room in the `accounts` entry whose `name` and `home` match. With no roster configured, `accounts` is empty and the gate passes on `decision.home` alone. The helper then runs Codex with `CODEX_HOME` set to `decision.home`. A `luna` or `wait` answer, including exit 3 with a wait answer, declines the preflight when Opus did not bind.
32
32
 
33
33
  ## Branches
34
34
 
35
35
  **Preflight pass.** Bind one Codex CLI session at `gpt-6-astra` with `model_reasoning_effort` set from the policy-selected `ADVISOR_EFFORT` value, `--sandbox read-only`, and JSON output. The helper receives the standing-reviewer charter on stdin and returns only parsed ENDORSE / CORRECTION / PLAN / STOP guidance with a session ID.
36
36
 
37
- **Preflight fail.** A picker failure, non-zero exit, timeout, malformed answer, or a `luna` or `wait` tier fails closed when Opus did not bind.
37
+ **Preflight fail.** A broker failure other than exit 3 with a wait answer, timeout, malformed answer, or a `luna` or `wait` tier fails closed when Opus did not bind.
38
38
 
39
39
  The helper owns the Astra attempt and returns an explicit fallback result. The consuming advisor path owns the Opus bind. Apply the same gate to every Astra attempt, including resume.
@@ -30,7 +30,7 @@ from advisor_scripts_constants.advisor_route_constants import (
30
30
  )
31
31
  from advisor_scripts_constants.astra_advisor_constants import (
32
32
  ADVISOR_CODEX_EXECUTABLE_ENV_VAR,
33
- ALL_ACCOUNT_PICKER_RELATIVE_PARTS,
33
+ ALL_ACCOUNT_BROKER_RELATIVE_PARTS,
34
34
  ALL_ASTRA_TRUTHY_VALUES,
35
35
  ASTRA_BIND_FAILURE_REASON,
36
36
  ASTRA_CODEX_TIMEOUT_REASON,
@@ -174,18 +174,18 @@ def resolve_advisor_effort(all_settings: Mapping[str, str] | None) -> str:
174
174
  return resolve_advisor_pair(all_settings, None)[1]
175
175
 
176
176
 
177
- def resolve_account_picker_path() -> Path:
178
- """Return the Codex account picker in the scripts directory beside this shared tree.
177
+ def resolve_account_broker_path() -> Path:
178
+ """Return the account broker in the scripts directory beside this shared tree.
179
179
 
180
180
  ::
181
181
 
182
- ~/.claude/_shared/advisor/scripts -> ~/.claude/scripts/codex_account_choice.py
182
+ ~/.claude/_shared/advisor/scripts -> ~/.claude/scripts/account_broker.py
183
183
 
184
184
  Returns:
185
- Path to ``codex_account_choice.py``.
185
+ Path to ``account_broker.py``.
186
186
  """
187
187
  shared_root = _scripts_directory.parents[SHARED_PACKAGE_ROOT_PARENT_INDEX]
188
- return shared_root.joinpath(*ALL_ACCOUNT_PICKER_RELATIVE_PARTS)
188
+ return shared_root.joinpath(*ALL_ACCOUNT_BROKER_RELATIVE_PARTS)
189
189
 
190
190
 
191
191
  def resolve_codex_executable(all_settings: Mapping[str, str] | None) -> str | None:
@@ -237,12 +237,12 @@ def build_codex_arguments(
237
237
 
238
238
  def _resolve_preflight(
239
239
  preflight: AstraPreflight | None,
240
- picker_path: Path | None,
240
+ broker_path: Path | None,
241
241
  process_runner: Callable[..., subprocess.CompletedProcess[str]],
242
242
  ) -> AstraPreflight:
243
243
  if preflight is not None:
244
244
  return preflight
245
- resolved_path = resolve_account_picker_path() if picker_path is None else picker_path
245
+ resolved_path = resolve_account_broker_path() if broker_path is None else broker_path
246
246
  return run_astra_preflight(resolved_path, process_runner)
247
247
 
248
248
 
@@ -331,7 +331,7 @@ def run_codex_astra_advisor(
331
331
  prompt: str,
332
332
  working_directory: Path,
333
333
  preflight: AstraPreflight | None,
334
- picker_path: Path | None,
334
+ broker_path: Path | None,
335
335
  setting_by_name: Mapping[str, str] | None,
336
336
  session_id: str | None,
337
337
  process_runner: Callable[..., subprocess.CompletedProcess[str]],
@@ -341,11 +341,11 @@ def run_codex_astra_advisor(
341
341
  Args:
342
342
  prompt: Advisor prompt text.
343
343
  working_directory: Working directory for Codex.
344
- preflight: Account picker result, or None to run the picker.
345
- picker_path: Account picker path, or None for the one beside this shared tree.
344
+ preflight: Account broker result, or None to run the broker.
345
+ broker_path: Account broker path, or None for the one beside this shared tree.
346
346
  setting_by_name: Environment mapping, or None to read os.environ.
347
347
  session_id: Session to resume, or None for a new bind.
348
- process_runner: Callable that runs the picker and Codex.
348
+ process_runner: Callable that runs the broker and Codex.
349
349
 
350
350
  Returns:
351
351
  Advisor reply, or a fallback.
@@ -355,7 +355,7 @@ def run_codex_astra_advisor(
355
355
  executable = resolve_codex_executable(setting_by_name)
356
356
  if executable is None:
357
357
  return build_fallback_reply(ASTRA_EXECUTABLE_NOT_FOUND_REASON, True)
358
- resolved_preflight = _resolve_preflight(preflight, picker_path, process_runner)
358
+ resolved_preflight = _resolve_preflight(preflight, broker_path, process_runner)
359
359
  return _run_enabled_advisor(prompt, working_directory, setting_by_name, session_id, executable, resolved_preflight, process_runner)
360
360
 
361
361
 
@@ -1,4 +1,4 @@
1
- """Ask the Codex account picker for an account before an Astra advisor request."""
1
+ """Ask the account broker for a Codex account before an Astra advisor request."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -11,9 +11,12 @@ from dataclasses import dataclass
11
11
  from pathlib import Path
12
12
 
13
13
  from advisor_scripts_constants.astra_advisor_constants import (
14
- ACCOUNT_PICKER_CHOOSE_COMMAND,
14
+ ACCOUNT_BROKER_CHOOSE_COMMAND,
15
15
  ASTRA_ACCOUNT_PICK_TIMEOUT_REASON,
16
+ ALL_BROKER_ACCEPTED_EXIT_CODES,
17
+ ALL_BROKER_METER_PERCENT_KEYS,
16
18
  ASTRA_ACCOUNT_PICK_TIMEOUT_SECONDS,
19
+ BROKER_WAIT_EXIT_CODE,
17
20
  ASTRA_FALLBACK_KIND_BROKEN,
18
21
  ASTRA_FALLBACK_KIND_DECLINED,
19
22
  ASTRA_PREFLIGHT_FAILURE_REASON,
@@ -49,25 +52,51 @@ def _finite_percent(raw_percent: object) -> float | None:
49
52
 
50
53
 
51
54
  def _preflight_from_answer(field_by_name: dict[str, object]) -> AstraPreflight:
52
- if not isinstance(field_by_name.get("tier"), str):
53
- return _broken("picker answer is malformed")
54
- if field_by_name["tier"] != CODEX_TIER_NORMAL:
55
- reason = f"{ASTRA_PREFLIGHT_FAILURE_REASON}: no Codex account has room ({field_by_name.get('reason')})"
55
+ decision = field_by_name.get("decision")
56
+ if not isinstance(decision, dict) or not isinstance(decision.get("tier"), str):
57
+ return _broken("broker answer is malformed")
58
+ if decision["tier"] != CODEX_TIER_NORMAL:
59
+ reason = f"{ASTRA_PREFLIGHT_FAILURE_REASON}: no Codex account has room ({decision.get('reason')})"
56
60
  return _preflight_fallback(reason, None, ASTRA_FALLBACK_KIND_DECLINED)
57
- codex_home = field_by_name.get("codex_home")
58
- percent_left = _finite_percent(field_by_name.get("percent_left"))
59
- if not isinstance(codex_home, str) or not codex_home or percent_left is None:
60
- return _broken("picker answer names no Codex home with room")
61
- reason = f"{field_by_name.get('account')} has {percent_left:.0f}% left"
62
- return AstraPreflight(True, percent_left, reason, codex_home=Path(codex_home))
63
-
64
-
65
- def _run_picker(
66
- picker_path: Path,
61
+ codex_home = decision.get("home")
62
+ account = decision.get("account")
63
+ accounts = field_by_name.get("accounts")
64
+ reason = decision.get("reason")
65
+ if not isinstance(codex_home, str) or not codex_home or not isinstance(account, str) or not isinstance(accounts, list):
66
+ return _broken("broker answer names no Codex home with room")
67
+ if not isinstance(reason, str):
68
+ return _broken("broker answer is malformed")
69
+ if not accounts:
70
+ return AstraPreflight(True, None, reason, codex_home=Path(codex_home))
71
+ chosen = next(
72
+ (
73
+ entry
74
+ for entry in accounts
75
+ if isinstance(entry, dict)
76
+ and entry.get("name") == account
77
+ and entry.get("home") == codex_home
78
+ ),
79
+ None,
80
+ )
81
+ meters = chosen.get("meters") if isinstance(chosen, dict) else None
82
+ if not isinstance(meters, dict):
83
+ return _broken("broker answer names no Codex home with room")
84
+ all_percent_left = [
85
+ percent
86
+ for key in ALL_BROKER_METER_PERCENT_KEYS
87
+ if (percent := _finite_percent(meters.get(key))) is not None
88
+ ]
89
+ if not all_percent_left:
90
+ return _broken("broker answer names no Codex home with room")
91
+ return AstraPreflight(True, min(all_percent_left), reason, codex_home=Path(codex_home))
92
+
93
+
94
+ def _run_broker(
95
+ broker_path: Path,
67
96
  process_runner: Callable[..., subprocess.CompletedProcess[str]],
68
97
  ) -> subprocess.CompletedProcess[str]:
69
98
  return process_runner(
70
- [sys.executable, str(picker_path), ACCOUNT_PICKER_CHOOSE_COMMAND],
99
+ [sys.executable, str(broker_path), ACCOUNT_BROKER_CHOOSE_COMMAND, "--product", "codex"],
71
100
  capture_output=True,
72
101
  text=True,
73
102
  check=False,
@@ -76,39 +105,39 @@ def _run_picker(
76
105
  )
77
106
 
78
107
 
79
- def _preflight_from_picker(completed: subprocess.CompletedProcess[str]) -> AstraPreflight:
80
- if completed.returncode != 0:
81
- return _broken(f"picker exit {completed.returncode}")
108
+ def _preflight_from_broker(completed: subprocess.CompletedProcess[str]) -> AstraPreflight:
109
+ if completed.returncode not in ALL_BROKER_ACCEPTED_EXIT_CODES:
110
+ return _broken(f"broker exit {completed.returncode}")
82
111
  try:
83
112
  answer = json.loads(completed.stdout)
84
113
  except (TypeError, json.JSONDecodeError):
85
- return _broken("picker answer is malformed")
114
+ return _broken("broker answer is malformed")
86
115
  if not isinstance(answer, dict):
87
- return _broken("picker answer is malformed")
116
+ return _broken("broker answer is malformed")
117
+ if completed.returncode == BROKER_WAIT_EXIT_CODE:
118
+ decision = answer.get("decision")
119
+ if not isinstance(decision, dict) or not isinstance(decision.get("tier"), str):
120
+ return _broken("broker answer is malformed")
121
+ reason = f"{ASTRA_PREFLIGHT_FAILURE_REASON}: no Codex account has room ({decision.get('reason')})"
122
+ return _preflight_fallback(reason, None, ASTRA_FALLBACK_KIND_DECLINED)
88
123
  return _preflight_from_answer(answer)
89
124
 
90
125
 
91
126
  def run_astra_preflight(
92
- picker_path: Path,
127
+ broker_path: Path,
93
128
  process_runner: Callable[..., subprocess.CompletedProcess[str]],
94
129
  ) -> AstraPreflight:
95
- """Run the Codex account picker and decide whether Astra may bind.
96
-
97
- ::
98
-
99
- tier normal, codex-2, 62% left -> eligible, codex_home of codex-2
100
- tier luna or wait -> declined
101
- picker exit 1, bad JSON, timeout -> broken
130
+ """Run the Codex account broker and decide whether Astra may bind.
102
131
 
103
132
  Args:
104
- picker_path: Path to ``codex_account_choice.py``.
105
- process_runner: Callable that runs the picker.
133
+ broker_path: Path to ``account_broker.py``.
134
+ process_runner: Callable that runs the broker.
106
135
 
107
136
  Returns:
108
137
  Eligibility, the chosen account's percent left and Codex home, reason, and fallback kind.
109
138
  """
110
139
  try:
111
- return _preflight_from_picker(_run_picker(picker_path, process_runner))
140
+ return _preflight_from_broker(_run_broker(broker_path, process_runner))
112
141
  except subprocess.TimeoutExpired as error:
113
142
  return _preflight_fallback(
114
143
  f"{ASTRA_ACCOUNT_PICK_TIMEOUT_REASON}: {error}", None, ASTRA_FALLBACK_KIND_BROKEN
@@ -14,10 +14,13 @@ CODEX_EXEC_SUBCOMMAND: str = "exec"
14
14
  CODEX_RESUME_SUBCOMMAND: str = "resume"
15
15
  CODEX_SANDBOX_FLAG: str = "--sandbox"
16
16
  CODEX_HOME_ENV_VAR: str = "CODEX_HOME"
17
- ALL_ACCOUNT_PICKER_RELATIVE_PARTS: tuple[str, ...] = ("scripts", "codex_account_choice.py")
17
+ ALL_ACCOUNT_BROKER_RELATIVE_PARTS: tuple[str, ...] = ("scripts", "account_broker.py")
18
18
  SHARED_PACKAGE_ROOT_PARENT_INDEX: int = 2
19
- ACCOUNT_PICKER_CHOOSE_COMMAND: str = "choose"
19
+ ACCOUNT_BROKER_CHOOSE_COMMAND: str = "choose"
20
20
  CODEX_TIER_NORMAL: str = "normal"
21
+ BROKER_WAIT_EXIT_CODE: int = 3
22
+ ALL_BROKER_ACCEPTED_EXIT_CODES: tuple[int, ...] = (0, BROKER_WAIT_EXIT_CODE)
23
+ ALL_BROKER_METER_PERCENT_KEYS: tuple[str, ...] = ("session_percent_left", "weekly_percent_left")
21
24
  ASTRA_SESSION_ID_METAVAR: str = "SESSION_ID"
22
25
  ASTRA_CODEX_TIMEOUT_SECONDS: float = 120.0
23
26
  ASTRA_ACCOUNT_PICK_TIMEOUT_SECONDS: float = 150.0
@@ -31,14 +31,28 @@ def _load_astra_module() -> ModuleType:
31
31
  astra_advisor = _load_astra_module()
32
32
 
33
33
  SCRIPTS_ROOT = Path(__file__).parent.parent
34
- PICKER_PATH = SCRIPTS_ROOT.parents[2] / "scripts" / "codex_account_choice.py"
34
+ BROKER_PATH = SCRIPTS_ROOT.parents[2] / "scripts" / "account_broker.py"
35
35
  PICKED_CODEX_HOME = Path("codex-profiles") / "codex-2"
36
36
  NORMAL_ANSWER = {
37
- "tier": "normal",
38
- "account": "codex-2",
39
- "codex_home": str(PICKED_CODEX_HOME),
40
- "percent_left": 90,
41
- "reason": "codex-2 has 90% left",
37
+ "decision": {
38
+ "action": "run",
39
+ "tier": "normal",
40
+ "account": "codex-2",
41
+ "home": str(PICKED_CODEX_HOME),
42
+ "resets_at": None,
43
+ "reason": "codex-2 has 90% left",
44
+ },
45
+ "accounts": [{
46
+ "name": "codex-2",
47
+ "home": str(PICKED_CODEX_HOME),
48
+ "is_main": False,
49
+ "meters": {
50
+ "session_percent_left": 90,
51
+ "session_resets_at": None,
52
+ "weekly_percent_left": 95,
53
+ "weekly_resets_at": None,
54
+ },
55
+ }],
42
56
  }
43
57
  ENABLED_SETTINGS = {astra_advisor.ASTRA_ENV_VAR: "1"}
44
58
 
@@ -48,9 +62,9 @@ def _codex_on_search_path(monkeypatch: pytest.MonkeyPatch) -> None:
48
62
  monkeypatch.setattr(astra_advisor.shutil, "which", lambda name: "codex")
49
63
 
50
64
 
51
- def _pick(payload: object, returncode: int = 0) -> subprocess.CompletedProcess[str]:
65
+ def _broker_answer(payload: object, returncode: int = 0) -> subprocess.CompletedProcess[str]:
52
66
  return subprocess.CompletedProcess(
53
- [sys.executable, str(PICKER_PATH), "choose"],
67
+ [sys.executable, str(BROKER_PATH), "choose", "--product", "codex"],
54
68
  returncode,
55
69
  json.dumps(payload),
56
70
  "",
@@ -83,7 +97,7 @@ def _two_step_runner(
83
97
  if all_call_keywords is not None:
84
98
  all_call_keywords.append(kwargs)
85
99
  if len(all_calls) == 1:
86
- return _pick(NORMAL_ANSWER)
100
+ return _broker_answer(NORMAL_ANSWER)
87
101
  return subprocess.CompletedProcess(arguments, 0, _events(guidance=guidance), "")
88
102
  return runner
89
103
 
@@ -165,26 +179,26 @@ def test_argument_parser_accepts_astra_and_rejects_sol() -> None:
165
179
  parser.parse_args(["--bind", "--cwd", ".", "--enable-sol"])
166
180
 
167
181
 
168
- def test_account_picker_path_names_the_packaged_picker() -> None:
169
- path = astra_advisor.resolve_account_picker_path()
170
- assert path == PICKER_PATH.resolve()
182
+ def test_should_resolve_packaged_account_broker_path() -> None:
183
+ path = astra_advisor.resolve_account_broker_path()
184
+ assert path == BROKER_PATH.resolve()
171
185
  assert path.is_file()
172
186
 
173
187
 
174
- def test_preflight_runs_the_picker_choose_command() -> None:
188
+ def test_should_run_broker_choose_command_for_codex_preflight() -> None:
175
189
  all_calls: list[list[str]] = []
176
190
 
177
191
  def runner(arguments: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
178
192
  all_calls.append(arguments)
179
- return _pick(NORMAL_ANSWER)
193
+ return _broker_answer(NORMAL_ANSWER)
180
194
 
181
- astra_advisor.run_astra_preflight(PICKER_PATH, runner)
182
- assert all_calls == [[sys.executable, str(PICKER_PATH), "choose"]]
195
+ astra_advisor.run_astra_preflight(BROKER_PATH, runner)
196
+ assert all_calls == [[sys.executable, str(BROKER_PATH), "choose", "--product", "codex"]]
183
197
 
184
198
 
185
- def test_preflight_accepts_the_normal_tier_and_keeps_its_codex_home() -> None:
199
+ def test_should_accept_normal_tier_and_keep_its_codex_home() -> None:
186
200
  preflight = astra_advisor.run_astra_preflight(
187
- PICKER_PATH, lambda arguments, **kwargs: _pick(NORMAL_ANSWER)
201
+ BROKER_PATH, lambda arguments, **kwargs: _broker_answer(NORMAL_ANSWER)
188
202
  )
189
203
  assert preflight.eligible
190
204
  assert preflight.percent_left == 90
@@ -192,10 +206,13 @@ def test_preflight_accepts_the_normal_tier_and_keeps_its_codex_home() -> None:
192
206
 
193
207
 
194
208
  @pytest.mark.parametrize("tier", ["luna", "wait"])
195
- def test_preflight_declines_a_tier_without_room(tier: str) -> None:
196
- answer = {**NORMAL_ANSWER, "tier": tier, "reason": "every account is low"}
209
+ def test_should_decline_a_tier_without_room(tier: str) -> None:
210
+ answer = {
211
+ **NORMAL_ANSWER,
212
+ "decision": {**NORMAL_ANSWER["decision"], "tier": tier, "reason": "every account is low"},
213
+ }
197
214
  preflight = astra_advisor.run_astra_preflight(
198
- PICKER_PATH, lambda arguments, **kwargs: _pick(answer)
215
+ BROKER_PATH, lambda arguments, **kwargs: _broker_answer(answer)
199
216
  )
200
217
  assert not preflight.eligible
201
218
  assert preflight.fallback_kind == astra_advisor.ASTRA_FALLBACK_KIND_DECLINED
@@ -205,23 +222,37 @@ def test_preflight_declines_a_tier_without_room(tier: str) -> None:
205
222
  @pytest.mark.parametrize(
206
223
  "payload",
207
224
  [
208
- {"tier": None},
209
- {**NORMAL_ANSWER, "codex_home": None},
210
- {**NORMAL_ANSWER, "percent_left": "90"},
225
+ {"decision": {"tier": None}},
226
+ {**NORMAL_ANSWER, "decision": {**NORMAL_ANSWER["decision"], "home": None}},
227
+ {
228
+ **NORMAL_ANSWER,
229
+ "accounts": [{
230
+ "name": "codex-2",
231
+ "home": str(PICKED_CODEX_HOME),
232
+ "meters": {"session_percent_left": "90"},
233
+ }],
234
+ },
235
+ {
236
+ **NORMAL_ANSWER,
237
+ "accounts": [{
238
+ **NORMAL_ANSWER["accounts"][0],
239
+ "home": "codex-profiles/codex-1",
240
+ }],
241
+ },
211
242
  ["bad"],
212
243
  ],
213
244
  )
214
- def test_preflight_rejects_a_malformed_answer(payload: object) -> None:
245
+ def test_should_reject_a_malformed_broker_answer(payload: object) -> None:
215
246
  preflight = astra_advisor.run_astra_preflight(
216
- PICKER_PATH, lambda arguments, **kwargs: _pick(payload)
247
+ BROKER_PATH, lambda arguments, **kwargs: _broker_answer(payload)
217
248
  )
218
249
  assert not preflight.eligible
219
250
  assert preflight.fallback_kind == astra_advisor.ASTRA_FALLBACK_KIND_BROKEN
220
251
 
221
252
 
222
- def test_preflight_rejects_a_failed_picker() -> None:
253
+ def test_should_reject_failed_broker_preflight() -> None:
223
254
  preflight = astra_advisor.run_astra_preflight(
224
- PICKER_PATH, lambda arguments, **kwargs: _pick(NORMAL_ANSWER, returncode=1)
255
+ BROKER_PATH, lambda arguments, **kwargs: _broker_answer(NORMAL_ANSWER, returncode=1)
225
256
  )
226
257
  assert not preflight.eligible
227
258
  assert preflight.fallback_kind == astra_advisor.ASTRA_FALLBACK_KIND_BROKEN
@@ -250,14 +281,14 @@ def test_jsonl_parser_falls_back_for_invalid_reply(jsonl_text: str) -> None:
250
281
  assert not reply.successful
251
282
 
252
283
 
253
- def test_bind_runs_picker_then_codex_under_the_chosen_home() -> None:
284
+ def test_should_bind_under_the_broker_chosen_home() -> None:
254
285
  calls: list[list[str]] = []
255
286
  all_call_keywords: list[dict[str, object]] = []
256
287
  reply = astra_advisor.run_codex_astra_advisor(
257
288
  "consult",
258
289
  Path("."),
259
290
  None,
260
- PICKER_PATH,
291
+ BROKER_PATH,
261
292
  ENABLED_SETTINGS,
262
293
  None,
263
294
  _two_step_runner(calls, all_call_keywords=all_call_keywords),
@@ -282,7 +313,7 @@ def test_bind_routes_astra_xhigh_to_the_policy_effort() -> None:
282
313
  "consult",
283
314
  Path("."),
284
315
  None,
285
- PICKER_PATH,
316
+ BROKER_PATH,
286
317
  settings,
287
318
  None,
288
319
  _two_step_runner(calls),
@@ -309,7 +340,7 @@ def test_disabled_flag_returns_declined_fallback() -> None:
309
340
  def test_missing_codex_returns_broken_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
310
341
  monkeypatch.setattr(astra_advisor.shutil, "which", lambda name: None)
311
342
  reply = astra_advisor.run_codex_astra_advisor(
312
- "consult", Path("."), None, PICKER_PATH, ENABLED_SETTINGS, None, subprocess.run
343
+ "consult", Path("."), None, BROKER_PATH, ENABLED_SETTINGS, None, subprocess.run
313
344
  )
314
345
  assert reply.is_fallback
315
346
  assert reply.fallback_kind == astra_advisor.ASTRA_FALLBACK_KIND_BROKEN
@@ -321,7 +352,7 @@ def test_resume_uses_existing_session() -> None:
321
352
  "resume",
322
353
  Path("."),
323
354
  None,
324
- PICKER_PATH,
355
+ BROKER_PATH,
325
356
  ENABLED_SETTINGS,
326
357
  "thread-1",
327
358
  _two_step_runner(calls, "ENDORSE\nready"),
@@ -3,22 +3,144 @@
3
3
  import json
4
4
  import subprocess
5
5
  import sys
6
+ from datetime import datetime, timezone
6
7
  from pathlib import Path
7
8
 
9
+ import pytest
10
+
8
11
  SCRIPTS_ROOT = Path(__file__).parent.parent
9
12
  sys.path.insert(0, str(SCRIPTS_ROOT / "config"))
10
13
  sys.path.insert(0, str(SCRIPTS_ROOT))
14
+ sys.path.insert(0, str(SCRIPTS_ROOT.parents[2] / "scripts"))
11
15
 
16
+ from account_broker import choose_from_readings, decision_payload, readings_payload
12
17
  from codex_astra_preflight import run_astra_preflight
18
+ from dev_env_scripts_constants.account_broker_constants import (
19
+ Account,
20
+ Decision,
21
+ Meters,
22
+ Product,
23
+ Reading,
24
+ )
25
+
26
+
27
+ def test_should_accept_broker_normal_answer_and_use_tighter_meter(tmp_path: Path) -> None:
28
+ broker_path = tmp_path / "account_broker.py"
29
+ home = tmp_path / "codex-2"
30
+ answer = {
31
+ "decision": {
32
+ "action": "run",
33
+ "account": "codex-2",
34
+ "home": str(home),
35
+ "resets_at": None,
36
+ "reason": "codex-2 has 42% left",
37
+ "tier": "normal",
38
+ },
39
+ "accounts": [
40
+ {
41
+ "name": "codex-2",
42
+ "home": str(home),
43
+ "is_main": False,
44
+ "meters": {
45
+ "session_percent_left": 72,
46
+ "session_resets_at": None,
47
+ "weekly_percent_left": 42,
48
+ "weekly_resets_at": None,
49
+ },
50
+ }
51
+ ],
52
+ }
53
+ calls: list[list[str]] = []
54
+
55
+ def process_runner(arguments: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
56
+ calls.append(arguments)
57
+ return subprocess.CompletedProcess(arguments, 0, json.dumps(answer), "")
58
+
59
+ preflight = run_astra_preflight(broker_path, process_runner)
60
+
61
+ assert calls == [[sys.executable, str(broker_path), "choose", "--product", "codex"]]
62
+ assert preflight.eligible
63
+ assert preflight.percent_left == 42
64
+ assert preflight.codex_home == home
65
+
66
+
67
+ def test_should_decline_broker_wait_answer_on_exit_three(tmp_path: Path) -> None:
68
+ broker_path = tmp_path / "account_broker.py"
69
+ answer = {
70
+ "decision": {
71
+ "action": "wait",
72
+ "account": None,
73
+ "home": None,
74
+ "resets_at": None,
75
+ "reason": "no account has room",
76
+ "tier": "wait",
77
+ },
78
+ "accounts": [],
79
+ }
13
80
 
81
+ def process_runner(arguments: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
82
+ return subprocess.CompletedProcess(arguments, 3, json.dumps(answer), "")
14
83
 
15
- def test_run_astra_preflight_rejects_picker_failure(tmp_path: Path) -> None:
16
- picker_path = tmp_path / "codex_account_choice.py"
84
+ preflight = run_astra_preflight(broker_path, process_runner)
85
+
86
+ assert not preflight.eligible
87
+ assert preflight.fallback_kind == "declined"
88
+ assert "no account has room" in preflight.reason
89
+
90
+
91
+ def test_should_reject_malformed_broker_answer_on_exit_three(tmp_path: Path) -> None:
92
+ broker_path = tmp_path / "account_broker.py"
93
+
94
+ def process_runner(arguments: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
95
+ return subprocess.CompletedProcess(arguments, 3, "not json", "")
96
+
97
+ preflight = run_astra_preflight(broker_path, process_runner)
98
+
99
+ assert not preflight.eligible
100
+ assert preflight.fallback_kind == "broken"
101
+
102
+
103
+ def test_should_reject_broker_failure(tmp_path: Path) -> None:
104
+ broker_path = tmp_path / "account_broker.py"
17
105
 
18
106
  def process_runner(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
19
107
  return subprocess.CompletedProcess([], 1, json.dumps({}), "")
20
108
 
21
- preflight = run_astra_preflight(picker_path, process_runner)
109
+ preflight = run_astra_preflight(broker_path, process_runner)
22
110
 
23
111
  assert not preflight.eligible
24
112
  assert preflight.fallback_kind == "broken"
113
+
114
+
115
+ def test_should_accept_the_answer_the_broker_serializes(tmp_path: Path) -> None:
116
+ account = Account(Product.CODEX, "codex-2", tmp_path / "codex-2")
117
+ reading = Reading(account, Meters(72.0, None, 42.0, None))
118
+ decision = Decision("run", account, None, "codex-2 has 42% left", "normal")
119
+ answer = {"decision": decision_payload(decision), "accounts": readings_payload([reading])}
120
+
121
+ def process_runner(arguments: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
122
+ return subprocess.CompletedProcess(arguments, 0, json.dumps(answer), "")
123
+
124
+ preflight = run_astra_preflight(tmp_path / "account_broker.py", process_runner)
125
+
126
+ assert preflight.eligible
127
+ assert preflight.percent_left == 42
128
+ assert preflight.codex_home == tmp_path / "codex-2"
129
+
130
+
131
+ def test_should_bind_the_default_home_when_no_roster_is_configured(
132
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
133
+ ) -> None:
134
+ default_home = tmp_path / ".codex"
135
+ monkeypatch.setenv("CODEX_HOME", str(default_home))
136
+ decision = choose_from_readings(Product.CODEX, [], now=datetime.now(timezone.utc))
137
+ answer = {"decision": decision_payload(decision), "accounts": readings_payload([])}
138
+
139
+ def process_runner(arguments: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
140
+ return subprocess.CompletedProcess(arguments, 0, json.dumps(answer), "")
141
+
142
+ preflight = run_astra_preflight(tmp_path / "account_broker.py", process_runner)
143
+
144
+ assert preflight.eligible
145
+ assert preflight.percent_left is None
146
+ assert preflight.codex_home == default_home.resolve()