claude-multiacc 1.0.13 → 1.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +276 -15
- package/bin/claude +565 -46
- package/bin/claude-accounts +447 -75
- package/bin/codex +254 -26
- package/bin/codex-accounts +157 -52
- package/install.sh +196 -69
- package/lib/common.sh +262 -2
- package/lib/credential.py +336 -0
- package/lib/report.py +434 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +1556 -8
package/bin/codex
CHANGED
|
@@ -13,7 +13,10 @@ set -u
|
|
|
13
13
|
|
|
14
14
|
# ${HOME:-} guards: with HOME stripped (env -i, some cron/systemd units) the shim
|
|
15
15
|
# must still fail OPEN into plain passthrough, never abort on an unbound variable.
|
|
16
|
-
|
|
16
|
+
# CODEX_ACCOUNTS_ROOT scopes the pool to one app-robot instance; CODEX_ACCOUNTS_DIR
|
|
17
|
+
# is the older spelling and still works. Same precedence as lib/common.sh, so the shim
|
|
18
|
+
# and codex-accounts always look at the same pool.
|
|
19
|
+
ACC_ROOT="${CODEX_ACCOUNTS_ROOT:-${CODEX_ACCOUNTS_DIR:-${HOME:-/nonexistent}/.codex-accounts}}"
|
|
17
20
|
MANIFEST="$ACC_ROOT/accounts.json"
|
|
18
21
|
|
|
19
22
|
canon_path() {
|
|
@@ -83,8 +86,11 @@ now="$(date +%s)"
|
|
|
83
86
|
# sed with a safe default, never a JSON parse.
|
|
84
87
|
if [ -z "${CODEX_MULTIACC_THRESHOLD:-}" ]; then
|
|
85
88
|
CODEX_MULTIACC_THRESHOLD="$(sed -n 's/.*"threshold"[^0-9]*\([0-9][0-9]*\).*/\1/p' "$MANIFEST" 2>/dev/null | head -1)"
|
|
86
|
-
|
|
89
|
+
CODEX_MULTIACC_THRESHOLD="$CODEX_MULTIACC_THRESHOLD"
|
|
87
90
|
fi
|
|
91
|
+
# Scraped or handed in by the caller, it has to be a number bash can compare without
|
|
92
|
+
# complaining to stderr.
|
|
93
|
+
case "$CODEX_MULTIACC_THRESHOLD" in ''|*[!0-9]*|??????*) CODEX_MULTIACC_THRESHOLD=90 ;; esac
|
|
88
94
|
|
|
89
95
|
if [ "$(uname -s)" = "Darwin" ]; then
|
|
90
96
|
file_mtime() { stat -f %m "$1" 2>/dev/null || echo 0; }
|
|
@@ -92,21 +98,28 @@ else
|
|
|
92
98
|
file_mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
|
|
93
99
|
fi
|
|
94
100
|
|
|
101
|
+
|
|
102
|
+
# A number this shim will do ARITHMETIC on: digits only, and short enough that bash
|
|
103
|
+
# cannot go out of range. An over-range value makes `[ x -lt y ]` print
|
|
104
|
+
# "integer expression expected" on stderr — which a service-spawned run must never see —
|
|
105
|
+
# and makes $((x + 1)) wrap negative. Pool state is a file anyone can corrupt, so every
|
|
106
|
+
# scraped number goes through here.
|
|
107
|
+
num_ok() { case "$1" in ''|*[!0-9]*) return 1 ;; esac; [ "${#1}" -le 18 ]; }
|
|
108
|
+
|
|
95
109
|
sel_log() {
|
|
96
|
-
printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$ACC_ROOT/selection.log"
|
|
110
|
+
printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" 2>/dev/null >> "$ACC_ROOT/selection.log" || true
|
|
97
111
|
}
|
|
98
112
|
|
|
99
113
|
marker_active() { # true if $1/.limited is still in force; clears cleanly-expired markers
|
|
100
114
|
local m="$1/.limited" reset=""
|
|
101
115
|
[ -f "$m" ] || return 1
|
|
102
116
|
IFS= read -r reset < "$m" 2>/dev/null || reset=""
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
esac
|
|
117
|
+
if ! num_ok "$reset"; then
|
|
118
|
+
# Empty/partial/garbled/absurd marker — e.g. read during a concurrent rewrite.
|
|
119
|
+
# Treat as ACTIVE and never delete: deleting here could destroy a marker
|
|
120
|
+
# another process is mid-write. The next limits refresh rewrites or clears it.
|
|
121
|
+
return 0
|
|
122
|
+
fi
|
|
110
123
|
if [ "$now" -ge "$reset" ]; then
|
|
111
124
|
rm -f "$m" 2>/dev/null
|
|
112
125
|
return 1
|
|
@@ -120,10 +133,10 @@ fresh_field() { # fresh_field <acct dir> <json key> -> integer if telemetry fres
|
|
|
120
133
|
local f="$1/limits.json" fetched v
|
|
121
134
|
[ -f "$f" ] || return 1
|
|
122
135
|
fetched="$(sed -n 's/.*"fetched_at"[^0-9]*\([0-9][0-9]*\).*/\1/p' "$f" 2>/dev/null | head -1)"
|
|
123
|
-
|
|
136
|
+
num_ok "$fetched" || return 1
|
|
124
137
|
[ $((now - fetched)) -le "$STALE_AFTER" ] || return 1
|
|
125
138
|
v="$(sed -n "s/.*\"$2\"[^0-9]*\([0-9][0-9]*\).*/\1/p" "$f" 2>/dev/null | head -1)"
|
|
126
|
-
|
|
139
|
+
num_ok "$v" || return 1
|
|
127
140
|
printf '%s\n' "$v"
|
|
128
141
|
}
|
|
129
142
|
|
|
@@ -148,6 +161,151 @@ over_threshold() { # $1 = acct dir
|
|
|
148
161
|
[ "$v" -ge "${CODEX_MULTIACC_THRESHOLD:-90}" ]
|
|
149
162
|
}
|
|
150
163
|
|
|
164
|
+
# ---- client-reported rate limits ---------------------------------------------
|
|
165
|
+
# Same idea as the claude shim: do not depend on the usage endpoint to notice that an
|
|
166
|
+
# account ran dry. The codex CLI writes every `token_count` event into the run's rollout
|
|
167
|
+
# with the windows the server just reported:
|
|
168
|
+
# "rate_limits":{"primary":{"used_percent":97.4,"window_minutes":10080,"resets_at":<epoch>},
|
|
169
|
+
# "secondary":{...}}
|
|
170
|
+
# Rollouts live under $CODEX_HOME/sessions/<Y>/<M>/<D>/. When that tree really belongs to
|
|
171
|
+
# the account they need no session->account index — but the installed layout SYMLINKS it
|
|
172
|
+
# to a shared ~/.codex/sessions so `codex resume` finds every session, and there it proves
|
|
173
|
+
# nothing about who spent the quota. sessions_owned() below is what keeps one account's
|
|
174
|
+
# spend from marking the whole pool; on the shared layout this scan simply stays off and
|
|
175
|
+
# the usage endpoint remains the only limit signal for codex.
|
|
176
|
+
# Usage only grows inside a window, so a report from earlier in the same window is still a
|
|
177
|
+
# valid lower bound — which is why an in-force `resets_at` is the only freshness test.
|
|
178
|
+
# ...and only when that tree is private to the account: a shared one says nothing
|
|
179
|
+
# about who spent the quota.
|
|
180
|
+
sessions_owned() { # $1 acct dir
|
|
181
|
+
# Structural and deliberately FORK-FREE: this runs for every account on every single
|
|
182
|
+
# invocation, and a pair of canon_path calls here cost more than the whole scan.
|
|
183
|
+
# A session tree is this account's own evidence only when neither the account dir nor
|
|
184
|
+
# its sessions dir is a symlink — which is exactly how the installed layout shares them
|
|
185
|
+
# (lib/common.sh seeds codex accounts with sessions -> ~/.codex/sessions, and an account
|
|
186
|
+
# dir may itself be a symlink to ~/.codex). Anything shared fails OPEN: no ownership,
|
|
187
|
+
# no exclusion, and the usage endpoint stays the only limit signal for that account.
|
|
188
|
+
[ -d "$1/sessions" ] || return 1
|
|
189
|
+
[ -L "$1/sessions" ] && return 1
|
|
190
|
+
[ -L "$1" ] && return 1
|
|
191
|
+
return 0
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
QUOTA_SCAN_BYTES=262144 # rollout tail read per session (the newest report is last)
|
|
195
|
+
QUOTA_SCAN_MAX_AGE=21600 # 6h of rollout mtime — older runs are re-reported by newer ones
|
|
196
|
+
QUOTA_SCAN_MAX_FILES=3 # hard cap per account: a limit still in force rejects the
|
|
197
|
+
# newest sessions too, so older ones can only repeat the news
|
|
198
|
+
|
|
199
|
+
rl_field() { # rl_field <json fragment> <key> -> leading integer of that key's value
|
|
200
|
+
printf '%s' "$1" | LC_ALL=C sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p" | head -1
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# Newest still-in-force over-threshold window this account's own runs recorded.
|
|
204
|
+
# Prints "<reset-epoch> <window>"; fails when there is none.
|
|
205
|
+
client_limit_scan() { # $1 acct dir
|
|
206
|
+
local day f line frag pct reset best=0 bestwin="" scanned=0 thr="${CODEX_MULTIACC_THRESHOLD:-90}" which
|
|
207
|
+
local memo="$1/.client-scan" last="" ttl
|
|
208
|
+
[ "${CODEX_MULTIACC_CLIENT_LIMITS:-1}" = "0" ] && return 1
|
|
209
|
+
sessions_owned "$1" || return 1
|
|
210
|
+
# A CLEAN result is remembered for a few seconds so a tight loop of `codex exec` runs
|
|
211
|
+
# does not re-read the same rollout tails every time. Only the clean answer is
|
|
212
|
+
# memoized — a spent window becomes a .limited marker, and marker_active short-circuits
|
|
213
|
+
# this scan from then on.
|
|
214
|
+
if [ -f "$memo" ]; then
|
|
215
|
+
IFS= read -r last < "$memo" 2>/dev/null || last=""
|
|
216
|
+
num_ok "$last" || last=0
|
|
217
|
+
# The TTL is caller-supplied, so it goes through num_ok too: `[ x -lt bogus ]` would
|
|
218
|
+
# print "integer expression expected" on the caller's stderr before exec.
|
|
219
|
+
ttl="${CODEX_MULTIACC_CLIENT_SCAN_TTL:-20}"
|
|
220
|
+
num_ok "$ttl" || ttl=20
|
|
221
|
+
[ $((now - last)) -lt "$ttl" ] && return 1
|
|
222
|
+
fi
|
|
223
|
+
# NEWEST FIRST, always. Both day dirs and rollout filenames start with an ISO timestamp,
|
|
224
|
+
# so lexicographic order IS chronological order — walking it forwards would spend the
|
|
225
|
+
# whole file budget on the oldest runs and never reach the one that actually hit the
|
|
226
|
+
# wall (codex-review finding: 13 rollouts, only the newest over threshold). The first
|
|
227
|
+
# file that reports a spent window wins: usage only grows inside a window, so nothing
|
|
228
|
+
# older can be more current.
|
|
229
|
+
#
|
|
230
|
+
# find(1), not a glob, for two reasons a second review pass turned up:
|
|
231
|
+
# * with -P (the default) find never descends a SYMLINKED component, so a nested
|
|
232
|
+
# sessions/<year> -> /somewhere/shared cannot smuggle another pool's rollouts in
|
|
233
|
+
# under an account whose own sessions/ dir is real;
|
|
234
|
+
# * `tail -n` bounds the list inside the pipe, so a tree with a hundred thousand stale
|
|
235
|
+
# rollouts never becomes a hundred-thousand-element shell array before the cap.
|
|
236
|
+
local days=() files=() di fx
|
|
237
|
+
while IFS= read -r day; do
|
|
238
|
+
# find(1) output is newline-delimited, so a pool file whose NAME contains a newline
|
|
239
|
+
# arrives as two lines and its tail would resolve relative to $PWD — outside the pool
|
|
240
|
+
# entirely. Every path is therefore re-checked against the tree it must have come from.
|
|
241
|
+
case "$day" in "$1"/sessions/?*) ;; *) continue ;; esac
|
|
242
|
+
days+=("$day")
|
|
243
|
+
done <<EOF
|
|
244
|
+
$(find "$1/sessions" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | LC_ALL=C sort | tail -3)
|
|
245
|
+
EOF
|
|
246
|
+
di=$(( ${#days[@]} - 1 ))
|
|
247
|
+
while [ "$di" -ge 0 ] && [ "$scanned" -lt "$QUOTA_SCAN_MAX_FILES" ]; do
|
|
248
|
+
day="${days[$di]}"
|
|
249
|
+
di=$((di - 1))
|
|
250
|
+
files=()
|
|
251
|
+
while IFS= read -r f; do
|
|
252
|
+
case "$f" in "$day"/rollout-?*) ;; *) continue ;; esac
|
|
253
|
+
files+=("$f")
|
|
254
|
+
done <<EOF
|
|
255
|
+
$(find "$day" -maxdepth 1 -type f -name 'rollout-*.jsonl' 2>/dev/null | LC_ALL=C sort | tail -n "$QUOTA_SCAN_MAX_FILES")
|
|
256
|
+
EOF
|
|
257
|
+
fx=$(( ${#files[@]} - 1 ))
|
|
258
|
+
# A file that fails the staleness test still costs a stat, so it spends budget too.
|
|
259
|
+
while [ "$fx" -ge 0 ] && [ "$scanned" -lt "$QUOTA_SCAN_MAX_FILES" ]; do
|
|
260
|
+
f="${files[$fx]}"
|
|
261
|
+
fx=$((fx - 1))
|
|
262
|
+
scanned=$((scanned + 1))
|
|
263
|
+
[ -L "$f" ] && continue
|
|
264
|
+
[ $((now - $(file_mtime "$f"))) -le "$QUOTA_SCAN_MAX_AGE" ] || continue
|
|
265
|
+
line="$(tail -c "$QUOTA_SCAN_BYTES" "$f" 2>/dev/null \
|
|
266
|
+
| LC_ALL=C grep -a '^{".*"rate_limits"' \
|
|
267
|
+
| tail -1)"
|
|
268
|
+
[ -n "$line" ] || continue
|
|
269
|
+
for which in primary secondary; do
|
|
270
|
+
frag="$(printf '%s' "$line" | LC_ALL=C sed -n "s/.*\"$which\"[[:space:]]*:[[:space:]]*{\([^}]*\)}.*/\1/p")"
|
|
271
|
+
[ -n "$frag" ] || continue
|
|
272
|
+
pct="$(rl_field "$frag" used_percent)"
|
|
273
|
+
reset="$(rl_field "$frag" resets_at)"
|
|
274
|
+
num_ok "$pct" || continue
|
|
275
|
+
num_ok "$reset" || continue
|
|
276
|
+
[ "$pct" -ge "$thr" ] || continue
|
|
277
|
+
if [ "$reset" -gt "$best" ]; then best="$reset"; bestwin="$which:$pct"; fi
|
|
278
|
+
done
|
|
279
|
+
[ "$best" -gt "$now" ] && break 2
|
|
280
|
+
done
|
|
281
|
+
done
|
|
282
|
+
if [ "$best" -le "$now" ]; then
|
|
283
|
+
printf '%s\n' "$now" 2>/dev/null > "$memo.$$" \
|
|
284
|
+
&& mv -f "$memo.$$" "$memo" 2>/dev/null || rm -f "$memo.$$" 2>/dev/null
|
|
285
|
+
return 1
|
|
286
|
+
fi
|
|
287
|
+
printf '%s %s\n' "$best" "$bestwin"
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 window:pct
|
|
291
|
+
local m="$1/.limited" cur=""
|
|
292
|
+
# Never shorten a marker that already reaches further out, and never rewrite the
|
|
293
|
+
# same one on every invocation.
|
|
294
|
+
if [ -f "$m" ]; then
|
|
295
|
+
IFS= read -r cur < "$m" 2>/dev/null || cur=""
|
|
296
|
+
num_ok "$cur" || cur=0
|
|
297
|
+
[ "$cur" -ge "$2" ] && return 0
|
|
298
|
+
fi
|
|
299
|
+
{
|
|
300
|
+
echo "$2"
|
|
301
|
+
echo "bucket=client:${3%%:*} percent=${3##*:} marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=client-rate-limit"
|
|
302
|
+
} 2>/dev/null > "$1/.limited.$$" \
|
|
303
|
+
&& mv -f "$1/.limited.$$" "$m" 2>/dev/null \
|
|
304
|
+
|| rm -f "$1/.limited.$$" 2>/dev/null || true
|
|
305
|
+
sel_log "$(basename "$1") LIMITED by its own run ($3, resets $2) — client-reported"
|
|
306
|
+
return 0
|
|
307
|
+
}
|
|
308
|
+
|
|
151
309
|
# Auth is a ChatGPT login: auth.json carrying a non-empty access token. An API-key-only
|
|
152
310
|
# auth.json is NOT auth here (subscription-only by design), and an empty file is NOT
|
|
153
311
|
# auth (an interrupted write must not make a dead account selectable).
|
|
@@ -225,6 +383,16 @@ for d in "$ACC_ROOT"/acct-*; do
|
|
|
225
383
|
fi
|
|
226
384
|
valid+=("$d")
|
|
227
385
|
marker_active "$d" && continue
|
|
386
|
+
# The account's own records are consulted BEFORE telemetry: what the server told a real
|
|
387
|
+
# call is first-hand and carries the real reset, while limits.json can be days stale —
|
|
388
|
+
# the usage endpoint rate-limits its own callers. Marking here (rather than lazily, on
|
|
389
|
+
# whichever account happens to be picked) is what makes the marker visible to
|
|
390
|
+
# `codex-accounts status`, to a concurrent run in another terminal, and to sync.
|
|
391
|
+
# The cost is bounded by the scan's own file budget and its clean-result memo.
|
|
392
|
+
if lim="$(client_limit_scan "$d")"; then
|
|
393
|
+
mark_client_limit "$d" "${lim%% *}" "${lim##* }"
|
|
394
|
+
continue
|
|
395
|
+
fi
|
|
228
396
|
over_threshold "$d" && continue
|
|
229
397
|
eligible+=("$d")
|
|
230
398
|
done
|
|
@@ -240,7 +408,7 @@ if [ "${#expired[@]}" -gt 0 ]; then
|
|
|
240
408
|
last=0
|
|
241
409
|
[ -f "$n" ] && last="$(file_mtime "$n")"
|
|
242
410
|
if [ $((now - last)) -gt 3600 ]; then
|
|
243
|
-
: > "$n"
|
|
411
|
+
: 2>/dev/null > "$n" || true
|
|
244
412
|
printf 'codex-multiacc: %s account(s) unusable (%s) — see: codex-accounts expired\n' \
|
|
245
413
|
"${#expired[@]}" "${ids# }" >&2
|
|
246
414
|
fi
|
|
@@ -260,6 +428,30 @@ if [ "${#valid[@]}" -eq 0 ]; then
|
|
|
260
428
|
exec "$REAL" "$@"
|
|
261
429
|
fi
|
|
262
430
|
|
|
431
|
+
# ---- rotation ----------------------------------------------------------------
|
|
432
|
+
# Deliberately NOT a full least-recently-used order: just "do not hand back the account
|
|
433
|
+
# you were on a moment ago". That is the whole of the bug (quit a session that ran into
|
|
434
|
+
# its limit, start another, land straight back on it), and it is the only part that can
|
|
435
|
+
# be done without serialising selection. Among equally-ranked candidates the most recent
|
|
436
|
+
# pick is dropped and the REST ARE SAMPLED RANDOMLY — so a burst of parallel `codex exec` runs
|
|
437
|
+
# still spreads across the pool instead of every one of them computing the same "oldest"
|
|
438
|
+
# account and piling onto it.
|
|
439
|
+
# The state is one id in one file. A pool root that cannot be written just leaves a stale
|
|
440
|
+
# id there, which costs one avoided account and nothing else — it can never starve one.
|
|
441
|
+
last_pick_id() { # -> id this pool last handed out, or empty
|
|
442
|
+
local v=""
|
|
443
|
+
[ -f "$ACC_ROOT/.last-pick" ] && { IFS= read -r v < "$ACC_ROOT/.last-pick" 2>/dev/null || v=""; }
|
|
444
|
+
case "$v" in acct-[0-9][0-9]) printf '%s\n' "$v" ;; esac
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
remember_pick() { # $1 acct dir — best effort. stderr is silenced BEFORE the redirect, or
|
|
448
|
+
# a read-only pool root prints "Permission denied" on every single run.
|
|
449
|
+
local f="$ACC_ROOT/.last-pick" id="${1##*/}"
|
|
450
|
+
printf '%s\n' "$id" 2>/dev/null > "$f.$$" \
|
|
451
|
+
&& mv -f "$f.$$" "$f" 2>/dev/null || rm -f "$f.$$" 2>/dev/null
|
|
452
|
+
return 0
|
|
453
|
+
}
|
|
454
|
+
|
|
263
455
|
# Pick the account with the MOST headroom (lowest ranking score = most weekly headroom,
|
|
264
456
|
# session as tiebreaker). Ties break randomly so equally-idle accounts still spread load.
|
|
265
457
|
# Sets PICK_DIR/PICK_SCORE as globals — it must never touch "$@", which holds the
|
|
@@ -267,16 +459,41 @@ fi
|
|
|
267
459
|
PICK_DIR=""
|
|
268
460
|
PICK_SCORE=""
|
|
269
461
|
pick_best() { # args: candidate dirs
|
|
270
|
-
local d
|
|
462
|
+
local d avoid best="" bestv=1000000 ties=0 i n
|
|
463
|
+
local cand=() score=()
|
|
464
|
+
avoid="$(last_pick_id)"
|
|
271
465
|
for d in "$@"; do
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
466
|
+
cand+=("$d")
|
|
467
|
+
score+=("$(sel_score_of "$d")")
|
|
468
|
+
done
|
|
469
|
+
n=${#cand[@]}
|
|
470
|
+
i=0
|
|
471
|
+
while [ "$i" -lt "$n" ]; do
|
|
472
|
+
[ "${score[$i]}" -lt "$bestv" ] && bestv="${score[$i]}"
|
|
473
|
+
i=$((i + 1))
|
|
474
|
+
done
|
|
475
|
+
# Reservoir-sample among the equally-best, skipping the account just handed out.
|
|
476
|
+
i=0
|
|
477
|
+
while [ "$i" -lt "$n" ]; do
|
|
478
|
+
if [ "${score[$i]}" -eq "$bestv" ] && [ "${cand[$i]##*/}" != "$avoid" ]; then
|
|
276
479
|
ties=$((ties + 1))
|
|
277
|
-
[ $((RANDOM % ties)) -eq 0 ] && best="$
|
|
480
|
+
[ $((RANDOM % ties)) -eq 0 ] && best="${cand[$i]}"
|
|
278
481
|
fi
|
|
482
|
+
i=$((i + 1))
|
|
279
483
|
done
|
|
484
|
+
if [ -z "$best" ]; then
|
|
485
|
+
# The only account at the best score IS the one just used — degraded rotation beats
|
|
486
|
+
# refusing to pick (and in a two-account pool this is the other half of the
|
|
487
|
+
# alternation).
|
|
488
|
+
i=0
|
|
489
|
+
while [ "$i" -lt "$n" ]; do
|
|
490
|
+
if [ "${score[$i]}" -eq "$bestv" ]; then
|
|
491
|
+
ties=$((ties + 1))
|
|
492
|
+
[ $((RANDOM % ties)) -eq 0 ] && best="${cand[$i]}"
|
|
493
|
+
fi
|
|
494
|
+
i=$((i + 1))
|
|
495
|
+
done
|
|
496
|
+
fi
|
|
280
497
|
PICK_DIR="$best"
|
|
281
498
|
PICK_SCORE="$bestv"
|
|
282
499
|
}
|
|
@@ -293,19 +510,27 @@ else
|
|
|
293
510
|
sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
|
|
294
511
|
fi
|
|
295
512
|
pick="$PICK_DIR"
|
|
296
|
-
|
|
297
|
-
#
|
|
513
|
+
# Remember the pick so the NEXT run does not hand back the same account. An explicit
|
|
514
|
+
# CODEX_ACCOUNT pin deliberately does not: a pin is a caller overriding selection,
|
|
515
|
+
# not a turn in the rotation.
|
|
516
|
+
remember_pick "$pick"
|
|
517
|
+
|
|
518
|
+
# Opportunistic limits refresh: non-blocking, throttled, backgrounded. The windows are
|
|
519
|
+
# deliberately wide (10m, matching the 5m scheduled pass): the usage endpoint rate-limits
|
|
520
|
+
# its OWN callers, and a fleet of machines polling one account too eagerly earns a 429
|
|
521
|
+
# with Retry-After 3600 — telemetry then goes stale for an hour at a time, which is
|
|
522
|
+
# exactly how every account ends up scoring NEUTRAL.
|
|
298
523
|
kick="$ACC_ROOT/.limits-kick"
|
|
299
524
|
stale=0
|
|
300
525
|
for d in "${valid[@]}"; do
|
|
301
526
|
f="$d/limits.json"
|
|
302
|
-
if [ ! -f "$f" ] || [ $((now - $(file_mtime "$f"))) -gt
|
|
527
|
+
if [ ! -f "$f" ] || [ $((now - $(file_mtime "$f"))) -gt 600 ]; then stale=1; break; fi
|
|
303
528
|
done
|
|
304
529
|
if [ "$stale" = 1 ] && [ -x "$SELF_DIR/codex-accounts" ]; then
|
|
305
530
|
last=0
|
|
306
531
|
[ -f "$kick" ] && last="$(file_mtime "$kick")"
|
|
307
|
-
if [ $((now - last)) -gt
|
|
308
|
-
: > "$kick"
|
|
532
|
+
if [ $((now - last)) -gt 600 ]; then
|
|
533
|
+
: 2>/dev/null > "$kick" || true
|
|
309
534
|
( "$SELF_DIR/codex-accounts" limits --quiet >/dev/null 2>&1 & ) >/dev/null 2>&1
|
|
310
535
|
fi
|
|
311
536
|
fi
|
|
@@ -417,7 +642,7 @@ while :; do
|
|
|
417
642
|
{
|
|
418
643
|
echo "$now"
|
|
419
644
|
echo "reason=$park_reason soft_until=$park_soft marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=$park_detail"
|
|
420
|
-
} > "$cur/.expired.$$"
|
|
645
|
+
} 2>/dev/null > "$cur/.expired.$$" \
|
|
421
646
|
&& mv -f "$cur/.expired.$$" "$cur/.expired" 2>/dev/null \
|
|
422
647
|
|| rm -f "$cur/.expired.$$" 2>/dev/null || true
|
|
423
648
|
sel_log "$(basename "$cur") parked ($park_reason until $park_soft) — see: codex-accounts expired"
|
|
@@ -425,7 +650,7 @@ while :; do
|
|
|
425
650
|
{
|
|
426
651
|
echo $((now + 600))
|
|
427
652
|
echo "bucket=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
|
|
428
|
-
} > "$cur/.limited.$$"
|
|
653
|
+
} 2>/dev/null > "$cur/.limited.$$" \
|
|
429
654
|
&& mv -f "$cur/.limited.$$" "$cur/.limited" 2>/dev/null \
|
|
430
655
|
|| rm -f "$cur/.limited.$$" 2>/dev/null || true
|
|
431
656
|
fi
|
|
@@ -441,6 +666,9 @@ while :; do
|
|
|
441
666
|
if [ -n "$next" ]; then
|
|
442
667
|
sel_log "retry from=$(basename "$cur") to=$(basename "$next") rc=$rc"
|
|
443
668
|
cur="$next"
|
|
669
|
+
# The account that actually serves the work is the one the next run should rotate
|
|
670
|
+
# away from — not the one that bounced.
|
|
671
|
+
remember_pick "$cur"
|
|
444
672
|
attempt=2
|
|
445
673
|
continue
|
|
446
674
|
fi
|