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.
- package/CLAUDE_ACCS_TASK.md +10 -5
- package/README.md +45 -18
- package/bin/claude +113 -59
- package/bin/claude-accounts +4 -1
- package/bin/codex +178 -43
- package/bin/codex-accounts +4 -1
- package/docs/ACCOUNT_OPERATIONS.md +7 -4
- package/docs/TRACK_PROMPT.md +76 -0
- package/docs/UNIFIED_SELECTOR.md +40 -7
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.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/selector_policy.py +42 -7
- package/lib/selector_primitives.py +25 -16
- package/package.json +1 -1
- package/tests/run-tests.sh +331 -35
- package/tests/test_selector.py +104 -2
package/bin/codex
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
# Every invocation runs under a randomly picked ChatGPT subscription account with limit
|
|
4
4
|
# headroom. Self-contained on purpose: no sourcing, so a broken repo file can never
|
|
5
5
|
# break `codex`.
|
|
6
|
-
# Selection: CODEX_HOME passthrough > CODEX_ACCOUNT pin > random among limit-eligible
|
|
7
|
-
# accounts
|
|
6
|
+
# Selection: CODEX_HOME passthrough > CODEX_ACCOUNT pin > random among the limit-eligible
|
|
7
|
+
# accounts that clear the 50-point session gate and then sit in the 30-point weekly
|
|
8
|
+
# headroom band > all-limited fallback: the same two cuts over the still-serving limited
|
|
9
|
+
# accounts, strict weekly (degraded beats down).
|
|
8
10
|
# Accounts whose login is DEAD (a `.expired` marker from a failed refresh/verify/run)
|
|
9
11
|
# are never selected — not even as the all-limited fallback — because they fail every
|
|
10
12
|
# call outright; `codex-accounts expired` / `relogin` fix them.
|
|
@@ -140,33 +142,101 @@ fresh_field() { # fresh_field <acct dir> <json key> -> integer if telemetry fres
|
|
|
140
142
|
printf '%s\n' "$v"
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
# RANKING
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
145
|
+
# RANKING — TWO CUTS, in this order, then chance. The operator's ask (2026-09-03):
|
|
146
|
+
# "among accounts where high session limits it must choose randomly from ones where
|
|
147
|
+
# highest weekly limits."
|
|
148
|
+
# 1. SESSION GATE (SESSION_GATE, default 50): a candidate clears the gate when its ~5h
|
|
149
|
+
# session usage is KNOWN and at or under the gate. When at least one candidate clears
|
|
150
|
+
# it, only those are ranked further — an account whose session bucket is nearly spent
|
|
151
|
+
# is about to be rejected whatever its weekly headroom is. When NOBODY clears it the
|
|
152
|
+
# gate steps aside and every candidate is ranked: the gate compares candidates, it
|
|
153
|
+
# never empties the pool.
|
|
154
|
+
# 2. WEEKLY BAND (HEADROOM_BAND, default 30, kept at the operator's request): among the
|
|
155
|
+
# gated candidates the lowest KNOWN weekly usage leads, and every gated candidate
|
|
156
|
+
# within the band of it is a peer. Weekly dominates because a weekly window only
|
|
157
|
+
# refills on its multi-day reset while the ~5h window self-heals (same asymmetry as
|
|
158
|
+
# the claude pool) — which is exactly why session acts as the gate and is NOT a
|
|
159
|
+
# tiebreaker inside the band any more: the operator asked for a random choice among
|
|
160
|
+
# the best-weekly accounts.
|
|
161
|
+
# Stale/unreadable telemetry never clears the gate and never enters the band — an unknown
|
|
162
|
+
# account can never beat a candidate with truthful usage telemetry — but when NOTHING is
|
|
163
|
+
# known the candidates all tie, which keeps an entirely blind pool selectable.
|
|
164
|
+
# Band 0 (or the all-limited PICK_STRICT fallback) means exact weekly ties only; gate 100
|
|
165
|
+
# turns the gate off.
|
|
156
166
|
|
|
157
167
|
HEADROOM_BAND="${CODEX_MULTIACC_HEADROOM_BAND:-30}"
|
|
158
168
|
case "$HEADROOM_BAND" in ''|*[!0-9]*|??????*) HEADROOM_BAND=30 ;; esac
|
|
159
169
|
[ "$HEADROOM_BAND" -gt 100 ] && HEADROOM_BAND=100
|
|
160
|
-
|
|
170
|
+
SESSION_GATE="${CODEX_MULTIACC_SESSION_GATE:-50}"
|
|
171
|
+
case "$SESSION_GATE" in ''|*[!0-9]*|??????*) SESSION_GATE=50 ;; esac
|
|
172
|
+
[ "$SESSION_GATE" -gt 100 ] && SESSION_GATE=100
|
|
173
|
+
# What the selection log prints for the gate: "off" in random mode, where no gate ran —
|
|
174
|
+
# the log must never claim a cut that was not made.
|
|
175
|
+
SESSION_GATE_LOG="$SESSION_GATE"
|
|
176
|
+
# The codex shim has no DEGRADED ranking mode (the claude shim ranks on still-valid stale
|
|
177
|
+
# weekly readings when nothing in its pool is fresh). The constant exists so pick_best
|
|
178
|
+
# can stay byte-identical with bin/claude.
|
|
179
|
+
SEL_DEGRADED=0
|
|
180
|
+
|
|
181
|
+
# weekly_percent ONLY. A reading without it is unknown here, exactly as it is unknown to
|
|
182
|
+
# pool-selection.v2 (which never sees max_percent). The old max_percent fallback let a
|
|
183
|
+
# weekly-less file rank — and, once the session gate existed, CLEAR the gate — on a number
|
|
184
|
+
# that may well be the session bucket's own peak (codex review, 2026-09-04).
|
|
161
185
|
rank_weekly_of() { # $1 = acct dir -> comparable weekly use, or fail when unknown
|
|
162
186
|
local w
|
|
163
|
-
if w="$(fresh_field "$1" weekly_percent)"
|
|
187
|
+
if w="$(fresh_field "$1" weekly_percent)"; then
|
|
164
188
|
printf '%s\n' "$w"
|
|
165
189
|
return 0
|
|
166
190
|
fi
|
|
167
191
|
return 1
|
|
168
192
|
}
|
|
169
193
|
|
|
194
|
+
rank_session_of() { # $1 = acct dir -> fresh session use, or fail when unknown
|
|
195
|
+
fresh_field "$1" session_percent
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# The .limited marker's own fields (line 1: reset epoch; line 2: "bucket=…
|
|
199
|
+
# percent=… … reason=…"). All tolerant: an unreadable or bare marker answers
|
|
200
|
+
# "unknown", never an error — these feed the all-limited fallback only.
|
|
201
|
+
limited_reset_of() { # $1 acct dir -> the marker's reset epoch, or 0 (unknown)
|
|
202
|
+
local m="$1/.limited" r=""
|
|
203
|
+
[ -f "$m" ] && { IFS= read -r r < "$m" 2>/dev/null || r=""; }
|
|
204
|
+
if num_ok "$r"; then printf '%s\n' "$r"; else printf '0\n'; fi
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
limited_percent_of() { # $1 acct dir -> the marked window's percent, or -1 (unknown)
|
|
208
|
+
local m="$1/.limited" line=""
|
|
209
|
+
[ -f "$m" ] && line="$(sed -n 2p "$m" 2>/dev/null)"
|
|
210
|
+
case "$line" in
|
|
211
|
+
*percent=*) line="${line#*percent=}"; line="${line%% *}" ;;
|
|
212
|
+
*) line="" ;;
|
|
213
|
+
esac
|
|
214
|
+
if num_ok "$line"; then printf '%s\n' "$line"; else printf '%s\n' "-1"; fi
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# Rejected RIGHT NOW, not merely near the threshold: the marked window is exhausted
|
|
218
|
+
# (100%), or the marker records a real client rejection (a 429 the server sent) or an
|
|
219
|
+
# error cooldown. A window at 90-99% still answers requests — the difference the
|
|
220
|
+
# all-limited fallback lives on, because "degraded service beats a hard failure" only
|
|
221
|
+
# holds for an account that can actually serve. Same rule as the claude shim.
|
|
222
|
+
limited_hard_blocked() { # $1 acct dir
|
|
223
|
+
local m="$1/.limited" line="" p
|
|
224
|
+
if [ -f "$m" ]; then
|
|
225
|
+
line="$(sed -n 2p "$m" 2>/dev/null)"
|
|
226
|
+
case "$line" in *reason=client-rate-limit*|*reason=error-cooldown*) return 0 ;; esac
|
|
227
|
+
p="$(limited_percent_of "$1")"
|
|
228
|
+
if [ "$p" -ge 0 ] 2>/dev/null; then
|
|
229
|
+
[ "$p" -ge 100 ]
|
|
230
|
+
return
|
|
231
|
+
fi
|
|
232
|
+
return 1
|
|
233
|
+
fi
|
|
234
|
+
# No marker (the over-threshold backstop put it in valid-but-not-eligible):
|
|
235
|
+
# fresh telemetry's peak decides; stale/unknown reads as still serving.
|
|
236
|
+
p="$(fresh_field "$1" max_percent)" || return 1
|
|
237
|
+
[ "$p" -ge 100 ]
|
|
238
|
+
}
|
|
239
|
+
|
|
170
240
|
# Backstop for a lost/failed marker write: fresh telemetry with ANY bucket at/over the
|
|
171
241
|
# threshold excludes the account even if .limited is missing. Stale/unreadable => not
|
|
172
242
|
# over (fail open — telemetry must never invent exclusions).
|
|
@@ -467,44 +537,67 @@ remember_pick() { # $1 acct dir — best effort. stderr is silenced BEFORE the r
|
|
|
467
537
|
return 0
|
|
468
538
|
}
|
|
469
539
|
|
|
470
|
-
#
|
|
471
|
-
#
|
|
472
|
-
#
|
|
473
|
-
#
|
|
540
|
+
# Two cuts, then chance: the session gate first, the weekly headroom band second, and a
|
|
541
|
+
# random peer with the account just handed out avoided. Sets PICK_DIR / PICK_BAND_COUNT /
|
|
542
|
+
# PICK_GATE_COUNT as globals — it must never touch "$@", which holds the user's codex
|
|
543
|
+
# arguments.
|
|
474
544
|
PICK_DIR=""
|
|
475
|
-
PICK_SCORE=""
|
|
476
545
|
PICK_BAND_COUNT=0
|
|
546
|
+
PICK_GATE_COUNT=0
|
|
477
547
|
PICK_STRICT=0
|
|
478
548
|
pick_best() { # args: candidate dirs
|
|
479
|
-
local d w avoid best=""
|
|
480
|
-
local cand=()
|
|
549
|
+
local d w s k sk avoid best="" bestw=101 band ceiling ties=0 i n
|
|
550
|
+
local cand=() weekly=() known=() gated=() pool=()
|
|
481
551
|
avoid="$(last_pick_id)"
|
|
552
|
+
PICK_GATE_COUNT=0
|
|
482
553
|
for d in "$@"; do
|
|
483
554
|
cand+=("$d")
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
555
|
+
w="$(rank_weekly_of "$d")" && k=1 || k=0
|
|
556
|
+
s="$(rank_session_of "$d")" && sk=1 || sk=0
|
|
557
|
+
# "Known" takes BOTH readings — pool-selection.v2's quota_known rule. A weekly figure
|
|
558
|
+
# on its own neither ranks nor clears the gate, and a session figure on its own could
|
|
559
|
+
# otherwise be the sole gate-clearer and win the all-gated tie over an account whose
|
|
560
|
+
# truthful weekly reading merely failed the gate. The one exception is a DEGRADED pool
|
|
561
|
+
# (SEL_DEGRADED=1): nothing is fresh anywhere, the gate has necessarily stepped aside,
|
|
562
|
+
# and a still-valid stale weekly reading is the only truth there is. (Neither writer
|
|
563
|
+
# emits one field without the other; this is parity with lib/selector_policy.py.)
|
|
564
|
+
if [ "$k" = 1 ] && { [ "$sk" = 1 ] || [ "$SEL_DEGRADED" = 1 ]; }; then
|
|
565
|
+
weekly+=("$w"); known+=(1)
|
|
566
|
+
else
|
|
567
|
+
weekly+=(100); known+=(0)
|
|
568
|
+
fi
|
|
569
|
+
if [ "$k" = 1 ] && [ "$sk" = 1 ] && [ "$s" -le "$SESSION_GATE" ]; then
|
|
570
|
+
gated+=(1); PICK_GATE_COUNT=$((PICK_GATE_COUNT + 1))
|
|
571
|
+
else
|
|
572
|
+
gated+=(0)
|
|
573
|
+
fi
|
|
487
574
|
done
|
|
488
575
|
n=${#cand[@]}
|
|
489
576
|
i=0
|
|
490
577
|
while [ "$i" -lt "$n" ]; do
|
|
491
|
-
|
|
492
|
-
[ "$
|
|
578
|
+
# Nobody clears the gate => everybody does: the gate compares, it never empties the pool.
|
|
579
|
+
[ "$PICK_GATE_COUNT" -eq 0 ] && gated[$i]=1
|
|
580
|
+
if [ "${gated[$i]}" = 1 ] && [ "${known[$i]}" = 1 ] && [ "${weekly[$i]}" -lt "$bestw" ]; then
|
|
581
|
+
bestw="${weekly[$i]}"
|
|
582
|
+
fi
|
|
493
583
|
i=$((i + 1))
|
|
494
584
|
done
|
|
495
|
-
|
|
585
|
+
band="$HEADROOM_BAND"
|
|
586
|
+
[ "$PICK_STRICT" = 1 ] && band=0
|
|
587
|
+
ceiling=$((bestw + band)); [ "$ceiling" -gt 100 ] && ceiling=100
|
|
496
588
|
i=0
|
|
497
589
|
while [ "$i" -lt "$n" ]; do
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
590
|
+
if [ "${gated[$i]}" = 1 ]; then
|
|
591
|
+
if [ "$bestw" -gt 100 ]; then
|
|
592
|
+
pool+=("$i") # no weekly reading anywhere: all gated tie
|
|
593
|
+
elif [ "${known[$i]}" = 1 ] && [ "${weekly[$i]}" -le "$ceiling" ]; then
|
|
594
|
+
pool+=("$i")
|
|
595
|
+
fi
|
|
503
596
|
fi
|
|
504
|
-
[ "$match" = 1 ] && pool+=("$i")
|
|
505
597
|
i=$((i + 1))
|
|
506
598
|
done
|
|
507
599
|
PICK_BAND_COUNT=${#pool[@]}
|
|
600
|
+
# Reservoir-sample the peers, skipping the account just handed out.
|
|
508
601
|
for i in "${pool[@]}"; do
|
|
509
602
|
if [ "${cand[$i]##*/}" != "$avoid" ]; then
|
|
510
603
|
ties=$((ties + 1))
|
|
@@ -518,22 +611,64 @@ pick_best() { # args: candidate dirs
|
|
|
518
611
|
done
|
|
519
612
|
fi
|
|
520
613
|
PICK_DIR="$best"
|
|
521
|
-
PICK_SCORE="$(sel_score_of "$best")"
|
|
522
614
|
}
|
|
523
615
|
|
|
524
616
|
if [ "${#eligible[@]}" -gt 0 ]; then
|
|
525
617
|
if [ "${CODEX_SHIM_SELECT:-headroom}" = "random" ]; then
|
|
526
618
|
PICK_DIR="${eligible[$((RANDOM % ${#eligible[@]}))]}"
|
|
527
619
|
PICK_BAND_COUNT=${#eligible[@]}
|
|
620
|
+
PICK_GATE_COUNT=${#eligible[@]}
|
|
621
|
+
SESSION_GATE_LOG=off
|
|
528
622
|
else
|
|
529
623
|
pick_best "${eligible[@]}"
|
|
530
624
|
fi
|
|
531
625
|
else
|
|
532
|
-
# Every account is limit-marked: degraded service beats a hard failure (100% rule)
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
626
|
+
# Every account is limit-marked: degraded service beats a hard failure (100% rule) —
|
|
627
|
+
# but not every limited account is equally dead. A window at 90-99% still answers;
|
|
628
|
+
# one at 100% (or a real client 429) rejects every request until its reset. The claude
|
|
629
|
+
# shim learned this on 2026-08-29; the codex shim ranked the whole valid set on weekly
|
|
630
|
+
# headroom until the session gate reached the fallback, where an exhausted account
|
|
631
|
+
# that happened to clear the gate would beat a still-serving one that did not
|
|
632
|
+
# (codex review, 2026-09-04).
|
|
633
|
+
soft=()
|
|
634
|
+
hard=()
|
|
635
|
+
for d in "${valid[@]}"; do
|
|
636
|
+
if limited_hard_blocked "$d"; then hard+=("$d"); else soft+=("$d"); fi
|
|
637
|
+
done
|
|
638
|
+
if [ "${#soft[@]}" -gt 0 ]; then
|
|
639
|
+
PICK_STRICT=1
|
|
640
|
+
pick_best "${soft[@]}"
|
|
641
|
+
PICK_STRICT=0
|
|
642
|
+
sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
|
|
643
|
+
else
|
|
644
|
+
# Every account is exhausted RIGHT NOW: nothing serves, so hand out the one that
|
|
645
|
+
# unblocks first — its rejection window is the shortest.
|
|
646
|
+
PICK_DIR=""
|
|
647
|
+
best_reset=0
|
|
648
|
+
for d in "${hard[@]}"; do
|
|
649
|
+
r="$(limited_reset_of "$d")"
|
|
650
|
+
if [ -z "$PICK_DIR" ]; then
|
|
651
|
+
PICK_DIR="$d"; best_reset="$r"; continue
|
|
652
|
+
fi
|
|
653
|
+
if [ "$r" -gt 0 ] && { [ "$best_reset" -eq 0 ] || [ "$r" -lt "$best_reset" ]; }; then
|
|
654
|
+
PICK_DIR="$d"; best_reset="$r"
|
|
655
|
+
fi
|
|
656
|
+
done
|
|
657
|
+
sel_log "all-limited fallback=$(basename "$PICK_DIR") all-exhausted resets_in=$((best_reset > now ? best_reset - now : 0))s"
|
|
658
|
+
# Every candidate rejects right now, so this pick WILL fail: say so on a terminal
|
|
659
|
+
# instead of letting the operator read the client's bare limit error as a bad
|
|
660
|
+
# choice by the pool. Throttled, and never on a service's stderr.
|
|
661
|
+
if [ -t 2 ]; then
|
|
662
|
+
exn="$ACC_ROOT/.exhausted-notice"
|
|
663
|
+
exlast=0
|
|
664
|
+
[ -f "$exn" ] && exlast="$(file_mtime "$exn")"
|
|
665
|
+
if [ $((now - exlast)) -gt 600 ]; then
|
|
666
|
+
: 2>/dev/null > "$exn" || true
|
|
667
|
+
printf 'codex-multiacc: every usable account is at a limit right now; %s frees up in %ds and was chosen for that.\n' \
|
|
668
|
+
"$(basename "$PICK_DIR")" "$((best_reset > now ? best_reset - now : 0))" >&2
|
|
669
|
+
fi
|
|
670
|
+
fi
|
|
671
|
+
fi
|
|
537
672
|
fi
|
|
538
673
|
pick="$PICK_DIR"
|
|
539
674
|
# Remember the pick so the NEXT run does not hand back the same account. An explicit
|
|
@@ -564,7 +699,7 @@ fi
|
|
|
564
699
|
acct="$(basename "$pick")"
|
|
565
700
|
sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')%" \
|
|
566
701
|
"session=$(fresh_field "$pick" session_percent || echo '?')%" \
|
|
567
|
-
"band=${HEADROOM_BAND} band-count=${PICK_BAND_COUNT} pwd=$PWD"
|
|
702
|
+
"band=${HEADROOM_BAND} band-count=${PICK_BAND_COUNT} session-gate=${SESSION_GATE_LOG} session-ok=${PICK_GATE_COUNT} pwd=$PWD"
|
|
568
703
|
|
|
569
704
|
export CODEX_SHIM_ACTIVE=1
|
|
570
705
|
|
package/bin/codex-accounts
CHANGED
|
@@ -1461,7 +1461,10 @@ for acct in manifest.get('accounts', []):
|
|
|
1461
1461
|
# THREE selection signals, same reset asymmetry as the claude pool:
|
|
1462
1462
|
# max_percent — peak of ALL buckets; drives >=90% EXCLUSION.
|
|
1463
1463
|
# weekly_percent — peak of the durable buckets; the PRIMARY ranking signal.
|
|
1464
|
-
# session_percent— peak of the self-healing ~5h buckets;
|
|
1464
|
+
# session_percent— peak of the self-healing ~5h buckets; the session GATE's input:
|
|
1465
|
+
# the shim ranks only accounts at/under CODEX_MULTIACC_SESSION_GATE
|
|
1466
|
+
# (default 50) while any clear it. (A soft tiebreaker until
|
|
1467
|
+
# 2026-09-03 — the operator asked for session FIRST, then weekly.)
|
|
1465
1468
|
maxp = max([b['percent'] for b in buckets] or [0])
|
|
1466
1469
|
weekly = [b['percent'] for b in buckets if b['group'] != 'session']
|
|
1467
1470
|
session = [b['percent'] for b in buckets if b['group'] == 'session']
|
|
@@ -333,6 +333,7 @@ derived from the pool root. Uninstalling an instance removes only that instance'
|
|
|
333
333
|
| `CLAUDE_CONFIG_DIR=...` | shim passes straight through (scripts can pin the old way) |
|
|
334
334
|
| `CLAUDE_MULTIACC_DISABLE=1` | bypass selection entirely |
|
|
335
335
|
| `CLAUDE_MULTIACC_HEADROOM_BAND=0..100` | weekly points treated as peers; default `30`, `0` is strict |
|
|
336
|
+
| `CLAUDE_MULTIACC_SESSION_GATE=0..100` | max 5h-session usage that still gets ranked; default `50`, `100` disables the gate |
|
|
336
337
|
| `CLAUDE_MULTIACC_CLIENT_LIMITS=0` | ignore the client's own rate-limit records |
|
|
337
338
|
| `CLAUDE_MULTIACC_CLIENT_SCAN_TTL=<s>` | clean client-limit scan cache; default 20s |
|
|
338
339
|
| `CLAUDE_MULTIACC_CLIENT_LIMIT_CONFIRM_DELAY=<s>` | low-telemetry recovery grace; default 300s |
|
|
@@ -343,8 +344,9 @@ derived from the pool root. Uninstalling an instance removes only that instance'
|
|
|
343
344
|
|
|
344
345
|
The codex shim honors the same switches spelled `CODEX_*`: `CODEX_ACCOUNT`,
|
|
345
346
|
`CODEX_HOME` (passthrough), `CODEX_MULTIACC_DISABLE`, `CODEX_SHIM_RETRY`,
|
|
346
|
-
`CODEX_SHIM_SELECT`, `
|
|
347
|
-
`CODEX_MULTIACC_SYNC_TARGET`,
|
|
347
|
+
`CODEX_SHIM_SELECT`, `CODEX_MULTIACC_HEADROOM_BAND`, `CODEX_MULTIACC_SESSION_GATE`,
|
|
348
|
+
`CODEX_ACCOUNTS_ROOT` (legacy `CODEX_ACCOUNTS_DIR`), `CODEX_MULTIACC_SYNC_TARGET`,
|
|
349
|
+
`CODEX_MULTIACC_THRESHOLD`.
|
|
348
350
|
|
|
349
351
|
## Verification
|
|
350
352
|
|
|
@@ -382,8 +384,9 @@ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
|
|
|
382
384
|
before direct, TUI, or `--resume` work sees the 401. Run `claude-accounts verify` to
|
|
383
385
|
check every token immediately; `claude-accounts expired` reports token-only accounts
|
|
384
386
|
as `UNVERIFIED` until that proof exists.
|
|
385
|
-
- **Everything marked limited** — the shim still runs
|
|
386
|
-
|
|
387
|
+
- **Everything marked limited** — the shim still runs: the still-serving limited accounts
|
|
388
|
+
go through the same two cuts (session gate, then strict best-weekly) and one is handed
|
|
389
|
+
out anyway; check `selection.log` for `all-limited fallback=` lines.
|
|
387
390
|
- **Sync fails** — `tail ~/.claude-accounts/sync.log`; it's ssh/rsync to the manifest's
|
|
388
391
|
`server` (BatchMode — needs key auth).
|
|
389
392
|
- **A service bypasses the shim** — it spawns an absolute path. Point its env
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# App-Robot Delivery Tracking Prompt
|
|
2
|
+
|
|
3
|
+
Replace `<TASK_URL>` with the delivery URL, then paste the full prompt into Claude Code.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
Track app-robot delivery task <TASK_URL> to completion and actively repair anything
|
|
7
|
+
that prevents forward progress.
|
|
8
|
+
|
|
9
|
+
Operating rules:
|
|
10
|
+
|
|
11
|
+
1. Start with a read-only timeline audit: current task/step/run, step age, last changed
|
|
12
|
+
evidence, worker/execution ownership, retry count, external operations, blockers,
|
|
13
|
+
and production build SHA.
|
|
14
|
+
|
|
15
|
+
2. Treat 15 minutes without changed durable evidence as a stall. Do not accept log
|
|
16
|
+
activity, repeated probes, heartbeats, or newly minted attempts as progress.
|
|
17
|
+
|
|
18
|
+
3. Never repeat the same failed action more than once. On the second identical failure,
|
|
19
|
+
stop retrying, identify the shared root cause, add a focused regression test, open a
|
|
20
|
+
fix PR, pass exact-head CI, merge/deploy it, verify production, then resume the task.
|
|
21
|
+
|
|
22
|
+
4. Use app-robot's supported operations only. Preserve at-most-once semantics for store
|
|
23
|
+
submissions and other external mutations. Never bypass an operator-disabled store lane.
|
|
24
|
+
|
|
25
|
+
5. Check that every supervising turn has a claimable execution, bounded retry and launch
|
|
26
|
+
budgets, expiring reservations, and a periodic reconciler. Repair the platform if not.
|
|
27
|
+
|
|
28
|
+
6. Parallelize independent diagnosis and fixes, but serialize overlapping PRs. Batch
|
|
29
|
+
related fixes where safe. Merge overlapping PRs once in dependency order instead of
|
|
30
|
+
rebasing each after every squash.
|
|
31
|
+
|
|
32
|
+
7. Run focused impacted tests during development. If local test admission waits over two
|
|
33
|
+
minutes, diagnose the admission and database lifecycle and use CI as the merge gate.
|
|
34
|
+
Do not wait indefinitely or start duplicate suites.
|
|
35
|
+
|
|
36
|
+
8. Perform one bounded adversarial review of each original root-cause area, using at most
|
|
37
|
+
three reviewers. Aggregate confirmed findings into at most one follow-up PR per
|
|
38
|
+
subsystem. Do not review review-generated follow-ups unless they introduce a genuinely
|
|
39
|
+
new subsystem. Launch no recursive review fan-out.
|
|
40
|
+
|
|
41
|
+
9. Cap each review workflow at 20 minutes. At the deadline, aggregate completed evidence
|
|
42
|
+
and stop incomplete or redundant workers. Cap each implementation agent at 20 minutes
|
|
43
|
+
to produce a pushed commit or test result; otherwise stop it and narrow or reassign the
|
|
44
|
+
task. A stalled agent is not progress.
|
|
45
|
+
|
|
46
|
+
10. Do not wait for manual cleanup that is not on the delivery critical path. Convert the
|
|
47
|
+
cause into a bounded code fix and regression test, then let exact-head CI verify it.
|
|
48
|
+
|
|
49
|
+
11. Every 10 minutes report: current step, age, last real progress, active blocker, exact
|
|
50
|
+
remediation, queue/CI/deploy status, remaining critical path, and any wall-clock budget
|
|
51
|
+
deviation.
|
|
52
|
+
|
|
53
|
+
12. Persist a restart-safe checkpoint before context or account exhaustion: task/run IDs,
|
|
54
|
+
operation IDs, PR queue, production build, monitors, and exact next commands.
|
|
55
|
+
|
|
56
|
+
13. Target four hours for a new delivery task and two hours for a repair-only critical
|
|
57
|
+
path, excluding unavoidable external store review. Use these sub-budgets:
|
|
58
|
+
- initial audit: 10 minutes
|
|
59
|
+
- root-cause diagnosis: 20 minutes per independent failure
|
|
60
|
+
- implementation agent: 20 minutes to a pushed artifact
|
|
61
|
+
- review workflow: 20 minutes, maximum three reviewers
|
|
62
|
+
- unchanged CI or deploy: investigate after 15 minutes
|
|
63
|
+
Report a budget miss immediately; do not silently extend it.
|
|
64
|
+
|
|
65
|
+
14. Completion means: all enabled lanes have accepted outcomes; policy-disabled lanes are
|
|
66
|
+
recorded as notified person waits; all confirmed root-cause fixes are merged, deployed,
|
|
67
|
+
and production-verified; the repair queue is empty; bounded reviews are finished.
|
|
68
|
+
Then mark /goal complete.
|
|
69
|
+
|
|
70
|
+
First response: give me the evidence-based critical path and the maximum wall-clock budget
|
|
71
|
+
for each remaining step. Then begin; do not merely propose a plan.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The limits prevent an open-ended review-of-review loop. External store review can outlast
|
|
75
|
+
the target, but the task must reach a durable submitted or policy-blocked state before the
|
|
76
|
+
tracking goal is considered complete.
|
package/docs/UNIFIED_SELECTOR.md
CHANGED
|
@@ -20,19 +20,52 @@ The request schema is `claude-multiacc/pool-selection.v2` and contains:
|
|
|
20
20
|
- the policy (`default_claude`, `default_codex`, `default_both`, `explicit`,
|
|
21
21
|
`producer_retry`, or `reviewer`);
|
|
22
22
|
- any required provider, producer identity and explicit exclusions;
|
|
23
|
-
- the runner generation's candidate telemetry plus exact reservation history
|
|
23
|
+
- the runner generation's candidate telemetry plus exact reservation history;
|
|
24
|
+
- optionally `headroom_band` and `session_gate`, the two ranking knobs below.
|
|
25
|
+
|
|
26
|
+
Both knobs are optional so a caller pinned to an older panel build keeps working: each
|
|
27
|
+
is a decimal clamped to 0–100, and a value that is not a plain decimal (a bool, a list,
|
|
28
|
+
`null`, `1e5`) is refused as `invalid_request` naming that field rather than silently
|
|
29
|
+
widening selection. `headroom_band` defaults to `30`, `session_gate` to `50`.
|
|
24
30
|
|
|
25
31
|
Candidates must be active, fresh, provider-capable, not currently limited, and not
|
|
26
32
|
covered by a live reservation. Percentages are finite decimals clamped to 0–100.
|
|
27
33
|
Canonical account IDs are NFC-normalized and trimmed with case preserved; an
|
|
28
34
|
NFC/case-folded key rejects case-only duplicate identities before ranking.
|
|
29
|
-
Known quota ranks ahead of unknown quota; higher minimum weekly/session headroom
|
|
30
|
-
wins; equal capacity goes to the least-recently-selected account, then a stable
|
|
31
|
-
Claude-before-Codex/account-id tie order.
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
## Ranking: the session gate, then the weekly band
|
|
37
|
+
|
|
38
|
+
The eligible set is ranked in two cuts (operator's decision, 2026-09-03: "among
|
|
39
|
+
accounts where high session limits it must choose randomly from ones where highest
|
|
40
|
+
weekly limits").
|
|
41
|
+
|
|
42
|
+
1. **Session gate.** An account clears the gate when its quota is known and its session
|
|
43
|
+
(5h) usage is at most `session_gate` points. If any account clears it, only those are
|
|
44
|
+
ranked further — a nearly spent 5h bucket is about to reject the launch whatever the
|
|
45
|
+
weekly headroom says. If nobody clears it the gate steps aside and every known
|
|
46
|
+
account is ranked: the gate compares, it never empties the pool. Unknown quota never
|
|
47
|
+
clears it. `session_gate: 100` disables the gate.
|
|
48
|
+
2. **Weekly band.** Among the gated accounts, the largest weekly remaining is the
|
|
49
|
+
leader and `band_floor` is that minus `headroom_band`; every gated account at or
|
|
50
|
+
above the floor is a peer. The band is measured on WEEKLY remaining, not on
|
|
51
|
+
`min(weekly, session)` — session headroom already had its say in the gate.
|
|
52
|
+
`headroom_band: 0` restores strict best-weekly ranking.
|
|
53
|
+
3. **Inside the band.** The least-recently-selected peer wins, then a spread digest over
|
|
54
|
+
(reservation key, identity), then a stable Claude-before-Codex/account-id order. This
|
|
55
|
+
is what makes a burst of parallel launches fan out instead of stacking on one
|
|
56
|
+
account, and it is deterministic: the same request always yields the same winner.
|
|
57
|
+
|
|
58
|
+
With no usable quota anywhere the band cannot apply (`band_floor` is `null`) and the
|
|
59
|
+
plain ordering stands: known quota ahead of unknown, then highest effective headroom,
|
|
60
|
+
then least-recently-selected.
|
|
61
|
+
|
|
62
|
+
Success returns the concrete engine/account/runner generation, normalized score basis
|
|
63
|
+
(which still reports `effective_headroom` = min(weekly, session) for observability),
|
|
64
|
+
`headroom_band` / `band_floor` / `band_count`, `session_gate` / `session_ok_count` (how
|
|
65
|
+
many accounts cleared the gate; `0` means nobody did and the gate stepped aside), an
|
|
66
|
+
RFC 8785/SHA-256 candidate-snapshot digest and a selection digest. Both knobs are inputs
|
|
67
|
+
to the selection digest, because both can change which account wins. Stable failures are
|
|
68
|
+
`invalid_request`, `unsupported_schema`, `duplicate_candidate_identity`, and
|
|
36
69
|
`no_candidate`. A reviewer stays on the producer's provider and avoids its account when
|
|
37
70
|
another eligible account exists.
|
|
38
71
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/selector_policy.py
CHANGED
|
@@ -7,6 +7,7 @@ from decimal import Decimal, localcontext
|
|
|
7
7
|
|
|
8
8
|
from selector_primitives import (
|
|
9
9
|
DEFAULT_HEADROOM_BAND,
|
|
10
|
+
DEFAULT_SESSION_GATE,
|
|
10
11
|
SCHEMA,
|
|
11
12
|
SELECTOR_VERSION,
|
|
12
13
|
canonical_sha256,
|
|
@@ -126,17 +127,39 @@ def _winner_key(row: dict) -> tuple:
|
|
|
126
127
|
row["engine"], row["account_id"], row["runner_id"], row["runner_generation"])
|
|
127
128
|
|
|
128
129
|
|
|
130
|
+
def _session_gate(rows: list[dict], gate: Decimal | None) -> list[dict]:
|
|
131
|
+
"""The FIRST cut: accounts whose 5h session bucket still has room.
|
|
132
|
+
|
|
133
|
+
The operator (2026-09-03): "among accounts where high session limits it must
|
|
134
|
+
choose randomly from ones where highest weekly limits." A nearly-spent session
|
|
135
|
+
bucket is about to reject the launch whatever the weekly headroom says, so it
|
|
136
|
+
disqualifies the account outright instead of merely nudging a tie-break. The
|
|
137
|
+
gate COMPARES, it never empties the pool: when nobody clears it the caller
|
|
138
|
+
falls back to every known row. Unknown quota never clears it — a missing
|
|
139
|
+
reading is not evidence of room.
|
|
140
|
+
"""
|
|
141
|
+
if gate is None:
|
|
142
|
+
return []
|
|
143
|
+
return [row for row in rows if Decimal(row["session_pct"]) <= gate]
|
|
144
|
+
|
|
145
|
+
|
|
129
146
|
def _band_floor(rows: list[dict], band: Decimal) -> Decimal | None:
|
|
130
|
-
"""The lowest
|
|
147
|
+
"""The lowest WEEKLY remaining still counted as "as good as the best" this round.
|
|
131
148
|
|
|
132
149
|
Ranking strictly by headroom hands every task to whichever account is on top,
|
|
133
150
|
which is precisely how one account's limit gets burned to zero while three
|
|
134
151
|
others idle: the runner-up only ever wins after the leader has been spent
|
|
135
152
|
below it. Accounts within ``band`` points of the leader are treated as equally
|
|
136
153
|
good, and the tie-break below spreads work across them.
|
|
154
|
+
|
|
155
|
+
The band is measured on weekly remaining, not on min(weekly, session): session
|
|
156
|
+
headroom already had its say in ``_session_gate`` above, and letting it back in
|
|
157
|
+
here both pushed the best weekly account out of the band over a half-spent 5h
|
|
158
|
+
bucket and let a fully spent one stay in. Rows reaching here are the gated set,
|
|
159
|
+
so every one of them has a known reading.
|
|
137
160
|
"""
|
|
138
|
-
known = [Decimal(row["
|
|
139
|
-
if row["quota_known"] and row["
|
|
161
|
+
known = [Decimal(row["weekly_remaining"]) for row in rows
|
|
162
|
+
if row["quota_known"] and row["weekly_remaining"] is not None]
|
|
140
163
|
return max(known) - band if known else None
|
|
141
164
|
|
|
142
165
|
|
|
@@ -197,15 +220,21 @@ def select(request: object) -> dict:
|
|
|
197
220
|
if not eligible:
|
|
198
221
|
return _error("no_candidate", {"eligible_count": 0})
|
|
199
222
|
band, band_text = decimal_value(request.get("headroom_band", DEFAULT_HEADROOM_BAND))
|
|
200
|
-
|
|
223
|
+
gate, gate_text = decimal_value(request.get("session_gate", DEFAULT_SESSION_GATE))
|
|
224
|
+
# Two cuts, in this order (operator's 2026-09-03 decision): the session gate says
|
|
225
|
+
# WHO may be considered, the weekly band says which of those count as equally
|
|
226
|
+
# good. "healthy or known" is the gate stepping aside when nobody clears it.
|
|
227
|
+
known = [row for row in eligible if row["quota_known"]]
|
|
228
|
+
healthy = _session_gate(known, gate)
|
|
229
|
+
ranked = healthy or known
|
|
230
|
+
floor = _band_floor(ranked, band) if band is not None else None
|
|
201
231
|
if floor is None:
|
|
202
232
|
# No usable quota anywhere: nothing to band, so keep the plain ordering
|
|
203
233
|
# (which already ranks unknown-quota rows last and rotates on ties).
|
|
204
234
|
banded = eligible
|
|
205
235
|
winner = min(banded, key=_winner_key)
|
|
206
236
|
else:
|
|
207
|
-
banded = [row for row in
|
|
208
|
-
and Decimal(row["effective_headroom"]) >= floor]
|
|
237
|
+
banded = [row for row in ranked if Decimal(row["weekly_remaining"]) >= floor]
|
|
209
238
|
winner = min(banded, key=_band_key(request["reservation_key"]))
|
|
210
239
|
rows.sort(key=_snapshot_key)
|
|
211
240
|
snapshot = canonical_sha256(rows)
|
|
@@ -214,8 +243,10 @@ def select(request: object) -> dict:
|
|
|
214
243
|
"schema": SCHEMA, "selector_version": SELECTOR_VERSION,
|
|
215
244
|
"database_now": canonical_now, "policy": request["policy"],
|
|
216
245
|
"required_engine": request.get("required_engine"), "reservation_key": request["reservation_key"],
|
|
217
|
-
# The band
|
|
246
|
+
# The band and the gate both change which account wins, so both belong in
|
|
247
|
+
# the proof. (This is why the golden digests moved on 2026-09-03.)
|
|
218
248
|
"headroom_band": band_text,
|
|
249
|
+
"session_gate": gate_text,
|
|
219
250
|
"candidate_snapshot_digest": snapshot, "selected_identity": chosen}
|
|
220
251
|
score_fields = ("quota_known", "weekly_pct", "session_pct", "weekly_remaining",
|
|
221
252
|
"session_remaining", "effective_headroom", "last_selected_at")
|
|
@@ -228,6 +259,10 @@ def select(request: object) -> dict:
|
|
|
228
259
|
"headroom_band": band_text,
|
|
229
260
|
"band_floor": format(floor, "f") if floor is not None else None,
|
|
230
261
|
"band_count": len(banded),
|
|
262
|
+
# ...and how many accounts the session gate let through at all
|
|
263
|
+
# (0 means nobody cleared it and the gate stepped aside).
|
|
264
|
+
"session_gate": gate_text,
|
|
265
|
+
"session_ok_count": len(healthy),
|
|
231
266
|
"candidate_snapshot_digest": snapshot,
|
|
232
267
|
"selection_digest": canonical_sha256(digest_input)}
|
|
233
268
|
if request["policy"] == "reviewer" and not alternatives and _row_identity(winner) == producer:
|