diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 6ff504a..df3fa4e 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -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: diff --git a/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw b/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw index 2e27295..566b16a 100644 --- a/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw +++ b/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw @@ -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: diff --git a/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw b/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw index 6800539..3108331 100644 --- a/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw +++ b/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw @@ -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() diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index 5f96756..c5b1063 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.22.2" +__version__ = "1.22.3" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/desktop_runtime.py b/app/modules/documents/local_agent_runtime/erp_local_agent/desktop_runtime.py new file mode 100644 index 0000000..37f039d --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/desktop_runtime.py @@ -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) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/main.py b/app/modules/documents/local_agent_runtime/erp_local_agent/main.py index b8b81c1..b88f075 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/main.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/main.py @@ -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__": diff --git a/app/modules/documents/local_agent_runtime/install_task_scheduler.bat b/app/modules/documents/local_agent_runtime/install_task_scheduler.bat index 67e3aff..0bf2beb 100644 --- a/app/modules/documents/local_agent_runtime/install_task_scheduler.bat +++ b/app/modules/documents/local_agent_runtime/install_task_scheduler.bat @@ -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.