From 961bf4623f46a378ec30f599ec01863f9933ea2d Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 1 Aug 2026 09:48:42 +0200 Subject: [PATCH 1/3] fix(plugins): diagnostics crash minimales pour l'install de plugin (backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Écran noir hello-plugin (#120) récidivant après 3 fixes déjà mergés sans capturer la cause réelle : on ne pouvait pas savoir si le crash venait de l'install, du reconcile MCP ou d'un panic silencieux. Ajoute un panic hook qui logge thread/location/backtrace, trace les étapes install/reconcile/ asset-protocol dans idea.log, et remplace les `.expect()` du superviseur MCP plugin par une erreur typée au lieu d'un panic sur mutex empoisonné. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/lib.rs | 29 +++++++++++ crates/app-tauri/src/plugins.rs | 69 +++++++++++++++++++++---- crates/application/src/plugin/mod.rs | 43 ++++++++++++++- crates/infrastructure/src/plugin/mod.rs | 66 ++++++++++++----------- 4 files changed, 166 insertions(+), 41 deletions(-) diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 98df2bb..0787119 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -47,6 +47,7 @@ use uuid::Uuid; use state::AppState; static EXIT_GUARD_CONFIRMED: AtomicBool = AtomicBool::new(false); +static PANIC_HOOK_INSTALLED: AtomicBool = AtomicBool::new(false); #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MainCloseAction { @@ -69,6 +70,33 @@ fn should_install_exit_guard(window_label: &str) -> bool { window_label == "main" } +fn install_panic_diagnostics_hook() { + if PANIC_HOOK_INSTALLED.swap(true, Ordering::SeqCst) { + return; + } + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let message = info + .payload() + .downcast_ref::<&str>() + .copied() + .or_else(|| info.payload().downcast_ref::().map(String::as_str)) + .unwrap_or(""); + let location = info + .location() + .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) + .unwrap_or_else(|| "".to_owned()); + let thread = std::thread::current(); + let thread_name = thread.name().unwrap_or(""); + application::diag!("[panic] thread={thread_name} location={location} message={message}"); + application::diag!( + "[panic] backtrace:\n{}", + std::backtrace::Backtrace::force_capture() + ); + previous(info); + })); +} + fn apply_main_close_decision( guard: application::AppExitWorkGuardState, already_confirmed: bool, @@ -151,6 +179,7 @@ pub fn run() { // click-launched AppImage (whose stderr is otherwise discarded). Best-effort: // if the file can't be opened the beacons simply stay on stderr. application::diag::set_log_path(app_data_dir.join("logs").join("idea.log")); + install_panic_diagnostics_hook(); application::diag!("[startup] IdeA launched; diagnostics log armed"); let app_state = AppState::build_with_resource_dir(app_data_dir, resource_dir); diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs index c748655..0759d6e 100644 --- a/crates/app-tauri/src/plugins.rs +++ b/crates/app-tauri/src/plugins.rs @@ -57,12 +57,28 @@ pub async fn plugin_install_from_archive( path: String, state: State<'_, AppState>, ) -> Result { - state + application::diag!("[plugins] install archive start path={path}"); + let result = state .install_plugin_from_archive .execute(path) .await .map(PluginInstallResultDto::from) - .map_err(ErrorDto::from) + .map_err(ErrorDto::from); + match &result { + Ok(result) => application::diag!( + "[plugins] install archive ok plugin={} version={} hash={} lifecycle={:?}", + result.plugin.id, + result.plugin.version, + result.review.content_hash, + result.plugin.lifecycle_state + ), + Err(err) => application::diag!( + "[plugins] install archive failed code={} message={}", + err.code, + err.message + ), + } + result } /// Installs a plugin from a local directory snapshot. @@ -71,12 +87,28 @@ pub async fn plugin_install_from_directory( path: String, state: State<'_, AppState>, ) -> Result { - state + application::diag!("[plugins] install directory start path={path}"); + let result = state .install_plugin_from_directory .execute(path) .await .map(PluginInstallResultDto::from) - .map_err(ErrorDto::from) + .map_err(ErrorDto::from); + match &result { + Ok(result) => application::diag!( + "[plugins] install directory ok plugin={} version={} hash={} lifecycle={:?}", + result.plugin.id, + result.plugin.version, + result.review.content_hash, + result.plugin.lifecycle_state + ), + Err(err) => application::diag!( + "[plugins] install directory failed code={} message={}", + err.code, + err.message + ), + } + result } /// Enables or disables a plugin. @@ -145,13 +177,30 @@ pub fn plugin_asset_protocol( app: &AppHandle, request: http::Request>, ) -> Response> { + let uri = request.uri().to_string(); match plugin_asset_response(app, request) { - Ok(response) => response, - Err((status, message)) => Response::builder() - .status(status) - .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") - .body(message.into_bytes()) - .expect("valid protocol error response"), + Ok(response) => { + application::diag!( + "[plugins] asset ok uri={} status={} bytes={}", + uri, + response.status(), + response.body().len() + ); + response + } + Err((status, message)) => { + application::diag!( + "[plugins] asset failed uri={uri} status={status} message={message}" + ); + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(message.into_bytes()) + .unwrap_or_else(|e| { + application::diag!("[plugins] asset error response build failed: {e}"); + Response::new(Vec::new()) + }) + } } } diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index 0a5d314..220106c 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -502,10 +502,18 @@ async fn install_from_staged( ) -> Result { let review = review_staged(packages, validator, &staged).await?; let plugin_id = review.manifest.id.clone(); + crate::diag!( + "[plugins] install staged reviewed plugin={} version={} hash={} source={}", + plugin_id.as_str(), + review.manifest.version.as_str(), + review.content_hash, + review.source.kind() + ); packages .commit_install(staged, &plugin_id) .await .map_err(map_store)?; + crate::diag!("[plugins] install committed plugin={}", plugin_id.as_str()); let mut registry = registry_store.load_registry().await.map_err(map_registry)?; let mut entry = PluginRegistryEntry { id: plugin_id.clone(), @@ -530,6 +538,11 @@ async fn install_from_staged( .save_registry(®istry) .await .map_err(map_registry)?; + crate::diag!( + "[plugins] install registry saved plugin={} lifecycle={:?}", + plugin_id.as_str(), + entry.lifecycle_state + ); events.publish(DomainEvent::PluginInstalled { plugin_id: plugin_id.clone(), version: review.manifest.version.clone(), @@ -537,7 +550,21 @@ async fn install_from_staged( let (active_servers, invalid_plugins) = active_mcp_specs(packages, validator, ®istry).await?; persist_invalid_runtime_plugins(registry_store, &mut registry, invalid_plugins).await; - let _ = mcp.reconcile(active_servers).await; + let active_server_count = active_servers.len(); + match mcp.reconcile(active_servers).await { + Ok(statuses) => crate::diag!( + "[plugins] install MCP reconcile ok plugin={} requested={} statuses={}", + plugin_id.as_str(), + active_server_count, + statuses.servers.len() + ), + Err(err) => crate::diag!( + "[plugins] install MCP reconcile failed plugin={} requested={} error={}", + plugin_id.as_str(), + active_server_count, + err + ), + } let admin = admin_from_descriptor( PluginDescriptor { manifest: review.manifest.clone(), @@ -846,7 +873,19 @@ impl ReconcilePluginMcpServers { active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), ®istry).await?; persist_invalid_runtime_plugins(self.registry.as_ref(), &mut registry, invalid_plugins) .await; - self.mcp.reconcile(specs).await.map_err(map_mcp) + let requested = specs.len(); + let result = self.mcp.reconcile(specs).await.map_err(map_mcp); + match &result { + Ok(statuses) => crate::diag!( + "[plugins] MCP reconcile ok requested={} statuses={}", + requested, + statuses.servers.len() + ), + Err(err) => { + crate::diag!("[plugins] MCP reconcile failed requested={requested} error={err}") + } + } + result } } diff --git a/crates/infrastructure/src/plugin/mod.rs b/crates/infrastructure/src/plugin/mod.rs index 43732b0..6277db8 100644 --- a/crates/infrastructure/src/plugin/mod.rs +++ b/crates/infrastructure/src/plugin/mod.rs @@ -5,7 +5,7 @@ use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Stdio; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{SystemTime, UNIX_EPOCH}; use async_trait::async_trait; @@ -386,6 +386,8 @@ trait ExternalMcpServerBridge: Send + Sync { ) -> Result, PluginMcpError>; } +type ExternalMcpChildren = HashMap>; + struct ProcessMcpServerHandle { child: Child, } @@ -433,7 +435,7 @@ impl ExternalMcpServerBridge for StdioExternalMcpServerBridge { /// External process supervisor for plugin MCP servers. pub struct ExternalMcpPluginSupervisor { bridge: Arc, - children: Mutex>>, + children: Mutex, } impl ExternalMcpPluginSupervisor { @@ -453,6 +455,12 @@ impl ExternalMcpPluginSupervisor { children: Mutex::new(HashMap::new()), } } + + fn children(&self) -> Result, PluginMcpError> { + self.children + .lock() + .map_err(|_| PluginMcpError::Process("plugin mcp supervisor mutex poisoned".to_owned())) + } } impl Default for ExternalMcpPluginSupervisor { @@ -469,10 +477,7 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor { ) -> Result { let desired: HashSet = active_servers.iter().map(|s| s.identity.clone()).collect(); let to_stop = { - let children = self - .children - .lock() - .expect("plugin mcp supervisor poisoned"); + let children = self.children()?; children .keys() .filter(|id| !desired.contains(*id)) @@ -480,22 +485,14 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor { .collect::>() }; for id in to_stop { - let child = self - .children - .lock() - .expect("plugin mcp supervisor poisoned") - .remove(&id); + let child = self.children()?.remove(&id); if let Some(mut child) = child { let _ = child.stop().await; } } let mut statuses = Vec::new(); for spec in active_servers { - let already = self - .children - .lock() - .expect("plugin mcp supervisor poisoned") - .contains_key(&spec.identity); + let already = self.children()?.contains_key(&spec.identity); if already { statuses.push(PluginMcpStatus { identity: spec.identity, @@ -506,10 +503,12 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor { } match self.bridge.start(&spec).await { Ok(handle) => { - self.children - .lock() - .expect("plugin mcp supervisor poisoned") - .insert(spec.identity.clone(), handle); + match self.children() { + Ok(mut children) => { + children.insert(spec.identity.clone(), handle); + } + Err(err) => return Err(err), + } statuses.push(PluginMcpStatus { identity: spec.identity, running: true, @@ -529,10 +528,7 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor { async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError> { let prefix = format!("plugin:{}:", plugin_id.as_str()); let ids = { - let children = self - .children - .lock() - .expect("plugin mcp supervisor poisoned"); + let children = self.children()?; children .keys() .filter(|id| id.starts_with(&prefix)) @@ -540,11 +536,7 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor { .collect::>() }; for id in ids { - let child = self - .children - .lock() - .expect("plugin mcp supervisor poisoned") - .remove(&id); + let child = self.children()?.remove(&id); if let Some(mut child) = child { child.stop().await?; } @@ -751,4 +743,20 @@ mod tests { assert_eq!(statuses.servers[0].identity, other.identity); assert_eq!(bridge.started.lock().unwrap().len(), 2); } + + #[tokio::test] + async fn supervisor_poisoned_registry_returns_typed_error() { + let supervisor = ExternalMcpPluginSupervisor::new(); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = supervisor.children.lock().unwrap(); + panic!("poison plugin mcp supervisor registry"); + })); + + let err = supervisor.reconcile(Vec::new()).await.unwrap_err(); + + assert_eq!( + err, + PluginMcpError::Process("plugin mcp supervisor mutex poisoned".to_owned()) + ); + } } From c19fb6bf8ccb91a66bd0784297583162f9058167 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 1 Aug 2026 09:48:49 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(frontend):=20=C3=A9cran=20noir=20plut?= =?UTF-8?q?=C3=B4t=20que=20muet=20sur=20crash=20de=20plugin=20ou=20d'activ?= =?UTF-8?q?ation=20fig=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complète le diagnostic backend (#120) côté frontend : un crash React non capturé (ex. plugin cassant le rendu) laissait un écran noir sans trace, et une activation de plugin qui ne se résout jamais (promesse infinie) bloquait le chargement sans échouer. Ajoute RootErrorBoundary + logging d'erreurs globales autour de l'arbre React, et un timeout sur l'import/l'activation de chaque plugin dans loadPlugins pour transformer un hang silencieux en échec explicite et diagnosticable. Co-Authored-By: Claude Opus 4.8 --- frontend/src/app/RootErrorBoundary.test.tsx | 76 +++++++++++++++ frontend/src/app/RootErrorBoundary.tsx | 102 ++++++++++++++++++++ frontend/src/app/main.tsx | 23 +++-- frontend/src/plugins/runtime/loader.test.ts | 22 +++++ frontend/src/plugins/runtime/loader.ts | 49 +++++++++- 5 files changed, 260 insertions(+), 12 deletions(-) create mode 100644 frontend/src/app/RootErrorBoundary.test.tsx create mode 100644 frontend/src/app/RootErrorBoundary.tsx diff --git a/frontend/src/app/RootErrorBoundary.test.tsx b/frontend/src/app/RootErrorBoundary.test.tsx new file mode 100644 index 0000000..55a6de4 --- /dev/null +++ b/frontend/src/app/RootErrorBoundary.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary"; + +function ThrowingView(): JSX.Element { + throw new Error("boom from render"); +} + +describe("RootErrorBoundary", () => { + it("renders a visible fallback when the app render tree throws", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + render( + + + , + ); + + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getByText("IdeA a rencontré une erreur d'affichage.")).toBeTruthy(); + expect(screen.getByText(/boom from render/)).toBeTruthy(); + } finally { + consoleError.mockRestore(); + } + }); + + it("offers a reload action from the fallback", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const reload = vi.fn(); + const originalLocation = window.location; + try { + Object.defineProperty(window, "location", { + configurable: true, + value: { ...originalLocation, reload }, + }); + + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Recharger" })); + expect(reload).toHaveBeenCalledOnce(); + } finally { + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); + consoleError.mockRestore(); + } + }); +}); + +describe("installGlobalErrorLogging", () => { + it("installs global error logging only once", () => { + const addEventListener = vi.spyOn(window, "addEventListener"); + const previousFlag = window.__IDEA_GLOBAL_ERROR_LOGGING__; + try { + window.__IDEA_GLOBAL_ERROR_LOGGING__ = undefined; + installGlobalErrorLogging(); + installGlobalErrorLogging(); + + expect( + addEventListener.mock.calls.filter(([event]) => event === "error"), + ).toHaveLength(1); + expect( + addEventListener.mock.calls.filter(([event]) => event === "unhandledrejection"), + ).toHaveLength(1); + } finally { + window.__IDEA_GLOBAL_ERROR_LOGGING__ = previousFlag; + addEventListener.mockRestore(); + } + }); +}); diff --git a/frontend/src/app/RootErrorBoundary.tsx b/frontend/src/app/RootErrorBoundary.tsx new file mode 100644 index 0000000..802405b --- /dev/null +++ b/frontend/src/app/RootErrorBoundary.tsx @@ -0,0 +1,102 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +const UNKNOWN_ERROR = "Erreur frontend inconnue"; + +export function installGlobalErrorLogging(): void { + const globalWindow = globalThis.window; + if (!globalWindow || globalWindow.__IDEA_GLOBAL_ERROR_LOGGING__) return; + globalWindow.__IDEA_GLOBAL_ERROR_LOGGING__ = true; + + globalWindow.addEventListener("error", (event) => { + console.error("[idea-ui] uncaught error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + error: event.error, + }); + }); + + globalWindow.addEventListener("unhandledrejection", (event) => { + console.error("[idea-ui] unhandled promise rejection", event.reason); + }); +} + +interface RootErrorBoundaryProps { + children: ReactNode; +} + +interface RootErrorBoundaryState { + error: Error | null; + componentStack: string | null; +} + +export class RootErrorBoundary extends Component< + RootErrorBoundaryProps, + RootErrorBoundaryState +> { + state: RootErrorBoundaryState = { + error: null, + componentStack: null, + }; + + static getDerivedStateFromError(error: Error): RootErrorBoundaryState { + return { error, componentStack: null }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error("[idea-ui] root render failed", error, errorInfo); + this.setState({ componentStack: errorInfo.componentStack ?? null }); + } + + render(): ReactNode { + const { error, componentStack } = this.state; + if (!error) return this.props.children; + + const message = error.message || UNKNOWN_ERROR; + const details = [error.stack, componentStack].filter(Boolean).join("\n\n"); + + return ( +
+
+
+

+ Interface interrompue +

+

+ IdeA a rencontré une erreur d'affichage. +

+

+ L'application reste ouverte. Rechargez la fenêtre après avoir copié + le diagnostic si le problème doit être investigué. +

+
+ +
+            {details || message}
+          
+ +
+ +
+
+
+ ); + } +} + +declare global { + interface Window { + __IDEA_GLOBAL_ERROR_LOGGING__?: boolean; + } +} diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index d2985d8..ef62dd4 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -7,6 +7,7 @@ import { App } from "./App"; import { ViewWindow, parseViewWindowParams } from "./ViewWindow"; import { WebApp } from "@/features/web"; import { DIProvider, resolveTransport } from "./di"; +import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary"; import "@/shared/styles/theme.css"; const root = document.getElementById("root"); @@ -32,16 +33,20 @@ const viewParams = parseViewWindowParams(window.location.search); // the full `App` (or a detached view window). Detached windows are desktop-only. const isWeb = resolveTransport() === "http"; +installGlobalErrorLogging(); + ReactDOM.createRoot(root).render( - - {isWeb ? ( - - ) : viewParams ? ( - - ) : ( - - )} - + + + {isWeb ? ( + + ) : viewParams ? ( + + ) : ( + + )} + + , ); diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index 79329d6..5485782 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -305,6 +305,28 @@ describe("loadPlugins", () => { ); }); + it("collects a failure when plugin activation does not settle", async () => { + const bundle = dataUrl(` + export function activate() { + return new Promise(() => {}); + } + `); + + const { registry, failures } = await loadPlugins( + [entry({ id: "dev.acme.hung", displayName: "Hung", bundleUrl: bundle })], + gateways, + { timeoutMs: 10 }, + ); + + expect(registry.list()).toEqual([]); + expect(failures).toEqual([ + { + pluginId: "dev.acme.hung", + reason: "activating plugin timed out after 10 ms", + }, + ]); + }); + it("only loads what the catalog contains — a disabled/absent plugin is simply never in it", async () => { // The backend contract (carnet §1.3) filters the catalog to // `enabled && !pendingUninstall` before the loader ever sees it; the diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 77d4803..788aa36 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -72,6 +72,32 @@ export interface PluginLoadResult { failures: PluginLoadFailure[]; } +export interface PluginLoadOptions { + timeoutMs?: number; +} + +const DEFAULT_PLUGIN_LOAD_TIMEOUT_MS = 10_000; + +async function withTimeout( + promise: Promise, + timeoutMs: number, + label: string, +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs} ms`)); + }, timeoutMs); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule { return ( typeof mod === "object" && @@ -170,6 +196,7 @@ async function disposeAll(disposables: Disposable[], activation?: void | PluginA async function loadOne( entry: PluginRuntimePlugin, gateways: PluginGatewaySet, + options: Required, ): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> { const entryObject = objectOrEmpty(entry); const pluginId = safePluginId(entry); @@ -186,7 +213,13 @@ async function loadOne( throw new Error("missing plugin bundle URL"); } - const mod = resolveIdeaPluginModule(await import(/* @vite-ignore */ bundleUrl)); + const mod = resolveIdeaPluginModule( + await withTimeout( + import(/* @vite-ignore */ bundleUrl), + options.timeoutMs, + "importing plugin bundle", + ), + ); if (!mod) { return { failure: { @@ -215,7 +248,11 @@ async function loadOne( ...gateways, }; - activation = await mod.activate(ctx); + activation = await withTimeout( + Promise.resolve(mod.activate(ctx)), + options.timeoutMs, + "activating plugin", + ); const plugin: LoadedPlugin = { pluginId, @@ -251,12 +288,18 @@ async function loadOne( export async function loadPlugins( catalogPlugins: PluginRuntimePlugin[], gateways: PluginGatewaySet, + options: PluginLoadOptions = {}, ): Promise { const registry = new PluginRuntimeRegistry(); const failures: PluginLoadFailure[] = []; + const resolvedOptions: Required = { + timeoutMs: options.timeoutMs ?? DEFAULT_PLUGIN_LOAD_TIMEOUT_MS, + }; const entries = Array.isArray(catalogPlugins) ? catalogPlugins : []; - const results = await Promise.all(entries.map((entry) => loadOne(entry, gateways))); + const results = await Promise.all( + entries.map((entry) => loadOne(entry, gateways, resolvedOptions)), + ); for (const result of results) { if ("failure" in result) failures.push(result.failure); else registry.add(result.plugin); From 3fc15fd706646cfd772e153fbbf9bb8a57cd5b72 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 1 Aug 2026 09:48:55 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(memoire):=20consigne=20la=20r=C3=A9ci?= =?UTF-8?q?dive=20=C3=A9cran=20noir=20hello-plugin=20(#120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois correctifs déjà mergés sur le même symptôme sans que #120 se ferme ; note de mémoire projet pour que le prochain agent ne reparte pas d'un patch symptomatique de plus sans avoir lu l'historique des tentatives. Co-Authored-By: Claude Opus 4.8 --- .ideai/memory/MEMORY.md | 1 + ...120-hello-plugin-blackscreen-recurrence.md | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 .ideai/memory/ticket120-hello-plugin-blackscreen-recurrence.md diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 94c8907..5a8ac04 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -76,3 +76,4 @@ - [ux-ai-profiles-opencode-persistence-list-coherence](ux-ai-profiles-opencode-persistence-list-coherence.md) — memory note ux-ai-profiles-opencode-persistence-list-coherence - [ticket113-controlled-args-field-rootcause](ticket113-controlled-args-field-rootcause.md) — memory note ticket113-controlled-args-field-rootcause - [ticket120-hello-plugin-recurrence-investigation-angle](ticket120-hello-plugin-recurrence-investigation-angle.md) — memory note ticket120-hello-plugin-recurrence-investigation-angle +- [ticket120-hello-plugin-blackscreen-recurrence](ticket120-hello-plugin-blackscreen-recurrence.md) — memory note ticket120-hello-plugin-blackscreen-recurrence diff --git a/.ideai/memory/ticket120-hello-plugin-blackscreen-recurrence.md b/.ideai/memory/ticket120-hello-plugin-blackscreen-recurrence.md new file mode 100644 index 0000000..c52ccca --- /dev/null +++ b/.ideai/memory/ticket120-hello-plugin-blackscreen-recurrence.md @@ -0,0 +1,26 @@ +--- +name: ticket120-hello-plugin-blackscreen-recurrence +description: memory note ticket120-hello-plugin-blackscreen-recurrence +metadata: + type: project +--- +# Récidive écran noir hello-plugin malgré 3 fixes mergés + +Le bug "écran noir à l'installation de hello-plugin" (#120, encore open) a déjà survécu à 3 correctifs mergés dans develop — traiter le prochain lot comme recherche de root cause, pas comme patch symptomatique de plus. + +## Constat + +Le ticket #120 ("Réinvestiguer l'installation de hello-plugin: écran noir / perte d'affichage IdeA") est toujours **`open`** au 2026-08-01, alors que **trois** correctifs distincts ont déjà été mergés dans `develop` sur le même symptôme : + +1. `fix/ticket116-plugin-install-black-screen` → mergé `f6685aa` (ticket #116, closed) — "isoler crash plugin hello-plugin + erreur explicite UI" +2. `feature/hello-plugin-manifest-fix-and-fixture-tests` → mergé `6270f98` — "aligne le manifeste hello-plugin sur le schéma backend + fixture de test" +3. `feature/ticket120-hello-plugin-blackscreen-reinvestigation` → mergé `e741f76` (aujourd'hui) — "isolation plugin invalide + réconciliation MCP durcie + loader export default/cleanup" + +Et le bug revient une 4e fois, ce qui a motivé la relance de #120 le 2026-08-01 sur la branche `feature/ticket120-hello-plugin-blackscreen-crash-diagnostics`. + +**Why** : chaque fix précédent a visiblement traité un symptôme observable (crash isolation, manifeste, réconciliation MCP, loader export) sans que la cause racine soit couverte — sinon le bug ne reviendrait pas. Fermer #120 à chaque merge sans validation e2e réelle post-merge semble être le trou du cycle. + +**How to apply** : +- Avant tout nouveau fix sur ce symptôme, lire l'historique des 3 tentatives ci-dessus pour ne pas répéter une piste déjà explorée et retombée. +- Ne pas fermer #120 au merge — seulement après validation réelle de l'installation du plugin de bout en bout (voir [[git-owns-commit-merge-decisions]] pour la règle générale de non-merge sans tests verts, qui s'applique ici avec une vigilance renforcée). +- La dette de diagnostic crash/logs est traitée dans le même lot que ce 4e essai (branche `feature/ticket120-hello-plugin-blackscreen-crash-diagnostics`), précisément pour éviter un 5e patch aveugle. \ No newline at end of file