claude-multiacc 2.0.19 → 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.
@@ -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 headroom still counted as "as good as the best" this round.
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["effective_headroom"]) for row in rows
139
- if row["quota_known"] and row["effective_headroom"] is not None]
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
- floor = _band_floor(eligible, band) if band is not None else None
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 eligible if row["quota_known"]
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 changes which account wins, so it belongs in the proof.
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:
@@ -11,13 +11,15 @@ from datetime import datetime, timezone
11
11
  from decimal import Decimal, InvalidOperation
12
12
 
13
13
  SCHEMA = "claude-multiacc/pool-selection.v2"
14
- # NOT bumped for the headroom band. This string is a BUILD FENCE, not a changelog:
15
- # a queued task records the version that reserved its account, and the claim function
16
- # (agent-sdk 0005_delivery_claim_functions.sql) only lets a runner advertising the same
17
- # string claim it while the runner's value is a hardcoded constant shipped in its
18
- # bundle. With Macs routinely several builds behind, bumping this stops every lagging
19
- # Mac from claiming any new work until it updates. The band is additive and optional:
20
- # the request shape, the response shape and every consumer are unchanged.
14
+ # NOT bumped for the headroom band, and NOT bumped for the session gate either. This
15
+ # string is a BUILD FENCE, not a changelog: a queued task records the version that
16
+ # reserved its account, and the claim function (agent-sdk
17
+ # 0005_delivery_claim_functions.sql) only lets a runner advertising the same string
18
+ # claim it while the runner's value is a hardcoded constant shipped in its bundle.
19
+ # With Macs routinely several builds behind, bumping this stops every lagging Mac from
20
+ # claiming any new work until it updates. Both knobs are additive and optional: the
21
+ # request only grows optional keys, the response only grows fields, and every existing
22
+ # consumer keeps reading exactly what it read before.
21
23
  SELECTOR_VERSION = "2.0.1"
22
24
  TIME_RE = re.compile(
23
25
  r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-5][0-9]"
@@ -33,9 +35,13 @@ REQUEST_KEYS = {
33
35
  "schema", "database_now", "policy", "required_engine", "producer_identity",
34
36
  "excluded_identities", "candidates", "reservation_key"}
35
37
  # Optional because a caller pinned to an older panel build still sends exactly
36
- # REQUEST_KEYS; absent, the policy applies its own default band.
37
- OPTIONAL_REQUEST_KEYS = {"headroom_band"}
38
+ # REQUEST_KEYS; absent, the policy applies its own defaults. app-robot's panel sends
39
+ # headroom_band from its own setting and never sends session_gate, so the gate default
40
+ # below is what every panel launch gets (operator's 2026-09-03 decision: session
41
+ # headroom gates FIRST, then the weekly band picks among the survivors).
42
+ OPTIONAL_REQUEST_KEYS = {"headroom_band", "session_gate"}
38
43
  DEFAULT_HEADROOM_BAND = "30"
44
+ DEFAULT_SESSION_GATE = "50"
39
45
  POLICIES = {
40
46
  "default_claude", "default_codex", "default_both", "explicit", "producer_retry", "reviewer"}
41
47
 
@@ -180,13 +186,16 @@ def request_error(request: object) -> tuple[str, dict] | None:
180
186
  keys = set(request)
181
187
  if not REQUEST_KEYS <= keys or keys - REQUEST_KEYS - OPTIONAL_REQUEST_KEYS:
182
188
  return "invalid_request", {"field": "$"}
183
- if "headroom_band" in request:
184
- band, _text = decimal_value(request["headroom_band"])
185
- # decimal_value clamps to [0,100] and rejects non-numeric shapes; a band
186
- # the caller cannot express exactly must fail loudly rather than silently
187
- # widen selection to the whole pool.
188
- if band is None or isinstance(request["headroom_band"], bool):
189
- return "invalid_request", {"field": "headroom_band"}
189
+ for knob in ("headroom_band", "session_gate"):
190
+ if knob not in request:
191
+ continue
192
+ value, _text = decimal_value(request[knob])
193
+ # decimal_value clamps to [0,100] and rejects non-numeric shapes; a knob the
194
+ # caller cannot express exactly must fail loudly rather than silently widen
195
+ # selection to the whole pool (band) or wave every session-heavy account
196
+ # through (gate). Both are validated identically so the panel can send either.
197
+ if value is None or isinstance(request[knob], bool):
198
+ return "invalid_request", {"field": knob}
190
199
  try:
191
200
  timestamp(request["database_now"])
192
201
  except ValueError:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.19",
3
+ "version": "2.0.21",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {