Add bidirectional RF handoff coordination
This commit is contained in:
444
tools/rf_handoff_channel.py
Normal file
444
tools/rf_handoff_channel.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
9
tools/start_tx_watcher.cmd
Normal file
9
tools/start_tx_watcher.cmd
Normal 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%
|
||||
8
tools/stop_tx_watcher.cmd
Normal file
8
tools/stop_tx_watcher.cmd
Normal 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
398
tools/tx_agent_watcher.py
Normal 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()
|
||||
Reference in New Issue
Block a user