"""Per-installation exclusive-execution lock for the Host Agent process. Backed by ``filelock.FileLock`` (OS-level advisory file locking) so that the lock is released automatically when the holding process exits for any reason — clean shutdown, unhandled exception, or ``SIGKILL`` — without requiring any stale-lock cleanup step. See ``openspec/changes/host-agent-single-instance-lock/design.md`` for the rationale behind choosing ``filelock`` over hand-rolled ``fcntl``/``msvcrt`` branching. """ from __future__ import annotations from pathlib import Path from filelock import FileLock, Timeout _LOCK_FILENAME = "host_agent.lock" class InstanceAlreadyRunningError(RuntimeError): """Raised when another live process already holds the instance lock.""" def __init__(self, lock_path: Path) -> None: self.lock_path = lock_path super().__init__( "another Host Agent instance is already running for this " f"identity state directory (lock held at {lock_path}); " "stop the other process before starting a new one" ) class InstanceLock: """Exclusive, non-blocking lock scoped to a Host Agent state directory. The state directory (typically ``identity_path.parent``) is created on construction to match the colocated-file convention already used for ``host_console_history.sqlite3`` and ``host_governance_policy.json``. """ def __init__(self, state_dir: Path) -> None: state_dir.mkdir(parents=True, exist_ok=True) self._lock_path = state_dir / _LOCK_FILENAME self._lock = FileLock(str(self._lock_path), timeout=0) self._acquired = False @property def lock_path(self) -> Path: return self._lock_path def acquire(self) -> None: try: self._lock.acquire() except Timeout as exc: raise InstanceAlreadyRunningError(self._lock_path) from exc self._acquired = True def release(self) -> None: if not self._acquired: return try: self._lock.release() finally: self._acquired = False def __enter__(self) -> InstanceLock: self.acquire() return self def __exit__(self, *exc_info: object) -> None: self.release()