Fix bidirectional RF-001A coordination
This commit is contained in:
104
tools/capture_process_runner.ps1
Normal file
104
tools/capture_process_runner.ps1
Normal 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
|
||||
@@ -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
154
tools/rf_handoff_smoke.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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