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.
87 lines
3.1 KiB
Python
Executable File
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()
|