Master-parity previously compared only the daemon's startup commit against the local on-disk HEAD. When the checkout was not pulled, parity reported green even though the live remote master had advanced, so a stale daemon could claim a mutation-safe result while running outdated capability gates (observed during PR #592 recovery, where the resolver correctly required restart but parity said in_parity=true). Changes: - master_parity_gate.assess_master_parity() gains an optional live_remote_head and reports the three commits distinctly (daemon_start_head, local_head, live_remote_head) plus live_known / live_stale / mutation_safe. A result is mutation_safe only when daemon, local checkout, and live remote all agree. - parity_block_reasons() now blocks mutations on live-staleness too; read-only operations remain unblocked (non-goal: never block diagnostics offline). - New parity_resolver_disagreement(): typed fail-closed blocker naming the capability resolver as authoritative when it requires restart but parity looks locally green. - New read_remote_master_head(): best-effort `git ls-remote` for the live target, cached with a 60s TTL (bounded offline latency, no network probe per gate call); env override GITEA_TEST_LIVE_REMOTE_HEAD keeps tests hermetic. - Server: _current_master_parity() reads the live remote head; gitea_assess_master_parity and runtime_context surface the distinguished SHAs, mutation_safe, and resolver-authoritative guidance. Tests: 13 new cases (live-remote parity, live-stale blocking + typed blocker, remote-head reader + TTL cache, server wiring). Full suite: 2423 passed; the 8 remaining failures (test_config TestAuthIntegration, test_credentials TestGetCredentials) are baseline-proven keychain/env failures identical on the unmodified base. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
351 lines
15 KiB
Python
351 lines
15 KiB
Python
"""Tests for the master-parity staleness gate (#420).
|
|
|
|
Covers the pure assessment logic, the mutation-block helper, the env escape
|
|
hatches, and the server-side wiring: reads are never blocked by staleness, a
|
|
mutation is refused when the on-disk master has advanced, and the read-only
|
|
gitea_assess_master_parity tool reports the stale state.
|
|
"""
|
|
import os
|
|
import sys
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
|
|
import master_parity_gate as mp # noqa: E402
|
|
|
|
|
|
SHA_A = "a" * 40
|
|
SHA_B = "b" * 40
|
|
|
|
|
|
class TestAssessMasterParity(unittest.TestCase):
|
|
def test_in_parity_when_heads_match(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
|
self.assertTrue(res["in_parity"])
|
|
self.assertFalse(res["stale"])
|
|
self.assertFalse(res["restart_required"])
|
|
self.assertTrue(res["determinable"])
|
|
|
|
def test_stale_when_master_advanced(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
|
self.assertFalse(res["in_parity"])
|
|
self.assertTrue(res["stale"])
|
|
self.assertTrue(res["restart_required"])
|
|
self.assertTrue(res["determinable"])
|
|
self.assertTrue(any("restart" in r for r in res["reasons"]))
|
|
|
|
def test_missing_startup_head_not_determinable(self):
|
|
res = mp.assess_master_parity({"startup_head": None}, SHA_B)
|
|
self.assertFalse(res["determinable"])
|
|
self.assertFalse(res["stale"])
|
|
self.assertTrue(res["in_parity"])
|
|
|
|
def test_missing_current_head_not_determinable(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, None)
|
|
self.assertFalse(res["determinable"])
|
|
self.assertFalse(res["stale"])
|
|
self.assertTrue(res["in_parity"])
|
|
|
|
def test_none_startup_baseline(self):
|
|
res = mp.assess_master_parity(None, SHA_A)
|
|
self.assertFalse(res["determinable"])
|
|
self.assertFalse(res["stale"])
|
|
|
|
|
|
class TestBlockReasonsAndReport(unittest.TestCase):
|
|
def test_stale_produces_block_reasons(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
|
self.assertTrue(mp.parity_block_reasons(res))
|
|
|
|
def test_in_parity_no_block_reasons(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
|
self.assertEqual(mp.parity_block_reasons(res), [])
|
|
|
|
def test_disable_env_suppresses_block(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
|
with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
|
|
self.assertTrue(mp.gate_disabled())
|
|
self.assertEqual(mp.parity_block_reasons(res), [])
|
|
|
|
def test_report_shape_when_stale(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
|
report = mp.parity_report(res)
|
|
self.assertEqual(report["kind"], "server_stale")
|
|
self.assertTrue(report["restart_required"])
|
|
self.assertEqual(report["startup_head"], SHA_A)
|
|
self.assertEqual(report["current_head"], SHA_B)
|
|
self.assertTrue(report["recovery"])
|
|
|
|
|
|
class TestLiveRemoteParity(unittest.TestCase):
|
|
"""#610: parity must account for the live remote master, not just local.
|
|
|
|
The daemon can be stale relative to the live remote target while the local
|
|
checkout HEAD still matches the daemon's startup commit, so local parity
|
|
reports green even though a mutation would run against outdated code.
|
|
"""
|
|
|
|
SHA_C = "c" * 40
|
|
|
|
def test_distinguishes_three_shas(self):
|
|
res = mp.assess_master_parity(
|
|
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
|
self.assertEqual(res["daemon_start_head"], SHA_A)
|
|
self.assertEqual(res["local_head"], SHA_A)
|
|
self.assertEqual(res["live_remote_head"], SHA_B)
|
|
|
|
def test_mutation_safe_only_when_all_three_match(self):
|
|
res = mp.assess_master_parity(
|
|
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_A)
|
|
self.assertTrue(res["mutation_safe"])
|
|
self.assertTrue(res["live_known"])
|
|
self.assertFalse(res["live_stale"])
|
|
|
|
def test_live_stale_when_remote_advanced_past_daemon(self):
|
|
# Local checkout still matches the daemon start (local parity green),
|
|
# but the live remote master has advanced -> daemon is live-stale.
|
|
res = mp.assess_master_parity(
|
|
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
|
self.assertTrue(res["in_parity"]) # local parity still green
|
|
self.assertTrue(res["live_stale"])
|
|
self.assertFalse(res["mutation_safe"])
|
|
self.assertTrue(any("live" in r.lower() for r in res["reasons"]))
|
|
|
|
def test_live_unknown_is_not_mutation_safe_but_not_stale(self):
|
|
# Non-goal: unfetchable live remote must not be treated as stale for
|
|
# read-only, but a mutation-safe claim fails closed.
|
|
res = mp.assess_master_parity(
|
|
{"startup_head": SHA_A}, SHA_A, live_remote_head=None)
|
|
self.assertFalse(res["live_known"])
|
|
self.assertFalse(res["mutation_safe"])
|
|
self.assertFalse(res["live_stale"])
|
|
self.assertTrue(res["in_parity"])
|
|
|
|
def test_default_live_remote_preserves_legacy_shape(self):
|
|
# Callers that do not supply a live head keep the pre-#610 behavior:
|
|
# in-parity, not live-stale, no live-derived block.
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
|
self.assertFalse(res["live_stale"])
|
|
self.assertEqual(mp.parity_block_reasons(res), [])
|
|
|
|
|
|
class TestLiveStaleBlockAndReport(unittest.TestCase):
|
|
"""#610: live-staleness must block mutations and surface a typed blocker."""
|
|
|
|
def test_live_stale_produces_block_reasons(self):
|
|
res = mp.assess_master_parity(
|
|
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
|
self.assertTrue(mp.parity_block_reasons(res))
|
|
|
|
def test_disable_env_suppresses_live_stale_block(self):
|
|
res = mp.assess_master_parity(
|
|
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
|
with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
|
|
self.assertEqual(mp.parity_block_reasons(res), [])
|
|
|
|
def test_resolver_disagreement_returns_typed_blocker(self):
|
|
# Parity says local-green, resolver says restart required -> disagreement
|
|
# is a typed, fail-closed blocker naming the resolver as authoritative.
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
|
blocker = mp.parity_resolver_disagreement(res, resolver_restart_required=True)
|
|
self.assertIsNotNone(blocker)
|
|
self.assertEqual(blocker["kind"], "parity_resolver_disagreement")
|
|
self.assertTrue(blocker["restart_required"])
|
|
self.assertTrue(blocker["resolver_authoritative"])
|
|
|
|
def test_no_disagreement_when_resolver_agrees(self):
|
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
|
self.assertIsNone(
|
|
mp.parity_resolver_disagreement(res, resolver_restart_required=False))
|
|
|
|
def test_live_stale_report_names_live_remote(self):
|
|
res = mp.assess_master_parity(
|
|
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
|
report = mp.parity_report(res)
|
|
self.assertEqual(report["live_remote_head"], SHA_B)
|
|
self.assertTrue(report["restart_required"])
|
|
|
|
|
|
class TestReadGitHead(unittest.TestCase):
|
|
def test_test_override_takes_precedence(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
|
self.assertEqual(mp.read_git_head("/nonexistent"), SHA_B)
|
|
|
|
def test_blank_override_is_none(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: " "}):
|
|
self.assertIsNone(mp.read_git_head("/nonexistent"))
|
|
|
|
def test_empty_root_is_none(self):
|
|
# No override set; empty root cannot resolve a HEAD.
|
|
env = {k: v for k, v in os.environ.items()
|
|
if k != mp.ENV_TEST_CURRENT_HEAD}
|
|
with patch.dict(os.environ, env, clear=True):
|
|
self.assertIsNone(mp.read_git_head(""))
|
|
|
|
|
|
class TestReadRemoteMasterHead(unittest.TestCase):
|
|
"""#610: live remote master head reader (env-overridable, fails to None)."""
|
|
|
|
def test_test_override_takes_precedence(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
|
|
self.assertEqual(mp.read_remote_master_head("/nonexistent"), SHA_B)
|
|
|
|
def test_blank_override_is_none(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: " "}):
|
|
self.assertIsNone(mp.read_remote_master_head("/nonexistent"))
|
|
|
|
def test_unfetchable_remote_is_none(self):
|
|
# No override; a bogus root/remote must fail closed to None, never raise.
|
|
env = {k: v for k, v in os.environ.items()
|
|
if k != mp.ENV_TEST_LIVE_REMOTE_HEAD}
|
|
with patch.dict(os.environ, env, clear=True):
|
|
self.assertIsNone(
|
|
mp.read_remote_master_head("/nonexistent", remote="nope"))
|
|
|
|
|
|
class TestRemoteHeadCache(unittest.TestCase):
|
|
"""#610: live remote reads are cached with a TTL to stay off the network.
|
|
|
|
The parity gate runs on every mutation and every runtime-context read, so an
|
|
unbounded ``git ls-remote`` per call would be a latency/flakiness regression.
|
|
"""
|
|
|
|
def setUp(self):
|
|
mp._clear_remote_head_cache()
|
|
env = {k: v for k, v in os.environ.items()
|
|
if k != mp.ENV_TEST_LIVE_REMOTE_HEAD}
|
|
self._env = patch.dict(os.environ, env, clear=True)
|
|
self._env.start()
|
|
self.addCleanup(self._env.stop)
|
|
self.addCleanup(mp._clear_remote_head_cache)
|
|
|
|
def _fake_run(self, sha):
|
|
class _R:
|
|
returncode = 0
|
|
stdout = f"{sha}\trefs/heads/master\n"
|
|
calls = {"n": 0}
|
|
|
|
def run(*args, **kwargs):
|
|
calls["n"] += 1
|
|
return _R()
|
|
return run, calls
|
|
|
|
def test_second_call_within_ttl_uses_cache(self):
|
|
run, calls = self._fake_run(SHA_B)
|
|
with patch.object(mp.subprocess, "run", run):
|
|
a = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
|
|
b = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
|
|
self.assertEqual(a, SHA_B)
|
|
self.assertEqual(b, SHA_B)
|
|
self.assertEqual(calls["n"], 1)
|
|
|
|
def test_zero_ttl_bypasses_cache(self):
|
|
run, calls = self._fake_run(SHA_B)
|
|
with patch.object(mp.subprocess, "run", run):
|
|
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
|
|
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
|
|
self.assertEqual(calls["n"], 2)
|
|
|
|
def test_env_override_never_touches_subprocess(self):
|
|
run, calls = self._fake_run(SHA_B)
|
|
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
|
|
with patch.object(mp.subprocess, "run", run):
|
|
self.assertEqual(
|
|
mp.read_remote_master_head("/repo", remote="prgs"), SHA_A)
|
|
self.assertEqual(calls["n"], 0)
|
|
|
|
|
|
class TestServerWiring(unittest.TestCase):
|
|
"""Integration with the gate choke point in the server namespace."""
|
|
|
|
def setUp(self):
|
|
import mcp_server
|
|
self.srv = mcp_server
|
|
# Pin a known startup baseline so the current-HEAD override can diverge.
|
|
self._saved = self.srv._STARTUP_PARITY
|
|
self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT,
|
|
"startup_head": SHA_A}
|
|
# Keep the live-remote read hermetic (no real ls-remote network call):
|
|
# default the live master to the daemon start so parity is fully green
|
|
# unless a test overrides the live head explicitly (#610).
|
|
self._live_patch = patch.dict(
|
|
os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A})
|
|
self._live_patch.start()
|
|
self.addCleanup(self._live_patch.stop)
|
|
|
|
def tearDown(self):
|
|
self.srv._STARTUP_PARITY = self._saved
|
|
|
|
def test_reads_not_blocked_when_stale(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
|
self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
|
|
|
|
def test_mutation_blocked_when_stale(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
|
reasons = self.srv._master_parity_block("gitea.pr.create")
|
|
self.assertTrue(reasons)
|
|
# The profile-operation choke point surfaces the same block.
|
|
self.assertTrue(self.srv._profile_operation_gate("gitea.pr.create"))
|
|
|
|
def test_mutation_allowed_when_in_parity(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}):
|
|
self.assertEqual(
|
|
self.srv._master_parity_block("gitea.pr.create"), [])
|
|
|
|
def test_disable_env_lets_mutation_through(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B,
|
|
mp.ENV_DISABLE: "1"}):
|
|
self.assertEqual(
|
|
self.srv._master_parity_block("gitea.pr.create"), [])
|
|
|
|
def test_assess_tool_reports_stale(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
|
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
|
self.assertTrue(out["stale"])
|
|
self.assertTrue(out["restart_required"])
|
|
self.assertEqual(out["startup_head"], SHA_A)
|
|
self.assertEqual(out["current_head"], SHA_B)
|
|
self.assertIn("report", out)
|
|
|
|
def test_assess_tool_reports_in_parity(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}):
|
|
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
|
self.assertFalse(out["stale"])
|
|
self.assertTrue(out["in_parity"])
|
|
self.assertNotIn("report", out)
|
|
|
|
# --- #610: live-remote wiring -------------------------------------------
|
|
|
|
def test_live_stale_blocks_mutation_though_local_green(self):
|
|
# Local checkout matches the daemon start (local parity green) but the
|
|
# live remote master has advanced -> mutations must fail closed.
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
|
|
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
|
|
self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
|
|
self.assertTrue(
|
|
self.srv._master_parity_block("gitea.pr.create"))
|
|
|
|
def test_assess_tool_exposes_three_distinct_shas(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
|
|
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
|
|
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
|
self.assertEqual(out["daemon_start_head"], SHA_A)
|
|
self.assertEqual(out["local_head"], SHA_A)
|
|
self.assertEqual(out["live_remote_head"], SHA_B)
|
|
self.assertTrue(out["live_stale"])
|
|
self.assertFalse(out["mutation_safe"])
|
|
self.assertIn("report", out)
|
|
|
|
def test_assess_tool_mutation_safe_when_all_three_match(self):
|
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
|
|
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
|
|
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
|
self.assertTrue(out["mutation_safe"])
|
|
self.assertFalse(out["live_stale"])
|
|
self.assertNotIn("report", out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|