Process all RX handoff commits in TX watcher
This commit is contained in:
@@ -35,6 +35,10 @@ class ResultBranchMissing(ResultChannelError):
|
||||
"""The configured result branch does not exist on the remote."""
|
||||
|
||||
|
||||
class ResultHistoryRewritten(ResultChannelError):
|
||||
"""The result branch no longer descends from the processed commit."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RxHandoffResult:
|
||||
schema_version: int
|
||||
@@ -382,6 +386,32 @@ class GitResultChannel:
|
||||
def branch_exists(self) -> bool:
|
||||
return self.remote_head() is not None
|
||||
|
||||
def _tracking_reference(self) -> str:
|
||||
return f"refs/remotes/{self.remote}/{self.branch}"
|
||||
|
||||
def _optional_revision(self, revision: str) -> str | None:
|
||||
result = self._run(["rev-parse", "--verify", revision], check=False)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
value = result.stdout.strip()
|
||||
if not _HEAD_RE.fullmatch(value):
|
||||
raise ResultChannelError(f"invalid Git revision for {revision}")
|
||||
return value
|
||||
|
||||
def _is_ancestor(self, ancestor: str, descendant: str) -> bool:
|
||||
result = self._run(
|
||||
["merge-base", "--is-ancestor", ancestor, descendant],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
if result.returncode == 1:
|
||||
return False
|
||||
raise ResultChannelError(
|
||||
f"cannot compare result history {ancestor}..{descendant}: "
|
||||
f"{result.stderr.strip()}"
|
||||
)
|
||||
|
||||
def fetch(self) -> str:
|
||||
initial_fetch = self._run(
|
||||
["fetch", "--quiet", self.remote, self.branch],
|
||||
@@ -399,8 +429,17 @@ class GitResultChannel:
|
||||
)
|
||||
tracking_reference = (
|
||||
f"refs/heads/{self.branch}:"
|
||||
f"refs/remotes/{self.remote}/{self.branch}"
|
||||
f"{self._tracking_reference()}"
|
||||
)
|
||||
previous_tracking_head = self._optional_revision(self._tracking_reference())
|
||||
if (
|
||||
previous_tracking_head is not None
|
||||
and not self._is_ancestor(previous_tracking_head, remote_head)
|
||||
):
|
||||
raise ResultHistoryRewritten(
|
||||
"remote result branch is not a fast-forward of the local "
|
||||
f"tracking branch: {previous_tracking_head} -> {remote_head}"
|
||||
)
|
||||
tracking_fetch = self._run(
|
||||
["fetch", "--quiet", self.remote, tracking_reference],
|
||||
check=False,
|
||||
@@ -411,7 +450,7 @@ class GitResultChannel:
|
||||
f"{tracking_fetch.stderr.strip()}"
|
||||
)
|
||||
tracking_head = self._run(
|
||||
["rev-parse", f"refs/remotes/{self.remote}/{self.branch}"]
|
||||
["rev-parse", self._tracking_reference()]
|
||||
).stdout.strip()
|
||||
if tracking_head != remote_head:
|
||||
raise ResultChannelError("remote-tracking result branch is stale")
|
||||
@@ -419,16 +458,100 @@ class GitResultChannel:
|
||||
|
||||
def read(self) -> tuple[RxHandoffResult, str, str]:
|
||||
remote_head = self.fetch()
|
||||
reference = f"refs/remotes/{self.remote}/{self.branch}"
|
||||
json_result = self._run(["show", f"{reference}:{RESULT_JSON_PATH}"])
|
||||
return self.read_commit(remote_head)
|
||||
|
||||
def read_commit(self, commit: str) -> tuple[RxHandoffResult, str, str]:
|
||||
"""Read and validate the result stored by one exact commit."""
|
||||
|
||||
if not _HEAD_RE.fullmatch(commit):
|
||||
raise ResultChannelError("result commit must be a full lowercase Git hash")
|
||||
commit_check = self._run(
|
||||
["cat-file", "-e", f"{commit}^{{commit}}"],
|
||||
check=False,
|
||||
)
|
||||
if commit_check.returncode != 0:
|
||||
raise ResultChannelError(f"result commit is unavailable: {commit}")
|
||||
json_result = self._run(
|
||||
["show", f"{commit}:{RESULT_JSON_PATH}"],
|
||||
check=False,
|
||||
)
|
||||
if json_result.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"result JSON is unavailable at commit {commit}: "
|
||||
f"{json_result.stderr.strip()}"
|
||||
)
|
||||
raw = json.loads(json_result.stdout)
|
||||
if not isinstance(raw, dict):
|
||||
raise ResultChannelError("remote result JSON root is not an object")
|
||||
raise ResultChannelError(
|
||||
f"result JSON root is not an object at commit {commit}"
|
||||
)
|
||||
result = result_from_mapping(raw)
|
||||
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
|
||||
return result, result_digest(result), commit
|
||||
|
||||
def commits_since(
|
||||
self,
|
||||
last_processed_commit: str | None,
|
||||
) -> tuple[list[str], str]:
|
||||
"""Return every new result-branch commit from oldest to newest."""
|
||||
|
||||
remote_head = self.fetch()
|
||||
if last_processed_commit is not None:
|
||||
if not _HEAD_RE.fullmatch(last_processed_commit):
|
||||
raise ResultChannelError(
|
||||
"last processed result commit must be a full lowercase Git hash"
|
||||
)
|
||||
if self._optional_revision(last_processed_commit) is None:
|
||||
raise ResultHistoryRewritten(
|
||||
"last processed result commit is unavailable locally"
|
||||
)
|
||||
if not self._is_ancestor(last_processed_commit, remote_head):
|
||||
raise ResultHistoryRewritten(
|
||||
"remote result history no longer descends from the last "
|
||||
f"processed commit: {last_processed_commit} -> {remote_head}"
|
||||
)
|
||||
if last_processed_commit == remote_head:
|
||||
return [], remote_head
|
||||
revision_range = f"{last_processed_commit}..{remote_head}"
|
||||
history = self._run(
|
||||
["rev-list", "--reverse", "--topo-order", revision_range]
|
||||
).stdout
|
||||
commits = [line.strip() for line in history.splitlines() if line.strip()]
|
||||
return commits, remote_head
|
||||
|
||||
history = self._run(
|
||||
["rev-list", "--reverse", "--topo-order", remote_head]
|
||||
).stdout
|
||||
all_commits = [line.strip() for line in history.splitlines() if line.strip()]
|
||||
first_result_index: int | None = None
|
||||
for index, commit in enumerate(all_commits):
|
||||
exists = self._run(
|
||||
["cat-file", "-e", f"{commit}:{RESULT_JSON_PATH}"],
|
||||
check=False,
|
||||
)
|
||||
if exists.returncode == 0:
|
||||
first_result_index = index
|
||||
break
|
||||
if first_result_index is None:
|
||||
return [], remote_head
|
||||
return all_commits[first_result_index:], remote_head
|
||||
|
||||
def find_commit_by_digest(self, digest: str) -> str | None:
|
||||
"""Find the newest valid result commit with a known canonical digest."""
|
||||
|
||||
if not _SHA256_RE.fullmatch(digest):
|
||||
raise ResultChannelError("stored result digest is invalid")
|
||||
remote_head = self.fetch()
|
||||
history = self._run(["rev-list", remote_head]).stdout
|
||||
for commit in (
|
||||
line.strip() for line in history.splitlines() if line.strip()
|
||||
):
|
||||
try:
|
||||
_result, observed_digest, _head = self.read_commit(commit)
|
||||
except (json.JSONDecodeError, ResultChannelError, ValueError):
|
||||
continue
|
||||
if observed_digest == digest:
|
||||
return commit
|
||||
return None
|
||||
|
||||
def publish(self, result: RxHandoffResult) -> PublishOutcome:
|
||||
validate_result(result)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user