Add bidirectional RF handoff coordination

This commit is contained in:
LittleSam129
2026-09-21 17:02:49 +03:00
parent 34f5ace545
commit bc55d0516d
14 changed files with 1978 additions and 556 deletions

444
tools/rf_handoff_channel.py Normal file
View File

@@ -0,0 +1,444 @@
"""Strict Git result channel for coordinated RX and MAIN PC handoff."""
from __future__ import annotations
from dataclasses import asdict, dataclass, fields
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import re
import subprocess
import tempfile
from typing import Any, Mapping
SCHEMA_VERSION = 1
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_002"
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_002"
READY_STATE = "RX_READY_FOR_TX"
COMPLETE_STATE = "CAPTURE_COMPLETE"
RESULT_JSON_PATH = "docs/handoff/results/RX_RESULT.json"
RESULT_MARKDOWN_PATH = "docs/handoff/results/RX_RESULT.md"
RESULT_PATHS = frozenset((RESULT_JSON_PATH, RESULT_MARKDOWN_PATH))
MAX_RESULT_BYTES = 64 * 1024
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
_HEAD_RE = re.compile(r"^[0-9a-f]{40}$")
class ResultChannelError(RuntimeError):
"""The result channel cannot be read or updated safely."""
class ResultBranchMissing(ResultChannelError):
"""The configured result branch does not exist on the remote."""
@dataclass(frozen=True)
class RxHandoffResult:
schema_version: int
task_id: str
state: str
rx_ready_for_tx: bool
process_alive: bool
file_growing: bool
capture_pid: int | None
published_at: str
capture_started_at: str
expected_capture_end: str
capture_path: str
common_clock_verified: bool
source_head: str
rf_tx: int
exit_code: int | None
file_size: int | None
duration_seconds: float | None
overruns: int | None
usb_errors: int | None
file_errors: int | None
sha256: str | None
clkin_after: str
rf_tx_observed: str
@dataclass(frozen=True)
class ReadyEvaluation:
ready: bool
reason: str
seconds_remaining: float | None = None
@dataclass(frozen=True)
class PublishOutcome:
status: str
commit: str
json_path: str = RESULT_JSON_PATH
markdown_path: str = RESULT_MARKDOWN_PATH
def _parse_utc(value: str, field_name: str) -> datetime:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except (TypeError, ValueError) as error:
raise ValueError(f"{field_name} must be an ISO-8601 timestamp") from error
if parsed.tzinfo is None:
raise ValueError(f"{field_name} must contain a timezone")
return parsed.astimezone(timezone.utc)
def _validate_types(result: RxHandoffResult) -> None:
string_fields = (
result.task_id,
result.state,
result.published_at,
result.capture_started_at,
result.expected_capture_end,
result.capture_path,
result.source_head,
result.clkin_after,
result.rf_tx_observed,
)
if any(type(value) is not str for value in string_fields):
raise ValueError("string result fields must contain strings")
boolean_fields = (
result.rx_ready_for_tx,
result.process_alive,
result.file_growing,
result.common_clock_verified,
)
if any(type(value) is not bool for value in boolean_fields):
raise ValueError("boolean result fields must contain booleans")
if type(result.schema_version) is not int or type(result.rf_tx) is not int:
raise ValueError("schema_version and rf_tx must contain integers")
optional_integer_fields = (
result.capture_pid,
result.exit_code,
result.file_size,
result.overruns,
result.usb_errors,
result.file_errors,
)
if any(
value is not None and type(value) is not int
for value in optional_integer_fields
):
raise ValueError("integer result fields must contain integers or null")
if (
result.duration_seconds is not None
and (
isinstance(result.duration_seconds, bool)
or not isinstance(result.duration_seconds, (int, float))
)
):
raise ValueError("duration_seconds must contain a number or null")
if result.sha256 is not None and type(result.sha256) is not str:
raise ValueError("sha256 must contain a string or null")
def _validate_common(result: RxHandoffResult) -> None:
_validate_types(result)
if result.schema_version != SCHEMA_VERSION:
raise ValueError(f"schema_version must be {SCHEMA_VERSION}")
if result.state not in {READY_STATE, COMPLETE_STATE}:
raise ValueError("state is not supported")
if result.rf_tx != 0:
raise ValueError("rf_tx must remain 0 on the RX node")
if not result.task_id or any(character in result.task_id for character in "\r\n"):
raise ValueError("task_id is invalid")
if not _HEAD_RE.fullmatch(result.source_head):
raise ValueError("source_head must be a lowercase 40-character Git hash")
if not result.capture_path or any(
character in result.capture_path for character in "\r\n"
):
raise ValueError("capture_path is invalid")
published = _parse_utc(result.published_at, "published_at")
started = _parse_utc(result.capture_started_at, "capture_started_at")
expected_end = _parse_utc(result.expected_capture_end, "expected_capture_end")
if published < started:
raise ValueError("published_at cannot precede capture_started_at")
if expected_end <= started:
raise ValueError("expected_capture_end must follow capture_started_at")
if result.clkin_after not in {"DETECTED", "NOT_DETECTED", "NOT_RUN"}:
raise ValueError("clkin_after is invalid")
if result.rf_tx_observed not in {
"EXPECTED_EXTERNAL_TX",
"UNKNOWN",
}:
raise ValueError("rf_tx_observed is invalid")
def _validate_ready(result: RxHandoffResult) -> None:
if not (
result.rx_ready_for_tx
and result.process_alive
and result.file_growing
and result.common_clock_verified
):
raise ValueError("READY requires a live capture and verified common clock")
if result.capture_pid is None or result.capture_pid <= 0:
raise ValueError("READY requires a positive capture_pid")
final_fields = (
result.exit_code,
result.file_size,
result.duration_seconds,
result.overruns,
result.usb_errors,
result.file_errors,
result.sha256,
)
if any(value is not None for value in final_fields):
raise ValueError("READY must not contain final capture evidence")
def _validate_complete(result: RxHandoffResult) -> None:
if result.rx_ready_for_tx or result.process_alive or result.file_growing:
raise ValueError("COMPLETE cannot advertise a live capture")
if result.capture_pid is None or result.capture_pid <= 0:
raise ValueError("COMPLETE requires the capture_pid")
if result.exit_code is None or result.file_size is None:
raise ValueError("COMPLETE requires exit_code and file_size")
if result.file_size < 0:
raise ValueError("file_size cannot be negative")
counters = (result.overruns, result.usb_errors, result.file_errors)
if any(value is not None and value < 0 for value in counters):
raise ValueError("error counters cannot be negative")
if result.duration_seconds is None or result.duration_seconds < 0:
raise ValueError("COMPLETE requires a non-negative duration_seconds")
if result.sha256 is None or not _SHA256_RE.fullmatch(result.sha256):
raise ValueError("COMPLETE requires a lowercase SHA256")
def validate_result(result: RxHandoffResult) -> RxHandoffResult:
_validate_common(result)
if result.state == READY_STATE:
_validate_ready(result)
else:
_validate_complete(result)
return result
def result_from_mapping(raw: Mapping[str, Any]) -> RxHandoffResult:
expected_fields = {field.name for field in fields(RxHandoffResult)}
actual_fields = set(raw)
if actual_fields != expected_fields:
missing = sorted(expected_fields - actual_fields)
extra = sorted(actual_fields - expected_fields)
raise ValueError(f"result fields disagree: missing={missing}, extra={extra}")
result = RxHandoffResult(**{name: raw[name] for name in expected_fields})
return validate_result(result)
def load_result(path: Path) -> RxHandoffResult:
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError("result JSON root must be an object")
return result_from_mapping(raw)
def canonical_result_json(result: RxHandoffResult) -> str:
validate_result(result)
return json.dumps(
asdict(result),
indent=2,
sort_keys=True,
ensure_ascii=False,
) + "\n"
def result_digest(result: RxHandoffResult) -> str:
return hashlib.sha256(canonical_result_json(result).encode("utf-8")).hexdigest()
def render_result_markdown(result: RxHandoffResult) -> str:
validate_result(result)
rows = [
"# RX result",
"",
f"SCHEMA_VERSION = {result.schema_version}",
f"TASK_ID = {result.task_id}",
f"STATE = {result.state}",
f"RX_READY_FOR_TX = {'YES' if result.rx_ready_for_tx else 'NO'}",
f"PROCESS_ALIVE = {'YES' if result.process_alive else 'NO'}",
f"FILE_GROWING = {'YES' if result.file_growing else 'NO'}",
f"CAPTURE_PID = {result.capture_pid}",
f"PUBLISHED_AT = {result.published_at}",
f"CAPTURE_STARTED_AT = {result.capture_started_at}",
f"EXPECTED_CAPTURE_END = {result.expected_capture_end}",
f"CAPTURE_PATH = {result.capture_path}",
f"COMMON_CLOCK_VERIFIED = {'YES' if result.common_clock_verified else 'NO'}",
f"SOURCE_HEAD = {result.source_head}",
f"EXIT_CODE = {result.exit_code}",
f"FILE_SIZE = {result.file_size}",
f"DURATION_SECONDS = {result.duration_seconds}",
f"OVERRUNS = {result.overruns}",
f"USB_ERRORS = {result.usb_errors}",
f"FILE_ERRORS = {result.file_errors}",
f"SHA256 = {result.sha256}",
f"CLKIN_AFTER = {result.clkin_after}",
f"RF_TX_OBSERVED = {result.rf_tx_observed}",
f"RF_TX = {result.rf_tx}",
"",
"Raw IQ, video, waveforms, and large logs are not stored in this branch.",
"",
]
return "\n".join(rows)
def evaluate_ready(
result: RxHandoffResult,
*,
now: datetime,
expected_task_id: str = EXPECTED_RX_TASK_ID,
max_age_seconds: float = 30.0,
min_remaining_seconds: float = 10.0,
) -> ReadyEvaluation:
validate_result(result)
now_utc = now.astimezone(timezone.utc)
if result.task_id != expected_task_id:
return ReadyEvaluation(False, "WRONG_TASK_ID")
if result.state != READY_STATE or not result.rx_ready_for_tx:
return ReadyEvaluation(False, "MISSING_READY")
started = _parse_utc(result.capture_started_at, "capture_started_at")
expected_end = _parse_utc(result.expected_capture_end, "expected_capture_end")
published = _parse_utc(result.published_at, "published_at")
planned_duration = (expected_end - started).total_seconds()
if not 119.0 <= planned_duration <= 121.0:
return ReadyEvaluation(False, "WRONG_CAPTURE_WINDOW")
age = (now_utc - published).total_seconds()
if age < -5.0:
return ReadyEvaluation(False, "FUTURE_READY")
if age > max_age_seconds:
return ReadyEvaluation(False, "STALE_READY")
remaining = (expected_end - now_utc).total_seconds()
if remaining < min_remaining_seconds:
return ReadyEvaluation(False, "CAPTURE_WINDOW_EXPIRED", remaining)
return ReadyEvaluation(True, "READY", remaining)
class GitResultChannel:
"""Read and publish the two allowlisted result files on an existing branch."""
def __init__(
self,
repository: Path,
*,
remote: str = "origin",
branch: str = "rx-node",
) -> None:
self.repository = repository
self.remote = remote
self.branch = branch
def _run(
self,
arguments: list[str],
*,
cwd: Path | None = None,
check: bool = True,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *arguments],
cwd=cwd or self.repository,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=check,
)
def branch_exists(self) -> bool:
result = self._run(
["ls-remote", "--exit-code", "--heads", self.remote, self.branch],
check=False,
)
return result.returncode == 0 and bool(result.stdout.strip())
def _require_branch(self) -> None:
if not self.branch_exists():
raise ResultBranchMissing(
f"remote branch {self.remote}/{self.branch} does not exist; "
"separate authorization is required before creating it"
)
def fetch(self) -> None:
self._require_branch()
self._run(
[
"fetch",
"--quiet",
self.remote,
f"refs/heads/{self.branch}:refs/remotes/{self.remote}/{self.branch}",
]
)
def read(self) -> tuple[RxHandoffResult, str, str]:
self.fetch()
reference = f"refs/remotes/{self.remote}/{self.branch}"
json_result = self._run(["show", f"{reference}:{RESULT_JSON_PATH}"])
raw = json.loads(json_result.stdout)
if not isinstance(raw, dict):
raise ResultChannelError("remote result JSON root is not an object")
result = result_from_mapping(raw)
head = self._run(["rev-parse", reference]).stdout.strip()
return result, result_digest(result), head
def publish(self, result: RxHandoffResult) -> PublishOutcome:
validate_result(result)
self._require_branch()
remote_url = self._run(["remote", "get-url", self.remote]).stdout.strip()
with tempfile.TemporaryDirectory(prefix="rx-result-publish-") as directory:
checkout = Path(directory) / "result-branch"
self._run(
[
"clone",
"--quiet",
"--single-branch",
"--branch",
self.branch,
remote_url,
str(checkout),
]
)
json_path = checkout / RESULT_JSON_PATH
markdown_path = checkout / RESULT_MARKDOWN_PATH
json_path.parent.mkdir(parents=True, exist_ok=True)
json_text = canonical_result_json(result)
markdown_text = render_result_markdown(result)
if len(json_text.encode("utf-8")) > MAX_RESULT_BYTES:
raise ResultChannelError("result JSON exceeds the size limit")
if len(markdown_text.encode("utf-8")) > MAX_RESULT_BYTES:
raise ResultChannelError("result Markdown exceeds the size limit")
json_path.write_text(json_text, encoding="utf-8", newline="\n")
markdown_path.write_text(markdown_text, encoding="utf-8", newline="\n")
self._run(["add", "--", *sorted(RESULT_PATHS)], cwd=checkout)
staged_text = self._run(
["diff", "--cached", "--name-only", "--"],
cwd=checkout,
).stdout
staged = {
line.strip().replace("\\", "/")
for line in staged_text.splitlines()
if line.strip()
}
if not staged:
commit = self._run(["rev-parse", "HEAD"], cwd=checkout).stdout.strip()
return PublishOutcome("UNCHANGED", commit)
if staged != RESULT_PATHS:
raise ResultChannelError(
f"refusing unexpected staged paths: {sorted(staged)}"
)
self._run(["config", "user.name", "RX Node"], cwd=checkout)
self._run(
["config", "user.email", "rx-node@local.invalid"],
cwd=checkout,
)
self._run(
["commit", "--quiet", "-m", f"Publish {result.task_id} {result.state}"],
cwd=checkout,
)
commit = self._run(["rev-parse", "HEAD"], cwd=checkout).stdout.strip()
self._run(
["push", "--quiet", "origin", f"HEAD:refs/heads/{self.branch}"],
cwd=checkout,
)
return PublishOutcome("PUBLISHED", commit)

View File

@@ -17,6 +17,12 @@ from typing import Callable, Protocol
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from tools.rf_handoff_channel import GitResultChannel, load_result # noqa: E402
DEFAULT_TASK_PATH = "docs/handoff/NEXT_RX_TASK.md"
DEFAULT_STATE_PATH = ROOT / ".local/rx_agent_state.json"
DEFAULT_OUTBOX = ROOT / ".local/rx_agent_outbox"
@@ -71,6 +77,7 @@ class WatcherState:
last_seen_task_id: str | None = None
last_completed_task_id: str | None = None
last_approved_task_id: str | None = None
deferred_task_id: str | None = None
running: bool = False
pid: int | None = None
@@ -404,6 +411,7 @@ def handle_task(
if decision != ApprovalDecision.APPROVE:
return "APPROVAL_BLOCKED", state
state.deferred_task_id = None
state.last_approved_task_id = task.task_id
paths = executor.prepare(task, task_text, source_head, dry_run=dry_run)
output_function(f"TASK PREPARED = {paths[0]}")
return "PREPARED", state
@@ -476,8 +484,58 @@ def mark_completed(state_path: Path, task_id: str) -> None:
save_state(state_path, state)
def publish_result(arguments: argparse.Namespace) -> int:
"""Publish only the approved small RX result to an existing branch."""
repository = arguments.repository.resolve()
task = parse_task((repository / DEFAULT_TASK_PATH).read_text(encoding="utf-8-sig"))
state = load_state(arguments.state_file.resolve())
result = load_result(arguments.publish_result.resolve())
if task.task_id != result.task_id:
raise ValueError("result TASK_ID disagrees with current RX task")
if task.allow_push != "RX_RESULT_ONLY" or task.rf_tx:
raise ValueError("current task does not allow RX result publication")
if state.last_approved_task_id != result.task_id:
raise ValueError("current RX task has not passed the local approval gate")
def git_output(arguments: list[str]) -> str:
return subprocess.run(
["git", *arguments],
cwd=repository,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=True,
).stdout.strip()
branch = git_output(["branch", "--show-current"])
if branch != task.source_branch:
raise ValueError("current branch disagrees with task SOURCE_BRANCH")
if git_output(["status", "--porcelain", "--untracked-files=no"]):
raise ValueError("tracked worktree must be clean before result publication")
head = git_output(["rev-parse", "HEAD"])
remote_head = git_output(["rev-parse", f"{arguments.remote}/{task.source_branch}"])
if head != remote_head:
raise ValueError("current HEAD disagrees with the remote task branch")
if result.source_head != head:
raise ValueError("result SOURCE_HEAD disagrees with current checkout")
channel = GitResultChannel(
repository,
remote=arguments.remote,
branch=arguments.result_branch,
)
outcome = channel.publish(result)
print(f"RX RESULT PUBLISH = {outcome.status}")
print(f"RX RESULT HEAD = {outcome.commit}")
print("RF TX = 0")
return 0
def run_watcher(arguments: argparse.Namespace) -> int:
state_path = arguments.state_file.resolve()
if arguments.publish_result is not None:
return publish_result(arguments)
if arguments.request_stop:
stopped = request_stop(state_path, arguments.stop_timeout)
print("WATCHER STOPPED" if stopped else "WATCHER STOP TIMEOUT")
@@ -576,6 +634,8 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--request-stop", action="store_true")
parser.add_argument("--stop-timeout", type=float, default=35.0)
parser.add_argument("--mark-completed")
parser.add_argument("--publish-result", type=Path)
parser.add_argument("--result-branch", default="rx-node")
return parser

View File

@@ -0,0 +1,9 @@
@echo off
setlocal
cd /d "%~dp0.."
python -B tools\tx_agent_watcher.py --poll-interval 5
set "TX_WATCHER_EXIT=%ERRORLEVEL%"
echo.
echo TX watcher exited with code %TX_WATCHER_EXIT%.
pause
exit /b %TX_WATCHER_EXIT%

View File

@@ -0,0 +1,8 @@
@echo off
setlocal
cd /d "%~dp0.."
python -B tools\tx_agent_watcher.py --request-stop --stop-timeout 15
set "TX_WATCHER_EXIT=%ERRORLEVEL%"
echo.
pause
exit /b %TX_WATCHER_EXIT%

398
tools/tx_agent_watcher.py Normal file
View File

@@ -0,0 +1,398 @@
"""Poll the RX result branch and gate a single RF-001A TX authorization."""
from __future__ import annotations
import argparse
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import subprocess
import sys
import time
from typing import Callable, Protocol
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from tools.rf_handoff_channel import ( # noqa: E402
EXPECTED_TX_TASK_ID,
GitResultChannel,
ResultBranchMissing,
ResultChannelError,
RxHandoffResult,
evaluate_ready,
load_result,
result_digest,
)
DEFAULT_STATE_PATH = ROOT / ".local" / "tx_agent_watcher_state.json"
DEFAULT_OUTBOX = ROOT / ".local" / "tx_authorizations"
class ResultSource(Protocol):
def fetch(self) -> None: ...
def read(self) -> tuple[RxHandoffResult, str, str]: ...
class TxExecutor(Protocol):
def prepare(
self,
result: RxHandoffResult,
digest: str,
result_head: str,
*,
dry_run: bool,
) -> Path: ...
@dataclass
class TxWatcherState:
running: bool = False
pid: int | None = None
last_seen_digest: str | None = None
last_prompted_digest: str | None = None
last_authorized_digest: str | None = None
class StaticResultSource:
"""Read one local fixture without network or Git writes."""
def __init__(self, result: RxHandoffResult) -> None:
self.result = result
self.digest = result_digest(result)
def fetch(self) -> None:
return None
def read(self) -> tuple[RxHandoffResult, str, str]:
return self.result, self.digest, self.result.source_head
class ManualTxExecutor:
"""Write a local authorization record; never access SDR hardware."""
def __init__(self, outbox: Path) -> None:
self.outbox = outbox
def prepare(
self,
result: RxHandoffResult,
digest: str,
result_head: str,
*,
dry_run: bool,
) -> Path:
self.outbox.mkdir(parents=True, exist_ok=True)
suffix = "fake" if dry_run else "authorized"
target = self.outbox / f"{EXPECTED_TX_TASK_ID}_{suffix}.json"
temporary = target.with_suffix(target.suffix + ".tmp")
payload = {
"task_id": EXPECTED_TX_TASK_ID,
"authorization": "AUTHORIZE RF TX",
"rx_task_id": result.task_id,
"rx_result_digest": digest,
"rx_result_head": result_head,
"capture_started_at": result.capture_started_at,
"expected_capture_end": result.expected_capture_end,
"prepared_at": datetime.now(timezone.utc).isoformat(),
"dry_run": dry_run,
"action": "FAKE_TX_ONLY" if dry_run else "MANUAL_EXECUTOR_GATE_OPEN",
"rf_tx_started": False,
"rf_tx": 0,
}
temporary.write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, target)
return target
def load_state(path: Path) -> TxWatcherState:
if not path.exists():
return TxWatcherState()
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError("TX watcher state must be a JSON object")
return TxWatcherState(
running=bool(raw.get("running", False)),
pid=raw.get("pid"),
last_seen_digest=raw.get("last_seen_digest"),
last_prompted_digest=raw.get("last_prompted_digest"),
last_authorized_digest=raw.get("last_authorized_digest"),
)
def save_state(path: Path, state: TxWatcherState) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(asdict(state), indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, path)
def stop_path_for(state_path: Path) -> Path:
return state_path.with_suffix(state_path.suffix + ".stop")
def process_exists(pid: int | None) -> bool:
if pid is None or pid <= 0:
return False
try:
os.kill(pid, 0)
except OSError:
return False
return True
def request_stop(state_path: Path, timeout_s: float) -> bool:
stop_path = stop_path_for(state_path)
stop_path.parent.mkdir(parents=True, exist_ok=True)
stop_path.write_text("STOP\n", encoding="ascii")
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
state = load_state(state_path)
if not state.running or not process_exists(state.pid):
return True
time.sleep(0.2)
return False
def interruptible_input(prompt: str, stop_path: Path) -> str:
if os.name != "nt":
return input(prompt)
import msvcrt
print(prompt, end="", flush=True)
characters: list[str] = []
while True:
if stop_path.exists():
print()
return "S"
if not msvcrt.kbhit():
time.sleep(0.1)
continue
character = msvcrt.getwch()
if character in {"\r", "\n"}:
print()
return "".join(characters)
if character == "\b":
if characters:
characters.pop()
print("\b \b", end="", flush=True)
continue
characters.append(character)
print(character, end="", flush=True)
def ready_message(result: RxHandoffResult) -> str:
return (
"RX IS READY\n\n"
f"TASK ID =\n{result.task_id}\n\n"
f"CAPTURE STARTED =\n{result.capture_started_at}\n\n"
f"CAPTURE END EXPECTED =\n{result.expected_capture_end}\n\n"
f"TX TASK =\n{EXPECTED_TX_TASK_ID}\n\n"
"RF TX REQUIRES RED APPROVAL\n\n"
"Enter:\nAUTHORIZE RF TX"
)
def handle_result(
result: RxHandoffResult,
digest: str,
result_head: str,
state: TxWatcherState,
source: ResultSource,
executor: TxExecutor,
*,
now_function: Callable[[], datetime],
input_function: Callable[[str], str],
output_function: Callable[[str], None] = print,
dry_run: bool = False,
max_age_seconds: float = 30.0,
min_remaining_seconds: float = 10.0,
) -> tuple[str, TxWatcherState]:
"""Validate READY, request exact approval, then revalidate remote state."""
if digest == state.last_seen_digest:
return "DUPLICATE_SUPPRESSED", state
evaluation = evaluate_ready(
result,
now=now_function(),
max_age_seconds=max_age_seconds,
min_remaining_seconds=min_remaining_seconds,
)
state.last_seen_digest = digest
if not evaluation.ready:
return evaluation.reason, state
output_function(ready_message(result))
state.last_prompted_digest = digest
phrase = input_function("Type AUTHORIZE RF TX to prepare one burst: ").strip()
if phrase != "AUTHORIZE RF TX":
output_function("RED APPROVAL BLOCKED")
output_function("RF TX = 0")
return "RED_APPROVAL_BLOCKED", state
source.fetch()
refreshed, refreshed_digest, refreshed_head = source.read()
if refreshed_digest != digest:
output_function("RX RESULT CHANGED; NEW APPROVAL REQUIRED")
output_function("RF TX = 0")
return "RESULT_CHANGED", state
refreshed_evaluation = evaluate_ready(
refreshed,
now=now_function(),
max_age_seconds=max_age_seconds,
min_remaining_seconds=min_remaining_seconds,
)
if not refreshed_evaluation.ready:
output_function(f"TX WINDOW BLOCKED = {refreshed_evaluation.reason}")
output_function("RF TX = 0")
return refreshed_evaluation.reason, state
authorization_path = executor.prepare(
refreshed,
refreshed_digest,
refreshed_head,
dry_run=dry_run,
)
state.last_authorized_digest = refreshed_digest
output_function(f"TX AUTHORIZATION PREPARED = {authorization_path}")
output_function("RF TX = 0")
return "TX_AUTHORIZATION_PREPARED", state
def run_watcher(arguments: argparse.Namespace) -> int:
state_path = arguments.state_file.resolve()
if arguments.request_stop:
stopped = request_stop(state_path, arguments.stop_timeout)
print("TX WATCHER STOPPED" if stopped else "TX WATCHER STOP TIMEOUT")
return 0 if stopped else 1
if arguments.result_file is not None and not arguments.dry_run:
raise ValueError("--result-file is allowed only with --dry-run")
if arguments.dry_run and arguments.result_file is None:
raise ValueError("--dry-run requires --result-file and never fetches Git")
if arguments.approval is not None and not arguments.dry_run:
raise ValueError("--approval is allowed only with --dry-run")
state = load_state(state_path)
if state.running and process_exists(state.pid):
raise RuntimeError(f"TX watcher already running with PID {state.pid}")
state.running = True
state.pid = os.getpid()
save_state(state_path, state)
stop_path = stop_path_for(state_path)
stop_path.unlink(missing_ok=True)
if arguments.result_file is not None:
source: ResultSource = StaticResultSource(load_result(arguments.result_file))
else:
source = GitResultChannel(
arguments.repository.resolve(),
remote=arguments.remote,
branch=arguments.result_branch,
)
executor = ManualTxExecutor(arguments.outbox.resolve())
def input_function(prompt: str) -> str:
if arguments.approval == "authorize":
return "AUTHORIZE RF TX"
if arguments.approval == "deny":
return "DENY"
return interruptible_input(prompt, stop_path)
try:
while not stop_path.exists():
try:
source.fetch()
result, digest, result_head = source.read()
status, state = handle_result(
result,
digest,
result_head,
state,
source,
executor,
now_function=lambda: datetime.now(timezone.utc),
input_function=input_function,
dry_run=arguments.dry_run,
max_age_seconds=arguments.max_age,
min_remaining_seconds=arguments.min_remaining,
)
save_state(state_path, state)
if status != "DUPLICATE_SUPPRESSED":
print(f"TX WATCHER STATUS = {status}")
if arguments.once or arguments.dry_run:
break
except (
OSError,
ValueError,
ResultBranchMissing,
ResultChannelError,
RuntimeError,
json.JSONDecodeError,
subprocess.SubprocessError,
) as error:
print(f"TX WATCHER ERROR = {error}", file=sys.stderr)
if arguments.once or arguments.dry_run:
return 1
if arguments.once:
break
for _ in range(max(1, int(arguments.poll_interval * 10))):
if stop_path.exists():
break
time.sleep(0.1)
finally:
state.running = False
state.pid = None
save_state(state_path, state)
stop_path.unlink(missing_ok=True)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", type=Path, default=ROOT)
parser.add_argument("--remote", default="origin")
parser.add_argument("--result-branch", default="rx-node")
parser.add_argument("--poll-interval", type=float, default=5.0)
parser.add_argument("--max-age", type=float, default=30.0)
parser.add_argument("--min-remaining", type=float, default=10.0)
parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_PATH)
parser.add_argument("--outbox", type=Path, default=DEFAULT_OUTBOX)
parser.add_argument("--once", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--result-file", type=Path)
parser.add_argument("--approval", choices=("authorize", "deny"))
parser.add_argument("--request-stop", action="store_true")
parser.add_argument("--stop-timeout", type=float, default=15.0)
return parser
def main() -> None:
arguments = build_parser().parse_args()
positive = (
arguments.poll_interval,
arguments.max_age,
arguments.min_remaining,
arguments.stop_timeout,
)
if any(value <= 0 for value in positive):
raise SystemExit("intervals must be positive")
raise SystemExit(run_watcher(arguments))
if __name__ == "__main__":
main()