diff --git a/src/plugin_system/plugin_manager.py b/src/plugin_system/plugin_manager.py index dc767eae..f0518399 100644 --- a/src/plugin_system/plugin_manager.py +++ b/src/plugin_system/plugin_manager.py @@ -136,11 +136,14 @@ class PluginManager: except (OSError, PermissionError) as e: self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True) - # Update shared state under lock + # Replace shared state under lock so uninstalled plugins don't linger with self._discovery_lock: + self.plugin_manifests.clear() self.plugin_manifests.update(new_manifests) if not hasattr(self, 'plugin_directories'): self.plugin_directories = {} + else: + self.plugin_directories.clear() self.plugin_directories.update(new_directories) return plugin_ids diff --git a/src/plugin_system/state_reconciliation.py b/src/plugin_system/state_reconciliation.py index e549090a..ca381cfd 100644 --- a/src/plugin_system/state_reconciliation.py +++ b/src/plugin_system/state_reconciliation.py @@ -182,10 +182,8 @@ class StateReconciliation: continue if plugin_id in self._SYSTEM_CONFIG_KEYS: continue - if 'enabled' not in plugin_config: - continue state[plugin_id] = { - 'enabled': plugin_config.get('enabled', False), + 'enabled': plugin_config.get('enabled', True), 'version': plugin_config.get('version'), 'exists_in_config': True } diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index e6a65d80..fba7dc36 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -1785,11 +1785,13 @@ class PluginStoreManager: self.fetch_registry(force_refresh=True) plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True) # Try without 'ledmatrix-' prefix (monorepo migration) + resolved_id = plugin_id if not plugin_info_remote and plugin_id.startswith('ledmatrix-'): alt_id = plugin_id[len('ledmatrix-'):] plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True) if plugin_info_remote: - self.logger.info(f"Plugin {plugin_id} found in registry as {alt_id}") + resolved_id = alt_id + self.logger.info(f"Plugin {plugin_id} found in registry as {resolved_id}") remote_branch = None remote_sha = None @@ -1804,13 +1806,13 @@ class PluginStoreManager: local_remote = git_info.get('remote_url', '') if local_remote and registry_repo and self._normalize_repo_url(local_remote) != self._normalize_repo_url(registry_repo): self.logger.info( - f"Plugin {plugin_id} git remote ({local_remote}) differs from registry ({registry_repo}). " + f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). " f"Reinstalling from registry to migrate to new source." ) if not self._safe_remove_directory(plugin_path): - self.logger.error(f"Failed to remove old plugin directory for {plugin_id}") + self.logger.error(f"Failed to remove old plugin directory for {resolved_id}") return False - return self.install_plugin(plugin_id) + return self.install_plugin(resolved_id) # Check if already up to date if remote_sha and local_sha and remote_sha.startswith(local_sha): diff --git a/web_interface/app.py b/web_interface/app.py index 4782cbfe..4263dcc8 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -653,8 +653,10 @@ def _initialize_health_monitor(): _reconciliation_done = False _reconciliation_started = False +import threading as _threading +_reconciliation_lock = _threading.Lock() -def _run_startup_reconciliation(): +def _run_startup_reconciliation() -> None: """Run state reconciliation in background to auto-repair missing plugins.""" global _reconciliation_done, _reconciliation_started from src.logging_config import get_logger @@ -678,10 +680,12 @@ def _run_startup_reconciliation(): _reconciliation_done = True else: _logger.warning("[Reconciliation] Finished with unresolved issues, will retry") - _reconciliation_started = False + with _reconciliation_lock: + _reconciliation_started = False except Exception as e: _logger.error("[Reconciliation] Error: %s", e, exc_info=True) - _reconciliation_started = False + with _reconciliation_lock: + _reconciliation_started = False # Initialize health monitor and run reconciliation on first request @app.before_request @@ -690,10 +694,10 @@ def check_health_monitor(): global _reconciliation_started if not _health_monitor_initialized: _initialize_health_monitor() - if not _reconciliation_started: - _reconciliation_started = True - import threading - threading.Thread(target=_run_startup_reconciliation, daemon=True).start() + with _reconciliation_lock: + if not _reconciliation_started: + _reconciliation_started = True + _threading.Thread(target=_run_startup_reconciliation, daemon=True).start() if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 24effcd4..f4a588f7 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -402,7 +402,10 @@ content.setAttribute('data-loaded', 'true'); console.log('Loading plugins directly via fetch (HTMX fallback)...'); fetch('/v3/partials/plugins') - .then(r => r.text()) + .then(r => { + if (!r.ok) throw new Error(r.status + ' ' + r.statusText); + return r.text(); + }) .then(html => { content.innerHTML = html; // Trigger full initialization chain @@ -426,13 +429,9 @@ setTimeout(() => { if (typeof htmx === 'undefined') { console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins'); - const appElement = document.querySelector('[x-data="app()"]'); - if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) { - const activeTab = appElement._x_dataStack[0].activeTab; - if (activeTab === 'plugins') { - loadPluginsDirect(); - } - } + // Load plugins tab content directly regardless of active tab, + // so it's ready when the user navigates to it + loadPluginsDirect(); } }, 5000); @@ -976,7 +975,7 @@