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

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