relay-codex 0.7.0 → 0.7.2

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 CHANGED
@@ -69,7 +69,8 @@ Use a session's menu to switch accounts after its work finishes. Account menus c
69
69
  enable or disable future routing without stopping current tasks. Actions show
70
70
  their result, and stopped workers offer a restart.
71
71
  Account menus also show earned usage resets when available. Choose **Use available
72
- reset…** and confirm to spend one; Relay refreshes allowance afterward. Recovery
72
+ reset…** and confirm to spend one; Relay refreshes allowance afterward. The button
73
+ is enabled only with weekly allowance at 0% and its reset more than 48 hours away. Recovery
73
74
  never spends these resets automatically.
74
75
 
75
76
  For tab completion in Zsh, run `source <(relay completion zsh)` after `compinit`.
package/docs/commands.md CHANGED
@@ -51,13 +51,17 @@ A session cap of zero means unlimited. Removing an account retains its login
51
51
  unless you request `--revoke`.
52
52
 
53
53
  The account menu shows **Usage resets available** and **Use available reset…**
54
- when Codex reports an earned reset. Choose it and confirm to use one. If the count
54
+ when Codex reports an earned reset. It is enabled only when weekly allowance is
55
+ fully exhausted (0%) and the weekly reset is **more than 48 hours away**. Exactly
56
+ 48 hours, any weekly allowance left, or unknown weekly data keeps it disabled.
57
+ The reason appears below the button. Choose it and confirm to use one. If the count
55
58
  is unknown, choose **Check allowance now** first. Reset availability is separate
56
59
  from purchased workspace credits. Resets are never spent by automatic recovery.
57
60
 
58
61
  The equivalent terminal command is `relay account reset ACCOUNT --yes`. If a
59
62
  response is lost, use the same action again: Relay saves and reuses the pending
60
- request rather than submitting a new redemption. The menu calls this **Check
63
+ request rather than submitting a new redemption. Pending retries must satisfy
64
+ the same weekly rule; their saved request is retained when locked. The menu calls this **Check
61
65
  previous reset…**. An optional `--request-id UUID` also identifies a specific
62
66
  logical attempt. Codex chooses the available reset; Relay checks the returned
63
67
  result and reads allowance again afterward.
@@ -136,7 +140,9 @@ Each session menu can switch to another enabled account after work and approvals
136
140
  finish. The worker checks allowance and session capacity before switching, then
137
141
  checks again that the session is idle before changing its login. Rejected actions
138
142
  show their reason. Pending menu actions cannot be submitted twice, and results
139
- remain visible until dismissed or another action starts. Stopped workers offer
143
+ disappear 30 seconds after first being viewed in the menu. Unseen results wait
144
+ until the menu is opened; action progress remains until the action finishes.
145
+ Starting another action replaces its previous result. Stopped workers offer
140
146
  Restart; restarting retains any review hold.
141
147
 
142
148
  Account menus can disable or enable future routing. Disabling an account does not
@@ -8,8 +8,8 @@ Friends can run `npx relay-codex` or install it with `npm install -g relay-codex
8
8
  The GitHub repository is enough for friends to install or try Relay:
9
9
 
10
10
  ```sh
11
- npm install -g github:blackspider-ops/relay#v0.7.0
12
- npx --yes github:blackspider-ops/relay#v0.7.0 --version
11
+ npm install -g github:blackspider-ops/relay#v0.7.2
12
+ npx --yes github:blackspider-ops/relay#v0.7.2 --version
13
13
  ```
14
14
 
15
15
  `npx` runs packages; it is not a separate publishing service. Publishing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-codex",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Keep your Codex sessions moving between your own accounts.",
5
5
  "license": "MIT",
6
6
  "author": "blackspider-ops",
package/python/Menu.swift CHANGED
@@ -6,12 +6,18 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
6
6
  var buffer = Data()
7
7
  var current: [String: Any] = [:]
8
8
  var message: String?
9
- var actionNotice: String?
9
+ var actionNotice: String? {
10
+ didSet { noticeVersion += 1; noticeExpiry?.cancel(); noticeExpiry = nil }
11
+ }
12
+ var noticeVersion = 0
13
+ var noticeExpiry: DispatchWorkItem?
10
14
  var actionFailed = false
11
15
  var actionRunning = false
12
16
  var reconnect: DispatchWorkItem?
13
17
  var stopping = false
14
18
  var snapshotInFlight = false
19
+ var resetExpiry: DispatchWorkItem?
20
+ var menuOpen = false
15
21
  let command = Bundle.main.object(forInfoDictionaryKey: "RelayCommand") as? String ?? "/usr/bin/false"
16
22
  let state = Bundle.main.object(forInfoDictionaryKey: "RelayState") as? String ?? ""
17
23
 
@@ -140,6 +146,15 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
140
146
  guard let seconds = value as? Double, seconds.isFinite, seconds > 0 else { return nil }
141
147
  return seconds
142
148
  }
149
+ static func resetBlockReason(_ account: [String: Any], now: Double = Date().timeIntervalSince1970) -> String? {
150
+ guard let weekly = account["secondary"] as? [String: Any], let used = (weekly["usedPercent"] as? NSNumber)?.doubleValue, used.isFinite else {
151
+ return "Reset locked: check allowance to confirm the weekly limit and reset time."
152
+ }
153
+ guard used >= 100 else { return "Reset locked: weekly allowance must be fully exhausted (0%)." }
154
+ guard let at = timestamp(weekly["resetsAt"]) else { return "Reset locked: check allowance to confirm the weekly limit and reset time." }
155
+ guard at - now > 48 * 60 * 60 else { return "Reset locked: the weekly reset must be more than 48 hours away." }
156
+ return nil
157
+ }
143
158
  static func accountTimingRows(_ account: [String: Any]) -> [TimingRow] {
144
159
  var rows: [TimingRow] = []
145
160
  if let primary = account["primary"] as? [String: Any], let at = timestamp(primary["resetsAt"]) {
@@ -182,6 +197,8 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
182
197
  updateTimes(menu)
183
198
  if let visible = item.menu { MenuController.synchronise(visible, with: menu) }
184
199
  else { item.menu = menu }
200
+ if menuOpen { scheduleResetExpiry() }
201
+ scheduleNoticeExpiry()
185
202
  }
186
203
  func makeMenu() -> NSMenu {
187
204
  let sessions = current["sessions"] as? [[String: Any]] ?? []
@@ -193,7 +210,6 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
193
210
  if let message = message { text(message, into: menu) }
194
211
  if let notice = actionNotice {
195
212
  text(notice, into: menu)
196
- if !actionRunning { action("Dismiss message", args: ["dismiss"], into: menu) }
197
213
  }
198
214
  if accounts.isEmpty { text("Add an account with relay setup in Terminal.", into: menu) }
199
215
  if liveSessions.isEmpty && !accounts.isEmpty { text("No Relay sessions running. Start with relay run.", into: menu) }
@@ -233,8 +249,10 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
233
249
  if let count = resetCount { text("Usage resets available: \(count)", into: submenu) }
234
250
  else { text("Usage resets: check allowance to see availability.", into: submenu) }
235
251
  if pendingReset || (resetCount ?? 0) > 0 {
252
+ let blocked = MenuController.resetBlockReason(account)
236
253
  let reset = action(pendingReset ? "Check previous reset…" : "Use available reset…", args: ["confirm-reset", id, name, pendingReset ? "pending" : "new"], into: submenu)
237
- reset.isEnabled = !actionRunning && !(account["maintenance"] as? Bool ?? false)
254
+ reset.isEnabled = !actionRunning && !(account["maintenance"] as? Bool ?? false) && blocked == nil
255
+ if let blocked = blocked { text(blocked, into: submenu) }
238
256
  }
239
257
  submenu.addItem(.separator())
240
258
  let enabled = account["enabled"] as? Bool ?? true
@@ -298,16 +316,55 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
298
316
  return menu
299
317
  }
300
318
  func menuWillOpen(_ menu: NSMenu) {
319
+ menuOpen = true
320
+ render()
301
321
  refreshSnapshot()
302
322
  updateTimes(menu)
303
323
  for entry in menu.items where entry.title == "Open at login" {
304
324
  entry.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
305
325
  }
306
326
  }
327
+ func scheduleResetExpiry() {
328
+ resetExpiry?.cancel()
329
+ let now = Date().timeIntervalSince1970
330
+ let accounts = current["accounts"] as? [[String: Any]] ?? []
331
+ let boundaries = accounts.compactMap { account -> Double? in
332
+ guard MenuController.resetBlockReason(account, now: now) == nil, let weekly = account["secondary"] as? [String: Any], let at = MenuController.timestamp(weekly["resetsAt"]) else { return nil }
333
+ return at - 48 * 60 * 60
334
+ }
335
+ if let boundary = boundaries.min() {
336
+ let expiry = DispatchWorkItem { [weak self] in self?.render() }
337
+ resetExpiry = expiry
338
+ DispatchQueue.main.asyncAfter(deadline: .now() + max(0, boundary - now), execute: expiry)
339
+ }
340
+ }
341
+ func menuDidClose(_ menu: NSMenu) { menuOpen = false; resetExpiry?.cancel(); resetExpiry = nil }
342
+ func scheduleNoticeExpiry() {
343
+ guard menuOpen, !actionRunning, actionNotice != nil, noticeExpiry == nil else { return }
344
+ let version = noticeVersion
345
+ let expiry = DispatchWorkItem { [weak self] in
346
+ guard let self = self, self.expireNotice(version: version) else { return }
347
+ self.render()
348
+ }
349
+ noticeExpiry = expiry
350
+ DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: expiry)
351
+ }
352
+ func expireNotice(version: Int) -> Bool {
353
+ guard !stopping, !actionRunning, actionNotice != nil, noticeVersion == version else { return false }
354
+ actionNotice = nil; actionFailed = false
355
+ return true
356
+ }
357
+ func resetAllowed(_ id: String) -> Bool {
358
+ let accounts = current["accounts"] as? [[String: Any]] ?? []
359
+ guard let account = accounts.first(where: { $0["id"] as? String == id }) else {
360
+ actionNotice = "Reset locked: account details are unavailable."; actionFailed = true; render(); return false
361
+ }
362
+ if let reason = MenuController.resetBlockReason(account) { actionNotice = reason; actionFailed = true; render(); return false }
363
+ return true
364
+ }
307
365
  @objc func runAction(_ sender: NSMenuItem) {
308
366
  guard var args = sender.representedObject as? [String], !args.isEmpty else { return }
309
367
  if args[0] == "copy" { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(args[1], forType: .string); return }
310
- if args[0] == "dismiss" { actionNotice = nil; actionFailed = false; render(); return }
311
368
  guard !actionRunning else { return }
312
369
  if args[0] == "confirm-retry" {
313
370
  let alert = NSAlert(); alert.messageText = "Continue this task?"; alert.informativeText = "Review any interrupted command or external action in the conversation before continuing."
@@ -317,12 +374,14 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
317
374
  args = ["retry", args[1], "--wait"]
318
375
  }
319
376
  if args[0] == "confirm-reset" {
377
+ guard resetAllowed(args[1]) else { return }
320
378
  let pending = args[3] == "pending"
321
379
  let alert = NSAlert(); alert.messageText = pending ? "Check the previous reset?" : "Use one usage reset?"
322
380
  alert.informativeText = args[2] + "\n\n" + (pending ? "Relay will check the same pending request. If it already used a reset, another one will not be spent." : "This spends one earned reset on eligible usage limits for this account. Allowance will be checked afterward. Paused tasks and review holds stay paused.")
323
381
  alert.addButton(withTitle: pending ? "Check reset" : "Use reset"); alert.addButton(withTitle: "Cancel")
324
382
  NSApp.activate(ignoringOtherApps: true)
325
383
  guard alert.runModal() == .alertFirstButtonReturn else { return }
384
+ guard resetAllowed(args[1]) else { return }
326
385
  args = ["account", "reset", args[1], "--yes", "--request-id", UUID().uuidString]
327
386
  }
328
387
  actionRunning = true; actionFailed = false; actionNotice = "Applying change…"; render()
@@ -363,7 +422,7 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
363
422
  runAction(sender)
364
423
  }
365
424
  @objc func quitMenu() { stopping = true; reconnect?.cancel(); stream?.terminate(); NSApp.terminate(nil) }
366
- func applicationWillTerminate(_ notification: Notification) { stopping = true; stream?.terminate() }
425
+ func applicationWillTerminate(_ notification: Notification) { stopping = true; noticeExpiry?.cancel(); resetExpiry?.cancel(); stream?.terminate() }
367
426
  }
368
427
  if CommandLine.arguments.contains("--self-check") {
369
428
  let normal = NSImage(systemSymbolName: "arrow.triangle.branch", accessibilityDescription: "Relay")
@@ -413,11 +472,18 @@ if CommandLine.arguments.contains("--self-check") {
413
472
  let correctEmpty = empty.items.contains { $0.title.contains("No Relay sessions running") } && empty.items.first { $0.identifier?.rawValue == "account:one" }?.state == .off
414
473
  controller.current["sessions"] = originalSessions
415
474
  let originalAccounts = controller.current["accounts"]
416
- controller.current["accounts"] = [["id": "one", "name": "One", "reset_credits": 2], ["id": "two", "name": "Two", "reset_credits": 0], ["id": "unknown", "name": "Unknown"], ["id": "pending", "name": "Pending", "reset_pending": true]]
475
+ controller.current["accounts"] = [["id": "one", "name": "One", "reset_credits": 2, "secondary": ["usedPercent": 100, "resetsAt": Date().timeIntervalSince1970 + 72 * 3600]], ["id": "two", "name": "Two", "reset_credits": 0], ["id": "unknown", "name": "Unknown"], ["id": "pending", "name": "Pending", "reset_pending": true]]
417
476
  let resets = controller.makeMenu()
418
477
  let correctResets = findAction(["confirm-reset", "one", "One", "new"], in: resets)?.isEnabled == true && findAction(["confirm-reset", "two", "Two", "new"], in: resets) == nil && findAction(["confirm-reset", "unknown", "Unknown", "new"], in: resets) == nil && findAction(["confirm-reset", "pending", "Pending", "pending"], in: resets) != nil
419
478
  controller.actionRunning = true
420
479
  let resetLocked = findAction(["confirm-reset", "one", "One", "new"], in: controller.makeMenu())?.isEnabled == false
480
+ let policyNow = 2000000000.0
481
+ let far: [String: Any] = ["secondary": ["usedPercent": 100, "resetsAt": policyNow + 172801]]
482
+ let exact: [String: Any] = ["secondary": ["usedPercent": 100, "resetsAt": policyNow + 172800]]
483
+ let near: [String: Any] = ["secondary": ["usedPercent": 100, "resetsAt": policyNow + 172799]]
484
+ let left: [String: Any] = ["secondary": ["usedPercent": 99.99, "resetsAt": policyNow + 259200]]
485
+ let correctPolicy = MenuController.resetBlockReason(far, now: policyNow) == nil && MenuController.resetBlockReason(exact, now: policyNow) != nil && MenuController.resetBlockReason(near, now: policyNow) != nil && MenuController.resetBlockReason(left, now: policyNow) != nil && MenuController.resetBlockReason([:], now: policyNow) != nil
486
+ let pendingLocked = findAction(["confirm-reset", "pending", "Pending", "pending"], in: resets)?.isEnabled == false
421
487
  controller.current["accounts"] = originalAccounts
422
488
  controller.actionRunning = true; controller.actionNotice = "Applying change…"
423
489
  let pending = controller.makeMenu()
@@ -425,11 +491,26 @@ if CommandLine.arguments.contains("--self-check") {
425
491
  controller.actionRunning = false; controller.actionNotice = "Task is busy."
426
492
  controller.current["at"] = 2000000001.0
427
493
  let retainedResult = controller.makeMenu().items.contains { $0.title == "Task is busy." }
494
+ let noDismissButton = findAction(["dismiss"], in: controller.makeMenu()) == nil
495
+ controller.menuOpen = false; controller.actionFailed = true; controller.actionNotice = "Unseen result"
496
+ controller.scheduleNoticeExpiry()
497
+ let unseenRetained = controller.noticeExpiry == nil && controller.actionNotice == "Unseen result"
498
+ controller.menuOpen = true; controller.scheduleNoticeExpiry()
499
+ let startsOnView = controller.noticeExpiry != nil
500
+ let previousNotice = controller.noticeVersion
501
+ controller.actionNotice = "Newer result"
502
+ let newerProtected = !controller.expireNotice(version: previousNotice) && controller.actionNotice == "Newer result"
503
+ controller.actionRunning = true; controller.scheduleNoticeExpiry()
504
+ let progressRetained = controller.noticeExpiry == nil && !controller.expireNotice(version: controller.noticeVersion)
505
+ controller.actionRunning = false
506
+ let expiresAfterView = controller.expireNotice(version: controller.noticeVersion) && controller.actionNotice == nil && !controller.actionFailed
428
507
  print("Account choices and stopped controls correct: \(correctChoices && stoppedControls)")
429
508
  print("Pending action protected and result retained: \(correctPending && retainedResult)")
430
509
  print("In-use accounts and current choice marked: \(correctMarker && correctMultiple && correctEmpty)")
431
510
  print("Available and pending reset actions protected: \(correctResets && resetLocked)")
432
- exit(normal != nil && attention != nil && liveUpdate && safeIdentity && correctTiming && correctChoices && stoppedControls && correctPending && retainedResult && correctMarker && correctMultiple && correctEmpty && correctResets && resetLocked ? 0 : 1)
511
+ print("Weekly zero and more-than-48-hour guard enforced: \(correctPolicy && pendingLocked)")
512
+ print("Viewed notices expire without hiding unseen or newer results: \(noDismissButton && unseenRetained && startsOnView && newerProtected && progressRetained && expiresAfterView)")
513
+ exit(normal != nil && attention != nil && liveUpdate && safeIdentity && correctTiming && correctChoices && stoppedControls && correctPending && retainedResult && correctMarker && correctMultiple && correctEmpty && correctResets && resetLocked && correctPolicy && pendingLocked && noDismissButton && unseenRetained && startsOnView && newerProtected && progressRetained && expiresAfterView ? 0 : 1)
433
514
  } else {
434
515
  let app = NSApplication.shared
435
516
  let delegate = MenuController(); app.delegate = delegate; app.run()
@@ -114,6 +114,7 @@ def snapshot(state, include_inactive=False):
114
114
  "usage_checked_at": pool.checked_at(label),
115
115
  "reset_credits": resets.available(usage),
116
116
  "reset_pending": resets.pending(state / "accounts" / label),
117
+ "reset_lock_reason": resets.weekly_block_reason(usage),
117
118
  "maintenance": options.get("maintenance", False),
118
119
  "next_check": min(
119
120
  (b["next_check"] for b in blocks if b["account"] == label),
package/python/resets.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import hashlib
4
4
  import json
5
+ import math
5
6
  import time
6
7
  import uuid
7
8
 
@@ -9,6 +10,29 @@ from pool import Pool, blocked, reset_time
9
10
  from settings import atomic_json
10
11
 
11
12
  OUTCOMES = {"reset", "alreadyRedeemed", "nothingToReset", "noCredit"}
13
+ WEEKLY_RESET_BUFFER = 48 * 60 * 60
14
+
15
+
16
+ def weekly_block_reason(snapshot, now=None):
17
+ window = ((snapshot or {}).get("rateLimits") or {}).get("secondary")
18
+ if not isinstance(window, dict):
19
+ return (
20
+ "Reset locked: check allowance to confirm the weekly limit and reset time."
21
+ )
22
+ used, at = window.get("usedPercent"), window.get("resetsAt")
23
+ if type(used) not in (int, float) or not math.isfinite(used):
24
+ return (
25
+ "Reset locked: check allowance to confirm the weekly limit and reset time."
26
+ )
27
+ if used < 100:
28
+ return "Reset locked: weekly allowance must be fully exhausted (0%)."
29
+ if type(at) not in (int, float) or not math.isfinite(at):
30
+ return (
31
+ "Reset locked: check allowance to confirm the weekly limit and reset time."
32
+ )
33
+ if at - (time.time() if now is None else now) <= WEEKLY_RESET_BUFFER:
34
+ return "Reset locked: the weekly reset must be more than 48 hours away."
35
+ return None
12
36
 
13
37
 
14
38
  def available(snapshot):
@@ -122,13 +146,16 @@ async def consume(state, label, binary, request_id=None):
122
146
  )
123
147
  if known and outcome == "reset":
124
148
  outcome = "alreadyRedeemed"
125
- if not retry and not known:
149
+ if not known:
126
150
  current = await rpc.call("account/rateLimits/read")
127
151
  update_allowance(pool, label, current)
152
+ reason = weekly_block_reason(current)
153
+ if reason:
154
+ raise RouterError(reason)
128
155
  count = available(current)
129
- if count == 0:
156
+ if count == 0 and not retry:
130
157
  return {"outcome": "noCredit", "refreshed": True}
131
- if count is None:
158
+ if count is None and not retry:
132
159
  raise RouterError(
133
160
  "Codex did not report reset availability. Check allowance again before using a reset."
134
161
  )
package/python/router.py CHANGED
@@ -24,7 +24,7 @@ from pool import Pool, blocked, reset_time, failure_kind, file_lock
24
24
  from launch import translate, RESUME_FIELDS
25
25
  import settings
26
26
 
27
- VERSION = "0.7.0"
27
+ VERSION = "0.7.2"
28
28
  _support = Path.home() / "Library/Application Support"
29
29
  _legacy = _support / "Codex Accounts"
30
30
  _default_state = _legacy if (_legacy / "accounts").exists() else _support / "Relay"
@@ -127,10 +127,21 @@ for raw in sys.stdin:
127
127
  result = {"type": params["type"]}
128
128
  elif method == "account/rateLimits/read":
129
129
  reset_count = change(lambda d: d.get("reset_credit_count", 1))
130
+ weekly = change(
131
+ lambda d: (
132
+ {
133
+ "usedPercent": d["reset_weekly_used"],
134
+ "resetsAt": d["reset_weekly_at"],
135
+ }
136
+ if "reset_weekly_used" in d
137
+ else None
138
+ )
139
+ )
130
140
  result = {
131
141
  "ordinaryUsageAllowed": True,
132
142
  "rateLimits": {
133
- "primary": {"usedPercent": 20, "resetsAt": int(time.time()) + 3600}
143
+ "primary": {"usedPercent": 20, "resetsAt": int(time.time()) + 3600},
144
+ "secondary": weekly,
134
145
  },
135
146
  "rateLimitResetCredits": {
136
147
  "availableCount": reset_count,
@@ -149,6 +160,8 @@ for raw in sys.stdin:
149
160
  return {"outcome": "noCredit"}
150
161
  keys.append(key)
151
162
  data["reset_credit_count"] = count - 1
163
+ if "reset_weekly_used" in data:
164
+ data["reset_weekly_used"] = 0
152
165
  return {"outcome": "reset"}
153
166
 
154
167
  result = change(redeem)
@@ -27,6 +27,8 @@ class ResetService:
27
27
  self.residual_limit = False
28
28
  self.outcome = None
29
29
  self.reject = False
30
+ self.weekly_used = 100
31
+ self.weekly_at = time.time() + 72 * 3600
30
32
 
31
33
  async def call(self, method, params=None):
32
34
  await asyncio.sleep(0)
@@ -39,7 +41,11 @@ class ResetService:
39
41
  "primary": {
40
42
  "usedPercent": 100 if self.blocked else 0,
41
43
  "resetsAt": time.time() + 3600,
42
- }
44
+ },
45
+ "secondary": {
46
+ "usedPercent": self.weekly_used,
47
+ "resetsAt": self.weekly_at,
48
+ },
43
49
  },
44
50
  "rateLimitResetCredits": {
45
51
  "availableCount": self.count,
@@ -194,11 +200,67 @@ class ResetTests(unittest.IsolatedAsyncioTestCase):
194
200
  with self.assertRaisesRegex(RouterError, "--yes"):
195
201
  await commands.manage(args, self.root, "fake")
196
202
 
203
+ async def test_weekly_guard_blocks_all_mutation_until_zero_and_more_than_48_hours(
204
+ self,
205
+ ):
206
+ for used, hours in ((0, 72), (99.99, 72), (100, 48), (100, 47), (100, -1)):
207
+ with self.subTest(used=used, hours=hours):
208
+ self.service.weekly_used = used
209
+ self.service.weekly_at = time.time() + hours * 3600
210
+ with self.assertRaisesRegex(RouterError, "Reset locked"):
211
+ await resets.consume(self.root, "one", "fake")
212
+ self.assertFalse(self.service.calls)
213
+ self.assertFalse((self.home / "reset-attempt.json").exists())
214
+ self.service.weekly_used = 100
215
+ self.service.weekly_at = time.time() + 72 * 3600
216
+ result = await resets.consume(self.root, "one", "fake")
217
+ self.assertEqual(result["outcome"], "reset")
218
+
219
+ async def test_pending_retry_cannot_bypass_a_changed_weekly_guard(self):
220
+ self.service.lose_reply = True
221
+ with self.assertRaises(RouterError):
222
+ await resets.consume(self.root, "one", "fake")
223
+ self.service.weekly_used = 0
224
+ with self.assertRaisesRegex(RouterError, "fully exhausted"):
225
+ await resets.consume(self.root, "one", "fake")
226
+ self.assertEqual(len(self.service.calls), 1)
227
+ self.assertTrue(resets.pending(self.home))
228
+ self.assertEqual(self.service.count, 1)
229
+
230
+ def test_weekly_guard_exact_boundary_unknown_values_and_unrounded_allowance(self):
231
+ now = 2000000000
232
+
233
+ def usage(used, at):
234
+ return {"rateLimits": {"secondary": {"usedPercent": used, "resetsAt": at}}}
235
+
236
+ self.assertIsNone(resets.weekly_block_reason(usage(100, now + 172801), now))
237
+ for used, at in (
238
+ (100, now + 172800),
239
+ (100, now + 172799),
240
+ (99.999, now + 200000),
241
+ (100, None),
242
+ (None, now + 200000),
243
+ (float("nan"), now + 200000),
244
+ (100, float("inf")),
245
+ ):
246
+ self.assertIsNotNone(resets.weekly_block_reason(usage(used, at), now))
247
+ self.assertIsNotNone(resets.weekly_block_reason({}, now))
248
+
197
249
  async def test_real_jsonl_helper_redeems_and_refreshes_synthetic_account(self):
198
250
  from test_services import fake_executable
199
251
 
200
252
  self.patch.stop()
201
253
  binary = fake_executable(self.root)
254
+ atomic_json(
255
+ self.home / "fake-history.json",
256
+ {
257
+ "threads": {},
258
+ "calls": [],
259
+ "reset_credit_count": 1,
260
+ "reset_weekly_used": 100,
261
+ "reset_weekly_at": time.time() + 72 * 3600,
262
+ },
263
+ )
202
264
  result = await resets.consume(self.root, "one", binary)
203
265
  self.assertEqual(result, {"outcome": "reset", "refreshed": True})
204
266
  history = json.loads((self.home / "fake-history.json").read_text())