Files
bench/tests/test_boards_sync.py
T
istos 9d98e2c55a sync: origin/main as the shared board, behind BOARD_SYNC
Team mode's second half. A board-made move already commits itself (18);
now that commit publishes, every board pulls on a beat, and a card two
boards move at once resolves the way git resolves everything else — the
push race is the concurrency control.

- core/sync.py: push is event-driven (a new state.COMMIT_HOOKS registry
  fires it from taskfiles, so taskfiles stays left of everything that
  reacts to it); pull is a beat that fast-forwards, or replays this
  board's own commits on top when the two diverged. A replay that
  conflicts on a task file drops the local move — origin is the
  linearizer — and toasts who took the card.
- The piggyback guard stands in front of every push and every replay:
  each local-ahead commit on main must be `board: `-prefixed, so a
  human's unpushed work is never published as a side effect of a card
  moving. Uncommitted changes, a checkout off main and an unreachable
  origin all stall or degrade rather than risk anything, each narrated
  once instead of once per beat.
- watch.py names the commit author instead of "disk" for moves a pull
  brought, via the arrivals sync files for it; its narration moved out
  of the loop into narrate() so it can be tested directly.
- The board grows a sync chip that appears only when sync stops
  converging, and the SSE stream grows a toast type so the server can
  say something to the person, not just to the ticker.

Gate off (the default) means no fetch, no push, no thread, no change.

Verified with tests/test_boards_sync.py: two real clones of a real bare
upstream race each other through every case above.
2026-07-30 09:47:38 +02:00

566 lines
22 KiB
Python

"""Boards sync through origin/main (task 19): a move pushes, a beat pulls,
a lost race is a toast, and a human's unpushed commit is never published.
Everything runs against real clones of a real bare upstream — the whole
point of the card is what git actually does under a race, so nothing here
is mocked except the SSE fan-out (captured, to read what the board said).
python3 -m unittest discover -s tests -v
"""
from __future__ import annotations
import importlib
import os
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO / "manager" / "core"))
import config # noqa: E402
import state # noqa: E402
import sync # noqa: E402
import taskfiles # noqa: E402
import watch # noqa: E402
FILENAME = "07-shared-card.md"
CARD = ("# 07 — A card two boards can reach\n\n"
"**Status:** Backlog\n"
"**Priority:** High\n"
"**Type:** Feature\n\n"
"Body text long enough that git sees a rename rather than a delete\n"
"and an add when the file moves between two stage directories, which\n"
"is what turns a same-card race into a conflict it can report.\n")
def git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(cwd), *args],
capture_output=True, text=True)
class TwoBoards(unittest.TestCase):
"""One bare upstream, two clones — 'ada' and 'elena', each with its own
board. config.REPO/TASKS point at whichever board is acting."""
def setUp(self):
# resolve(): macOS tempdirs sit behind the /var → /private/var
# symlink and git reports the resolved path.
self.tmp = Path(tempfile.mkdtemp(prefix="bench-sync-")).resolve()
self.addCleanup(shutil.rmtree, self.tmp, True)
self.origin = self.tmp / "origin.git"
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(self.origin)],
check=True, capture_output=True)
self.ada = self._clone("ada")
for slug in config.STAGE_DIRS:
(self.ada / "tasks" / slug).mkdir(parents=True)
(self.ada / "tasks" / "backlog" / FILENAME).write_text(CARD, encoding="utf-8")
(self.ada / "code.txt").write_text("shipped\n", encoding="utf-8")
git(self.ada, "add", "-A")
git(self.ada, "commit", "-q", "-m", "root")
git(self.ada, "push", "-q", "origin", "main")
self.elena = self._clone("elena")
# REPO/TASKS are patched here (not just assigned by use()) so the
# checkout under test is restored for every other test module.
self.patch(SYNC=True, COMMIT_MOVES=True, FETCH_TIMEOUT=10.0,
SESSIONS_DIR=self.tmp / "sessions",
REPO=self.ada, TASKS=self.ada / "tasks")
self.use(self.ada)
state.BOARD_EVENTS.clear()
state.EXPECTED_MOVES.clear()
state.COMMIT_HOOKS.clear()
self.addCleanup(state.COMMIT_HOOKS.clear)
sync.ARRIVED.clear()
sync._NOTES.clear()
self.addCleanup(sync._NOTES.clear)
self.addCleanup(sync.ARRIVED.clear)
self.broadcasts: list[dict] = []
self.addCleanup(setattr, state, "broadcast", state.broadcast)
state.broadcast = self.broadcasts.append
def _clone(self, who: str) -> Path:
path = self.tmp / who
subprocess.run(["git", "clone", "-q", str(self.origin), str(path)],
check=True, capture_output=True)
git(path, "config", "user.name", who)
git(path, "config", "user.email", f"{who}@example.com")
return path
def patch(self, **values) -> None:
for attr, value in values.items():
self.addCleanup(setattr, config, attr, getattr(config, attr))
setattr(config, attr, value)
# — acting as one board or the other —
def use(self, board: Path) -> None:
config.REPO, config.TASKS = board, board / "tasks"
def move(self, board: Path, source: str, target: str) -> None:
self.use(board)
taskfiles.move_task(FILENAME, source, target)
def stage_of(self, board: Path) -> str | None:
for slug in config.STAGE_DIRS:
if (board / "tasks" / slug / FILENAME).is_file():
return slug
return None
def card(self, board: Path) -> str:
stage = self.stage_of(board)
return (board / "tasks" / stage / FILENAME).read_text(encoding="utf-8")
def head(self, board: Path) -> str:
return git(board, "rev-parse", "HEAD").stdout.strip()
def origin_head(self) -> str:
return git(self.origin, "rev-parse", "main").stdout.strip()
def summaries(self) -> list[str]:
return [e["summary"] for e in state.BOARD_EVENTS]
def toasts(self) -> list[str]:
return [b["message"] for b in self.broadcasts if b.get("type") == "toast"]
def sig(self, board: Path) -> dict[str, set[str]]:
self.use(board)
return {slug: {p.name for p in (board / "tasks" / slug).glob("*.md")}
for slug in config.STAGE_DIRS
if (board / "tasks" / slug).is_dir()}
# — a move reaching the other board —
def test_a_move_pushes_and_the_other_board_pulls_it(self):
self.move(self.ada, "backlog", "to-do")
self.assertEqual(sync.push_now(), "ok")
self.use(self.elena)
self.assertEqual(sync.pull_now(), "pulled")
self.assertEqual(self.stage_of(self.elena), "to-do")
self.assertEqual(self.card(self.elena), self.card(self.ada),
"both boards hold the same bytes")
self.assertIn("**Assignee:** ada", self.card(self.elena))
def test_the_pull_attributes_the_move_to_its_author(self):
self.move(self.ada, "backlog", "to-do")
sync.push_now()
self.use(self.elena)
before = self.sig(self.elena)
sync.pull_now()
after = self.sig(self.elena)
# Two boards are two processes: elena's has no memory of ada moving
# anything, so nothing but the pull can attribute this.
state.EXPECTED_MOVES.clear()
state.BOARD_EVENTS.clear()
watch.narrate(before, after)
moves = [e for e in state.BOARD_EVENTS if e["kind"] == "move"]
self.assertEqual(len(moves), 1)
self.assertEqual(moves[0]["actor"], "ada",
"a move that arrived over origin is not 'disk'")
self.assertEqual((moves[0]["from"], moves[0]["to"]), ("backlog", "to-do"))
def test_attribution_is_consumed_once_and_expires(self):
self.move(self.ada, "backlog", "to-do")
sync.push_now()
self.use(self.elena)
sync.pull_now()
self.assertEqual(sync.arrived_actor(FILENAME), "ada")
self.assertEqual(sync.arrived_actor(FILENAME), "",
"the next hand-move on this disk is not ada's")
def test_a_pull_that_brings_nothing_says_nothing(self):
self.use(self.elena)
state.BOARD_EVENTS.clear()
self.assertEqual(sync.pull_now(), "up-to-date")
self.assertEqual(self.summaries(), [])
def test_the_commit_hook_publishes_without_being_asked(self):
sync.install()
self.assertIn(sync.on_commit, state.COMMIT_HOOKS)
before = self.origin_head()
self.move(self.ada, "backlog", "to-do")
for _ in range(100): # the push runs off-thread
if self.origin_head() != before:
break
time.sleep(0.05)
self.assertEqual(self.origin_head(), self.head(self.ada),
"the move published itself")
# — the same-card race —
def test_losing_a_race_undoes_the_move_and_names_who_took_the_card(self):
self.move(self.ada, "backlog", "to-do")
self.assertEqual(sync.push_now(), "ok")
# elena never saw ada's move: her board still shows the card in
# backlog/ and she moves it somewhere else entirely.
self.move(self.elena, "backlog", "in-progress")
self.assertEqual(sync.push_now(), "pulled")
self.assertEqual(self.stage_of(self.elena), "to-do",
"the loser's board holds the winner's version")
self.assertEqual(self.card(self.elena), self.card(self.ada))
self.assertIn("**Assignee:** ada", self.card(self.elena))
self.assertEqual(self.origin_head(), self.head(self.elena))
self.assertIn("07 claimed by ada — your move was undone", self.toasts())
self.assertIn("07 claimed by ada — your move was undone", self.summaries())
def test_exactly_one_claim_survives_when_both_claim_the_same_card(self):
self.move(self.ada, "backlog", "to-do")
sync.push_now()
self.move(self.elena, "backlog", "to-do") # same target, other name
sync.push_now()
self.use(self.ada)
sync.pull_now()
self.assertEqual(self.card(self.ada), self.card(self.elena),
"both boards converge on the same file bytes")
self.assertEqual(self.card(self.elena).count("**Assignee:**"), 1)
self.assertIn("**Assignee:** ada", self.card(self.elena))
self.assertEqual(self.origin_head(), self.head(self.ada))
self.assertEqual(self.origin_head(), self.head(self.elena))
def test_the_winner_keeps_moving_after_the_loser_gave_way(self):
"""Convergence is not a dead end: the board that lost re-reads the
card and can move it on, and that move publishes normally."""
self.move(self.ada, "backlog", "to-do")
sync.push_now()
self.move(self.elena, "backlog", "in-progress")
sync.push_now()
self.move(self.elena, "to-do", "in-progress")
self.assertEqual(sync.push_now(), "ok")
self.use(self.ada)
sync.pull_now()
self.assertEqual(self.stage_of(self.ada), "in-progress")
def test_a_race_on_two_different_cards_keeps_both_moves(self):
other = "08-another-card.md"
(self.ada / "tasks" / "backlog" / other).write_text(
CARD.replace("# 07", "# 08"), encoding="utf-8")
git(self.ada, "add", "-A")
git(self.ada, "commit", "-q", "-m", "board: 08 → backlog (ada)")
git(self.ada, "push", "-q", "origin", "main")
self.use(self.elena)
sync.pull_now()
self.move(self.ada, "backlog", "to-do")
sync.push_now()
self.use(self.elena)
taskfiles.move_task(other, "backlog", "to-do")
self.assertEqual(sync.push_now(), "pulled")
self.assertTrue((self.elena / "tasks" / "to-do" / other).is_file(),
"elena's own move survives a rebase it does not collide with")
self.assertTrue((self.elena / "tasks" / "to-do" / FILENAME).is_file())
self.assertEqual(self.toasts(), [], "nothing was undone")
def test_a_drag_started_before_the_card_moved_underneath_is_refused(self):
"""The mid-drag race. The browser sends the stage it picked the card
up from, so a move that arrived meanwhile makes the drop stale — and
a stale drop must fail, not resurrect the card in two places."""
self.move(self.ada, "backlog", "to-do")
sync.push_now()
self.use(self.elena)
sync.pull_now()
with self.assertRaises(ValueError) as caught:
taskfiles.move_task(FILENAME, "backlog", "in-progress")
self.assertIn("no longer in backlog/", str(caught.exception))
self.assertEqual(self.stage_of(self.elena), "to-do")
self.assertFalse((self.elena / "tasks" / "in-progress" / FILENAME).exists())
# — the piggyback guard —
def test_a_human_commit_on_main_stops_the_push(self):
self.use(self.ada)
(self.ada / "code.txt").write_text("my unpushed experiment\n", encoding="utf-8")
git(self.ada, "commit", "-qam", "wip: not ready for anyone else")
before = self.origin_head()
self.move(self.ada, "backlog", "to-do")
self.assertEqual(sync.push_now(), "stray")
self.assertEqual(self.origin_head(), before, "origin never saw it")
self.assertNotIn("wip: not ready for anyone else",
git(self.origin, "log", "--format=%s", "main").stdout)
warnings = [s for s in self.summaries() if "not a board commit" in s]
self.assertEqual(len(warnings), 1)
self.assertIn("wip: not ready for anyone else", warnings[0])
self.assertEqual(sync.status()["state"], "stalled")
def test_the_guard_warns_once_however_often_the_beat_runs(self):
self.use(self.ada)
git(self.ada, "commit", "-q", "--allow-empty", "-m", "wip: mine")
self.move(self.ada, "backlog", "to-do")
for _ in range(4):
sync.push_now()
sync.pull_now()
self.assertEqual(len([s for s in self.summaries() if "not a board commit" in s]), 1)
def test_a_human_commit_also_blocks_the_rebase_and_says_so(self):
self.move(self.ada, "backlog", "to-do")
sync.push_now()
self.use(self.elena)
(self.elena / "code.txt").write_text("elena's experiment\n", encoding="utf-8")
git(self.elena, "commit", "-qam", "wip: elena's own work")
before = self.head(self.elena)
self.assertEqual(sync.pull_now(), "diverged")
self.assertEqual(self.head(self.elena), before,
"nothing was rebased over the human's commit")
self.assertEqual(self.stage_of(self.elena), "backlog")
self.assertTrue(any("diverged" in s for s in self.summaries()))
self.assertEqual(sync.status()["state"], "stalled")
def test_the_guard_stands_down_once_the_human_commit_is_gone(self):
self.use(self.ada)
git(self.ada, "commit", "-q", "--allow-empty", "-m", "wip: mine")
self.move(self.ada, "backlog", "to-do")
self.assertEqual(sync.push_now(), "stray")
git(self.ada, "push", "-q", "origin", "main") # the human pushes it themselves
self.assertEqual(sync.push_now(), "nothing")
self.assertEqual(sync.status()["state"], "ok")
# — offline —
def test_an_unreachable_origin_is_quiet_and_catches_up(self):
self.use(self.ada)
git(self.ada, "remote", "set-url", "origin", str(self.tmp / "gone.git"))
self.move(self.ada, "backlog", "to-do")
self.assertEqual(sync.push_now(), "offline")
for _ in range(3):
self.assertEqual(sync.pull_now(), "offline")
self.assertEqual(len([s for s in self.summaries() if "unreachable" in s]), 1,
"one quiet note, not one per beat")
self.assertEqual(sync.status()["state"], "offline")
self.assertEqual(self.stage_of(self.ada), "to-do",
"the board kept working while origin was gone")
git(self.ada, "remote", "set-url", "origin", str(self.origin))
self.assertEqual(sync.pull_now(), "up-to-date")
self.assertEqual(self.origin_head(), self.head(self.ada),
"the queued commit went out on the next reachable beat")
self.assertTrue(any("caught up" in s for s in self.summaries()))
self.assertEqual(sync.status()["state"], "ok")
self.use(self.elena)
sync.pull_now()
self.assertEqual(self.stage_of(self.elena), "to-do")
def test_no_origin_at_all_is_simply_nothing_to_do(self):
self.use(self.ada)
git(self.ada, "remote", "remove", "origin")
self.move(self.ada, "backlog", "to-do")
self.assertEqual(sync.push_now(), "no-origin")
self.assertEqual(sync.pull_now(), "no-origin")
self.assertEqual(self.summaries(), [])
# — never pulling into a checkout that is not ready —
def test_uncommitted_changes_stall_the_pull_loudly(self):
self.move(self.ada, "backlog", "to-do")
sync.push_now()
self.use(self.elena)
(self.elena / "code.txt").write_text("half-finished\n", encoding="utf-8")
before = self.head(self.elena)
self.assertEqual(sync.pull_now(), "dirty")
self.assertEqual(self.head(self.elena), before)
self.assertEqual((self.elena / "code.txt").read_text(encoding="utf-8"),
"half-finished\n")
self.assertTrue(any("uncommitted changes" in s for s in self.summaries()))
self.assertEqual(sync.status()["state"], "stalled")
git(self.elena, "checkout", "--", "code.txt")
self.assertEqual(sync.pull_now(), "pulled")
self.assertEqual(sync.status()["state"], "ok")
def test_untracked_files_do_not_stall_anything(self):
self.move(self.ada, "backlog", "to-do")
sync.push_now()
self.use(self.elena)
(self.elena / "scratch.txt").write_text("mine\n", encoding="utf-8")
self.assertEqual(sync.pull_now(), "pulled")
def test_a_checkout_off_main_pauses_sync(self):
self.move(self.ada, "backlog", "to-do")
sync.push_now()
self.use(self.elena)
git(self.elena, "checkout", "-q", "-b", "side")
before = self.head(self.elena)
self.assertEqual(sync.pull_now(), "not-on-main")
self.assertEqual(self.head(self.elena), before)
self.assertTrue(any("not main" in s for s in self.summaries()))
# — the gate —
def test_the_gate_off_does_not_touch_the_network(self):
self.patch(SYNC=False)
self.use(self.ada)
# A remote that never answers: anything that fetched or pushed here
# would hang instead of returning at once.
git(self.ada, "config", "protocol.ext.allow", "always")
git(self.ada, "remote", "set-url", "origin", "ext::sleep 30")
started = time.monotonic()
self.assertEqual(sync.push_now(), "off")
self.assertEqual(sync.pull_now(), "off")
sync.on_commit(FILENAME)
self.assertLess(time.monotonic() - started, 2)
self.assertEqual(self.summaries(), [])
self.assertEqual(sync.status(), {"enabled": False, "state": "off", "detail": ""})
def test_the_gate_off_leaves_moves_exactly_as_they_were(self):
self.patch(SYNC=False, COMMIT_MOVES=False)
before = self.origin_head()
self.move(self.ada, "backlog", "to-do")
self.assertEqual(self.stage_of(self.ada), "to-do")
self.assertEqual(self.head(self.ada), before, "no commit, no push")
self.assertEqual(self.origin_head(), before)
self.assertNotIn("**Assignee:**", self.card(self.ada))
def test_a_registered_hook_is_inert_with_the_gate_off(self):
sync.install()
self.patch(SYNC=False)
before = self.origin_head()
self.move(self.ada, "backlog", "to-do")
time.sleep(0.2)
self.assertEqual(self.origin_head(), before)
def test_the_watcher_still_says_disk_for_a_plain_hand_move(self):
self.use(self.ada)
before = self.sig(self.ada)
shutil.move(str(self.ada / "tasks" / "backlog" / FILENAME),
str(self.ada / "tasks" / "to-do" / FILENAME))
state.BOARD_EVENTS.clear()
watch.narrate(before, self.sig(self.ada))
self.assertEqual(state.BOARD_EVENTS[0]["actor"], "disk")
class TheGateImpliesCommitMoves(unittest.TestCase):
"""BOARD_SYNC=1 turns BOARD_COMMIT_MOVES on: there is nothing to publish
until moves commit themselves."""
def reload(self, **env) -> None:
saved = {k: os.environ.get(k) for k in ("BOARD_SYNC", "BOARD_COMMIT_MOVES")}
def restore():
for key, value in saved.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
importlib.reload(config)
self.addCleanup(restore)
for key in saved:
os.environ.pop(key, None)
os.environ.update(env)
importlib.reload(config)
def test_sync_on_implies_commit_moves(self):
self.reload(BOARD_SYNC="1")
self.assertTrue(config.SYNC)
self.assertTrue(config.COMMIT_MOVES)
def test_both_are_off_by_default(self):
self.reload()
self.assertFalse(config.SYNC)
self.assertFalse(config.COMMIT_MOVES)
self.assertEqual(config.SYNC_INTERVAL, 30.0)
def test_commit_moves_alone_stays_alone(self):
self.reload(BOARD_COMMIT_MOVES="1")
self.assertTrue(config.COMMIT_MOVES)
self.assertFalse(config.SYNC)
class TheStrayCommitTest(unittest.TestCase):
"""The piggyback guard reads commit subjects — the one thing standing
between a human's private work and origin."""
def test_board_commits_pass(self):
self.assertEqual(sync._stray(["abc board: 07 → to-do (ada)",
"def board: 08 → done (elena)"]), "")
def test_the_oldest_stray_is_the_one_named(self):
self.assertEqual(
sync._stray(["abc board: 07 → to-do (ada)", "def wip: older", "aaa wip: oldest"]),
"aaa wip: oldest")
def test_a_commit_merely_mentioning_the_board_is_still_stray(self):
self.assertEqual(sync._stray(["abc fix the board: really"]),
"abc fix the board: really")
class TheSyncChip(unittest.TestCase):
"""board.html is a single file with no frontend runner — these are the
source-level invariants of the surface this card adds."""
@classmethod
def setUpClass(cls):
cls.html = (REPO / "manager" / "core" / "board.html").read_text(encoding="utf-8")
def test_a_server_toast_reaches_the_person(self):
self.assertIn("msg.type === 'toast'", self.html)
self.assertIn("toast(msg.message, !!msg.error)", self.html)
def test_the_chip_hides_itself_while_sync_is_healthy(self):
self.assertIn("if (!s || !s.enabled || s.state === 'ok') { el.hidden = true;", self.html)
self.assertIn(".livechip[hidden]{display:none}", self.html,
"the chip's own display:flex would beat the UA's [hidden]")
def test_a_refused_move_re_reads_the_board(self):
"""What makes the mid-drag race safe in the browser: the drop sends
the stage it started from, and a rejected move reloads disk state
rather than leaving the stale card on screen."""
self.assertIn("const { file, from } = JSON.parse(e.dataTransfer.getData("
"'application/json'));", self.html)
self.assertIn("if (!res.ok) { toast(data.error || 'move failed', true); "
"await loadState(); return false; }", self.html)
def test_sync_events_have_a_glyph_and_a_filter(self):
self.assertIn("sync: '⇅'", self.html)
self.assertIn("new Set(['move', 'new', 'agent', 'sync'])", self.html)
if __name__ == "__main__":
unittest.main()