claude-dev-env 8.37.3 → 8.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/account-broker.md +32 -0
- package/package.json +1 -1
- package/scripts/account_broker.py +387 -0
- package/scripts/account_broker_support.py +445 -0
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +302 -0
- package/scripts/test_account_broker.py +501 -0
- package/scripts/test_account_broker_guard.py +109 -0
- package/scripts/test_account_broker_support.py +127 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Account floors and command status values for the shared account broker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable, Mapping, Sequence
|
|
13
|
+
|
|
14
|
+
from dev_env_scripts_constants.claude_account_constants import (
|
|
15
|
+
FULL_PERCENT,
|
|
16
|
+
MAIN_SESSION_USED_CEILING_PERCENT,
|
|
17
|
+
MAIN_SPEND_WINDOW,
|
|
18
|
+
MAIN_WEEKLY_USED_CEILING_PERCENT,
|
|
19
|
+
SECOND_SESSION_USED_CEILING_PERCENT,
|
|
20
|
+
SECOND_WEEKLY_USED_CEILING_PERCENT,
|
|
21
|
+
)
|
|
22
|
+
from dev_env_scripts_constants.codex_account_constants import (
|
|
23
|
+
CODEX_HOME_ENVIRONMENT_VARIABLE,
|
|
24
|
+
LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
|
|
25
|
+
LUNA_TIER_STOP_PERCENT_LEFT,
|
|
26
|
+
MAIN_CODEX_HOME_DIRECTORY_NAME,
|
|
27
|
+
NORMAL_TIER_MINIMUM_PERCENT_LEFT,
|
|
28
|
+
TIER_LUNA,
|
|
29
|
+
TIER_NORMAL,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
ALL_CLAUDE_FLOORS = {
|
|
33
|
+
"main_weekly_used_ceiling": MAIN_WEEKLY_USED_CEILING_PERCENT,
|
|
34
|
+
"main_session_used_ceiling": MAIN_SESSION_USED_CEILING_PERCENT,
|
|
35
|
+
"main_spend_window": MAIN_SPEND_WINDOW,
|
|
36
|
+
"extra_weekly_used_ceiling": SECOND_WEEKLY_USED_CEILING_PERCENT,
|
|
37
|
+
"extra_session_used_ceiling": SECOND_SESSION_USED_CEILING_PERCENT,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
ALL_CODEX_FLOORS = {
|
|
41
|
+
"normal_minimum_left": NORMAL_TIER_MINIMUM_PERCENT_LEFT,
|
|
42
|
+
"luna_stop_left": LUNA_TIER_STOP_PERCENT_LEFT,
|
|
43
|
+
"luna_short_minimum_left": LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
WAIT_EXIT_CODE = 3
|
|
47
|
+
COMMAND_MISSING_EXIT_CODE = 127
|
|
48
|
+
REPORT_INDENT_SPACES = 2
|
|
49
|
+
BROKER_STATE_DIRECTORY_NAME = "account-broker"
|
|
50
|
+
BROKER_STATE_FILE_NAME = "state.json"
|
|
51
|
+
BROKER_STATE_TEMP_SUFFIX = ".json"
|
|
52
|
+
BROKER_STATE_LOCK_SUFFIX = ".lock"
|
|
53
|
+
SECONDS_PER_HOUR = 3600
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def codex_usage_limit_signatures() -> tuple[str, ...]:
|
|
57
|
+
"""Load usage limit markers.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Markers that identify usage limit responses.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ImportError: The marker module cannot be loaded.
|
|
64
|
+
"""
|
|
65
|
+
constants_path = Path(__file__).resolve().parents[2] / "_shared" / "pr-loop" / "scripts" / "codex_review_scripts_constants" / "classifier_constants.py"
|
|
66
|
+
specification = importlib.util.spec_from_file_location("account_broker_codex_classifier_constants", constants_path)
|
|
67
|
+
if specification is None or specification.loader is None:
|
|
68
|
+
raise ImportError(f"cannot load usage markers from {constants_path}")
|
|
69
|
+
module = importlib.util.module_from_spec(specification)
|
|
70
|
+
specification.loader.exec_module(module)
|
|
71
|
+
return module.ALL_USAGE_LIMIT_MARKERS
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def utc_time_text(moment: datetime | None) -> str | None:
|
|
75
|
+
return moment.astimezone(timezone.utc).isoformat() if moment is not None else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_utc_time(timestamp: object) -> datetime | None:
|
|
79
|
+
"""Parse an ISO timestamp as UTC.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
timestamp: Serialized timestamp.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
UTC timestamp or None.
|
|
86
|
+
"""
|
|
87
|
+
if not isinstance(timestamp, str):
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
parsed = datetime.fromisoformat(timestamp)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return None
|
|
93
|
+
return parsed.astimezone(timezone.utc) if parsed.tzinfo is not None else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Product(str, Enum):
|
|
97
|
+
CLAUDE = "claude"
|
|
98
|
+
CODEX = "codex"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class BrokerConfigurationError(Exception):
|
|
102
|
+
"""An account list cannot be read."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Account:
|
|
107
|
+
product: Product
|
|
108
|
+
name: str
|
|
109
|
+
home: Path
|
|
110
|
+
is_main: bool = False
|
|
111
|
+
command: str | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class Meters:
|
|
116
|
+
session_percent_left: float | None
|
|
117
|
+
session_resets_at: datetime | None
|
|
118
|
+
weekly_percent_left: float | None
|
|
119
|
+
weekly_resets_at: datetime | None
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def tightest_percent_left(self) -> float | None:
|
|
123
|
+
all_percentages = (self.session_percent_left, self.weekly_percent_left)
|
|
124
|
+
return min((each_percent for each_percent in all_percentages if each_percent is not None), default=None)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class Reading:
|
|
129
|
+
account: Account
|
|
130
|
+
meters: Meters | None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class Decision:
|
|
135
|
+
action: str
|
|
136
|
+
account: Account | None
|
|
137
|
+
resets_at: datetime | None
|
|
138
|
+
reason: str
|
|
139
|
+
tier: str | None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class JobOutcome:
|
|
144
|
+
returncode: int
|
|
145
|
+
stdout: str
|
|
146
|
+
stderr: str
|
|
147
|
+
account_name: str | None
|
|
148
|
+
attempts: tuple[tuple[str, str], ...]
|
|
149
|
+
status: str
|
|
150
|
+
session_id: str | None
|
|
151
|
+
wait_reset_at: datetime | None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class Report:
|
|
156
|
+
product: Product
|
|
157
|
+
command: list[str]
|
|
158
|
+
events: list[dict[str, object]] = field(default_factory=list)
|
|
159
|
+
final_decision: Decision | None = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
MeterReader = Callable[[Account], Meters | None]
|
|
163
|
+
AccountLoader = Callable[[], Sequence[Account]]
|
|
164
|
+
SubprocessRunner = Callable[..., subprocess.CompletedProcess[str]]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class ProductAdapter:
|
|
169
|
+
load_accounts: AccountLoader
|
|
170
|
+
read_meters: MeterReader
|
|
171
|
+
environment_variable: str
|
|
172
|
+
usage_limit_signatures: tuple[str, ...]
|
|
173
|
+
main_guard: bool
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _main_reason(meters: Meters | None, now: datetime) -> str | None:
|
|
178
|
+
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:
|
|
179
|
+
return None
|
|
180
|
+
until_reset = meters.weekly_resets_at - now
|
|
181
|
+
if (
|
|
182
|
+
until_reset > ALL_CLAUDE_FLOORS["main_spend_window"]
|
|
183
|
+
or meters.weekly_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["main_weekly_used_ceiling"]
|
|
184
|
+
or meters.session_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["main_session_used_ceiling"]
|
|
185
|
+
):
|
|
186
|
+
return None
|
|
187
|
+
hours = int(until_reset.total_seconds() // SECONDS_PER_HOUR)
|
|
188
|
+
return f"main week resets in {hours} hours with {meters.weekly_percent_left:.0f}% left"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _claude_extra_tier(meters: Meters | None) -> str | None:
|
|
192
|
+
if meters is None or meters.tightest_percent_left is None:
|
|
193
|
+
return None
|
|
194
|
+
if (
|
|
195
|
+
meters.weekly_percent_left is not None
|
|
196
|
+
and meters.weekly_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_weekly_used_ceiling"]
|
|
197
|
+
) or (
|
|
198
|
+
meters.session_percent_left is not None
|
|
199
|
+
and meters.session_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_session_used_ceiling"]
|
|
200
|
+
):
|
|
201
|
+
return None
|
|
202
|
+
return "normal"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _codex_tier(meters: Meters | None) -> str | None:
|
|
206
|
+
if meters is None or meters.tightest_percent_left is None:
|
|
207
|
+
return None
|
|
208
|
+
if meters.tightest_percent_left > ALL_CODEX_FLOORS["normal_minimum_left"]:
|
|
209
|
+
return "normal"
|
|
210
|
+
if (
|
|
211
|
+
meters.tightest_percent_left > ALL_CODEX_FLOORS["luna_stop_left"]
|
|
212
|
+
and (
|
|
213
|
+
meters.session_percent_left is None
|
|
214
|
+
or meters.session_percent_left >= ALL_CODEX_FLOORS["luna_short_minimum_left"]
|
|
215
|
+
)
|
|
216
|
+
):
|
|
217
|
+
return "luna"
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _account_reset(reading: Reading, is_spent: bool = False) -> datetime | None:
|
|
222
|
+
meters = reading.meters
|
|
223
|
+
if meters is None:
|
|
224
|
+
return None
|
|
225
|
+
if is_spent:
|
|
226
|
+
return min((reset for reset in (meters.session_resets_at, meters.weekly_resets_at) if reset is not None), default=None)
|
|
227
|
+
if reading.account.product is Product.CLAUDE:
|
|
228
|
+
if reading.account.is_main:
|
|
229
|
+
return meters.weekly_resets_at
|
|
230
|
+
blocking = (
|
|
231
|
+
(meters.session_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_session_used_ceiling"], meters.session_resets_at),
|
|
232
|
+
(meters.weekly_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_weekly_used_ceiling"], meters.weekly_resets_at),
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
blocking = (
|
|
236
|
+
(meters.session_percent_left, ALL_CODEX_FLOORS["luna_short_minimum_left"], meters.session_resets_at),
|
|
237
|
+
(meters.weekly_percent_left, ALL_CODEX_FLOORS["luna_stop_left"], meters.weekly_resets_at),
|
|
238
|
+
)
|
|
239
|
+
return max((reset for room, floor, reset in blocking if room is not None and room <= floor and reset is not None), default=None)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _rank_key(reading: Reading) -> float:
|
|
243
|
+
room = reading.meters.tightest_percent_left if reading.meters else None
|
|
244
|
+
return -(room if room is not None else -1.0)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _main_has_room(meters: Meters | None) -> bool:
|
|
248
|
+
return bool(
|
|
249
|
+
meters is not None
|
|
250
|
+
and meters.session_percent_left is not None
|
|
251
|
+
and meters.weekly_percent_left is not None
|
|
252
|
+
and meters.session_percent_left > FULL_PERCENT - ALL_CLAUDE_FLOORS["main_session_used_ceiling"]
|
|
253
|
+
and meters.weekly_percent_left > FULL_PERCENT - ALL_CLAUDE_FLOORS["main_weekly_used_ceiling"]
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _choose_claude(
|
|
258
|
+
all_available: Sequence[Reading], now: datetime, preferred_command: str | None, has_main_guard: bool
|
|
259
|
+
) -> Decision | None:
|
|
260
|
+
for each_reading in all_available:
|
|
261
|
+
is_bound = preferred_command in (each_reading.account.command, each_reading.account.name)
|
|
262
|
+
has_room = _main_has_room(each_reading.meters) if each_reading.account.is_main else bool(_claude_extra_tier(each_reading.meters))
|
|
263
|
+
if preferred_command is not None and is_bound and has_room:
|
|
264
|
+
return Decision("run", each_reading.account, None, "resume affinity", TIER_NORMAL)
|
|
265
|
+
main = next((each_reading for each_reading in all_available if each_reading.account.is_main), None)
|
|
266
|
+
main_reason = _main_reason(main.meters, now) if main and has_main_guard else None
|
|
267
|
+
if main is not None and main_reason is not None:
|
|
268
|
+
return Decision("run", main.account, None, main_reason, TIER_NORMAL)
|
|
269
|
+
extras = sorted((each_reading for each_reading in all_available if not each_reading.account.is_main), key=_rank_key)
|
|
270
|
+
for each_reading in extras:
|
|
271
|
+
if _claude_extra_tier(each_reading.meters):
|
|
272
|
+
return Decision("run", each_reading.account, None, f"{each_reading.account.name} has {each_reading.meters.tightest_percent_left:g}% left", TIER_NORMAL)
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _choose_codex(all_available: Sequence[Reading]) -> Decision | None:
|
|
277
|
+
all_candidates = sorted(all_available, key=_rank_key)
|
|
278
|
+
for each_tier in (TIER_NORMAL, TIER_LUNA):
|
|
279
|
+
chosen = _choose_codex_tier(all_candidates, each_tier)
|
|
280
|
+
if chosen is not None:
|
|
281
|
+
return chosen
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _choose_codex_tier(all_candidates: Sequence[Reading], tier: str) -> Decision | None:
|
|
286
|
+
for each_reading in all_candidates:
|
|
287
|
+
if _codex_tier(each_reading.meters) == tier:
|
|
288
|
+
return Decision("run", each_reading.account, None, f"{each_reading.account.name} has {each_reading.meters.tightest_percent_left:g}% left", tier)
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _wait_decision(
|
|
293
|
+
all_readings: Sequence[Reading], now: datetime, all_spent_accounts: frozenset[Account], all_spent_resets: Mapping[Account, datetime]
|
|
294
|
+
) -> Decision:
|
|
295
|
+
all_resets = []
|
|
296
|
+
for each_reading in all_readings:
|
|
297
|
+
is_spent = each_reading.account in all_spent_accounts
|
|
298
|
+
reset = all_spent_resets.get(each_reading.account) if is_spent else None
|
|
299
|
+
reset = reset or _account_reset(each_reading, is_spent)
|
|
300
|
+
all_resets.append(reset if reset is not None and reset > now else now + timedelta(hours=1))
|
|
301
|
+
soonest = min(all_resets, default=now + timedelta(hours=1))
|
|
302
|
+
return Decision("wait", None, soonest, f"no account has room; next reset at {utc_time_text(soonest)}", "wait")
|