Harden RF node control and agent updates
This commit is contained in:
235
tests/test_rf001_launcher_health_diagnostic.py
Normal file
235
tests/test_rf001_launcher_health_diagnostic.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Regression tests for strict launcher/health process identity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from tools.rf001_launcher_health_diagnostic import (
|
||||
HandshakeExpectation,
|
||||
ProcessRecord,
|
||||
_terminate_owned_processes,
|
||||
atomic_write_json,
|
||||
evaluate_identity,
|
||||
strict_owned_process_ids,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 29, 13, 0, tzinfo=timezone.utc)
|
||||
SCRIPT = r"C:\diagnostic\rf001_launcher_health_diagnostic.py"
|
||||
|
||||
|
||||
def _expectation(*, popen_pid: int = 200) -> HandshakeExpectation:
|
||||
return HandshakeExpectation(
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
launch_nonce="fixed-unique-nonce",
|
||||
state="RUNNING",
|
||||
launcher_pid=100,
|
||||
popen_pid=popen_pid,
|
||||
launched_at=NOW.isoformat(),
|
||||
diagnostic_script=SCRIPT,
|
||||
)
|
||||
|
||||
|
||||
def _record(pid: int, parent: int, *, seconds: float = 1.0) -> ProcessRecord:
|
||||
return ProcessRecord(
|
||||
pid=pid,
|
||||
parent_pid=parent,
|
||||
name="python.exe",
|
||||
executable=r"C:\Python313\python.exe",
|
||||
command_line=f'python "{SCRIPT}" --launch-nonce fixed-unique-nonce',
|
||||
created_at=(NOW + timedelta(seconds=seconds)).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _health(*, pid: int = 200) -> dict[str, object]:
|
||||
return {
|
||||
"release_id": "rf001-agent-2026.09.29.1",
|
||||
"agent_version": "2026.09.29.1",
|
||||
"launch_nonce": "fixed-unique-nonce",
|
||||
"state": "RUNNING",
|
||||
"pid": pid,
|
||||
"updated_at": (NOW + timedelta(seconds=2)).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _evaluate(
|
||||
health: dict[str, object] | None,
|
||||
records: list[ProcessRecord],
|
||||
*,
|
||||
expectation: HandshakeExpectation | None = None,
|
||||
popen_alive: bool = True,
|
||||
health_process_alive: bool = True,
|
||||
) -> dict[str, object]:
|
||||
return evaluate_identity(
|
||||
health,
|
||||
expectation or _expectation(),
|
||||
records,
|
||||
popen_alive=popen_alive,
|
||||
health_process_alive=health_process_alive,
|
||||
)
|
||||
|
||||
|
||||
def test_exact_process_identity_passes() -> None:
|
||||
result = _evaluate(_health(), [_record(200, 100)])
|
||||
assert result["identity_valid"] is True
|
||||
assert result["identity_model"] == "EXACT_POPEN_PROCESS"
|
||||
|
||||
|
||||
def test_wrong_nonce_fails() -> None:
|
||||
health = _health()
|
||||
health["launch_nonce"] = "wrong"
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH"
|
||||
|
||||
|
||||
def test_wrong_release_fails() -> None:
|
||||
health = _health()
|
||||
health["release_id"] = "rf001-agent-elsewhere"
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH"
|
||||
|
||||
|
||||
def test_wrong_version_fails() -> None:
|
||||
health = _health()
|
||||
health["agent_version"] = "2026.09.29.2"
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH"
|
||||
|
||||
|
||||
def test_health_before_running_state_fails() -> None:
|
||||
health = _health()
|
||||
health["state"] = "STARTING"
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH"
|
||||
|
||||
|
||||
def test_stale_health_fails() -> None:
|
||||
health = _health()
|
||||
health["updated_at"] = (NOW - timedelta(seconds=1)).isoformat()
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "STALE_HEALTH"
|
||||
|
||||
|
||||
def test_rollback_does_not_accept_previous_launch_health() -> None:
|
||||
previous_health = _health()
|
||||
previous_health["launch_nonce"] = "previous-launch-nonce"
|
||||
assert (
|
||||
_evaluate(previous_health, [_record(200, 100)])["reason"]
|
||||
== "HEALTH_FIELDS_MISMATCH"
|
||||
)
|
||||
|
||||
|
||||
def test_unrelated_pid_fails() -> None:
|
||||
result = _evaluate(
|
||||
_health(pid=300),
|
||||
[_record(200, 100), _record(300, 999)],
|
||||
)
|
||||
assert result["reason"] == "UNRELATED_PID"
|
||||
|
||||
|
||||
def test_unique_expected_wrapper_child_passes_strict_lineage() -> None:
|
||||
result = _evaluate(
|
||||
_health(pid=201),
|
||||
[_record(200, 100), _record(201, 200)],
|
||||
)
|
||||
assert result["identity_valid"] is True
|
||||
assert (
|
||||
result["identity_model"]
|
||||
== "UNIQUE_DIRECT_CHILD_WITH_NONCE_AND_CREATION_TIME"
|
||||
)
|
||||
assert result["pid_match"] is False
|
||||
|
||||
|
||||
def test_unexpected_grandchild_fails() -> None:
|
||||
result = _evaluate(
|
||||
_health(pid=202),
|
||||
[_record(200, 100), _record(201, 200), _record(202, 201)],
|
||||
)
|
||||
assert result["reason"] == "UNEXPECTED_GRANDCHILD"
|
||||
|
||||
|
||||
def test_pid_reuse_creation_time_mismatch_fails() -> None:
|
||||
reused = _record(200, 100, seconds=-30)
|
||||
assert _evaluate(_health(), [reused])["reason"] == "PROCESS_CREATION_TIME_MISMATCH"
|
||||
|
||||
|
||||
def test_target_dies_before_health_fails() -> None:
|
||||
result = _evaluate(None, [_record(200, 100)], popen_alive=False)
|
||||
assert result["reason"] == "HEALTH_ABSENT"
|
||||
|
||||
|
||||
def test_target_writes_health_then_dies_immediately_fails() -> None:
|
||||
result = _evaluate(
|
||||
_health(),
|
||||
[_record(200, 100)],
|
||||
health_process_alive=False,
|
||||
)
|
||||
assert result["reason"] == "HEALTH_PROCESS_DIED"
|
||||
|
||||
|
||||
def test_orphan_selection_never_includes_unrelated_process() -> None:
|
||||
expectation = _expectation()
|
||||
related = _record(201, 200)
|
||||
unrelated = replace(_record(300, 999), command_line="python unrelated.py")
|
||||
assert strict_owned_process_ids(
|
||||
expectation,
|
||||
[_record(200, 100), related, unrelated],
|
||||
) == [201, 200]
|
||||
|
||||
|
||||
def test_orphan_cleanup_terminates_only_strictly_owned_processes(monkeypatch) -> None:
|
||||
expectation = _expectation()
|
||||
wrapper = _record(200, 100)
|
||||
child = _record(201, 200)
|
||||
unrelated = replace(_record(300, 999), command_line="python unrelated.py")
|
||||
records = [wrapper, child, unrelated]
|
||||
terminated: list[int] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.rf001_launcher_health_diagnostic.capture_windows_processes",
|
||||
lambda: records,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tools.rf001_launcher_health_diagnostic.os.kill",
|
||||
lambda pid, _signal: terminated.append(pid),
|
||||
)
|
||||
|
||||
actions = _terminate_owned_processes(expectation, records)
|
||||
|
||||
assert terminated == [201, 200]
|
||||
assert actions == [
|
||||
{"pid": 201, "status": "TERMINATED"},
|
||||
{"pid": 200, "status": "TERMINATED"},
|
||||
]
|
||||
|
||||
|
||||
def test_repeated_success_is_deterministic_with_distinct_identity() -> None:
|
||||
first = _expectation(popen_pid=200)
|
||||
second = replace(
|
||||
first,
|
||||
popen_pid=300,
|
||||
launch_nonce="second-unique-nonce",
|
||||
launched_at=(NOW + timedelta(seconds=10)).isoformat(),
|
||||
)
|
||||
first_result = _evaluate(_health(pid=200), [_record(200, 100)], expectation=first)
|
||||
second_record = replace(
|
||||
_record(300, 100, seconds=11),
|
||||
command_line=f'python "{SCRIPT}" --launch-nonce second-unique-nonce',
|
||||
)
|
||||
second_health = {
|
||||
**_health(pid=300),
|
||||
"launch_nonce": "second-unique-nonce",
|
||||
"updated_at": (NOW + timedelta(seconds=12)).isoformat(),
|
||||
}
|
||||
second_result = _evaluate(second_health, [second_record], expectation=second)
|
||||
assert first_result["identity_valid"] is True
|
||||
assert second_result["identity_valid"] is True
|
||||
assert first.launch_nonce != second.launch_nonce
|
||||
assert first.popen_pid != second.popen_pid
|
||||
|
||||
|
||||
def test_diagnostic_json_replacement_is_atomic(tmp_path: Path) -> None:
|
||||
path = tmp_path / "health.json"
|
||||
for sequence in range(20):
|
||||
atomic_write_json(path, {"sequence": sequence})
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"sequence": sequence}
|
||||
assert not path.with_suffix(".json.tmp").exists()
|
||||
Reference in New Issue
Block a user