diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 9214c96..259cc80 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.10" +ERP_LOCAL_AGENT_VERSION = "1.22.11" 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) diff --git a/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw b/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw index 566b16a..996f5d6 100644 --- a/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw +++ b/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw @@ -26,7 +26,7 @@ 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}" + return f"Global\\ARRR_ERP_Local_Agent_DASHBOARD_{suffix}" def _acquire_dashboard_mutex(): @@ -142,14 +142,35 @@ def _pythonw() -> Path: def _ensure_supervisor() -> None: if _supervisor_running(): return - subprocess.Popen( - [str(_pythonw()), str(SUPERVISOR_SCRIPT), "--background"], - cwd=str(INSTALL_ROOT), - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - creationflags=_create_no_window(), - close_fds=True, - ) + + # The Scheduled Task is the canonical owner/launcher. Starting it repeatedly + # is safe because the task is configured with MultipleInstances=IgnoreNew, + # and the supervisor itself also owns a machine-wide Global mutex. + started_by_task = False + if os.name == "nt": + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", + "Start-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction Stop"], + cwd=str(INSTALL_ROOT), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=_create_no_window(), + check=False, + ) + started_by_task = result.returncode == 0 + + if not started_by_task: + # Recovery fallback for a workstation where the scheduled task was not + # installed yet. The Global mutex still prevents a second supervisor. + subprocess.Popen( + [str(_pythonw()), str(SUPERVISOR_SCRIPT), "--background"], + cwd=str(INSTALL_ROOT), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=_create_no_window(), + close_fds=True, + ) + deadline = time.time() + 20 while time.time() < deadline: if _supervisor_running(): diff --git a/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw b/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw index 3108331..5160de4 100644 --- a/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw +++ b/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw @@ -21,6 +21,7 @@ LOG_DIR = INSTALL_ROOT / "logs" UPDATES_DIR = INSTALL_ROOT / "updates" LOCK_FILE = DATA_DIR / "supervisor.lock" STATE_FILE = DATA_DIR / "supervisor_state.json" +OWNER_FILE = DATA_DIR / "supervisor_owner.json" REQUEST_FILE = UPDATES_DIR / "supervisor_request.json" WORKER_LOG = LOG_DIR / "worker-supervisor.log" SUPERVISOR_LOG = LOG_DIR / "supervisor.log" @@ -29,12 +30,13 @@ WORKER_MODULE = "erp_local_agent.main" RESTART_DELAY_SECONDS = 3 HEALTH_TIMEOUT_SECONDS = 60 ERROR_ALREADY_EXISTS = 183 +SINGLETON_WATCHDOG_SECONDS = 10 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}" + return f"Global\\ARRR_ERP_Local_Agent_SUPERVISOR_{suffix}" def _acquire_os_mutex(): @@ -188,6 +190,42 @@ def _kill_unmanaged_workers() -> None: ) +def _enforce_singleton_processes(*, keep_worker_pid: int | None = None) -> None: + """Kill stale/duplicate supervisors and workers for this install only. + + The Global named mutex is the primary atomic guard. This watchdog is a + recovery layer for legacy processes started before the hardened runtime. + """ + if os.name != "nt": + return + root = str(INSTALL_ROOT.resolve()).replace("'", "''") + keep_worker = int(keep_worker_pid or 0) + script = rf""" +$root='{root}' +$selfPid={os.getpid()} +$keepWorker={keep_worker} +Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | ForEach-Object {{ + $cmd=[string]$_.CommandLine + if (-not $cmd -or $cmd -notlike ('*'+$root+'*')) {{ return }} + if (($cmd -match 'ERPAgentSupervisor\.pyw') -and ($_.ProcessId -ne $selfPid)) {{ + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + return + }} + if (($cmd -match 'erp_local_agent\.main') -and ($_.ProcessId -ne $keepWorker)) {{ + 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, + ) + + class Supervisor: def __init__(self): self.worker: subprocess.Popen | None = None @@ -209,6 +247,11 @@ class Supervisor: if existing and existing != os.getpid() and _pid_alive(existing): return False LOCK_FILE.write_text(str(os.getpid()), encoding="utf-8") + _write_json_atomic(OWNER_FILE, { + "supervisor_pid": os.getpid(), + "install_root": str(INSTALL_ROOT), + "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + }) return True def release_lock(self) -> None: @@ -217,6 +260,13 @@ class Supervisor: LOCK_FILE.unlink() except Exception: pass + try: + if OWNER_FILE.exists(): + owner = json.loads(OWNER_FILE.read_text(encoding="utf-8")) + if int(owner.get("supervisor_pid") or 0) == os.getpid(): + OWNER_FILE.unlink() + except Exception: + pass _close_os_mutex(self._os_mutex) self._os_mutex = None @@ -436,8 +486,16 @@ class Supervisor: _kill_duplicate_supervisors() _state("starting", "ERP Local Agent supervisor starting.") self.start_worker() + last_singleton_watchdog = 0.0 while self.running: + now = time.time() + if now - last_singleton_watchdog >= SINGLETON_WATCHDOG_SECONDS: + _enforce_singleton_processes( + keep_worker_pid=(self.worker.pid if self.worker is not None and self.worker.poll() is None else None) + ) + last_singleton_watchdog = now + request = self._read_request() if request and str(request.get("action") or "") == "install_update": self._clear_request() @@ -464,7 +522,17 @@ class Supervisor: def main() -> int: parser = argparse.ArgumentParser(description="ERP Local Agent desktop/background supervisor") parser.add_argument("--background", action="store_true") - parser.parse_args() + parser.add_argument("--replace", action="store_true", help="Explicit controlled replacement of an older supervisor instance") + args = parser.parse_args() + + if args.replace and os.name == "nt": + # Explicit restart/update only. Normal duplicate launches never disturb + # the healthy owner; they simply fail the Global mutex and exit. + _kill_duplicate_supervisors() + deadline = time.time() + 15 + while time.time() < deadline: + time.sleep(0.25) + break supervisor = Supervisor() 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 1e69a72..8e5115c 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.10" +__version__ = "1.22.11" 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 index 37f039d..3fd0eeb 100644 --- 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 @@ -14,7 +14,7 @@ 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}" + return f"Global\\ARRR_ERP_Local_Agent_{scope}_{suffix}" def acquire_named_mutex(scope: str, root: Path):