Fix bidirectional RF-001A coordination

This commit is contained in:
LittleSam129
2026-09-21 18:05:28 +03:00
parent bc55d0516d
commit a516b99015
17 changed files with 1745 additions and 244 deletions

View File

@@ -0,0 +1,87 @@
"""Software-only checks for the durable capture process runner."""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import subprocess
import pytest
ROOT = Path(__file__).resolve().parents[1]
RUNNER = ROOT / "tools" / "capture_process_runner.ps1"
@pytest.mark.parametrize("expected_exit_code", [0, 7])
def test_runner_persists_exact_child_exit_code(
tmp_path: Path,
expected_exit_code: int,
) -> None:
child = tmp_path / f"child_{expected_exit_code}.cmd"
child.write_text(
f"@echo off\r\nexit /b {expected_exit_code}\r\n",
encoding="ascii",
)
capture = tmp_path / "fake_capture.cs8"
capture_bytes = b"software-only-capture-evidence\x00\x01"
capture.write_bytes(capture_bytes)
arguments_path = tmp_path / "arguments.json"
arguments_path.write_text(
json.dumps(["/d", "/c", str(child)]),
encoding="utf-8",
)
stdout_path = tmp_path / "child.stdout.txt"
stderr_path = tmp_path / "child.stderr.txt"
started_path = tmp_path / "capture_started.json"
result_path = tmp_path / "capture_result.json"
completed = subprocess.run(
[
"powershell.exe",
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
str(RUNNER),
"-Executable",
str(Path(os.environ["COMSPEC"])),
"-ArgumentsJsonPath",
str(arguments_path),
"-CapturePath",
str(capture),
"-StdoutPath",
str(stdout_path),
"-StderrPath",
str(stderr_path),
"-StartedStatusPath",
str(started_path),
"-ResultStatusPath",
str(result_path),
],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
assert completed.returncode == expected_exit_code
started = json.loads(started_path.read_text(encoding="utf-8-sig"))
result = json.loads(result_path.read_text(encoding="utf-8-sig"))
assert started["state"] == "RUNNING"
assert isinstance(started["pid"], int) and started["pid"] > 0
assert result["pid"] == started["pid"]
assert result["started_at"] == started["started_at"]
assert result["finished_at"]
assert result["duration"] >= 0
assert result["exit_code"] == expected_exit_code
assert result["capture_path"] == str(capture)
assert result["capture_size"] == len(capture_bytes)
assert result["sha256"] == hashlib.sha256(capture_bytes).hexdigest()
assert result["runner_error"] is None

View File

@@ -0,0 +1,49 @@
"""Checks for the tracked RF-001A decoder truth artifact."""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SPEC_PATH = ROOT / "configs" / "rf" / "rf001a_common_clock.json"
TRUTH_PATH = ROOT / "configs" / "rf" / "rf001a_truth_metadata.json"
def test_truth_metadata_is_small_complete_and_spec_aligned() -> None:
spec = json.loads(SPEC_PATH.read_text(encoding="utf-8"))
truth = json.loads(TRUTH_PATH.read_text(encoding="utf-8"))
assert TRUTH_PATH.stat().st_size < 64 * 1024
assert truth["shared_spec_test_id"] == spec["test_id"]
assert truth["test_id"] == spec["legacy_waveform_test_id"]
assert truth["seed"] == spec["seed"]
assert truth["packet_count"] == spec["packet_count"] == 100
assert truth["payload_size"] == spec["application_payload_bytes"] == 256
assert truth["application_payload_bytes"] == truth["payload_size"]
assert truth["expected_sequence_numbers"] == list(range(100))
assert len(truth["expected_packet_hashes"]) == 100
assert [
row["sequence_number"] for row in truth["expected_packet_hashes"]
] == list(range(100))
assert all(
len(row["payload_sha256"]) == 64 and len(row["packet_sha256"]) == 64
for row in truth["expected_packet_hashes"]
)
assert truth["tx_waveform_sha256"] == spec["expected_hardware_sha256"]
assert truth["iq_sha256"] == spec["expected_canonical_sha256"]
assert truth["modulation"] == spec["modulation"]
assert truth["symbol_rate"] == spec["symbol_rate_sps"]
assert truth["sample_rate"] == spec["sample_rate_hz"]
assert truth["fec"] == spec["inner_fec"]
assert truth["fec_rate"] == truth["fec"]
assert truth["gap_ms"] == spec["inter_packet_gap_ms"]
assert truth["inter_packet_gap_ms"] == truth["gap_ms"]
assert (
truth["digital_carrier_offset_hz"]
== spec["digital_carrier_offset_hz"]
)
assert truth["tx_waveform_format"] == spec["hardware_format"]
assert "waveform" not in truth
assert "raw_iq" not in truth

View File

@@ -11,6 +11,7 @@ import subprocess
import pytest
from tools.rf_handoff_channel import (
CHANNEL_TEST_TASK_ID,
COMPLETE_STATE,
EXPECTED_RX_TASK_ID,
GitResultChannel,
@@ -18,18 +19,26 @@ from tools.rf_handoff_channel import (
RESULT_JSON_PATH,
RESULT_MARKDOWN_PATH,
ResultBranchMissing,
ResultChannelError,
RxHandoffResult,
canonical_result_json,
evaluate_ready,
result_digest,
result_from_mapping,
)
from tools.rf_handoff_smoke import run_smoke
from tools.rx_agent_watcher import (
WatcherState as RxWatcherState,
publish_result,
save_state as save_rx_state,
)
from tools.tx_agent_watcher import TxWatcherState, handle_result
from tools.tx_agent_watcher import (
TxWatcherState,
handle_result,
load_state as load_tx_state,
request_stop,
save_state as save_tx_state,
)
NOW = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
@@ -65,6 +74,40 @@ def ready_result(**changes: object) -> RxHandoffResult:
return replace(result, **changes)
def channel_ready_result(**changes: object) -> RxHandoffResult:
return ready_result(
task_id=CHANNEL_TEST_TASK_ID,
capture_path="MODEL_FREE_FAKE_CAPTURE",
common_clock_verified=False,
**changes,
)
def complete_result(
result: RxHandoffResult | None = None,
**changes: object,
) -> RxHandoffResult:
source = result or ready_result()
complete = replace(
source,
state=COMPLETE_STATE,
rx_ready_for_tx=False,
process_alive=False,
file_growing=False,
published_at=(NOW + timedelta(seconds=121)).isoformat(),
exit_code=0,
file_size=1_920_000_000,
duration_seconds=120.01,
overruns=0,
usb_errors=0,
file_errors=0,
sha256="b" * 64,
clkin_after="DETECTED" if source.common_clock_verified else "NOT_RUN",
rf_tx_observed="UNKNOWN",
)
return replace(complete, **changes)
def _git(arguments: list[str], cwd: Path) -> str:
completed = subprocess.run(
["git", *arguments],
@@ -106,6 +149,7 @@ class FakeSource:
def fetch(self) -> None:
self.fetch_count += 1
return None
def read(self) -> tuple[RxHandoffResult, str, str]:
return self.result, self.digest, self.head
@@ -136,7 +180,7 @@ class FakeExecutor:
return self.path
def test_result_parsing_rejects_extra_and_inconsistent_fields() -> None:
def test_result_parsing_rejects_extra_types_and_inconsistent_fields() -> None:
raw = asdict(ready_result())
assert result_from_mapping(raw) == ready_result()
@@ -157,35 +201,20 @@ def test_result_parsing_rejects_extra_and_inconsistent_fields() -> None:
incomplete = dict(raw)
incomplete["file_growing"] = False
with pytest.raises(ValueError, match="live capture"):
with pytest.raises(ValueError, match="live process"):
result_from_mapping(incomplete)
def test_complete_result_requires_final_evidence() -> None:
final = ready_result(
state=COMPLETE_STATE,
rx_ready_for_tx=False,
process_alive=False,
file_growing=False,
published_at=(NOW + timedelta(seconds=121)).isoformat(),
exit_code=0,
file_size=1_920_000_000,
duration_seconds=120.01,
overruns=0,
usb_errors=0,
file_errors=0,
sha256="b" * 64,
clkin_after="DETECTED",
rf_tx_observed="EXPECTED_EXTERNAL_TX",
)
def test_complete_result_accepts_explicit_unreported_counters() -> None:
final = complete_result()
assert result_from_mapping(asdict(final)) == final
unreported_counters = replace(
unreported = replace(
final,
overruns=None,
usb_errors=None,
file_errors=None,
)
assert result_from_mapping(asdict(unreported_counters)) == unreported_counters
assert result_from_mapping(asdict(unreported)) == unreported
@pytest.mark.parametrize(
@@ -213,41 +242,37 @@ def test_complete_result_requires_final_evidence() -> None:
NOW,
"CAPTURE_WINDOW_EXPIRED",
),
(ready_result(task_id="WRONG_TASK_001"), NOW, "WRONG_TASK_ID"),
(
ready_result(task_id="WRONG_TASK_001"),
NOW,
"WRONG_TASK_ID",
),
(
ready_result(
state=COMPLETE_STATE,
rx_ready_for_tx=False,
process_alive=False,
file_growing=False,
exit_code=0,
file_size=1_920_000_000,
duration_seconds=120.0,
overruns=0,
usb_errors=0,
file_errors=0,
sha256="c" * 64,
clkin_after="DETECTED",
),
complete_result(),
NOW,
"MISSING_READY",
),
(
ready_result(source_head="c" * 40),
NOW,
"WRONG_SOURCE_HEAD",
),
],
)
def test_freshness_and_window_rejections(
def test_freshness_window_task_and_source_rejections(
result: RxHandoffResult,
now: datetime,
reason: str,
) -> None:
assert evaluate_ready(result, now=now).reason == reason
assert evaluate_ready(
result,
now=now,
expected_source_head=HEAD,
).reason == reason
def test_ready_evaluation_passes_with_more_than_ten_seconds() -> None:
evaluation = evaluate_ready(ready_result(), now=NOW + timedelta(seconds=2))
evaluation = evaluate_ready(
ready_result(),
now=NOW + timedelta(seconds=2),
expected_source_head=HEAD,
)
assert evaluation.ready
assert evaluation.reason == "READY"
assert evaluation.seconds_remaining == pytest.approx(118.0)
@@ -271,6 +296,7 @@ def test_red_approval_and_duplicate_suppression(tmp_path: Path) -> None:
input_function=lambda _prompt: "DENY",
output_function=output.append,
dry_run=True,
expected_source_head=HEAD,
)
assert status == "RED_APPROVAL_BLOCKED"
assert executor.calls == 0
@@ -287,12 +313,13 @@ def test_red_approval_and_duplicate_suppression(tmp_path: Path) -> None:
input_function=lambda _prompt: "AUTHORIZE RF TX",
output_function=output.append,
dry_run=True,
expected_source_head=HEAD,
)
assert status == "DUPLICATE_SUPPRESSED"
assert executor.calls == 0
def test_window_is_rechecked_after_exact_approval(tmp_path: Path) -> None:
def test_window_and_commit_are_rechecked_after_exact_approval(tmp_path: Path) -> None:
result = ready_result()
digest = result_digest(result)
source = FakeSource(result, digest)
@@ -310,57 +337,134 @@ def test_window_is_rechecked_after_exact_approval(tmp_path: Path) -> None:
input_function=lambda _prompt: "AUTHORIZE RF TX",
output_function=lambda _text: None,
dry_run=True,
expected_source_head=HEAD,
max_age_seconds=200.0,
)
assert status == "CAPTURE_WINDOW_EXPIRED"
assert source.fetch_count == 1
assert executor.calls == 0
def test_fake_end_to_end_publish_detect_authorize_without_rf(
tmp_path: Path,
) -> None:
_remote, work = git_fixture(tmp_path)
result = ready_result()
publisher = GitResultChannel(work)
outcome = publisher.publish(result)
assert outcome.status == "PUBLISHED"
publisher.fetch()
branch_files = set(
_git(["ls-tree", "-r", "--name-only", "origin/rx-node"], work).splitlines()
)
assert RESULT_JSON_PATH in branch_files
assert RESULT_MARKDOWN_PATH in branch_files
assert not any(path.endswith((".cs8", ".iq", ".mp4")) for path in branch_files)
source = GitResultChannel(work)
source.fetch()
observed, digest, head = source.read()
assert observed == result
assert head == outcome.commit
executor = FakeExecutor(tmp_path / "fake_tx.txt")
output: list[str] = []
status, state = handle_result(
observed,
source = FakeSource(result, digest, head="d" * 40)
status, _ = handle_result(
result,
digest,
head,
HEAD,
TxWatcherState(),
source,
executor,
now_function=lambda: NOW + timedelta(seconds=2),
input_function=lambda _prompt: "AUTHORIZE RF TX",
output_function=output.append,
output_function=lambda _text: None,
dry_run=True,
expected_source_head=HEAD,
)
assert status == "TX_AUTHORIZATION_PREPARED"
assert state.last_authorized_digest == digest
assert executor.calls == 1
assert (tmp_path / "fake_tx.txt").read_text(encoding="utf-8") == (
"FAKE TX ONLY\nRF TX = 0\n"
assert status == "RESULT_CHANGED"
assert executor.calls == 0
def test_stop_request_clears_stale_watcher_pid(tmp_path: Path) -> None:
state_path = tmp_path / "watcher_state.json"
save_tx_state(state_path, TxWatcherState(running=True, pid=2_000_000_000))
assert request_stop(state_path, timeout_s=1.0)
state = load_tx_state(state_path)
assert not state.running
assert state.pid is None
assert not state_path.with_suffix(".json.stop").exists()
def test_real_origin_detection_and_worktree_publication(tmp_path: Path) -> None:
remote, work = git_fixture(tmp_path)
branch_before = _git(["branch", "--show-current"], work)
channel = GitResultChannel(work)
assert channel.branch_exists()
remote_before = channel.fetch()
outcome = channel.publish(ready_result())
assert outcome.status == "PUBLISHED"
assert outcome.commit == outcome.remote_head
assert outcome.commit != remote_before
assert _git(["branch", "--show-current"], work) == branch_before
assert not (work / RESULT_JSON_PATH).exists()
assert not (work / RESULT_MARKDOWN_PATH).exists()
remote_head = _git(
["--git-dir", str(remote), "rev-parse", "refs/heads/rx-node"],
tmp_path,
)
assert any(line == "RF TX = 0" for line in output)
assert remote_head == outcome.commit
worktrees = _git(["worktree", "list", "--porcelain"], work)
assert worktrees.count("worktree ") == 1
def test_ready_then_complete_reaches_main_watcher_without_red_prompt(
tmp_path: Path,
) -> None:
_remote, work = git_fixture(tmp_path)
channel = GitResultChannel(work)
ready = channel_ready_result()
ready_outcome = channel.publish(ready)
observed_ready, ready_digest, ready_head = channel.read()
prompts: list[str] = []
status, state = handle_result(
observed_ready,
ready_digest,
ready_head,
TxWatcherState(),
channel,
FakeExecutor(tmp_path / "forbidden.txt"),
now_function=lambda: NOW,
input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX",
output_function=lambda _text: None,
expected_source_head=HEAD,
)
assert status == "CHANNEL_TEST_READY"
assert state.channel_test_ready_digest == ready_digest
assert prompts == []
complete = complete_result(
ready,
published_at=(NOW + timedelta(seconds=1)).isoformat(),
file_size=4,
duration_seconds=1.0,
sha256="d" * 64,
)
complete_outcome = channel.publish(complete)
observed_complete, complete_digest, complete_head = channel.read()
status, state = handle_result(
observed_complete,
complete_digest,
complete_head,
state,
channel,
FakeExecutor(tmp_path / "forbidden.txt"),
now_function=lambda: NOW + timedelta(seconds=1),
input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX",
output_function=lambda _text: None,
expected_source_head=HEAD,
)
assert status == "CHANNEL_TEST_COMPLETE"
assert state.channel_test_complete_digest == complete_digest
assert ready_outcome.remote_head != complete_outcome.remote_head
assert prompts == []
def test_smoke_utility_leaves_complete_not_ready(tmp_path: Path) -> None:
_remote, work = git_fixture(tmp_path)
ready, complete, expected = run_smoke(
work,
source_head=HEAD,
hold_seconds=0,
sleep_function=lambda _seconds: None,
)
assert ready.status == "PUBLISHED"
assert complete.status == "PUBLISHED"
assert ready.remote_head != complete.remote_head
observed, _digest, head = GitResultChannel(work).read()
assert observed == expected
assert observed.state == COMPLETE_STATE
assert not observed.rx_ready_for_tx
assert head == complete.remote_head
def test_missing_result_branch_is_blocked(tmp_path: Path) -> None:
@@ -371,11 +475,14 @@ def test_missing_result_branch_is_blocked(tmp_path: Path) -> None:
_git(["init"], work)
_git(["remote", "add", "origin", str(remote)], work)
channel = GitResultChannel(work)
assert not channel.branch_exists()
with pytest.raises(ResultBranchMissing):
channel.publish(ready_result())
def test_push_failure_is_reported(tmp_path: Path) -> None:
def test_push_failure_is_reported_and_remote_head_is_unchanged(
tmp_path: Path,
) -> None:
_remote, work = git_fixture(tmp_path)
class FailingPushChannel(GitResultChannel):
@@ -387,17 +494,23 @@ def test_push_failure_is_reported(tmp_path: Path) -> None:
check: bool = True,
) -> subprocess.CompletedProcess[str]:
if arguments and arguments[0] == "push":
raise subprocess.CalledProcessError(
1,
return subprocess.CompletedProcess(
["git", *arguments],
stderr="simulated push failure",
1,
"",
"simulated push failure",
)
return super()._run(arguments, cwd=cwd, check=check)
with pytest.raises(subprocess.CalledProcessError, match="git"):
FailingPushChannel(work).publish(ready_result())
channel = FailingPushChannel(work)
before = channel.remote_head()
with pytest.raises(ResultChannelError, match="simulated push failure"):
channel.publish(ready_result())
assert channel.remote_head() == before
assert _git(["worktree", "list", "--porcelain"], work).count("worktree ") == 1
def test_rx_watcher_publishes_ready_only_after_local_task_approval(
def test_rx_watcher_publishes_ready_after_local_task_approval(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
@@ -453,8 +566,8 @@ Fake task used only by the local coordination test.
assert publish_result(arguments) == 0
output = capsys.readouterr().out
assert "RX RESULT PUBLISH = PUBLISHED" in output
assert "RX RESULT REMOTE HEAD" in output
assert "RF TX = 0" in output
channel = GitResultChannel(work)
published, _digest, _head = channel.read()
published, _digest, _head = GitResultChannel(work).read()
assert published == ready_result(source_head=source_head)