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
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.22.2"
ERP_LOCAL_AGENT_VERSION = "1.22.3"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_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:
continue
_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:
_write_zip_bytes(dst, ".env", env_text.encode("utf-8"))
if include_admin_readme:
@@ -1,6 +1,10 @@
from __future__ import annotations
import ctypes
from ctypes import wintypes
import hashlib
import os
import atexit
from pathlib import Path
import subprocess
import sys
@@ -16,6 +20,63 @@ DATA_DIR = INSTALL_ROOT / "data"
SUPERVISOR_LOCK = DATA_DIR / "supervisor.lock"
SUPERVISOR_SCRIPT = INSTALL_ROOT / "ERPAgentSupervisor.pyw"
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:
@@ -115,6 +176,11 @@ def _wait_ready(seconds: int = 75) -> bool:
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:
_ensure_supervisor()
except Exception as exc:
@@ -1,6 +1,9 @@
from __future__ import annotations
import argparse
import ctypes
from ctypes import wintypes
import hashlib
import json
import os
from pathlib import Path
@@ -25,6 +28,60 @@ DASHBOARD_URL = "http://127.0.0.1:8788"
WORKER_MODULE = "erp_local_agent.main"
RESTART_DELAY_SECONDS = 3
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:
@@ -135,8 +192,14 @@ class Supervisor:
def __init__(self):
self.worker: subprocess.Popen | None = None
self.running = True
self._os_mutex = None
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)
if LOCK_FILE.exists():
try:
@@ -154,6 +217,8 @@ class Supervisor:
LOCK_FILE.unlink()
except Exception:
pass
_close_os_mutex(self._os_mutex)
self._os_mutex = None
def start_worker(self) -> None:
if self.worker is not None and self.worker.poll() is None:
@@ -368,6 +433,7 @@ class Supervisor:
return 0
try:
_kill_duplicate_supervisors()
_state("starting", "ERP Local Agent supervisor starting.")
self.start_worker()
@@ -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__":
@@ -13,7 +13,7 @@ set "AGENT_DIR=%~dp0"
set "AGENT_PYTHONW=%~dp0.venv\Scripts\pythonw.exe"
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
echo ERP Local Agent Supervisor installed and started successfully.