Skip to content

feat(plugins): table de vérité persistante, ramasse-miette forcé et supervision IPC/events - #279

Merged
traoreera merged 3 commits into
mainfrom
feature/plugin-supervision-and-gc
Sep 28, 2026
Merged

traoreera merged 3 commits into
mainfrom
feature/plugin-supervision-and-gc

Conversation

@traoreera

@traoreera traoreera commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Résumé

  • Table de vérité persistante pour l'activation/désactivation des plugins (PluginStateStore) — PluginLoader.load_all() saute désormais les plugins désactivés au boot, et PluginSupervisor.enable()/disable(reason=) togglent l'état en live tout en le persistant (survit à un redémarrage du process).
  • Ramasse-miette forcé au unload/disable (PluginResourceTracker) : jobs scheduler, health checks et abonnements events/hooks sont désinscrits de force, indépendamment de la qualité des hooks on_stop/on_unload du plugin. PluginRegistry.unregister() (qui existait mais n'était jamais appelé) est enfin invoqué. Nouveau ctx.spawn_task() pour les tâches de fond suivies et annulées au unload. Les routes HTTP d'un plugin sont maintenant démontées au unload (pas seulement au reload).
  • Centre de contrôle HTTP sur /plugins/ipc/* : GET /registry (table de vérité complète), POST /{name}/enable, POST /{name}/disable, GET /audit (journal des appels IPC), GET /events (activité EventBus/HookManager).
  • 3 corrections identifiées en revérifiant reports/technical_debt_remediation_verification_2026-08-11.md contre le code actuel :
    • propagate_services() cassait le reload/réactivation d'un plugin TrustedBase (collision sur un service protégé reçu en injection, pas exporté par le plugin)
    • SandboxProcessManager.call() ne recyclait le subprocess que sur IPCProcessDead, jamais sur IPCTimeoutError — recyclage différé au prochain health check au lieu d'être immédiat
    • PermissionEngine(audit_cache_hits=False) — option pour sauter l'audit log sur cache hit (défaut inchangé, comportement historique préservé)
    • tests/conftest.py : préfixe xcore_test_ uniforme + fixture autouse de nettoyage des dossiers temporaires orphelins en cas de crash dur d'un test

Test plan

  • poetry run pytest tests/ — suite complète verte (1540 passed)
  • make lint-check — clean sur les fichiers touchés
  • Tests d'intégration bout-en-bout pour disable → persistance → redémarrage → enable (tests/integration/test_plugin_enable_disable.py)
  • Tests unitaires pour chaque proxy de ramasse-miette (scheduler/health/events/hooks), les nouveaux endpoints, et les 3 corrections du rapport de dette technique

Summary by Sourcery

Strengthen plugin lifecycle control and operational visibility with persistent activation state, forced cleanup, HTTP supervision, and targeted reliability fixes.

New Features:

  • Persist plugin enable/disable state across process restarts and expose live control endpoints.
  • Add forced plugin resource cleanup during unload, including scheduled jobs, health checks, event/hook subscriptions, background tasks, registry entries, and HTTP routes.
  • Provide IPC, event, hook, and registry supervision through new HTTP control and observability endpoints.

Bug Fixes:

  • Prevent protected injected services from breaking trusted-plugin reloads or re-enablement.
  • Recycle sandbox subprocesses immediately after IPC timeouts as well as process-death errors.
  • Allow permission audit logging to omit cache-hit entries when configured without changing the default behavior.
  • Improve cleanup of temporary test directories left by hard-crashed test runs.

Enhancements:

  • Add bounded IPC audit trails and aggregated call statistics.
  • Add recent EventBus and HookManager emission records and metrics.
  • Add health-check unregistration support and tracked background task spawning for plugin contexts.

Build:

  • Release version 2.6.0 and remove unused direct core dependencies while regenerating the lockfile.

Documentation:

  • Update changelogs for the 2.6.0 plugin lifecycle, supervision, observability, and dependency changes.

Tests:

  • Add integration coverage for persistent plugin disablement across restart and live re-enablement.
  • Expand unit coverage for resource cleanup, control endpoints, event and hook supervision, permission audit configuration, IPC timeout recycling, state persistence, and test-directory cleanup.

Chores:

  • Update roadmap progress markers to reflect the current distribution and cloud-native status.

…upervision IPC/events

Activation/désactivation des plugins :
- PluginStateStore (JSON) persiste l'état actif/inactif, consulté par
  PluginLoader.load_all() pour sauter les plugins désactivés au boot
- PluginSupervisor.enable()/disable(reason=) togglent en live + persistent
- registry_table() expose la table de vérité complète (disque + persisté +
  état live), y compris les plugins désactivés ou jamais chargés

Ramasse-miette forcé au unload/disable (PluginResourceTracker) :
- jobs scheduler, health checks, abonnements events/hooks désinscrits de
  force, indépendamment de la qualité des hooks on_stop/on_unload du plugin
- PluginRegistry.unregister() enfin appelé (existait, jamais invoqué)
- ctx.spawn_task() pour des tâches de fond suivies et annulées au unload
- routes HTTP démontées au unload (pas seulement au reload)

Centre de contrôle HTTP sur /plugins/ipc/* :
- GET /registry, POST /{name}/enable, POST /{name}/disable
- GET /audit (PluginSupervisor.ipc_audit()/ipc_stats())
- GET /events (EventBus.recent_emissions()/stats(), HookManager.recent_emissions())

Corrections (vérification reports/technical_debt_remediation_verification_2026-08-11.md) :
- propagate_services() cassait le reload d'un plugin TrustedBase (collision
  sur un service protégé reçu en injection, pas exporté)
- SandboxProcessManager.call() ne recyclait pas le subprocess sur
  IPCTimeoutError, seulement sur IPCProcessDead
- PermissionEngine(audit_cache_hits=False) pour sauter l'audit log sur
  cache hit (optionnel, défaut inchangé)
- tests/conftest.py : préfixe xcore_test_ uniforme + fixture autouse de
  nettoyage des dossiers temporaires orphelins en cas de crash dur
@sourcery-ai

sourcery-ai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Cette PR introduit un cycle de vie plugin supervisé de bout en bout : l’état enable/disable est persistant, les ressources et routes sont retirées de force à l’unload, et un centre HTTP expose l’état, les audits IPC et l’activité events/hooks. Elle ajoute aussi des protections de robustesse pour le reload TrustedBase, les timeouts sandbox, l’audit des permissions et le nettoyage des tests, avec une couverture unitaire et d’intégration dédiée.

Sequence diagram for persistent plugin disable and restart

sequenceDiagram
    participant Admin
    participant API as PluginAPI
    participant Supervisor as PluginSupervisor
    participant Store as PluginStateStore
    participant Lifecycle as LifecycleManager
    participant Loader as PluginLoader

    Admin->>API: POST /{plugin_name}/disable
    API->>Supervisor: disable(plugin_name, reason)
    Supervisor->>Store: set_enabled(plugin_name, false, reason)
    Store-->>Supervisor: state persisted
    Supervisor->>Lifecycle: unload(plugin_name)
    Lifecycle-->>Supervisor: plugin unloaded
    Supervisor-->>API: status ok
    API-->>Admin: disabled

    Note over Loader,Store: Process restart
    Loader->>Store: is_enabled(plugin_name)
    Store-->>Loader: false
    Loader-->>Loader: skip disabled plugin
Loading

Sequence diagram for forced plugin resource cleanup

sequenceDiagram
    participant Plugin
    participant Context as PluginContext
    participant Tracker as PluginResourceTracker
    participant Lifecycle as LifecycleManager
    participant Registry as PluginRegistry

    Lifecycle->>Tracker: wrap scheduler, health, events, hooks
    Lifecycle->>Context: assign _task_sink
    Plugin->>Context: spawn_task(coro)
    Context->>Context: track asyncio.Task
    Plugin->>Tracker: register jobs, checks, subscriptions

    Lifecycle->>Plugin: on_stop/on_unload()
    Plugin-->>Lifecycle: success or error
    Lifecycle->>Tracker: cleanup()
    Tracker-->>Tracker: remove jobs, checks, events, hooks
    Lifecycle->>Context: cancel spawned tasks
    Lifecycle->>Registry: unregister(plugin_name)
Loading

Flow diagram for plugin unload route removal

flowchart TD
    Unload["Plugin unload or disable"] --> Event["plugin.*.unloaded"]
    Event --> Handler["_on_plugin_unloaded(event)"]
    Handler --> Unmount["_unmount_plugin_router(plugin_name)"]
    Unmount --> Routes["Remove plugin paths from app.routes"]
    Routes --> OpenAPI["Invalidate app.openapi_schema"]
Loading

File-Level Changes

Change Details Files
Persiste l’état d’activation des plugins et l’intègre au cycle de chargement et de supervision.
  • Ajoute un store JSON atomiquement écrit, avec activation par défaut et conservation de la raison.
  • Ignore les plugins désactivés au démarrage et expose une table incluant les plugins non chargés.
  • Ajoute les opérations live enable/disable, avec déchargement ou rechargement immédiat et persistance.
xcore/registry/state_store.py
xcore/registry/__init__.py
xcore/kernel/runtime/loader.py
xcore/kernel/runtime/supervisor.py
xcore/kernel/api/router.py
tests/unit/test_registry_state_store.py
tests/unit/kernel/test_loader.py
tests/unit/kernel/test_supervisor.py
tests/unit/kernel/test_api_router.py
tests/integration/test_plugin_enable_disable.py
Ajoute un nettoyage forcé des ressources plugin lors des unloads et désactivations.
  • Intercepte les enregistrements de jobs scheduler, health checks et abonnements EventBus/HookManager pour pouvoir les retirer sans dépendre des hooks plugin.
  • Ajoute ctx.spawn_task() et annule les tâches suivies au déchargement.
  • Rend les hooks on_stop/on_unload best-effort, puis nettoie les ressources et désenregistre toujours le plugin.
  • Démonte les routes FastAPI lors de l’unload et invalide le schéma OpenAPI.
xcore/kernel/runtime/plugin_gc.py
xcore/kernel/runtime/lifecycle.py
xcore/kernel/api/context.py
xcore/kernel/observability/health.py
xcore/__init__.py
tests/unit/kernel/test_lifecycle.py
tests/unit/kernel/test_health.py
Expose une supervision HTTP des appels IPC et de l’activité events/hooks.
  • Ajoute les endpoints registry, enable, disable, audit et events sous /plugins/ipc/*.
  • Conserve un journal IPC borné avec filtrage et statistiques par plugin.
  • Ajoute des journaux bornés et métriques d’émission à EventBus et HookManager.
  • Couvre les endpoints, filtres, métriques et scénarios sans bus par tests unitaires.
xcore/kernel/api/router.py
xcore/kernel/runtime/supervisor.py
xcore/kernel/events/bus.py
xcore/kernel/events/hooks.py
tests/unit/kernel/test_api_router.py
tests/unit/kernel/test_events.py
tests/unit/kernel/test_hooks.py
tests/unit/kernel/test_supervisor.py
Corrige trois comportements de robustesse et améliore l’isolation des tests.
  • Ignore uniquement les collisions de services protégés lorsque l’objet réinjecté est identique; les overrides réels restent rejetés.
  • Recycle immédiatement les sous-processus après un décès ou un timeout IPC.
  • Permet de ne pas auditer les cache hits de PermissionEngine tout en conservant le comportement par défaut.
  • Nettoie en fin de session les répertoires temporaires de tests portant le préfixe dédié.
xcore/kernel/runtime/lifecycle.py
xcore/registry/index.py
xcore/kernel/sandbox/process_manager.py
xcore/kernel/permissions/engine.py
tests/conftest.py
tests/unit/kernel/test_process_manager.py
tests/unit/kernel/test_permissions.py
tests/test_conftest_cleanup.py
Documente la version 2.6.0 et ses changements fonctionnels et correctifs.
  • Ajoute les entrées de changelog pour l’état persistant, le nettoyage forcé, la supervision et les corrections associées.
CHANGELOG.md
doc/changelog.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="xcore/kernel/runtime/lifecycle.py" line_range="195-208" />
<code_context>
                 isolate_scheduler=tenancy.isolate_scheduler,
             )

+        # Ramasse-miette forcé : on intercepte scheduler/health/events/hooks
+        # pour mémoriser ce que CE plugin y enregistre pendant on_load, afin
+        # de tout désinscrire au unload sans dépendre de on_unload/on_stop.
+        tracker = PluginResourceTracker(self.manifest.name)
+        ctx.events = tracker.wrap_events(ctx.events)
+        ctx.hooks = tracker.wrap_hooks(ctx.hooks)
+        ctx.health = tracker.wrap_health(ctx.health)
+        if ctx.services.get("scheduler") is not None:
+            ctx.services = dict(ctx.services)
+            ctx.services["scheduler"] = tracker.wrap_scheduler(
+                ctx.services["scheduler"]
+            )
+        self._resource_tracker = tracker
+        ctx._task_sink = self._spawned_tasks
+
         if hasattr(self._instance, "_inject_context"):
</code_context>
<issue_to_address>
**issue (bug_risk):** Resources registered during `_inject_context` or `on_load` leak when loading fails, because the tracker is created and installed before those hooks but cleanup only runs from `_do_unload()`, which is not invoked for a failed load.

**Triggers:** When a plugin raises during context injection or `on_init`/`on_load`/`on_start`.

**Suggested fix:** Run tracker cleanup, task cancellation, registry cleanup, and module cleanup from the load-error path before propagating the load failure.
</issue_to_address>

### Comment 2
<location path="xcore/registry/state_store.py" line_range="46-61" />
<code_context>
+        if not self._path.exists():
+            return
+        try:
+            self._data = json.loads(self._path.read_text(encoding="utf-8")) or {}
+        except (json.JSONDecodeError, OSError) as e:
+            logger.warning(
+                "plugin state file unreadable, starting empty",
+                path=str(self._path),
+                error=str(e),
+            )
+            self._data = {}
+
+    def _persist(self) -> None:
+        self._path.parent.mkdir(parents=True, exist_ok=True)
+        tmp_path = self._path.with_suffix(f"{self._path.suffix}.tmp")
+        tmp_path.write_text(
+            json.dumps(self._data, indent=2, sort_keys=True), encoding="utf-8"
+        )
+        os.replace(tmp_path, self._path)
+
+    def is_enabled(self, name: str, default: bool = True) -> bool:
</code_context>
<issue_to_address>
**issue (bug_risk):** Concurrent enable/disable requests write through the same fixed temporary path without locking, so one request can overwrite another's in-memory state or cause `os.replace` to race on a temporary file; the persisted truth table is not concurrency-safe.

**Triggers:** When two HTTP enable/disable operations update plugin state concurrently.

**Suggested fix:** Serialize read-modify-write operations with a process-level lock and use a unique temporary filename per atomic write.
</issue_to_address>

### Comment 3
<location path="xcore/registry/state_store.py" line_range="46" />
<code_context>
+            self._data = json.loads(self._path.read_text(encoding="utf-8")) or {}
</code_context>
<issue_to_address>
**issue (bug_risk):** A syntactically valid JSON file containing a non-object, such as `[]` or `null` with the fallback not applying to the wrong shape, leaves `_data` with a non-dictionary value and later `is_enabled()` raises `AttributeError` on `.get`.

**Triggers:** When the state file is truncated or replaced with valid JSON of the wrong shape.

**Suggested fix:** Validate that the loaded value is a dictionary of plugin-state dictionaries and reset invalid shapes to `{}`.

```suggestion
            data = json.loads(self._path.read_text(encoding="utf-8"))
            self._data = (
                data
                if isinstance(data, dict)
                and all(isinstance(value, dict) for value in data.values())
                else {}
            )
```
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 3 findings to address first, and this changes persistent plugin activation policy and runtime reachability: a faulty enable/disable decision could expose or withdraw plugin IPC and HTTP behavior, while unload cleanup could also remove resources or routes incorrectly. Reverting the code would not automatically undo the persisted state or effects that occurred while a plugin was enabled or disabled.

Blocking findings: xcore/kernel/runtime/lifecycle.py:208, xcore/registry/state_store.py:61, xcore/registry/state_store.py:46


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment on lines +195 to +208
# Ramasse-miette forcé : on intercepte scheduler/health/events/hooks
# pour mémoriser ce que CE plugin y enregistre pendant on_load, afin
# de tout désinscrire au unload sans dépendre de on_unload/on_stop.
tracker = PluginResourceTracker(self.manifest.name)
ctx.events = tracker.wrap_events(ctx.events)
ctx.hooks = tracker.wrap_hooks(ctx.hooks)
ctx.health = tracker.wrap_health(ctx.health)
if ctx.services.get("scheduler") is not None:
ctx.services = dict(ctx.services)
ctx.services["scheduler"] = tracker.wrap_scheduler(
ctx.services["scheduler"]
)
self._resource_tracker = tracker
ctx._task_sink = self._spawned_tasks

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Resources registered during _inject_context or on_load leak when loading fails, because the tracker is created and installed before those hooks but cleanup only runs from _do_unload(), which is not invoked for a failed load.

Triggers: When a plugin raises during context injection or on_init/on_load/on_start.

Suggested fix: Run tracker cleanup, task cancellation, registry cleanup, and module cleanup from the load-error path before propagating the load failure.

Comment on lines +46 to +61
self._data = json.loads(self._path.read_text(encoding="utf-8")) or {}
except (json.JSONDecodeError, OSError) as e:
logger.warning(
"plugin state file unreadable, starting empty",
path=str(self._path),
error=str(e),
)
self._data = {}

def _persist(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self._path.with_suffix(f"{self._path.suffix}.tmp")
tmp_path.write_text(
json.dumps(self._data, indent=2, sort_keys=True), encoding="utf-8"
)
os.replace(tmp_path, self._path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Concurrent enable/disable requests write through the same fixed temporary path without locking, so one request can overwrite another's in-memory state or cause os.replace to race on a temporary file; the persisted truth table is not concurrency-safe.

Triggers: When two HTTP enable/disable operations update plugin state concurrently.

Suggested fix: Serialize read-modify-write operations with a process-level lock and use a unique temporary filename per atomic write.

if not self._path.exists():
return
try:
self._data = json.loads(self._path.read_text(encoding="utf-8")) or {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): A syntactically valid JSON file containing a non-object, such as [] or null with the fallback not applying to the wrong shape, leaves _data with a non-dictionary value and later is_enabled() raises AttributeError on .get.

Triggers: When the state file is truncated or replaced with valid JSON of the wrong shape.

Suggested fix: Validate that the loaded value is a dictionary of plugin-state dictionaries and reset invalid shapes to {}.

Suggested change
self._data = json.loads(self._path.read_text(encoding="utf-8")) or {}
data = json.loads(self._path.read_text(encoding="utf-8"))
self._data = (
data
if isinstance(data, dict)
and all(isinstance(value, dict) for value in data.values())
else {}
)

…onisée

- xcore/__version__.py était figé à 2.3.3 (désynchronisé de pyproject.toml
  depuis plusieurs releases) — aligné sur 2.6.0
- pyproject.toml : retrait de uvicorn, pydantic-settings, rich (zéro usage
  dans xcore/, déjà couverts transitivement par fastapi[standard] pour les
  deux premiers) ; pydantic[email] → pydantic (EmailStr jamais utilisé).
  poetry.lock régénéré en conséquence, aucun changement de comportement.
- roadmap/executed_roadmap.md (FR) affichait V3 60% / V4 15% alors que son
  propre tableau détaillé (identique à la version EN) ne justifie que
  25% / 5% — recompté et aligné sur ROADMAP_PROGRESS.md
@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file core tests config labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ PR très large (1909 lignes modifiées). Pensez à la découper en plusieurs PRs plus petites pour faciliter la revue.

@github-actions

Copy link
Copy Markdown
Contributor

Résout les conflits (main a avancé à 2.5.3 pendant ce travail — fix
shutdown()/AutoDispatchMixin/PermissionEngine LRU/TenantAware wrappers/
version sync/RedisCacheBackend.clear()/sanitisation des logs) :

- xcore/__version__.py, pyproject.toml : garde 2.6.0 (postérieur à 2.5.3)
- xcore/kernel/permissions/engine.py : combine le cache LRU borné
  (OrderedDict, main) avec le toggle audit_cache_hits (cette branche)
- xcore/kernel/runtime/loader.py : auto-mergé proprement (fix shutdown()
  de main + PluginStateStore de cette branche touchaient des sections
  disjointes)
- CHANGELOG.md / doc/changelog.md : entrée 2.6.0 en tête, 2.5.3 conservée
  en dessous
- xcore/kernel/observability/logging.py : reformatage black (fichier
  touché par le fix sécurité clear-text logging, non conforme au push)
@traoreera
traoreera force-pushed the feature/plugin-supervision-and-gc branch from 77ec7f0 to 54924ad Compare September 28, 2026 14:35
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ PR très large (1899 lignes modifiées). Pensez à la découper en plusieurs PRs plus petites pour faciliter la revue.

@traoreera
traoreera merged commit d507bd8 into main Sep 28, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

config core dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant