feat(consistency): distributed multi-machine safety (CUST-WP-0026)

T01 — No-regress rule (C-15): fix-consistency now detects when a DB task
status is ahead of the workplan file (e.g. marked done on CoulombCore)
and emits C-15 WARN instead of regressing the DB back to the stale file
value. STATUS_ORDER ranking: todo(0) < in_progress/blocked(1) < done/cancelled(2).

T02 — Pull gate (C-16): fix_repo runs git fetch + rev-parse at the start
of every --fix run. If the local repo is behind its remote tracking branch,
all write operations are skipped and C-16 WARN is emitted. Best-effort:
offline/no-remote silently skips the check.

T03 — DB→file writeback: C-15 fix path patches the status field in the
matching task block and git-commits the change with a standard message.
--no-writeback flag disables writeback while keeping T01/T02 active.

T04 — CLAUDE.md + session-protocol.template updated with new guidance,
C-15/C-16 semantics, and fix-consistency-remote recommendation.

T05 — Makefile: fix-consistency-remote pulls then fixes in one step.

16 new tests; 155 passed total.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 10:19:23 +01:00
parent dff9806bb6
commit 505ace5617
4 changed files with 406 additions and 20 deletions

View File

@@ -24,6 +24,9 @@ from consistency_check import (
ConsistencyReport,
Issue,
FILE_TO_DB_WORKSTREAM_STATUS,
STATUS_ORDER,
_detect_behind_remote,
_patch_task_status_in_file,
get_tasks_from_workplan,
normalise_workstream_status,
parse_frontmatter,
@@ -371,3 +374,169 @@ class TestNormaliseWorkstreamStatus:
def test_c04_real_drift_still_detected(self):
"""done (file) vs active (DB) IS real drift and must be detected."""
assert normalise_workstream_status("done") != normalise_workstream_status("active")
# ---------------------------------------------------------------------------
# STATUS_ORDER / no-regress rule (T01 / C-15)
# ---------------------------------------------------------------------------
class TestStatusOrder:
"""STATUS_ORDER drives the no-regress rule: DB-ahead wins, file-ahead syncs."""
def test_todo_is_lowest(self):
assert STATUS_ORDER["todo"] == 0
def test_done_and_cancelled_are_highest(self):
assert STATUS_ORDER["done"] == STATUS_ORDER["cancelled"] == 2
def test_in_progress_and_blocked_are_mid(self):
assert STATUS_ORDER["in_progress"] == STATUS_ORDER["blocked"] == 1
def test_db_ahead_detected(self):
"""done (DB) vs todo (file) — DB is ahead."""
assert STATUS_ORDER["done"] > STATUS_ORDER["todo"]
def test_file_ahead_detected(self):
"""done (file) vs todo (DB) — file is ahead, should sync."""
assert STATUS_ORDER["todo"] < STATUS_ORDER["done"]
def test_same_rank_treated_as_db_ahead(self):
"""in_progress (DB) vs blocked (file) — same rank, no regression."""
assert STATUS_ORDER["in_progress"] >= STATUS_ORDER["blocked"]
def test_todo_to_done_is_regression(self):
"""Applying file=todo to DB=done would be a regression."""
db_rank = STATUS_ORDER["done"]
file_rank = STATUS_ORDER["todo"]
assert db_rank >= file_rank # → should emit C-15, not C-10
# ---------------------------------------------------------------------------
# _detect_behind_remote (T02 / C-16)
# ---------------------------------------------------------------------------
class TestDetectBehindRemote:
"""_detect_behind_remote must return False on any error (best-effort)."""
def test_returns_false_for_nonexistent_path(self):
assert _detect_behind_remote("/nonexistent/path/xyz") is False
def test_returns_false_for_non_git_dir(self, tmp_path):
assert _detect_behind_remote(str(tmp_path)) is False
def test_returns_false_for_repo_without_remote(self, tmp_path):
"""A local-only repo with no remote tracking branch → not behind."""
import subprocess
subprocess.run(["git", "init", str(tmp_path)], capture_output=True)
subprocess.run(
["git", "-C", str(tmp_path), "commit", "--allow-empty", "-m", "init"],
capture_output=True,
)
# No remote configured → rev-parse @{u} fails → best-effort returns False
assert _detect_behind_remote(str(tmp_path)) is False
def test_returns_false_when_up_to_date(self, tmp_path):
"""Clone from a local bare repo, no new commits → not behind."""
import subprocess
bare = tmp_path / "bare.git"
clone = tmp_path / "clone"
bare.mkdir()
subprocess.run(["git", "init", "--bare", str(bare)], capture_output=True)
subprocess.run(["git", "clone", str(bare), str(clone)], capture_output=True)
subprocess.run(
["git", "-C", str(clone), "commit", "--allow-empty", "-m", "init"],
capture_output=True,
)
subprocess.run(["git", "-C", str(clone), "push", "origin", "HEAD"], capture_output=True)
assert _detect_behind_remote(str(clone)) is False
def test_returns_true_when_behind(self, tmp_path):
"""Remote has a commit the clone doesn't → clone is behind."""
import subprocess
bare = tmp_path / "bare.git"
clone = tmp_path / "clone"
bare.mkdir()
subprocess.run(["git", "init", "--bare", str(bare)], capture_output=True)
subprocess.run(["git", "clone", str(bare), str(clone)], capture_output=True)
# Initial commit pushed so there's an upstream ref
subprocess.run(
["git", "-C", str(clone), "commit", "--allow-empty", "-m", "init"],
capture_output=True,
)
subprocess.run(["git", "-C", str(clone), "push", "origin", "HEAD:main"], capture_output=True)
# Add a commit directly in the bare repo (simulates remote-only progress)
work = tmp_path / "work"
subprocess.run(["git", "clone", str(bare), str(work)], capture_output=True)
subprocess.run(
["git", "-C", str(work), "commit", "--allow-empty", "-m", "remote commit"],
capture_output=True,
)
subprocess.run(["git", "-C", str(work), "push"], capture_output=True)
# After fetch the clone should appear behind
assert _detect_behind_remote(str(clone)) is True
# ---------------------------------------------------------------------------
# _patch_task_status_in_file (T03 writeback)
# ---------------------------------------------------------------------------
class TestPatchTaskStatusInFile:
"""_patch_task_status_in_file must only change the status field in the
matching task block and leave everything else untouched."""
def _make_workplan(self, tmp_path, content: str) -> Path:
f = tmp_path / "workplan.md"
f.write_text(content, encoding="utf-8")
return f
def test_patches_matching_task_block(self, tmp_path):
content = (
"---\nid: WP-001\n---\n"
"## My Task\n\n"
"```task\n"
"id: T01\n"
"status: todo\n"
"priority: high\n"
"```\n"
)
f = self._make_workplan(tmp_path, content)
result = _patch_task_status_in_file(f, "T01", "done")
assert result is True
patched = f.read_text()
assert "status: done" in patched
assert "status: todo" not in patched
assert "priority: high" in patched # other fields untouched
def test_does_not_patch_non_matching_block(self, tmp_path):
content = (
"---\nid: WP-001\n---\n"
"```task\n"
"id: T02\n"
"status: todo\n"
"```\n"
)
f = self._make_workplan(tmp_path, content)
result = _patch_task_status_in_file(f, "T01", "done")
assert result is False
assert "status: todo" in f.read_text()
def test_patches_correct_block_among_multiple(self, tmp_path):
content = (
"---\nid: WP-001\n---\n"
"```task\nid: T01\nstatus: todo\n```\n"
"```task\nid: T02\nstatus: in_progress\n```\n"
)
f = self._make_workplan(tmp_path, content)
_patch_task_status_in_file(f, "T02", "done")
patched = f.read_text()
assert "id: T01\nstatus: todo" in patched
assert "id: T02\nstatus: done" in patched
def test_idempotent_when_already_correct(self, tmp_path):
content = "---\nid: WP\n---\n```task\nid: T01\nstatus: done\n```\n"
f = self._make_workplan(tmp_path, content)
result = _patch_task_status_in_file(f, "T01", "done")
# status matches, re.sub produces same text → no write → False
assert result is False