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
+12 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.22.2" ERP_LOCAL_AGENT_VERSION = "1.22.3"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -48,6 +48,17 @@ def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme:
if not path.is_file() or "__pycache__" in path.parts: if not path.is_file() or "__pycache__" in path.parts:
continue continue
_write_zip_bytes(dst, path.relative_to(RUNTIME_ROOT).as_posix(), path.read_bytes()) _write_zip_bytes(dst, path.relative_to(RUNTIME_ROOT).as_posix(), path.read_bytes())
# Self-healing desktop launchers. Older supervisors update only the
# erp_local_agent package, so place canonical copies inside the worker
# package as data. The updated worker promotes them to INSTALL_ROOT.
for helper_name in ("ERPAgentSupervisor.pyw", "ERPAgentDashboard.pyw"):
helper = RUNTIME_ROOT / helper_name
if helper.is_file():
_write_zip_bytes(
dst,
f"erp_local_agent/_desktop_runtime/{helper_name}",
helper.read_bytes(),
)
if include_env and env_text is not None: if include_env and env_text is not None:
_write_zip_bytes(dst, ".env", env_text.encode("utf-8")) _write_zip_bytes(dst, ".env", env_text.encode("utf-8"))
if include_admin_readme: if include_admin_readme:
@@ -1,6 +1,10 @@
from __future__ import annotations from __future__ import annotations
import ctypes
from ctypes import wintypes
import hashlib
import os import os
import atexit
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import sys import sys
@@ -16,6 +20,63 @@ DATA_DIR = INSTALL_ROOT / "data"
SUPERVISOR_LOCK = DATA_DIR / "supervisor.lock" SUPERVISOR_LOCK = DATA_DIR / "supervisor.lock"
SUPERVISOR_SCRIPT = INSTALL_ROOT / "ERPAgentSupervisor.pyw" SUPERVISOR_SCRIPT = INSTALL_ROOT / "ERPAgentSupervisor.pyw"
URL = "http://127.0.0.1:8788" URL = "http://127.0.0.1:8788"
ERROR_ALREADY_EXISTS = 183
def _dashboard_mutex_name() -> str:
normalized = str(INSTALL_ROOT.resolve()).casefold().encode("utf-8", errors="ignore")
suffix = hashlib.sha256(normalized).hexdigest()[:20]
return f"Local\\ARRR_ERP_Local_Agent_DASHBOARD_{suffix}"
def _acquire_dashboard_mutex():
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, _dashboard_mutex_name())
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_dashboard_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 _focus_existing_dashboard() -> None:
if os.name != "nt":
return
try:
user32 = ctypes.WinDLL("user32", use_last_error=True)
EnumWindowsProc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
@EnumWindowsProc
def callback(hwnd, _lparam):
length = user32.GetWindowTextLengthW(hwnd)
if length <= 0:
return True
buf = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buf, length + 1)
if "ERP Local Agent Dashboard" in buf.value:
user32.ShowWindow(hwnd, 9) # SW_RESTORE
user32.SetForegroundWindow(hwnd)
return False
return True
user32.EnumWindows(callback, 0)
except Exception:
pass
def _create_no_window() -> int: def _create_no_window() -> int:
@@ -115,6 +176,11 @@ def _wait_ready(seconds: int = 75) -> bool:
def main() -> None: def main() -> None:
dashboard_mutex = _acquire_dashboard_mutex()
if dashboard_mutex is None:
_focus_existing_dashboard()
return
atexit.register(_close_dashboard_mutex, dashboard_mutex)
try: try:
_ensure_supervisor() _ensure_supervisor()
except Exception as exc: except Exception as exc:
@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import ctypes
from ctypes import wintypes
import hashlib
import json import json
import os import os
from pathlib import Path from pathlib import Path
@@ -25,6 +28,60 @@ DASHBOARD_URL = "http://127.0.0.1:8788"
WORKER_MODULE = "erp_local_agent.main" WORKER_MODULE = "erp_local_agent.main"
RESTART_DELAY_SECONDS = 3 RESTART_DELAY_SECONDS = 3
HEALTH_TIMEOUT_SECONDS = 60 HEALTH_TIMEOUT_SECONDS = 60
ERROR_ALREADY_EXISTS = 183
def _supervisor_mutex_name() -> str:
normalized = str(INSTALL_ROOT.resolve()).casefold().encode("utf-8", errors="ignore")
suffix = hashlib.sha256(normalized).hexdigest()[:20]
return f"Local\\ARRR_ERP_Local_Agent_SUPERVISOR_{suffix}"
def _acquire_os_mutex():
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, _supervisor_mutex_name())
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_os_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 _kill_duplicate_supervisors() -> None:
if os.name != "nt":
return
root = str(INSTALL_ROOT).replace("'", "''")
script = (
"$root='" + root + "';$self=" + str(os.getpid()) + ";"
"Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | "
"Where-Object { $_.ProcessId -ne $self -and $_.CommandLine -and "
"$_.CommandLine -match 'ERPAgentSupervisor\\.pyw' -and "
"$_.CommandLine -like ('*'+$root+'*') } | "
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
)
subprocess.run(
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
cwd=str(INSTALL_ROOT),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=_create_no_window(),
check=False,
)
def _create_no_window() -> int: def _create_no_window() -> int:
@@ -135,8 +192,14 @@ class Supervisor:
def __init__(self): def __init__(self):
self.worker: subprocess.Popen | None = None self.worker: subprocess.Popen | None = None
self.running = True self.running = True
self._os_mutex = None
def acquire_lock(self) -> bool: def acquire_lock(self) -> bool:
# The named mutex is atomic at the Windows kernel level and prevents
# the lock-file race that allowed multiple supervisors before 1.22.3.
self._os_mutex = _acquire_os_mutex()
if self._os_mutex is None:
return False
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
if LOCK_FILE.exists(): if LOCK_FILE.exists():
try: try:
@@ -154,6 +217,8 @@ class Supervisor:
LOCK_FILE.unlink() LOCK_FILE.unlink()
except Exception: except Exception:
pass pass
_close_os_mutex(self._os_mutex)
self._os_mutex = None
def start_worker(self) -> None: def start_worker(self) -> None:
if self.worker is not None and self.worker.poll() is None: if self.worker is not None and self.worker.poll() is None:
@@ -368,6 +433,7 @@ class Supervisor:
return 0 return 0
try: try:
_kill_duplicate_supervisors()
_state("starting", "ERP Local Agent supervisor starting.") _state("starting", "ERP Local Agent supervisor starting.")
self.start_worker() self.start_worker()
@@ -1,2 +1,2 @@
__version__ = "1.22.2" __version__ = "1.22.3"
AGENT_NAME = "ERP Local Agent" 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 argparse
import asyncio import asyncio
import os
from dataclasses import replace from dataclasses import replace
from pathlib import Path from pathlib import Path
import threading import threading
@@ -12,6 +13,7 @@ from .client import ERPClient
from .config import load_config from .config import load_config
from .dashboard import AgentDashboard from .dashboard import AgentDashboard
from .db import LocalDB 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 .logger import setup_logger
from .sync import StorageAgent from .sync import StorageAgent
from .tunnel import StorageAgentTunnel from .tunnel import StorageAgentTunnel
@@ -29,9 +31,19 @@ def build_parser() -> argparse.ArgumentParser:
def main() -> int: def main() -> int:
args = build_parser().parse_args() args = build_parser().parse_args()
root = Path.cwd() 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) logger = setup_logger(root)
db = None db = None
try: 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) config = load_config(args.env)
db = LocalDB(root / "data" / "agent.db") db = LocalDB(root / "data" / "agent.db")
db.prune_history() db.prune_history()
@@ -84,6 +96,8 @@ def main() -> int:
except Exception: except Exception:
pass pass
return 1 return 1
finally:
close_named_mutex(worker_mutex)
if __name__ == "__main__": if __name__ == "__main__":
@@ -13,7 +13,7 @@ set "AGENT_DIR=%~dp0"
set "AGENT_PYTHONW=%~dp0.venv\Scripts\pythonw.exe" set "AGENT_PYTHONW=%~dp0.venv\Scripts\pythonw.exe"
set "SUPERVISOR=%~dp0ERPAgentSupervisor.pyw" set "SUPERVISOR=%~dp0ERPAgentSupervisor.pyw"
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; try { Stop-ScheduledTask -TaskName 'AuditFirmStorageAgent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'AuditFirmStorageAgent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; try { Stop-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'ERP Local Agent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and (($_.CommandLine -match 'ERPAgentSupervisor\.pyw') -or ($_.CommandLine -match 'erp_local_agent\.main')) -and $_.CommandLine -like ('*'+$env:AGENT_DIR.TrimEnd('\')+'*') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; $d=$env:AGENT_DIR.TrimEnd('\'); $p=$env:AGENT_PYTHONW; $s=$env:SUPERVISOR; $a=New-ScheduledTaskAction -Execute $p -Argument ('\"'+$s+'\" --background') -WorkingDirectory $d; $t=New-ScheduledTaskTrigger -AtStartup; $pr=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $settings=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName 'ERP Local Agent' -Action $a -Trigger $t -Principal $pr -Settings $settings -Force | Out-Null; Start-ScheduledTask -TaskName 'ERP Local Agent'" powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; try { Stop-ScheduledTask -TaskName 'AuditFirmStorageAgent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'AuditFirmStorageAgent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; try { Stop-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'ERP Local Agent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and (($_.CommandLine -match 'ERPAgentSupervisor\.pyw') -or ($_.CommandLine -match 'erp_local_agent\.main')) -and $_.CommandLine -like ('*'+$env:AGENT_DIR.TrimEnd('\')+'*') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; $d=$env:AGENT_DIR.TrimEnd('\'); $p=$env:AGENT_PYTHONW; $s=$env:SUPERVISOR; $a=New-ScheduledTaskAction -Execute $p -Argument ('\"'+$s+'\" --background') -WorkingDirectory $d; $t=New-ScheduledTaskTrigger -AtStartup; $pr=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $settings=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew; Register-ScheduledTask -TaskName 'ERP Local Agent' -Action $a -Trigger $t -Principal $pr -Settings $settings -Force | Out-Null; Start-ScheduledTask -TaskName 'ERP Local Agent'"
if errorlevel 1 goto :failed if errorlevel 1 goto :failed
echo ERP Local Agent Supervisor installed and started successfully. echo ERP Local Agent Supervisor installed and started successfully.