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.
Files changed (43) hide show
  1. package/README.md +2 -2
  2. package/bin/claude +3 -3
  3. package/bin/codex +2 -2
  4. package/docs/AUTORESUME.md +9 -6
  5. package/docs/UNIFIED_SELECTOR.md +4 -3
  6. package/docs/VERIFICATION.md +2 -2
  7. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  20. package/lib/autoresume.py +4 -49
  21. package/lib/autoresume_probe.py +50 -0
  22. package/lib/selector_policy.py +3 -12
  23. package/package.json +3 -2
  24. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  35. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  36. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  37. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  38. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  39. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  40. package/tests/run-tests.sh +3 -3
  41. package/tests/test_autoresume_fallback.py +134 -0
  42. package/tests/test_autoresume_probe.py +119 -0
  43. 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. If none does, it **holds** and touches nothing, and
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` only
451
- # — never `valid` — so a pool with nowhere else to go still reaches the all-limited
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. `eligible` only: the all-limited fallback still sees it.
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
@@ -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
- - **Limit, login and refusal errors** probe with the current account excluded. They
87
- rotate only when the probe finds an **unlimited account other than the current
88
- one**.
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 unlimited account;
300
- - `soft`: only the all-limited fallback;
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
@@ -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`) and the
61
- plain ordering stands: known quota ahead of unknown, then highest effective headroom,
62
- then least-recently-selected.
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),
@@ -4,8 +4,8 @@
4
4
 
5
5
  ```bash
6
6
  tests/run-tests.sh # sandboxed compatibility tests, no quota
7
- python3 tests/test_selector.py # unified selector contract
8
- python3 tests/test_autoresume.py # auto-resume classifier/relaunch unit tests (also run by run-tests.sh)
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
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)
@@ -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: nothing to band, so keep the plain ordering
233
- # (which already ranks unknown-quota rows last and rotates on ties).
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
- winner = min(banded, key=_band_key(request["reservation_key"]))
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.48",
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 && python3 tests/test_selector.py && python3 tests/test_shim_path.py",
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"
@@ -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=soft") t_ok "probe with every account avoided answers tier=soft" ;;
8777
- *) t_fail "probe tier=soft" "$out" ;; esac
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.py'; then
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()