"""JSON state store under state/: one file for all projects, an exclusive tick lock, heartbeat and log. state/agentd-state.json: {"projects": {PROJECT: {"issues": {issue_uuid: rec}}}, "paused_groups": {group: {...}}} rec: seq, runs, thread_id, thread_on [terminal, executor], thread_rounds, tags [...], last_message, seen_comments [...], last_comment_at, retries, retry {at, reason}, rebuild, inflight {...}""" import contextlib import fcntl import json import os import time from pathlib import Path class Busy(Exception): pass class Store: def __init__(self, state_dir, echo=True): self.dir = Path(state_dir) self.echo = echo self.path = self.dir / "agentd-state.json" def load(self): if self.path.exists(): data = json.loads(self.path.read_text(encoding="utf-8")) else: data = {} data.setdefault("projects", {}) data.setdefault("paused_groups", {}) return data def _atomic_write(self, path, obj): """Write temp + fsync + rename, so a reader (or a crash) never sees half a file.""" self.dir.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".tmp") with open(tmp, "w", encoding="utf-8") as fh: fh.write(json.dumps(obj, ensure_ascii=False, indent=1)) fh.flush() os.fsync(fh.fileno()) os.replace(tmp, path) def save(self, data): self._atomic_write(self.path, data) @contextlib.contextmanager def lock(self): self.dir.mkdir(parents=True, exist_ok=True) with open(self.dir / "agentd.lock", "w") as fh: try: fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: raise Busy("another tick holds %s" % (self.dir / "agentd.lock")) from None yield def heartbeat(self, info): info = dict(info, ts=time.time(), at=time.strftime("%Y-%m-%dT%H:%M:%S")) self._atomic_write(self.dir / "heartbeat.json", info) def log(self, msg): self.dir.mkdir(parents=True, exist_ok=True) line = time.strftime("%Y-%m-%dT%H:%M:%S") + " " + msg if self.echo: print(line, flush=True) with open(self.dir / "agentd.log", "a", encoding="utf-8") as fh: fh.write(line + "\n")