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

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

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

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

87 lines
3.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""Live kanban board for ../tasks/ — board, session timeline, heads-up display.
python3 .task-manager/manager/board.py # serve on :26071, open a browser
python3 .task-manager/manager/board.py --port 9000 --no-open
The manager sits cleanly on top of the tasks/ directory: it reads and moves
task files, but the tasks work as a plain folder kanban without it. See
../AGENTS.md for the workflow and the module map:
config.py paths, stages, launch configuration
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
.prompts/ agent prompt templates (read fresh on every launch)
Stdlib only, no install.
"""
from __future__ import annotations
import argparse
import errno
import threading
import webbrowser
from http.server import ThreadingHTTPServer
import config
import drive
import events
import github
import httpd
import state
import sync
import watch
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", type=int, default=config.PORT)
parser.add_argument("--no-open", action="store_true", help="don't open a browser")
args = parser.parse_args()
state.serve_port = args.port
url = f"http://127.0.0.1:{args.port}/"
try:
server = ThreadingHTTPServer(("127.0.0.1", args.port), httpd.Handler)
except OSError as exc:
if exc.errno != errno.EADDRINUSE:
raise
# The port is pinned, so this is nearly always the board already running.
print(f"Port {args.port} is already in use — assuming the board is up at {url}")
if not args.no_open:
webbrowser.open(url)
return
# parents=True: local/state/ is gitignored and ships empty, so a fresh
# checkout has neither it nor its children — boot must create the chain.
config.SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
config.AGENT_DIR.mkdir(parents=True, exist_ok=True)
events.load_disk_sessions()
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")
if not args.no_open:
webbrowser.open(url)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nstopped")
if __name__ == "__main__":
main()