Files
bench/manager/core/state.py
T
istosandClaude Opus 5 b690060be2 Finishing a phase finishes its cards, and clears up after them
Merge & clean up on a phase card now ends the phase: after the merge into
main has actually succeeded, every member the phase merged moves to done/
and its workspace is cleared the way completing an ordinary card clears
its own — worktree removed, local branch deleted, branch on the remote
deleted.

Only what the phase merged. A member is swept when its card settled into
review/ or done/ and its branch is contained in the phase branch (or
there was never a branch to bring) — the same pair the runner reads a
member as merged by. One that halted, was held or was walked back keeps
its card, its worktree and its branch: there is work in them.

Nothing uncommitted is thrown away: a member's worktree comes out without
--force, and a dirty one is reported in the ticker and kept with its
branch rather than forced.

One ending, told once. taskfiles.move_together moves the cards in a
single commit naming all of them (`board: 47, 52 → done with phase 53`,
so it publishes in team mode like any other board commit), and the moves
are marked quiet so the watcher does not also scroll five identical move
lines behind the one line the ending gets.

The other endings are untouched: "just move the card" and archiving the
phase card move no member, because neither puts anything in main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:08:00 +02:00

203 lines
7.7 KiB
Python

"""Shared in-memory state, persistence of event logs, and the SSE fan-out.
All cross-thread registries live here, guarded by LOCK where they are
mutated from several threads. Modules communicate through this state rather
than importing each other's internals.
"""
from __future__ import annotations
import json
import queue
import threading
import time
from pathlib import Path
import config
LOCK = threading.Lock()
CLIENTS: set[queue.Queue] = set() # one queue per open SSE connection
SESSIONS: dict[str, dict] = {} # session_id -> meta
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, bool]] = {} # (file, to) -> (actor, ts, quiet)
COMMIT_HOOKS: list = [] # run after a board-made task commit
COMPLETING: dict[str, dict] = {} # filename -> {started, step}: merge & clean up in flight
# The port actually being served; board.py sets it from --port at startup so
# launched agents know where to report events.
serve_port = config.PORT
# The last card archived through this board — the scope of the ⌘Z undo.
LAST_ARCHIVED: dict | None = None
# A session's identity sidecar, beside its <sid>.jsonl event log. Not a
# `.jsonl` itself, so no reader that globs the event logs picks it up.
IDENTITY_SUFFIX = ".who.json"
def broadcast(payload: dict) -> None:
msg = json.dumps(payload)
with LOCK:
clients = list(CLIENTS)
for q in clients:
try:
q.put_nowait(msg)
except queue.Full:
pass
def persist(name: str, record: dict) -> None:
try:
config.SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
with (config.SESSIONS_DIR / name).open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record) + "\n")
except OSError:
pass
def _session_file(name: str) -> Path | None:
if "/" in name or ".." in name:
return None
return config.SESSIONS_DIR / name
def persist_identity(sid: str, identity: dict) -> None:
"""Who a session belonged to, written beside its event log.
A whole small file of its own rather than a key on the events: the logs
are append-only JSONL whose first line every reader takes for an event,
and identity is a property of the session, not of anything that happened
inside it. The agent's *name* and *model* live only in board memory, so
this file is the only thing a restart can read them back from.
Rewritten whenever what we know changes — an agent id that arrives on a
later event, a name that was not registered yet when the first event
landed. The file is written whole, so the last write is simply the truth.
"""
path = _session_file(f"{sid}{IDENTITY_SUFFIX}")
if path is None:
return
try:
config.SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(identity), encoding="utf-8")
except OSError:
pass
def read_identity(sid: str) -> dict | None:
"""The persisted identity, or None when nothing was ever recorded — the
difference between "this session was the person" and "we do not know",
which is exactly what the label must not blur."""
path = _session_file(f"{sid}{IDENTITY_SUFFIX}")
if path is None or not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return None
return data if isinstance(data, dict) else None
def record_board_event(event: dict) -> None:
event["ts"] = time.time()
with LOCK:
BOARD_EVENTS.append(event)
del BOARD_EVENTS[:-config.BOARD_EVENTS_CAP]
# a card being completed says which step it is on, and the steps are
# already narrated here — so the registry reads them rather than
# asking every caller to report twice
claimed = COMPLETING.get(event.get("file"))
stepped = bool(claimed) and bool(event.get("summary"))
if stepped:
claimed["step"] = event["summary"]
persist("board.jsonl", event)
broadcast({"type": "board_event", "event": event})
if stepped:
publish_completing()
def completing_public() -> dict:
with LOCK:
return {filename: dict(record) for filename, record in COMPLETING.items()}
def publish_completing() -> None:
"""The whole registry, every time it changes. It is one entry at most in
practice, and a whole map costs nothing to send and cannot go stale in
the way a patch can."""
broadcast({"type": "completing", "completing": completing_public()})
def claim_completing(filename: str, step: str) -> bool:
"""Claim a card for the long, destructive run behind "merge & clean up".
The claim is the card's busy state — what it renders instead of looking
idle, and what refuses a second request rather than starting a second
merge. It lives here, in this board's memory, so it dies with the
process: a board that is killed mid-completion leaves no card stuck
busy, and every other replica sees the card unchanged until the move
arrives (state syncs; reactions don't).
False when the card is already claimed — the caller refuses and must
not release what it did not take.
"""
with LOCK:
if filename in COMPLETING:
return False
COMPLETING[filename] = {"started": time.time(), "step": step}
publish_completing()
return True
def release_completing(filename: str) -> None:
"""Give the card back — on success, on conflict, on crash alike. A card
stuck busy forever is worse than a card that looked idle."""
with LOCK:
released = COMPLETING.pop(filename, None) is not None
if released:
publish_completing()
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, quiet: bool = False) -> None:
"""Tell the watcher who is about to move a file so it can attribute it.
`quiet` adds the other half of that: the mover has already narrated
this one, so the watcher renders it and says nothing. A phase ending
moves every card it merged at once and reports it as the one thing it
is; five identical move lines scrolling behind that would be the same
fact told badly. It suppresses the ticker line only — the move is
still a move, and everything the board hangs off one still happens.
"""
with LOCK:
EXPECTED_MOVES[(filename, target)] = (actor, time.time(), quiet)
def claim_move(filename: str, target: str) -> tuple[str, bool]:
"""Who moved this, and whether they already said so."""
with LOCK:
expected = EXPECTED_MOVES.pop((filename, target), None)
# forget stale expectations while we're here
cutoff = time.time() - 30
for key in [k for k, (_, ts, _) in EXPECTED_MOVES.items() if ts < cutoff]:
EXPECTED_MOVES.pop(key, None)
return (expected[0], expected[2]) if expected else ("disk", False)
def claim_expected(filename: str, target: str) -> str:
return claim_move(filename, target)[0]