Harden RF handoff and stream OTA decode

This commit is contained in:
LittleSam129
2026-09-22 17:43:58 +03:00
parent 168e03d9e7
commit a94f2db4ee
17 changed files with 1548 additions and 497 deletions

View File

@@ -31,6 +31,12 @@ from tools.qpsk_rf_common import (
GAP_MS, PACKET_COUNT, PAYLOAD_BYTES, SEED, TEST_ID,
payload_for_sequence, read_iq_file, sha256_file,
)
from tools.rf001a_streaming import (
DEFAULT_CHUNK_SAMPLES,
MemoryPeakSampler,
cs8_sample_count,
decode_cs8_streaming,
)
def _bit_errors(observed: bytes, expected: bytes) -> tuple[int, int]:
@@ -124,7 +130,7 @@ def summarize_result(
unique_sequences = set(sequences)
cfo = [row["cfo_hz"] for row in rows]
report: dict[str, Any] = {
"rf001_status": "PREPARED_NOT_RUN",
"rf001_status": "OFFLINE_ANALYZED",
"test_id": truth.get("test_id") if truth is not None else None,
"modulation": "QPSK",
"symbol_rate": 500_000,
@@ -242,10 +248,32 @@ def decode_file(
iq_format: str = "c64le",
truth_metadata_path: Path | None = None,
decoder_backend: str = "numba",
capture_started_at: str | None = None,
tx_started_at: str | None = None,
tx_finished_at: str | None = None,
chunk_samples: int = DEFAULT_CHUNK_SAMPLES,
) -> dict[str, Any]:
"""Открывает только файл; truth читается после вызова рабочего приёмника."""
iq, clipping_codes = read_iq_file(iq_path, iq_format)
reception = receive_iq_stream(iq, decoder_backend=decoder_backend)
"""Декодирует c64le целиком, а большие cs8 — ограниченными окнами."""
streaming = None
with MemoryPeakSampler() as memory:
if iq_format == "cs8":
streaming = decode_cs8_streaming(
iq_path,
capture_started_at=capture_started_at,
tx_started_at=tx_started_at,
tx_finished_at=tx_finished_at,
decoder_backend=decoder_backend,
chunk_samples=chunk_samples,
)
reception = streaming.reception
iq_sample_count = cs8_sample_count(iq_path)
clipping_codes = streaming.clipping_codes
iq = None
else:
iq, clipping_codes = read_iq_file(iq_path, iq_format)
reception = receive_iq_stream(iq, decoder_backend=decoder_backend)
iq_sample_count = int(iq.size)
truth = (json.loads(truth_metadata_path.read_text(encoding="utf-8"))
if truth_metadata_path is not None else None)
if truth is not None and (
@@ -262,17 +290,38 @@ def decode_file(
or truth.get("seed") != SEED
):
raise ValueError("TX-ведомость не соответствует режиму RF-001")
raw_bits = (_raw_ber_after_decode(iq, reception, int(truth["seed"]), decoder_backend)
if truth is not None else None)
return summarize_result(
raw_bits = (
_raw_ber_after_decode(iq, reception, int(truth["seed"]), decoder_backend)
if truth is not None and iq is not None
else None
)
report = summarize_result(
reception,
iq_sample_count=int(iq.size),
iq_sample_count=iq_sample_count,
iq_sha256=sha256_file(iq_path),
iq_format=iq_format,
clipping_codes=clipping_codes,
truth=truth,
raw_bits=raw_bits,
)
report["peak_analysis_memory_bytes"] = memory.peak_bytes
report["peak_additional_ram_bytes"] = memory.additional_peak_bytes
if streaming is not None:
report.update({
"streaming_decoder": True,
"window_source": streaming.window_source,
"analysis_windows": [
{"start_sample": window.start, "stop_sample": window.stop}
for window in streaming.windows
],
"overlap_samples": streaming.overlap_samples,
"maximum_loaded_complex_samples": streaming.maximum_loaded_samples,
"maximum_loaded_iq_bytes": streaming.maximum_loaded_samples * 8,
"raw_ber_scope": "NOT_COMPUTED_FOR_STREAMING_WINDOWS",
})
else:
report["streaming_decoder"] = False
return report
def main() -> None:
@@ -282,6 +331,10 @@ def main() -> None:
parser.add_argument("--truth-metadata", type=Path)
parser.add_argument("--report", required=True, type=Path)
parser.add_argument("--numba-packages", type=Path)
parser.add_argument("--capture-started-at")
parser.add_argument("--tx-started-at")
parser.add_argument("--tx-finished-at")
parser.add_argument("--chunk-samples", type=int, default=DEFAULT_CHUNK_SAMPLES)
arguments = parser.parse_args()
if arguments.report.exists():
parser.error("отчёт уже существует; перезапись запрещена")
@@ -290,7 +343,11 @@ def main() -> None:
parser.error("каталог пакетов Numba отсутствует")
sys.path.insert(0, str(arguments.numba_packages.resolve()))
result = decode_file(arguments.iq_file, iq_format=arguments.iq_format,
truth_metadata_path=arguments.truth_metadata)
truth_metadata_path=arguments.truth_metadata,
capture_started_at=arguments.capture_started_at,
tx_started_at=arguments.tx_started_at,
tx_finished_at=arguments.tx_finished_at,
chunk_samples=arguments.chunk_samples)
arguments.report.parent.mkdir(parents=True, exist_ok=True)
arguments.report.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")

363
tools/rf001a_streaming.py Normal file
View File

@@ -0,0 +1,363 @@
"""Потоковое чтение больших cs8-захватов и оконный приём RF-001A."""
from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import datetime, timezone
import ctypes
from pathlib import Path
import threading
from typing import Iterable
import numpy as np
from protocol.video003_modem import SAMPLE_RATE_HZ, build_frame, samples_per_symbol
from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE, shape_packet
from protocol.video005_receiver import IqStreamResult, ReceivedIqPacket, receive_iq_stream
from tools.qpsk_rf_common import PAYLOAD_BYTES, SEED, payload_for_sequence
DEFAULT_CHUNK_SAMPLES = 4_000_000
LOCATOR_BLOCK_SAMPLES = 400_000
TIMESTAMP_MARGIN_SECONDS = 2.0
@dataclass(frozen=True)
class SampleWindow:
start: int
stop: int
@property
def sample_count(self) -> int:
return self.stop - self.start
@dataclass(frozen=True)
class StreamingReception:
reception: IqStreamResult
windows: tuple[SampleWindow, ...]
window_source: str
overlap_samples: int
maximum_loaded_samples: int
clipping_codes: int
class MemoryPeakSampler:
"""Измеряет пиковый working set процесса во время анализа."""
def __init__(self, poll_seconds: float = 0.02) -> None:
self.poll_seconds = poll_seconds
self.baseline_bytes = current_process_rss_bytes()
self.peak_bytes = self.baseline_bytes
self._stop = threading.Event()
self._thread = threading.Thread(target=self._sample, daemon=True)
def _sample(self) -> None:
while not self._stop.wait(self.poll_seconds):
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
def __enter__(self) -> MemoryPeakSampler:
self._thread.start()
return self
def __exit__(self, *_exc: object) -> None:
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
self._stop.set()
self._thread.join(timeout=1.0)
@property
def additional_peak_bytes(self) -> int:
return max(0, self.peak_bytes - self.baseline_bytes)
def current_process_rss_bytes() -> int:
"""Возвращает текущий working set процесса без сторонних библиотек."""
if not hasattr(ctypes, "windll"):
return 0
class ProcessMemoryCounters(ctypes.Structure):
_fields_ = [
("cb", ctypes.c_ulong),
("page_fault_count", ctypes.c_ulong),
("peak_working_set_size", ctypes.c_size_t),
("working_set_size", ctypes.c_size_t),
("quota_peak_paged_pool_usage", ctypes.c_size_t),
("quota_paged_pool_usage", ctypes.c_size_t),
("quota_peak_non_paged_pool_usage", ctypes.c_size_t),
("quota_non_paged_pool_usage", ctypes.c_size_t),
("pagefile_usage", ctypes.c_size_t),
("peak_pagefile_usage", ctypes.c_size_t),
]
counters = ProcessMemoryCounters()
counters.cb = ctypes.sizeof(counters)
get_current_process = ctypes.windll.kernel32.GetCurrentProcess # type: ignore[attr-defined]
get_current_process.restype = ctypes.c_void_p
get_process_memory_info = ctypes.windll.psapi.GetProcessMemoryInfo # type: ignore[attr-defined]
get_process_memory_info.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(ProcessMemoryCounters),
ctypes.c_ulong,
]
get_process_memory_info.restype = ctypes.c_int
succeeded = get_process_memory_info(
get_current_process(),
ctypes.byref(counters),
counters.cb,
)
return int(counters.working_set_size) if succeeded else 0
def cs8_sample_count(path: Path) -> int:
size = path.stat().st_size
if size == 0 or size % 2:
raise ValueError("cs8 должен содержать целое ненулевое число пар I/Q")
return size // 2
def read_cs8_window(raw: np.memmap, start: int, stop: int) -> np.ndarray:
"""Преобразует в complex64 только заданное окно cs8."""
total_samples = raw.size // 2
if start < 0 or stop <= start or stop > total_samples:
raise ValueError("границы окна cs8 выходят за пределы файла")
values = np.asarray(raw[start * 2:stop * 2], dtype=np.int8)
real = values[0::2].astype(np.float32)
imaginary = values[1::2].astype(np.float32)
return ((real + 1j * imaginary) / 128).astype(np.complex64)
def rf001_packet_waveform_samples() -> int:
"""Вычисляет длину волны из настоящего 256-байтного пакета RF-001."""
if PAYLOAD_BYTES != 256:
raise AssertionError("канонический RF-001 payload неожиданно изменился")
payload = payload_for_sequence(SEED, 0)
frame = build_frame(payload, "QPSK", "2/3", sequence_number=0)
return int(shape_packet(frame).size)
def required_overlap_samples() -> int:
"""Перекрывает целую максимальную волну одного пакета RF-001."""
return rf001_packet_waveform_samples()
def _parse_utc(value: str) -> datetime:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("timestamp должен содержать часовой пояс")
return parsed.astimezone(timezone.utc)
def timestamp_sample_window(
*,
capture_started_at: str,
tx_started_at: str,
tx_finished_at: str,
total_samples: int,
sample_rate: int = SAMPLE_RATE_HZ,
margin_seconds: float = TIMESTAMP_MARGIN_SECONDS,
) -> SampleWindow:
"""Переводит UTC-границы TX в безопасное окно отсчётов RX."""
if total_samples <= 0 or sample_rate <= 0 or margin_seconds < 0:
raise ValueError("параметры timestamp-окна должны быть положительными")
capture = _parse_utc(capture_started_at)
started = _parse_utc(tx_started_at)
finished = _parse_utc(tx_finished_at)
if finished < started:
raise ValueError("TX_FINISHED_AT не может предшествовать TX_STARTED_AT")
margin_samples = round(margin_seconds * sample_rate)
start_offset = round((started - capture).total_seconds() * sample_rate)
stop_offset = round((finished - capture).total_seconds() * sample_rate)
start = max(0, start_offset - margin_samples)
stop = min(total_samples, stop_offset + margin_samples)
if stop <= start:
raise ValueError("TX timestamps не пересекаются с RX capture")
return SampleWindow(start, stop)
def _merge_windows(windows: Iterable[SampleWindow]) -> tuple[SampleWindow, ...]:
ordered = sorted(windows, key=lambda item: item.start)
merged: list[SampleWindow] = []
for window in ordered:
if not merged or window.start > merged[-1].stop:
merged.append(window)
else:
merged[-1] = SampleWindow(
merged[-1].start,
max(merged[-1].stop, window.stop),
)
return tuple(merged)
def locate_power_windows(
path: Path,
*,
block_samples: int = LOCATOR_BLOCK_SAMPLES,
padding_seconds: float = 1.5,
) -> tuple[SampleWindow, ...]:
"""Ищет повышенную мощность по всему cs8-файлу ограниченными блоками."""
total_samples = cs8_sample_count(path)
if block_samples <= 0 or padding_seconds < 0:
raise ValueError("параметры locator должны быть неотрицательными")
powers: list[float] = []
with path.open("rb") as stream:
while raw_bytes := stream.read(block_samples * 2):
values = np.frombuffer(raw_bytes, dtype=np.int8).astype(np.float32)
powers.append(float(np.mean(values * values)))
if not powers:
return ()
power_array = np.asarray(powers, dtype=np.float64)
median = float(np.median(power_array))
mad = float(np.median(np.abs(power_array - median)))
threshold = median + max(6.0 * mad, median * 0.15, 1.0)
active = np.flatnonzero(power_array > threshold)
if active.size == 0:
return ()
padding = round(padding_seconds * SAMPLE_RATE_HZ)
candidates = (
SampleWindow(
max(0, int(index) * block_samples - padding),
min(total_samples, (int(index) + 1) * block_samples + padding),
)
for index in active
)
return _merge_windows(candidates)
def count_cs8_clipping(path: Path, *, block_samples: int = DEFAULT_CHUNK_SAMPLES) -> int:
"""Считает rail-коды во всём файле, сохраняя ограниченный объём памяти."""
cs8_sample_count(path)
clipping = 0
with path.open("rb") as stream:
while raw_bytes := stream.read(block_samples * 2):
values = np.frombuffer(raw_bytes, dtype=np.int8)
clipping += int(np.count_nonzero((values == -128) | (values == 127)))
return clipping
def _is_overlap_duplicate(
packets: list[ReceivedIqPacket],
candidate: ReceivedIqPacket,
) -> bool:
tolerance = samples_per_symbol(SYMBOL_RATE)
return any(
packet.packet == candidate.packet
and abs(packet.start_sample - candidate.start_sample) <= tolerance
for packet in packets
)
def receive_cs8_windows(
path: Path,
windows: Iterable[SampleWindow],
*,
decoder_backend: str = "numba",
chunk_samples: int = DEFAULT_CHUNK_SAMPLES,
overlap_samples: int | None = None,
) -> StreamingReception:
"""Принимает заданные окна частями с перекрытием и удаляет повторы."""
selected = _merge_windows(windows)
overlap = required_overlap_samples() if overlap_samples is None else overlap_samples
if chunk_samples <= overlap or overlap < required_overlap_samples():
raise ValueError("chunk должен быть длиннее полного overlap RF-001")
raw = np.memmap(path, dtype=np.int8, mode="r")
packets: list[ReceivedIqPacket] = []
candidate_count = 0
rejected_candidates = 0
stage_time: dict[str, float] = {}
maximum_loaded = 0
for selected_window in selected:
for primary_start in range(selected_window.start, selected_window.stop, chunk_samples):
primary_stop = min(selected_window.stop, primary_start + chunk_samples)
read_start = max(selected_window.start, primary_start - overlap)
read_stop = min(selected_window.stop, primary_stop + overlap)
iq = read_cs8_window(raw, read_start, read_stop)
maximum_loaded = max(maximum_loaded, int(iq.size))
received = receive_iq_stream(
iq,
carrier_hz=NOMINAL_CARRIER_HZ,
decoder_backend=decoder_backend,
)
candidate_count += received.candidate_count
rejected_candidates += received.rejected_candidates
for name, duration in received.stage_time_s.items():
stage_time[name] = stage_time.get(name, 0.0) + duration
for packet in received.packets:
global_packet = replace(
packet,
start_sample=packet.start_sample + read_start,
)
if not primary_start <= global_packet.start_sample < primary_stop:
continue
if not _is_overlap_duplicate(packets, global_packet):
packets.append(global_packet)
del iq
packets.sort(key=lambda packet: packet.start_sample)
return StreamingReception(
IqStreamResult(
packets=tuple(packets),
candidate_count=candidate_count,
rejected_candidates=rejected_candidates,
stage_time_s=stage_time,
),
selected,
"EXPLICIT",
overlap,
maximum_loaded,
count_cs8_clipping(path),
)
def decode_cs8_streaming(
path: Path,
*,
capture_started_at: str | None = None,
tx_started_at: str | None = None,
tx_finished_at: str | None = None,
decoder_backend: str = "numba",
chunk_samples: int = DEFAULT_CHUNK_SAMPLES,
margin_seconds: float = TIMESTAMP_MARGIN_SECONDS,
) -> StreamingReception:
"""Выбирает timestamp-окно либо потоковый power locator и запускает приём."""
total_samples = cs8_sample_count(path)
timestamp_values = (capture_started_at, tx_started_at, tx_finished_at)
if all(value is not None for value in timestamp_values):
windows = (
timestamp_sample_window(
capture_started_at=str(capture_started_at),
tx_started_at=str(tx_started_at),
tx_finished_at=str(tx_finished_at),
total_samples=total_samples,
margin_seconds=margin_seconds,
),
)
source = "TX_TIMESTAMPS"
elif any(value is not None for value in timestamp_values):
raise ValueError("для timestamp-окна нужны все три времени RX/TX")
else:
windows = locate_power_windows(path)
source = "STREAMING_POWER_LOCATOR"
if not windows:
return StreamingReception(
IqStreamResult((), 0, 0, {}),
(),
source,
required_overlap_samples(),
0,
count_cs8_clipping(path),
)
reception = receive_cs8_windows(
path,
windows,
decoder_backend=decoder_backend,
chunk_samples=chunk_samples,
)
return replace(reception, window_source=source)

View File

@@ -14,8 +14,9 @@ from typing import Any, Mapping
SCHEMA_VERSION = 1
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_003"
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_003"
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_004"
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_004"
EXPECTED_CAPTURE_DURATION_SECONDS = 60.0
CHANNEL_TEST_TASK_ID = "RF_HANDOFF_CHANNEL_TEST_001"
READY_STATE = "RX_READY_FOR_TX"
COMPLETE_STATE = "CAPTURE_COMPLETE"
@@ -25,6 +26,21 @@ 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}$")
_TRANSIENT_PUSH_ERRORS = (
"authentication failed",
"failed to authenticate",
"could not resolve host",
"connection reset",
"connection timed out",
"failed to connect",
"remote end hung up",
"rpc failed",
"schannel",
"ssl",
"tls",
"the requested url returned error: 5",
"unable to access",
)
class ResultChannelError(RuntimeError):
@@ -319,7 +335,11 @@ def evaluate_ready(
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:
if not (
EXPECTED_CAPTURE_DURATION_SECONDS - 1.0
<= planned_duration
<= EXPECTED_CAPTURE_DURATION_SECONDS + 1.0
):
return ReadyEvaluation(False, "WRONG_CAPTURE_WINDOW")
age = (now_utc - published).total_seconds()
if age < -5.0:
@@ -386,6 +406,32 @@ class GitResultChannel:
def branch_exists(self) -> bool:
return self.remote_head() is not None
def verify_write_access(self) -> str:
"""Проверить право записи dry-run push без коммита и изменения ветки."""
remote_head = self.fetch()
dry_run = self._run(
[
"push",
"--dry-run",
"--porcelain",
self.remote,
f"{remote_head}:refs/heads/{self.branch}",
],
check=False,
)
if dry_run.returncode != 0:
raise ResultChannelError(
"authenticated result-branch push preflight failed: "
f"{dry_run.stderr.strip()}"
)
confirmed_head = self.remote_head()
if confirmed_head != remote_head:
raise ResultChannelError(
"result branch changed during write preflight"
)
return remote_head
def _tracking_reference(self) -> str:
return f"refs/remotes/{self.remote}/{self.branch}"
@@ -553,6 +599,66 @@ class GitResultChannel:
return commit
return None
@staticmethod
def _is_transient_push_error(stderr: str) -> bool:
normalized = stderr.casefold()
return any(pattern in normalized for pattern in _TRANSIENT_PUSH_ERRORS)
def _push_exact_commit(
self,
checkout: Path,
commit: str,
remote_before: str,
) -> str:
"""Отправить неизменный коммит с одной безопасной повторной попыткой."""
push_arguments = [
"push",
"--quiet",
self.remote,
f"{commit}:refs/heads/{self.branch}",
]
first = self._run(push_arguments, cwd=checkout, check=False)
if first.returncode == 0:
remote_after = self.remote_head()
if remote_after != commit:
raise ResultChannelError(
"remote result HEAD does not confirm the published commit"
)
return commit
observed_after_failure = self.remote_head()
if observed_after_failure == commit:
return commit
if observed_after_failure != remote_before:
raise ResultChannelError(
"result push failed and remote branch changed to a conflicting "
f"commit: {remote_before} -> {observed_after_failure}"
)
if not self._is_transient_push_error(first.stderr):
raise ResultChannelError(f"result push failed: {first.stderr.strip()}")
fetched_head = self.fetch()
if fetched_head != remote_before:
raise ResultChannelError(
"remote result branch changed before the single retry"
)
second = self._run(push_arguments, cwd=checkout, check=False)
if second.returncode != 0:
observed_after_retry = self.remote_head()
if observed_after_retry == commit:
return commit
raise ResultChannelError(
"result push failed after one retry of the same commit: "
f"{second.stderr.strip()}"
)
remote_after = self.remote_head()
if remote_after != commit:
raise ResultChannelError(
"remote result HEAD does not confirm the retried commit"
)
return commit
def publish(self, result: RxHandoffResult) -> PublishOutcome:
validate_result(result)
remote_before = self.fetch()
@@ -610,25 +716,11 @@ class GitResultChannel:
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,
remote_after = self._push_exact_commit(
checkout,
commit,
remote_before,
)
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:

View File

@@ -23,6 +23,8 @@ if str(ROOT) not in sys.path:
from tools.rf_handoff_channel import ( # noqa: E402
EXPECTED_RX_TASK_ID,
GitResultChannel,
READY_STATE,
RxHandoffResult,
load_result,
)
from tools.tx_handoff_channel import ( # noqa: E402
@@ -571,6 +573,7 @@ def publish_result(arguments: argparse.Namespace) -> int:
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())
require_live_ready_window(result)
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:
@@ -613,10 +616,55 @@ def publish_result(arguments: argparse.Namespace) -> int:
return 0
def require_live_ready_window(
result: RxHandoffResult,
*,
now: datetime | None = None,
) -> None:
"""Запретить READY после окончания конечного окна захвата."""
if result.state != READY_STATE:
return
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
expected_end = datetime.fromisoformat(
result.expected_capture_end.replace("Z", "+00:00")
).astimezone(timezone.utc)
if current >= expected_end:
raise ValueError("READY capture window has already ended")
def verify_result_channel_write_access(
repository: Path,
*,
remote: str = "origin",
result_branch: str = "rx-node",
output_function: Callable[[str], None] = print,
) -> str:
"""Проверить право публикации результата RX без изменения ветки."""
channel = GitResultChannel(
repository,
remote=remote,
branch=result_branch,
)
remote_head = channel.verify_write_access()
output_function("RX-NODE WRITE PREFLIGHT = PASS")
output_function(f"RX-NODE REMOTE HEAD = {remote_head}")
output_function("RF TX = 0")
return remote_head
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.check_result_write:
verify_result_channel_write_access(
arguments.repository.resolve(),
remote=arguments.remote,
result_branch=arguments.result_branch,
)
return 0
if arguments.request_stop:
stopped = request_stop(state_path, arguments.stop_timeout)
print("WATCHER STOPPED" if stopped else "WATCHER STOP TIMEOUT")
@@ -670,6 +718,12 @@ def run_watcher(arguments: argparse.Namespace) -> int:
task_text, source_head = source.read()
task = parse_task(task_text)
if task.task_id == EXPECTED_RX_TASK_ID and not arguments.dry_run:
if is_new_task(task, state):
verify_result_channel_write_access(
arguments.repository.resolve(),
remote=arguments.remote,
result_branch=arguments.result_branch,
)
confirm_main_channel_test(
arguments.repository.resolve(),
state,
@@ -726,6 +780,7 @@ def build_parser() -> argparse.ArgumentParser:
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("--check-result-write", action="store_true")
parser.add_argument("--result-branch", default="rx-node")
parser.add_argument("--acknowledgement-branch", default="tx-node")
return parser

View File

@@ -37,9 +37,13 @@ from tools.tx_handoff_channel import ( # noqa: E402
CHANNEL_COMPLETE_OBSERVED,
CHANNEL_READY_OBSERVED,
RX_READY_OBSERVED,
TX_AUTHORIZATION_CREATED,
TX_ACK_SCHEMA_VERSION,
TX_AUTHORIZED,
TX_COMPLETE,
TX_STARTED,
GitAcknowledgementChannel,
TxHandoffAcknowledgement,
load_acknowledgement,
)
@@ -445,12 +449,12 @@ def acknowledgement_for_result(
elif status == "RED_APPROVAL_BLOCKED":
acknowledgement_state = RX_READY_OBSERVED
elif status == "TX_AUTHORIZATION_PREPARED":
acknowledgement_state = TX_AUTHORIZATION_CREATED
acknowledgement_state = TX_AUTHORIZED
rf_tx_authorized = True
if acknowledgement_state is None:
return None
return TxHandoffAcknowledgement(
schema_version=1,
schema_version=TX_ACK_SCHEMA_VERSION,
task_id=result.task_id,
state=acknowledgement_state,
source_rx_commit=result_head,
@@ -461,6 +465,7 @@ def acknowledgement_for_result(
rf_tx_authorized=rf_tx_authorized,
rf_tx_started=False,
rf_tx=0,
tx_task_id=(EXPECTED_TX_TASK_ID if rf_tx_authorized else None),
)
@@ -536,6 +541,42 @@ def process_history_cycle(
def run_watcher(arguments: argparse.Namespace) -> int:
state_path = arguments.state_file.resolve()
if arguments.publish_tx_event is not None:
if arguments.dry_run:
raise ValueError("--publish-tx-event cannot be combined with --dry-run")
repository = arguments.repository.resolve()
event = load_acknowledgement(arguments.publish_tx_event.resolve())
if event.state not in {TX_STARTED, TX_COMPLETE}:
raise ValueError("published execution event has the wrong state")
branch = _git_output(repository, ["branch", "--show-current"])
if branch != arguments.source_branch:
raise ValueError("TX event publisher is on the wrong source branch")
if _git_output(repository, ["status", "--porcelain", "--untracked-files=no"]):
raise ValueError("tracked worktree must be clean before TX event publication")
_git_output(
repository,
["fetch", "--quiet", arguments.remote, arguments.source_branch],
)
head = _git_output(repository, ["rev-parse", "HEAD"])
remote_head = _git_output(
repository,
["rev-parse", f"{arguments.remote}/{arguments.source_branch}"],
)
if head != remote_head:
raise ValueError("current HEAD disagrees with remote source branch")
if event.source_head != head:
raise ValueError("TX event SOURCE_HEAD disagrees with current HEAD")
publisher = GitAcknowledgementChannel(
repository,
remote=arguments.remote,
branch=arguments.acknowledgement_branch,
)
outcome = publisher.publish_execution_event(event)
print(f"TX EVENT PUBLISH = {outcome.status}")
print(f"TX EVENT STATE = {event.state}")
print(f"TX EVENT HEAD = {outcome.commit}")
print(f"TX EVENT REMOTE HEAD = {outcome.remote_head}")
return 0
if arguments.request_stop:
stopped = request_stop(state_path, arguments.stop_timeout)
print("TX WATCHER STOPPED" if stopped else "TX WATCHER STOP TIMEOUT")
@@ -664,6 +705,7 @@ def build_parser() -> argparse.ArgumentParser:
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("--publish-tx-event", 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)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from dataclasses import asdict, dataclass, fields
from dataclasses import asdict, dataclass, fields, replace
from datetime import datetime, timezone
import hashlib
import json
@@ -22,7 +22,8 @@ from tools.rf_handoff_channel import (
)
TX_ACK_SCHEMA_VERSION = 1
TX_ACK_SCHEMA_VERSION = 2
LEGACY_TX_ACK_SCHEMA_VERSION = 1
TX_RESULT_JSON_PATH = "docs/handoff/results/TX_RESULT.json"
TX_RESULT_MARKDOWN_PATH = "docs/handoff/results/TX_RESULT.md"
TX_RESULT_PATHS = frozenset((TX_RESULT_JSON_PATH, TX_RESULT_MARKDOWN_PATH))
@@ -32,6 +33,7 @@ CHANNEL_READY_OBSERVED = "CHANNEL_READY_OBSERVED"
CHANNEL_COMPLETE_OBSERVED = "CHANNEL_COMPLETE_OBSERVED"
RX_READY_OBSERVED = "RX_READY_OBSERVED"
TX_AUTHORIZATION_CREATED = "TX_AUTHORIZATION_CREATED"
TX_AUTHORIZED = "TX_AUTHORIZED"
TX_STARTED = "TX_STARTED"
TX_COMPLETE = "TX_COMPLETE"
TX_ACK_STATES = frozenset(
@@ -40,6 +42,7 @@ TX_ACK_STATES = frozenset(
CHANNEL_COMPLETE_OBSERVED,
RX_READY_OBSERVED,
TX_AUTHORIZATION_CREATED,
TX_AUTHORIZED,
TX_STARTED,
TX_COMPLETE,
)
@@ -74,6 +77,12 @@ class TxHandoffAcknowledgement:
rf_tx_authorized: bool
rf_tx_started: bool
rf_tx: int
tx_task_id: str | None = None
tx_started_at: str | None = None
tx_finished_at: str | None = None
tx_process_exit_code: int | None = None
tx_underruns: int | None = None
tx_errors: int | None = None
@dataclass(frozen=True)
@@ -99,8 +108,13 @@ def validate_acknowledgement(
) -> TxHandoffAcknowledgement:
if type(acknowledgement.schema_version) is not int:
raise ValueError("schema_version must contain an integer")
if acknowledgement.schema_version != TX_ACK_SCHEMA_VERSION:
raise ValueError(f"schema_version must be {TX_ACK_SCHEMA_VERSION}")
if acknowledgement.schema_version not in {
LEGACY_TX_ACK_SCHEMA_VERSION,
TX_ACK_SCHEMA_VERSION,
}:
raise ValueError(
"schema_version must be a supported TX acknowledgement version"
)
string_values = (
acknowledgement.task_id,
acknowledgement.state,
@@ -121,7 +135,7 @@ def validate_acknowledgement(
raise ValueError("source_rx_digest must be a lowercase SHA256")
if not _HEAD_RE.fullmatch(acknowledgement.source_head):
raise ValueError("source_head must be a lowercase 40-character hash")
_parse_utc(acknowledgement.observed_at)
observed_at = _parse_utc(acknowledgement.observed_at)
if (
type(acknowledgement.main_watcher_pid) is not int
or acknowledgement.main_watcher_pid <= 0
@@ -133,6 +147,58 @@ def validate_acknowledgement(
raise ValueError("rf_tx_started must contain a boolean")
if type(acknowledgement.rf_tx) is not int or acknowledgement.rf_tx not in {0, 1}:
raise ValueError("rf_tx must be 0 or 1")
optional_integers = (
acknowledgement.tx_process_exit_code,
acknowledgement.tx_underruns,
acknowledgement.tx_errors,
)
if any(value is not None and type(value) is not int for value in optional_integers):
raise ValueError("TX counters and exit code must contain integers or null")
if any(
value is not None and value < 0
for value in (acknowledgement.tx_underruns, acknowledgement.tx_errors)
):
raise ValueError("TX error counters cannot be negative")
optional_strings = (
acknowledgement.tx_task_id,
acknowledgement.tx_started_at,
acknowledgement.tx_finished_at,
)
if any(value is not None and type(value) is not str for value in optional_strings):
raise ValueError("optional TX fields must contain strings or null")
if (
acknowledgement.tx_task_id is not None
and not _TASK_ID_RE.fullmatch(acknowledgement.tx_task_id)
):
raise ValueError("tx_task_id is invalid")
tx_started_at = (
_parse_utc(acknowledgement.tx_started_at)
if acknowledgement.tx_started_at is not None
else None
)
tx_finished_at = (
_parse_utc(acknowledgement.tx_finished_at)
if acknowledgement.tx_finished_at is not None
else None
)
if (
tx_started_at is not None
and tx_finished_at is not None
and tx_finished_at < tx_started_at
):
raise ValueError("tx_finished_at cannot precede tx_started_at")
if acknowledgement.schema_version == LEGACY_TX_ACK_SCHEMA_VERSION and any(
value is not None
for value in (
acknowledgement.tx_task_id,
acknowledgement.tx_started_at,
acknowledgement.tx_finished_at,
acknowledgement.tx_process_exit_code,
acknowledgement.tx_underruns,
acknowledgement.tx_errors,
)
):
raise ValueError("legacy acknowledgement cannot contain TX event metadata")
if (
acknowledgement.state
@@ -153,41 +219,146 @@ def validate_acknowledgement(
CHANNEL_COMPLETE_OBSERVED,
} and acknowledgement.task_id != CHANNEL_TEST_TASK_ID:
raise ValueError("channel acknowledgement has the wrong task_id")
if (
acknowledgement.state == TX_AUTHORIZATION_CREATED
and (
not acknowledgement.rf_tx_authorized
or acknowledgement.rf_tx_started
or acknowledgement.rf_tx != 0
)
if acknowledgement.state == TX_AUTHORIZATION_CREATED and (
acknowledgement.schema_version != LEGACY_TX_ACK_SCHEMA_VERSION
or not acknowledgement.rf_tx_authorized
or acknowledgement.rf_tx_started
or acknowledgement.rf_tx != 0
):
raise ValueError("legacy TX authorization state is inconsistent")
if acknowledgement.state == TX_AUTHORIZED and (
acknowledgement.schema_version != TX_ACK_SCHEMA_VERSION
or not acknowledgement.rf_tx_authorized
or acknowledgement.rf_tx_started
or acknowledgement.rf_tx != 0
or acknowledgement.tx_task_id is None
or acknowledgement.tx_started_at is not None
or acknowledgement.tx_finished_at is not None
or acknowledgement.tx_process_exit_code is not None
):
raise ValueError("TX authorization state is inconsistent")
if acknowledgement.state in {TX_STARTED, TX_COMPLETE} and not (
acknowledgement.rf_tx_authorized
and acknowledgement.rf_tx_started
and acknowledgement.rf_tx == 1
if acknowledgement.state == TX_STARTED and not (
acknowledgement.rf_tx_authorized
and acknowledgement.rf_tx_started
and acknowledgement.rf_tx == 1
and acknowledgement.tx_task_id is not None
and acknowledgement.tx_started_at is not None
and acknowledgement.tx_finished_at is None
and acknowledgement.tx_process_exit_code is None
):
raise ValueError("TX execution state is inconsistent")
raise ValueError("TX start state is inconsistent")
if acknowledgement.state == TX_STARTED and (
tx_started_at is None or observed_at < tx_started_at
):
raise ValueError("TX start observation cannot precede process start")
if acknowledgement.state == TX_COMPLETE and not (
acknowledgement.rf_tx_authorized
and acknowledgement.rf_tx_started
and acknowledgement.rf_tx == 1
and acknowledgement.tx_task_id is not None
and acknowledgement.tx_started_at is not None
and acknowledgement.tx_finished_at is not None
and acknowledgement.tx_process_exit_code is not None
and acknowledgement.tx_underruns is not None
and acknowledgement.tx_errors is not None
):
raise ValueError("TX completion state is inconsistent")
if acknowledgement.state == TX_COMPLETE and (
tx_finished_at is None or observed_at < tx_finished_at
):
raise ValueError("TX completion observation cannot precede process finish")
if acknowledgement.state in {
CHANNEL_READY_OBSERVED,
CHANNEL_COMPLETE_OBSERVED,
RX_READY_OBSERVED,
} and any(
value is not None
for value in (
acknowledgement.tx_task_id,
acknowledgement.tx_started_at,
acknowledgement.tx_finished_at,
acknowledgement.tx_process_exit_code,
acknowledgement.tx_underruns,
acknowledgement.tx_errors,
)
):
raise ValueError("observation state cannot contain TX event metadata")
return acknowledgement
def build_tx_execution_event(
authorization: TxHandoffAcknowledgement,
*,
state: str,
observed_at: str,
tx_started_at: str,
tx_finished_at: str | None = None,
process_exit_code: int | None = None,
underruns: int | None = None,
errors: int | None = None,
) -> TxHandoffAcknowledgement:
"""Построить строгое событие TX_STARTED или TX_COMPLETE из разрешения."""
validate_acknowledgement(authorization)
if authorization.state != TX_AUTHORIZED:
raise ValueError("TX execution event requires a TX_AUTHORIZED source")
if state not in {TX_STARTED, TX_COMPLETE}:
raise ValueError("TX execution event state is not supported")
event = replace(
authorization,
state=state,
observed_at=observed_at,
rf_tx_started=True,
rf_tx=1,
tx_started_at=tx_started_at,
tx_finished_at=tx_finished_at,
tx_process_exit_code=process_exit_code,
tx_underruns=underruns,
tx_errors=errors,
)
return validate_acknowledgement(event)
def acknowledgement_from_mapping(
raw: Mapping[str, Any],
) -> TxHandoffAcknowledgement:
expected_fields = {field.name for field in fields(TxHandoffAcknowledgement)}
optional_event_fields = {
"tx_task_id",
"tx_started_at",
"tx_finished_at",
"tx_process_exit_code",
"tx_underruns",
"tx_errors",
}
actual_fields = set(raw)
if actual_fields != expected_fields:
schema_version = raw.get("schema_version")
legacy_fields = expected_fields - optional_event_fields
if schema_version == LEGACY_TX_ACK_SCHEMA_VERSION and actual_fields == legacy_fields:
normalized = {**raw, **dict.fromkeys(optional_event_fields)}
elif actual_fields == expected_fields:
normalized = dict(raw)
else:
missing = sorted(expected_fields - actual_fields)
extra = sorted(actual_fields - expected_fields)
raise ValueError(
f"acknowledgement fields disagree: missing={missing}, extra={extra}"
)
acknowledgement = TxHandoffAcknowledgement(
**{name: raw[name] for name in expected_fields}
**{name: normalized[name] for name in expected_fields}
)
return validate_acknowledgement(acknowledgement)
def load_acknowledgement(path: Path) -> TxHandoffAcknowledgement:
"""Прочитать строгое малое TX-событие из локального JSON."""
raw = json.loads(path.read_text(encoding="utf-8-sig"))
if not isinstance(raw, dict):
raise ValueError("TX acknowledgement root must be a JSON object")
return acknowledgement_from_mapping(raw)
def canonical_acknowledgement_json(
acknowledgement: TxHandoffAcknowledgement,
) -> str:
@@ -226,6 +397,12 @@ def render_acknowledgement_markdown(
f"{'YES' if acknowledgement.rf_tx_authorized else 'NO'}",
f"RF_TX_STARTED = {'YES' if acknowledgement.rf_tx_started else 'NO'}",
f"RF_TX = {acknowledgement.rf_tx}",
f"TX_TASK_ID = {acknowledgement.tx_task_id}",
f"TX_STARTED_AT = {acknowledgement.tx_started_at}",
f"TX_FINISHED_AT = {acknowledgement.tx_finished_at}",
f"TX_PROCESS_EXIT_CODE = {acknowledgement.tx_process_exit_code}",
f"TX_UNDERRUNS = {acknowledgement.tx_underruns}",
f"TX_ERRORS = {acknowledgement.tx_errors}",
"",
"Raw IQ, video, waveforms, source code, local state and large logs "
"are not stored in this branch.",
@@ -368,10 +545,59 @@ class GitAcknowledgementChannel(GitResultChannel):
== acknowledgement.rf_tx_authorized
and observed.rf_tx_started == acknowledgement.rf_tx_started
and observed.rf_tx == acknowledgement.rf_tx
and observed.tx_task_id == acknowledgement.tx_task_id
and observed.tx_started_at == acknowledgement.tx_started_at
and observed.tx_finished_at == acknowledgement.tx_finished_at
and observed.tx_process_exit_code
== acknowledgement.tx_process_exit_code
and observed.tx_underruns == acknowledgement.tx_underruns
and observed.tx_errors == acknowledgement.tx_errors
):
return commit
return None
@staticmethod
def _same_execution_binding(
left: TxHandoffAcknowledgement,
right: TxHandoffAcknowledgement,
) -> bool:
return (
left.task_id == right.task_id
and left.source_rx_commit == right.source_rx_commit
and left.source_rx_digest == right.source_rx_digest
and left.source_head == right.source_head
and left.tx_task_id == right.tx_task_id
)
def publish_execution_event(
self,
event: TxHandoffAcknowledgement,
) -> PublishOutcome:
"""Опубликовать TX-событие только после связанного TX_AUTHORIZED."""
validate_acknowledgement(event)
if event.state not in {TX_STARTED, TX_COMPLETE}:
raise ResultChannelError("execution event must be TX_STARTED or TX_COMPLETE")
commits, _remote_head = self.commits_since(None)
authorization_found = False
start_found = event.state == TX_STARTED
for commit in commits:
observed, _digest, _head = self.read_commit(commit)
if not self._same_execution_binding(observed, event):
continue
if observed.state == TX_AUTHORIZED:
authorization_found = True
if (
observed.state == TX_STARTED
and observed.tx_started_at == event.tx_started_at
):
start_found = True
if not authorization_found:
raise ResultChannelError("matching TX_AUTHORIZED event is absent")
if not start_found:
raise ResultChannelError("matching TX_STARTED event is absent")
return self.publish(event)
def publish(
self,
acknowledgement: TxHandoffAcknowledgement,
@@ -471,25 +697,11 @@ class GitAcknowledgementChannel(GitResultChannel):
raise ResultChannelError(
"committed tx-node tree violates the path allowlist"
)
push = self._run(
[
"push",
"--quiet",
self.remote,
f"HEAD:refs/heads/{self.branch}",
],
cwd=checkout,
check=False,
self._push_exact_commit(
checkout,
commit,
remote_before,
)
if push.returncode != 0:
raise ResultChannelError(
f"acknowledgement push failed: {push.stderr.strip()}"
)
remote_after = self.remote_head()
if remote_after != commit:
raise ResultChannelError(
"remote tx-node HEAD does not confirm the published commit"
)
return PublishOutcome(
"PUBLISHED",
commit,