Files
q792602257andClaude Opus 4.6 d00ada67a5 feat(host-agent): add single-instance lock to prevent duplicate-process dispatch races
Acquire an exclusive, non-blocking filelock on the identity state directory
as the first action of create_application(), before resolve_host_identity()
or any enrollment/heartbeat side effect. A second process against the same
identity_path exits immediately with InstanceAlreadyRunningError naming the
lock path; the lock releases automatically on any process exit (including
SIGKILL) via OS-level advisory locking, and explicitly during run_async()'s
shutdown finally block. filelock is promoted from transitive to direct
dependency (version unchanged at 3.29.7).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 15:49:30 +08:00

72 lines
2.2 KiB
Python

"""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()