Enforce single instance Local Agent supervisor worker and dashboard

This commit is contained in:
A R R R Associates
2026-09-03 14:03:26 +05:30
parent 010d0969b4
commit 5dce7f16f9
7 changed files with 267 additions and 3 deletions
@@ -1,2 +1,2 @@
__version__ = "1.22.2"
__version__ = "1.22.3"
AGENT_NAME = "ERP Local Agent"
@@ -0,0 +1,107 @@
from __future__ import annotations
import ctypes
from ctypes import wintypes
import hashlib
import os
from pathlib import Path
import subprocess
ERROR_ALREADY_EXISTS = 183
def _mutex_name(scope: str, root: Path) -> str:
normalized = str(root.resolve()).casefold().encode("utf-8", errors="ignore")
suffix = hashlib.sha256(normalized).hexdigest()[:20]
return f"Local\\ARRR_ERP_Local_Agent_{scope}_{suffix}"
def acquire_named_mutex(scope: str, root: Path):
"""Return an owned Windows mutex handle, or None when another instance owns it."""
if os.name != "nt":
return object()
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateMutexW.argtypes = (ctypes.c_void_p, wintypes.BOOL, wintypes.LPCWSTR)
kernel32.CreateMutexW.restype = wintypes.HANDLE
ctypes.set_last_error(0)
handle = kernel32.CreateMutexW(None, False, _mutex_name(scope, root))
if not handle:
raise ctypes.WinError(ctypes.get_last_error())
if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
kernel32.CloseHandle(handle)
return None
return handle
def close_named_mutex(handle) -> None:
if os.name != "nt" or handle is None or not isinstance(handle, int):
return
try:
ctypes.WinDLL("kernel32", use_last_error=True).CloseHandle(handle)
except Exception:
pass
def sync_desktop_helpers(root: Path, logger=None) -> bool:
"""Promote canonical desktop launchers shipped inside the worker package."""
source_root = Path(__file__).resolve().parent / "_desktop_runtime"
changed = False
if not source_root.is_dir():
return False
for name in ("ERPAgentSupervisor.pyw", "ERPAgentDashboard.pyw"):
source = source_root / name
target = root / name
if not source.is_file():
continue
source_bytes = source.read_bytes()
try:
if target.is_file() and target.read_bytes() == source_bytes:
continue
except Exception:
pass
tmp = target.with_suffix(target.suffix + ".new")
tmp.write_bytes(source_bytes)
os.replace(tmp, target)
changed = True
if logger:
logger.info("Refreshed Local Agent desktop helper: %s", name)
return changed
def _run_powershell(script: str) -> None:
if os.name != "nt":
return
subprocess.run(
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
check=False,
timeout=30,
)
def cleanup_legacy_duplicates(root: Path, *, keep_supervisor_pid: int, close_dashboards: bool) -> None:
"""Remove duplicates left behind by pre-1.22.3 launchers on this installation only."""
if os.name != "nt":
return
root_text = str(root.resolve()).replace("'", "''")
dashboard_clause = ""
if close_dashboards:
dashboard_clause = " -or ($cmd -match 'ERPAgentDashboard\\.pyw')"
script = rf"""
$root='{root_text}'
$keepSupervisor={int(keep_supervisor_pid or 0)}
$selfPid={os.getpid()}
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | ForEach-Object {{
$cmd = [string]$_.CommandLine
if (-not $cmd -or $cmd -notlike ('*'+$root+'*')) {{ return }}
$kill = $false
if (($cmd -match 'ERPAgentSupervisor\.pyw') -and ($_.ProcessId -ne $keepSupervisor)) {{ $kill = $true }}
if (($cmd -match 'erp_local_agent\.main') -and ($_.ProcessId -ne $selfPid)) {{ $kill = $true }}
if (($cmd -match 'ERPAgentDashboard\.pyw') -and {str(bool(close_dashboards)).lower()}) {{ $kill = $true }}
if ($kill) {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}
}}
"""
_run_powershell(script)
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import asyncio
import os
from dataclasses import replace
from pathlib import Path
import threading
@@ -12,6 +13,7 @@ from .client import ERPClient
from .config import load_config
from .dashboard import AgentDashboard
from .db import LocalDB
from .desktop_runtime import acquire_named_mutex, cleanup_legacy_duplicates, close_named_mutex, sync_desktop_helpers
from .logger import setup_logger
from .sync import StorageAgent
from .tunnel import StorageAgentTunnel
@@ -29,9 +31,19 @@ def build_parser() -> argparse.ArgumentParser:
def main() -> int:
args = build_parser().parse_args()
root = Path.cwd()
worker_mutex = acquire_named_mutex("WORKER", root)
if worker_mutex is None:
# A supervisor already owns the one permitted worker for this install.
return 0
logger = setup_logger(root)
db = None
try:
helpers_changed = sync_desktop_helpers(root, logger=logger)
cleanup_legacy_duplicates(
root,
keep_supervisor_pid=os.getppid(),
close_dashboards=helpers_changed,
)
config = load_config(args.env)
db = LocalDB(root / "data" / "agent.db")
db.prune_history()
@@ -84,6 +96,8 @@ def main() -> int:
except Exception:
pass
return 1
finally:
close_named_mutex(worker_mutex)
if __name__ == "__main__":