Files
bench/manager/core/state.py
T
istosandClaude Fable 5 cc931a9174 Start cleanly when bench is the project itself
Three layout defects broke ./start.sh in the self-hosted repo, one of
which (missing local/state/) broke every fresh vendored install too:

- install.py resolved the project as TM.parent, a hardcoded vendored-
  layout assumption. It now asks git for the toplevel from the manager's
  directory (same resolution as config._repo_root), falling back to the
  parent when git is unavailable — vendored installs still find the host
  repo, self-hosted bench finds itself instead of its parent.

- adapters/claude/wire hardcoded ".task-manager/" into the emit hook
  command and plansDirectory. Both are now derived from the manager's
  path relative to the project root, so vendored installs keep the
  .task-manager/ prefix and self-hosted bench gets prefix-free paths.
  _is_ours also recognises the emit.py suffix, so settings wired with
  the old literal path count as stale and are repaired idempotently.

- board.py and state.py created the sessions/agent dirs without
  parents=True; local/state/ is gitignored and ships empty, so a virgin
  checkout died with FileNotFoundError before serving. Boot now creates
  the whole chain, wiring or no wiring.

tests/test_self_hosting.py (stdlib unittest) covers both layouts' wiring,
stale-path repair, idempotent re-runs, refusal without .claude/, root
resolution with and without git, and an integration boot of board.py
from a scratch checkout with no local/state/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:55:57 +02:00

76 lines
2.4 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
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]] = {} # (file, to) -> (actor, ts)
# 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
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 record_board_event(event: dict) -> None:
event["ts"] = time.time()
with LOCK:
BOARD_EVENTS.append(event)
del BOARD_EVENTS[:-config.BOARD_EVENTS_CAP]
persist("board.jsonl", event)
broadcast({"type": "board_event", "event": event})
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:
EXPECTED_MOVES[(filename, target)] = (actor, time.time())
def claim_expected(filename: str, target: str) -> str:
with LOCK:
actor_ts = 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 actor_ts[0] if actor_ts else "disk"