claude-multiacc 2.0.48 → 2.0.50
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/README.md +2 -2
- package/bin/claude +3 -3
- package/bin/codex +2 -2
- package/docs/AUTORESUME.md +9 -6
- package/docs/UNIFIED_SELECTOR.md +4 -3
- package/docs/VERIFICATION.md +2 -2
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/autoresume.py +4 -49
- package/lib/autoresume_probe.py +50 -0
- package/lib/selector_policy.py +3 -12
- package/package.json +3 -2
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/run-tests.sh +3 -3
- package/tests/test_autoresume_fallback.py +134 -0
- package/tests/test_autoresume_probe.py +119 -0
- package/tests/test_selector_spread.py +65 -0
package/README.md
CHANGED
|
@@ -446,8 +446,8 @@ Every other launch, including `-p` and `codex exec`, runs exactly as before.
|
|
|
446
446
|
(`lib/autoresume.py`, which needs `python3`). It reads that run's transcript (codex: its
|
|
447
447
|
rollout).
|
|
448
448
|
- **Probe.** Once an error settles, the watcher asks the shim, in probe mode, whether
|
|
449
|
-
another account has headroom
|
|
450
|
-
Claude's own "continuing automatically at …" carries on.
|
|
449
|
+
another account has headroom, including the 90–99% fallback for quota errors. If none
|
|
450
|
+
does, it **holds** and Claude's own "continuing automatically at …" carries on.
|
|
451
451
|
- **Relaunch.** Otherwise it stops the TUI with SIGTERM and waits for the pane's shell
|
|
452
452
|
prompt. It then types a one-line relaunch of the shim into that **shell**. It never
|
|
453
453
|
sends a keystroke to the TUI, whose limit menu can add funds or spend the one-shot
|
package/bin/claude
CHANGED
|
@@ -447,8 +447,8 @@ ar_relaunch_apply() {
|
|
|
447
447
|
return 0
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
-
# AVOID: accounts this chain just left, each until its own epoch. Filters `eligible`
|
|
451
|
-
#
|
|
450
|
+
# AVOID: accounts this chain just left, each until its own epoch. Filters `eligible` and
|
|
451
|
+
# the soft fallback, never `valid`, so a pool with nowhere else to go reaches the hard
|
|
452
452
|
# fallback exactly as before. Builtins only: this runs once per candidate.
|
|
453
453
|
ar_avoided() { # $1 acct dir
|
|
454
454
|
local rest e id until
|
|
@@ -2038,7 +2038,7 @@ else
|
|
|
2038
2038
|
soft=()
|
|
2039
2039
|
hard=()
|
|
2040
2040
|
for d in "${valid[@]}"; do
|
|
2041
|
-
if limited_hard_blocked "$d"; then hard+=("$d"); else soft+=("$d"); fi
|
|
2041
|
+
if ar_avoided "$d" || limited_hard_blocked "$d"; then hard+=("$d"); else soft+=("$d"); fi
|
|
2042
2042
|
done
|
|
2043
2043
|
if [ "${#soft[@]}" -gt 0 ]; then
|
|
2044
2044
|
assess_telemetry "${soft[@]}"
|
package/bin/codex
CHANGED
|
@@ -1157,7 +1157,7 @@ for d in "$ACC_ROOT"/acct-*; do
|
|
|
1157
1157
|
fi
|
|
1158
1158
|
over_threshold "$d" && continue
|
|
1159
1159
|
# An auto-resume relaunch (or its probe) skips the account the session just left until
|
|
1160
|
-
# the reset it was handed.
|
|
1160
|
+
# the reset it was handed. The soft fallback also skips it; the hard fallback still sees it.
|
|
1161
1161
|
ar_avoided "$d" && continue
|
|
1162
1162
|
eligible+=("$d")
|
|
1163
1163
|
done
|
|
@@ -1318,7 +1318,7 @@ else
|
|
|
1318
1318
|
soft=()
|
|
1319
1319
|
hard=()
|
|
1320
1320
|
for d in "${valid[@]}"; do
|
|
1321
|
-
if limited_hard_blocked "$d"; then hard+=("$d"); else soft+=("$d"); fi
|
|
1321
|
+
if ar_avoided "$d" || limited_hard_blocked "$d"; then hard+=("$d"); else soft+=("$d"); fi
|
|
1322
1322
|
done
|
|
1323
1323
|
# A probe only ASKS: the fallback lines below would claim a pick nobody launched.
|
|
1324
1324
|
if [ "${#soft[@]}" -gt 0 ]; then
|
package/docs/AUTORESUME.md
CHANGED
|
@@ -83,9 +83,12 @@ shell: runs the shim again → token → marker on the old account → normal
|
|
|
83
83
|
4. **It asks before it acts.** The watcher runs the shim in *probe mode*
|
|
84
84
|
(`CLAUDE_MULTIACC_AR_PROBE=1`). This is the same candidate loop and the same argv the
|
|
85
85
|
relaunch will use. It prints `pick=acct-NN tier=…` and never execs.
|
|
86
|
-
- **
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
- **Usage-limit errors** probe with the current account excluded. They prefer an
|
|
87
|
+
account below the pool's exclusion threshold (90% by default), then accept a
|
|
88
|
+
different `soft` fallback with remaining quota. An account at 90–99% can still
|
|
89
|
+
serve; an exhausted account or one with an active client rejection cannot.
|
|
90
|
+
- **Login, refusal and model-limit errors** still require a different account below
|
|
91
|
+
the exclusion threshold.
|
|
89
92
|
- **Otherwise the watcher holds.** Nothing is stopped, and Claude's own "continuing
|
|
90
93
|
automatically" keeps running. The probe repeats every 60 s for as long as the error
|
|
91
94
|
stands.
|
|
@@ -296,9 +299,9 @@ CLAUDE_MULTIACC_AR_PROBE=1 CLAUDE_MULTIACC_AR_AVOID=acct-07:1790000000 claude
|
|
|
296
299
|
```
|
|
297
300
|
|
|
298
301
|
- **`tier`** is one of:
|
|
299
|
-
- `eligible`: an
|
|
300
|
-
- `soft`: only the
|
|
301
|
-
- `hard`: only exhausted accounts are left;
|
|
302
|
+
- `eligible`: an account below the pool's exclusion threshold;
|
|
303
|
+
- `soft`: only the fallback with remaining quota, excluding accounts the chain avoids;
|
|
304
|
+
- `hard`: only exhausted accounts or accounts the chain avoids are left;
|
|
302
305
|
- `none`: nothing is usable; exit 3.
|
|
303
306
|
- **`resets_at`** (codex only): a `soft` or `hard` answer may end with
|
|
304
307
|
` resets_at=<epoch>`, when that tier's reset is known. For `hard` it is the soonest
|
package/docs/UNIFIED_SELECTOR.md
CHANGED
|
@@ -57,9 +57,10 @@ weekly limits").
|
|
|
57
57
|
is what makes a burst of parallel launches fan out instead of stacking on one
|
|
58
58
|
account, and it is deterministic: the same request always yields the same winner.
|
|
59
59
|
|
|
60
|
-
With no usable quota anywhere the band cannot apply (`band_floor` is `null`)
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
With no usable quota anywhere the band cannot apply (`band_floor` is `null`). Every
|
|
61
|
+
eligible account is then a peer: least-recently-selected wins, followed by the same
|
|
62
|
+
request-key spread. Equal histories must not concentrate launches on the first provider,
|
|
63
|
+
account or runner identity merely because telemetry is unknown.
|
|
63
64
|
|
|
64
65
|
Success returns the concrete engine/account/runner generation, normalized score basis
|
|
65
66
|
(which still reports `effective_headroom` = min(weekly, session) for observability),
|
package/docs/VERIFICATION.md
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
tests/run-tests.sh # sandboxed compatibility tests, no quota
|
|
7
|
-
python3 tests
|
|
8
|
-
python3 tests
|
|
7
|
+
python3 -m unittest discover -s tests -p 'test_selector*.py' # unified selector contract and distribution
|
|
8
|
+
python3 -m unittest discover -s tests -p 'test_autoresume*.py' # watchers and fallback probes (also in run-tests.sh)
|
|
9
9
|
npm run test:commands # packed npm commands; run npm install --ignore-scripts first
|
|
10
10
|
claude-accounts verify # real matrix: `claude -p "reply OK"` per authed account
|
|
11
11
|
claude-accounts verify --quick # auth presence/expiry only, no inference
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/autoresume.py
CHANGED
|
@@ -55,6 +55,10 @@ import tempfile
|
|
|
55
55
|
import time
|
|
56
56
|
import traceback
|
|
57
57
|
|
|
58
|
+
# -I omits the script directory; load only helpers from this installation.
|
|
59
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
60
|
+
from autoresume_probe import ACCT_RE, ROTATE, parse_probe, probe_allows, probe_env
|
|
61
|
+
|
|
58
62
|
# ---- vocabulary ---------------------------------------------------------------------
|
|
59
63
|
|
|
60
64
|
PROVIDERS = ("claude", "codex")
|
|
@@ -79,10 +83,6 @@ REASONS = {
|
|
|
79
83
|
"crash": "the previous process exited unexpectedly",
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
# Classes that MOVE the session to a different account (the probe must offer an eligible
|
|
83
|
-
# one that is not the current account). transient/crash may land on the same account.
|
|
84
|
-
ROTATE = ("quota", "auth", "blocked", "model")
|
|
85
|
-
|
|
86
86
|
# How long the chain keeps away from the account it is leaving. quota uses the server's
|
|
87
87
|
# own reset instead (QUOTA_FALLBACK when it named none that is still ahead).
|
|
88
88
|
AVOID_SECONDS = {"auth": 3600, "blocked": 6 * 3600, "model": 5 * 3600}
|
|
@@ -110,7 +110,6 @@ CODEX_CANCEL = ("user_message", "task_started", "turn_aborted")
|
|
|
110
110
|
SHELLS = ("zsh", "bash", "sh", "dash", "ksh")
|
|
111
111
|
|
|
112
112
|
TOKEN_ALPHABET = string.ascii_letters + string.digits
|
|
113
|
-
ACCT_RE = re.compile(r"^acct-[A-Za-z0-9._-]{1,64}$")
|
|
114
113
|
PANE_RE = re.compile(r"^%[0-9]{1,9}$")
|
|
115
114
|
CHAIN_RE = re.compile(r"^[A-Za-z0-9]{1,64}$")
|
|
116
115
|
TOKEN_RE = re.compile(r"^[A-Za-z0-9]{8,64}$")
|
|
@@ -1100,50 +1099,6 @@ def notice_argv(sock, pane, provider, sid, sticky=True):
|
|
|
1100
1099
|
"claude-multiacc: auto-resume failed — run: " + resume_hint(provider, sid))
|
|
1101
1100
|
|
|
1102
1101
|
|
|
1103
|
-
# ---- probe --------------------------------------------------------------------------
|
|
1104
|
-
|
|
1105
|
-
PROBE_DROP = {
|
|
1106
|
-
"claude": ("CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_SHIM_ACTIVE",
|
|
1107
|
-
"CLAUDE_ACCOUNT", "CLAUDE_MULTIACC_AR"),
|
|
1108
|
-
"codex": ("CODEX_HOME", "CODEX_SHIM_ACTIVE", "CODEX_ACCOUNT", "CODEX_MULTIACC_AR"),
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
def probe_env(environ, provider, avoid):
|
|
1113
|
-
"""The watcher inherited the picked account's exports; the probe must select from
|
|
1114
|
-
scratch, so they go, and probe mode + the AVOID list come in."""
|
|
1115
|
-
env = {k: v for k, v in environ.items() if k not in PROBE_DROP[provider]}
|
|
1116
|
-
up = provider.upper()
|
|
1117
|
-
env[up + "_MULTIACC_AR_PROBE"] = "1"
|
|
1118
|
-
env[up + "_MULTIACC_AR_AVOID"] = avoid or ""
|
|
1119
|
-
return env
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
def parse_probe(out):
|
|
1123
|
-
"""The probe's ``pick=<acct> tier=<eligible|soft|hard|none>`` -> (pick, tier).
|
|
1124
|
-
|
|
1125
|
-
Trailing ``key=value`` fields (the codex shim's ``resets_at=<epoch>``) are accepted
|
|
1126
|
-
and ignored here."""
|
|
1127
|
-
pick, tier = "", ""
|
|
1128
|
-
for line in (out or "").split("\n"):
|
|
1129
|
-
m = re.match(r"^pick=(\S*) tier=(\S+)(?:\s+\w+=\S*)*\s*$", line.strip())
|
|
1130
|
-
if m:
|
|
1131
|
-
pick, tier = m.group(1), m.group(2)
|
|
1132
|
-
if pick and not ACCT_RE.match(pick):
|
|
1133
|
-
return "", ""
|
|
1134
|
-
if tier not in ("eligible", "soft", "hard", "none"):
|
|
1135
|
-
return "", ""
|
|
1136
|
-
return pick, tier
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
def probe_allows(cls, pick, tier, current):
|
|
1140
|
-
"""Rotate classes need an ELIGIBLE account that is not the one being left (holding
|
|
1141
|
-
beats landing on a soft/hard fallback); transient/crash may retry anywhere usable."""
|
|
1142
|
-
if cls in ROTATE:
|
|
1143
|
-
return tier == "eligible" and bool(pick) and pick != current
|
|
1144
|
-
return tier in ("eligible", "soft") and bool(pick)
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
1102
|
# ---- side effects -------------------------------------------------------------------
|
|
1148
1103
|
|
|
1149
1104
|
def descendants(table, pid):
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Probe environment, wire response and restart eligibility shared by both watchers."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
ACCT_RE = re.compile(r"^acct-[A-Za-z0-9._-]{1,64}$")
|
|
7
|
+
# These errors require a different account; transient/crash may retry the same one.
|
|
8
|
+
ROTATE = ("quota", "auth", "blocked", "model")
|
|
9
|
+
PROBE_DROP = {
|
|
10
|
+
"claude": ("CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_SHIM_ACTIVE",
|
|
11
|
+
"CLAUDE_ACCOUNT", "CLAUDE_MULTIACC_AR"),
|
|
12
|
+
"codex": ("CODEX_HOME", "CODEX_SHIM_ACTIVE", "CODEX_ACCOUNT", "CODEX_MULTIACC_AR"),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def probe_env(environ, provider, avoid):
|
|
17
|
+
"""Remove the watched account's exports so the shim selects from the whole pool."""
|
|
18
|
+
env = {k: v for k, v in environ.items() if k not in PROBE_DROP[provider]}
|
|
19
|
+
up = provider.upper()
|
|
20
|
+
env[up + "_MULTIACC_AR_PROBE"] = "1"
|
|
21
|
+
env[up + "_MULTIACC_AR_AVOID"] = avoid or ""
|
|
22
|
+
return env
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_probe(out):
|
|
26
|
+
"""Read pick/tier, accepting trailing fields such as Codex's resets_at epoch."""
|
|
27
|
+
pick, tier = "", ""
|
|
28
|
+
for line in (out or "").split("\n"):
|
|
29
|
+
match = re.match(r"^pick=(\S*) tier=(\S+)(?:\s+\w+=\S*)*\s*$", line.strip())
|
|
30
|
+
if match:
|
|
31
|
+
pick, tier = match.group(1), match.group(2)
|
|
32
|
+
if pick and not ACCT_RE.match(pick):
|
|
33
|
+
return "", ""
|
|
34
|
+
if tier not in ("eligible", "soft", "hard", "none"):
|
|
35
|
+
return "", ""
|
|
36
|
+
return pick, tier
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def probe_allows(cls, pick, tier, current):
|
|
40
|
+
"""Quota rotations may use remaining quota, as an ordinary shim launch already does.
|
|
41
|
+
|
|
42
|
+
The shim excludes chain-avoided accounts from soft fallback, and treats exhausted
|
|
43
|
+
accounts and observed client rejections as hard. Other rotation classes keep their
|
|
44
|
+
below-threshold requirement; transient/crash may retry anywhere still usable.
|
|
45
|
+
"""
|
|
46
|
+
if cls == "quota":
|
|
47
|
+
return tier in ("eligible", "soft") and bool(pick) and pick != current
|
|
48
|
+
if cls in ROTATE:
|
|
49
|
+
return tier == "eligible" and bool(pick) and pick != current
|
|
50
|
+
return tier in ("eligible", "soft") and bool(pick)
|
package/lib/selector_policy.py
CHANGED
|
@@ -119,14 +119,6 @@ def _apply_policy(request: dict, rows: list[dict]) -> tuple[tuple, int]:
|
|
|
119
119
|
return producer, alternatives
|
|
120
120
|
|
|
121
121
|
|
|
122
|
-
def _winner_key(row: dict) -> tuple:
|
|
123
|
-
descending_headroom = Decimal(row["effective_headroom"] or "-1").copy_negate()
|
|
124
|
-
return (
|
|
125
|
-
not row["quota_known"], descending_headroom,
|
|
126
|
-
row["last_selected_at"] is not None, row["last_selected_at"] or "",
|
|
127
|
-
row["engine"], row["account_id"], row["runner_id"], row["runner_generation"])
|
|
128
|
-
|
|
129
|
-
|
|
130
122
|
def _session_gate(rows: list[dict], gate: Decimal | None) -> list[dict]:
|
|
131
123
|
"""The FIRST cut: accounts whose 5h session bucket still has room.
|
|
132
124
|
|
|
@@ -229,13 +221,12 @@ def select(request: object) -> dict:
|
|
|
229
221
|
ranked = healthy or known
|
|
230
222
|
floor = _band_floor(ranked, band) if band is not None else None
|
|
231
223
|
if floor is None:
|
|
232
|
-
# No usable quota anywhere:
|
|
233
|
-
#
|
|
224
|
+
# No usable quota anywhere: every eligible row is an unknown peer.
|
|
225
|
+
# Keep LRU and the request spread instead of preferring a fixed identity.
|
|
234
226
|
banded = eligible
|
|
235
|
-
winner = min(banded, key=_winner_key)
|
|
236
227
|
else:
|
|
237
228
|
banded = [row for row in ranked if Decimal(row["weekly_remaining"]) >= floor]
|
|
238
|
-
|
|
229
|
+
winner = min(banded, key=_band_key(request["reservation_key"]))
|
|
239
230
|
rows.sort(key=_snapshot_key)
|
|
240
231
|
snapshot = canonical_sha256(rows)
|
|
241
232
|
chosen = dict(zip(("runner_id", "runner_generation", "engine", "account_id"), _row_identity(winner)))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.50",
|
|
4
4
|
"description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -56,7 +56,8 @@
|
|
|
56
56
|
"scripts": {
|
|
57
57
|
"postinstall": "node scripts/postinstall.mjs",
|
|
58
58
|
"test": "npm run test:core && npm run test:seeding && npm run test:commands",
|
|
59
|
-
"test:core": "bash tests/run-tests.sh &&
|
|
59
|
+
"test:core": "bash tests/run-tests.sh && npm run test:selector && python3 tests/test_shim_path.py",
|
|
60
|
+
"test:selector": "python3 -m unittest discover -s tests -p 'test_selector*.py'",
|
|
60
61
|
"test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_mcp_registry.py && npm run test:codex-settings",
|
|
61
62
|
"test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
|
|
62
63
|
"test:commands": "python3 tests/test_packaged_commands.py"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tests/run-tests.sh
CHANGED
|
@@ -8773,8 +8773,8 @@ done
|
|
|
8773
8773
|
out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_AR_PROBE=1 \
|
|
8774
8774
|
CLAUDE_MULTIACC_AR_AVOID="acct-01:$(( $(date +%s) + 3600 )),acct-02:$(( $(date +%s) + 3600 ))" \
|
|
8775
8775
|
claude --dangerously-skip-permissions </dev/null 2>&1)"
|
|
8776
|
-
case "$out" in "pick=acct-0"[12]" tier=
|
|
8777
|
-
*) t_fail "probe tier=
|
|
8776
|
+
case "$out" in "pick=acct-0"[12]" tier=hard") t_ok "probe with every account avoided answers tier=hard" ;;
|
|
8777
|
+
*) t_fail "probe tier=hard" "$out" ;; esac
|
|
8778
8778
|
ar_logged "$ARC" 'all-limited fallback' \
|
|
8779
8779
|
&& t_fail "a probe logs no fallback pick" "$(grep 'all-limited' "$ARC/selection.log")" \
|
|
8780
8780
|
|| t_ok "a probe logs no fallback pick"
|
|
@@ -9279,7 +9279,7 @@ else
|
|
|
9279
9279
|
fi
|
|
9280
9280
|
# ...and the auto-resume watcher, unit by unit: classifiers, argv sanitizer, relaunch
|
|
9281
9281
|
# files, budgets, tmux commands and a fake-runner walk through every stop/relaunch branch.
|
|
9282
|
-
if python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_autoresume
|
|
9282
|
+
if python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_autoresume*.py'; then
|
|
9283
9283
|
t_ok "auto-resume watcher unit suite"
|
|
9284
9284
|
else
|
|
9285
9285
|
t_fail "auto-resume watcher unit suite" "see unittest output above"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Quota fallback regressions using disposable pools and simulated processes only."""
|
|
3
|
+
|
|
4
|
+
import signal
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
import test_autoresume as fixtures
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QuotaFallbackTests(unittest.TestCase):
|
|
11
|
+
def scenario(self, provider):
|
|
12
|
+
case_type = fixtures.PoolCase if provider == "claude" else fixtures.CodexWatcherTests
|
|
13
|
+
case = case_type()
|
|
14
|
+
case.setUp()
|
|
15
|
+
self.addCleanup(case.tearDown)
|
|
16
|
+
reset = int(case.t0) + 7200
|
|
17
|
+
previous = "acct-03:%d" % (reset + 100)
|
|
18
|
+
if provider == "claude":
|
|
19
|
+
state = case.write_state(["--dangerously-skip-permissions"], avoid=previous)
|
|
20
|
+
case.registry()
|
|
21
|
+
path = case.transcript([fixtures.cc_quota(case.t0, resets=reset)])
|
|
22
|
+
else:
|
|
23
|
+
state = case.write_state(["--yolo", "resume", fixtures.CX_SID],
|
|
24
|
+
provider=provider, avoid=previous)
|
|
25
|
+
path = case.rollout([
|
|
26
|
+
fixtures.cx_meta(case.launched, cwd=str(case.work)),
|
|
27
|
+
fixtures.cx_tokens(case.t0, primary={"used_percent": 100,
|
|
28
|
+
"window_minutes": 300, "resets_at": reset}),
|
|
29
|
+
fixtures.cx_error(case.t0, "usage_limit_exceeded")])
|
|
30
|
+
watcher, runner = case.watcher(state)
|
|
31
|
+
runner.probe_out = "pick=acct-02 tier=soft\n"
|
|
32
|
+
runner.at(case.t0 + 1500, lambda: runner.exit(case.PID))
|
|
33
|
+
return case, watcher, runner, path
|
|
34
|
+
|
|
35
|
+
def assert_rotation(self, case, watcher, runner, provider):
|
|
36
|
+
self.assertEqual(runner.signals, [(case.PID, signal.SIGTERM)])
|
|
37
|
+
case.assert_sequence(runner, provider)
|
|
38
|
+
fields, argv = case.relaunch_of(runner)
|
|
39
|
+
reset = int(case.t0) + 7200
|
|
40
|
+
avoid = "acct-01:%d,acct-03:%d" % (reset, reset + 100)
|
|
41
|
+
sid = fixtures.SID if provider == "claude" else fixtures.CX_SID
|
|
42
|
+
expected = ["--dangerously-skip-permissions", "--resume", sid] if provider == "claude" \
|
|
43
|
+
else ["resume", "--yolo", sid]
|
|
44
|
+
self.assertEqual(argv, expected + [fixtures.ar.resume_prompt("quota")])
|
|
45
|
+
self.assertEqual((fields["provider"], fields["class"], fields["acct"], fields["sid"]),
|
|
46
|
+
(provider, "quota", "acct-01", sid))
|
|
47
|
+
self.assertEqual((fields["cwd"], fields["reset"], fields["depth"], fields["avoid"]),
|
|
48
|
+
(str(case.work), str(reset), "1", avoid))
|
|
49
|
+
self.assertEqual(fields["rtype"], "five_hour" if provider == "claude" else "")
|
|
50
|
+
self.assertEqual(runner.probes[-1]["env"][provider.upper() + "_MULTIACC_AR_AVOID"], avoid)
|
|
51
|
+
self.assertIsNotNone(watcher.tracker.pending)
|
|
52
|
+
self.assertIn("probe=acct-02", case.log_lines()[-1])
|
|
53
|
+
|
|
54
|
+
def test_quota_uses_other_soft_account_for_both_providers(self):
|
|
55
|
+
for provider in ("claude", "codex"):
|
|
56
|
+
with self.subTest(provider=provider):
|
|
57
|
+
case, watcher, runner, _ = self.scenario(provider)
|
|
58
|
+
self.assertEqual(watcher.run(), 0)
|
|
59
|
+
self.assert_rotation(case, watcher, runner, provider)
|
|
60
|
+
self.assertEqual(len(runner.probes), 1)
|
|
61
|
+
self.assertEqual(case.events(), ["watch", "detect", "switch"])
|
|
62
|
+
|
|
63
|
+
def test_hard_same_account_and_missing_candidates_hold(self):
|
|
64
|
+
replies = ("pick=acct-02 tier=hard", "pick=acct-01 tier=soft",
|
|
65
|
+
"pick=acct-01 tier=eligible", "pick= tier=none", "pick= tier=soft")
|
|
66
|
+
for provider in ("claude", "codex"):
|
|
67
|
+
for reply in replies:
|
|
68
|
+
with self.subTest(provider=provider, reply=reply):
|
|
69
|
+
case, watcher, runner, _ = self.scenario(provider)
|
|
70
|
+
runner.probe_out = reply + "\n"
|
|
71
|
+
runner.at(case.t0 + 15, lambda: runner.exit(case.PID))
|
|
72
|
+
self.assertEqual(watcher.run(), 0)
|
|
73
|
+
self.assertEqual(len(runner.probes), 1)
|
|
74
|
+
self.assertEqual((runner.signals, runner.sends, case.relaunch_files()), ([], [], []))
|
|
75
|
+
self.assertEqual(case.events(), ["watch", "detect", "hold"])
|
|
76
|
+
|
|
77
|
+
def test_other_rotation_classes_keep_the_eligible_requirement(self):
|
|
78
|
+
for cls in ("auth", "blocked", "model"):
|
|
79
|
+
with self.subTest(error_class=cls):
|
|
80
|
+
self.assertFalse(fixtures.ar.probe_allows(cls, "acct-02", "soft", "acct-01"))
|
|
81
|
+
self.assertFalse(fixtures.ar.probe_allows(cls, "acct-02", "hard", "acct-01"))
|
|
82
|
+
self.assertTrue(fixtures.ar.probe_allows(cls, "acct-02", "eligible", "acct-01"))
|
|
83
|
+
|
|
84
|
+
def test_user_input_during_soft_probe_cancels_rotation(self):
|
|
85
|
+
for provider in ("claude", "codex"):
|
|
86
|
+
with self.subTest(provider=provider):
|
|
87
|
+
case, watcher, runner, path = self.scenario(provider)
|
|
88
|
+
record = fixtures.cc_user(case.t0 + 5, "stop, I will continue manually") \
|
|
89
|
+
if provider == "claude" else fixtures.cx_event(case.t0 + 5,
|
|
90
|
+
{"type": "user_message", "message": "stop, I will continue manually"})
|
|
91
|
+
original_run = runner.run
|
|
92
|
+
|
|
93
|
+
def run(argv, timeout, env=None, cwd=None):
|
|
94
|
+
if argv[0] == case.self_path and not runner.probes:
|
|
95
|
+
fixtures.append(path, [record])
|
|
96
|
+
return original_run(argv, timeout, env=env, cwd=cwd)
|
|
97
|
+
|
|
98
|
+
runner.run = run
|
|
99
|
+
registry = case.root / "acct-01" / "sessions" / ("%d.json" % case.PID)
|
|
100
|
+
if provider == "claude":
|
|
101
|
+
runner.at(case.t0 + 15, registry.unlink)
|
|
102
|
+
runner.at(case.t0 + 15, lambda: runner.exit(case.PID))
|
|
103
|
+
self.assertEqual(watcher.run(), 0)
|
|
104
|
+
self.assertEqual(len(runner.probes), 1)
|
|
105
|
+
self.assertIsNone(watcher.tracker.pending)
|
|
106
|
+
self.assertEqual((runner.signals, runner.sends, case.relaunch_files()), ([], [], []))
|
|
107
|
+
|
|
108
|
+
def test_prolonged_hold_reprobes_and_recovers_when_soft_capacity_returns(self):
|
|
109
|
+
for provider in ("claude", "codex"):
|
|
110
|
+
with self.subTest(provider=provider):
|
|
111
|
+
case, watcher, runner, _ = self.scenario(provider)
|
|
112
|
+
runner.probe_out = "pick=acct-02 tier=hard\n"
|
|
113
|
+
runner.at(case.t0 + 600,
|
|
114
|
+
lambda: setattr(runner, "probe_out", "pick=acct-02 tier=soft\n"))
|
|
115
|
+
self.assertEqual(watcher.run(), 0)
|
|
116
|
+
self.assert_rotation(case, watcher, runner, provider)
|
|
117
|
+
self.assertEqual([round(probe["t"] - case.t0) for probe in runner.probes],
|
|
118
|
+
list(range(5, 606, 60)))
|
|
119
|
+
self.assertEqual(case.events(), ["watch", "detect", "hold", "switch"])
|
|
120
|
+
|
|
121
|
+
def test_soft_capacity_does_not_bypass_rotation_budget(self):
|
|
122
|
+
for provider in ("claude", "codex"):
|
|
123
|
+
with self.subTest(provider=provider):
|
|
124
|
+
case, watcher, runner, _ = self.scenario(provider)
|
|
125
|
+
watcher.st["hist"] = ",".join("quota:%d" % (case.t0 - offset) for offset in range(8))
|
|
126
|
+
runner.at(case.t0 + 15, lambda: runner.exit(case.PID))
|
|
127
|
+
self.assertEqual(watcher.run(), 0)
|
|
128
|
+
self.assertEqual((runner.probes, runner.signals, runner.sends), ([], [], []))
|
|
129
|
+
self.assertEqual(case.relaunch_files(), [])
|
|
130
|
+
self.assertIn("reason=rotate-budget", case.log_lines()[-1])
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
unittest.main()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Real shim probes distinguish remaining quota from accounts this chain rejected.
|
|
2
|
+
|
|
3
|
+
Every account, credential and provider executable is synthetic and local to a
|
|
4
|
+
temporary HOME. Probe mode must never launch the provider or poll its network.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
import unittest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AutoresumeProbeTests(unittest.TestCase):
|
|
20
|
+
def setUp(self):
|
|
21
|
+
work = tempfile.TemporaryDirectory(prefix="multiacc-ar-probe-")
|
|
22
|
+
self.addCleanup(work.cleanup)
|
|
23
|
+
self.home = Path(work.name)
|
|
24
|
+
self.now = int(time.time())
|
|
25
|
+
self.fakebin = self.home / "bin"
|
|
26
|
+
self.fakebin.mkdir()
|
|
27
|
+
self.env = {k: v for k, v in os.environ.items()
|
|
28
|
+
if not k.startswith(("CLAUDE_", "CODEX_", "ANTHROPIC_", "OPENAI_"))
|
|
29
|
+
and k not in ("TMUX", "TMUX_PANE")}
|
|
30
|
+
self.env.update(HOME=str(self.home), PATH=f"{self.fakebin}:{os.environ['PATH']}",
|
|
31
|
+
PYTHONDONTWRITEBYTECODE="1")
|
|
32
|
+
for name in ("claude", "codex", "security", "curl"):
|
|
33
|
+
script = self.fakebin / name
|
|
34
|
+
script.write_text('#!/bin/sh\nprintf "%s\\n" "$0" >> "$HOME/unexpected-calls"\nexit 79\n')
|
|
35
|
+
script.chmod(0o755)
|
|
36
|
+
for provider in ("claude", "codex"):
|
|
37
|
+
root = self.home / provider
|
|
38
|
+
root.mkdir()
|
|
39
|
+
up = provider.upper()
|
|
40
|
+
self.env[up + "_ACCOUNTS_ROOT"] = str(root)
|
|
41
|
+
for key in ("KEYCHAIN", "MCP", "AUTORESUME", "AUTO_RESET", "CLIENT_LIMITS", "PATH_PROBE"):
|
|
42
|
+
self.env[up + "_MULTIACC_" + key] = "0"
|
|
43
|
+
self.env[up + "_MULTIACC_NO_SYNC"] = "1"
|
|
44
|
+
self.env[up + "_MULTIACC_NO_DISTRIBUTE"] = "1"
|
|
45
|
+
self.env[up + "_MULTIACC_HEADROOM_BAND"] = "0"
|
|
46
|
+
accounts = [{"id": f"acct-{i:02}", "email": f"fixture-{i}@example.invalid", "home": "mac"}
|
|
47
|
+
for i in range(1, 4)]
|
|
48
|
+
(root / "accounts.json").write_text(json.dumps({
|
|
49
|
+
"version": 1, "server": "none", "threshold": 90, "accounts": accounts}))
|
|
50
|
+
for account, used in zip(accounts, (90, 96, 99)):
|
|
51
|
+
self.seed_account(provider, account["id"], used)
|
|
52
|
+
|
|
53
|
+
def seed_account(self, provider, account, used, reason="limits"):
|
|
54
|
+
path = self.home / provider / account
|
|
55
|
+
path.mkdir(exist_ok=True)
|
|
56
|
+
if provider == "claude":
|
|
57
|
+
credential = {"claudeAiOauth": {"accessToken": "synthetic", "refreshToken": "synthetic",
|
|
58
|
+
"expiresAt": (self.now + 86400) * 1000}}
|
|
59
|
+
filename = ".credentials.json"
|
|
60
|
+
else:
|
|
61
|
+
credential = {"tokens": {"access_token": "synthetic", "refresh_token": "synthetic"}}
|
|
62
|
+
filename = "auth.json"
|
|
63
|
+
(path / filename).write_text(json.dumps(credential))
|
|
64
|
+
(path / "limits.json").write_text(json.dumps({
|
|
65
|
+
"fetched_at": self.now, "weekly_percent": used, "session_percent": 0,
|
|
66
|
+
"max_percent": used, "weekly_resets_epoch": self.now + 86400, "buckets": []}))
|
|
67
|
+
marked = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self.now))
|
|
68
|
+
(path / ".limited").write_text(f"{self.now + 86400}\n"
|
|
69
|
+
f"bucket=weekly_all percent={used} marked_at={marked} reason={reason}\n")
|
|
70
|
+
|
|
71
|
+
def probe(self, provider, avoid=""):
|
|
72
|
+
up = provider.upper()
|
|
73
|
+
env = {**self.env, up + "_MULTIACC_AR_PROBE": "1", up + "_MULTIACC_AR_AVOID": avoid}
|
|
74
|
+
result = subprocess.run([str(REPO / "bin" / provider)], env=env, cwd=self.home,
|
|
75
|
+
capture_output=True, text=True, input="", timeout=30)
|
|
76
|
+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
77
|
+
self.assertFalse((self.home / "unexpected-calls").exists(), result.stderr)
|
|
78
|
+
fields = dict(field.split("=", 1) for field in result.stdout.split())
|
|
79
|
+
return fields["pick"], fields["tier"]
|
|
80
|
+
|
|
81
|
+
def test_remaining_quota_is_soft_and_strictly_ranked(self):
|
|
82
|
+
for provider in ("claude", "codex"):
|
|
83
|
+
with self.subTest(provider=provider):
|
|
84
|
+
self.assertEqual(self.probe(provider), ("acct-01", "soft"))
|
|
85
|
+
|
|
86
|
+
def test_current_and_previous_chain_accounts_cannot_win_soft_fallback(self):
|
|
87
|
+
for provider in ("claude", "codex"):
|
|
88
|
+
with self.subTest(provider=provider):
|
|
89
|
+
avoid = f"acct-01:{self.now + 3600}"
|
|
90
|
+
self.assertEqual(self.probe(provider, avoid), ("acct-02", "soft"))
|
|
91
|
+
avoid += f",acct-02:{self.now + 3600}"
|
|
92
|
+
self.assertEqual(self.probe(provider, avoid), ("acct-03", "soft"))
|
|
93
|
+
|
|
94
|
+
def test_all_avoided_accounts_cannot_be_reported_as_usable(self):
|
|
95
|
+
avoid = ",".join(f"acct-{i:02}:{self.now + 3600}" for i in range(1, 4))
|
|
96
|
+
for provider in ("claude", "codex"):
|
|
97
|
+
with self.subTest(provider=provider):
|
|
98
|
+
self.assertEqual(self.probe(provider, avoid)[1], "hard")
|
|
99
|
+
|
|
100
|
+
def test_expired_avoidance_restores_remaining_quota(self):
|
|
101
|
+
for provider in ("claude", "codex"):
|
|
102
|
+
with self.subTest(provider=provider):
|
|
103
|
+
self.assertEqual(self.probe(provider, f"acct-01:{self.now - 1}"), ("acct-01", "soft"))
|
|
104
|
+
|
|
105
|
+
def test_rejected_and_exhausted_accounts_remain_hard(self):
|
|
106
|
+
for provider in ("claude", "codex"):
|
|
107
|
+
for reason, used in (("client-rate-limit", 90), ("error-cooldown", 90), ("limits", 100)):
|
|
108
|
+
with self.subTest(provider=provider, reason=reason):
|
|
109
|
+
self.seed_account(provider, "acct-01", used, reason)
|
|
110
|
+
self.assertEqual(self.probe(provider), ("acct-02", "soft"))
|
|
111
|
+
for account in ("acct-02", "acct-03"):
|
|
112
|
+
self.seed_account(provider, account, used, reason)
|
|
113
|
+
self.assertEqual(self.probe(provider)[1], "hard")
|
|
114
|
+
self.seed_account(provider, "acct-02", 96)
|
|
115
|
+
self.seed_account(provider, "acct-03", 99)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
unittest.main()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Unknown quota must not turn a launch burst into a fixed-identity preference."""
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from test_selector import _candidate, _request
|
|
7
|
+
from selector_policy import select
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnknownQuotaSpreadTests(unittest.TestCase):
|
|
11
|
+
def test_unknown_and_partial_quota_spread_across_accounts_and_providers(self):
|
|
12
|
+
for weekly, session in ((None, None), (0, None), (None, 0)):
|
|
13
|
+
with self.subTest(weekly=weekly, session=session):
|
|
14
|
+
pool = [_candidate(engine=engine, account_id=f"acct-{index:02d}",
|
|
15
|
+
weekly_pct=weekly, session_pct=session)
|
|
16
|
+
for engine in ("claude", "codex") for index in range(4)]
|
|
17
|
+
counts = Counter()
|
|
18
|
+
for index in range(120):
|
|
19
|
+
response = select(_request(pool, reservation_key=f"burst-{index}"))
|
|
20
|
+
self.assertTrue(response["ok"])
|
|
21
|
+
self.assertIsNone(response["band_floor"])
|
|
22
|
+
self.assertEqual(response["session_ok_count"], 0)
|
|
23
|
+
counts[(response["engine"], response["account_id"])] += 1
|
|
24
|
+
self.assertEqual(len(counts), len(pool), counts)
|
|
25
|
+
self.assertLess(max(counts.values()), 40, counts)
|
|
26
|
+
|
|
27
|
+
def test_equal_history_spreads_same_account_id_across_runner_identities(self):
|
|
28
|
+
pool = [_candidate(runner_id=index, engine="codex", account_id="acct-01",
|
|
29
|
+
weekly_pct=None, session_pct=None,
|
|
30
|
+
reservation_history={"active_expires_at": None,
|
|
31
|
+
"last_selected_at": "2026-08-25T08:00:00Z"})
|
|
32
|
+
for index in range(1, 9)]
|
|
33
|
+
counts = Counter(select(_request(pool, reservation_key=f"burst-{index}"))["runner_id"]
|
|
34
|
+
for index in range(120))
|
|
35
|
+
self.assertEqual(len(counts), len(pool), counts)
|
|
36
|
+
self.assertLess(max(counts.values()), 40, counts)
|
|
37
|
+
|
|
38
|
+
def test_unknown_quota_preserves_lru_and_active_reservation_exclusion(self):
|
|
39
|
+
pool = [_candidate(account_id=name, weekly_pct=None, session_pct=None,
|
|
40
|
+
reservation_history={"active_expires_at": expiry,
|
|
41
|
+
"last_selected_at": last})
|
|
42
|
+
for name, expiry, last in (
|
|
43
|
+
("reserved", "2026-08-25T09:10:00Z", None),
|
|
44
|
+
("unused", None, None),
|
|
45
|
+
("old", None, "2026-08-25T08:00:00Z"),
|
|
46
|
+
("recent", None, "2026-08-25T08:50:00Z"))]
|
|
47
|
+
for index in range(20):
|
|
48
|
+
response = select(_request(pool, reservation_key=f"burst-{index}"))
|
|
49
|
+
self.assertEqual(response["account_id"], "unused")
|
|
50
|
+
self.assertEqual(response["eligible_count"], 3)
|
|
51
|
+
for index in range(20):
|
|
52
|
+
response = select(_request(pool[2:], reservation_key=f"burst-{index}"))
|
|
53
|
+
self.assertEqual(response["account_id"], "old")
|
|
54
|
+
|
|
55
|
+
def test_unknown_quota_remains_deterministic_and_input_order_independent(self):
|
|
56
|
+
pool = [_candidate(account_id=f"acct-{index:02d}", weekly_pct=None, session_pct=None)
|
|
57
|
+
for index in range(8)]
|
|
58
|
+
response = select(_request(pool, reservation_key="stable"))
|
|
59
|
+
self.assertEqual(response, select(_request(pool, reservation_key="stable")))
|
|
60
|
+
reversed_response = select(_request(list(reversed(pool)), reservation_key="stable"))
|
|
61
|
+
self.assertEqual(response["account_id"], reversed_response["account_id"])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
unittest.main()
|