diff --git a/manager/core/board.html b/manager/core/board.html
index 7cbe7e5..ddcbc9e 100644
--- a/manager/core/board.html
+++ b/manager/core/board.html
@@ -86,6 +86,9 @@
background:var(--surface);border:1px solid var(--border);border-radius:99px;font-size:12.5px;
}
.livechip .mono{font-size:11.5px;color:var(--dim)}
+ /* an author display beats the UA's [hidden] — say it here or the sync
+ chip is never hidden */
+ .livechip[hidden]{display:none}
.dot{width:7px;height:7px;border-radius:99px;background:var(--idle);flex:none}
.dot.live{background:var(--accent);animation:breathe 2.4s ease-in-out infinite}
@@ -480,6 +483,7 @@
+
@@ -562,10 +566,10 @@ const STAGE_TINT = { backlog: 'var(--dim)', 'to-do': 'var(--muted)',
const STAGE_NOTE = { 'to-do': 'next up', review: 'your move' };
const GLYPHS = { session: '●', end: '○', idle: '…', edit: '✎', read: '◔', search: '⌕',
command: '$', test: '▶', check: '☑', git: '⎇', plan: '≡', subagent: '⑂', web: '∿',
- move: '⇢', new: '+', agent: '⚑', report: '▣', other: '·' };
+ move: '⇢', new: '+', agent: '⚑', report: '▣', sync: '⇅', other: '·' };
const FILTERS = [
['all', 'All', null],
- ['moves', 'Moves', new Set(['move', 'new', 'agent'])],
+ ['moves', 'Moves', new Set(['move', 'new', 'agent', 'sync'])],
['edits', 'Edits', new Set(['edit'])],
['reads', 'Reads', new Set(['read', 'search'])],
['tests', 'Tests', new Set(['test', 'check'])],
@@ -702,6 +706,10 @@ function connectStream() {
scheduleRender();
} else if (msg.type === 'board' || msg.type === 'agents') {
loadState();
+ } else if (msg.type === 'toast') {
+ // the server needs to say something to the person, not just the
+ // ticker — losing a card to another board is the case that matters
+ toast(msg.message, !!msg.error);
} else if (msg.type === 'board_event') {
S.state?.boardEvents.push(msg.event);
scheduleRender();
@@ -741,6 +749,23 @@ function renderChip() {
}
}
+/* Sync only shows itself when it has stopped converging: a stall that is
+ not visible is two halves of a team quietly drifting apart. Offline is
+ driftwood (degraded, self-healing); anything waiting on a human is
+ terracotta. */
+function renderSync() {
+ const s = S.state?.sync;
+ const el = $('#syncchip');
+ if (!s || !s.enabled || s.state === 'ok') { el.hidden = true; return; }
+ const alarm = s.state === 'stalled';
+ const detail = s.detail || '';
+ el.hidden = false;
+ el.title = detail;
+ el.innerHTML = `` +
+ `sync ${alarm ? 'stalled' : 'behind'}` +
+ `${esc(detail.split(' — ')[0].replace(/^sync[^:]*:\s*/, ''))}`;
+}
+
function setView(view) {
S.view = view;
document.querySelectorAll('#views button').forEach(b => b.classList.toggle('on', b.dataset.view === view));
@@ -762,6 +787,7 @@ function render() {
if (!S.state) return;
renderTitle();
renderChip();
+ renderSync();
if (S.view === 'board') renderBoard();
else if (S.view === 'flight') renderFlight();
else renderFocus();
diff --git a/manager/core/board.py b/manager/core/board.py
index eae7286..556ec46 100755
--- a/manager/core/board.py
+++ b/manager/core/board.py
@@ -12,6 +12,7 @@ task files, but the tasks work as a plain folder kanban without it. See
state.py shared registries, event persistence, SSE fan-out
taskfiles.py reading/moving task files (the only code touching tasks/)
events.py hook payloads → displayable events, session registry
+ sync.py origin/main as the shared board: push on move, pull on a beat
agents.py headless work/review agents: launch, reap, stop, diff
watch.py 2s disk poller narrating moves made outside the API
httpd.py HTTP routes, SSE stream, the page itself
@@ -34,6 +35,7 @@ import events
import github
import httpd
import state
+import sync
import watch
@@ -64,6 +66,11 @@ def main() -> None:
threading.Thread(target=watch.watcher, daemon=True).start()
threading.Thread(target=github.poller, daemon=True).start()
threading.Thread(target=github.reconcile, daemon=True).start()
+ if config.SYNC:
+ # Team mode: board commits publish themselves and a beat pulls what
+ # the other boards published. Off, neither thread nor hook exists.
+ sync.install()
+ threading.Thread(target=sync.beat, daemon=True).start()
drive.adopt()
print(f"Task board for {config.TASKS}\n {url}\n Ctrl-C to stop")
diff --git a/manager/core/config.py b/manager/core/config.py
index 4eff8f2..d0bf41a 100644
--- a/manager/core/config.py
+++ b/manager/core/config.py
@@ -134,12 +134,19 @@ PR_POLL_INTERVAL = float(setting("BOARD_PR_POLL_INTERVAL", "60"))
# network weather; this bounds the whole delay.
FETCH_TIMEOUT = float(setting("BOARD_FETCH_TIMEOUT", "10"))
+# Team mode's second half: origin/main is the shared truth and every board
+# a converging replica — board commits push as they are made, a beat pulls
+# what other boards published. Off by default; on, it implies COMMIT_MOVES
+# below, because a move that never commits has nothing to publish.
+SYNC = flag("BOARD_SYNC")
+SYNC_INTERVAL = float(setting("BOARD_SYNC_INTERVAL", "30"))
+
# Team mode's first half: a board-made move claims the card (writing
# **Assignee:** from git's own user.name) and commits itself, so ownership
# and stage travel with the file to every clone. Off by default — a
# single-player board moves cards exactly as it always did, and committing
# tasks/ stays a hand job.
-COMMIT_MOVES = flag("BOARD_COMMIT_MOVES")
+COMMIT_MOVES = flag("BOARD_COMMIT_MOVES") or SYNC
WATCH_INTERVAL = float(setting("BOARD_WATCH_INTERVAL", "2"))
EVENTS_CAP = int(setting("BOARD_EVENTS_CAP", "800"))
diff --git a/manager/core/httpd.py b/manager/core/httpd.py
index 817e8ac..56dd2b5 100644
--- a/manager/core/httpd.py
+++ b/manager/core/httpd.py
@@ -18,6 +18,7 @@ import drive
import events
import github
import state
+import sync
import taskfiles
@@ -40,6 +41,7 @@ def state_payload() -> dict:
"commandRuns": commands.public(),
"checks": config.checks(),
"archivedCount": taskfiles.archived_count(),
+ "sync": sync.status(),
"boardEvents": board_events,
"now": time.time(),
}
diff --git a/manager/core/state.py b/manager/core/state.py
index d387f43..ef4171e 100644
--- a/manager/core/state.py
+++ b/manager/core/state.py
@@ -21,6 +21,7 @@ EVENTS: dict[str, list[dict]] = {} # session_id -> slim events
BOARD_EVENTS: list[dict] = [] # moves + agent lifecycle
AGENTS: dict[str, dict] = {} # agent_id -> launch record
EXPECTED_MOVES: dict[tuple[str, str], tuple[str, float]] = {} # (file, to) -> (actor, ts)
+COMMIT_HOOKS: list = [] # run after a board-made task commit
# The port actually being served; board.py sets it from --port at startup so
# launched agents know where to report events.
@@ -59,6 +60,19 @@ def record_board_event(event: dict) -> None:
broadcast({"type": "board_event", "event": event})
+def task_committed(filename: str) -> None:
+ """A board-made move committed itself. Registered hooks turn that into
+ whatever else should follow — sync.py's push, when the gate is on. The
+ hook is a registry rather than an import so taskfiles stays to the left
+ of everything that reacts to it; a hook that raises must never break a
+ move that has already happened on disk."""
+ for hook in list(COMMIT_HOOKS):
+ try:
+ hook(filename)
+ except Exception: # noqa: BLE001 — the move is done; nothing may undo it
+ pass
+
+
def expect_move(filename: str, target: str, actor: str) -> None:
"""Tell the watcher who is about to move a file so it can attribute it."""
with LOCK:
diff --git a/manager/core/sync.py b/manager/core/sync.py
new file mode 100644
index 0000000..587937f
--- /dev/null
+++ b/manager/core/sync.py
@@ -0,0 +1,448 @@
+"""Boards converge through origin/main: push what this board commits, pull
+what the other boards published.
+
+Gated on `BOARD_SYNC` (which implies `BOARD_COMMIT_MOVES` — a move that
+never commits has nothing to publish). Off, nothing here runs: no fetch,
+no push, no thread.
+
+The shape of it:
+
+- **push** is event-driven. `taskfiles` fires `state.task_committed` after
+ a board-made move commits; the hook installed here publishes it. A
+ rejected push means another board got there first, so the whole converge
+ runs and pushes again.
+- **pull** is a beat: fetch, then integrate. Purely behind → fast-forward.
+ Diverged → the board's own commits are rebased on top, never merged
+ past; a rebase that conflicts on a task file means the local move lost
+ the race, and it is dropped with a toast naming who took the card.
+- **the piggyback guard** stands in front of every push: each local-ahead
+ commit on main must be `board: `-prefixed. A human's unpushed work is
+ never published as a side effect of a card moving.
+- **offline** is not an error. The first unreachable fetch says so once,
+ the rest are silent, commits queue on local main and the next reachable
+ fetch catches up.
+
+Git is the lock server and main the linearizer — that is the whole
+concurrency control. Nothing here reacts to synced state beyond narrating
+it: replicas render, they do not act.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import threading
+import time
+from pathlib import Path
+
+import config
+import state
+from taskfiles import NUMBER_RE
+
+REMOTE = "origin" # one remote, one branch — by design
+BRANCH = "main"
+UPSTREAM = f"{REMOTE}/{BRANCH}"
+BOARD_COMMIT = "board: " # the prefix taskfiles messages its own commits with
+PUSH_TIMEOUT = 120
+REBASE_TIMEOUT = 120
+ARRIVED_TTL = 60.0 # the watcher polls every 2s; this is generous
+
+_LOCK = threading.Lock() # one git operation on the checkout at a time
+_ARRIVED_LOCK = threading.Lock()
+ARRIVED: dict[str, tuple[str, float]] = {} # filename -> (author, ts) from the last pull
+_NOTES: dict[str, tuple[str, str]] = {} # key -> (summary, level) already narrated
+
+
+def _git(*args: str, timeout: float = 30) -> subprocess.CompletedProcess:
+ """Never raises: a timeout or a missing binary is just a failed run."""
+ try:
+ return subprocess.run(["git", "-C", str(config.REPO), *args],
+ capture_output=True, text=True, timeout=timeout)
+ except subprocess.TimeoutExpired:
+ return subprocess.CompletedProcess(args, 128, "", "timed out")
+ except OSError as exc:
+ return subprocess.CompletedProcess(args, 128, "", str(exc))
+
+
+# ── narration ──────────────────────────────────────────────────────────
+# Every condition here repeats on every beat, so each one is narrated once
+# and then held: the ticker says it, the header chip keeps saying it.
+
+
+def status() -> dict:
+ """What the header shows: ok while converging, otherwise the reason."""
+ if not config.SYNC:
+ return {"enabled": False, "state": "off", "detail": ""}
+ for level in ("offline", "stalled"):
+ for summary, note_level in _NOTES.values():
+ if note_level == level:
+ return {"enabled": True, "state": level, "detail": summary}
+ return {"enabled": True, "state": "ok", "detail": ""}
+
+
+def _note(key: str, summary: str, level: str = "stalled") -> None:
+ if _NOTES.get(key) == (summary, level):
+ return # same condition as last time: said once is enough
+ _NOTES[key] = (summary, level)
+ state.record_board_event({"kind": "sync", "actor": "sync", "summary": summary})
+ state.broadcast({"type": "board"})
+
+
+def _clear(key: str, recovery: str = "") -> None:
+ if _NOTES.pop(key, None) is None:
+ return
+ if recovery:
+ state.record_board_event({"kind": "sync", "actor": "sync", "summary": recovery})
+ state.broadcast({"type": "board"})
+
+
+# ── the checkout ───────────────────────────────────────────────────────
+
+
+def _origin_present() -> bool:
+ return REMOTE in _git("remote").stdout.split()
+
+
+def _head() -> str:
+ return _git("rev-parse", "HEAD").stdout.strip()
+
+
+def _on_main() -> bool:
+ return _git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() == BRANCH
+
+
+def _clean() -> bool:
+ """Tracked files only: an untracked scratch file is nobody's business,
+ but a modified one is what a fast-forward would run over."""
+ return not _git("status", "--porcelain", "--untracked-files=no").stdout.strip()
+
+
+def _count(rng: str) -> int:
+ out = _git("rev-list", "--count", rng).stdout.strip()
+ return int(out) if out.isdigit() else 0
+
+
+def _tasks_prefix() -> str:
+ try:
+ return config.TASKS.resolve().relative_to(config.REPO.resolve()).as_posix() + "/"
+ except ValueError:
+ return "tasks/"
+
+
+def _fetch() -> bool:
+ result = _git("fetch", REMOTE, BRANCH, timeout=config.FETCH_TIMEOUT)
+ if result.returncode != 0:
+ if "couldn't find remote ref" in (result.stderr or "").lower():
+ _note("no-branch", f"sync stalled: {REMOTE} has no {BRANCH} branch — "
+ f"sync rides {UPSTREAM} and nothing else")
+ return False
+ _note("offline",
+ f"sync is behind: {REMOTE} is unreachable — this board keeps "
+ f"working locally and catches up when it returns", "offline")
+ return False
+ _clear("no-branch")
+ _clear("offline", f"sync caught up: {REMOTE} is reachable again")
+ return True
+
+
+# ── publishing ─────────────────────────────────────────────────────────
+
+
+def _ahead() -> list[str]:
+ """`` for every commit local main has and
+ origin/main does not — newest first."""
+ out = _git("log", "--format=%h %s", f"{UPSTREAM}..{BRANCH}").stdout
+ return [line for line in out.splitlines() if line.strip()]
+
+
+def _stray(commits: list[str]) -> str:
+ """The piggyback hazard: pushing publishes *every* local-ahead commit,
+ so one that the board did not make is a human's private work and stops
+ the push. Oldest first — that is the one to deal with."""
+ for line in reversed(commits):
+ subject = line.split(" ", 1)[1] if " " in line else ""
+ if not subject.startswith(BOARD_COMMIT):
+ return line
+ return ""
+
+
+def _publish() -> str:
+ """Push local main if — and only if — everything on it is the board's.
+
+ ok | nothing | stray | not-on-main | retry | offline | stalled
+ """
+ if not _on_main():
+ branch = _git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() or "a detached HEAD"
+ _note("branch", f"sync paused: this checkout is on '{branch}', not {BRANCH} — "
+ f"board commits are not landing where sync publishes from")
+ return "not-on-main"
+ _clear("branch")
+ if _git("rev-parse", "--verify", "--quiet", UPSTREAM).returncode != 0:
+ return "retry" # never fetched: converge first, then publish
+ commits = _ahead()
+ stray = _stray(commits)
+ if stray:
+ _note("stray", f"not pushing: {stray} is not a board commit — sync will not "
+ f"publish it for you. Push main yourself, or move that commit "
+ f"off main, and sync resumes")
+ return "stray"
+ _clear("stray") # nothing stray left to refuse, however that happened
+ if not commits:
+ return "nothing"
+
+ result = _git("push", REMOTE, f"{BRANCH}:{BRANCH}", timeout=PUSH_TIMEOUT)
+ if result.returncode == 0:
+ _clear("push")
+ _clear("offline", f"sync caught up: {REMOTE} is reachable again")
+ state.record_board_event({
+ "kind": "sync", "actor": "sync",
+ "summary": f"pushed {len(commits)} board commit"
+ f"{'s' if len(commits) > 1 else ''} to {UPSTREAM}"})
+ return "ok"
+ stderr = (result.stderr or result.stdout).strip()
+ if _rejected(stderr):
+ return "retry"
+ if _unreachable(stderr):
+ _note("offline",
+ f"sync is behind: {REMOTE} is unreachable — this board keeps "
+ f"working locally and catches up when it returns", "offline")
+ return "offline"
+ detail = stderr.splitlines()[-1][:140] if stderr else "git said nothing"
+ _note("push", f"sync could not push to {UPSTREAM}: {detail}")
+ return "stalled"
+
+
+def _rejected(stderr: str) -> bool:
+ text = stderr.lower()
+ return "non-fast-forward" in text or "fetch first" in text or "! [rejected]" in text
+
+
+def _unreachable(stderr: str) -> bool:
+ text = stderr.lower()
+ return any(mark in text for mark in (
+ "could not read from remote", "could not resolve", "unable to access",
+ "does not appear to be a git repository", "connection", "timed out",
+ "no such file or directory", "permission denied"))
+
+
+# ── integrating what arrived ───────────────────────────────────────────
+
+
+def _conflicted() -> list[str]:
+ out = _git("diff", "--name-only", "--diff-filter=U").stdout
+ return [line.strip() for line in out.splitlines() if line.strip()]
+
+
+def _is_task_file(path: str) -> bool:
+ return path.startswith(_tasks_prefix()) and path.endswith(".md")
+
+
+def _author_of(filename: str, rev: str) -> str:
+ """Who wrote the newest commit touching this card in `rev` — a range for
+ what a pull brought, a ref for what origin already holds."""
+ return _git("log", "-1", "--format=%an", rev, "--",
+ f"{_tasks_prefix()}*/{filename}").stdout.strip()
+
+
+def _number(filename: str) -> str:
+ match = NUMBER_RE.match(filename)
+ return match.group(1) if match else filename[:-3] if filename.endswith(".md") else filename
+
+
+def _lost(filename: str) -> None:
+ """The local move lost the race. Say who took the card — the file itself
+ reverts to origin's version when the rebase drops our commit."""
+ who = _author_of(filename, UPSTREAM) or "someone else"
+ message = f"{_number(filename)} claimed by {who} — your move was undone"
+ state.record_board_event({"kind": "sync", "actor": "sync", "file": filename,
+ "summary": message})
+ state.broadcast({"type": "toast", "message": message, "error": True})
+ state.broadcast({"type": "board"})
+
+
+def _replay() -> str:
+ """Rebase this board's commits onto origin/main. Conflicts on a task
+ file are resolved by dropping our commit: origin is the linearizer, and
+ a card someone else moved first is theirs. Anything conflicting outside
+ tasks/ is a real collision — abort and wait for a human.
+
+ ok | dirty | stalled
+ """
+ if not _clean():
+ _note("dirty", "sync paused: main has uncommitted changes — commit or stash "
+ "them and sync resumes (code work belongs in a worktree)")
+ return "dirty"
+ _clear("dirty")
+
+ result = _git("rebase", UPSTREAM, timeout=REBASE_TIMEOUT)
+ for _ in range(50): # bounded: one round per replayed commit
+ if result.returncode == 0:
+ _clear("replay")
+ return "ok"
+ conflicted = _conflicted()
+ if not conflicted or not all(_is_task_file(p) for p in conflicted):
+ _git("rebase", "--abort")
+ detail = ", ".join(conflicted[:3]) or (result.stderr or result.stdout).strip()[-140:]
+ _note("replay", f"sync stalled: replaying this board's commits onto {UPSTREAM} "
+ f"collides outside tasks/ ({detail}) — a human has to settle it")
+ return "stalled"
+ for name in dict.fromkeys(Path(p).name for p in conflicted):
+ _lost(name)
+ result = _git("rebase", "--skip", timeout=REBASE_TIMEOUT)
+ _git("rebase", "--abort")
+ _note("replay", f"sync stalled: replaying onto {UPSTREAM} did not settle — "
+ f"a human has to settle it")
+ return "stalled"
+
+
+def _integrate() -> str:
+ """Bring local main to origin/main without ever merging past a
+ divergence.
+
+ up-to-date | pulled | not-on-main | dirty | diverged | stalled
+ """
+ if _count(f"{BRANCH}..{UPSTREAM}") == 0:
+ return "up-to-date"
+ if not _on_main():
+ branch = _git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() or "a detached HEAD"
+ _note("branch", f"sync paused: this checkout is on '{branch}', not {BRANCH} — "
+ f"switch back and the board catches up with {UPSTREAM}")
+ return "not-on-main"
+ _clear("branch")
+
+ # Diverged. The board's own bookkeeping can be replayed on top of what
+ # arrived — that is how a lost race resolves. A human's commit cannot,
+ # and the guard that refuses to push it refuses to rebase it too.
+ commits = _ahead()
+ stray = _stray(commits)
+ if stray:
+ _note("diverged", f"sync stalled: main and {UPSTREAM} have diverged and "
+ f"{stray} is not a board commit — pull or rebase it by "
+ f"hand, and this board starts converging again")
+ return "diverged"
+ _clear("diverged")
+ if commits:
+ outcome = _replay()
+ return "pulled" if outcome == "ok" else outcome
+
+ if not _clean():
+ _note("dirty", "sync paused: main has uncommitted changes — commit or stash "
+ "them and sync resumes (code work belongs in a worktree)")
+ return "dirty"
+ _clear("dirty")
+ result = _git("merge", "--ff-only", UPSTREAM, timeout=REBASE_TIMEOUT)
+ if result.returncode != 0:
+ detail = (result.stderr or result.stdout).strip().splitlines()
+ _note("merge", f"sync stalled: fast-forwarding to {UPSTREAM} failed "
+ f"({detail[-1][:140] if detail else 'no detail'})")
+ return "stalled"
+ _clear("merge")
+ return "pulled"
+
+
+def _record_arrivals(before: str) -> None:
+ """Attribute what the pull brought: each task file it touched is filed
+ under the name of whoever committed it, for the watcher to use instead
+ of "disk" when the move surfaces on the next poll."""
+ head = _head()
+ if not before or not head or head == before:
+ return
+ rng = f"{before}..{head}"
+ changed = _git("diff", "--name-only", rng, "--", _tasks_prefix()).stdout.splitlines()
+ names = sorted({Path(p).name for p in changed if p.strip().endswith(".md")})
+ if not names:
+ return
+ now = time.time()
+ authors = set()
+ with _ARRIVED_LOCK:
+ for name in names:
+ who = _author_of(name, rng)
+ if who:
+ ARRIVED[name] = (who, now)
+ authors.add(who)
+ for name in [n for n, (_, ts) in ARRIVED.items() if now - ts > ARRIVED_TTL]:
+ ARRIVED.pop(name, None)
+ count = _count(rng)
+ state.record_board_event({
+ "kind": "sync", "actor": "sync",
+ "summary": f"pulled {count} commit{'s' if count != 1 else ''} from {UPSTREAM}"
+ + (f" ({', '.join(sorted(authors))})" if authors else "")})
+ state.broadcast({"type": "board"})
+
+
+def arrived_actor(filename: str) -> str:
+ """Who moved this card, if a pull just brought it. Consumed once — the
+ watcher asks exactly when it notices the move."""
+ with _ARRIVED_LOCK:
+ entry = ARRIVED.pop(filename, None)
+ if not entry:
+ return ""
+ who, ts = entry
+ return who if time.time() - ts <= ARRIVED_TTL else ""
+
+
+# ── the two entry points ───────────────────────────────────────────────
+
+
+def _converge() -> str:
+ """One full beat: fetch, integrate what arrived, publish what is ours."""
+ if not _origin_present():
+ return "no-origin"
+ if not _fetch():
+ return "offline"
+ before = _head()
+ outcome = _integrate()
+ _record_arrivals(before)
+ if outcome in ("up-to-date", "pulled"):
+ published = _publish()
+ if published in ("stray", "offline", "stalled", "not-on-main"):
+ return published
+ return outcome
+
+
+def push_now() -> str:
+ """A board commit just landed — publish it. The fast path skips the
+ fetch; a rejection means another board pushed first, and then the full
+ converge (fetch, replay, push) runs."""
+ if not config.SYNC:
+ return "off"
+ with _LOCK:
+ if not _origin_present():
+ return "no-origin"
+ outcome = _publish()
+ if outcome != "retry":
+ return outcome
+ return _converge()
+
+
+def pull_now() -> str:
+ """The beat. Also the offline catch-up: a fetch that works again is
+ followed by the push that could not happen while origin was gone."""
+ if not config.SYNC:
+ return "off"
+ with _LOCK:
+ return _converge()
+
+
+def on_commit(filename: str) -> None:
+ """The `state.task_committed` hook: publish off the caller's thread, so
+ a card move never waits on the network."""
+ if not config.SYNC:
+ return
+ threading.Thread(target=push_now, name="sync-push", daemon=True).start()
+
+
+def install() -> None:
+ """Wire the push hook. Called once at startup, only with the gate on."""
+ if on_commit not in state.COMMIT_HOOKS:
+ state.COMMIT_HOOKS.append(on_commit)
+
+
+def beat(interval: float | None = None) -> None:
+ interval = config.SYNC_INTERVAL if interval is None else interval
+ while True:
+ try:
+ pull_now()
+ except Exception as exc: # noqa: BLE001 — the beat outlives a bad cycle
+ state.record_board_event({"kind": "sync", "actor": "sync",
+ "summary": f"sync cycle failed: {str(exc)[:140]}"})
+ time.sleep(interval)
diff --git a/manager/core/taskfiles.py b/manager/core/taskfiles.py
index 6ad70b2..3d42ce8 100644
--- a/manager/core/taskfiles.py
+++ b/manager/core/taskfiles.py
@@ -201,6 +201,7 @@ def _commit_move(filename: str, target: str, src: Path, dst: Path, who: str,
if result.returncode == 0:
result = _git("commit", "--no-verify", "-m", message, "--", *spec, timeout=60)
if result.returncode == 0:
+ state.task_committed(filename) # sync (when on) publishes it
return
lines = (result.stderr or result.stdout).strip().splitlines()
detail = lines[-1] if lines else f"git exited {result.returncode}"
diff --git a/manager/core/watch.py b/manager/core/watch.py
index 0195ac1..14a3680 100644
--- a/manager/core/watch.py
+++ b/manager/core/watch.py
@@ -1,7 +1,8 @@
"""Disk watcher: the directories are the source of truth, so poll and narrate.
-Catches moves the HTTP API never saw — a file dragged by hand, an agent, or
-another tool — and attributes them via the expectations registered in state.
+Catches moves the HTTP API never saw — a file dragged by hand, an agent,
+another tool, or a pull from origin/main — and attributes them via the
+expectations registered in state and the arrivals registered by sync.
"""
from __future__ import annotations
@@ -11,6 +12,7 @@ import time
import config
import github
import state
+import sync
def _board_sig() -> dict[str, set[str]]:
@@ -21,6 +23,40 @@ def _board_sig() -> dict[str, set[str]]:
return sig
+def _actor(filename: str, stage: str) -> str:
+ """Who did this. A move this board made is claimed from the
+ expectations; one a pull brought carries its commit author's name; a
+ plain mv on this disk is nobody in particular."""
+ actor = state.claim_expected(filename, stage)
+ if actor == "disk":
+ return sync.arrived_actor(filename) or "disk"
+ return actor
+
+
+def narrate(prev: dict[str, set[str]], cur: dict[str, set[str]]) -> None:
+ """Two board signatures → the events between them."""
+ prev_loc = {f: s for s, files in prev.items() for f in files}
+ cur_loc = {f: s for s, files in cur.items() for f in files}
+ for f, stage in sorted(cur_loc.items()):
+ if f in prev_loc and prev_loc[f] != stage:
+ actor = _actor(f, stage)
+ state.record_board_event({
+ "kind": "move", "file": f, "from": prev_loc[f], "to": stage,
+ "actor": actor,
+ "summary": f"{f} moved {prev_loc[f]} → {stage} ({actor})",
+ })
+ if stage == "review":
+ # a card entering review with a work branch gets a PR
+ github.open_pr_async(f)
+ elif f not in prev_loc:
+ actor = _actor(f, stage)
+ state.record_board_event({
+ "kind": "new", "file": f, "to": stage, "actor": actor,
+ "summary": f"{f} appeared in {stage}/"
+ + (f" ({actor})" if actor != "disk" else ""),
+ })
+
+
def watcher(interval: float | None = None) -> None:
interval = config.WATCH_INTERVAL if interval is None else interval
prev = _board_sig()
@@ -32,23 +68,6 @@ def watcher(interval: float | None = None) -> None:
continue
if cur == prev:
continue
- prev_loc = {f: s for s, files in prev.items() for f in files}
- cur_loc = {f: s for s, files in cur.items() for f in files}
- for f, stage in sorted(cur_loc.items()):
- if f in prev_loc and prev_loc[f] != stage:
- actor = state.claim_expected(f, stage)
- state.record_board_event({
- "kind": "move", "file": f, "from": prev_loc[f], "to": stage,
- "actor": actor,
- "summary": f"{f} moved {prev_loc[f]} → {stage} ({actor})",
- })
- if stage == "review":
- # a card entering review with a work branch gets a PR
- github.open_pr_async(f)
- elif f not in prev_loc:
- state.record_board_event({
- "kind": "new", "file": f, "to": stage, "actor": "disk",
- "summary": f"{f} appeared in {stage}/",
- })
+ narrate(prev, cur)
prev = cur
state.broadcast({"type": "board"})
diff --git a/tests/test_boards_sync.py b/tests/test_boards_sync.py
new file mode 100644
index 0000000..1265b2f
--- /dev/null
+++ b/tests/test_boards_sync.py
@@ -0,0 +1,565 @@
+"""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()