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,104 @@
param(
[Parameter(Mandatory = $true)][string]$Executable,
[Parameter(Mandatory = $true)][string]$ArgumentsJsonPath,
[Parameter(Mandatory = $true)][string]$CapturePath,
[Parameter(Mandatory = $true)][string]$StdoutPath,
[Parameter(Mandatory = $true)][string]$StderrPath,
[Parameter(Mandatory = $true)][string]$StartedStatusPath,
[Parameter(Mandatory = $true)][string]$ResultStatusPath
)
$ErrorActionPreference = 'Stop'
function Write-JsonAtomically {
param(
[Parameter(Mandatory = $true)][object]$Value,
[Parameter(Mandatory = $true)][string]$Path
)
$temporaryPath = "$Path.tmp"
$json = $Value | ConvertTo-Json -Depth 8
$encoding = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($temporaryPath, $json + [Environment]::NewLine, $encoding)
Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
}
$decodedArguments = Get-Content -Raw -LiteralPath $ArgumentsJsonPath |
ConvertFrom-Json
if ($decodedArguments -isnot [System.Array]) {
throw 'ArgumentsJsonPath must contain a JSON array.'
}
$childArguments = @(
foreach ($argument in $decodedArguments) {
[string]$argument
}
)
$startedAt = [DateTimeOffset]::UtcNow
$finishedAt = $null
$childProcess = $null
$exitCode = $null
$runnerError = $null
$captureSize = 0
$sha256 = $null
try {
$startParameters = @{
FilePath = $Executable
ArgumentList = $childArguments
RedirectStandardOutput = $StdoutPath
RedirectStandardError = $StderrPath
PassThru = $true
WindowStyle = 'Hidden'
}
$childProcess = Start-Process @startParameters
# PowerShell 5 can lose ExitCode for redirected processes unless the
# underlying process handle is opened before WaitForExit().
$null = $childProcess.Handle
Write-JsonAtomically -Path $StartedStatusPath -Value ([ordered]@{
state = 'RUNNING'
pid = $childProcess.Id
started_at = $startedAt.ToString('o')
capture_path = $CapturePath
})
$childProcess.WaitForExit()
$childProcess.Refresh()
$exitCode = [int]$childProcess.ExitCode
} catch {
$runnerError = $_.Exception.ToString()
} finally {
$finishedAt = [DateTimeOffset]::UtcNow
if (Test-Path -LiteralPath $CapturePath) {
try {
$captureSize = [int64](Get-Item -LiteralPath $CapturePath).Length
$sha256 = (
Get-FileHash -LiteralPath $CapturePath -Algorithm SHA256
).Hash.ToLowerInvariant()
} catch {
$additionalError = $_.Exception.ToString()
if ([string]::IsNullOrWhiteSpace($runnerError)) {
$runnerError = $additionalError
} else {
$runnerError = $runnerError + [Environment]::NewLine + $additionalError
}
}
}
Write-JsonAtomically -Path $ResultStatusPath -Value ([ordered]@{
pid = if ($null -ne $childProcess) { $childProcess.Id } else { $null }
started_at = $startedAt.ToString('o')
finished_at = $finishedAt.ToString('o')
duration = ($finishedAt - $startedAt).TotalSeconds
exit_code = $exitCode
capture_path = $CapturePath
capture_size = $captureSize
sha256 = $sha256
runner_error = $runnerError
})
}
if ($null -eq $exitCode) {
exit 255
}
exit $exitCode

View File

@@ -14,8 +14,9 @@ from typing import Any, Mapping
SCHEMA_VERSION = 1
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_002"
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_002"
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_003"
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_003"
CHANNEL_TEST_TASK_ID = "RF_HANDOFF_CHANNEL_TEST_001"
READY_STATE = "RX_READY_FOR_TX"
COMPLETE_STATE = "CAPTURE_COMPLETE"
RESULT_JSON_PATH = "docs/handoff/results/RX_RESULT.json"
@@ -72,6 +73,7 @@ class ReadyEvaluation:
class PublishOutcome:
status: str
commit: str
remote_head: str
json_path: str = RESULT_JSON_PATH
markdown_path: str = RESULT_MARKDOWN_PATH
@@ -172,9 +174,13 @@ def _validate_ready(result: RxHandoffResult) -> None:
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")
raise ValueError("READY requires a live process and growing capture")
if result.task_id == CHANNEL_TEST_TASK_ID:
if result.common_clock_verified:
raise ValueError("channel smoke READY must not claim hardware clock proof")
elif not result.common_clock_verified:
raise ValueError("production READY requires verified common clock")
if result.capture_pid is None or result.capture_pid <= 0:
raise ValueError("READY requires a positive capture_pid")
final_fields = (
@@ -289,15 +295,22 @@ def evaluate_ready(
*,
now: datetime,
expected_task_id: str = EXPECTED_RX_TASK_ID,
expected_source_head: str | None = None,
max_age_seconds: float = 30.0,
min_remaining_seconds: float = 10.0,
) -> ReadyEvaluation:
validate_result(result)
if now.tzinfo is None:
raise ValueError("now must contain a timezone")
now_utc = now.astimezone(timezone.utc)
if result.task_id != expected_task_id:
return ReadyEvaluation(False, "WRONG_TASK_ID")
if expected_source_head is not None and result.source_head != expected_source_head:
return ReadyEvaluation(False, "WRONG_SOURCE_HEAD")
if result.state != READY_STATE or not result.rx_ready_for_tx:
return ReadyEvaluation(False, "MISSING_READY")
if not result.common_clock_verified:
return ReadyEvaluation(False, "COMMON_CLOCK_NOT_VERIFIED")
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")
@@ -316,7 +329,7 @@ def evaluate_ready(
class GitResultChannel:
"""Read and publish the two allowlisted result files on an existing branch."""
"""Publish through a temporary worktree of the configured main repository."""
def __init__(
self,
@@ -346,99 +359,163 @@ class GitResultChannel:
check=check,
)
def branch_exists(self) -> bool:
def remote_head(self) -> str | None:
result = self._run(
["ls-remote", "--exit-code", "--heads", self.remote, self.branch],
["ls-remote", "--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"
if result.returncode != 0:
raise ResultChannelError(
f"git ls-remote failed for {self.remote}/{self.branch}: "
f"{result.stderr.strip()}"
)
lines = [line for line in result.stdout.splitlines() if line.strip()]
if not lines:
return None
if len(lines) != 1:
raise ResultChannelError("remote result branch lookup is ambiguous")
head, reference = lines[0].split(maxsplit=1)
if reference != f"refs/heads/{self.branch}" or not _HEAD_RE.fullmatch(head):
raise ResultChannelError("remote result branch returned an invalid HEAD")
return head
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 branch_exists(self) -> bool:
return self.remote_head() is not None
def fetch(self) -> str:
initial_fetch = self._run(
["fetch", "--quiet", self.remote, self.branch],
check=False,
)
remote_head = self.remote_head()
if remote_head is None:
raise ResultBranchMissing(
f"remote branch {self.remote}/{self.branch} does not exist"
)
if initial_fetch.returncode != 0:
raise ResultChannelError(
f"git fetch failed for {self.remote}/{self.branch}: "
f"{initial_fetch.stderr.strip()}"
)
tracking_reference = (
f"refs/heads/{self.branch}:"
f"refs/remotes/{self.remote}/{self.branch}"
)
tracking_fetch = self._run(
["fetch", "--quiet", self.remote, tracking_reference],
check=False,
)
if tracking_fetch.returncode != 0:
raise ResultChannelError(
f"tracking fetch failed for {self.remote}/{self.branch}: "
f"{tracking_fetch.stderr.strip()}"
)
tracking_head = self._run(
["rev-parse", f"refs/remotes/{self.remote}/{self.branch}"]
).stdout.strip()
if tracking_head != remote_head:
raise ResultChannelError("remote-tracking result branch is stale")
return remote_head
def read(self) -> tuple[RxHandoffResult, str, str]:
self.fetch()
remote_head = 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
observed_head = self._run(["rev-parse", reference]).stdout.strip()
if observed_head != remote_head:
raise ResultChannelError("result commit differs from remote HEAD")
return result, result_digest(result), observed_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)}"
remote_before = self.fetch()
tracking_reference = f"refs/remotes/{self.remote}/{self.branch}"
with tempfile.TemporaryDirectory(prefix="rx-result-worktree-") as directory:
checkout = Path(directory) / "rx-node"
worktree_added = False
try:
self._run(
["worktree", "add", "--detach", str(checkout), tracking_reference]
)
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)
worktree_added = True
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:
return PublishOutcome("UNCHANGED", remote_before, remote_before)
if staged != RESULT_PATHS:
raise ResultChannelError(
f"refusing unexpected staged paths: {sorted(staged)}"
)
self._run(
[
"-c",
"user.name=RX Node",
"-c",
"user.email=rx-node@local.invalid",
"commit",
"--quiet",
"-m",
f"Publish {result.task_id} {result.state}",
],
cwd=checkout,
)
commit = self._run(["rev-parse", "HEAD"], cwd=checkout).stdout.strip()
push = self._run(
[
"push",
"--quiet",
self.remote,
f"HEAD:refs/heads/{self.branch}",
],
cwd=checkout,
check=False,
)
if push.returncode != 0:
raise ResultChannelError(
f"result push failed: {push.stderr.strip()}"
)
remote_after = self.remote_head()
if remote_after is None or remote_after != commit:
raise ResultChannelError(
"remote result HEAD does not confirm the published commit"
)
return PublishOutcome("PUBLISHED", commit, remote_after)
finally:
if worktree_added:
removal = self._run(
["worktree", "remove", "--force", str(checkout)],
check=False,
)
if removal.returncode != 0:
raise ResultChannelError(
f"temporary worktree cleanup failed: "
f"{removal.stderr.strip()}"
)
self._run(["worktree", "prune"], check=False)

154
tools/rf_handoff_smoke.py Normal file
View File

@@ -0,0 +1,154 @@
"""Publish a model-free READY/COMPLETE smoke sequence to the RX result branch."""
from __future__ import annotations
import argparse
from dataclasses import replace
from datetime import datetime, timedelta, timezone
import hashlib
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import time
from typing import Callable
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
CHANNEL_TEST_TASK_ID,
COMPLETE_STATE,
READY_STATE,
GitResultChannel,
PublishOutcome,
RxHandoffResult,
)
def _git_head(repository: Path) -> str:
return subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=repository,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=True,
).stdout.strip()
def run_smoke(
repository: Path,
*,
remote: str = "origin",
branch: str = "rx-node",
source_head: str | None = None,
hold_seconds: float = 7.0,
sleep_function: Callable[[float], None] = time.sleep,
) -> tuple[PublishOutcome, PublishOutcome, RxHandoffResult]:
"""Publish fake READY then COMPLETE; never access SDR or open a TX gate."""
repository = repository.resolve()
observed_source_head = source_head or _git_head(repository)
channel = GitResultChannel(repository, remote=remote, branch=branch)
channel.fetch()
with tempfile.TemporaryDirectory(prefix="rf-handoff-smoke-") as directory:
fake_capture = Path(directory) / "model_free_fake_capture.txt"
started = datetime.now(timezone.utc)
fake_capture.write_text(
"RF_HANDOFF_CHANNEL_TEST_001\nREADY\nRF TX = 0\n",
encoding="utf-8",
)
published = datetime.now(timezone.utc)
ready = 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=max(1, os.getpid()),
published_at=published.isoformat(),
capture_started_at=started.isoformat(),
expected_capture_end=(started + timedelta(seconds=120)).isoformat(),
capture_path=str(fake_capture.resolve()),
common_clock_verified=False,
source_head=observed_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",
)
ready_outcome = channel.publish(ready)
sleep_function(hold_seconds)
fake_capture.write_text(
"RF_HANDOFF_CHANNEL_TEST_001\nCOMPLETE\nRF TX = 0\n",
encoding="utf-8",
)
finished = datetime.now(timezone.utc)
content = fake_capture.read_bytes()
complete = replace(
ready,
state=COMPLETE_STATE,
rx_ready_for_tx=False,
process_alive=False,
file_growing=False,
published_at=finished.isoformat(),
exit_code=0,
file_size=len(content),
duration_seconds=(finished - started).total_seconds(),
overruns=0,
usb_errors=0,
file_errors=0,
sha256=hashlib.sha256(content).hexdigest(),
)
complete_outcome = channel.publish(complete)
return ready_outcome, complete_outcome, complete
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("--source-head")
parser.add_argument("--hold-seconds", type=float, default=7.0)
return parser
def main() -> None:
arguments = build_parser().parse_args()
if arguments.hold_seconds < 0:
raise SystemExit("--hold-seconds cannot be negative")
ready, complete, result = run_smoke(
arguments.repository,
remote=arguments.remote,
branch=arguments.result_branch,
source_head=arguments.source_head,
hold_seconds=arguments.hold_seconds,
)
print("TASK_ID =")
print(CHANNEL_TEST_TASK_ID)
print(f"READY PUBLISH = {ready.status}")
print(f"READY REMOTE HEAD = {ready.remote_head}")
print(f"COMPLETE PUBLISH = {complete.status}")
print(f"COMPLETE REMOTE HEAD = {complete.remote_head}")
print(f"FINAL STATE = {result.state}")
print("REAL RX = NOT_RUN")
print("RF TX = 0")
if __name__ == "__main__":
main()

View File

@@ -528,6 +528,7 @@ def publish_result(arguments: argparse.Namespace) -> int:
outcome = channel.publish(result)
print(f"RX RESULT PUBLISH = {outcome.status}")
print(f"RX RESULT HEAD = {outcome.commit}")
print(f"RX RESULT REMOTE HEAD = {outcome.remote_head}")
print("RF TX = 0")
return 0

View File

@@ -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)