claude-multiacc 2.0.20 → 2.0.21

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.
@@ -11,13 +11,15 @@ from datetime import datetime, timezone
11
11
  from decimal import Decimal, InvalidOperation
12
12
 
13
13
  SCHEMA = "claude-multiacc/pool-selection.v2"
14
- # NOT bumped for the headroom band. This string is a BUILD FENCE, not a changelog:
15
- # a queued task records the version that reserved its account, and the claim function
16
- # (agent-sdk 0005_delivery_claim_functions.sql) only lets a runner advertising the same
17
- # string claim it while the runner's value is a hardcoded constant shipped in its
18
- # bundle. With Macs routinely several builds behind, bumping this stops every lagging
19
- # Mac from claiming any new work until it updates. The band is additive and optional:
20
- # the request shape, the response shape and every consumer are unchanged.
14
+ # NOT bumped for the headroom band, and NOT bumped for the session gate either. This
15
+ # string is a BUILD FENCE, not a changelog: a queued task records the version that
16
+ # reserved its account, and the claim function (agent-sdk
17
+ # 0005_delivery_claim_functions.sql) only lets a runner advertising the same string
18
+ # claim it while the runner's value is a hardcoded constant shipped in its bundle.
19
+ # With Macs routinely several builds behind, bumping this stops every lagging Mac from
20
+ # claiming any new work until it updates. Both knobs are additive and optional: the
21
+ # request only grows optional keys, the response only grows fields, and every existing
22
+ # consumer keeps reading exactly what it read before.
21
23
  SELECTOR_VERSION = "2.0.1"
22
24
  TIME_RE = re.compile(
23
25
  r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-5][0-9]"
@@ -33,9 +35,13 @@ REQUEST_KEYS = {
33
35
  "schema", "database_now", "policy", "required_engine", "producer_identity",
34
36
  "excluded_identities", "candidates", "reservation_key"}
35
37
  # Optional because a caller pinned to an older panel build still sends exactly
36
- # REQUEST_KEYS; absent, the policy applies its own default band.
37
- OPTIONAL_REQUEST_KEYS = {"headroom_band"}
38
+ # REQUEST_KEYS; absent, the policy applies its own defaults. app-robot's panel sends
39
+ # headroom_band from its own setting and never sends session_gate, so the gate default
40
+ # below is what every panel launch gets (operator's 2026-09-03 decision: session
41
+ # headroom gates FIRST, then the weekly band picks among the survivors).
42
+ OPTIONAL_REQUEST_KEYS = {"headroom_band", "session_gate"}
38
43
  DEFAULT_HEADROOM_BAND = "30"
44
+ DEFAULT_SESSION_GATE = "50"
39
45
  POLICIES = {
40
46
  "default_claude", "default_codex", "default_both", "explicit", "producer_retry", "reviewer"}
41
47
 
@@ -180,13 +186,16 @@ def request_error(request: object) -> tuple[str, dict] | None:
180
186
  keys = set(request)
181
187
  if not REQUEST_KEYS <= keys or keys - REQUEST_KEYS - OPTIONAL_REQUEST_KEYS:
182
188
  return "invalid_request", {"field": "$"}
183
- if "headroom_band" in request:
184
- band, _text = decimal_value(request["headroom_band"])
185
- # decimal_value clamps to [0,100] and rejects non-numeric shapes; a band
186
- # the caller cannot express exactly must fail loudly rather than silently
187
- # widen selection to the whole pool.
188
- if band is None or isinstance(request["headroom_band"], bool):
189
- return "invalid_request", {"field": "headroom_band"}
189
+ for knob in ("headroom_band", "session_gate"):
190
+ if knob not in request:
191
+ continue
192
+ value, _text = decimal_value(request[knob])
193
+ # decimal_value clamps to [0,100] and rejects non-numeric shapes; a knob the
194
+ # caller cannot express exactly must fail loudly rather than silently widen
195
+ # selection to the whole pool (band) or wave every session-heavy account
196
+ # through (gate). Both are validated identically so the panel can send either.
197
+ if value is None or isinstance(request[knob], bool):
198
+ return "invalid_request", {"field": knob}
190
199
  try:
191
200
  timestamp(request["database_now"])
192
201
  except ValueError:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.20",
3
+ "version": "2.0.21",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -317,7 +317,7 @@ check "passthrough with CLAUDE_CODE_OAUTH_TOKEN" "CFG=none" "$out"
317
317
  out="$(CLAUDE_MULTIACC_DISABLE=1 claude 2>&1)"
318
318
  check "passthrough when disabled" "CFG=none" "$out"
319
319
 
320
- # ---- 5. headroom selection: most WEEKLY headroom wins (session is only a tiebreaker) --
320
+ # ---- 5. headroom selection: the session gate first, then the WEEKLY headroom band ----
321
321
  lj() { # lj <weekly> <session> <max> -> a fresh limits.json body
322
322
  printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":%s,"max_percent":%s,"buckets":[]}' "$now" "$1" "$2" "$3"
323
323
  }
@@ -360,28 +360,131 @@ done
360
360
  [ "$all1" = 1 ] && t_ok "Claude headroom band 0 restores strict ranking" \
361
361
  || t_fail "Claude zero headroom band" "the runner-up was selected"
362
362
 
363
- # THE KEY CASE from the research: a high (but sub-threshold) SESSION bucket must NOT
364
- # deprioritize an account whose weekly headroom is better. acct-01: session 85, weekly 10;
365
- # acct-02: session 20, weekly 70. Both eligible (max<90). acct-01 is the better pick —
366
- # its near-full bucket is the 5h session (self-heals), weekly is nearly untouched.
363
+ # THE KEY CASE, REVERSED on 2026-09-03. It used to assert that a high (but
364
+ # sub-threshold) SESSION bucket must not deprioritize an account with better weekly
365
+ # headroom. The operator asked for the opposite: "among accounts where high session
366
+ # limits it must choose randomly from ones where highest weekly limits" an account
367
+ # whose 5h bucket is nearly spent is about to be rejected whatever its weekly headroom.
368
+ # So the SESSION GATE (default 50) is the FIRST cut and the 30-point weekly band ranks
369
+ # only what clears it. acct-01: session 85 (past the gate), weekly 10; acct-02: session
370
+ # 20, weekly 70. Both eligible (max<90) — acct-02 wins every time now.
367
371
  lj 10 85 85 > "$ACC/acct-01/limits.json"
368
372
  lj 70 20 70 > "$ACC/acct-02/limits.json"
373
+ all2=1
374
+ for _ in $(seq 1 15); do
375
+ case "$(claude 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
376
+ done
377
+ [ "$all2" = "1" ] && t_ok "a session bucket past the gate is skipped while a fresher one exists (70w/20s over 10w/85s)" \
378
+ || t_fail "session gate" "the account with its 5h bucket at 85% was still selected"
379
+ # The pick has to be auditable: the log names the gate and how many cleared it.
380
+ : > "$ACC/selection.log"
381
+ claude >/dev/null 2>&1
382
+ grep -qE 'acct-02 weekly=70% session=20% band=30 band-count=1 session-gate=50 session-ok=1 pwd=' "$ACC/selection.log" \
383
+ && t_ok "the selection log carries session-gate=50 session-ok=1 when one of two clears" \
384
+ || t_fail "session gate log" "$(tail -1 "$ACC/selection.log")"
385
+
386
+ # Both session buckets inside the gate: the gate has nothing to say and weekly decides,
387
+ # exactly as before the change.
388
+ lj 10 45 45 > "$ACC/acct-01/limits.json"
389
+ lj 70 20 70 > "$ACC/acct-02/limits.json"
369
390
  all1=1
370
391
  for _ in $(seq 1 15); do
371
392
  case "$(claude 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
372
393
  done
373
- [ "$all1" = "1" ] && t_ok "high session does NOT beat better weekly headroom (10w/85s over 70w/20s)" \
374
- || t_fail "weekly-over-session" "ranked the account with less weekly headroom higher"
394
+ [ "$all1" = "1" ] && t_ok "with both sessions inside the gate weekly headroom decides (10w/45s over 70w/20s)" \
395
+ || t_fail "session gate no-op" "the gate changed a ranking where every candidate cleared it"
375
396
 
376
- # In strict mode, equal weekly usage still uses session usage as its tiebreaker.
397
+ # Nobody clears the gate: it COMPARES candidates, it never empties the pool — it steps
398
+ # aside and weekly ranks the whole set (10w wins), and the log says session-ok=0.
399
+ lj 10 85 85 > "$ACC/acct-01/limits.json"
400
+ lj 70 60 70 > "$ACC/acct-02/limits.json"
401
+ : > "$ACC/selection.log"
402
+ all1=1
403
+ for _ in $(seq 1 15); do
404
+ case "$(claude 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
405
+ done
406
+ [ "$all1" = "1" ] && t_ok "with nobody inside the gate it steps aside and weekly ranks (10w/85s over 70w/60s)" \
407
+ || t_fail "session gate step-aside" "an empty gate emptied the pool instead of stepping aside"
408
+ grep -qE 'acct-01 weekly=10% session=85% band=30 band-count=1 session-gate=50 session-ok=0 pwd=' "$ACC/selection.log" \
409
+ && t_ok "the selection log carries session-ok=0 when the gate steps aside" \
410
+ || t_fail "session gate log" "$(tail -1 "$ACC/selection.log")"
411
+
412
+ # The gate is a knob, like the band: 100 turns it off and pure weekly ranking returns.
413
+ lj 10 85 85 > "$ACC/acct-01/limits.json"
414
+ lj 70 20 70 > "$ACC/acct-02/limits.json"
415
+ all1=1
416
+ for _ in $(seq 1 15); do
417
+ case "$(CLAUDE_MULTIACC_SESSION_GATE=100 claude 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
418
+ done
419
+ [ "$all1" = "1" ] && t_ok "CLAUDE_MULTIACC_SESSION_GATE=100 disables the gate" \
420
+ || t_fail "session gate off" "the gate still fired at 100"
421
+ # ...and garbage in that env var falls back to the 50-point default, never to "off".
422
+ all2=1
423
+ for _ in $(seq 1 15); do
424
+ case "$(CLAUDE_MULTIACC_SESSION_GATE=abc claude 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
425
+ done
426
+ [ "$all2" = "1" ] && t_ok "a non-numeric CLAUDE_MULTIACC_SESSION_GATE falls back to 50" \
427
+ || t_fail "session gate validation" "a garbage gate value changed the outcome"
428
+
429
+ # Equal weekly usage: the GATE decides, not a tiebreak. Session stopped being a ranking
430
+ # input inside the band on 2026-09-03 — acct-02's 80-point session bucket simply never
431
+ # reaches the weekly comparison, whether the band is strict or the default 30.
377
432
  lj 40 20 40 > "$ACC/acct-01/limits.json"
378
433
  lj 40 80 80 > "$ACC/acct-02/limits.json"
379
434
  all1=1
380
435
  for _ in $(seq 1 15); do
381
436
  case "$(CLAUDE_MULTIACC_HEADROOM_BAND=0 claude 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
382
437
  done
383
- [ "$all1" = "1" ] && t_ok "strict mode: equal weekly usage uses the session tiebreak" \
384
- || t_fail "session tiebreak" "did not use session to break a weekly tie"
438
+ [ "$all1" = "1" ] && t_ok "strict mode: the session gate decides an exact weekly tie" \
439
+ || t_fail "session gate tie" "a weekly tie was not resolved by the session gate"
440
+ all1=1
441
+ for _ in $(seq 1 15); do
442
+ case "$(claude 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
443
+ done
444
+ [ "$all1" = "1" ] && t_ok "the gate (not the band) removes the session-heavy half of a weekly tie" \
445
+ || t_fail "session gate tie" "the default band let the 80-point session account back in"
446
+
447
+ # Inside the gate, session is NOT a tiebreaker any more: an exact weekly tie between two
448
+ # gate-clearing accounts is a coin flip even in strict mode (it used to go to the lower
449
+ # session, through the old weekly*1000+session score).
450
+ lj 40 20 40 > "$ACC/acct-01/limits.json"
451
+ lj 40 45 45 > "$ACC/acct-02/limits.json"
452
+ hits1=0; hits2=0
453
+ for _ in $(seq 1 20); do
454
+ case "$(CLAUDE_MULTIACC_HEADROOM_BAND=0 claude 2>&1)" in
455
+ *CFG=acct-01*) hits1=$((hits1+1)) ;;
456
+ *CFG=acct-02*) hits2=$((hits2+1)) ;;
457
+ esac
458
+ done
459
+ { [ "$hits1" -gt 0 ] && [ "$hits2" -gt 0 ]; } \
460
+ && t_ok "strict mode: session does not break an exact weekly tie inside the gate (acct-01=$hits1 acct-02=$hits2)" \
461
+ || t_fail "session tiebreak removed" "acct-01=$hits1 acct-02=$hits2 (want both >0)"
462
+
463
+ # Clearing the gate takes BOTH readings, as in pool-selection.v2: a fresh file with a
464
+ # session reading but no weekly one must not become the sole gate-clearer and win the
465
+ # all-gated tie over an account with a truthful weekly reading — not even with a
466
+ # max_percent to fall back on (the shims used to rank on that; the policy never could).
467
+ # (No writer produces such a file; this pins parity with lib/selector_policy.py.)
468
+ printf '{"fetched_at":%s,"max_percent":10,"session_percent":10,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
469
+ lj 20 80 80 > "$ACC/acct-02/limits.json"
470
+ all2=1
471
+ for _ in $(seq 1 15); do
472
+ case "$(claude 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
473
+ done
474
+ [ "$all2" = "1" ] && t_ok "a session reading without a weekly one never clears the gate" \
475
+ || t_fail "gate needs both readings" "an unknown-weekly account beat a truthful weekly reading"
476
+
477
+ # ...and the converse: a weekly reading without a session one is not "known" either — it
478
+ # neither clears the gate nor ranks once the gate steps aside (10w/?s vs 70w/80s -> the
479
+ # 70w account, as in pool-selection.v2, where quota_known needs both readings).
480
+ printf '{"fetched_at":%s,"weekly_percent":10,"max_percent":10,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
481
+ lj 70 80 80 > "$ACC/acct-02/limits.json"
482
+ all2=1
483
+ for _ in $(seq 1 15); do
484
+ case "$(claude 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
485
+ done
486
+ [ "$all2" = "1" ] && t_ok "a weekly reading without a session one is unknown to both cuts" \
487
+ || t_fail "known needs both readings" "a session-less weekly reading ranked as known"
385
488
 
386
489
  # fully equal scores spread load across accounts
387
490
  lj 10 10 10 > "$ACC/acct-01/limits.json"
@@ -409,6 +512,10 @@ done
409
512
  { [ "$hits1" -gt 0 ] && [ "$hits2" -gt 0 ]; } \
410
513
  && t_ok "CLAUDE_SHIM_SELECT=random restores uniform spread" \
411
514
  || t_fail "random opt-out" "acct-01=$hits1 acct-02=$hits2"
515
+ # ...and its log line must not claim a gate that never ran (codex review, 2026-09-04).
516
+ grep -qE 'band=30 band-count=2 session-gate=off session-ok=2 pwd=' "$ACC/selection.log" \
517
+ && t_ok "random mode logs session-gate=off instead of a cut it never made" \
518
+ || t_fail "random mode gate log" "$(grep 'SHIM_SELECT\|session-gate' "$ACC/selection.log" | tail -1)"
412
519
 
413
520
  # Unknown telemetry ranks behind every truthful reading, never as neutral or free.
414
521
  printf '{"fetched_at":1,"weekly_percent":1,"session_percent":1,"max_percent":1,"buckets":[]}' > "$ACC/acct-01/limits.json"
@@ -478,17 +585,35 @@ printf '%s\nbucket=session percent=95 reason=limits\n' "$((now-10))" > "$ACC/acc
478
585
  claude >/dev/null 2>&1
479
586
  [ ! -f "$ACC/acct-01/.limited" ] && t_ok "expired marker auto-cleared" || t_fail "expired marker auto-cleared" "marker still present"
480
587
 
481
- # ---- 9. all limited -> least utilized fallback ------------------------------
588
+ # ---- 9. all limited -> the still-serving accounts go through the same two cuts, strict weekly ----
482
589
  printf '%s\nx\n' "$((now+3600))" > "$ACC/acct-01/.limited"
483
590
  printf '%s\nx\n' "$((now+3600))" > "$ACC/acct-02/.limited"
484
- printf '{"fetched_at":%s,"max_percent":97,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
485
- printf '{"fetched_at":%s,"max_percent":91,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
591
+ lj 97 10 97 > "$ACC/acct-01/limits.json"
592
+ lj 91 10 91 > "$ACC/acct-02/limits.json"
486
593
  out="$(claude 2>&1)"
487
- check "all-limited falls back to least utilized" "CFG=acct-02" "$out"
594
+ check "all-limited falls back to the still-serving account with the most weekly headroom" "CFG=acct-02" "$out"
488
595
  grep -q "all-limited fallback=acct-02" "$ACC/selection.log" \
489
596
  && t_ok "fallback logged" || t_fail "fallback logged" "no all-limited line in selection.log"
490
597
  rm -f "$ACC/acct-01/.limited" "$ACC/acct-02/.limited"
491
598
 
599
+ # The fallback applies the SAME two cuts: with every account limit-marked but still
600
+ # serving, an account past the session gate (10w/85s) yields to one inside it (70w/20s)
601
+ # even though its weekly headroom is far better — exactly as in ordinary selection.
602
+ # (On the pre-gate rule this picked 10w/85s, the best strict weekly score.)
603
+ printf '%s\nbucket=weekly_all percent=95 marked_at=x reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
604
+ printf '%s\nbucket=weekly_all percent=95 marked_at=x reason=limits\n' "$((now+3600))" > "$ACC/acct-02/.limited"
605
+ lj 10 85 85 > "$ACC/acct-01/limits.json"
606
+ lj 70 20 70 > "$ACC/acct-02/limits.json"
607
+ all2=1
608
+ for _ in $(seq 1 12); do
609
+ case "$(claude 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
610
+ done
611
+ [ "$all2" = "1" ] && t_ok "the all-limited fallback applies the session gate before strict weekly (70w/20s over 10w/85s)" \
612
+ || t_fail "fallback session gate" "the fallback handed out the account past the session gate"
613
+ grep -q "all-limited fallback=acct-02 weekly=70%" "$ACC/selection.log" \
614
+ && t_ok "the fallback line names the gated pick" || t_fail "fallback gate log" "$(tail -1 "$ACC/selection.log")"
615
+ rm -f "$ACC/acct-01/.limited" "$ACC/acct-02/.limited"
616
+
492
617
  # ---- 9a2. the fallback tells "still serving" from "rejected right now" --------------
493
618
  # acct-01: weekly at 99% — worse headroom, but still answering requests.
494
619
  # acct-02: session at 100% — far better weekly (7%), but every request bounces until
@@ -512,8 +637,8 @@ check "all exhausted: the soonest reset is handed out" "CFG=acct-02" "$out"
512
637
  grep -q "all-exhausted resets_in=" "$ACC/selection.log" \
513
638
  && t_ok "the all-exhausted pick is logged with its reset" || t_fail "all-exhausted log" "no line"
514
639
  rm -f "$ACC/acct-01/.limited" "$ACC/acct-02/.limited"
515
- printf '{"fetched_at":%s,"max_percent":97,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
516
- printf '{"fetched_at":%s,"max_percent":91,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
640
+ lj 97 10 97 > "$ACC/acct-01/limits.json"
641
+ lj 91 10 91 > "$ACC/acct-02/limits.json"
517
642
 
518
643
  # ---- 9a3. a token park is credential-scoped ----------------------------------------
519
644
  # A dead portable token beside a LIVE login: this session runs the account on the
@@ -717,8 +842,8 @@ rm -f "$ACC/acct-01/server.token"
717
842
  printf '%s' "$DEAD_CREDS" > "$ACC/acct-01/.credentials.json"
718
843
  printf '%s' "$HEALTHY_CREDS" > "$ACC/acct-02/.credentials.json"
719
844
  printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' > "$ACC/acct-01/server.token"
720
- printf '{"fetched_at":%s,"max_percent":1,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
721
- printf '{"fetched_at":%s,"max_percent":50,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
845
+ lj 1 1 1 > "$ACC/acct-01/limits.json" # ranks first, so its preflight is what runs
846
+ lj 50 1 50 > "$ACC/acct-02/limits.json"
722
847
  rm -f "$ACC/.last-pick" "$ACC/acct-01/.expired" "$ACC/acct-01/.server-token-verified"
723
848
  out="$(claude --resume d6ccbac0-6643-4780-a99e-3afa1683478e 2>&1)"
724
849
  check "revoked setup-token fails over before --resume" "CFG=acct-02" "$out"
@@ -1439,7 +1564,7 @@ expected="$(cat "$WORK/stdin13")"
1439
1564
 
1440
1565
  # ---- 14. selection log written ------------------------------------------------
1441
1566
  log_pattern='^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*acct-0[123] weekly=[0-9?]+% session=[0-9?]+%'
1442
- log_pattern="$log_pattern band=30 band-count=[0-9]+ pwd="
1567
+ log_pattern="$log_pattern band=30 band-count=[0-9]+ session-gate=50 session-ok=[0-9]+ pwd="
1443
1568
  grep -qE "$log_pattern" "$ACC/selection.log" \
1444
1569
  && t_ok "selection.log format" || t_fail "selection.log format" "no matching lines"
1445
1570
  grep -qE 'sk-ant-oat|accessToken|refreshToken' "$ACC/selection.log" \
@@ -1931,10 +2056,13 @@ assert groups["weekly_scoped:Fable"] == "weekly", groups
1931
2056
  EOF
1932
2057
  [ $? -eq 0 ] && t_ok "limits records weekly_percent(55) + session_percent(88) with correct groups" \
1933
2058
  || t_fail "weekly/session classification" "see limits.json"
1934
- # a session bucket at 88 must NOT poison weekly ranking: score uses weekly(55), not 88
1935
- sc="$(python3 -c "import json;d=json.load(open('$ACC/acct-01/limits.json'));print(d['weekly_percent']*1000+d['session_percent'])")"
1936
- [ "$sc" = "55088" ] && t_ok "ranking score weighs weekly over session (55088)" \
1937
- || t_fail "ranking score" "got $sc"
2059
+ # The two numbers must stay SEPARATE on disk. There is no combined score any more
2060
+ # (2026-09-03): the shim GATES on session_percent and BANDS on weekly_percent, so a
2061
+ # session bucket at 88 must never be folded into or allowed to poison — the weekly
2062
+ # reading the band ranks on.
2063
+ sc="$(python3 -c "import json;d=json.load(open('$ACC/acct-01/limits.json'));print('%s/%s' % (d['weekly_percent'], d['session_percent']))")"
2064
+ [ "$sc" = "55/88" ] && t_ok "weekly and session are recorded as separate ranking inputs (55 weekly / 88 session)" \
2065
+ || t_fail "weekly/session ranking inputs" "got $sc"
1938
2066
  rm -f "$ACC/acct-01/limits.json" "$ACC/acct-01/.limited"
1939
2067
 
1940
2068
  # ---- 16-marker. the marker names ONE bucket and carries THAT bucket's reset ----------
@@ -2410,6 +2538,9 @@ EOF
2410
2538
  grep -q "ranking=BLIND" "$SD/selection.log" \
2411
2539
  && t_ok "selection.log records that ranking ran blind" \
2412
2540
  || t_fail "blind ranking log" "no ranking=BLIND line: $(tail -1 "$SD/selection.log")"
2541
+ grep -qE "ranking=BLIND telemetry-age=[0-9]+s band=30 band-count=[0-9]+ session-gate=50 session-ok=0 pwd=" "$SD/selection.log" \
2542
+ && t_ok "the blind line carries both cuts in the standard field order" \
2543
+ || t_fail "blind line fields" "$(tail -1 "$SD/selection.log")"
2413
2544
  grep -q "telemetry-age=9[0-9]\{5\}s" "$SD/selection.log" \
2414
2545
  && t_ok "the blind line carries the age of the outage" \
2415
2546
  || t_fail "blind ranking age" "$(tail -1 "$SD/selection.log")"
@@ -2431,17 +2562,20 @@ EOF
2431
2562
  # ...and telemetry INSIDE the window must still rank. 900s used to be the window,
2432
2563
  # which is below the ~3600s floor the endpoint itself enforces (Retry-After: 3600),
2433
2564
  # so a healthy pool spent most of every hour ranking neutral for no reason.
2434
- for i in 01 02; do
2435
- printf '{"fetched_at":%s,"source":"oauth","max_percent":%s,"weekly_percent":%s,"session_percent":1,"buckets":[]}' \
2436
- "$((now - 1200))" "$((i + 3))" "$((i + 3))" > "$SD/acct-$i/limits.json"
2437
- done
2565
+ # acct-02 is put OUTSIDE the 30-point band (weekly 40 against acct-01's 4) on purpose:
2566
+ # inside the band the two are peers and the pick is a coin flip, which would make this
2567
+ # assertion about the freshness window flaky for reasons that have nothing to do with it.
2568
+ printf '{"fetched_at":%s,"source":"oauth","max_percent":4,"weekly_percent":4,"session_percent":1,"buckets":[]}' \
2569
+ "$((now - 1200))" > "$SD/acct-01/limits.json"
2570
+ printf '{"fetched_at":%s,"source":"oauth","max_percent":40,"weekly_percent":40,"session_percent":1,"buckets":[]}' \
2571
+ "$((now - 1200))" > "$SD/acct-02/limits.json"
2438
2572
  : > "$SD/selection.log"
2439
2573
  CLAUDE_ACCOUNTS_ROOT="$SD" claude >/dev/null 2>&1
2440
2574
  ! grep -q "ranking=BLIND" "$SD/selection.log" \
2441
2575
  && t_ok "20-minute-old telemetry still ranks (window matches the endpoint's own floor)" \
2442
2576
  || t_fail "stale window" "20-minute-old data was treated as blind"
2443
2577
  grep -q "acct-01 weekly=4%" "$SD/selection.log" \
2444
- && t_ok "the pool ranks on real numbers and picks the account with more headroom" \
2578
+ && t_ok "the pool ranks on real numbers and picks the account with more headroom (4% over 40%)" \
2445
2579
  || t_fail "headroom ranking" "$(tail -1 "$SD/selection.log")"
2446
2580
 
2447
2581
  # ---- blind does not mean neutral --------------------------------------------
@@ -2467,6 +2601,10 @@ EOF
2467
2601
  grep -q "ranking=DEGRADED" "$SD/selection.log" \
2468
2602
  && t_ok "a degraded pick is logged as degraded, not as blind" \
2469
2603
  || t_fail "degraded log" "$(tail -1 "$SD/selection.log")"
2604
+ # Session is unknown in a degraded pool, so nobody clears the gate: session-ok=0.
2605
+ grep -qE "ranking=DEGRADED telemetry-age=[0-9]+s band=30 band-count=[0-9]+ session-gate=50 session-ok=0 pwd=" "$SD/selection.log" \
2606
+ && t_ok "the degraded line carries both cuts in the standard field order" \
2607
+ || t_fail "degraded line fields" "$(tail -1 "$SD/selection.log")"
2470
2608
  grep -q "acct-02 weekly=4% .*ranking=DEGRADED" "$SD/selection.log" \
2471
2609
  && t_ok "the degraded line reports the stale reading it actually ranked on" \
2472
2610
  || t_fail "degraded weekly" "$(tail -1 "$SD/selection.log")"
@@ -2712,10 +2850,14 @@ RACER
2712
2850
  # threshold on its session bucket (max 91%). So it is picked whenever it is eligible,
2713
2851
  # and skipped only when the cutoff actually fires — which isolates the cutoff window
2714
2852
  # from the ranking window instead of conflating "excluded" with "outranked".
2853
+ # BOTH sessions sit above the 50-point session gate (91 and 55) deliberately: with
2854
+ # acct-02 inside the gate it would be the only gated candidate and win on the gate
2855
+ # alone, and these assertions would then be measuring the gate instead of the cutoff
2856
+ # window. Nobody clearing the gate makes it step aside, so weekly alone ranks here.
2715
2857
  mk_cutoff_pool() { # $1 = age of both readings, in seconds
2716
2858
  printf '{"fetched_at":%s,"weekly_percent":1,"session_percent":91,"max_percent":91,"weekly_resets_epoch":%s,"buckets":[]}' \
2717
2859
  "$((now - $1))" "$((now + 200000))" > "$SD/acct-01/limits.json"
2718
- printf '{"fetched_at":%s,"weekly_percent":50,"session_percent":5,"max_percent":50,"weekly_resets_epoch":%s,"buckets":[]}' \
2860
+ printf '{"fetched_at":%s,"weekly_percent":50,"session_percent":55,"max_percent":55,"weekly_resets_epoch":%s,"buckets":[]}' \
2719
2861
  "$((now - $1))" "$((now + 200000))" > "$SD/acct-02/limits.json"
2720
2862
  : > "$SD/selection.log"
2721
2863
  rm -f "$SD/.last-pick"
@@ -3346,7 +3488,7 @@ check "codex: passthrough with CODEX_HOME" "CFG=other" "$out"
3346
3488
  out="$(CODEX_MULTIACC_DISABLE=1 codex 2>&1)"
3347
3489
  check "codex: passthrough when disabled" "CFG=none" "$out"
3348
3490
 
3349
- # ---- C3. headroom selection --------------------------------------------------------
3491
+ # ---- C3. headroom selection: session gate first, then the WEEKLY headroom band ------
3350
3492
  cxlj() { printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":%s,"max_percent":%s,"buckets":[]}' "$now" "$1" "$2" "$3"; }
3351
3493
  cxlj 80 10 80 > "$CX/acct-01/limits.json"
3352
3494
  cxlj 20 10 20 > "$CX/acct-02/limits.json"
@@ -3377,15 +3519,118 @@ for _ in $(seq 1 10); do
3377
3519
  done
3378
3520
  [ "$all1" = 1 ] && t_ok "codex: headroom band 0 restores strict ranking" \
3379
3521
  || t_fail "codex zero headroom band" "the runner-up was selected"
3380
- # high session must NOT beat better weekly headroom
3522
+ # THE KEY CASE, REVERSED on 2026-09-03, in parity with the claude shim (section 5):
3523
+ # the operator asked for "among accounts where high session limits it must choose
3524
+ # randomly from ones where highest weekly limits", so a nearly-spent 5h bucket is the
3525
+ # FIRST cut and the 30-point weekly band ranks only what clears the gate. acct-01:
3526
+ # session 85 (past the gate), weekly 10; acct-02: session 20, weekly 70 -> acct-02.
3527
+ cxlj 10 85 85 > "$CX/acct-01/limits.json"
3528
+ cxlj 70 20 70 > "$CX/acct-02/limits.json"
3529
+ all2=1
3530
+ for _ in $(seq 1 15); do
3531
+ case "$(codex 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
3532
+ done
3533
+ [ "$all2" = "1" ] && t_ok "codex: a session bucket past the gate is skipped while a fresher one exists (70w/20s over 10w/85s)" \
3534
+ || t_fail "codex session gate" "the account with its 5h bucket at 85% was still selected"
3535
+ : > "$CX/selection.log"
3536
+ codex >/dev/null 2>&1
3537
+ grep -qE 'acct-02 weekly=70% session=20% band=30 band-count=1 session-gate=50 session-ok=1 pwd=' "$CX/selection.log" \
3538
+ && t_ok "codex: the selection log carries session-gate=50 session-ok=1 when one of two clears" \
3539
+ || t_fail "codex session gate log" "$(tail -1 "$CX/selection.log")"
3540
+ # Both sessions inside the gate: it has nothing to say and weekly decides as before.
3541
+ cxlj 10 45 45 > "$CX/acct-01/limits.json"
3542
+ cxlj 70 20 70 > "$CX/acct-02/limits.json"
3543
+ all1=1
3544
+ for _ in $(seq 1 15); do
3545
+ case "$(codex 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
3546
+ done
3547
+ [ "$all1" = "1" ] && t_ok "codex: with both sessions inside the gate weekly headroom decides (10w/45s over 70w/20s)" \
3548
+ || t_fail "codex session gate no-op" "the gate changed a ranking where every candidate cleared it"
3549
+ # Nobody clears it: the gate compares, it never empties the pool — it steps aside.
3550
+ cxlj 10 85 85 > "$CX/acct-01/limits.json"
3551
+ cxlj 70 60 70 > "$CX/acct-02/limits.json"
3552
+ : > "$CX/selection.log"
3553
+ all1=1
3554
+ for _ in $(seq 1 15); do
3555
+ case "$(codex 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
3556
+ done
3557
+ [ "$all1" = "1" ] && t_ok "codex: with nobody inside the gate it steps aside and weekly ranks (10w/85s over 70w/60s)" \
3558
+ || t_fail "codex session gate step-aside" "an empty gate emptied the pool instead of stepping aside"
3559
+ grep -qE 'acct-01 weekly=10% session=85% band=30 band-count=1 session-gate=50 session-ok=0 pwd=' "$CX/selection.log" \
3560
+ && t_ok "codex: the selection log carries session-ok=0 when the gate steps aside" \
3561
+ || t_fail "codex session gate log" "$(tail -1 "$CX/selection.log")"
3562
+ # The gate is a knob, like the band: 100 turns it off; garbage falls back to 50.
3381
3563
  cxlj 10 85 85 > "$CX/acct-01/limits.json"
3382
3564
  cxlj 70 20 70 > "$CX/acct-02/limits.json"
3383
3565
  all1=1
3566
+ for _ in $(seq 1 15); do
3567
+ case "$(CODEX_MULTIACC_SESSION_GATE=100 codex 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
3568
+ done
3569
+ [ "$all1" = "1" ] && t_ok "codex: CODEX_MULTIACC_SESSION_GATE=100 disables the gate" \
3570
+ || t_fail "codex session gate off" "the gate still fired at 100"
3571
+ all2=1
3572
+ for _ in $(seq 1 15); do
3573
+ case "$(CODEX_MULTIACC_SESSION_GATE=abc codex 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
3574
+ done
3575
+ [ "$all2" = "1" ] && t_ok "codex: a non-numeric CODEX_MULTIACC_SESSION_GATE falls back to 50" \
3576
+ || t_fail "codex session gate validation" "a garbage gate value changed the outcome"
3577
+ # Equal weekly usage: the GATE decides, never a tiebreak — strict band or default 30.
3578
+ cxlj 40 20 40 > "$CX/acct-01/limits.json"
3579
+ cxlj 40 80 80 > "$CX/acct-02/limits.json"
3580
+ all1=1
3581
+ for _ in $(seq 1 15); do
3582
+ case "$(CODEX_MULTIACC_HEADROOM_BAND=0 codex 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
3583
+ done
3584
+ [ "$all1" = "1" ] && t_ok "codex: strict mode: the session gate decides an exact weekly tie" \
3585
+ || t_fail "codex session gate tie" "a weekly tie was not resolved by the session gate"
3586
+ all1=1
3384
3587
  for _ in $(seq 1 15); do
3385
3588
  case "$(codex 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
3386
3589
  done
3387
- [ "$all1" = "1" ] && t_ok "codex: high session does NOT beat better weekly headroom" \
3388
- || t_fail "codex weekly-over-session" "ranked the account with less weekly headroom higher"
3590
+ [ "$all1" = "1" ] && t_ok "codex: the gate (not the band) removes the session-heavy half of a weekly tie" \
3591
+ || t_fail "codex session gate tie" "the default band let the 80-point session account back in"
3592
+
3593
+ # Inside the gate, session is NOT a tiebreaker any more: an exact weekly tie between two
3594
+ # gate-clearing accounts is a coin flip even in strict mode (it used to go to the lower
3595
+ # session, through the old weekly*1000+session score).
3596
+ cxlj 40 20 40 > "$CX/acct-01/limits.json"
3597
+ cxlj 40 45 45 > "$CX/acct-02/limits.json"
3598
+ hits1=0; hits2=0
3599
+ for _ in $(seq 1 20); do
3600
+ case "$(CODEX_MULTIACC_HEADROOM_BAND=0 codex 2>&1)" in
3601
+ *CFG=acct-01*) hits1=$((hits1+1)) ;;
3602
+ *CFG=acct-02*) hits2=$((hits2+1)) ;;
3603
+ esac
3604
+ done
3605
+ { [ "$hits1" -gt 0 ] && [ "$hits2" -gt 0 ]; } \
3606
+ && t_ok "codex: strict mode: session does not break an exact weekly tie inside the gate (acct-01=$hits1 acct-02=$hits2)" \
3607
+ || t_fail "codex: session tiebreak removed" "acct-01=$hits1 acct-02=$hits2 (want both >0)"
3608
+
3609
+ # Clearing the gate takes BOTH readings, as in pool-selection.v2: a fresh file with a
3610
+ # session reading but no weekly one must not become the sole gate-clearer and win the
3611
+ # all-gated tie over an account with a truthful weekly reading — not even with a
3612
+ # max_percent to fall back on (the shims used to rank on that; the policy never could).
3613
+ # (No writer produces such a file; this pins parity with lib/selector_policy.py.)
3614
+ printf '{"fetched_at":%s,"max_percent":10,"session_percent":10,"buckets":[]}' "$now" > "$CX/acct-01/limits.json"
3615
+ cxlj 20 80 80 > "$CX/acct-02/limits.json"
3616
+ all2=1
3617
+ for _ in $(seq 1 15); do
3618
+ case "$(codex 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
3619
+ done
3620
+ [ "$all2" = "1" ] && t_ok "codex: a session reading without a weekly one never clears the gate" \
3621
+ || t_fail "codex: gate needs both readings" "an unknown-weekly account beat a truthful weekly reading"
3622
+
3623
+ # ...and the converse: a weekly reading without a session one is not "known" either — it
3624
+ # neither clears the gate nor ranks once the gate steps aside (10w/?s vs 70w/80s -> the
3625
+ # 70w account, as in pool-selection.v2, where quota_known needs both readings).
3626
+ printf '{"fetched_at":%s,"weekly_percent":10,"max_percent":10,"buckets":[]}' "$now" > "$CX/acct-01/limits.json"
3627
+ cxlj 70 80 80 > "$CX/acct-02/limits.json"
3628
+ all2=1
3629
+ for _ in $(seq 1 15); do
3630
+ case "$(codex 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
3631
+ done
3632
+ [ "$all2" = "1" ] && t_ok "codex: a weekly reading without a session one is unknown to both cuts" \
3633
+ || t_fail "codex: known needs both readings" "a session-less weekly reading ranked as known"
3389
3634
  # equal scores spread load
3390
3635
  cxlj 10 10 10 > "$CX/acct-01/limits.json"
3391
3636
  cxlj 10 10 10 > "$CX/acct-02/limits.json"
@@ -3652,17 +3897,65 @@ c_n="$(printf '%s' "$c_distinct" | wc -w | tr -d ' ')"
3652
3897
  rm -rf "$CX/acct-09"
3653
3898
  rm -f "$CX/.last-pick"
3654
3899
 
3655
- # ---- C6. all limited -> least-utilized fallback -------------------------------------
3900
+ # ---- C6. all limited -> the still-serving accounts go through the same two cuts, strict weekly --
3656
3901
  printf '%s\nbucket=7d percent=95 reason=limits\n' "$((now+3600))" > "$CX/acct-01/.limited"
3657
3902
  printf '%s\nbucket=7d percent=99 reason=limits\n' "$((now+3600))" > "$CX/acct-02/.limited"
3658
3903
  cxlj 95 10 95 > "$CX/acct-01/limits.json"
3659
3904
  cxlj 99 10 99 > "$CX/acct-02/limits.json"
3660
3905
  out="$(codex 2>&1)"
3661
- check "codex: all-limited falls back to least utilized" "CFG=acct-01" "$out"
3906
+ check "codex: all-limited falls back to the still-serving account with the most weekly headroom" "CFG=acct-01" "$out"
3662
3907
  grep -q "all-limited fallback=acct-01" "$CX/selection.log" \
3663
3908
  && t_ok "codex: all-limited fallback logged" || t_fail "codex fallback log" "no all-limited line"
3664
3909
  rm -f "$CX"/acct-*/.limited "$CX"/acct-*/limits.json
3665
3910
 
3911
+ # The fallback applies the SAME two cuts (parity with the claude shim, section 9): an
3912
+ # account past the session gate yields to one inside it even with far better weekly.
3913
+ printf '%s\nbucket=7d percent=95 reason=limits\n' "$((now+3600))" > "$CX/acct-01/.limited"
3914
+ printf '%s\nbucket=7d percent=95 reason=limits\n' "$((now+3600))" > "$CX/acct-02/.limited"
3915
+ cxlj 10 85 85 > "$CX/acct-01/limits.json"
3916
+ cxlj 70 20 70 > "$CX/acct-02/limits.json"
3917
+ all2=1
3918
+ for _ in $(seq 1 12); do
3919
+ case "$(codex 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
3920
+ done
3921
+ [ "$all2" = "1" ] && t_ok "codex: the all-limited fallback applies the session gate before strict weekly (70w/20s over 10w/85s)" \
3922
+ || t_fail "codex fallback session gate" "the fallback handed out the account past the session gate"
3923
+ grep -q "all-limited fallback=acct-02 weekly=70%" "$CX/selection.log" \
3924
+ && t_ok "codex: the fallback line names the gated pick" || t_fail "codex fallback gate log" "$(tail -1 "$CX/selection.log")"
3925
+ rm -f "$CX"/acct-*/.limited "$CX"/acct-*/limits.json
3926
+
3927
+ # ---- C6b. the fallback tells "still serving" from "rejected right now" ----------------
3928
+ # Ported from the claude shim (2026-08-29) when the session gate reached the fallback.
3929
+ # acct-01: 7d window at 99% — worse headroom, but still answering. acct-02: 5h window at
3930
+ # 100% — far better weekly (7%), but every request bounces until the reset.
3931
+ printf '%s\nbucket=7d percent=99 marked_at=x reason=limits\n' "$((now+3600))" > "$CX/acct-01/.limited"
3932
+ printf '%s\nbucket=5h percent=100 marked_at=x reason=limits\n' "$((now+600))" > "$CX/acct-02/.limited"
3933
+ cxlj 99 10 99 > "$CX/acct-01/limits.json"
3934
+ cxlj 7 100 100 > "$CX/acct-02/limits.json"
3935
+ out="$(codex 2>&1)"
3936
+ check "codex: a still-serving limited account beats an exhausted one with more headroom" "CFG=acct-01" "$out"
3937
+ # The codex-review case (2026-09-04): the exhausted account is the only one INSIDE the
3938
+ # session gate; the gate must not resurrect it over a still-serving account outside it.
3939
+ printf '%s\nbucket=7d percent=100 marked_at=x reason=limits\n' "$((now+3600))" > "$CX/acct-01/.limited"
3940
+ printf '%s\nbucket=7d percent=95 marked_at=x reason=limits\n' "$((now+3600))" > "$CX/acct-02/.limited"
3941
+ cxlj 100 20 100 > "$CX/acct-01/limits.json"
3942
+ cxlj 95 85 95 > "$CX/acct-02/limits.json"
3943
+ out="$(codex 2>&1)"
3944
+ check "codex: an exhausted account inside the gate never beats a still-serving one outside it" "CFG=acct-02" "$out"
3945
+ # A real client rejection (a 429 the server sent) is exhausted whatever percent says.
3946
+ printf '%s\nbucket=client:5h percent=95 marked_at=x reason=client-rate-limit\n' "$((now+600))" > "$CX/acct-01/.limited"
3947
+ cxlj 95 10 95 > "$CX/acct-01/limits.json"
3948
+ out="$(codex 2>&1)"
3949
+ check "codex: a client-rejected account is not the fallback while another still serves" "CFG=acct-02" "$out"
3950
+ # Every account exhausted RIGHT NOW: hand out the one that unblocks first.
3951
+ printf '%s\nbucket=5h percent=100 marked_at=x reason=limits\n' "$((now+7200))" > "$CX/acct-01/.limited"
3952
+ printf '%s\nbucket=5h percent=100 marked_at=x reason=limits\n' "$((now+600))" > "$CX/acct-02/.limited"
3953
+ out="$(codex 2>&1)"
3954
+ check "codex: all exhausted: the soonest reset is handed out" "CFG=acct-02" "$out"
3955
+ grep -q "all-exhausted resets_in=" "$CX/selection.log" \
3956
+ && t_ok "codex: the all-exhausted pick is logged with its reset" || t_fail "codex all-exhausted log" "no line"
3957
+ rm -f "$CX"/acct-*/.limited "$CX"/acct-*/limits.json
3958
+
3666
3959
  # ---- C7. dead logins: .expired excludes, heals on newer credential ------------------
3667
3960
  printf '%s\nreason=refresh-denied-http-400 marked_at=t detail=x\n' "$now" > "$CX/acct-01/.expired"
3668
3961
  all2=1
@@ -3682,6 +3975,9 @@ mk_cx_auth "$CX/acct-01/auth.json" a@cx "$FUTURE_EXP"
3682
3975
  out="$(CODEX_SHIM_SELECT=random codex 2>&1)"
3683
3976
  [ ! -f "$CX/acct-01/.expired" ] && t_ok "codex: newer auth.json clears a credential park" \
3684
3977
  || t_fail "codex park heal" ".expired survived a newer credential"
3978
+ grep -qE 'band=30 band-count=[0-9]+ session-gate=off session-ok=[0-9]+ pwd=' "$CX/selection.log" \
3979
+ && t_ok "codex: random mode logs session-gate=off instead of a cut it never made" \
3980
+ || t_fail "codex random mode gate log" "$(tail -1 "$CX/selection.log")"
3685
3981
  # an org-blocked park survives a newer credential (policy, not credential)
3686
3982
  printf '%s\nreason=org-blocked marked_at=t detail=x\n' "$now" > "$CX/acct-01/.expired"
3687
3983
  sleep 1