claude-multiacc 2.0.56 → 2.0.57
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 +7 -2
- package/bin/claude +71 -8
- package/bin/codex +67 -14
- package/docs/AUTORESUME-HANDOFF.md +82 -0
- package/docs/AUTORESUME.md +16 -5
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/autoresume.py +58 -14
- package/lib/autoresume_cross.py +143 -0
- package/lib/autoresume_cross_dispatch.py +114 -0
- package/lib/autoresume_cross_receipt.py +138 -0
- package/lib/autoresume_handoff.py +191 -0
- package/lib/autoresume_probe.py +36 -0
- package/package.json +1 -1
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/autoresume_cross_receipt_support.py +58 -0
- package/tests/autoresume_cross_tmux_environment.py +59 -0
- package/tests/autoresume_cross_tmux_support.py +112 -0
- package/tests/autoresume_cross_unit_support.py +92 -0
- package/tests/fixtures/cross_provider_cli.py +121 -0
- package/tests/test_autoresume.py +3 -1
- package/tests/test_autoresume_cross_claude_holders.py +81 -0
- package/tests/test_autoresume_cross_guards.py +133 -0
- package/tests/test_autoresume_cross_holders.py +89 -0
- package/tests/test_autoresume_cross_policy.py +123 -0
- package/tests/test_autoresume_cross_receipt.py +141 -0
- package/tests/test_autoresume_cross_tmux.py +140 -0
- package/tests/test_autoresume_cross_tmux_races.py +45 -0
- package/tests/test_autoresume_cross_tokens.py +102 -0
- package/tests/test_autoresume_handoff_argv.py +127 -0
- package/tests/test_autoresume_handoff_history.py +115 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Disposable dual-provider pools and simulated processes for cross-provider policy tests."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import test_autoresume as fixtures
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DualRunner(fixtures.FakeRunner):
|
|
10
|
+
def __init__(self, case, target):
|
|
11
|
+
super().__init__(case.t0, case.PID, case.PPID, case.self_path)
|
|
12
|
+
self.ar_dir = case.ar_dir
|
|
13
|
+
self.target = target
|
|
14
|
+
self.probe_out = "pick=acct-02 tier=hard\n"
|
|
15
|
+
self.target_out = "pick=acct-01 tier=eligible\n"
|
|
16
|
+
self.source_hook = None
|
|
17
|
+
self.target_hook = None
|
|
18
|
+
|
|
19
|
+
def run(self, argv, timeout, env=None, cwd=None):
|
|
20
|
+
if argv[0] == self.self_path and self.source_hook:
|
|
21
|
+
callback, self.source_hook = self.source_hook, None
|
|
22
|
+
callback()
|
|
23
|
+
if argv[0] == self.target:
|
|
24
|
+
self.calls.append({"argv": list(argv), "env": env, "cwd": cwd, "t": self.t})
|
|
25
|
+
if self.target_hook:
|
|
26
|
+
callback, self.target_hook = self.target_hook, None
|
|
27
|
+
callback()
|
|
28
|
+
return (3 if "tier=none" in self.target_out else 0), self.target_out
|
|
29
|
+
return super().run(argv, timeout, env=env, cwd=cwd)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def target_probes(self):
|
|
33
|
+
return [call for call in self.calls if call["argv"][0] == self.target]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def source_history(case, provider, error_class):
|
|
37
|
+
reset = int(case.t0) + 7200
|
|
38
|
+
if provider == "claude":
|
|
39
|
+
case.registry()
|
|
40
|
+
record = fixtures.cc_quota(case.t0, resets=reset, cwd=str(case.work))
|
|
41
|
+
if error_class != "quota":
|
|
42
|
+
codes = {"auth": "authentication_failed", "blocked": "account_on_hold",
|
|
43
|
+
"transient": "server_error", "model": "model_requires_usage_credits"}
|
|
44
|
+
record = fixtures.cc_api_error(case.t0, codes[error_class], cwd=str(case.work))
|
|
45
|
+
if error_class == "model":
|
|
46
|
+
record.update(error="rate_limit", apiError="model_requires_usage_credits")
|
|
47
|
+
return case.transcript([record])
|
|
48
|
+
code = {"quota": "usage_limit_exceeded", "auth": "unauthorized", "transient": "internal_server_error"}
|
|
49
|
+
return case.rollout([fixtures.cx_meta(case.launched, cwd=str(case.work)),
|
|
50
|
+
fixtures.cx_tokens(case.t0, primary={"used_percent": 100,
|
|
51
|
+
"window_minutes": 300, "resets_at": reset}),
|
|
52
|
+
fixtures.cx_error(case.t0, code[error_class])])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def scenario(test, provider, argv=None, error_class="quota", environment=None):
|
|
56
|
+
case = fixtures.PoolCase() if provider == "claude" else fixtures.CodexWatcherTests()
|
|
57
|
+
case.setUp()
|
|
58
|
+
test.addCleanup(case.tearDown)
|
|
59
|
+
target = "codex" if provider == "claude" else "claude"
|
|
60
|
+
sibling = case.tmp / "bin" / target
|
|
61
|
+
sibling.parent.mkdir(exist_ok=True)
|
|
62
|
+
sibling.write_text("#!/bin/sh\nexit 3\n")
|
|
63
|
+
sibling.chmod(0o700)
|
|
64
|
+
Path(case.self_path).write_text("#!/bin/sh\nexit 3\n")
|
|
65
|
+
Path(case.self_path).chmod(0o700)
|
|
66
|
+
target_root = case.tmp / (target + "-pool")
|
|
67
|
+
target_root.mkdir()
|
|
68
|
+
(target_root / "acct-01").mkdir()
|
|
69
|
+
manifest = json.dumps({"accounts": ["acct-01", "acct-02"], "threshold": 90})
|
|
70
|
+
(target_root / "accounts.json").write_text(manifest)
|
|
71
|
+
(case.root / "accounts.json").write_text(manifest)
|
|
72
|
+
default = ["--dangerously-skip-permissions"] if provider == "claude" else ["--yolo", "resume", fixtures.CX_SID]
|
|
73
|
+
state = case.write_state(default if argv is None else argv, provider=provider)
|
|
74
|
+
history = source_history(case, provider, error_class)
|
|
75
|
+
runner = DualRunner(case, str(sibling))
|
|
76
|
+
env = {"HOME": str(case.tmp), "PATH": str(sibling.parent) + ":/usr/bin:/bin",
|
|
77
|
+
provider.upper() + "_ACCOUNTS_ROOT": str(case.root),
|
|
78
|
+
target.upper() + "_ACCOUNTS_ROOT": str(target_root)}
|
|
79
|
+
env.update(environment or {})
|
|
80
|
+
watcher = fixtures.ar.Watcher(state, fixtures.ar.load_state(state),
|
|
81
|
+
fixtures.ar.load_argv(fixtures.ar.argv_path_for(state)), runner=runner, environ=env)
|
|
82
|
+
watcher.started = runner.now()
|
|
83
|
+
watcher.lstart = fixtures.LSTART
|
|
84
|
+
watcher.read()
|
|
85
|
+
runner.at(case.t0 + 15, lambda: runner.exit(case.PID))
|
|
86
|
+
return case, watcher, runner, history, target_root
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cancel_record(case, provider):
|
|
90
|
+
return fixtures.cc_user(case.t0 + 5, "I will continue manually", cwd=str(case.work)) \
|
|
91
|
+
if provider == "claude" else fixtures.cx_event(case.t0 + 5,
|
|
92
|
+
{"type": "user_message", "message": "I will continue manually"})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Disposable native-CLI stand-in for the real tmux cross-provider acceptance tests."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import select
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
import termios
|
|
10
|
+
import time
|
|
11
|
+
import tty
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
PROVIDER = Path(sys.argv[0]).name
|
|
15
|
+
HOME = Path(os.environ["FAKE_HOME"])
|
|
16
|
+
SIDS = {"codex": "01a0cb0f-a719-7461-bba6-a59bdf2412cc", "claude": "f8470bf4-f810-4492-b564-1712a7c4930e"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def record(kind, **fields):
|
|
20
|
+
with (HOME / "provider.jsonl").open("a") as target:
|
|
21
|
+
target.write(json.dumps(dict(kind=kind, provider=PROVIDER, pid=os.getpid(), **fields)) + "\n")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def stop(signum, _frame):
|
|
25
|
+
record("signal", signal=signum)
|
|
26
|
+
target = os.environ.get("FAKE_PARK_TARGET_ON_TERM")
|
|
27
|
+
if signum == signal.SIGTERM and target in SIDS and target != PROVIDER:
|
|
28
|
+
for account in (HOME / (target + "-pool")).glob("acct-*"):
|
|
29
|
+
(account / ".limited").write_text(
|
|
30
|
+
f"{int(time.time()) + 3600}\nbucket=error-cooldown reason=error-cooldown\n")
|
|
31
|
+
raise SystemExit(0)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def timestamp():
|
|
35
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def session_identity():
|
|
39
|
+
arguments = sys.argv[1:]
|
|
40
|
+
for flag in ("--session-id", "--resume", "-r"):
|
|
41
|
+
if flag in arguments:
|
|
42
|
+
return arguments[arguments.index(flag) + 1]
|
|
43
|
+
if "resume" in arguments:
|
|
44
|
+
return next((arg for arg in arguments if len(arg) == 36 and arg.count("-") == 4), SIDS[PROVIDER])
|
|
45
|
+
records = HOME / "provider.jsonl"
|
|
46
|
+
if records.exists() and any(json.loads(line).get("provider") == PROVIDER
|
|
47
|
+
for line in records.read_text().splitlines()):
|
|
48
|
+
return str(uuid.uuid4())
|
|
49
|
+
return SIDS[PROVIDER]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def user_record(sid, prompt):
|
|
53
|
+
if PROVIDER == "codex":
|
|
54
|
+
return {"timestamp": timestamp(), "type": "response_item", "payload": {
|
|
55
|
+
"type": "message", "role": "user", "content": [{"type": "input_text", "text": prompt}]}}
|
|
56
|
+
return {"timestamp": timestamp(), "type": "user", "sessionId": sid, "cwd": os.getcwd(),
|
|
57
|
+
"isSidechain": False, "uuid": "585fd5be-e0df-4cb3-ae28-672ba54550a3",
|
|
58
|
+
"message": {"role": "user", "content": prompt}}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def initialize_history(account, sid):
|
|
62
|
+
if PROVIDER == "codex":
|
|
63
|
+
directory = account / "sessions/2026/10/11"
|
|
64
|
+
path = directory / ("rollout-test-" + sid + ".jsonl")
|
|
65
|
+
else:
|
|
66
|
+
directory = account / "projects" / os.getcwd().replace("/", "-")
|
|
67
|
+
path = directory / (sid + ".jsonl")
|
|
68
|
+
registry = account / "sessions" / (str(os.getpid()) + ".json")
|
|
69
|
+
registry.parent.mkdir(exist_ok=True)
|
|
70
|
+
registry.write_text(json.dumps({"pid": os.getpid(), "sessionId": sid, "cwd": os.getcwd(),
|
|
71
|
+
"startedAt": int(time.time() * 1000), "kind": "interactive", "entrypoint": "cli"}))
|
|
72
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
history = path.open("a+")
|
|
74
|
+
if history.tell() == 0:
|
|
75
|
+
if PROVIDER == "codex":
|
|
76
|
+
history.write(json.dumps({"timestamp": timestamp(), "type": "session_meta", "payload": {
|
|
77
|
+
"id": sid, "session_id": sid, "timestamp": timestamp(),
|
|
78
|
+
"cwd": os.getcwd(), "source": "cli"}}) + "\n")
|
|
79
|
+
history.write(json.dumps(user_record(sid, "Complete the synthetic acceptance task.")) + "\n")
|
|
80
|
+
prompt = sys.argv[-1] if len(sys.argv) > 1 and " " in sys.argv[-1] else ""
|
|
81
|
+
if prompt and os.environ.get("FAKE_DROP_PROMPT") != PROVIDER:
|
|
82
|
+
user = user_record(sid, prompt)
|
|
83
|
+
if os.environ.get("FAKE_OLD_PROMPT") == PROVIDER:
|
|
84
|
+
user["timestamp"] = "2000-01-01T00:00:00Z"
|
|
85
|
+
history.write(json.dumps(user) + "\n")
|
|
86
|
+
record("prompt", text=prompt, sid=sid, path=str(path.resolve()))
|
|
87
|
+
history.flush()
|
|
88
|
+
return history
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main():
|
|
92
|
+
if sys.argv[1:] == ["--help"]:
|
|
93
|
+
print("Usage: " + PROVIDER + " [OPTIONS]\n --no-daemon Run locally\n --session-id UUID")
|
|
94
|
+
return
|
|
95
|
+
if sys.argv[1:] == ["--version"]:
|
|
96
|
+
print(PROVIDER + " test")
|
|
97
|
+
return
|
|
98
|
+
account = Path(os.environ["CODEX_HOME" if PROVIDER == "codex" else "CLAUDE_CONFIG_DIR"])
|
|
99
|
+
sid = session_identity()
|
|
100
|
+
record("launch", argv=sys.argv[1:], cwd=os.getcwd(), account=account.name, sid=sid)
|
|
101
|
+
settings = termios.tcgetattr(0)
|
|
102
|
+
signal.signal(signal.SIGTERM, stop)
|
|
103
|
+
signal.signal(signal.SIGHUP, stop)
|
|
104
|
+
history = initialize_history(account, sid)
|
|
105
|
+
try:
|
|
106
|
+
tty.setraw(0)
|
|
107
|
+
record("ready", sid=sid, path=str(Path(history.name).resolve()))
|
|
108
|
+
print("FAKE " + PROVIDER.upper() + " READY", flush=True)
|
|
109
|
+
while True:
|
|
110
|
+
if select.select([0], [], [], 0.1)[0]:
|
|
111
|
+
data = os.read(0, 4096)
|
|
112
|
+
if not data:
|
|
113
|
+
return
|
|
114
|
+
record("keys", data=data.hex())
|
|
115
|
+
finally:
|
|
116
|
+
history.close()
|
|
117
|
+
termios.tcsetattr(0, termios.TCSANOW, settings)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
main()
|
package/tests/test_autoresume.py
CHANGED
|
@@ -1927,9 +1927,11 @@ class ClaudeWatcherTests(PoolCase):
|
|
|
1927
1927
|
self.registry()
|
|
1928
1928
|
self.transcript([cc_quota(self.t0, resets=int(self.t0) + 999)])
|
|
1929
1929
|
other = self.root / "acct-02" / "sessions" / "6060.json"
|
|
1930
|
-
other.write_text(json.dumps({"pid": 6060, "sessionId": SID, "cwd": str(self.work)
|
|
1930
|
+
other.write_text(json.dumps({"pid": 6060, "sessionId": SID, "cwd": str(self.work),
|
|
1931
|
+
"startedAt": self.launched * 1000 + 700}))
|
|
1931
1932
|
w, r = self.watcher(state)
|
|
1932
1933
|
r.live.add(6060)
|
|
1934
|
+
r.starts[6060] = time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(self.launched))
|
|
1933
1935
|
self.assertEqual(w.run(), 0)
|
|
1934
1936
|
self.assertEqual((r.signals, r.sends, self.relaunch_files()), ([], [], []))
|
|
1935
1937
|
self.assertIn("reason=holder", self.log_lines()[-1])
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Claude registry files must identify a current live holder before preventing a handoff."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from autoresume_cross_unit_support import scenario
|
|
8
|
+
import autoresume_cross as cross
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ClaudeHolderIdentityTests(unittest.TestCase):
|
|
12
|
+
def holder(self, case, watcher, runner):
|
|
13
|
+
pid = 6060
|
|
14
|
+
path = case.root / "acct-02" / "sessions" / (str(pid) + ".json")
|
|
15
|
+
record = {"pid": pid, "sessionId": watcher.sid, "cwd": str(case.work),
|
|
16
|
+
"startedAt": case.launched * 1000 + 700}
|
|
17
|
+
path.write_text(json.dumps(record))
|
|
18
|
+
runner.live.add(pid)
|
|
19
|
+
runner.starts[pid] = time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(case.launched))
|
|
20
|
+
return pid, path, record
|
|
21
|
+
|
|
22
|
+
def test_genuine_live_holder_blocks_both_same_and_cross_provider_relaunch(self):
|
|
23
|
+
case, watcher, runner, _, _ = scenario(self, "claude")
|
|
24
|
+
pid, _, _ = self.holder(case, watcher, runner)
|
|
25
|
+
self.assertEqual(watcher.other_holder(), pid)
|
|
26
|
+
self.assertEqual(cross.shared_source(watcher), pid)
|
|
27
|
+
self.assertIsNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
28
|
+
self.assertEqual((runner.target_probes, runner.signals, runner.sends), ([], [], []))
|
|
29
|
+
|
|
30
|
+
def test_dead_zombie_or_reused_pid_does_not_hold_the_source(self):
|
|
31
|
+
for stale in ("dead", "zombie", "reused", "invalid-process-start"):
|
|
32
|
+
with self.subTest(stale=stale):
|
|
33
|
+
case, watcher, runner, _, _ = scenario(self, "claude")
|
|
34
|
+
pid, _, _ = self.holder(case, watcher, runner)
|
|
35
|
+
if stale == "dead":
|
|
36
|
+
runner.exit(pid)
|
|
37
|
+
elif stale == "zombie":
|
|
38
|
+
runner.gone = lambda candidate: candidate == pid or candidate not in runner.live
|
|
39
|
+
else:
|
|
40
|
+
runner.starts[pid] = "invalid start" if stale == "invalid-process-start" else time.strftime(
|
|
41
|
+
"%a %b %d %H:%M:%S %Y", time.localtime(case.launched + 120))
|
|
42
|
+
self.assertIsNone(watcher.other_holder())
|
|
43
|
+
self.assertIsNotNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
44
|
+
|
|
45
|
+
def test_malformed_registry_identity_cannot_claim_a_matching_live_pid(self):
|
|
46
|
+
invalid = (("pid", "6060"), ("pid", 7070), ("pid", None), ("startedAt", None),
|
|
47
|
+
("startedAt", True), ("startedAt", "123"), ("startedAt", float("nan")),
|
|
48
|
+
("startedAt", float("inf")), ("startedAt", 10 ** 1000))
|
|
49
|
+
for key, value in invalid:
|
|
50
|
+
with self.subTest(key=key, value=str(value)[:20]):
|
|
51
|
+
case, watcher, runner, _, _ = scenario(self, "claude")
|
|
52
|
+
_, path, record = self.holder(case, watcher, runner)
|
|
53
|
+
record[key] = value
|
|
54
|
+
path.write_text(json.dumps(record))
|
|
55
|
+
self.assertIsNone(watcher.other_holder())
|
|
56
|
+
self.assertIsNotNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
57
|
+
|
|
58
|
+
def test_registry_of_another_session_or_invalid_document_does_not_hold(self):
|
|
59
|
+
for malformed in ("another-session", "list", "invalid-json", "future-start"):
|
|
60
|
+
with self.subTest(malformed=malformed):
|
|
61
|
+
case, watcher, runner, _, _ = scenario(self, "claude")
|
|
62
|
+
_, path, record = self.holder(case, watcher, runner)
|
|
63
|
+
if malformed == "another-session":
|
|
64
|
+
record["sessionId"] = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
|
65
|
+
elif malformed == "future-start":
|
|
66
|
+
record["startedAt"] = (runner.now() + 120) * 1000
|
|
67
|
+
path.write_text("[1]" if malformed == "list" else "{" if malformed == "invalid-json"
|
|
68
|
+
else json.dumps(record))
|
|
69
|
+
self.assertIsNone(watcher.other_holder())
|
|
70
|
+
self.assertIsNotNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
71
|
+
|
|
72
|
+
def test_new_holder_after_target_probe_invalidates_the_plan(self):
|
|
73
|
+
case, watcher, runner, _, _ = scenario(self, "claude")
|
|
74
|
+
plan = cross.plan_fallback(watcher, watcher.tracker.pending, "")
|
|
75
|
+
self.assertIsNotNone(plan)
|
|
76
|
+
self.holder(case, watcher, runner)
|
|
77
|
+
self.assertFalse(cross.revalidate(watcher, plan))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
unittest.main()
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Fallback opt-outs, pool boundaries and probe-time changes remain fail-closed."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import unittest
|
|
5
|
+
from unittest import mock
|
|
6
|
+
|
|
7
|
+
from autoresume_cross_receipt_support import TOKEN
|
|
8
|
+
from autoresume_cross_unit_support import scenario
|
|
9
|
+
import autoresume as ar
|
|
10
|
+
import autoresume_cross as cross
|
|
11
|
+
from autoresume_probe import cross_env, handoff_settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CrossProviderGuardsTests(unittest.TestCase):
|
|
15
|
+
def test_preparation_write_failure_removes_only_its_own_artifacts(self):
|
|
16
|
+
for provider in ("claude", "codex"):
|
|
17
|
+
for failed_write in range(1, 6):
|
|
18
|
+
with self.subTest(provider=provider, failed_write=failed_write):
|
|
19
|
+
case, watcher, runner, _, root = scenario(self, provider)
|
|
20
|
+
watcher.cross_plan = cross.plan_fallback(watcher, watcher.tracker.pending, "")
|
|
21
|
+
unrelated = case.ar_dir / "r-unrelated.relaunch"
|
|
22
|
+
unrelated.write_text("preserve another chain\n")
|
|
23
|
+
original, writes = ar._atomic_write, []
|
|
24
|
+
def fail_write(path, data):
|
|
25
|
+
writes.append(path)
|
|
26
|
+
if len(writes) == failed_write:
|
|
27
|
+
raise OSError("simulated preparation write failure")
|
|
28
|
+
return original(path, data)
|
|
29
|
+
with mock.patch.object(ar, "_atomic_write", side_effect=fail_write):
|
|
30
|
+
with self.assertRaises(OSError):
|
|
31
|
+
cross.prepare(watcher, watcher.tracker.pending, "", TOKEN,
|
|
32
|
+
watcher.build_relaunch_argv("quota"))
|
|
33
|
+
self.assertEqual(list(case.ar_dir.glob("r-" + TOKEN + ".*")), [])
|
|
34
|
+
self.assertEqual(list((root / "tmp/autoresume").glob("r-" + TOKEN + ".*")), [])
|
|
35
|
+
self.assertEqual(unrelated.read_text(), "preserve another chain\n")
|
|
36
|
+
self.assertEqual((runner.signals, runner.sends), ([], []))
|
|
37
|
+
|
|
38
|
+
def test_source_cross_provider_opt_out_does_not_probe_the_target(self):
|
|
39
|
+
for provider in ("claude", "codex"):
|
|
40
|
+
for disabled in ("0", "false", "no", "off"):
|
|
41
|
+
with self.subTest(provider=provider, disabled=disabled):
|
|
42
|
+
_, watcher, runner, _, _ = scenario(self, provider,
|
|
43
|
+
environment={provider.upper() + "_MULTIACC_CROSS_PROVIDER": disabled})
|
|
44
|
+
self.assertIsNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
45
|
+
self.assertEqual((runner.target_probes, runner.signals), ([], []))
|
|
46
|
+
|
|
47
|
+
def test_either_provider_disable_or_pool_kill_switch_prevents_a_handoff(self):
|
|
48
|
+
for provider in ("claude", "codex"):
|
|
49
|
+
for stopped in ("claude", "codex"):
|
|
50
|
+
for mode in ("environment", "disable", "file"):
|
|
51
|
+
with self.subTest(provider=provider, stopped=stopped, mode=mode):
|
|
52
|
+
_, watcher, runner, _, _ = scenario(self, provider)
|
|
53
|
+
if mode == "file":
|
|
54
|
+
(Path(cross.pool_roots(watcher)[stopped]) / "autoresume.off").touch()
|
|
55
|
+
else:
|
|
56
|
+
suffix = "AUTORESUME" if mode == "environment" else "DISABLE"
|
|
57
|
+
watcher.env[stopped.upper() + "_MULTIACC_" + suffix] = "0" if mode == "environment" else "1"
|
|
58
|
+
self.assertIsNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
59
|
+
self.assertEqual((runner.target_probes, runner.signals), ([], []))
|
|
60
|
+
|
|
61
|
+
def test_missing_target_pool_or_executable_holds_without_stopping(self):
|
|
62
|
+
for provider in ("claude", "codex"):
|
|
63
|
+
for missing in ("pool", "shim", "executable", "same-root", "alias-root"):
|
|
64
|
+
with self.subTest(provider=provider, missing=missing):
|
|
65
|
+
_, watcher, runner, _, root = scenario(self, provider)
|
|
66
|
+
if missing == "pool":
|
|
67
|
+
(root / "accounts.json").unlink()
|
|
68
|
+
elif missing == "shim":
|
|
69
|
+
Path(runner.target).unlink()
|
|
70
|
+
elif missing == "executable":
|
|
71
|
+
Path(runner.target).chmod(0o600)
|
|
72
|
+
else:
|
|
73
|
+
target = "codex" if provider == "claude" else "claude"
|
|
74
|
+
shared = Path(watcher.st["acc_root"])
|
|
75
|
+
if missing == "alias-root":
|
|
76
|
+
alias = root.parent / "alias-root"
|
|
77
|
+
alias.symlink_to(shared, target_is_directory=True)
|
|
78
|
+
shared = alias
|
|
79
|
+
watcher.env[target.upper() + "_ACCOUNTS_ROOT"] = str(shared)
|
|
80
|
+
self.assertIsNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
81
|
+
self.assertEqual((runner.target_probes, runner.signals), ([], []))
|
|
82
|
+
|
|
83
|
+
def test_restrictive_permissions_hold_before_probing(self):
|
|
84
|
+
for mode in ("plan", "acceptEdits", "dontAsk", "auto"):
|
|
85
|
+
with self.subTest(mode=mode):
|
|
86
|
+
_, watcher, runner, _, _ = scenario(self, "claude", argv=["--permission-mode", mode])
|
|
87
|
+
self.assertIsNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
88
|
+
self.assertEqual((runner.target_probes, runner.signals, runner.sends), ([], [], []))
|
|
89
|
+
|
|
90
|
+
def test_target_kill_switch_appearing_during_probe_prevents_source_signal(self):
|
|
91
|
+
for provider in ("claude", "codex"):
|
|
92
|
+
with self.subTest(provider=provider):
|
|
93
|
+
case, watcher, runner, _, root = scenario(self, provider)
|
|
94
|
+
runner.target_hook = lambda: (root / "autoresume.off").touch()
|
|
95
|
+
self.assertEqual(watcher.run(), 0)
|
|
96
|
+
self.assertEqual((runner.signals, runner.sends, case.relaunch_files()), ([], [], []))
|
|
97
|
+
|
|
98
|
+
def test_snapshot_truncation_with_same_inode_invalidates_a_prepared_plan(self):
|
|
99
|
+
for provider in ("claude", "codex"):
|
|
100
|
+
with self.subTest(provider=provider):
|
|
101
|
+
_, watcher, _, history, _ = scenario(self, provider)
|
|
102
|
+
with history.open("a") as target:
|
|
103
|
+
target.write('{"type":"progress","data":"later source state"}\n')
|
|
104
|
+
plan = cross.plan_fallback(watcher, watcher.tracker.pending, "")
|
|
105
|
+
self.assertIsNotNone(plan)
|
|
106
|
+
inode = history.stat().st_ino
|
|
107
|
+
lines = history.read_bytes().splitlines(keepends=True)
|
|
108
|
+
history.write_bytes(b"".join(lines[:-1]))
|
|
109
|
+
self.assertEqual(history.stat().st_ino, inode)
|
|
110
|
+
self.assertFalse(cross.revalidate(watcher, plan))
|
|
111
|
+
|
|
112
|
+
def test_target_environment_cannot_inherit_source_auth_pins_or_relaunch_tokens(self):
|
|
113
|
+
roots = {"claude": "/tmp/fixture-claude", "codex": "/tmp/fixture-codex"}
|
|
114
|
+
private = {key: "TEST_ONLY_SENTINEL" for key in (
|
|
115
|
+
"CLAUDE_CONFIG_DIR", "CODEX_HOME", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "OPENAI_API_KEY",
|
|
116
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
117
|
+
"CLAUDE_ACCOUNT", "CODEX_ACCOUNT", "CLAUDE_SHIM_ACTIVE", "CODEX_SHIM_ACTIVE",
|
|
118
|
+
"CLAUDE_MULTIACC_AR", "CODEX_MULTIACC_AR", "CLAUDE_MULTIACC_AR_AVOID", "CODEX_MULTIACC_AR_AVOID")}
|
|
119
|
+
env = dict(private, HOME="/tmp/fixture-home", CLAUDE_MULTIACC_HEADROOM_BAND="17",
|
|
120
|
+
CODEX_MULTIACC_CREDITS="1", CODEX_MULTIACC_AR_POLL="0.1")
|
|
121
|
+
cleaned = cross_env(env, roots)
|
|
122
|
+
self.assertFalse(set(private) & set(cleaned))
|
|
123
|
+
self.assertEqual(cleaned["CLAUDE_ACCOUNTS_ROOT"], roots["claude"])
|
|
124
|
+
self.assertEqual(cleaned["CODEX_ACCOUNTS_ROOT"], roots["codex"])
|
|
125
|
+
self.assertEqual(cleaned["CODEX_MULTIACC_CREDITS"], "1")
|
|
126
|
+
settings = handoff_settings(env, roots)
|
|
127
|
+
self.assertFalse(set(private) & set(settings))
|
|
128
|
+
self.assertNotIn("HOME", settings)
|
|
129
|
+
self.assertEqual(settings["CODEX_MULTIACC_AR_POLL"], "0.1")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
unittest.main()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""An exact live source holder blocks a fork; stale or unrelated claims do not."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import time
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
import test_autoresume as fixtures
|
|
8
|
+
from autoresume_cross_unit_support import scenario
|
|
9
|
+
import autoresume_cross as cross
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CrossProviderHolderTests(unittest.TestCase):
|
|
13
|
+
def holder(self, case, watcher, runner, history, pid=5454):
|
|
14
|
+
state = Path(case.write_state(["resume", fixtures.CX_SID], provider="codex", pid=pid))
|
|
15
|
+
claim = state.with_suffix(".rollout")
|
|
16
|
+
claim.write_text(str(history))
|
|
17
|
+
started = time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(case.launched))
|
|
18
|
+
runner.live.add(pid)
|
|
19
|
+
runner.starts[pid] = started
|
|
20
|
+
runner.table.append((pid, case.PPID, started))
|
|
21
|
+
return state, claim, pid
|
|
22
|
+
|
|
23
|
+
def test_live_matching_codex_holder_prevents_a_new_provider_conversation(self):
|
|
24
|
+
case, watcher, runner, history, _ = scenario(self, "codex")
|
|
25
|
+
_, _, pid = self.holder(case, watcher, runner, history)
|
|
26
|
+
self.assertEqual(cross.shared_source(watcher), pid)
|
|
27
|
+
self.assertIsNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
28
|
+
self.assertEqual((runner.target_probes, runner.signals), ([], []))
|
|
29
|
+
|
|
30
|
+
def test_dead_zombie_or_reused_pid_cannot_keep_a_stale_claim_alive(self):
|
|
31
|
+
for stale in ("dead", "zombie", "reused", "invalid-start"):
|
|
32
|
+
with self.subTest(stale=stale):
|
|
33
|
+
case, watcher, runner, history, _ = scenario(self, "codex")
|
|
34
|
+
_, _, pid = self.holder(case, watcher, runner, history)
|
|
35
|
+
if stale == "dead":
|
|
36
|
+
runner.exit(pid)
|
|
37
|
+
elif stale == "zombie":
|
|
38
|
+
runner.gone = lambda candidate: candidate == pid or candidate not in runner.live
|
|
39
|
+
else:
|
|
40
|
+
started = "invalid process time" if stale == "invalid-start" else time.strftime(
|
|
41
|
+
"%a %b %d %H:%M:%S %Y", time.localtime(case.launched + 120))
|
|
42
|
+
runner.starts[pid] = started
|
|
43
|
+
runner.table = [(pid, case.PPID, started)]
|
|
44
|
+
self.assertIsNone(cross.shared_source(watcher))
|
|
45
|
+
self.assertIsNotNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
46
|
+
|
|
47
|
+
def test_claim_must_match_state_basename_provider_root_and_parent_identity(self):
|
|
48
|
+
for changed in ("pid", "provider", "acc_root", "ppid"):
|
|
49
|
+
with self.subTest(changed=changed):
|
|
50
|
+
case, watcher, runner, history, _ = scenario(self, "codex")
|
|
51
|
+
state, _, _ = self.holder(case, watcher, runner, history)
|
|
52
|
+
fields = dict(line.split("=", 1) for line in state.read_text().splitlines())
|
|
53
|
+
values = {"pid": "7878", "provider": "claude", "acc_root": str(case.tmp / "another-pool"),
|
|
54
|
+
"ppid": "888"}
|
|
55
|
+
fields[changed] = values[changed]
|
|
56
|
+
state.write_text("".join(key + "=" + value + "\n" for key, value in fields.items()))
|
|
57
|
+
self.assertIsNone(cross.shared_source(watcher))
|
|
58
|
+
self.assertIsNotNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
59
|
+
|
|
60
|
+
def test_malformed_state_and_missing_claim_target_do_not_crash_or_block(self):
|
|
61
|
+
for invalid in ("malformed-state", "missing-rollout"):
|
|
62
|
+
with self.subTest(invalid=invalid):
|
|
63
|
+
case, watcher, runner, history, _ = scenario(self, "codex")
|
|
64
|
+
state, claim, _ = self.holder(case, watcher, runner, history)
|
|
65
|
+
if invalid == "malformed-state":
|
|
66
|
+
state.write_text("v=1\nprovider=codex\npid=not-a-pid\n")
|
|
67
|
+
else:
|
|
68
|
+
claim.write_text(str(case.tmp / "absent-rollout.jsonl"))
|
|
69
|
+
self.assertIsNone(cross.shared_source(watcher))
|
|
70
|
+
self.assertIsNotNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
71
|
+
|
|
72
|
+
def test_another_live_session_is_not_a_holder_of_this_source(self):
|
|
73
|
+
case, watcher, runner, _, _ = scenario(self, "codex")
|
|
74
|
+
unrelated = case.rollout([fixtures.cx_meta(case.launched, sid=fixtures.CX_SID2, cwd=str(case.work))],
|
|
75
|
+
sid=fixtures.CX_SID2)
|
|
76
|
+
self.holder(case, watcher, runner, unrelated)
|
|
77
|
+
self.assertIsNone(cross.shared_source(watcher))
|
|
78
|
+
self.assertIsNotNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
79
|
+
|
|
80
|
+
def test_new_holder_after_target_probe_invalidates_the_plan(self):
|
|
81
|
+
case, watcher, runner, history, _ = scenario(self, "codex")
|
|
82
|
+
plan = cross.plan_fallback(watcher, watcher.tracker.pending, "")
|
|
83
|
+
self.assertIsNotNone(plan)
|
|
84
|
+
self.holder(case, watcher, runner, history)
|
|
85
|
+
self.assertFalse(cross.revalidate(watcher, plan))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
unittest.main()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Quota fallback keeps same-provider priority and never stops a client without a safe target."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
6
|
+
import test_autoresume as fixtures
|
|
7
|
+
from autoresume_cross_unit_support import cancel_record, scenario
|
|
8
|
+
import autoresume_cross as cross
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CrossProviderPolicyTests(unittest.TestCase):
|
|
12
|
+
def test_unknown_or_inconsistent_source_capacity_never_probes_another_provider(self):
|
|
13
|
+
replies = ((127, ""), (124, "pick=acct-02 tier=hard"), (0, "malformed reply"),
|
|
14
|
+
(3, "pick=acct-02 tier=hard"), (0, "pick= tier=none"),
|
|
15
|
+
(3, "pick=acct-02 tier=none"), (0, "pick= tier=hard"),
|
|
16
|
+
(0, "pick=acct-01 tier=eligible"), (1, "pick=acct-02 tier=eligible"))
|
|
17
|
+
for provider in ("claude", "codex"):
|
|
18
|
+
for status, reply in replies:
|
|
19
|
+
with self.subTest(provider=provider, status=status, reply=reply):
|
|
20
|
+
case, watcher, runner, _, _ = scenario(self, provider)
|
|
21
|
+
original = runner.run
|
|
22
|
+
def source_failure(argv, timeout, env=None, cwd=None):
|
|
23
|
+
if argv[0] == watcher.st["self"]:
|
|
24
|
+
return status, reply + "\n"
|
|
25
|
+
return original(argv, timeout, env=env, cwd=cwd)
|
|
26
|
+
with mock.patch.object(runner, "run", side_effect=source_failure):
|
|
27
|
+
self.assertEqual(watcher.run(), 0)
|
|
28
|
+
self.assertEqual((runner.target_probes, runner.signals, runner.sends), ([], [], []))
|
|
29
|
+
self.assertEqual(case.relaunch_files(), [])
|
|
30
|
+
self.assertIn("hold", case.events())
|
|
31
|
+
|
|
32
|
+
def test_valid_empty_source_pool_probes_another_provider(self):
|
|
33
|
+
for provider in ("claude", "codex"):
|
|
34
|
+
with self.subTest(provider=provider):
|
|
35
|
+
_, watcher, runner, _, _ = scenario(self, provider)
|
|
36
|
+
runner.probe_out = "pick= tier=none\n"
|
|
37
|
+
runner.target_out = "pick= tier=none\n"
|
|
38
|
+
self.assertEqual(watcher.run(), 0)
|
|
39
|
+
self.assertTrue(runner.target_probes)
|
|
40
|
+
self.assertEqual((runner.signals, runner.sends), ([], []))
|
|
41
|
+
|
|
42
|
+
def test_same_provider_eligible_soft_or_configured_credits_wins_without_cross_probe(self):
|
|
43
|
+
for provider in ("claude", "codex"):
|
|
44
|
+
for tier in ("eligible", "soft", "credits"):
|
|
45
|
+
with self.subTest(provider=provider, tier=tier):
|
|
46
|
+
case, watcher, runner, _, _ = scenario(self, provider)
|
|
47
|
+
runner.probe_out = "pick=acct-02 tier=" + tier + "\n"
|
|
48
|
+
if tier == "credits":
|
|
49
|
+
watcher.env[provider.upper() + "_MULTIACC_CREDITS"] = "1"
|
|
50
|
+
self.assertEqual(watcher.run(), 0)
|
|
51
|
+
self.assertEqual(len(runner.probes), 1)
|
|
52
|
+
self.assertEqual(runner.target_probes, [])
|
|
53
|
+
self.assertEqual(len(runner.signals), 1)
|
|
54
|
+
self.assertIn("switch", case.events())
|
|
55
|
+
|
|
56
|
+
def test_nonquota_errors_never_probe_the_other_provider(self):
|
|
57
|
+
cases = (("claude", "auth"), ("claude", "blocked"), ("claude", "model"),
|
|
58
|
+
("claude", "transient"), ("codex", "auth"), ("codex", "transient"))
|
|
59
|
+
for provider, error_class in cases:
|
|
60
|
+
with self.subTest(provider=provider, error_class=error_class):
|
|
61
|
+
case, watcher, runner, _, _ = scenario(self, provider, error_class=error_class)
|
|
62
|
+
self.assertIsNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
63
|
+
self.assertEqual((runner.target_probes, runner.signals, runner.sends), ([], [], []))
|
|
64
|
+
|
|
65
|
+
def test_exhausted_or_missing_target_holds_the_source(self):
|
|
66
|
+
for provider in ("claude", "codex"):
|
|
67
|
+
for reply in ("pick=acct-01 tier=hard", "pick= tier=none", "malformed answer"):
|
|
68
|
+
with self.subTest(provider=provider, reply=reply):
|
|
69
|
+
case, watcher, runner, _, _ = scenario(self, provider)
|
|
70
|
+
runner.target_out = reply + "\n"
|
|
71
|
+
self.assertEqual(watcher.run(), 0)
|
|
72
|
+
self.assertTrue(runner.target_probes)
|
|
73
|
+
self.assertEqual((runner.signals, runner.sends, case.relaunch_files()), ([], [], []))
|
|
74
|
+
self.assertIn("hold", case.events())
|
|
75
|
+
|
|
76
|
+
def test_both_directions_accept_same_named_target_account_without_source_avoid_leak(self):
|
|
77
|
+
for provider in ("claude", "codex"):
|
|
78
|
+
with self.subTest(provider=provider):
|
|
79
|
+
case, watcher, runner, _, target_root = scenario(self, provider)
|
|
80
|
+
target = "codex" if provider == "claude" else "claude"
|
|
81
|
+
avoided = "acct-01:%d" % (case.t0 + 7200)
|
|
82
|
+
plan = cross.plan_fallback(watcher, watcher.tracker.pending, avoided)
|
|
83
|
+
self.assertIsNotNone(plan)
|
|
84
|
+
self.assertEqual((plan["provider"], plan["pick"], plan["root"]),
|
|
85
|
+
(target, "acct-01", str(target_root)))
|
|
86
|
+
self.assertEqual(runner.target_probes[-1]["env"][target.upper() + "_MULTIACC_AR_AVOID"], "")
|
|
87
|
+
fields = cross.chain_fields(watcher, avoided)
|
|
88
|
+
self.assertEqual((fields["avoid_" + provider], fields["avoid_" + target]), (avoided, ""))
|
|
89
|
+
|
|
90
|
+
def test_existing_target_avoidance_survives_independently(self):
|
|
91
|
+
for provider in ("claude", "codex"):
|
|
92
|
+
with self.subTest(provider=provider):
|
|
93
|
+
case, watcher, runner, _, _ = scenario(self, provider)
|
|
94
|
+
target = "codex" if provider == "claude" else "claude"
|
|
95
|
+
target_avoid = "acct-02:%d" % (case.t0 + 3600)
|
|
96
|
+
watcher.st["avoid_" + target] = target_avoid
|
|
97
|
+
plan = cross.plan_fallback(watcher, watcher.tracker.pending, "acct-01:%d" % (case.t0 + 7200))
|
|
98
|
+
self.assertIsNotNone(plan)
|
|
99
|
+
self.assertEqual(plan["avoid"], target_avoid)
|
|
100
|
+
self.assertEqual(runner.target_probes[-1]["env"][target.upper() + "_MULTIACC_AR_AVOID"], target_avoid)
|
|
101
|
+
|
|
102
|
+
def test_user_input_during_either_probe_cancels_before_any_signal(self):
|
|
103
|
+
for provider in ("claude", "codex"):
|
|
104
|
+
for stage in ("source", "target"):
|
|
105
|
+
with self.subTest(provider=provider, stage=stage):
|
|
106
|
+
case, watcher, runner, history, _ = scenario(self, provider)
|
|
107
|
+
setattr(runner, stage + "_hook", lambda: fixtures.append(history, [cancel_record(case, provider)]))
|
|
108
|
+
self.assertEqual(watcher.run(), 0)
|
|
109
|
+
self.assertIsNone(watcher.tracker.pending)
|
|
110
|
+
self.assertEqual((runner.signals, runner.sends, case.relaunch_files()), ([], [], []))
|
|
111
|
+
|
|
112
|
+
def test_rotation_budget_cannot_be_bypassed_by_changing_provider(self):
|
|
113
|
+
for provider in ("claude", "codex"):
|
|
114
|
+
with self.subTest(provider=provider):
|
|
115
|
+
case, watcher, runner, _, _ = scenario(self, provider)
|
|
116
|
+
watcher.st["hist"] = ",".join("quota:%d" % (case.t0 - n) for n in range(8))
|
|
117
|
+
self.assertEqual(watcher.run(), 0)
|
|
118
|
+
self.assertEqual((runner.probes, runner.target_probes, runner.signals), ([], [], []))
|
|
119
|
+
self.assertIn("reason=rotate-budget", case.log_lines()[-1])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
unittest.main()
|