Process all RX handoff commits in TX watcher

This commit is contained in:
LittleSam129
2026-09-22 11:46:53 +03:00
parent a516b99015
commit a9317989a2
6 changed files with 544 additions and 43 deletions

View File

@@ -27,6 +27,7 @@ from tools.rf_handoff_channel import ( # noqa: E402
READY_STATE,
ResultBranchMissing,
ResultChannelError,
ResultHistoryRewritten,
RxHandoffResult,
evaluate_ready,
load_result,
@@ -36,6 +37,7 @@ from tools.rf_handoff_channel import ( # noqa: E402
DEFAULT_STATE_PATH = ROOT / ".local" / "tx_agent_watcher_state.json"
DEFAULT_OUTBOX = ROOT / ".local" / "tx_authorizations"
STATE_VERSION = 2
_HEAD_RE = re.compile(r"^[0-9a-f]{40}$")
@@ -44,6 +46,15 @@ class ResultSource(Protocol):
def read(self) -> tuple[RxHandoffResult, str, str]: ...
def read_commit(self, commit: str) -> tuple[RxHandoffResult, str, str]: ...
def commits_since(
self,
last_processed_commit: str | None,
) -> tuple[list[str], str]: ...
def find_commit_by_digest(self, digest: str) -> str | None: ...
class TxExecutor(Protocol):
def prepare(
@@ -58,9 +69,12 @@ class TxExecutor(Protocol):
@dataclass
class TxWatcherState:
state_version: int = STATE_VERSION
running: bool = False
pid: int | None = None
last_seen_digest: str | None = None
last_seen_commit: str | None = None
last_processed_rx_node_commit: str | None = None
last_prompted_digest: str | None = None
last_authorized_digest: str | None = None
channel_test_ready_digest: str | None = None
@@ -80,6 +94,21 @@ class StaticResultSource:
def read(self) -> tuple[RxHandoffResult, str, str]:
return self.result, self.digest, self.result.source_head
def read_commit(self, commit: str) -> tuple[RxHandoffResult, str, str]:
if commit != self.result.source_head:
raise ResultHistoryRewritten("static result commit changed")
return self.read()
def commits_since(
self,
last_processed_commit: str | None,
) -> tuple[list[str], str]:
head = self.result.source_head
return ([] if last_processed_commit == head else [head], head)
def find_commit_by_digest(self, digest: str) -> str | None:
return self.result.source_head if digest == self.digest else None
class ManualTxExecutor:
"""Write a local authorization record; never access SDR hardware."""
@@ -131,7 +160,12 @@ def load_state(path: Path) -> TxWatcherState:
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)
version = raw.get("state_version", 1)
if type(version) is not int or version not in {1, STATE_VERSION}:
raise ValueError("TX watcher state version is not supported")
migrated = dict(raw)
migrated["state_version"] = STATE_VERSION
return TxWatcherState(**migrated)
def save_state(path: Path, state: TxWatcherState) -> None:
@@ -352,6 +386,77 @@ def _git_output(repository: Path, arguments: list[str]) -> str:
).stdout.strip()
def migrate_state_cursor(
source: ResultSource,
state: TxWatcherState,
) -> bool:
"""Map a version-1 digest-only state to its exact result commit."""
state.state_version = STATE_VERSION
if state.last_processed_rx_node_commit is not None:
if state.last_seen_commit is None:
state.last_seen_commit = state.last_processed_rx_node_commit
return True
return False
if state.last_seen_digest is None:
return False
matched_commit = source.find_commit_by_digest(state.last_seen_digest)
if matched_commit is None:
raise ResultHistoryRewritten(
"stored result digest cannot be mapped to origin/rx-node history"
)
state.last_seen_commit = matched_commit
state.last_processed_rx_node_commit = matched_commit
return True
def process_history_cycle(
source: ResultSource,
state: TxWatcherState,
executor: TxExecutor,
*,
now_function: Callable[[], datetime],
input_function: Callable[[str], str],
save_function: Callable[[TxWatcherState], None],
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,
) -> list[tuple[str, str]]:
"""Process every new result commit in chronological order."""
commits, _remote_head = source.commits_since(
state.last_processed_rx_node_commit
)
outcomes: list[tuple[str, str]] = []
for commit in commits:
result, digest, result_head = source.read_commit(commit)
status, state = handle_result(
result,
digest,
result_head,
state,
source,
executor,
now_function=now_function,
input_function=input_function,
output_function=output_function,
dry_run=dry_run,
expected_source_head=expected_source_head,
max_age_seconds=max_age_seconds,
min_remaining_seconds=min_remaining_seconds,
)
state.last_seen_commit = result_head
state.last_processed_rx_node_commit = result_head
state.state_version = STATE_VERSION
save_function(state)
outcomes.append((result_head, status))
if status != "DUPLICATE_SUPPRESSED":
output_function(f"TX WATCHER STATUS = {status}")
return outcomes
def run_watcher(arguments: argparse.Namespace) -> int:
state_path = arguments.state_file.resolve()
if arguments.request_stop:
@@ -375,26 +480,6 @@ def run_watcher(arguments: argparse.Namespace) -> int:
stop_path = stop_path_for(state_path)
stop_path.unlink(missing_ok=True)
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(
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:
@@ -405,26 +490,51 @@ def run_watcher(arguments: argparse.Namespace) -> int:
return interruptible_input(prompt, stop_path)
try:
if arguments.result_file is not None:
static_source = StaticResultSource(load_result(arguments.result_file))
source: ResultSource = static_source
expected_source_head = (
arguments.expected_source_head or static_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(
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"
)
if migrate_state_cursor(source, state):
save_state(state_path, state)
while not stop_path.exists():
try:
result, digest, result_head = source.read()
status, state = handle_result(
result,
digest,
result_head,
state,
process_history_cycle(
source,
state,
executor,
now_function=lambda: datetime.now(timezone.utc),
input_function=input_function,
save_function=lambda current: save_state(
state_path,
current,
),
dry_run=arguments.dry_run,
expected_source_head=expected_source_head,
max_age_seconds=arguments.max_age,
min_remaining_seconds=arguments.min_remaining,
)
save_state(state_path, state)
if status != "DUPLICATE_SUPPRESSED":
print(f"TX WATCHER STATUS = {status}")
if arguments.once or arguments.dry_run:
break
except (