claude-dev-env 8.38.0 → 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
CHANGED
|
@@ -18,7 +18,7 @@ python scripts/account_broker.py run --product claude --report report.json -- cl
|
|
|
18
18
|
|
|
19
19
|
`check` prints nothing and exits 3 while every account is below its floor. `run` sets only `CLAUDE_CONFIG_DIR` or `CODEX_HOME` for each attempt. It replays the same stdin bytes when a usage limit or start failure leads to another account. It writes the command's stdout to stdout and diagnostics to stderr. A resumed Claude session uses the account bound to its session when that account has room.
|
|
20
20
|
|
|
21
|
-
The broker stores cached meters, spent marks, and Claude session bindings in one JSON file under `~/.claude/account-broker`.
|
|
21
|
+
The broker stores cached meters, spent marks, and Claude session bindings in one JSON file under `~/.claude/account-broker`. Every broker process for the same user reads that file, so a spent mark pauses that account for every job. A write holds an exclusive lock on the sibling `state.json.lock`, re-reads the file, merges, and swaps in a sibling temporary file with `os.replace`. The merge keeps the latest reset for each spent mark and the newest meter read for each account.
|
|
22
22
|
|
|
23
23
|
## Exit codes and report
|
|
24
24
|
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import subprocess
|
|
8
|
+
import sys
|
|
8
9
|
import tempfile
|
|
9
10
|
import threading
|
|
10
11
|
from contextlib import contextmanager
|
|
@@ -12,6 +13,11 @@ from datetime import datetime, timezone
|
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
from typing import Iterator, Sequence
|
|
14
15
|
|
|
16
|
+
if sys.platform == "win32":
|
|
17
|
+
import msvcrt
|
|
18
|
+
else:
|
|
19
|
+
import fcntl
|
|
20
|
+
|
|
15
21
|
import codex_account_choice
|
|
16
22
|
import codex_account_meters
|
|
17
23
|
from claude_account_profile import default_profile_home, validate_profile_name
|
|
@@ -19,6 +25,7 @@ from claude_chain_usage import WeeklyUtilizationProbeError, probe_account_meters
|
|
|
19
25
|
from dev_env_scripts_constants.account_broker_constants import (
|
|
20
26
|
BROKER_STATE_DIRECTORY_NAME,
|
|
21
27
|
BROKER_STATE_FILE_NAME,
|
|
28
|
+
BROKER_STATE_LOCK_SUFFIX,
|
|
22
29
|
BROKER_STATE_TEMP_SUFFIX,
|
|
23
30
|
Account,
|
|
24
31
|
BrokerConfigurationError,
|
|
@@ -222,18 +229,79 @@ def _load_state(path: Path) -> dict[str, object]:
|
|
|
222
229
|
return {key: parsed_state.get(key) if isinstance(parsed_state.get(key), dict) else {} for key in ("meters", "spent", "affinity")}
|
|
223
230
|
|
|
224
231
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
232
|
+
if sys.platform == "win32":
|
|
233
|
+
|
|
234
|
+
def _acquire_state_lock(lock_descriptor: int) -> None:
|
|
235
|
+
os.lseek(lock_descriptor, 0, os.SEEK_SET)
|
|
236
|
+
msvcrt.locking(lock_descriptor, msvcrt.LK_LOCK, 1)
|
|
237
|
+
|
|
238
|
+
def _release_state_lock(lock_descriptor: int) -> None:
|
|
239
|
+
os.lseek(lock_descriptor, 0, os.SEEK_SET)
|
|
240
|
+
msvcrt.locking(lock_descriptor, msvcrt.LK_UNLCK, 1)
|
|
241
|
+
|
|
242
|
+
else:
|
|
243
|
+
|
|
244
|
+
def _acquire_state_lock(lock_descriptor: int) -> None:
|
|
245
|
+
fcntl.flock(lock_descriptor, fcntl.LOCK_EX)
|
|
246
|
+
|
|
247
|
+
def _release_state_lock(lock_descriptor: int) -> None:
|
|
248
|
+
fcntl.flock(lock_descriptor, fcntl.LOCK_UN)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@contextmanager
|
|
252
|
+
def _state_lock(path: Path) -> Iterator[None]:
|
|
253
|
+
"""Hold an exclusive operating-system lock on the state file's sibling lock file."""
|
|
254
|
+
lock_descriptor = os.open(path.with_name(path.name + BROKER_STATE_LOCK_SUFFIX), os.O_CREAT | os.O_RDWR)
|
|
228
255
|
try:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
256
|
+
_acquire_state_lock(lock_descriptor)
|
|
257
|
+
try:
|
|
258
|
+
yield
|
|
259
|
+
finally:
|
|
260
|
+
_release_state_lock(lock_descriptor)
|
|
234
261
|
finally:
|
|
235
|
-
|
|
236
|
-
|
|
262
|
+
os.close(lock_descriptor)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _read_at(entry: object) -> float:
|
|
266
|
+
if not isinstance(entry, dict):
|
|
267
|
+
return float("-inf")
|
|
268
|
+
read_at = entry.get("read_at")
|
|
269
|
+
return float(read_at) if isinstance(read_at, (int, float)) else float("-inf")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _merged_state(all_saved_state: dict[str, object], all_unsaved_state: dict[str, object]) -> dict[str, object]:
|
|
273
|
+
"""Combine two state documents so each writer keeps the other writer's entries.
|
|
274
|
+
|
|
275
|
+
A spent mark keeps its latest reset, a meter entry keeps its newest read,
|
|
276
|
+
and the unsaved session bindings take their keys.
|
|
277
|
+
"""
|
|
278
|
+
all_meters = dict(all_saved_state["meters"])
|
|
279
|
+
for each_key, each_entry in all_unsaved_state["meters"].items():
|
|
280
|
+
if _read_at(each_entry) >= _read_at(all_meters.get(each_key)):
|
|
281
|
+
all_meters[each_key] = each_entry
|
|
282
|
+
all_spent = dict(all_saved_state["spent"])
|
|
283
|
+
for each_key, each_reset in all_unsaved_state["spent"].items():
|
|
284
|
+
saved_reset = all_spent.get(each_key)
|
|
285
|
+
if not isinstance(saved_reset, (int, float)) or (isinstance(each_reset, (int, float)) and each_reset > saved_reset):
|
|
286
|
+
all_spent[each_key] = each_reset
|
|
287
|
+
return {"meters": all_meters, "spent": all_spent, "affinity": {**all_saved_state["affinity"], **all_unsaved_state["affinity"]}}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _save_state(path: Path, all_state: dict[str, object]) -> None:
|
|
291
|
+
"""Merge the caller's state into the saved file under a lock, then refresh the caller's copy."""
|
|
292
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
with _state_lock(path):
|
|
294
|
+
all_state.update(_merged_state(_load_state(path), all_state))
|
|
295
|
+
temporary_name: str | None = None
|
|
296
|
+
try:
|
|
297
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, prefix="state-", suffix=BROKER_STATE_TEMP_SUFFIX, delete=False, newline="\n") as stream:
|
|
298
|
+
temporary_name = stream.name
|
|
299
|
+
json.dump(all_state, stream, indent=REPORT_INDENT_SPACES, sort_keys=True)
|
|
300
|
+
stream.write("\n")
|
|
301
|
+
os.replace(temporary_name, path)
|
|
302
|
+
finally:
|
|
303
|
+
if temporary_name is not None and os.path.exists(temporary_name):
|
|
304
|
+
os.unlink(temporary_name)
|
|
237
305
|
|
|
238
306
|
|
|
239
307
|
def _state_key(account: Account) -> str:
|
|
@@ -105,3 +105,23 @@ def test_should_extract_session_id_and_restore_runner() -> None:
|
|
|
105
105
|
assert support.subprocess_runner is runner
|
|
106
106
|
assert support.subprocess_runner is original
|
|
107
107
|
assert support.extract_session_id_from_stdout('{"session_id":"one"}') == "one"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_should_keep_every_concurrent_spent_mark_and_newest_meter_reading(tmp_path: Path) -> None:
|
|
111
|
+
state_path = tmp_path / "broker" / "state.json"
|
|
112
|
+
first_worker = support._load_state(state_path)
|
|
113
|
+
second_worker = support._load_state(state_path)
|
|
114
|
+
first_worker["spent"]["codex:one"] = 2000.0
|
|
115
|
+
first_worker["meters"]["codex:one"] = {"read_at": 500.0, "meters": None}
|
|
116
|
+
support._save_state(state_path, first_worker)
|
|
117
|
+
second_worker["spent"]["codex:two"] = 3000.0
|
|
118
|
+
second_worker["meters"]["codex:one"] = {"read_at": 100.0, "meters": None}
|
|
119
|
+
support._save_state(state_path, second_worker)
|
|
120
|
+
stale_worker = {"meters": {}, "spent": {"codex:one": 1000.0}, "affinity": {"session": "one"}}
|
|
121
|
+
support._save_state(state_path, stale_worker)
|
|
122
|
+
|
|
123
|
+
saved = support._load_state(state_path)
|
|
124
|
+
|
|
125
|
+
assert saved["spent"] == {"codex:one": 2000.0, "codex:two": 3000.0}
|
|
126
|
+
assert saved["meters"]["codex:one"]["read_at"] == 500.0
|
|
127
|
+
assert saved["affinity"] == {"session": "one"}
|