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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.75.2",
3
+ "version": "8.75.3",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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, or None when the probe fails.
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
- try:
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, read again once when the first read fails.
240
+ Its meters.
235
241
 
236
242
  Raises:
237
- CodexMeterUnreadError: Both reads failed; the message is the second failure.
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
- try:
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 = None
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 test_should_read_codex_meters_again_after_one_failed_read(
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
- meters = support.read_codex_account_meters(Account(Product.CODEX, "one", tmp_path))
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 test_should_keep_the_unread_reason_after_two_failed_codex_reads(
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
- second_unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
178
- all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [first_unread, second_unread])
179
- account = Account(Product.CODEX, "codex-3", tmp_path)
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
- first_reading = support.read_accounts(Product.CODEX, adapter, all_state=state, now=now)[0]
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 first_reading.meters is None
188
- assert first_reading.unread_reason == "codex app-server sent no rate-limit reply"
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(