Add MAIN-to-RX acknowledgement channel
This commit is contained in:
489
tests/test_tx_handoff_acknowledgement.py
Normal file
489
tests/test_tx_handoff_acknowledgement.py
Normal file
@@ -0,0 +1,489 @@
|
||||
"""Model-free tests for the MAIN-to-RX acknowledgement channel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.rx_agent_watcher as rx_agent_watcher
|
||||
|
||||
from tools.rf_handoff_channel import (
|
||||
CHANNEL_TEST_TASK_ID,
|
||||
COMPLETE_STATE,
|
||||
GitResultChannel,
|
||||
READY_STATE,
|
||||
ResultBranchMissing,
|
||||
ResultChannelError,
|
||||
ResultHistoryRewritten,
|
||||
RxHandoffResult,
|
||||
result_digest,
|
||||
)
|
||||
from tools.tx_agent_watcher import TxWatcherState, process_history_cycle
|
||||
from tools.tx_handoff_channel import (
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
CHANNEL_READY_OBSERVED,
|
||||
TX_RESULT_PATHS,
|
||||
GitAcknowledgementChannel,
|
||||
TxHandoffAcknowledgement,
|
||||
acknowledgement_from_mapping,
|
||||
channel_test_acknowledgements,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 22, 12, 0, tzinfo=timezone.utc)
|
||||
SOURCE_HEAD = "a" * 40
|
||||
|
||||
|
||||
def _git(arguments: list[str], cwd: Path) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=True,
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def repository_fixture(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
with_tx_node: bool = True,
|
||||
) -> tuple[Path, Path, str | None]:
|
||||
remote = tmp_path / "remote.git"
|
||||
work = tmp_path / "work"
|
||||
_git(["init", "--bare", str(remote)], tmp_path)
|
||||
work.mkdir()
|
||||
_git(["init"], work)
|
||||
_git(["config", "user.name", "Test"], work)
|
||||
_git(["config", "user.email", "test@example.invalid"], work)
|
||||
(work / "README.md").write_text("fixture\n", encoding="utf-8")
|
||||
_git(["add", "README.md"], work)
|
||||
_git(["commit", "-m", "Seed RX result branch"], work)
|
||||
initial_branch = _git(["branch", "--show-current"], work)
|
||||
_git(["remote", "add", "origin", str(remote)], work)
|
||||
_git(["push", "origin", "HEAD:refs/heads/rx-node"], work)
|
||||
tx_root: str | None = None
|
||||
if with_tx_node:
|
||||
_git(["switch", "--orphan", "tx-node"], work)
|
||||
_git(["rm", "-rf", "--ignore-unmatch", "."], work)
|
||||
_git(["commit", "--allow-empty", "-m", "Initialize tx-node"], work)
|
||||
tx_root = _git(["rev-parse", "HEAD"], work)
|
||||
_git(["push", "origin", "HEAD:refs/heads/tx-node"], work)
|
||||
_git(["switch", initial_branch], work)
|
||||
return remote, work, tx_root
|
||||
|
||||
|
||||
def channel_ready(**changes: object) -> RxHandoffResult:
|
||||
result = RxHandoffResult(
|
||||
schema_version=1,
|
||||
task_id=CHANNEL_TEST_TASK_ID,
|
||||
state=READY_STATE,
|
||||
rx_ready_for_tx=True,
|
||||
process_alive=True,
|
||||
file_growing=True,
|
||||
capture_pid=1234,
|
||||
published_at=NOW.isoformat(),
|
||||
capture_started_at=NOW.isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=120)).isoformat(),
|
||||
capture_path="MODEL_FREE_FAKE_CAPTURE",
|
||||
common_clock_verified=False,
|
||||
source_head=SOURCE_HEAD,
|
||||
rf_tx=0,
|
||||
exit_code=None,
|
||||
file_size=None,
|
||||
duration_seconds=None,
|
||||
overruns=None,
|
||||
usb_errors=None,
|
||||
file_errors=None,
|
||||
sha256=None,
|
||||
clkin_after="NOT_RUN",
|
||||
rf_tx_observed="UNKNOWN",
|
||||
)
|
||||
return replace(result, **changes)
|
||||
|
||||
|
||||
def channel_complete(
|
||||
ready: RxHandoffResult,
|
||||
**changes: object,
|
||||
) -> RxHandoffResult:
|
||||
result = replace(
|
||||
ready,
|
||||
state=COMPLETE_STATE,
|
||||
rx_ready_for_tx=False,
|
||||
process_alive=False,
|
||||
file_growing=False,
|
||||
published_at=(NOW + timedelta(seconds=1)).isoformat(),
|
||||
exit_code=0,
|
||||
file_size=4,
|
||||
duration_seconds=1.0,
|
||||
overruns=0,
|
||||
usb_errors=0,
|
||||
file_errors=0,
|
||||
sha256="b" * 64,
|
||||
)
|
||||
return replace(result, **changes)
|
||||
|
||||
|
||||
def acknowledgement(
|
||||
state: str,
|
||||
source_commit: str,
|
||||
source_digest: str,
|
||||
**changes: object,
|
||||
) -> TxHandoffAcknowledgement:
|
||||
result = TxHandoffAcknowledgement(
|
||||
schema_version=1,
|
||||
task_id=CHANNEL_TEST_TASK_ID,
|
||||
state=state,
|
||||
source_rx_commit=source_commit,
|
||||
source_rx_digest=source_digest,
|
||||
source_head=SOURCE_HEAD,
|
||||
observed_at=NOW.isoformat(),
|
||||
main_watcher_pid=4321,
|
||||
rf_tx_authorized=False,
|
||||
rf_tx_started=False,
|
||||
rf_tx=0,
|
||||
)
|
||||
return replace(result, **changes)
|
||||
|
||||
|
||||
class FakeExecutor:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def prepare(self, *_args: object, **_kwargs: object) -> Path:
|
||||
self.calls += 1
|
||||
raise AssertionError("channel test must not prepare RF TX")
|
||||
|
||||
|
||||
def test_acknowledgement_schema_binds_digest_and_source_commit() -> None:
|
||||
valid = acknowledgement(CHANNEL_READY_OBSERVED, "1" * 40, "2" * 64)
|
||||
raw = valid.__dict__.copy()
|
||||
assert acknowledgement_from_mapping(raw) == valid
|
||||
|
||||
with pytest.raises(ValueError, match="source_rx_commit"):
|
||||
acknowledgement_from_mapping({**raw, "source_rx_commit": "1" * 39})
|
||||
with pytest.raises(ValueError, match="source_rx_digest"):
|
||||
acknowledgement_from_mapping({**raw, "source_rx_digest": "2" * 63})
|
||||
with pytest.raises(ValueError, match="cannot claim RF TX"):
|
||||
acknowledgement_from_mapping({**raw, "rf_tx_authorized": True})
|
||||
|
||||
|
||||
def test_main_publish_uses_exact_allowlist_and_suppresses_duplicate(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
remote, work, tx_root = repository_fixture(tmp_path)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
item = acknowledgement(CHANNEL_READY_OBSERVED, "1" * 40, "2" * 64)
|
||||
|
||||
first = channel.publish(item)
|
||||
second = channel.publish(item)
|
||||
|
||||
assert first.status == "PUBLISHED"
|
||||
assert second.status == "UNCHANGED"
|
||||
assert second.commit == first.commit
|
||||
assert tx_root is not None
|
||||
assert _git(
|
||||
["--git-dir", str(remote), "rev-list", "--count", "refs/heads/tx-node"],
|
||||
tmp_path,
|
||||
) == "2"
|
||||
tree = set(
|
||||
_git(
|
||||
[
|
||||
"--git-dir",
|
||||
str(remote),
|
||||
"ls-tree",
|
||||
"-r",
|
||||
"--name-only",
|
||||
"refs/heads/tx-node",
|
||||
],
|
||||
tmp_path,
|
||||
).splitlines()
|
||||
)
|
||||
assert tree == TX_RESULT_PATHS
|
||||
|
||||
|
||||
def test_two_acknowledgements_between_polls_are_both_confirmed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
ready_commit = "1" * 40
|
||||
ready_digest = "2" * 64
|
||||
complete_commit = "3" * 40
|
||||
complete_digest = "4" * 64
|
||||
ready_outcome = channel.publish(
|
||||
acknowledgement(CHANNEL_READY_OBSERVED, ready_commit, ready_digest)
|
||||
)
|
||||
complete_outcome = channel.publish(
|
||||
acknowledgement(
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
complete_commit,
|
||||
complete_digest,
|
||||
observed_at=(NOW + timedelta(seconds=1)).isoformat(),
|
||||
)
|
||||
)
|
||||
|
||||
status = channel_test_acknowledgements(
|
||||
channel,
|
||||
expected_source_head=SOURCE_HEAD,
|
||||
ready_source_commit=ready_commit,
|
||||
ready_source_digest=ready_digest,
|
||||
complete_source_commit=complete_commit,
|
||||
complete_source_digest=complete_digest,
|
||||
)
|
||||
|
||||
assert status.ready_confirmed
|
||||
assert status.complete_confirmed
|
||||
assert status.ready_ack_commit == ready_outcome.commit
|
||||
assert status.complete_ack_commit == complete_outcome.commit
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrong_field", ["digest", "commit"])
|
||||
def test_wrong_binding_does_not_confirm_ready(
|
||||
tmp_path: Path,
|
||||
wrong_field: str,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
expected_commit = "1" * 40
|
||||
expected_digest = "2" * 64
|
||||
channel.publish(
|
||||
acknowledgement(
|
||||
CHANNEL_READY_OBSERVED,
|
||||
"9" * 40 if wrong_field == "commit" else expected_commit,
|
||||
"8" * 64 if wrong_field == "digest" else expected_digest,
|
||||
)
|
||||
)
|
||||
|
||||
status = channel_test_acknowledgements(
|
||||
channel,
|
||||
expected_source_head=SOURCE_HEAD,
|
||||
ready_source_commit=expected_commit,
|
||||
ready_source_digest=expected_digest,
|
||||
complete_source_commit="3" * 40,
|
||||
complete_source_digest="4" * 64,
|
||||
)
|
||||
assert not status.ready_confirmed
|
||||
assert not status.complete_confirmed
|
||||
|
||||
|
||||
def test_stale_acknowledgement_with_old_source_head_is_rejected(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
channel.publish(
|
||||
acknowledgement(
|
||||
CHANNEL_READY_OBSERVED,
|
||||
"1" * 40,
|
||||
"2" * 64,
|
||||
source_head="f" * 40,
|
||||
)
|
||||
)
|
||||
status = channel_test_acknowledgements(
|
||||
channel,
|
||||
expected_source_head=SOURCE_HEAD,
|
||||
ready_source_commit="1" * 40,
|
||||
ready_source_digest="2" * 64,
|
||||
complete_source_commit="3" * 40,
|
||||
complete_source_digest="4" * 64,
|
||||
)
|
||||
assert not status.ready_confirmed
|
||||
|
||||
|
||||
def test_missing_tx_node_is_blocked(tmp_path: Path) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path, with_tx_node=False)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
with pytest.raises(ResultBranchMissing):
|
||||
channel_test_acknowledgements(
|
||||
channel,
|
||||
expected_source_head=SOURCE_HEAD,
|
||||
)
|
||||
|
||||
|
||||
def test_acknowledgement_push_failure_preserves_remote_head(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
|
||||
class FailingChannel(GitAcknowledgementChannel):
|
||||
def _run(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if arguments and arguments[0] == "push":
|
||||
return subprocess.CompletedProcess(
|
||||
["git", *arguments],
|
||||
1,
|
||||
"",
|
||||
"simulated push failure",
|
||||
)
|
||||
return super()._run(arguments, cwd=cwd, check=check)
|
||||
|
||||
channel = FailingChannel(work)
|
||||
before = channel.remote_head()
|
||||
with pytest.raises(ResultChannelError, match="simulated push failure"):
|
||||
channel.publish(
|
||||
acknowledgement(CHANNEL_READY_OBSERVED, "1" * 40, "2" * 64)
|
||||
)
|
||||
assert channel.remote_head() == before
|
||||
|
||||
|
||||
def test_tx_node_history_rewrite_is_rejected(tmp_path: Path) -> None:
|
||||
_remote, work, tx_root = repository_fixture(tmp_path)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
outcome = channel.publish(
|
||||
acknowledgement(CHANNEL_READY_OBSERVED, "1" * 40, "2" * 64)
|
||||
)
|
||||
assert tx_root is not None
|
||||
_git(
|
||||
["push", "--force", "origin", f"{tx_root}:refs/heads/tx-node"],
|
||||
work,
|
||||
)
|
||||
with pytest.raises(ResultHistoryRewritten):
|
||||
channel.commits_since(outcome.commit)
|
||||
|
||||
|
||||
def test_fake_two_way_flow_is_event_safe_and_uses_no_tx(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
rx_channel = GitResultChannel(work)
|
||||
tx_channel = GitAcknowledgementChannel(work)
|
||||
ready = channel_ready()
|
||||
ready_outcome = rx_channel.publish(ready)
|
||||
complete = channel_complete(ready)
|
||||
complete_outcome = rx_channel.publish(complete)
|
||||
state = TxWatcherState()
|
||||
executor = FakeExecutor()
|
||||
|
||||
outcomes = process_history_cycle(
|
||||
rx_channel,
|
||||
state,
|
||||
executor,
|
||||
now_function=lambda: NOW + timedelta(seconds=2),
|
||||
input_function=lambda _prompt: "AUTHORIZE RF TX",
|
||||
save_function=lambda _state: None,
|
||||
output_function=lambda _text: None,
|
||||
expected_source_head=SOURCE_HEAD,
|
||||
acknowledgement_publisher=tx_channel,
|
||||
main_watcher_pid=4321,
|
||||
)
|
||||
|
||||
assert outcomes == [
|
||||
(ready_outcome.commit, "CHANNEL_TEST_READY"),
|
||||
(complete_outcome.commit, "CHANNEL_TEST_COMPLETE"),
|
||||
]
|
||||
confirmations = channel_test_acknowledgements(
|
||||
tx_channel,
|
||||
expected_source_head=SOURCE_HEAD,
|
||||
ready_source_commit=ready_outcome.commit,
|
||||
ready_source_digest=result_digest(ready),
|
||||
complete_source_commit=complete_outcome.commit,
|
||||
complete_source_digest=result_digest(complete),
|
||||
)
|
||||
assert confirmations.ready_confirmed
|
||||
assert confirmations.complete_confirmed
|
||||
assert executor.calls == 0
|
||||
assert state.last_authorized_digest is None
|
||||
|
||||
|
||||
def test_rx_watcher_confirms_both_main_acknowledgements(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
rx_channel = GitResultChannel(work)
|
||||
tx_channel = GitAcknowledgementChannel(work)
|
||||
ready = channel_ready()
|
||||
ready_outcome = rx_channel.publish(ready)
|
||||
complete = channel_complete(ready)
|
||||
complete_outcome = rx_channel.publish(complete)
|
||||
tx_channel.publish(
|
||||
acknowledgement(
|
||||
CHANNEL_READY_OBSERVED,
|
||||
ready_outcome.commit,
|
||||
result_digest(ready),
|
||||
)
|
||||
)
|
||||
tx_channel.publish(
|
||||
acknowledgement(
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
complete_outcome.commit,
|
||||
result_digest(complete),
|
||||
observed_at=(NOW + timedelta(seconds=1)).isoformat(),
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rx_agent_watcher,
|
||||
"CHANNEL_TEST_READY_COMMIT",
|
||||
ready_outcome.commit,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rx_agent_watcher,
|
||||
"CHANNEL_TEST_READY_DIGEST",
|
||||
result_digest(ready),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rx_agent_watcher,
|
||||
"CHANNEL_TEST_COMPLETE_COMMIT",
|
||||
complete_outcome.commit,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rx_agent_watcher,
|
||||
"CHANNEL_TEST_COMPLETE_DIGEST",
|
||||
result_digest(complete),
|
||||
)
|
||||
state = rx_agent_watcher.WatcherState()
|
||||
output: list[str] = []
|
||||
|
||||
rx_agent_watcher.confirm_main_channel_test(
|
||||
work,
|
||||
state,
|
||||
output_function=output.append,
|
||||
)
|
||||
|
||||
assert state.channel_test_ready_digest == result_digest(ready)
|
||||
assert state.channel_test_complete_digest == result_digest(complete)
|
||||
assert state.channel_test_ready_ack_commit is not None
|
||||
assert state.channel_test_complete_ack_commit is not None
|
||||
assert "MAIN READY RECEIVED = CONFIRMED" in output
|
||||
assert "MAIN COMPLETE RECEIVED = CONFIRMED" in output
|
||||
|
||||
|
||||
def test_ack_push_failure_does_not_advance_rx_cursor(tmp_path: Path) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
rx_channel = GitResultChannel(work)
|
||||
ready_outcome = rx_channel.publish(channel_ready())
|
||||
|
||||
class FailingPublisher:
|
||||
def publish(self, acknowledgement: TxHandoffAcknowledgement) -> object:
|
||||
del acknowledgement
|
||||
raise ResultChannelError("simulated acknowledgement push failure")
|
||||
|
||||
state = TxWatcherState()
|
||||
with pytest.raises(ResultChannelError, match="simulated acknowledgement"):
|
||||
process_history_cycle(
|
||||
rx_channel,
|
||||
state,
|
||||
FakeExecutor(),
|
||||
now_function=lambda: NOW,
|
||||
input_function=lambda _prompt: "DENY",
|
||||
save_function=lambda _state: None,
|
||||
output_function=lambda _text: None,
|
||||
expected_source_head=SOURCE_HEAD,
|
||||
acknowledgement_publisher=FailingPublisher(),
|
||||
)
|
||||
assert state.last_processed_rx_node_commit is None
|
||||
assert state.channel_test_ready_digest is None
|
||||
assert ready_outcome.commit != state.last_seen_commit
|
||||
Reference in New Issue
Block a user