Process all RX handoff commits in TX watcher
This commit is contained in:
@@ -20,6 +20,7 @@ from tools.rf_handoff_channel import (
|
||||
RESULT_MARKDOWN_PATH,
|
||||
ResultBranchMissing,
|
||||
ResultChannelError,
|
||||
ResultHistoryRewritten,
|
||||
RxHandoffResult,
|
||||
canonical_result_json,
|
||||
evaluate_ready,
|
||||
@@ -36,6 +37,8 @@ from tools.tx_agent_watcher import (
|
||||
TxWatcherState,
|
||||
handle_result,
|
||||
load_state as load_tx_state,
|
||||
migrate_state_cursor,
|
||||
process_history_cycle,
|
||||
request_stop,
|
||||
save_state as save_tx_state,
|
||||
)
|
||||
@@ -140,6 +143,44 @@ def git_fixture(tmp_path: Path) -> tuple[Path, Path]:
|
||||
return remote, work
|
||||
|
||||
|
||||
def commit_on_result_branch(
|
||||
work: Path,
|
||||
temporary_root: Path,
|
||||
message: str,
|
||||
*,
|
||||
json_text: str | None = None,
|
||||
allow_empty: bool = False,
|
||||
) -> str:
|
||||
"""Create one test-only result branch commit from the main repository."""
|
||||
|
||||
channel = GitResultChannel(work)
|
||||
channel.fetch()
|
||||
checkout = temporary_root / f"result-{message.replace(' ', '-')}"
|
||||
_git(["worktree", "add", "--detach", str(checkout), "origin/rx-node"], work)
|
||||
try:
|
||||
if json_text is not None:
|
||||
result_path = checkout / RESULT_JSON_PATH
|
||||
result_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
result_path.write_text(json_text, encoding="utf-8")
|
||||
_git(["add", "--", RESULT_JSON_PATH], checkout)
|
||||
commit_arguments = [
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.invalid",
|
||||
"commit",
|
||||
]
|
||||
if allow_empty:
|
||||
commit_arguments.append("--allow-empty")
|
||||
commit_arguments.extend(["-m", message])
|
||||
_git(commit_arguments, checkout)
|
||||
commit = _git(["rev-parse", "HEAD"], checkout)
|
||||
_git(["push", "origin", "HEAD:refs/heads/rx-node"], checkout)
|
||||
return commit
|
||||
finally:
|
||||
_git(["worktree", "remove", "--force", str(checkout)], work)
|
||||
|
||||
|
||||
class FakeSource:
|
||||
def __init__(self, result: RxHandoffResult, digest: str, head: str = HEAD) -> None:
|
||||
self.result = result
|
||||
@@ -154,6 +195,19 @@ class FakeSource:
|
||||
def read(self) -> tuple[RxHandoffResult, str, str]:
|
||||
return self.result, self.digest, self.head
|
||||
|
||||
def read_commit(self, commit: str) -> tuple[RxHandoffResult, str, str]:
|
||||
assert commit == self.head
|
||||
return self.read()
|
||||
|
||||
def commits_since(
|
||||
self,
|
||||
last_processed_commit: str | None,
|
||||
) -> tuple[list[str], str]:
|
||||
return ([] if last_processed_commit == self.head else [self.head], self.head)
|
||||
|
||||
def find_commit_by_digest(self, digest: str) -> str | None:
|
||||
return self.head if digest == self.digest else None
|
||||
|
||||
|
||||
class FakeExecutor:
|
||||
def __init__(self, path: Path) -> None:
|
||||
@@ -372,6 +426,206 @@ def test_stop_request_clears_stale_watcher_pid(tmp_path: Path) -> None:
|
||||
assert not state_path.with_suffix(".json.stop").exists()
|
||||
|
||||
|
||||
def test_version_one_state_migrates_without_losing_safety_fields(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
state_path = tmp_path / "legacy_state.json"
|
||||
state_path.write_text(
|
||||
"""{
|
||||
"running": false,
|
||||
"pid": null,
|
||||
"last_seen_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"last_prompted_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"last_authorized_digest": null,
|
||||
"channel_test_ready_digest": null,
|
||||
"channel_test_complete_digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
state = load_tx_state(state_path)
|
||||
assert state.state_version == 2
|
||||
assert state.last_seen_commit is None
|
||||
assert state.last_processed_rx_node_commit is None
|
||||
assert state.last_prompted_digest == "b" * 64
|
||||
assert state.channel_test_complete_digest == "c" * 64
|
||||
|
||||
|
||||
def test_history_cycle_processes_ready_duplicate_and_complete_in_order(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
channel = GitResultChannel(work)
|
||||
baseline = complete_result(
|
||||
channel_ready_result(),
|
||||
duration_seconds=1.0,
|
||||
file_size=4,
|
||||
sha256="c" * 64,
|
||||
)
|
||||
baseline_outcome = channel.publish(baseline)
|
||||
state = TxWatcherState(last_seen_digest=result_digest(baseline))
|
||||
assert migrate_state_cursor(channel, state)
|
||||
assert state.last_processed_rx_node_commit == baseline_outcome.commit
|
||||
|
||||
ready = channel_ready_result(published_at=NOW.isoformat())
|
||||
ready_outcome = channel.publish(ready)
|
||||
duplicate_commit = commit_on_result_branch(
|
||||
work,
|
||||
tmp_path,
|
||||
"Duplicate READY result",
|
||||
allow_empty=True,
|
||||
)
|
||||
complete = complete_result(
|
||||
ready,
|
||||
published_at=(NOW + timedelta(seconds=1)).isoformat(),
|
||||
duration_seconds=1.0,
|
||||
file_size=4,
|
||||
sha256="d" * 64,
|
||||
)
|
||||
complete_outcome = channel.publish(complete)
|
||||
prompts: list[str] = []
|
||||
executor = FakeExecutor(tmp_path / "forbidden.txt")
|
||||
saved_commits: list[str | None] = []
|
||||
|
||||
outcomes = process_history_cycle(
|
||||
channel,
|
||||
state,
|
||||
executor,
|
||||
now_function=lambda: NOW + timedelta(seconds=2),
|
||||
input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX",
|
||||
save_function=lambda current: saved_commits.append(
|
||||
current.last_processed_rx_node_commit
|
||||
),
|
||||
output_function=lambda _text: None,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
|
||||
assert outcomes == [
|
||||
(ready_outcome.commit, "CHANNEL_TEST_READY"),
|
||||
(duplicate_commit, "DUPLICATE_SUPPRESSED"),
|
||||
(complete_outcome.commit, "CHANNEL_TEST_COMPLETE"),
|
||||
]
|
||||
assert saved_commits == [
|
||||
ready_outcome.commit,
|
||||
duplicate_commit,
|
||||
complete_outcome.commit,
|
||||
]
|
||||
assert state.channel_test_ready_digest == result_digest(ready)
|
||||
assert state.channel_test_complete_digest == result_digest(complete)
|
||||
assert state.last_seen_commit == complete_outcome.commit
|
||||
assert state.last_processed_rx_node_commit == complete_outcome.commit
|
||||
assert prompts == []
|
||||
assert executor.calls == 0
|
||||
|
||||
|
||||
def test_malformed_intermediate_commit_blocks_later_history(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
channel = GitResultChannel(work)
|
||||
baseline = complete_result(
|
||||
channel_ready_result(),
|
||||
duration_seconds=1.0,
|
||||
file_size=4,
|
||||
sha256="c" * 64,
|
||||
)
|
||||
baseline_outcome = channel.publish(baseline)
|
||||
state = TxWatcherState(
|
||||
last_seen_digest=result_digest(baseline),
|
||||
last_seen_commit=baseline_outcome.commit,
|
||||
last_processed_rx_node_commit=baseline_outcome.commit,
|
||||
)
|
||||
ready = channel_ready_result()
|
||||
ready_outcome = channel.publish(ready)
|
||||
malformed_commit = commit_on_result_branch(
|
||||
work,
|
||||
tmp_path,
|
||||
"Malformed result",
|
||||
json_text="{not-json\n",
|
||||
)
|
||||
complete = complete_result(
|
||||
ready,
|
||||
duration_seconds=1.0,
|
||||
file_size=4,
|
||||
sha256="d" * 64,
|
||||
)
|
||||
complete_outcome = channel.publish(complete)
|
||||
saved_commits: list[str | None] = []
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
process_history_cycle(
|
||||
channel,
|
||||
state,
|
||||
FakeExecutor(tmp_path / "forbidden.txt"),
|
||||
now_function=lambda: NOW,
|
||||
input_function=lambda _prompt: "DENY",
|
||||
save_function=lambda current: saved_commits.append(
|
||||
current.last_processed_rx_node_commit
|
||||
),
|
||||
output_function=lambda _text: None,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
|
||||
assert saved_commits == [ready_outcome.commit]
|
||||
assert state.last_processed_rx_node_commit == ready_outcome.commit
|
||||
assert state.last_processed_rx_node_commit != malformed_commit
|
||||
assert state.last_processed_rx_node_commit != complete_outcome.commit
|
||||
|
||||
|
||||
def test_result_history_rewrite_is_rejected(tmp_path: Path) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
channel = GitResultChannel(work)
|
||||
ready_outcome = channel.publish(channel_ready_result())
|
||||
original_branch_head = _git(["rev-parse", "HEAD"], work)
|
||||
_git(
|
||||
[
|
||||
"push",
|
||||
"--force",
|
||||
"origin",
|
||||
f"{original_branch_head}:refs/heads/rx-node",
|
||||
],
|
||||
work,
|
||||
)
|
||||
|
||||
with pytest.raises(ResultHistoryRewritten):
|
||||
channel.commits_since(ready_outcome.commit)
|
||||
|
||||
|
||||
def test_expired_historical_ready_is_observed_without_authorization(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
channel = GitResultChannel(work)
|
||||
started = NOW - timedelta(seconds=200)
|
||||
expired = ready_result(
|
||||
capture_started_at=started.isoformat(),
|
||||
expected_capture_end=(started + timedelta(seconds=120)).isoformat(),
|
||||
published_at=(NOW - timedelta(seconds=100)).isoformat(),
|
||||
)
|
||||
expired_outcome = channel.publish(expired)
|
||||
state = TxWatcherState()
|
||||
prompts: list[str] = []
|
||||
executor = FakeExecutor(tmp_path / "forbidden.txt")
|
||||
|
||||
outcomes = process_history_cycle(
|
||||
channel,
|
||||
state,
|
||||
executor,
|
||||
now_function=lambda: NOW,
|
||||
input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX",
|
||||
save_function=lambda _state: None,
|
||||
output_function=lambda _text: None,
|
||||
dry_run=True,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
|
||||
assert outcomes == [(expired_outcome.commit, "STALE_READY")]
|
||||
assert state.last_processed_rx_node_commit == expired_outcome.commit
|
||||
assert prompts == []
|
||||
assert executor.calls == 0
|
||||
|
||||
|
||||
def test_real_origin_detection_and_worktree_publication(tmp_path: Path) -> None:
|
||||
remote, work = git_fixture(tmp_path)
|
||||
branch_before = _git(["branch", "--show-current"], work)
|
||||
|
||||
Reference in New Issue
Block a user