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()