Fix bidirectional RF-001A coordination
This commit is contained in:
@@ -8,6 +8,7 @@ from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -19,8 +20,11 @@ if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tools.rf_handoff_channel import ( # noqa: E402
|
||||
CHANNEL_TEST_TASK_ID,
|
||||
COMPLETE_STATE,
|
||||
EXPECTED_TX_TASK_ID,
|
||||
GitResultChannel,
|
||||
READY_STATE,
|
||||
ResultBranchMissing,
|
||||
ResultChannelError,
|
||||
RxHandoffResult,
|
||||
@@ -32,10 +36,11 @@ from tools.rf_handoff_channel import ( # noqa: E402
|
||||
|
||||
DEFAULT_STATE_PATH = ROOT / ".local" / "tx_agent_watcher_state.json"
|
||||
DEFAULT_OUTBOX = ROOT / ".local" / "tx_authorizations"
|
||||
_HEAD_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
class ResultSource(Protocol):
|
||||
def fetch(self) -> None: ...
|
||||
def fetch(self) -> str | None: ...
|
||||
|
||||
def read(self) -> tuple[RxHandoffResult, str, str]: ...
|
||||
|
||||
@@ -58,6 +63,8 @@ class TxWatcherState:
|
||||
last_seen_digest: str | None = None
|
||||
last_prompted_digest: str | None = None
|
||||
last_authorized_digest: str | None = None
|
||||
channel_test_ready_digest: str | None = None
|
||||
channel_test_complete_digest: str | None = None
|
||||
|
||||
|
||||
class StaticResultSource:
|
||||
@@ -98,6 +105,7 @@ class ManualTxExecutor:
|
||||
"rx_task_id": result.task_id,
|
||||
"rx_result_digest": digest,
|
||||
"rx_result_head": result_head,
|
||||
"source_head": result.source_head,
|
||||
"capture_started_at": result.capture_started_at,
|
||||
"expected_capture_end": result.expected_capture_end,
|
||||
"prepared_at": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -117,16 +125,13 @@ class ManualTxExecutor:
|
||||
def load_state(path: Path) -> TxWatcherState:
|
||||
if not path.exists():
|
||||
return TxWatcherState()
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
raw = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
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"),
|
||||
)
|
||||
allowed = set(TxWatcherState.__dataclass_fields__)
|
||||
if any(key not in allowed for key in raw):
|
||||
raise ValueError("TX watcher state contains an unknown field")
|
||||
return TxWatcherState(**raw)
|
||||
|
||||
|
||||
def save_state(path: Path, state: TxWatcherState) -> None:
|
||||
@@ -148,7 +153,7 @@ def process_exists(pid: int | None) -> bool:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
except (OSError, SystemError):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -160,7 +165,14 @@ def request_stop(state_path: Path, timeout_s: float) -> bool:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
state = load_state(state_path)
|
||||
if not state.running or not process_exists(state.pid):
|
||||
if not state.running:
|
||||
stop_path.unlink(missing_ok=True)
|
||||
return True
|
||||
if not process_exists(state.pid):
|
||||
state.running = False
|
||||
state.pid = None
|
||||
save_state(state_path, state)
|
||||
stop_path.unlink(missing_ok=True)
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
@@ -206,6 +218,46 @@ def ready_message(result: RxHandoffResult) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _valid_result_head(result_head: str) -> bool:
|
||||
return _HEAD_RE.fullmatch(result_head) is not None
|
||||
|
||||
|
||||
def handle_channel_test(
|
||||
result: RxHandoffResult,
|
||||
digest: str,
|
||||
result_head: str,
|
||||
state: TxWatcherState,
|
||||
*,
|
||||
output_function: Callable[[str], None] = print,
|
||||
) -> tuple[str, TxWatcherState]:
|
||||
"""Record model-free channel smoke states without opening the RED gate."""
|
||||
|
||||
if digest == state.last_seen_digest:
|
||||
return "DUPLICATE_SUPPRESSED", state
|
||||
state.last_seen_digest = digest
|
||||
if result.task_id != CHANNEL_TEST_TASK_ID:
|
||||
return "WRONG_TASK_ID", state
|
||||
if not _valid_result_head(result_head):
|
||||
return "INVALID_RESULT_COMMIT", state
|
||||
if result.common_clock_verified:
|
||||
return "CHANNEL_TEST_CLAIMS_HARDWARE", state
|
||||
if result.state == READY_STATE:
|
||||
state.channel_test_ready_digest = digest
|
||||
output_function("CHANNEL TEST READY RECEIVED = YES")
|
||||
output_function(f"RX RESULT HEAD = {result_head}")
|
||||
output_function("RF TX = 0")
|
||||
return "CHANNEL_TEST_READY", state
|
||||
if result.state == COMPLETE_STATE:
|
||||
state.channel_test_complete_digest = digest
|
||||
output_function("CHANNEL TEST COMPLETE RECEIVED = YES")
|
||||
output_function(f"RX RESULT HEAD = {result_head}")
|
||||
output_function("RF TX = 0")
|
||||
if state.channel_test_ready_digest is None:
|
||||
return "CHANNEL_TEST_COMPLETE_WITHOUT_READY", state
|
||||
return "CHANNEL_TEST_COMPLETE", state
|
||||
return "CHANNEL_TEST_INVALID_STATE", state
|
||||
|
||||
|
||||
def handle_result(
|
||||
result: RxHandoffResult,
|
||||
digest: str,
|
||||
@@ -218,17 +270,30 @@ def handle_result(
|
||||
input_function: Callable[[str], str],
|
||||
output_function: Callable[[str], None] = print,
|
||||
dry_run: bool = False,
|
||||
expected_source_head: str | None = None,
|
||||
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 result.task_id == CHANNEL_TEST_TASK_ID:
|
||||
return handle_channel_test(
|
||||
result,
|
||||
digest,
|
||||
result_head,
|
||||
state,
|
||||
output_function=output_function,
|
||||
)
|
||||
if digest == state.last_seen_digest:
|
||||
return "DUPLICATE_SUPPRESSED", state
|
||||
if not _valid_result_head(result_head):
|
||||
state.last_seen_digest = digest
|
||||
return "INVALID_RESULT_COMMIT", state
|
||||
|
||||
evaluation = evaluate_ready(
|
||||
result,
|
||||
now=now_function(),
|
||||
expected_source_head=expected_source_head,
|
||||
max_age_seconds=max_age_seconds,
|
||||
min_remaining_seconds=min_remaining_seconds,
|
||||
)
|
||||
@@ -246,7 +311,7 @@ def handle_result(
|
||||
|
||||
source.fetch()
|
||||
refreshed, refreshed_digest, refreshed_head = source.read()
|
||||
if refreshed_digest != digest:
|
||||
if refreshed_digest != digest or refreshed_head != result_head:
|
||||
output_function("RX RESULT CHANGED; NEW APPROVAL REQUIRED")
|
||||
output_function("RF TX = 0")
|
||||
return "RESULT_CHANGED", state
|
||||
@@ -254,6 +319,7 @@ def handle_result(
|
||||
refreshed_evaluation = evaluate_ready(
|
||||
refreshed,
|
||||
now=now_function(),
|
||||
expected_source_head=expected_source_head,
|
||||
max_age_seconds=max_age_seconds,
|
||||
min_remaining_seconds=min_remaining_seconds,
|
||||
)
|
||||
@@ -274,6 +340,18 @@ def handle_result(
|
||||
return "TX_AUTHORIZATION_PREPARED", state
|
||||
|
||||
|
||||
def _git_output(repository: Path, 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()
|
||||
|
||||
|
||||
def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
state_path = arguments.state_file.resolve()
|
||||
if arguments.request_stop:
|
||||
@@ -287,6 +365,7 @@ def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
if arguments.approval is not None and not arguments.dry_run:
|
||||
raise ValueError("--approval is allowed only with --dry-run")
|
||||
|
||||
repository = arguments.repository.resolve()
|
||||
state = load_state(state_path)
|
||||
if state.running and process_exists(state.pid):
|
||||
raise RuntimeError(f"TX watcher already running with PID {state.pid}")
|
||||
@@ -298,12 +377,24 @@ def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
|
||||
if arguments.result_file is not None:
|
||||
source: ResultSource = StaticResultSource(load_result(arguments.result_file))
|
||||
expected_source_head = (
|
||||
arguments.expected_source_head or source.result.source_head
|
||||
)
|
||||
else:
|
||||
branch = _git_output(repository, ["branch", "--show-current"])
|
||||
if branch != arguments.source_branch:
|
||||
raise ValueError("TX watcher must run on the configured source branch")
|
||||
expected_source_head = (
|
||||
arguments.expected_source_head
|
||||
or _git_output(repository, ["rev-parse", "HEAD"])
|
||||
)
|
||||
source = GitResultChannel(
|
||||
arguments.repository.resolve(),
|
||||
repository,
|
||||
remote=arguments.remote,
|
||||
branch=arguments.result_branch,
|
||||
)
|
||||
if not _HEAD_RE.fullmatch(expected_source_head):
|
||||
raise ValueError("expected source HEAD must be a full lowercase Git hash")
|
||||
executor = ManualTxExecutor(arguments.outbox.resolve())
|
||||
|
||||
def input_function(prompt: str) -> str:
|
||||
@@ -316,7 +407,6 @@ def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
try:
|
||||
while not stop_path.exists():
|
||||
try:
|
||||
source.fetch()
|
||||
result, digest, result_head = source.read()
|
||||
status, state = handle_result(
|
||||
result,
|
||||
@@ -328,6 +418,7 @@ def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
now_function=lambda: datetime.now(timezone.utc),
|
||||
input_function=input_function,
|
||||
dry_run=arguments.dry_run,
|
||||
expected_source_head=expected_source_head,
|
||||
max_age_seconds=arguments.max_age,
|
||||
min_remaining_seconds=arguments.min_remaining,
|
||||
)
|
||||
@@ -366,7 +457,9 @@ 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("--source-branch", default="video-development")
|
||||
parser.add_argument("--result-branch", default="rx-node")
|
||||
parser.add_argument("--expected-source-head")
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user