claude-multiacc 2.0.18 → 2.0.19

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.
@@ -1492,8 +1492,17 @@ for acct in manifest.get('accounts', []):
1492
1492
  b for b in buckets if b['percent'] >= threshold]
1493
1493
  mpath = os.path.join(d, '.limited')
1494
1494
  if offenders:
1495
- worst = max(offenders, key=lambda b: b['percent'])
1496
- reset_epoch = max(int(b['resets_epoch']) for b in offenders)
1495
+ # ONE bucket, described consistently: the marker's epoch is the reset of
1496
+ # the bucket its detail line names. It used to pair the highest PERCENT
1497
+ # with the LATEST reset over every offender, and the two came from
1498
+ # different buckets — a 100% five-hour window resetting tonight written
1499
+ # with a seven-day epoch — so everything that trusts the marker parked
1500
+ # the account for days over a window that refills in hours. #20 fixed
1501
+ # this for the claude pool and left the codex writer behind; app-robot
1502
+ # reads both with the same rule. Among offenders the longest-lived wins,
1503
+ # since any bucket over the threshold stays there until its own reset.
1504
+ worst = max(offenders, key=lambda b: (int(b['resets_epoch']), b['percent']))
1505
+ reset_epoch = int(worst['resets_epoch'])
1497
1506
  # Atomic: a concurrent shim must never read a half-written marker.
1498
1507
  with open(mpath + '.tmp', 'w') as f:
1499
1508
  f.write(f'{reset_epoch}\n')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.18",
3
+ "version": "2.0.19",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,7 +61,9 @@ class ResetHandler(BaseHTTPRequestHandler):
61
61
  return
62
62
 
63
63
 
64
- class CodexResetIntegrationTest(unittest.TestCase):
64
+ class CodexPoolSandbox:
65
+ """A one-account codex pool with a fake usage/credits endpoint."""
66
+
65
67
  def setUp(self) -> None:
66
68
  self.temp = tempfile.TemporaryDirectory(prefix="multiacc-reset-")
67
69
  self.pool = Path(self.temp.name) / "pool"
@@ -88,6 +90,8 @@ class CodexResetIntegrationTest(unittest.TestCase):
88
90
  self.thread.join(timeout=5)
89
91
  self.temp.cleanup()
90
92
 
93
+
94
+ class CodexResetIntegrationTest(CodexPoolSandbox, unittest.TestCase):
91
95
  def run_limits(self) -> subprocess.CompletedProcess:
92
96
  base = f"http://127.0.0.1:{self.server.server_port}/wham"
93
97
  env = os.environ.copy()
@@ -166,5 +170,66 @@ class CodexResetIntegrationTest(unittest.TestCase):
166
170
  self.assertEqual(self.server.state["credit_gets"], 1)
167
171
 
168
172
 
173
+ class CodexMarkerNamesOneBucketTest(CodexPoolSandbox, unittest.TestCase):
174
+ """The marker's epoch is the reset of the bucket its detail line NAMES.
175
+
176
+ The codex writer used to pair the highest-PERCENT bucket's name with the
177
+ LATEST reset over every offender, so a five-hour window at 100% resetting
178
+ tonight was written under a seven-day epoch — and everything that trusts the
179
+ marker parked the account for a week over a window that refills in hours.
180
+ #20 fixed this for the claude pool and left codex behind.
181
+ """
182
+
183
+ def run_limits(self) -> subprocess.CompletedProcess: # no auto-reset: it clears markers
184
+ base = f"http://127.0.0.1:{self.server.server_port}/wham"
185
+ env = os.environ.copy()
186
+ env.update({"CODEX_ACCOUNTS_ROOT": str(self.pool),
187
+ "CODEX_MULTIACC_NO_SYNC": "1", "CODEX_MULTIACC_MIN_FETCH": "0",
188
+ "CODEX_MULTIACC_AUTO_RESET": "0", "PYTHONDONTWRITEBYTECODE": "1",
189
+ "CODEX_MULTIACC_USAGE_URL": f"{base}/usage"})
190
+ return subprocess.run([REPO / "bin/codex-accounts", "limits", "--force"],
191
+ capture_output=True, text=True, env=env, timeout=20, check=False)
192
+
193
+ def _marker(self):
194
+ text = (self.pool / "acct-01/.limited").read_text(encoding="utf-8").splitlines()
195
+ return int(text[0]), text[1]
196
+
197
+ def test_two_offenders_write_one_bucket_with_its_own_reset(self) -> None:
198
+ now = int(time.time())
199
+ session_reset, weekly_reset = now + 3600, now + 6 * 86400
200
+ self.server.state["usage"] = {"plan_type": "pro", "rate_limit": {
201
+ "allowed": True,
202
+ # 100% and back in an hour …
203
+ "primary_window": {"used_percent": 100, "limit_window_seconds": 18000,
204
+ "reset_at": session_reset},
205
+ # … beside 95% that is six days out. Both are over the threshold.
206
+ "secondary_window": {"used_percent": 95, "limit_window_seconds": 604800,
207
+ "reset_at": weekly_reset}}}
208
+ self.assertEqual(self.run_limits().returncode, 0)
209
+ epoch, detail = self._marker()
210
+ # The longest-lived offender is named, and the epoch is ITS reset — the
211
+ # account really is excluded until then, and a reader that takes the
212
+ # detail's percent gets the percent of the very bucket the epoch belongs
213
+ # to rather than a different bucket's.
214
+ self.assertEqual(epoch, weekly_reset)
215
+ self.assertIn("percent=95", detail)
216
+ self.assertIn(time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(weekly_reset)), detail)
217
+ self.assertNotIn("percent=100", detail)
218
+
219
+ def test_a_single_offender_still_names_itself(self) -> None:
220
+ now = int(time.time())
221
+ reset = now + 4 * 86400
222
+ self.server.state["usage"] = {"plan_type": "pro", "rate_limit": {
223
+ "allowed": True,
224
+ "primary_window": {"used_percent": 91, "limit_window_seconds": 604800,
225
+ "reset_at": reset},
226
+ "secondary_window": {"used_percent": 12, "limit_window_seconds": 18000,
227
+ "reset_at": now + 900}}}
228
+ self.assertEqual(self.run_limits().returncode, 0)
229
+ epoch, detail = self._marker()
230
+ self.assertEqual(epoch, reset)
231
+ self.assertIn("percent=91", detail)
232
+
233
+
169
234
  if __name__ == "__main__":
170
235
  unittest.main()