"""File-based lock for ALL processes writing to the main worktree. One lock, one mechanism (Ganymede: Option C). Used by: - Pipeline daemon stages (entity_batch, source archiver, substantive_fixer) via async wrapper - Telegram bot (sync context manager) Protects: /opt/teleo-eval/workspaces/main/ flock auto-releases on process exit (even crash/kill). No stale lock cleanup needed. """ import asyncio import fcntl import logging import time from contextlib import asynccontextmanager, contextmanager from pathlib import Path logger = logging.getLogger("worktree-lock") LOCKFILE = Path("/opt/teleo-eval/workspaces/.main-worktree.lock") @contextmanager def main_worktree_lock(timeout: float = 10.0): """Sync context manager — use in telegram bot and other external processes. Usage: with main_worktree_lock(): # write to inbox/queue/, git add/commit/push, etc. """ LOCKFILE.parent.mkdir(parents=True, exist_ok=True) fp = open(LOCKFILE, "w") start = time.monotonic() while True: try: fcntl.flock(fp, fcntl.LOCK_EX | fcntl.LOCK_NB) break except BlockingIOError: if time.monotonic() - start > timeout: fp.close() logger.warning("Main worktree lock timeout after %.0fs", timeout) raise TimeoutError(f"Could not acquire main worktree lock in {timeout}s") time.sleep(0.1) try: yield finally: fcntl.flock(fp, fcntl.LOCK_UN) fp.close() @asynccontextmanager async def async_main_worktree_lock(timeout: float = 10.0): """Async context manager — use in pipeline daemon stages. Acquires the same file lock via run_in_executor (Ganymede: <1ms overhead). Usage: async with async_main_worktree_lock(): await _git("fetch", "origin", "main", cwd=main_dir) await _git("reset", "--hard", "origin/main", cwd=main_dir) # ... write files, commit, push ... """ loop = asyncio.get_event_loop() LOCKFILE.parent.mkdir(parents=True, exist_ok=True) fp = open(LOCKFILE, "w") def _acquire(): start = time.monotonic() while True: try: fcntl.flock(fp, fcntl.LOCK_EX | fcntl.LOCK_NB) return except BlockingIOError: if time.monotonic() - start > timeout: fp.close() raise TimeoutError(f"Could not acquire main worktree lock in {timeout}s") time.sleep(0.1) await loop.run_in_executor(None, _acquire) try: yield finally: fcntl.flock(fp, fcntl.LOCK_UN) fp.close()