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/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
- ACC_ROOT="${CODEX_ACCOUNTS_DIR:-${HOME:-/nonexistent}/.codex-accounts}"
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
- case "$CODEX_MULTIACC_THRESHOLD" in ''|*[!0-9]*) CODEX_MULTIACC_THRESHOLD=90 ;; esac
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" 2>/dev/null || true
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
- case "$reset" in
104
- ''|*[!0-9]*)
105
- # Empty/partial/garbled marker e.g. read during a concurrent rewrite.
106
- # Treat as ACTIVE and never delete: deleting here could destroy a marker
107
- # another process is mid-write. The next limits refresh rewrites or clears it.
108
- return 0 ;;
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
- case "$fetched" in ''|*[!0-9]*) return 1 ;; esac
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
- case "$v" in ''|*[!0-9]*) return 1 ;; esac
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" 2>/dev/null || true
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 v best="" bestv=1000000 ties=1
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
- v="$(sel_score_of "$d")"
273
- if [ "$v" -lt "$bestv" ]; then
274
- bestv="$v"; best="$d"; ties=1
275
- elif [ "$v" -eq "$bestv" ]; then
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="$d" # reservoir-sample among equals
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
- # Opportunistic limits refresh: non-blocking, throttled, backgrounded.
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 180 ]; then stale=1; break; fi
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 120 ]; then
308
- : > "$kick" 2>/dev/null || true
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.$$" 2>/dev/null \
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.$$" 2>/dev/null \
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