fix(python): retry rejected prev_chain_hash within a bounded submit budget; release 2.0.1

Claude-Session: https://claude.ai/code/session_01QoUjdc7HGKTsgj5GhG8HvF
This commit is contained in:
雷电芽衣 committed 2026-09-02 03:56:50 -04:00
1 parent d94989b400
commit 9aaf08bf51
3 files changed
+194 -53

No files matched your search

+119
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
import json
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from threading import Thread
from typing import Any
import pytest
@@ -144,3 +148,118 @@ def test_runtime_failures_are_observable_and_fail_open(
log = (fixture.agent_dir / "error.log").read_text(encoding="utf-8")
assert "invalid JSON" in log
assert "refused" in log.lower() or "urlopen" in log.lower()
class _MismatchApi(BaseHTTPRequestHandler):
submissions: list[dict[str, Any]] = []
rejections = 1
delay = 0.0
expected = "Rxlf4j36C3KvIQ3hWuOkX698BR5iDypUFuB70JjEuvM"
@classmethod
def reset(cls, rejections: int, delay: float = 0.0) -> None:
cls.submissions = []
cls.rejections = rejections
cls.delay = delay
def _respond(self, status: int, value: object) -> None:
body = json.dumps(value).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
self._respond(200, {"agent": {"status": "active"}})
def do_POST(self) -> None:
raw = self.rfile.read(int(self.headers.get("Content-Length", "0")))
cls = type(self)
cls.submissions.append(json.loads(raw))
count = len(cls.submissions)
if cls.delay:
time.sleep(cls.delay)
if count > cls.rejections:
self._respond(202, {"receipt": {"seq_no": count}})
return
expected = cls.expected if cls.rejections == 1 else str(count).rjust(43, "A")
self._respond(400, {"error": {
"code": "PREV_HASH_MISMATCH",
"message": f'Expected prev_chain_hash "{expected}", got "x".',
}})
def log_message(self, _format: str, *_args: object) -> None:
return
def _mismatch_server(rejections: int, delay: float = 0.0) -> ThreadingHTTPServer:
_MismatchApi.reset(rejections, delay)
server = ThreadingHTTPServer(("127.0.0.1", 0), _MismatchApi)
Thread(target=server.serve_forever, daemon=True).start()
return server
def test_audit_retries_rejected_prev_chain_hash_with_server_value(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
server = _mismatch_server(1)
fixture = prepare_fixture(monkeypatch, tmp_path, base_url=server_base_url(server))
try:
fixture.install()
audit = managed_handler(fixture.settings(), "PostToolUse")
result = run_handler(
audit, encoded(official_payload("PostToolUse", tool_response={"stdout": "ok"})), fixture
)
assert result.returncode == 0, result.stderr
submissions = _MismatchApi.submissions
assert len(submissions) == 2
assert submissions[1]["prev_chain_hash"] == _MismatchApi.expected
assert submissions[1]["operation_id"] != submissions[0]["operation_id"]
assert submissions[1]["nonce"] != submissions[0]["nonce"]
state = json.loads((fixture.agent_dir / "chain-state.json").read_text(encoding="utf-8"))
assert state["prev_chain_hash"] == submissions[1]["chain_hash"]
assert "resynced to server: Rxlf" in (fixture.agent_dir / "error.log").read_text(encoding="utf-8")
finally:
server.shutdown()
def test_audit_stops_after_five_rejected_prev_chain_hash_attempts(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
server = _mismatch_server(99)
fixture = prepare_fixture(monkeypatch, tmp_path, base_url=server_base_url(server))
try:
fixture.install()
audit = managed_handler(fixture.settings(), "PostToolUse")
result = run_handler(
audit, encoded(official_payload("PostToolUse", tool_response={"stdout": "ok"})), fixture
)
assert result.returncode == 0, result.stderr
assert len(_MismatchApi.submissions) == 5
assert _MismatchApi.submissions[4]["prev_chain_hash"] == "4".rjust(43, "A")
assert "rejected prev_chain_hash 5 times" in (fixture.agent_dir / "error.log").read_text(encoding="utf-8")
finally:
server.shutdown()
def test_audit_stops_retrying_when_the_submit_budget_is_spent(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
server = _mismatch_server(99, delay=2.6)
fixture = prepare_fixture(monkeypatch, tmp_path, base_url=server_base_url(server))
try:
fixture.install()
audit = managed_handler(fixture.settings(), "PostToolUse")
started = time.monotonic()
result = run_handler(
audit, encoded(official_payload("PostToolUse", tool_response={"stdout": "ok"})), fixture
)
elapsed = time.monotonic() - started
assert result.returncode == 0, result.stderr
assert elapsed < 7.5, elapsed
assert 2 <= len(_MismatchApi.submissions) <= 3
log = (fixture.agent_dir / "error.log").read_text(encoding="utf-8").lower()
assert "timed out" in log or "retry budget exhausted" in log
finally:
server.shutdown()