claude-dev-env 8.75.2 → 8.75.3
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/package.json
CHANGED
|
@@ -12,6 +12,7 @@ import subprocess
|
|
|
12
12
|
import sys
|
|
13
13
|
import tempfile
|
|
14
14
|
import threading
|
|
15
|
+
import time
|
|
15
16
|
from contextlib import contextmanager
|
|
16
17
|
from datetime import datetime, timezone
|
|
17
18
|
from pathlib import Path, PureWindowsPath
|
|
@@ -37,6 +38,10 @@ from dev_env_scripts_constants.account_broker_constants import (
|
|
|
37
38
|
CMD_SHELL_METACHARACTERS,
|
|
38
39
|
Decision,
|
|
39
40
|
JobOutcome,
|
|
41
|
+
METER_READ_ATTEMPTS,
|
|
42
|
+
METER_READ_DELAY_FACTOR,
|
|
43
|
+
METER_READ_FIRST_DELAY_SECONDS,
|
|
44
|
+
METER_READ_MAX_DELAY_SECONDS,
|
|
40
45
|
Meters,
|
|
41
46
|
Product,
|
|
42
47
|
ProductAdapter,
|
|
@@ -210,12 +215,13 @@ def read_claude_meters(account: Account) -> Meters | None:
|
|
|
210
215
|
account: Account whose meter is read.
|
|
211
216
|
|
|
212
217
|
Returns:
|
|
213
|
-
Its meters
|
|
218
|
+
Its meters.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
WeeklyUtilizationProbeError: The usage meters could not be measured.
|
|
222
|
+
OSError: The credentials file could not be read.
|
|
214
223
|
"""
|
|
215
|
-
|
|
216
|
-
usage = probe_account_meters(account.home / CREDENTIALS_FILE_NAME)
|
|
217
|
-
except (WeeklyUtilizationProbeError, OSError):
|
|
218
|
-
return None
|
|
224
|
+
usage = probe_account_meters(account.home / CREDENTIALS_FILE_NAME)
|
|
219
225
|
return Meters(
|
|
220
226
|
FULL_PERCENT - usage.session_utilization if usage.session_utilization is not None else None,
|
|
221
227
|
usage.session_resets_at,
|
|
@@ -231,17 +237,14 @@ def read_codex_account_meters(account: Account) -> Meters:
|
|
|
231
237
|
account: Account whose meter is read.
|
|
232
238
|
|
|
233
239
|
Returns:
|
|
234
|
-
Its meters
|
|
240
|
+
Its meters.
|
|
235
241
|
|
|
236
242
|
Raises:
|
|
237
|
-
CodexMeterUnreadError:
|
|
243
|
+
CodexMeterUnreadError: The meter read failed.
|
|
238
244
|
OSError: No Codex executable was found.
|
|
239
245
|
"""
|
|
240
246
|
codex_path = codex_account_meters.resolve_codex_path(None)
|
|
241
|
-
|
|
242
|
-
usage = codex_account_meters.read_codex_meters(codex_path, account.home)
|
|
243
|
-
except codex_account_meters.CodexMeterUnreadError:
|
|
244
|
-
usage = codex_account_meters.read_codex_meters(codex_path, account.home)
|
|
247
|
+
usage = codex_account_meters.read_codex_meters(codex_path, account.home)
|
|
245
248
|
weekly = [window for window in usage.all_windows if window.duration_minutes is None or window.duration_minutes >= WEEKLY_WINDOW_MINUTES]
|
|
246
249
|
short = [window for window in usage.all_windows if window.duration_minutes is not None and window.duration_minutes < WEEKLY_WINDOW_MINUTES]
|
|
247
250
|
weekly_window = max(weekly, key=lambda window: window.used_percent) if weekly else None
|
|
@@ -254,6 +257,35 @@ def read_codex_account_meters(account: Account) -> Meters:
|
|
|
254
257
|
)
|
|
255
258
|
|
|
256
259
|
|
|
260
|
+
def read_meters_with_backoff(read_meters: Callable[[Account], Meters | None], account: Account) -> tuple[Meters | None, str | None]:
|
|
261
|
+
"""Read one account's meters, retrying a failed read with exponential backoff.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
read_meters: The product's meter reader.
|
|
265
|
+
account: Account whose meter is read.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
The meters from the first read that succeeds, or None with the last
|
|
269
|
+
failure text once every attempt failed.
|
|
270
|
+
"""
|
|
271
|
+
unread_reason = None
|
|
272
|
+
for each_attempt in range(METER_READ_ATTEMPTS):
|
|
273
|
+
if each_attempt:
|
|
274
|
+
time.sleep(min(METER_READ_FIRST_DELAY_SECONDS * METER_READ_DELAY_FACTOR ** (each_attempt - 1), METER_READ_MAX_DELAY_SECONDS))
|
|
275
|
+
try:
|
|
276
|
+
return read_meters(account), None
|
|
277
|
+
except (
|
|
278
|
+
codex_account_meters.CodexMeterUnreadError,
|
|
279
|
+
WeeklyUtilizationProbeError,
|
|
280
|
+
OSError,
|
|
281
|
+
ValueError,
|
|
282
|
+
RuntimeError,
|
|
283
|
+
subprocess.SubprocessError,
|
|
284
|
+
) as error:
|
|
285
|
+
unread_reason = str(error) or type(error).__name__
|
|
286
|
+
return None, unread_reason
|
|
287
|
+
|
|
288
|
+
|
|
257
289
|
all_product_adapters = {
|
|
258
290
|
Product.CLAUDE: ProductAdapter(load_claude_accounts, read_claude_meters, CLAUDE_CONFIG_DIR_ENV_VAR, ALL_USAGE_LIMIT_SIGNATURES),
|
|
259
291
|
Product.CODEX: ProductAdapter(load_codex_accounts, read_codex_account_meters, CODEX_HOME_ENVIRONMENT_VARIABLE, codex_usage_limit_signatures()),
|
|
@@ -409,12 +441,7 @@ def _read_one_account(account: Account, adapter: ProductAdapter, all_cache: dict
|
|
|
409
441
|
if isinstance(cached, dict) and isinstance(cached.get("read_at"), (int, float)) and 0 <= now.timestamp() - cached["read_at"] < 60:
|
|
410
442
|
cached_reason = cached.get("unread_reason")
|
|
411
443
|
return Reading(account, _meters_from_payload(cached.get("meters")), cached_reason if isinstance(cached_reason, str) else None), False
|
|
412
|
-
unread_reason =
|
|
413
|
-
try:
|
|
414
|
-
meters = adapter.read_meters(account)
|
|
415
|
-
except (codex_account_meters.CodexMeterUnreadError, OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
|
|
416
|
-
meters = None
|
|
417
|
-
unread_reason = str(error) or type(error).__name__
|
|
444
|
+
meters, unread_reason = read_meters_with_backoff(adapter.read_meters, account)
|
|
418
445
|
all_cache[key] = {"read_at": now.timestamp(), "meters": _meter_payload(meters), "unread_reason": unread_reason}
|
|
419
446
|
return Reading(account, meters, unread_reason), True
|
|
420
447
|
|
|
@@ -48,6 +48,10 @@ ALL_CODEX_FLOORS = {
|
|
|
48
48
|
"luna_short_minimum_left": LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
METER_READ_ATTEMPTS = 3
|
|
52
|
+
METER_READ_FIRST_DELAY_SECONDS = 1.0
|
|
53
|
+
METER_READ_DELAY_FACTOR = 2.0
|
|
54
|
+
METER_READ_MAX_DELAY_SECONDS = 8.0
|
|
51
55
|
WAIT_EXIT_CODE = 3
|
|
52
56
|
COMMAND_MISSING_EXIT_CODE = 127
|
|
53
57
|
TIMEOUT_EXIT_CODE = 124
|
|
@@ -10,6 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
import pytest
|
|
11
11
|
|
|
12
12
|
import account_broker
|
|
13
|
+
import account_broker_support
|
|
13
14
|
import claude_account_worker
|
|
14
15
|
from account_broker import (
|
|
15
16
|
Account,
|
|
@@ -75,7 +76,8 @@ def _adapter(accounts: tuple[Account, ...], meters: dict[str, Meters | None]) ->
|
|
|
75
76
|
)
|
|
76
77
|
|
|
77
78
|
|
|
78
|
-
def test_should_name_the_unread_account_and_its_reason_when_no_account_has_room() -> None:
|
|
79
|
+
def test_should_name_the_unread_account_and_its_reason_when_no_account_has_room(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
80
|
+
monkeypatch.setattr(account_broker_support.time, "sleep", lambda _: None)
|
|
79
81
|
codex_3 = _account("codex-3")
|
|
80
82
|
codex_4 = _account("codex-4")
|
|
81
83
|
|
|
@@ -154,40 +154,113 @@ def _codex_meter_reads(
|
|
|
154
154
|
return all_homes_read
|
|
155
155
|
|
|
156
156
|
|
|
157
|
-
def
|
|
157
|
+
def _claude_meter_reads(monkeypatch: pytest.MonkeyPatch, all_outcomes: list[object]) -> list[Path]:
|
|
158
|
+
all_paths_read: list[Path] = []
|
|
159
|
+
|
|
160
|
+
def probe_account_meters(credentials_path: Path) -> object:
|
|
161
|
+
all_paths_read.append(credentials_path)
|
|
162
|
+
outcome = all_outcomes.pop(0)
|
|
163
|
+
if isinstance(outcome, Exception):
|
|
164
|
+
raise outcome
|
|
165
|
+
return outcome
|
|
166
|
+
|
|
167
|
+
monkeypatch.setattr(support, "probe_account_meters", probe_account_meters)
|
|
168
|
+
return all_paths_read
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _recorded_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
|
|
172
|
+
all_delays: list[float] = []
|
|
173
|
+
monkeypatch.setattr(support.time, "sleep", all_delays.append)
|
|
174
|
+
return all_delays
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _first_reading(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, account: Account, read_meters: object) -> object:
|
|
178
|
+
monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
179
|
+
adapter = ProductAdapter(lambda: (account,), read_meters, "HOME", ())
|
|
180
|
+
state = support._load_state(support.broker_state_path())
|
|
181
|
+
now = datetime(2026, 10, 3, tzinfo=timezone.utc)
|
|
182
|
+
return support.read_accounts(account.product, adapter, all_state=state, now=now)[0], adapter, state, now
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_should_read_claude_meters_after_transient_failures_with_growing_delays(
|
|
186
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
187
|
+
) -> None:
|
|
188
|
+
usage = SimpleNamespace(session_utilization=20.0, session_resets_at=None, weekly_utilization=30.0, weekly_resets_at=None)
|
|
189
|
+
unread = support.WeeklyUtilizationProbeError("usage endpoint timed out")
|
|
190
|
+
all_paths_read = _claude_meter_reads(monkeypatch, [unread, unread, usage])
|
|
191
|
+
all_delays = _recorded_sleeps(monkeypatch)
|
|
192
|
+
|
|
193
|
+
reading = _first_reading(monkeypatch, tmp_path, Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)[0]
|
|
194
|
+
|
|
195
|
+
assert reading.meters is not None
|
|
196
|
+
assert reading.meters.weekly_percent_left == 70.0
|
|
197
|
+
assert reading.unread_reason is None
|
|
198
|
+
assert len(all_paths_read) == 3
|
|
199
|
+
assert all_delays == [1.0, 2.0]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_should_read_codex_meters_after_transient_failures_with_growing_delays(
|
|
158
203
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
159
204
|
) -> None:
|
|
160
205
|
weekly = SimpleNamespace(duration_minutes=10080, used_percent=30.0, resets_at=None)
|
|
161
206
|
usage = SimpleNamespace(all_windows=(weekly,), short_window_percent_left=None)
|
|
162
207
|
unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
|
|
163
|
-
all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [unread, usage])
|
|
208
|
+
all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [unread, unread, usage])
|
|
209
|
+
all_delays = _recorded_sleeps(monkeypatch)
|
|
164
210
|
|
|
165
|
-
|
|
211
|
+
reading = _first_reading(monkeypatch, tmp_path, Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
|
|
166
212
|
|
|
167
|
-
assert meters is not None
|
|
168
|
-
assert meters.weekly_percent_left == 70.0
|
|
169
|
-
assert all_homes_read == [tmp_path, tmp_path]
|
|
213
|
+
assert reading.meters is not None
|
|
214
|
+
assert reading.meters.weekly_percent_left == 70.0
|
|
215
|
+
assert all_homes_read == [tmp_path, tmp_path, tmp_path]
|
|
216
|
+
assert all_delays == [1.0, 2.0]
|
|
170
217
|
|
|
171
218
|
|
|
172
|
-
def
|
|
219
|
+
def test_should_keep_the_unread_reason_after_every_claude_read_fails(
|
|
220
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
221
|
+
) -> None:
|
|
222
|
+
all_failures = [support.WeeklyUtilizationProbeError(f"usage endpoint failed {each_number}") for each_number in range(3)]
|
|
223
|
+
all_paths_read = _claude_meter_reads(monkeypatch, all_failures)
|
|
224
|
+
all_delays = _recorded_sleeps(monkeypatch)
|
|
225
|
+
|
|
226
|
+
reading, adapter, state, now = _first_reading(monkeypatch, tmp_path, Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)
|
|
227
|
+
cached_reading = support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=now)[0]
|
|
228
|
+
|
|
229
|
+
assert reading.meters is None
|
|
230
|
+
assert reading.unread_reason == "usage endpoint failed 2"
|
|
231
|
+
assert cached_reading.unread_reason == "usage endpoint failed 2"
|
|
232
|
+
assert len(all_paths_read) == 3
|
|
233
|
+
assert all_delays == [1.0, 2.0]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_should_keep_the_unread_reason_after_every_codex_read_fails(
|
|
173
237
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
174
238
|
) -> None:
|
|
175
|
-
monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
176
239
|
first_unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server failed: timed out")
|
|
177
|
-
|
|
178
|
-
all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [first_unread,
|
|
179
|
-
|
|
180
|
-
adapter = ProductAdapter(lambda: (account,), support.read_codex_account_meters, "CODEX_HOME", ())
|
|
181
|
-
state = support._load_state(support.broker_state_path())
|
|
182
|
-
now = datetime(2026, 10, 3, tzinfo=timezone.utc)
|
|
240
|
+
last_unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
|
|
241
|
+
all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [first_unread, first_unread, last_unread])
|
|
242
|
+
_recorded_sleeps(monkeypatch)
|
|
183
243
|
|
|
184
|
-
|
|
244
|
+
reading, adapter, state, now = _first_reading(monkeypatch, tmp_path, Account(Product.CODEX, "codex-3", tmp_path), support.read_codex_account_meters)
|
|
185
245
|
cached_reading = support.read_accounts(Product.CODEX, adapter, all_state=state, now=now)[0]
|
|
186
246
|
|
|
187
|
-
assert
|
|
188
|
-
assert
|
|
247
|
+
assert reading.meters is None
|
|
248
|
+
assert reading.unread_reason == "codex app-server sent no rate-limit reply"
|
|
189
249
|
assert cached_reading.unread_reason == "codex app-server sent no rate-limit reply"
|
|
190
|
-
assert all_homes_read == [tmp_path, tmp_path]
|
|
250
|
+
assert all_homes_read == [tmp_path, tmp_path, tmp_path]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_should_cap_the_meter_read_delay(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
254
|
+
monkeypatch.setattr(support, "METER_READ_ATTEMPTS", 6)
|
|
255
|
+
all_delays = _recorded_sleeps(monkeypatch)
|
|
256
|
+
all_failures = [support.WeeklyUtilizationProbeError("down") for _ in range(6)]
|
|
257
|
+
_claude_meter_reads(monkeypatch, all_failures)
|
|
258
|
+
|
|
259
|
+
meters, unread_reason = support.read_meters_with_backoff(support.read_claude_meters, Account(Product.CLAUDE, "one", tmp_path))
|
|
260
|
+
|
|
261
|
+
assert meters is None
|
|
262
|
+
assert unread_reason == "down"
|
|
263
|
+
assert all_delays == [1.0, 2.0, 4.0, 8.0, 8.0]
|
|
191
264
|
|
|
192
265
|
|
|
193
266
|
def test_should_cache_account_reading_under_injected_state_path(
|