claude-multiacc 2.0.20 → 2.0.22

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.
@@ -379,7 +379,14 @@ for a in doc.get('accounts', []):
379
379
  # entirely and picks at random, so the reading below is decoration.
380
380
  stale = age is None or age > STALE_AFTER
381
381
  flag = ' << STALE — NOT USED FOR RANKING' if stale else ''
382
- print(f" limits : {' '.join(parts) or '(none)'} [{shown}, max {lim.get('max_percent')}%]{flag}")
382
+ # A no_data document (all-zero buckets with no reset windows, 2026-09-04)
383
+ # carries no percent fields at all. Printing "max None%" would read like a
384
+ # healthy account sitting at zero — the exact misreading that handed 31 of
385
+ # ~60 picks to two exhausted accounts — so name the state instead.
386
+ peak = lim.get('max_percent')
387
+ usable = isinstance(peak, (int, float)) and not isinstance(peak, bool)
388
+ head = f'max {peak}%' if usable else 'NO USABLE TELEMETRY — ranks as unknown'
389
+ print(f" limits : {' '.join(parts) or '(none)'} [{shown}, {head}]{flag}")
383
390
  err = lim.get('last_error')
384
391
  if isinstance(err, str) and err:
385
392
  when = lim.get('last_error_at')
@@ -428,14 +435,46 @@ if verdict == 'unknown':
428
435
  print(f"RANKING STATE UNKNOWN: could not compute the pool-wide telemetry verdict "
429
436
  f"({verdict_err}). Check the per-account ages above by hand.")
430
437
  elif verdict == 'blind':
431
- print("RANKING IS BLIND: no account has usage telemetry inside the "
432
- f"{STALE_AFTER}s window, and the last readings are too old to mean anything, "
433
- "so every account scores the same and `claude` picks at RANDOM including "
434
- "accounts that are nearly out of weekly headroom.")
435
- print(" why : see the 'telemetry' lines above (a setup token cannot read the usage "
436
- "endpoint it has no user:profile scope; only an OAuth login on this machine can)")
437
- print(" fix : claude-accounts limits --force # then, if it still fails:")
438
- print(" claude-accounts login <acct-NN> # per account, on THIS machine")
438
+ # Two ways to be blind, and they take OPPOSITE advice. STALE: the fetches stopped,
439
+ # so fetch again and, if that keeps failing, log in the eleven-day 2026-08 outage.
440
+ # CURRENT-BUT-UNUSABLE: the endpoint answered inside the window and said nothing
441
+ # rankable (a no_data document all-zero buckets with no reset window, 2026-09-04 —
442
+ # or a reading carrying only one of the two percentages). There the credential is
443
+ # working perfectly; telling the operator to re-login sends them after a fault that
444
+ # does not exist, and --force just re-asks for the same emptiness. The candidate
445
+ # rule below is telemetry_state's own, so this text can never name a state the
446
+ # verdict did not come from.
447
+ usable_rows = [a for a in doc_rows if a.get('status') in ('active', 'limited')]
448
+ cands = [a for a in usable_rows if a.get('status') == 'active'] or usable_rows
449
+ current = [a for a in cands if (a.get('usage') or {})
450
+ and not (a.get('usage') or {}).get('stale')]
451
+ nodata = [a['id'] for a in current if (a.get('usage') or {}).get('no_data')]
452
+ if current:
453
+ print("RANKING IS BLIND: usage telemetry is INSIDE the "
454
+ f"{STALE_AFTER}s window but carries no reading the shim can rank on, so "
455
+ "every account scores the same and `claude` picks at RANDOM — including "
456
+ "accounts that are nearly out of weekly headroom.")
457
+ if nodata:
458
+ print(" why : the usage endpoint returned no usable data for "
459
+ f"{', '.join(nodata)} — all-zero buckets with no reset window, which "
460
+ 'the writer records as "no_data": true rather than as 0% usage. '
461
+ "Those fetches authenticated; a re-login does NOT fix this.")
462
+ else:
463
+ print(" why : the readings are incomplete — ranking needs BOTH a weekly and "
464
+ "a session percentage (see the 'limits' lines above); an account with "
465
+ "only one of them is unknown to selection, exactly as if it had none.")
466
+ print(" fix : nothing local to repair — the endpoint has to answer with real "
467
+ "buckets again. `claude-accounts limits --force` re-asks; while it keeps "
468
+ "answering this way, selection stays random.")
469
+ else:
470
+ print("RANKING IS BLIND: no account has usage telemetry inside the "
471
+ f"{STALE_AFTER}s window, and the last readings are too old to mean anything, "
472
+ "so every account scores the same and `claude` picks at RANDOM — including "
473
+ "accounts that are nearly out of weekly headroom.")
474
+ print(" why : see the 'telemetry' lines above (a setup token cannot read the usage "
475
+ "endpoint — it has no user:profile scope; only an OAuth login on this machine can)")
476
+ print(" fix : claude-accounts limits --force # then, if it still fails:")
477
+ print(" claude-accounts login <acct-NN> # per account, on THIS machine")
439
478
  elif verdict == 'degraded':
440
479
  print("RANKING IS DEGRADED: no account has telemetry inside the "
441
480
  f"{STALE_AFTER}s window, so `claude` is ranking on the last readings whose "
@@ -1402,7 +1441,22 @@ limits_distribute() {
1402
1441
  limits_distribute_now() {
1403
1442
  local lock="$ACC_ROOT/tmp/limits-push.lock"
1404
1443
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
1405
- mkdir "$lock" 2>/dev/null || return 0
1444
+ if ! mkdir "$lock" 2>/dev/null; then
1445
+ # A DETACHED push that is killed (logout, reboot, pkill) never runs its EXIT trap,
1446
+ # and mkdir can never take a lock dir nobody will remove: one stranded lock
1447
+ # silently stopped ALL telemetry distribution on the live pool from 2026-09-03
1448
+ # 00:29 until it was deleted by hand on 2026-09-04 — 32 hours in which every peer
1449
+ # ranked on whatever limits.json it happened to already have, which is the exact
1450
+ # blindness this push exists to prevent, and nothing anywhere said so. A push is
1451
+ # seconds of rsync under hard timeouts (--timeout=20, ConnectTimeout=10), so a
1452
+ # lock older than ten minutes belongs to a process that is gone: break it and
1453
+ # retake it. If the retake still fails, a live pusher owns it and this pass skips,
1454
+ # exactly as before.
1455
+ [ $(( $(epoch_now) - $(file_mtime "$lock") )) -gt 600 ] || return 0
1456
+ rm -rf "$lock" 2>/dev/null
1457
+ mkdir "$lock" 2>/dev/null || return 0
1458
+ log_to sync.log "stale limits-push lock broken (older than 600s); distributing"
1459
+ fi
1406
1460
  trap 'rmdir "$lock" 2>/dev/null || true' EXIT
1407
1461
  local server sroot list id d
1408
1462
  list="$ACC_ROOT/tmp/limits-push.$$"
@@ -1544,6 +1598,34 @@ def parse_iso(s):
1544
1598
  except Exception:
1545
1599
  return None
1546
1600
 
1601
+ # The ONE rule for deleting a `.limited` marker on a clean pass. It is the writer half
1602
+ # of the shim's client_marker_recovered and has to be the SAME rule: the scheduled
1603
+ # limits pass runs every 15 minutes, so a writer that clears more freely than the shim
1604
+ # just undoes the shim's fix on its own timer. Before 2026-09-04 it did exactly that —
1605
+ # a truthful client:seven_day marker was deleted 300s after it was written, on a pass
1606
+ # whose every bucket said `percent 0, resets_at null`, i.e. on nothing at all.
1607
+ # * a pass with NO informative bucket proves nothing, so it clears nothing;
1608
+ # * a client rejection naming a WEEKLY window outlives every reading until its own
1609
+ # reset — a weekly bucket cannot fall from the server-proven 100% that wrote the
1610
+ # marker to under the threshold while that window is still open, so a reading
1611
+ # that says it did is wrong by construction;
1612
+ # * a client rejection naming a session/5h window still clears once the pass is
1613
+ # informative and at least CLIENT_LIMIT_CONFIRM_DELAY newer than the marker (#22,
1614
+ # 2026-09-03: 5h markers stranded accounts sitting at 0% usage for days);
1615
+ # * error-cooldown is untouched: it keeps its own window out, exactly as before.
1616
+ def marker_bucket(txt):
1617
+ for part in txt.split():
1618
+ if part.startswith('bucket='):
1619
+ return part[7:]
1620
+ return ''
1621
+
1622
+ def weekly_marker(txt):
1623
+ # Matched the way both shims match it (bin/claude ~303, same token list): the
1624
+ # claude client writes client:seven_day / client:seven_day_opus, the codex client
1625
+ # writes client:7d, and any future weekly* name is caught too.
1626
+ b = marker_bucket(txt).lower()
1627
+ return 'seven_day' in b or '7d' in b or 'weekly' in b
1628
+
1547
1629
  # `.expired` — the persistent "this account cannot authenticate" marker the shim
1548
1630
  # honors. Written only for a PROVEN dead grant (expired/absent refresh token, or a
1549
1631
  # 4xx from the refresh endpoint), never for a transient network/5xx/429 hiccup.
@@ -2156,12 +2238,41 @@ for acct in manifest.get('accounts', []):
2156
2238
  # weekly_percent — peak of the durable (weekly/monthly) buckets; the PRIMARY
2157
2239
  # ranking signal, because weekly headroom only returns on the
2158
2240
  # account's fixed weekly reset (days away).
2159
- # session_percent— peak of the self-healing 5h bucket; a soft tiebreaker only.
2160
- maxp = max([b['percent'] for b in buckets] or [0])
2161
- weekly = [b['percent'] for b in buckets if b['group'] != 'session']
2162
- session = [b['percent'] for b in buckets if b['group'] == 'session']
2163
- weeklyp = max(weekly) if weekly else maxp
2164
- sessionp = max(session) if session else 0
2241
+ # session_percent— peak of the self-healing 5h bucket; the session GATE's input:
2242
+ # the shim ranks only accounts at/under CLAUDE_MULTIACC_SESSION_GATE
2243
+ # (default 50) while any clear it. (A soft tiebreaker until
2244
+ # 2026-09-03 the operator asked for session FIRST, then weekly.)
2245
+ # A bucket only feeds those three signals if it SAID something. An INFORMATIVE
2246
+ # bucket has a percent above 0, or a parseable reset window. 2026-09-04: for
2247
+ # acct-13/acct-14 the usage endpoint answered EVERY bucket `percent: 0,
2248
+ # resets_at: null` while Claude Code was being rejected on those same accounts
2249
+ # with "You've hit your weekly limit · resets Sep 8"; this writer recorded the
2250
+ # zeros verbatim, which made two provably exhausted accounts the leaders of the
2251
+ # weekly band and handed them 31 of the last ~60 picks. A truthful bucket ALWAYS
2252
+ # carries the window it resets in, so 0% with no window is NO DATA, not an empty
2253
+ # account. 0% WITH a real window stays informative — a genuinely fresh account
2254
+ # must still rank as empty — and one uninformative bucket beside real ones (the
2255
+ # acct-16 shape: `weekly_scoped:Fable` 0/null next to a real session and
2256
+ # weekly_all) leaves the real buckets ranking exactly as they do today.
2257
+ def informative(b):
2258
+ return b['percent'] > 0 or parse_iso(b.get('resets_at')) is not None
2259
+
2260
+ live = [b for b in buckets if informative(b)]
2261
+ weekly = [b['percent'] for b in live if b['group'] != 'session']
2262
+ session = [b['percent'] for b in live if b['group'] == 'session']
2263
+ # PER SIGNAL, never borrowed from another one. Each of the three answers a
2264
+ # different question, so each is written only when a bucket of ITS OWN kind said
2265
+ # something. Until 2026-09-04 weekly_percent fell back to the overall peak and
2266
+ # session_percent to 0: an account whose weekly buckets were all uninformative
2267
+ # while its 5h bucket read 40% was recorded as 40% WEEKLY — a number no bucket
2268
+ # ever reported, and the signal the band ranks on — and the mirror image
2269
+ # (informative weekly, silent session) was recorded as session 0%, which walks
2270
+ # straight through the session gate. A signal nobody reported must be ABSENT so
2271
+ # the shim reads it as unknown; inventing one is the same mistake as recording a
2272
+ # fake zero, one layer up.
2273
+ maxp = max([b['percent'] for b in live] or [0])
2274
+ weeklyp = max(weekly) if weekly else None
2275
+ sessionp = max(session) if session else None
2165
2276
  # How long weekly_percent keeps meaning something. A weekly bucket only ever RISES
2166
2277
  # until its reset, so before that moment a stale percent is still a valid lower
2167
2278
  # bound and the shim can rank on it when nothing fresher exists; after it, the
@@ -2170,13 +2281,32 @@ for acct in manifest.get('accounts', []):
2170
2281
  # It must come from the bucket weekly_percent actually CAME FROM: a low monthly
2171
2282
  # bucket resetting in an hour says nothing about an 80% weekly one that resets in
2172
2283
  # five days, and taking the minimum over all of them would throw the 80% away.
2173
- wresets = [int(b['resets_epoch']) for b in buckets
2284
+ wresets = [int(b['resets_epoch']) for b in live
2174
2285
  if b['group'] != 'session' and b['percent'] == weeklyp
2175
2286
  and isinstance(b.get('resets_epoch'), int)]
2176
- out = {'fetched_at': int(now), 'source': source, 'max_percent': maxp,
2177
- 'weekly_percent': weeklyp, 'session_percent': sessionp,
2178
- 'weekly_resets_epoch': min(wresets) if wresets else 0,
2179
- 'buckets': buckets}
2287
+ out = {'fetched_at': int(now), 'source': source}
2288
+ if live:
2289
+ out['max_percent'] = maxp
2290
+ if weeklyp is not None:
2291
+ out['weekly_percent'] = weeklyp
2292
+ if sessionp is not None:
2293
+ out['session_percent'] = sessionp
2294
+ if weeklyp is not None:
2295
+ # Written with weekly_percent or not at all: the horizon describes THAT
2296
+ # reading, and the shim's degraded path needs both or neither.
2297
+ out['weekly_resets_epoch'] = min(wresets) if wresets else 0
2298
+ if not live:
2299
+ # Nothing usable in the entire payload. Keep the diagnostics (fetched_at,
2300
+ # source, the raw buckets) and write NONE of the three percent signals: a
2301
+ # missing field makes the shim's fresh_field/cutoff_field reads fail, so the
2302
+ # account is UNKNOWN to both selection cuts — never the weekly band's leader,
2303
+ # never inside the session gate, and never able to clear a client-rate-limit
2304
+ # marker. Unknown is the honest reading; "0%" is what re-admitted two provably
2305
+ # exhausted accounts on 2026-09-04 (see informative() above).
2306
+ out['no_data'] = True
2307
+ say(f'{aid}: usage endpoint returned all-zero buckets with no reset windows — '
2308
+ f'no usable telemetry (account ranks as unknown, not as empty)')
2309
+ out['buckets'] = buckets
2180
2310
  tmp = lpath + '.tmp'
2181
2311
  with open(tmp, 'w') as f:
2182
2312
  json.dump(out, f, indent=1)
@@ -2202,14 +2332,37 @@ for acct in manifest.get('accounts', []):
2202
2332
  worst = max(account_level or offenders,
2203
2333
  key=lambda b: (int(b['resets_epoch']), b['percent']))
2204
2334
  reset_epoch = int(worst['resets_epoch'])
2205
- # Atomic: a concurrent shim must never read a half-written marker.
2206
- with open(mpath + '.tmp', 'w') as f:
2207
- f.write(f'{reset_epoch}\n')
2208
- f.write(f"bucket={worst['name']} percent={worst['percent']} "
2209
- f"marked_at={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} "
2210
- f"reason=limits resets_at={worst['resets_at']}\n")
2211
- os.replace(mpath + '.tmp', mpath)
2212
- say(f"{aid}: LIMITED {worst['name']} at {worst['percent']}% (resets {worst['resets_at']})")
2335
+ # Never SHORTEN an active client-rate-limit marker. A client rejection is
2336
+ # first-hand server evidence with its own reset; this pass's worst offender
2337
+ # can be a mere session bucket an hour from resetting, and overwriting the
2338
+ # marker with that shorter horizon re-admits a provably exhausted account
2339
+ # early (codex review, 2026-09-04: a client:seven_day four days out replaced
2340
+ # by a 95% session bucket +1h). A LATER reset may still extend the exclusion.
2341
+ keep_client = False
2342
+ try:
2343
+ cur = open(mpath).read()
2344
+ first = cur.splitlines()[0] if cur else ''
2345
+ # The shims' own validity rule (num_ok: digits only, bounded length): a
2346
+ # signed/padded/absurd first line is a GARBLED marker to them, and a
2347
+ # garbled marker must be rewritten here, not preserved.
2348
+ if first.isdigit() and len(first) <= 18:
2349
+ cur_reset = int(first)
2350
+ keep_client = ('reason=client-rate-limit' in cur
2351
+ and cur_reset > now and cur_reset >= reset_epoch)
2352
+ except Exception:
2353
+ pass
2354
+ if keep_client:
2355
+ say(f"{aid}: keeping the client-reported marker (its reset reaches further "
2356
+ f"than this pass's worst offender)")
2357
+ else:
2358
+ # Atomic: a concurrent shim must never read a half-written marker.
2359
+ with open(mpath + '.tmp', 'w') as f:
2360
+ f.write(f'{reset_epoch}\n')
2361
+ f.write(f"bucket={worst['name']} percent={worst['percent']} "
2362
+ f"marked_at={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} "
2363
+ f"reason=limits resets_at={worst['resets_at']}\n")
2364
+ os.replace(mpath + '.tmp', mpath)
2365
+ say(f"{aid}: LIMITED {worst['name']} at {worst['percent']}% (resets {worst['resets_at']})")
2213
2366
  else:
2214
2367
  if os.path.exists(mpath):
2215
2368
  # A shim-written marker outlives a clean limits pass while its own window
@@ -2232,11 +2385,17 @@ for acct in manifest.get('accounts', []):
2232
2385
  if marked_epoch is None:
2233
2386
  marked_epoch = os.path.getmtime(mpath)
2234
2387
  recent_client = now - marked_epoch < CLIENT_LIMIT_CONFIRM_DELAY
2235
- client_recovered = active and 'reason=client-rate-limit' in txt \
2236
- and not recent_client
2237
- if active and ('reason=error-cooldown' in txt
2238
- or ('reason=client-rate-limit' in txt and recent_client)):
2388
+ is_client = 'reason=client-rate-limit' in txt
2389
+ # See weekly_marker() above for why these three come first and in
2390
+ # this order; the shim applies the identical test per invocation.
2391
+ if active and not live:
2392
+ keep = True
2393
+ elif active and is_client and weekly_marker(txt):
2394
+ keep = True
2395
+ elif active and ('reason=error-cooldown' in txt
2396
+ or (is_client and recent_client)):
2239
2397
  keep = True
2398
+ client_recovered = active and is_client and not keep
2240
2399
  except Exception:
2241
2400
  pass
2242
2401
  if not keep:
@@ -2246,7 +2405,16 @@ for acct in manifest.get('accounts', []):
2246
2405
  with open(cleared + '.tmp', 'w') as f:
2247
2406
  f.write(f'{int(now)}\n')
2248
2407
  os.replace(cleared + '.tmp', cleared)
2249
- say(f'{aid}: marker cleared (max {maxp}%)')
2408
+ # On a no-data pass `live` is empty and maxp is 0 only because
2409
+ # nothing was reported (see informative() above). Log that, instead
2410
+ # of a "0%" that reads like a proven-empty account — the exact
2411
+ # misreading behind the 2026-09-04 incident.
2412
+ seen = f'max {maxp}%' if live else 'no usable telemetry'
2413
+ say(f'{aid}: marker cleared ({seen})')
2414
+ elif not live:
2415
+ # One line per account, so a no-data pass is legible in limits.log:
2416
+ # the marker was not re-confirmed here, it was merely not disproved.
2417
+ say(f'{aid}: marker kept (no usable telemetry)')
2250
2418
  if not quiet:
2251
2419
  detail = ' '.join(f"{b['name']}={b['percent']}%" for b in buckets)
2252
2420
  print(f'{aid}: ok {detail}')