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.
@@ -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")