claude-dev-env 8.49.0 → 8.49.2
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/.agents/skills/second-account-workers/SKILL.md +2 -1
- package/docs/second-claude-account.md +10 -8
- package/package.json +1 -1
- package/scripts/account_broker.py +3 -5
- package/scripts/account_broker_support.py +2 -2
- package/scripts/claude_account_worker.py +2 -2
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +43 -40
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -12
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +0 -2
- package/scripts/test_account_broker.py +160 -7
- package/scripts/test_account_broker_support.py +1 -1
- package/scripts/test_agent_merge_check.py +0 -6
- package/scripts/test_claude_account_worker.py +13 -3
- package/scripts/test_resolve_worker_spawn.py +0 -1
|
@@ -22,7 +22,8 @@ JSON list of profile names, with the first choice first. Names use letters,
|
|
|
22
22
|
digits, hyphens, and underscores. The worker uses the existing second profile
|
|
23
23
|
when the file is absent. When the file exists, its list sets the full order.
|
|
24
24
|
Keep `main` and `wait` out of the list because they name picker decisions.
|
|
25
|
-
The picker
|
|
25
|
+
The picker ranks main and every extra profile by room left and picks the one
|
|
26
|
+
with the most.
|
|
26
27
|
For direct picker calls, `--second-config-dir` sets the first extra profile and
|
|
27
28
|
each `--extra-config-dir` adds another in the order given.
|
|
28
29
|
Picker JSON prints `config_dir` for the choice and meters under `main`, `second`,
|
|
@@ -36,17 +36,19 @@ Claude refreshes it on each run.
|
|
|
36
36
|
|
|
37
37
|
## Which account a job uses
|
|
38
38
|
|
|
39
|
-
The
|
|
40
|
-
|
|
39
|
+
The broker ranks every Claude account that has room and picks the one with the
|
|
40
|
+
most room left. An account's room is the smaller of its 5-hour and weekly
|
|
41
|
+
percent left. A resumed session stays on its account while that account has
|
|
42
|
+
room.
|
|
41
43
|
|
|
42
|
-
|
|
|
44
|
+
| Account | Has room while |
|
|
43
45
|
|---|---|
|
|
44
|
-
|
|
|
45
|
-
|
|
|
46
|
-
| Second meter unreadable | wait until the meter can be read |
|
|
47
|
-
| Otherwise | wait, with the next reset time |
|
|
46
|
+
| main | under 90% of its week and under 50% of its 5-hour window |
|
|
47
|
+
| second and each extra profile | under 95% of its week and under 90% of its 5-hour window |
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
When no account has room, the job waits until the next account reset, or for one
|
|
50
|
+
hour when no reset is known. An unreadable meter never picks its account, and
|
|
51
|
+
the wait reason names each account whose meter could not be read.
|
|
50
52
|
|
|
51
53
|
```
|
|
52
54
|
python packages/claude-dev-env/scripts/account_broker.py choose --product claude
|
package/package.json
CHANGED
|
@@ -65,7 +65,6 @@ def choose_from_readings(
|
|
|
65
65
|
now: datetime,
|
|
66
66
|
all_spent_accounts: frozenset[Account] = frozenset(),
|
|
67
67
|
preferred_command: str | None = None,
|
|
68
|
-
adapter: ProductAdapter | None = None,
|
|
69
68
|
all_spent_resets: Mapping[Account, datetime] | None = None,
|
|
70
69
|
) -> Decision:
|
|
71
70
|
"""Choose an account or a wait.
|
|
@@ -80,10 +79,9 @@ def choose_from_readings(
|
|
|
80
79
|
if product is Product.CODEX and not all_readings:
|
|
81
80
|
home = Path(os.environ.get(CODEX_HOME_ENVIRONMENT_VARIABLE) or Path.home() / MAIN_CODEX_HOME_DIRECTORY_NAME).resolve()
|
|
82
81
|
return Decision("run", Account(product, "default", home, True), None, "no roster is configured", TIER_NORMAL)
|
|
83
|
-
active = adapter or all_product_adapters[product]
|
|
84
82
|
all_available = [each_reading for each_reading in all_readings if each_reading.account not in all_spent_accounts]
|
|
85
83
|
selected = (
|
|
86
|
-
_choose_claude(all_available,
|
|
84
|
+
_choose_claude(all_available, preferred_command)
|
|
87
85
|
if product is Product.CLAUDE
|
|
88
86
|
else _choose_codex(all_available)
|
|
89
87
|
)
|
|
@@ -238,7 +236,7 @@ def _wait_outcome(context: _RunContext, decision: Decision) -> JobOutcome:
|
|
|
238
236
|
if all_attempt_statuses == {"start_failed"}:
|
|
239
237
|
return JobOutcome(COMMAND_MISSING_EXIT_CODE, "", "", None, tuple(context.all_attempts), "start_failed", None, None)
|
|
240
238
|
status = "exhausted" if context.all_attempts else "wait"
|
|
241
|
-
return JobOutcome(WAIT_EXIT_CODE, "", "", None, tuple(context.all_attempts), status, None, decision.resets_at)
|
|
239
|
+
return JobOutcome(WAIT_EXIT_CODE, "", "", None, tuple(context.all_attempts), status, None, decision.resets_at, decision.reason)
|
|
242
240
|
|
|
243
241
|
|
|
244
242
|
def _record_spent_attempt(context: _RunContext, account: Account, status: str, returncode: int | None) -> None:
|
|
@@ -314,7 +312,7 @@ def _execute(
|
|
|
314
312
|
) -> tuple[JobOutcome, Report]:
|
|
315
313
|
context = _prepare_run(product, all_argv, now, timeout_seconds, stdin_text, cwd, encoding, errors)
|
|
316
314
|
while True:
|
|
317
|
-
picked = choose_from_readings(product, context.all_readings, now=now, all_spent_accounts=frozenset(context.all_spent_accounts), preferred_command=context.preferred_command,
|
|
315
|
+
picked = choose_from_readings(product, context.all_readings, now=now, all_spent_accounts=frozenset(context.all_spent_accounts), preferred_command=context.preferred_command, all_spent_resets=context.all_spent_resets)
|
|
318
316
|
decision = _with_outside_spent_marks(picked, _outside_roster_resets(product, context.all_readings, context.all_state, now))
|
|
319
317
|
context.report.events.append({"type": "pick", "decision": decision_payload(decision)})
|
|
320
318
|
if decision.account is None:
|
|
@@ -212,8 +212,8 @@ def read_codex_account_meters(account: Account) -> Meters | None:
|
|
|
212
212
|
|
|
213
213
|
|
|
214
214
|
all_product_adapters = {
|
|
215
|
-
Product.CLAUDE: ProductAdapter(load_claude_accounts, read_claude_meters, CLAUDE_CONFIG_DIR_ENV_VAR, ALL_USAGE_LIMIT_SIGNATURES
|
|
216
|
-
Product.CODEX: ProductAdapter(load_codex_accounts, read_codex_account_meters, CODEX_HOME_ENVIRONMENT_VARIABLE, codex_usage_limit_signatures()
|
|
215
|
+
Product.CLAUDE: ProductAdapter(load_claude_accounts, read_claude_meters, CLAUDE_CONFIG_DIR_ENV_VAR, ALL_USAGE_LIMIT_SIGNATURES),
|
|
216
|
+
Product.CODEX: ProductAdapter(load_codex_accounts, read_codex_account_meters, CODEX_HOME_ENVIRONMENT_VARIABLE, codex_usage_limit_signatures()),
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
|
|
@@ -70,9 +70,9 @@ def _invocation(*, model: str | None, permission_mode: str) -> list[str]:
|
|
|
70
70
|
|
|
71
71
|
|
|
72
72
|
def _wait_reason(outcome: JobOutcome) -> str:
|
|
73
|
-
if outcome.
|
|
73
|
+
if outcome.wait_reason is None:
|
|
74
74
|
return "no account has room"
|
|
75
|
-
return
|
|
75
|
+
return outcome.wait_reason
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
def run_worker(
|
|
@@ -15,7 +15,6 @@ from typing import Callable, Mapping, Sequence
|
|
|
15
15
|
from dev_env_scripts_constants.claude_account_constants import (
|
|
16
16
|
FULL_PERCENT,
|
|
17
17
|
MAIN_SESSION_USED_CEILING_PERCENT,
|
|
18
|
-
MAIN_SPEND_WINDOW,
|
|
19
18
|
MAIN_WEEKLY_USED_CEILING_PERCENT,
|
|
20
19
|
SECOND_SESSION_USED_CEILING_PERCENT,
|
|
21
20
|
SECOND_WEEKLY_USED_CEILING_PERCENT,
|
|
@@ -39,7 +38,6 @@ from shared_tree_paths import resolve_shared_scripts_directory
|
|
|
39
38
|
ALL_CLAUDE_FLOORS = {
|
|
40
39
|
"main_weekly_used_ceiling": MAIN_WEEKLY_USED_CEILING_PERCENT,
|
|
41
40
|
"main_session_used_ceiling": MAIN_SESSION_USED_CEILING_PERCENT,
|
|
42
|
-
"main_spend_window": MAIN_SPEND_WINDOW,
|
|
43
41
|
"extra_weekly_used_ceiling": SECOND_WEEKLY_USED_CEILING_PERCENT,
|
|
44
42
|
"extra_session_used_ceiling": SECOND_SESSION_USED_CEILING_PERCENT,
|
|
45
43
|
}
|
|
@@ -57,7 +55,6 @@ BROKER_STATE_DIRECTORY_NAME = "account-broker"
|
|
|
57
55
|
BROKER_STATE_FILE_NAME = "state.json"
|
|
58
56
|
BROKER_STATE_TEMP_SUFFIX = ".json"
|
|
59
57
|
BROKER_STATE_LOCK_SUFFIX = ".lock"
|
|
60
|
-
SECONDS_PER_HOUR = 3600
|
|
61
58
|
ALL_BATCH_FILE_EXTENSIONS = frozenset({".bat", ".cmd"})
|
|
62
59
|
CMD_SHELL_METACHARACTERS = "&|<>^%!\"\r\n"
|
|
63
60
|
ALL_PARENT_CLAUDE_SESSION_VARIABLES: frozenset[str] = frozenset({
|
|
@@ -180,6 +177,7 @@ class JobOutcome:
|
|
|
180
177
|
status: str
|
|
181
178
|
session_id: str | None
|
|
182
179
|
wait_reset_at: datetime | None
|
|
180
|
+
wait_reason: str | None = None
|
|
183
181
|
|
|
184
182
|
|
|
185
183
|
@dataclass
|
|
@@ -201,22 +199,6 @@ class ProductAdapter:
|
|
|
201
199
|
read_meters: MeterReader
|
|
202
200
|
environment_variable: str
|
|
203
201
|
usage_limit_signatures: tuple[str, ...]
|
|
204
|
-
main_guard: bool
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def _main_reason(meters: Meters | None, now: datetime) -> str | None:
|
|
209
|
-
if meters is None or meters.session_percent_left is None or meters.weekly_percent_left is None or meters.weekly_resets_at is None:
|
|
210
|
-
return None
|
|
211
|
-
until_reset = meters.weekly_resets_at - now
|
|
212
|
-
if (
|
|
213
|
-
until_reset > ALL_CLAUDE_FLOORS["main_spend_window"]
|
|
214
|
-
or meters.weekly_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["main_weekly_used_ceiling"]
|
|
215
|
-
or meters.session_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["main_session_used_ceiling"]
|
|
216
|
-
):
|
|
217
|
-
return None
|
|
218
|
-
hours = int(until_reset.total_seconds() // SECONDS_PER_HOUR)
|
|
219
|
-
return f"main week resets in {hours} hours with {meters.weekly_percent_left:.0f}% left"
|
|
220
202
|
|
|
221
203
|
|
|
222
204
|
def _claude_extra_tier(meters: Meters | None) -> str | None:
|
|
@@ -272,8 +254,6 @@ def _account_reset(reading: Reading, is_spent: bool = False) -> datetime | None:
|
|
|
272
254
|
(meters.weekly_percent_left, ALL_CODEX_FLOORS["luna_stop_left"], meters.weekly_resets_at),
|
|
273
255
|
)
|
|
274
256
|
all_blocking_resets = [reset for room, floor, reset in blocking if room is not None and room <= floor and reset is not None]
|
|
275
|
-
if is_claude_main and meters.weekly_resets_at is not None:
|
|
276
|
-
all_blocking_resets.append(meters.weekly_resets_at - ALL_CLAUDE_FLOORS["main_spend_window"])
|
|
277
257
|
return max(all_blocking_resets, default=None)
|
|
278
258
|
|
|
279
259
|
|
|
@@ -292,23 +272,22 @@ def _main_has_room(meters: Meters | None) -> bool:
|
|
|
292
272
|
)
|
|
293
273
|
|
|
294
274
|
|
|
295
|
-
def
|
|
296
|
-
|
|
297
|
-
)
|
|
298
|
-
|
|
275
|
+
def _claude_has_room(reading: Reading) -> bool:
|
|
276
|
+
if reading.account.is_main:
|
|
277
|
+
return _main_has_room(reading.meters)
|
|
278
|
+
return _claude_extra_tier(reading.meters) is not None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _choose_claude(all_available: Sequence[Reading], preferred_command: str | None) -> Decision | None:
|
|
282
|
+
all_with_room = [each_reading for each_reading in all_available if _claude_has_room(each_reading)]
|
|
283
|
+
for each_reading in all_with_room:
|
|
299
284
|
is_bound = preferred_command in (each_reading.account.command, each_reading.account.name)
|
|
300
|
-
|
|
301
|
-
if preferred_command is not None and is_bound and has_room:
|
|
285
|
+
if preferred_command is not None and is_bound:
|
|
302
286
|
return Decision("run", each_reading.account, None, "resume affinity", TIER_NORMAL)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
extras = sorted((each_reading for each_reading in all_available if not each_reading.account.is_main), key=_rank_key)
|
|
308
|
-
for each_reading in extras:
|
|
309
|
-
if _claude_extra_tier(each_reading.meters):
|
|
310
|
-
return Decision("run", each_reading.account, None, f"{each_reading.account.name} has {each_reading.meters.tightest_percent_left:g}% left", TIER_NORMAL)
|
|
311
|
-
return None
|
|
287
|
+
roomiest = min(all_with_room, key=_rank_key, default=None)
|
|
288
|
+
if roomiest is None:
|
|
289
|
+
return None
|
|
290
|
+
return Decision("run", roomiest.account, None, f"{roomiest.account.name} has {roomiest.meters.tightest_percent_left:g}% left", TIER_NORMAL)
|
|
312
291
|
|
|
313
292
|
|
|
314
293
|
def _choose_codex(all_available: Sequence[Reading]) -> Decision | None:
|
|
@@ -327,14 +306,38 @@ def _choose_codex_tier(all_candidates: Sequence[Reading], tier: str) -> Decision
|
|
|
327
306
|
return None
|
|
328
307
|
|
|
329
308
|
|
|
309
|
+
@dataclass(frozen=True)
|
|
310
|
+
class _RetryTime:
|
|
311
|
+
at: datetime
|
|
312
|
+
is_known_reset: bool
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _wait_reason(all_unreadable_names: Sequence[str], is_every_account_unreadable: bool, soonest: _RetryTime) -> str:
|
|
316
|
+
next_event = "reset" if soonest.is_known_reset else "check"
|
|
317
|
+
next_text = f"next {next_event} at {utc_time_text(soonest.at)}"
|
|
318
|
+
if not all_unreadable_names:
|
|
319
|
+
return f"no account has room; {next_text}"
|
|
320
|
+
unreadable_text = ", ".join(all_unreadable_names)
|
|
321
|
+
if is_every_account_unreadable:
|
|
322
|
+
return f"no account meter could be read ({unreadable_text}); {next_text}"
|
|
323
|
+
return f"no readable account has room; meter unreadable for {unreadable_text}; {next_text}"
|
|
324
|
+
|
|
325
|
+
|
|
330
326
|
def _wait_decision(
|
|
331
327
|
all_readings: Sequence[Reading], now: datetime, all_spent_accounts: frozenset[Account], all_spent_resets: Mapping[Account, datetime]
|
|
332
328
|
) -> Decision:
|
|
333
|
-
|
|
329
|
+
next_check = _RetryTime(now + timedelta(hours=1), is_known_reset=False)
|
|
330
|
+
all_retry_times = []
|
|
334
331
|
for each_reading in all_readings:
|
|
335
332
|
is_spent = each_reading.account in all_spent_accounts
|
|
336
333
|
reset = all_spent_resets.get(each_reading.account) if is_spent else None
|
|
337
334
|
reset = reset or _account_reset(each_reading, is_spent)
|
|
338
|
-
|
|
339
|
-
soonest = min(
|
|
340
|
-
|
|
335
|
+
all_retry_times.append(_RetryTime(reset, is_known_reset=True) if reset is not None and reset > now else next_check)
|
|
336
|
+
soonest = min(all_retry_times, key=lambda each_retry_time: each_retry_time.at, default=next_check)
|
|
337
|
+
all_unreadable_names = [
|
|
338
|
+
each_reading.account.name
|
|
339
|
+
for each_reading in all_readings
|
|
340
|
+
if each_reading.meters is None and each_reading.account not in all_spent_accounts
|
|
341
|
+
]
|
|
342
|
+
is_every_account_unreadable = len(all_unreadable_names) == len(all_readings)
|
|
343
|
+
return Decision("wait", None, soonest.at, _wait_reason(all_unreadable_names, is_every_account_unreadable, soonest), "wait")
|
|
@@ -9,7 +9,6 @@ main account so its owner never runs out.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
from dataclasses import dataclass
|
|
12
|
-
from datetime import timedelta
|
|
13
12
|
|
|
14
13
|
from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
|
|
15
14
|
|
|
@@ -128,9 +127,6 @@ WINDOWS_OS_NAME: str = "nt"
|
|
|
128
127
|
TEXT_ENCODING: str = "utf-8"
|
|
129
128
|
"""Encoding for the launcher file and the JSON reports."""
|
|
130
129
|
|
|
131
|
-
MAIN_SPEND_WINDOW: timedelta = timedelta(hours=24)
|
|
132
|
-
"""Main takes jobs only when its weekly window resets within this span."""
|
|
133
|
-
|
|
134
130
|
MAIN_WEEKLY_USED_CEILING_PERCENT: float = 90.0
|
|
135
131
|
"""Main takes jobs only while its weekly use is under this percent."""
|
|
136
132
|
|
|
@@ -146,9 +142,6 @@ SECOND_SESSION_USED_CEILING_PERCENT: float = 90.0
|
|
|
146
142
|
FULL_PERCENT: float = 100.0
|
|
147
143
|
"""Percent scale ceiling, so remaining is this minus used."""
|
|
148
144
|
|
|
149
|
-
SECONDS_PER_HOUR: int = 3600
|
|
150
|
-
"""Seconds in one hour, for reason text that names hours until a reset."""
|
|
151
|
-
|
|
152
145
|
CHOICE_MAIN: str = "main"
|
|
153
146
|
"""Picker answer: run the job on the main account."""
|
|
154
147
|
|
|
@@ -158,11 +151,6 @@ CHOICE_SECOND: str = "second"
|
|
|
158
151
|
CHOICE_WAIT: str = "wait"
|
|
159
152
|
"""Picker answer: neither account has room, so the job waits."""
|
|
160
153
|
|
|
161
|
-
REASON_MAIN_EXPIRING_TEMPLATE: str = (
|
|
162
|
-
"main week resets in {hours_until_reset} hours with {remaining_percent:.0f}% left"
|
|
163
|
-
)
|
|
164
|
-
"""Reason when main spends leftover usage that expires soon."""
|
|
165
|
-
|
|
166
154
|
REASON_SECOND_HAS_ROOM_TEMPLATE: str = (
|
|
167
155
|
"second account has {weekly_remaining_percent:.0f}% of its week"
|
|
168
156
|
" and {session_remaining_percent:.0f}% of its 5-hour window left"
|
|
@@ -23,7 +23,6 @@ from dev_env_scripts_constants.account_broker_constants import (
|
|
|
23
23
|
)
|
|
24
24
|
from dev_env_scripts_constants.claude_account_constants import (
|
|
25
25
|
MAIN_SESSION_USED_CEILING_PERCENT,
|
|
26
|
-
MAIN_SPEND_WINDOW,
|
|
27
26
|
MAIN_WEEKLY_USED_CEILING_PERCENT,
|
|
28
27
|
SECOND_SESSION_USED_CEILING_PERCENT,
|
|
29
28
|
SECOND_WEEKLY_USED_CEILING_PERCENT,
|
|
@@ -41,7 +40,6 @@ def test_should_keep_claude_floors_tied_to_the_account_ceilings() -> None:
|
|
|
41
40
|
assert ALL_CLAUDE_FLOORS == {
|
|
42
41
|
"main_weekly_used_ceiling": MAIN_WEEKLY_USED_CEILING_PERCENT,
|
|
43
42
|
"main_session_used_ceiling": MAIN_SESSION_USED_CEILING_PERCENT,
|
|
44
|
-
"main_spend_window": MAIN_SPEND_WINDOW,
|
|
45
43
|
"extra_weekly_used_ceiling": SECOND_WEEKLY_USED_CEILING_PERCENT,
|
|
46
44
|
"extra_session_used_ceiling": SECOND_SESSION_USED_CEILING_PERCENT,
|
|
47
45
|
}
|
|
@@ -10,6 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
import pytest
|
|
11
11
|
|
|
12
12
|
import account_broker
|
|
13
|
+
import claude_account_worker
|
|
13
14
|
from account_broker import (
|
|
14
15
|
Account,
|
|
15
16
|
Meters,
|
|
@@ -71,7 +72,6 @@ def _adapter(accounts: tuple[Account, ...], meters: dict[str, Meters | None]) ->
|
|
|
71
72
|
read_meters=lambda account: meters[account.name],
|
|
72
73
|
environment_variable="CODEX_HOME",
|
|
73
74
|
usage_limit_signatures=("rate limit",),
|
|
74
|
-
main_guard=False,
|
|
75
75
|
)
|
|
76
76
|
|
|
77
77
|
|
|
@@ -112,7 +112,6 @@ def test_should_keep_list_order_when_tighter_windows_tie() -> None:
|
|
|
112
112
|
@pytest.mark.parametrize(
|
|
113
113
|
("weekly_reset", "weekly_left", "short_left"),
|
|
114
114
|
(
|
|
115
|
-
(NOW + timedelta(days=3), 20, 80),
|
|
116
115
|
(NOW + timedelta(hours=4), 10, 80),
|
|
117
116
|
(NOW + timedelta(hours=4), 20, 50),
|
|
118
117
|
),
|
|
@@ -131,24 +130,80 @@ def test_should_guard_main_at_each_limit(
|
|
|
131
130
|
assert decision.account == extra.account
|
|
132
131
|
|
|
133
132
|
|
|
134
|
-
def
|
|
133
|
+
def test_should_pick_main_when_it_has_more_room_than_every_extra() -> None:
|
|
135
134
|
main = Reading(
|
|
136
135
|
_account("main", Product.CLAUDE, main=True),
|
|
137
|
-
_meters(80,
|
|
136
|
+
_meters(80, 80, weekly_reset=NOW + timedelta(days=5)),
|
|
137
|
+
)
|
|
138
|
+
extras = (
|
|
139
|
+
Reading(_account("second", Product.CLAUDE), _meters(60, 60)),
|
|
140
|
+
Reading(_account("third", Product.CLAUDE), _meters(40, 40)),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
decision = choose_from_readings(Product.CLAUDE, (*extras, main), now=NOW)
|
|
144
|
+
|
|
145
|
+
assert decision.action == "run"
|
|
146
|
+
assert decision.account == main.account
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_should_pick_an_extra_with_more_room_than_main() -> None:
|
|
150
|
+
main = Reading(
|
|
151
|
+
_account("main", Product.CLAUDE, main=True),
|
|
152
|
+
_meters(60, 60, weekly_reset=NOW + timedelta(hours=4)),
|
|
138
153
|
)
|
|
139
154
|
extra = Reading(_account("extra", Product.CLAUDE), _meters(90, 90))
|
|
140
155
|
|
|
141
156
|
decision = choose_from_readings(Product.CLAUDE, (main, extra), now=NOW)
|
|
142
157
|
|
|
158
|
+
assert decision.account == extra.account
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@pytest.mark.parametrize(("main_short_left", "expected_name"), ((51, "main"), (50, "extra")))
|
|
162
|
+
def test_should_pick_main_only_while_under_its_5_hour_ceiling(
|
|
163
|
+
main_short_left: float, expected_name: str
|
|
164
|
+
) -> None:
|
|
165
|
+
main = Reading(
|
|
166
|
+
_account("main", Product.CLAUDE, main=True),
|
|
167
|
+
_meters(main_short_left, 95, weekly_reset=NOW + timedelta(days=5)),
|
|
168
|
+
)
|
|
169
|
+
extra = Reading(_account("extra", Product.CLAUDE), _meters(20, 20))
|
|
170
|
+
|
|
171
|
+
decision = choose_from_readings(Product.CLAUDE, (main, extra), now=NOW)
|
|
172
|
+
|
|
173
|
+
assert decision.account.name == expected_name
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_should_pick_main_when_it_is_the_only_readable_account() -> None:
|
|
177
|
+
main = Reading(
|
|
178
|
+
_account("main", Product.CLAUDE, main=True),
|
|
179
|
+
_meters(80, 80, weekly_reset=NOW + timedelta(days=5)),
|
|
180
|
+
)
|
|
181
|
+
unread = Reading(_account("extra", Product.CLAUDE), None)
|
|
182
|
+
|
|
183
|
+
decision = choose_from_readings(Product.CLAUDE, (unread, main), now=NOW)
|
|
184
|
+
|
|
185
|
+
assert decision.action == "run"
|
|
143
186
|
assert decision.account == main.account
|
|
144
187
|
|
|
145
188
|
|
|
189
|
+
def test_should_wait_for_main_5_hour_reset_while_its_week_resets_days_away() -> None:
|
|
190
|
+
short_reset = NOW + timedelta(hours=2)
|
|
191
|
+
main = Reading(
|
|
192
|
+
_account("main", Product.CLAUDE, main=True),
|
|
193
|
+
_meters(40, 50, short_reset=short_reset, weekly_reset=NOW + timedelta(days=3)),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
decision = choose_from_readings(Product.CLAUDE, (main,), now=NOW)
|
|
197
|
+
|
|
198
|
+
assert decision.action == "wait"
|
|
199
|
+
assert decision.resets_at == short_reset
|
|
200
|
+
|
|
201
|
+
|
|
146
202
|
@pytest.mark.parametrize(
|
|
147
203
|
("short_left", "weekly_left", "weekly_reset", "expected_reset"),
|
|
148
204
|
(
|
|
149
205
|
(40, 20, NOW + timedelta(hours=4), NOW + timedelta(hours=2)),
|
|
150
206
|
(80, 5, NOW + timedelta(hours=4), NOW + timedelta(hours=4)),
|
|
151
|
-
(80, 50, NOW + timedelta(days=3), NOW + timedelta(days=2)),
|
|
152
207
|
),
|
|
153
208
|
)
|
|
154
209
|
def test_should_wait_for_the_meter_or_window_that_blocks_main(
|
|
@@ -339,6 +394,104 @@ def test_should_wait_when_all_meter_reads_fail() -> None:
|
|
|
339
394
|
assert decision.resets_at == NOW + timedelta(hours=1)
|
|
340
395
|
|
|
341
396
|
|
|
397
|
+
_MAIN_CLAUDE = _account("main", Product.CLAUDE, main=True)
|
|
398
|
+
_EV_CLAUDE = _account("ev", Product.CLAUDE)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@pytest.mark.parametrize(
|
|
402
|
+
("readings", "spent_resets", "expected_reason", "expected_resets_at"),
|
|
403
|
+
(
|
|
404
|
+
pytest.param(
|
|
405
|
+
(Reading(_MAIN_CLAUDE, None), Reading(_EV_CLAUDE, None)),
|
|
406
|
+
{},
|
|
407
|
+
"no account meter could be read (main, ev); next check at 2026-10-03T01:00:00+00:00",
|
|
408
|
+
NOW + timedelta(hours=1),
|
|
409
|
+
id="every-meter-unreadable",
|
|
410
|
+
),
|
|
411
|
+
pytest.param(
|
|
412
|
+
(Reading(_MAIN_CLAUDE, _meters(40, 50, short_reset=NOW + timedelta(minutes=30))), Reading(_EV_CLAUDE, None)),
|
|
413
|
+
{},
|
|
414
|
+
"no readable account has room; meter unreadable for ev; next reset at 2026-10-03T00:30:00+00:00",
|
|
415
|
+
NOW + timedelta(minutes=30),
|
|
416
|
+
id="unreadable-beside-a-reset-before-the-check",
|
|
417
|
+
),
|
|
418
|
+
pytest.param(
|
|
419
|
+
(Reading(_MAIN_CLAUDE, _meters(40, 50, short_reset=NOW + timedelta(hours=2))), Reading(_EV_CLAUDE, None)),
|
|
420
|
+
{},
|
|
421
|
+
"no readable account has room; meter unreadable for ev; next check at 2026-10-03T01:00:00+00:00",
|
|
422
|
+
NOW + timedelta(hours=1),
|
|
423
|
+
id="unreadable-beside-a-reset-after-the-check",
|
|
424
|
+
),
|
|
425
|
+
pytest.param(
|
|
426
|
+
(Reading(_MAIN_CLAUDE, _meters(80, 80)), Reading(_EV_CLAUDE, None)),
|
|
427
|
+
{_MAIN_CLAUDE: NOW + timedelta(minutes=30)},
|
|
428
|
+
"no readable account has room; meter unreadable for ev; next reset at 2026-10-03T00:30:00+00:00",
|
|
429
|
+
NOW + timedelta(minutes=30),
|
|
430
|
+
id="unreadable-beside-a-spent-account",
|
|
431
|
+
),
|
|
432
|
+
pytest.param(
|
|
433
|
+
(
|
|
434
|
+
Reading(_MAIN_CLAUDE, _meters(40, 50, short_reset=NOW + timedelta(hours=2))),
|
|
435
|
+
Reading(_EV_CLAUDE, _meters(5, 80, short_reset=NOW + timedelta(hours=3))),
|
|
436
|
+
),
|
|
437
|
+
{},
|
|
438
|
+
"no account has room; next reset at 2026-10-03T02:00:00+00:00",
|
|
439
|
+
NOW + timedelta(hours=2),
|
|
440
|
+
id="every-meter-read-with-known-resets",
|
|
441
|
+
),
|
|
442
|
+
pytest.param(
|
|
443
|
+
(Reading(_MAIN_CLAUDE, Meters(40, None, 50, None)),),
|
|
444
|
+
{},
|
|
445
|
+
"no account has room; next check at 2026-10-03T01:00:00+00:00",
|
|
446
|
+
NOW + timedelta(hours=1),
|
|
447
|
+
id="every-meter-read-without-a-known-reset",
|
|
448
|
+
),
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
def test_should_name_what_the_broker_knows_in_the_wait_reason(
|
|
452
|
+
readings: tuple[Reading, ...],
|
|
453
|
+
spent_resets: dict[Account, datetime],
|
|
454
|
+
expected_reason: str,
|
|
455
|
+
expected_resets_at: datetime,
|
|
456
|
+
) -> None:
|
|
457
|
+
decision = choose_from_readings(
|
|
458
|
+
Product.CLAUDE,
|
|
459
|
+
readings,
|
|
460
|
+
now=NOW,
|
|
461
|
+
all_spent_accounts=frozenset(spent_resets),
|
|
462
|
+
all_spent_resets=spent_resets,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
assert decision.action == "wait"
|
|
466
|
+
assert decision.reason == expected_reason
|
|
467
|
+
assert decision.resets_at == expected_resets_at
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def test_should_carry_the_unreadable_meter_reason_into_the_worker_report(
|
|
471
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
472
|
+
) -> None:
|
|
473
|
+
adapter = _adapter((_MAIN_CLAUDE, _EV_CLAUDE), {"main": None, "ev": None})
|
|
474
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CLAUDE, adapter)
|
|
475
|
+
prompt_file = tmp_path / "brief.md"
|
|
476
|
+
prompt_file.write_text("standalone brief", encoding="utf-8")
|
|
477
|
+
report_file = tmp_path / "report.json"
|
|
478
|
+
|
|
479
|
+
with account_broker.override_subprocess_runner(lambda command, **options: pytest.fail("a waiting job ran")):
|
|
480
|
+
exit_code = claude_account_worker.run_worker(
|
|
481
|
+
prompt_file=prompt_file,
|
|
482
|
+
cwd=tmp_path,
|
|
483
|
+
report_file=report_file,
|
|
484
|
+
model=None,
|
|
485
|
+
permission_mode="auto",
|
|
486
|
+
timeout_minutes=60,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
report = json.loads(report_file.read_text(encoding="utf-8"))
|
|
490
|
+
assert exit_code == WAIT_EXIT_CODE
|
|
491
|
+
assert report["account"] == "wait"
|
|
492
|
+
assert report["reason"].startswith("no account meter could be read")
|
|
493
|
+
|
|
494
|
+
|
|
342
495
|
def test_should_replay_stdin_bytes_and_only_print_served_stdout(
|
|
343
496
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
344
497
|
) -> None:
|
|
@@ -542,7 +695,7 @@ def test_should_list_accounts_without_reading_meters(
|
|
|
542
695
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
543
696
|
) -> None:
|
|
544
697
|
account = _account("listed")
|
|
545
|
-
adapter = ProductAdapter(lambda: (account,), lambda _: pytest.fail("meter read"), "CODEX_HOME", ()
|
|
698
|
+
adapter = ProductAdapter(lambda: (account,), lambda _: pytest.fail("meter read"), "CODEX_HOME", ())
|
|
546
699
|
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, adapter)
|
|
547
700
|
|
|
548
701
|
assert account_broker.main(("accounts", "--product", "codex")) == 0
|
|
@@ -573,7 +726,7 @@ def test_should_reuse_meter_cache_for_60_seconds() -> None:
|
|
|
573
726
|
calls.append(selected.name)
|
|
574
727
|
return _meters(80, 80)
|
|
575
728
|
|
|
576
|
-
adapter = ProductAdapter(lambda: (account,), read_meter, "CODEX_HOME", ()
|
|
729
|
+
adapter = ProductAdapter(lambda: (account,), read_meter, "CODEX_HOME", ())
|
|
577
730
|
state = account_broker._load_state(account_broker.broker_state_path())
|
|
578
731
|
|
|
579
732
|
account_broker.read_accounts(Product.CODEX, adapter, all_state=state, now=NOW)
|
|
@@ -88,7 +88,7 @@ def test_should_cache_account_reading_under_injected_state_path(
|
|
|
88
88
|
seen.append(selected.name)
|
|
89
89
|
return Meters(80.0, None, 80.0, None)
|
|
90
90
|
|
|
91
|
-
adapter = ProductAdapter(lambda: (account,), reader, "CODEX_HOME", ()
|
|
91
|
+
adapter = ProductAdapter(lambda: (account,), reader, "CODEX_HOME", ())
|
|
92
92
|
state = support._load_state(support.broker_state_path())
|
|
93
93
|
now = datetime(2026, 10, 3, tzinfo=timezone.utc)
|
|
94
94
|
|
|
@@ -2,16 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import sys
|
|
6
5
|
import urllib.parse
|
|
7
|
-
from pathlib import Path
|
|
8
6
|
|
|
9
7
|
import pytest
|
|
10
8
|
|
|
11
|
-
_SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
12
|
-
if str(_SCRIPTS_DIRECTORY) not in sys.path:
|
|
13
|
-
sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
|
|
14
|
-
|
|
15
9
|
import agent_merge_check
|
|
16
10
|
from dev_env_scripts_constants.agent_merge_check_constants import (
|
|
17
11
|
BEHIND_HOLD_REASON,
|
|
@@ -23,6 +23,7 @@ def _outcome(
|
|
|
23
23
|
account_name: str | None = "extra_2",
|
|
24
24
|
status: str = "served",
|
|
25
25
|
wait_reset_at: datetime | None = None,
|
|
26
|
+
wait_reason: str | None = None,
|
|
26
27
|
) -> JobOutcome:
|
|
27
28
|
return JobOutcome(
|
|
28
29
|
returncode,
|
|
@@ -33,6 +34,7 @@ def _outcome(
|
|
|
33
34
|
status,
|
|
34
35
|
None,
|
|
35
36
|
wait_reset_at,
|
|
37
|
+
wait_reason,
|
|
36
38
|
)
|
|
37
39
|
|
|
38
40
|
|
|
@@ -86,20 +88,28 @@ def test_should_send_prompt_and_flags_to_broker(
|
|
|
86
88
|
assert captured["timeout_seconds"] == 3600
|
|
87
89
|
|
|
88
90
|
|
|
89
|
-
def
|
|
91
|
+
def test_should_report_the_broker_wait_reason_and_exit_three(
|
|
90
92
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
91
93
|
) -> None:
|
|
92
94
|
reset_at = datetime(2026, 10, 4, 12, tzinfo=timezone.utc)
|
|
95
|
+
broker_reason = f"no readable account has room; meter unreadable for ev; next check at {reset_at.isoformat()}"
|
|
93
96
|
exit_code, report, captured = _run_worker(
|
|
94
97
|
monkeypatch,
|
|
95
98
|
tmp_path,
|
|
96
|
-
_outcome(
|
|
99
|
+
_outcome(
|
|
100
|
+
stdout="",
|
|
101
|
+
returncode=WAIT_EXIT_CODE,
|
|
102
|
+
account_name=None,
|
|
103
|
+
status="wait",
|
|
104
|
+
wait_reset_at=reset_at,
|
|
105
|
+
wait_reason=broker_reason,
|
|
106
|
+
),
|
|
97
107
|
)
|
|
98
108
|
|
|
99
109
|
assert exit_code == WAIT_EXIT_CODE
|
|
100
110
|
assert report["account"] == "wait"
|
|
101
111
|
assert report["wait_reset_at"] == reset_at.isoformat()
|
|
102
|
-
assert report["reason"] ==
|
|
112
|
+
assert report["reason"] == broker_reason
|
|
103
113
|
assert report["is_error"] is False
|
|
104
114
|
assert captured["product"] is Product.CLAUDE
|
|
105
115
|
|