2026-07-29 17:57:07 +02:00
|
|
|
"""Disk watcher: the directories are the source of truth, so poll and narrate.
|
|
|
|
|
|
2026-07-30 09:47:38 +02:00
|
|
|
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.
|
2026-07-30 11:18:17 +02:00
|
|
|
|
|
|
|
|
Attribution is also the trigger gate. **State syncs; reactions don't**: a
|
|
|
|
|
move a pull applied is somebody else's action reaching this replica, so it
|
|
|
|
|
renders and narrates and nothing else — the side effects (opening a PR)
|
|
|
|
|
belong to the board whose user made the move. Every future automation hung
|
|
|
|
|
off a stage transition asks the same question here.
|
2026-07-29 17:57:07 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import time
|
|
|
|
|
|
2026-07-30 13:10:26 +02:00
|
|
|
import agents
|
2026-07-29 17:57:07 +02:00
|
|
|
import config
|
|
|
|
|
import github
|
|
|
|
|
import state
|
2026-07-30 09:47:38 +02:00
|
|
|
import sync
|
2026-07-29 17:57:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _board_sig() -> dict[str, set[str]]:
|
|
|
|
|
sig = {}
|
|
|
|
|
for slug in config.STAGE_DIRS:
|
|
|
|
|
directory = config.TASKS / slug
|
|
|
|
|
sig[slug] = {p.name for p in directory.glob("*.md")} if directory.is_dir() else set()
|
|
|
|
|
return sig
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 10:08:00 +02:00
|
|
|
def _actor(filename: str, stage: str) -> tuple[str, bool, bool]:
|
|
|
|
|
"""Who did this, whether it happened somewhere else, and whether it has
|
|
|
|
|
already been narrated.
|
2026-07-30 11:18:17 +02:00
|
|
|
|
|
|
|
|
A move this board made is claimed from the expectations; one a pull
|
|
|
|
|
brought carries its commit author's name and is *remote* — this board
|
|
|
|
|
is only rendering it; a plain mv on this disk is nobody in particular,
|
|
|
|
|
but it is still this board's own disk, so it acts.
|
2026-08-02 10:08:00 +02:00
|
|
|
|
|
|
|
|
*Quiet* is the mover saying it has told this story already: a phase
|
|
|
|
|
ending sweeps its whole list into done/ and reports one ending. The
|
|
|
|
|
line is skipped; nothing else about the move is.
|
2026-07-30 11:18:17 +02:00
|
|
|
"""
|
2026-08-02 10:08:00 +02:00
|
|
|
actor, quiet = state.claim_move(filename, stage)
|
2026-07-30 09:47:38 +02:00
|
|
|
if actor == "disk":
|
2026-07-30 11:18:17 +02:00
|
|
|
who = sync.arrived_actor(filename)
|
|
|
|
|
if who:
|
2026-08-02 10:08:00 +02:00
|
|
|
return who, True, False
|
|
|
|
|
return actor, False, quiet
|
2026-07-30 09:47:38 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-08-02 10:08:00 +02:00
|
|
|
actor, remote, quiet = _actor(f, stage)
|
|
|
|
|
if not quiet:
|
|
|
|
|
state.record_board_event({
|
|
|
|
|
"kind": "move", "file": f, "from": prev_loc[f], "to": stage,
|
|
|
|
|
"actor": actor, "remote": remote,
|
|
|
|
|
"summary": f"{f} moved {prev_loc[f]} → {stage} ({actor})",
|
|
|
|
|
})
|
2026-07-30 13:10:26 +02:00
|
|
|
# a failed run is worn by the card in the stage it died in —
|
|
|
|
|
# wherever the card goes next, it arrives without the alarm
|
|
|
|
|
agents.forget_failure(f)
|
2026-07-30 11:18:17 +02:00
|
|
|
if stage == "review" and not remote:
|
|
|
|
|
# a card entering review with a work branch gets a PR — on
|
|
|
|
|
# the actor's board only, or the team gets one PR attempt
|
|
|
|
|
# per replica
|
2026-07-30 09:47:38 +02:00
|
|
|
github.open_pr_async(f)
|
|
|
|
|
elif f not in prev_loc:
|
2026-08-02 10:08:00 +02:00
|
|
|
actor, remote, quiet = _actor(f, stage)
|
|
|
|
|
if quiet:
|
|
|
|
|
continue
|
2026-07-30 09:47:38 +02:00
|
|
|
state.record_board_event({
|
|
|
|
|
"kind": "new", "file": f, "to": stage, "actor": actor,
|
2026-07-30 11:18:17 +02:00
|
|
|
"remote": remote,
|
2026-07-30 09:47:38 +02:00
|
|
|
"summary": f"{f} appeared in {stage}/"
|
|
|
|
|
+ (f" ({actor})" if actor != "disk" else ""),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 17:57:07 +02:00
|
|
|
def watcher(interval: float | None = None) -> None:
|
|
|
|
|
interval = config.WATCH_INTERVAL if interval is None else interval
|
|
|
|
|
prev = _board_sig()
|
|
|
|
|
while True:
|
|
|
|
|
time.sleep(interval)
|
|
|
|
|
try:
|
|
|
|
|
cur = _board_sig()
|
|
|
|
|
except OSError:
|
|
|
|
|
continue
|
|
|
|
|
if cur == prev:
|
|
|
|
|
continue
|
2026-07-30 09:47:38 +02:00
|
|
|
narrate(prev, cur)
|
2026-07-29 17:57:07 +02:00
|
|
|
prev = cur
|
|
|
|
|
state.broadcast({"type": "board"})
|