Enforce single instance Local Agent supervisor worker and dashboard
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user