diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 89695d5..1f5ef69 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,6 +37,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install "safe-start-for-codex @ git+https://github.com/dev-bricks/safe-start-for-codex.git@dcb369a64f403f6551bcb3bac16565c56ec79474" + python -m pip install "zombie-killer-tray @ git+https://github.com/dev-bricks/zombie-killer-tray.git@039b4f2c7acd69063b39d6168c757b0b2fca2438" python -m pip install -e ".[dev]" - name: Lint with ruff @@ -45,5 +46,8 @@ jobs: - name: Compile sources run: python -m compileall -q src tests + - name: Lint sources and tests + run: python -m ruff check src tests + - name: Run tests run: python -m pytest -q diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 23aff97..3dd3d2e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,12 +12,12 @@ kann die Logik getestet werden, ohne die Tray-App zu starten (der interne Python |---|---| | `config.py` | lokale Konfiguration, Standardpfade (aus `%LOCALAPPDATA%`/`%APPDATA%`/`~/.codex`), Schwellwerte | | `i18n.py` | leichtgewichtige DE-/EN-Lokalisierung und persistierte Sprachauswahl | -| `processes.py` | Codex-Prozessprüfung über PowerShell/CIM; Klassifikation (`--type`), exaktes Exe-Matching, Prozessbaum, Parent-/Altersprüfung für Runtime-Waisen und fail-closed Erkennung wiederholter Runtime-MCP-Launcher | +| `processes.py` | Codex-Prozessprüfung über PowerShell/CIM; Klassifikation (`--type`), exaktes Exe-Matching, Prozessbaum, tote-Parent-/Altersprüfung für allowlist-basierte MCP-/Language-Server-Waisen und fail-closed Erkennung wiederholter Runtime-MCP-Launcher | | `maintenance.py` | Backup + Retention, Integritätscheck, WAL-Checkpoint, `PRAGMA optimize`, `VACUUM`, Protokolle | | `health.py` | Startup-Diagnose (`diagnose`) und gezielte Reparatur (`repair_start`) — getrennt vom Wartungsblocker | | `orchestrator.py` | Autonome Wartung (`auto_maintain`): Aktivitätsmessung (CPU+DB des ganzen Baums), zwei Modi (safe/fast) | | `automation_control.py` | aktive Codex-Automatisierungen pausieren, CareCenter-eigene Pausen nachhalten und gezielt reaktivieren | -| `watchdog.py` | Hintergrund-Wächter: reapt idle Ghosts, alte Runtime-Waisen ohne Lebenszeichen und sicher wiederholte, CPU-inaktive Runtime-MCP-Prozessbäume; erfolgreiche Waisen-Kills landen mit PID, Commandline und Kriterium im App-Log | +| `watchdog.py` | Hintergrund-Wächter: reapt idle Ghosts und alte, CPU-inaktive MCP-/Language-Server-Waisen nur mit totem Parent; Live-Cohorts werden nicht gekillt, erfolgreiche Waisen-Kills landen mit Kind-/Parent-Identität und Kriterium im App-Log | | `start_repair.py` | Klassifikation der Start-Lage für die zusammengefasste „Codex reparieren"-Eskalation | | `repair_workflow.py` | Hang-sichere S1–S7-Eskalationsengine (rein, injizierbare Bausteine) | | `repair_live.py` | Echte Windows-/AppX-Implementierungen der Reparatur-Bausteine (P11 absent-Erkennung, Reinstall-Prävention) | @@ -115,7 +115,7 @@ nicht in bereits laufende Datenbankoperationen ein. - Ohne explizite Archivkonfiguration werden keine Logdaten gelöscht. - Thread-Archivierung ist separat konfiguriert (`auto_archive_threads_days=0` bedeutet aus), wartet beim Empty-Thread-Autofix mindestens 300 Sekunden und sichert `state_5.sqlite` vor Änderungen. Ein breiter Prozess-Snapshot blockiert bei Desktop oder npm-Codex-CLI und wird unmittelbar vor Backup sowie Move erneut erhoben. - Startup-Reparatur beendet ausschließlich Zombie-Hauptprozesse (kein Renderer); aktive Sitzungen nie. -- Runtime-MCP-Bereinigung gilt nur für direkte Launcher unter dem Store-Desktop-App-Server: der neueste Start-Cohort bleibt immer bestehen, mindestens zwei verschiedene Signaturen müssen exakt wiederholt sein, die Karenzzeit muss abgelaufen sein und der vollständige Kandidatenbaum darf im CPU-Sample nicht arbeiten. +- Runtime-MCP-Erkennung gilt nur für direkte Launcher unter dem Store-Desktop-App-Server: der neueste Start-Cohort bleibt immer bestehen, mindestens zwei verschiedene Signaturen müssen exakt wiederholt sein, die Karenzzeit muss abgelaufen sein und der vollständige Kandidatenbaum darf im CPU-Sample nicht arbeiten. Für den mutierenden Reap muss zusätzlich der Parent tot sein; eine lebende Parent-Beziehung sperrt den Kill. - Runtime-Waisen müssen einen toten oder nach dem Kind neu belegten Parent haben, mindestens 30 Minuten alt sein und in zwei Messpunkten CPU-inaktiv bleiben. Bei `codex exec` blockieren zusätzlich ein Session-Rollout jünger als 120 Sekunden, ein noch fehlendes `--output-last-message`-Ziel oder das vollständige Fehlen dieser Output-Option den Kill. Der systemweite externe Task-Schutz pausiert außerdem die Runtime-MCP-Bereinigung bei `codex-companion.mjs` oder laufendem `codex exec`. - npm-/CLI-app-server, der Desktop-App-Server selbst, fremde Kindprozesse und Kandidaten mit unvollständigen Zeitdaten werden fail-closed ausgeschlossen. -- Ghost-Targeting nutzt den exakten konfigurierten Exe-Pfad plus Prozessbaum. Runtime-Waisen nutzen stattdessen enge Typ-Signaturen: Companion-app-server, Prozessnamen mit Präfix `language_server` oder den exakten Prozessnamen `codex.exe` mit `exec`-Subcommand. +- Ghost-Targeting nutzt den exakten konfigurierten Exe-Pfad plus Prozessbaum. Runtime-Waisen nutzen allowlist-basierte Typ-Signaturen: Companion-app-server, gängige Language-Server, MCP-Nodes oder der exakte Prozessname `codex.exe` mit `exec`-Subcommand. Die letzte bekannte Parent-Identität wird nur protokolliert und niemals als Kill-Grundlage erfunden. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ad8cd1..b8ff8f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,9 +2,9 @@ ## Deutsch -Vielen Dank fuer Ihr Interesse, zu diesem Projekt beizutragen! +Vielen Dank für Ihr Interesse, zu diesem Projekt beizutragen! -### Wie Sie beitragen koennen +### Wie Sie beitragen können 1. **Bug melden:** Erstellen Sie ein Issue mit dem Label `bug` 2. **Feature vorschlagen:** Erstellen Sie ein Issue mit dem Label `enhancement` @@ -14,27 +14,29 @@ Vielen Dank fuer Ihr Interesse, zu diesem Projekt beizutragen! 1. Forken Sie das Repository 2. Erstellen Sie einen Feature-Branch: `git checkout -b feature/mein-feature` -3. Committen Sie Ihre Aenderungen: `git commit -m "Beschreibung der Aenderung"` +3. Committen Sie Ihre Änderungen: `git commit -m "Beschreibung der Änderung"` 4. Pushen Sie den Branch: `git push origin feature/mein-feature` 5. Erstellen Sie einen Pull Request ### Code-Richtlinien -- Python: PEP 8 Stil, Python 3.10+ -- Encoding: UTF-8 fuer alle Dateien +- Python: PEP 8 Stil, Python 3.12+ +- Encoding: UTF-8 für alle Dateien - Sprache: Code und Kommentare auf Deutsch oder Englisch - Keine hardcoded Pfade oder API-Keys -- Tests muessen gruen sein: `python -m pytest` +- Ruff muss erfolgreich sein: `python -m ruff check src tests` +- Tests müssen grün sein: `python -m pytest` ### Erste Schritte ```powershell $env:PYTHONPATH="$PWD\src" +python -m ruff check src tests python -m pytest python -m codex_logdatenbank_wartung.cli status ``` -Ohne ausdrueckliche Zusatzregel gelten Pull Requests unter der Lizenz des Projekts (MIT). +Ohne ausdrückliche Zusatzregel gelten Pull Requests unter der Lizenz des Projekts (MIT). --- @@ -58,10 +60,11 @@ Thank you for your interest in contributing to this project! ### Code Guidelines -- Python: PEP 8 style, Python 3.10+ +- Python: PEP 8 style, Python 3.12+ - Encoding: UTF-8 for all files - Language: Code and comments in German or English - No hardcoded paths or API keys +- Ruff must pass: `python -m ruff check src tests` - Tests must pass: `python -m pytest` Unless stated otherwise, pull requests are understood to be submitted under the diff --git a/README.de.md b/README.de.md index cf0f073..95110f2 100644 --- a/README.de.md +++ b/README.de.md @@ -236,7 +236,7 @@ Die folgende Matrix vergleicht CareCenter for Codex mit alternativen Betriebsans ## Funktionen -- Hintergrund-Wächter: prüft alle 60 Sekunden auf alte Startblocker, abgelöste Runtime-Waisen und doppelte Runtime-MCP-Prozessgenerationen. Die Waisenbereinigung verlangt einen toten Parent, eine feste Karenzzeit von 30 Minuten und zwei CPU-Messpunkte. Abgelöste `codex exec`-Läufe bleiben geschützt, solange CPU-Zeit wächst, ein Session-Rollout jünger als zwei Minuten ist oder ihr `--output-last-message`-Ziel noch fehlt; inaktive `language_server*`-Waisen bleiben bereinigungsfähig. Jeder erfolgreiche Waisen-Kill schreibt PID, Commandline und Kriterium in `app.log`. Die Runtime-MCP-Bereinigung erfasst weiterhin nur inaktive Launcher-Bäume unter demselben Store-Desktop-App-Server und behält immer den neuesten Start-Cohort. +- Hintergrund-Wächter: prüft alle 60 Sekunden auf alte Startblocker, abgelöste Runtime-Waisen und doppelte Runtime-MCP-Prozessgenerationen. Die Waisenbereinigung verlangt einen toten Parent, eine feste Karenzzeit von 30 Minuten und zwei CPU-Messpunkte. Allowlist-basierte MCP-Nodes und gängige Language-Server werden erfasst. Abgelöste `codex exec`-Läufe bleiben geschützt, solange CPU-Zeit wächst, ein Session-Rollout jünger als zwei Minuten ist oder ihr `--output-last-message`-Ziel noch fehlt. Jeder erfolgreiche Kill beendet den vollständigen Prozessbaum (`taskkill /T`) und schreibt Kind, letzte bekannte Parent-Identität und Kriterium in `app.log`. Eine lebende Parent-Beziehung bedeutet aktive Cohort und ist immer ein Nicht-Kill. - Spracheinstellung im Tray: Im Bereich Einstellungen kann zwischen Deutsch und Englisch gewechselt werden. Die Auswahl wird in `config.json` gespeichert und die sichtbare Tray-Oberfläche wird sofort neu beschriftet. - Automatisierungssteuerung im Tray: alle aktuell aktiven Codex-Automatisierungen ausschalten, nur von CCC ausgeschaltete Automatisierungen wieder aktivieren oder Automatisierungen sofort beziehungsweise gestaffelt nacheinander einschalten. Der Abstand ist über `automation_stagger_delay_seconds` konfigurierbar (Standard: 60 Sekunden). - Thread-Postfachpflege: alle als gelesen markieren, ungelesene Threads älter als X Tage markieren und Threads nach einem getrennt einstellbaren Alter automatisch archivieren. Der Empty-Thread-Autofix wartet mindestens 300 Sekunden, damit neue CLI-/Desktop-Threads ihren ersten Schreibvorgang abschließen können. Änderungen werden bei Desktop- oder npm-Codex-CLI-Aktivität blockiert, unmittelbar vor Backup/Move erneut geprüft und nur mit Backups, atomarem State-Schreiben und transaktionaler Archivierung ausgeführt. @@ -309,6 +309,8 @@ $env:CARECENTER_SAFE_START_SOURCE = "C:\Pfad\zu\REL-PUB_safe-start-for-codex" build_exe.bat ``` +Dasselbe Muster gilt für die optionale [zombie-killer-tray](https://github.com/dev-bricks/zombie-killer-tray)-Integration (konservative Bereinigung verwaister MCP- und Language-Server-Prozesse, gestartet als eigener Subprozess über `zombie-killer-watch`): standardmäßig auf einen exakten Commit gepinnt, überschreibbar für einen lokalen Schwester-Checkout via `$env:CARECENTER_ZOMBIE_KILLER_SOURCE`. + ## CLI-Befehle @@ -328,6 +330,10 @@ python -m codex_logdatenbank_wartung.cli store-repair --level repair --execute python -m codex_logdatenbank_wartung.cli store-materials python -m codex_logdatenbank_wartung.cli safe-start-report python -m codex_logdatenbank_wartung.cli safe-start-install +python -m codex_logdatenbank_wartung.cli zombie-killer-report +python -m codex_logdatenbank_wartung.cli zombie-killer-install +python -m codex_logdatenbank_wartung.cli zombie-killer-watch +python -m codex_logdatenbank_wartung.cli zombie-killer-stop python -m codex_logdatenbank_wartung.cli schedule install --interval-minutes 180 ``` @@ -353,12 +359,13 @@ database: %USERPROFILE%\.codex\logs_2.sqlite Codex-Pfade werden aus `%LOCALAPPDATA%`, `%APPDATA%` und `CODEX_HOME` erkannt. Neue Installationen legen auch die CareCenter-Daten standardmäßig unter `%LOCALAPPDATA%\CareCenterForCodex` ab. Bestehende lokale Setups unter `C:\_Local_DEV\codex-maintenance\` werden als Legacy-Fallback automatisch weiterverwendet. Alle Pfade lassen sich in `config.json` überschreiben. -Die Runtime-MCP-Bereinigung ist über `reap_runtime_mcp_duplicates` standardmäßig -aktiv. Ihre konservativen Vorgaben sind ein konfigurierbares Mindestalter von 3600 Sekunden -(einer Stunde) für jeden Kandidaten-Root, 90 Sekunden Start-Cohort-Abstand, ein -30-Sekunden-Launcherfenster, mindestens zwei verschiedene -wiederholte MCP-Signierung und eine Sekunde CPU-Aktivitätsmessung. Alle Schwellen -lassen sich in `config.json` anpassen. +Die Runtime-MCP-Kandidatenerkennung ist über `reap_runtime_mcp_duplicates` +standardmäßig aktiv. Ihre konservativen Vorgaben sind ein konfigurierbares +Mindestalter von 3600 Sekunden (einer Stunde) für jeden Kandidaten-Root, 90 +Sekunden Start-Cohort-Abstand, ein 30-Sekunden-Launcherfenster, mindestens zwei +verschiedene wiederholte MCP-Signaturen und eine Sekunde CPU-Aktivitätsmessung. +Für einen Kill muss zusätzlich der Parent tot sein; eine aktive Store-App-Server- +Cohort wird nie beendet. Alle Schwellen lassen sich in `config.json` anpassen. Die Runtime-Waisenbereinigung behält aus Kompatibilitätsgründen das Präfix `reap_companion_orphans`. Für `companion_orphan_min_age_seconds` gilt eine feste @@ -382,9 +389,9 @@ größere Werte verlängern sie. - Safe Auto-Maintain schließt Codex erst, wenn der gesamte Prozessbaum im Leerlauf ist. - Der Safe-Abbruch stoppt nur das Warten vor dem Schließen von Codex; laufende Datenbankoperationen werden nicht hart unterbrochen. - Der Wächter beendet inaktive Ghosts ohne Renderer nur nach der konfigurierten Altersschwelle. -- Die Runtime-MCP-Bereinigung behält immer den neuesten Start-Cohort und überspringt Kandidaten, deren CPU-Zähler noch steigen. -- Die Runtime-Waisenbereinigung verlangt einen toten Parent sowie Alters- und CPU-Leerlaufbelege. Ein abgelöster `codex exec` bleibt zusätzlich ausgeschlossen, solange ein CPU-, Rollout- oder ausstehendes Output-Lebenszeichen vorliegt; ohne `--output-last-message`-Vertrag wird er fail-closed ausgeschlossen. Inaktive `language_server*`-Prozesse mit totem Parent bleiben Bereinigungsziele. -- Der Codex-Desktop-App-Server, fremde Kindprozesse, aktive Codex-CLI-Arbeit und aktive Desktop-Arbeit sind von Prozessbeendigungen ausgeschlossen. Der breite read-only Detektor behandelt Desktop- und npm-CLI-Arbeit dennoch als Blocker für Thread-Store-Mutationen. +- Die Runtime-MCP-Kandidatenerkennung behält immer den neuesten Start-Cohort und überspringt Kandidaten, deren CPU-Zähler noch steigen; ein lebender Parent ist ein unbedingtes Nicht-Kill-Kriterium. +- Die Runtime-Waisenbereinigung verlangt einen toten Parent sowie Alters- und CPU-Leerlaufbelege. Ein abgelöster `codex exec` bleibt zusätzlich ausgeschlossen, solange ein CPU-, Rollout- oder ausstehendes Output-Lebenszeichen vorliegt; ohne `--output-last-message`-Vertrag wird er fail-closed ausgeschlossen. Inaktive MCP-Nodes und gängige Language-Server mit totem Parent bleiben Bereinigungsziele; die letzte bekannte Parent-Identität landet im Audit-Log. +- Der Codex-Desktop-App-Server, fremde Kindprozesse, aktive Codex-CLI-Arbeit und aktive Desktop-Arbeit sind von Prozessbeendigungen ausgeschlossen. Der breite read-only Detektor behandelt Desktop- und npm-CLI-Aktivität dennoch als Blocker für Thread-Store-Mutationen. - Destruktive Pfade wie Store-Reset, Admin-Reparatur, Neuinstallation und Reboot sind Vorschläge oder ausdrückliche Nutzeraktionen, keine automatischen Überraschungen. - Der [CareCenter-Gesundheitsaustauschvertrag v1](CARE_CENTER_EXCHANGE_CONTRACT.md) definiert einen datensparsamen, ausschließlich lesenden Schnappschuss für @@ -441,7 +448,7 @@ CareCenter for Codex gewährleistet vollständige Lizenztransparenz und strikte - **Hauptanwendung:** Lizenziert unter der freien und permissiven [MIT-Lizenz](LICENSE). - **GUI-Subsystem:** Entwickelt mit **PySide6** (`>=6.7`), dynamisch eingebunden unter vollständiger Einhaltung der **GNU Lesser General Public License v3 (LGPL-3.0-only)**. Es werden keine Qt6/PySide6-Quelltexte modifiziert oder in proprietärer Form verteilt. Nutzer behalten die Freiheit, die installierten PySide6-Laufzeitbibliotheken zu ersetzen oder neu zu binden. - **Konfigurations-Engine:** Basiert auf **tomlkit** unter der **MIT-Lizenz**. -- **Build- & Integrationswerkzeuge:** Safe-Start-Integration (`safe-start-for-codex`, MIT), PyInstaller-Kompilierung (GPLv2 mit PyInstaller-Ausnahme) und Hatchling (MIT). +- **Build- & Integrationswerkzeuge:** Safe-Start-Integration (`safe-start-for-codex`, MIT), zombie-killer-tray-Integration (`zombie-killer-tray`, MIT), PyInstaller-Kompilierung (GPLv2 mit PyInstaller-Ausnahme) und Hatchling (MIT). - **Audit- & Invarianten-Dokumentation:** Ein detaillierter Prüfbericht aller Laufzeit-, Entwicklungs- und Standardbibliotheks-Abhängigkeiten sowie der 10 Governance- und Sicherheits-Laufzeitinvarianten ist in [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) dokumentiert. Das historische Textformat wird in [THIRD_PARTY_LICENSES.txt](THIRD_PARTY_LICENSES.txt) weitergeführt. diff --git a/README.md b/README.md index 3fa1a21..7625167 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ The following matrix compares CareCenter for Codex against alternative operation ## Features -- Background watcher: checks every 60 seconds for old start blockers, detached runtime orphans, and duplicate runtime MCP process generations. Runtime-orphan cleanup requires a dead parent, a hard 30-minute grace period, and two CPU snapshots. Detached `codex exec` runs remain protected while CPU advances, a session rollout is newer than two minutes, or their `--output-last-message` target is still missing; inactive `language_server*` orphans remain eligible. Every successful orphan kill records PID, command line, and criterion in `app.log`. Runtime MCP cleanup still targets only idle launcher trees repeated under the same Store desktop app-server and always keeps the newest launch cohort. +- Background watcher: checks every 60 seconds for old start blockers, detached runtime orphans, and duplicate runtime MCP process generations. Runtime-orphan cleanup requires a dead parent, a hard 30-minute grace period, and two CPU snapshots. Allowlisted MCP nodes and common language servers are covered; detached `codex exec` runs remain protected while CPU advances, a session rollout is newer than two minutes, or their `--output-last-message` target is still missing. Every successful kill uses the complete process tree (`taskkill /T`) and records the child plus the last known parent identity and criterion in `app.log`. Duplicate signatures are candidate evidence only: a live parent means an active cohort and is never killed. - Tray settings with language switching: choose English or German in the Settings area. The choice is saved in `config.json` and the visible tray UI is relabeled immediately. - Tray automation controls: pause all currently active Codex automations, restore only automations disabled by CCC, or turn automations back on immediately or gradually. The spacing is configurable via `automation_stagger_delay_seconds` (default: 60 seconds). - Thread inbox hygiene: mark every result as read, mark only unread threads older than a configurable number of days, and automatically archive threads older than a separate configurable age. Empty-thread auto-fix waits at least 300 seconds so new CLI/Desktop threads can finish their first write. Current Codex thread ages and archive flags come from `state_5.sqlite`; unread IDs come from `.codex-global-state.json`. Changes are blocked by either Desktop or npm Codex CLI activity, rechecked immediately before backup/move, and use database/state backups, atomic JSON writes, and transactional archive updates. @@ -322,6 +322,8 @@ $env:CARECENTER_SAFE_START_SOURCE = "C:\path\to\REL-PUB_safe-start-for-codex" build_exe.bat ``` +The same pattern applies to the optional [zombie-killer-tray](https://github.com/dev-bricks/zombie-killer-tray) integration (conservative cleanup of orphaned MCP and language-server processes, launched as its own subprocess via `zombie-killer-watch`): pinned to an exact commit by default, overridable for a local sibling checkout via `$env:CARECENTER_ZOMBIE_KILLER_SOURCE`. + ## CLI Usage @@ -341,6 +343,10 @@ python -m codex_logdatenbank_wartung.cli store-repair --level repair --execute python -m codex_logdatenbank_wartung.cli store-materials python -m codex_logdatenbank_wartung.cli safe-start-report python -m codex_logdatenbank_wartung.cli safe-start-install +python -m codex_logdatenbank_wartung.cli zombie-killer-report +python -m codex_logdatenbank_wartung.cli zombie-killer-install +python -m codex_logdatenbank_wartung.cli zombie-killer-watch +python -m codex_logdatenbank_wartung.cli zombie-killer-stop python -m codex_logdatenbank_wartung.cli schedule install --interval-minutes 180 ``` @@ -369,11 +375,13 @@ database: %USERPROFILE%\.codex\logs_2.sqlite Codex paths are detected from `%LOCALAPPDATA%`, `%APPDATA%`, and `CODEX_HOME`. New installs also place CareCenter data under `%LOCALAPPDATA%\CareCenterForCodex` by default. Existing local setups under `C:\_Local_DEV\codex-maintenance\` are reused automatically as a legacy fallback. You can override every path in `config.json`. -Runtime MCP cleanup is enabled by default through `reap_runtime_mcp_duplicates`. -Its conservative defaults are a configurable 3600-second (one-hour) minimum age for -every candidate root, a 90-second launch-cohort -gap, a 30-second launcher window, at least two distinct repeated MCP signatures, -and a 1-second CPU activity sample. Each threshold can be overridden in `config.json`. +Runtime MCP candidate detection is enabled by default through +`reap_runtime_mcp_duplicates`. Its conservative defaults are a configurable +3600-second (one-hour) minimum age for every candidate root, a 90-second +launch-cohort gap, a 30-second launcher window, at least two distinct repeated +MCP signatures, and a 1-second CPU activity sample. Reaping additionally requires +the candidate's parent to be dead; a live Store app-server cohort is never killed. +Each threshold can be overridden in `config.json`. Runtime-orphan cleanup keeps the compatible `reap_companion_orphans` configuration prefix. `companion_orphan_min_age_seconds` has a hard 1800-second safety floor; @@ -403,8 +411,8 @@ When set, `config.json`, `logs\`, and `backups\` are placed under that path inst - Safe auto-maintain only closes Codex after the full process tree is idle. - Safe cancellation stops only the waiting phase before Codex is closed; active database operations are not force-interrupted. - The watcher kills inactive ghosts without a renderer only after the configured age threshold. -- Duplicate runtime MCP cleanup always keeps the newest launch cohort and skips candidate trees whose CPU counters still advance. -- Runtime-orphan cleanup requires a dead parent plus age and CPU-idle evidence. A detached `codex exec` is additionally excluded while any CPU, recent rollout, or pending-output signal remains; a run without an `--output-last-message` contract is excluded fail-closed. Idle dead-parent `language_server*` processes remain cleanup targets. +- Duplicate runtime MCP detection keeps the newest launch cohort and skips candidate trees whose CPU counters still advance; a live parent is an unconditional no-kill result. +- Runtime-orphan cleanup requires a dead parent plus age and CPU-idle evidence. A detached `codex exec` is additionally excluded while any CPU, recent rollout, or pending-output signal remains; a run without an `--output-last-message` contract is excluded fail-closed. Idle dead-parent MCP nodes and common language-server processes remain cleanup targets, with the last known parent written to the audit log. - The Codex desktop app-server, unrelated child processes, active Codex CLI work, and active desktop work are excluded from process termination. The broad read-only detector still treats Desktop and npm CLI activity as a blocker for thread-store mutation. - Destructive paths such as Store reset, admin repair, reinstall, and reboot are suggestions or explicit user actions, not automatic surprises. - The [CareCenter Health Exchange Contract v1](CARE_CENTER_EXCHANGE_CONTRACT.md) @@ -467,7 +475,7 @@ CareCenter for Codex maintains strict license transparency and distribution comp - **Core Application:** Licensed under the permissive [MIT License](LICENSE). - **GUI Subsystem:** Powered by **PySide6** (`>=6.7`), dynamically linked in full compliance with the **GNU Lesser General Public License v3 (LGPL-3.0-only)**. No Qt6/PySide6 source code is modified or redistributed in proprietary form. Users retain the freedom to relink or replace the installed PySide6 runtime wheels. - **Configuration Engine:** Built on **tomlkit** under the **MIT License**. -- **Build & Integration Tooling:** Safe Start integration (`safe-start-for-codex`, MIT), PyInstaller packaging (GPLv2 with PyInstaller Exception), and Hatchling (MIT). +- **Build & Integration Tooling:** Safe Start integration (`safe-start-for-codex`, MIT), zombie-killer-tray integration (`zombie-killer-tray`, MIT), PyInstaller packaging (GPLv2 with PyInstaller Exception), and Hatchling (MIT). - **Audit & Invariants Document:** A comprehensive audit of all runtime, development, standard library dependencies, and the 10 Governance & Runtime Safety Invariants is maintained in [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md). Legacy text format is preserved in [THIRD_PARTY_LICENSES.txt](THIRD_PARTY_LICENSES.txt). diff --git a/README_de.md b/README_de.md index cf0f073..95110f2 100644 --- a/README_de.md +++ b/README_de.md @@ -236,7 +236,7 @@ Die folgende Matrix vergleicht CareCenter for Codex mit alternativen Betriebsans ## Funktionen -- Hintergrund-Wächter: prüft alle 60 Sekunden auf alte Startblocker, abgelöste Runtime-Waisen und doppelte Runtime-MCP-Prozessgenerationen. Die Waisenbereinigung verlangt einen toten Parent, eine feste Karenzzeit von 30 Minuten und zwei CPU-Messpunkte. Abgelöste `codex exec`-Läufe bleiben geschützt, solange CPU-Zeit wächst, ein Session-Rollout jünger als zwei Minuten ist oder ihr `--output-last-message`-Ziel noch fehlt; inaktive `language_server*`-Waisen bleiben bereinigungsfähig. Jeder erfolgreiche Waisen-Kill schreibt PID, Commandline und Kriterium in `app.log`. Die Runtime-MCP-Bereinigung erfasst weiterhin nur inaktive Launcher-Bäume unter demselben Store-Desktop-App-Server und behält immer den neuesten Start-Cohort. +- Hintergrund-Wächter: prüft alle 60 Sekunden auf alte Startblocker, abgelöste Runtime-Waisen und doppelte Runtime-MCP-Prozessgenerationen. Die Waisenbereinigung verlangt einen toten Parent, eine feste Karenzzeit von 30 Minuten und zwei CPU-Messpunkte. Allowlist-basierte MCP-Nodes und gängige Language-Server werden erfasst. Abgelöste `codex exec`-Läufe bleiben geschützt, solange CPU-Zeit wächst, ein Session-Rollout jünger als zwei Minuten ist oder ihr `--output-last-message`-Ziel noch fehlt. Jeder erfolgreiche Kill beendet den vollständigen Prozessbaum (`taskkill /T`) und schreibt Kind, letzte bekannte Parent-Identität und Kriterium in `app.log`. Eine lebende Parent-Beziehung bedeutet aktive Cohort und ist immer ein Nicht-Kill. - Spracheinstellung im Tray: Im Bereich Einstellungen kann zwischen Deutsch und Englisch gewechselt werden. Die Auswahl wird in `config.json` gespeichert und die sichtbare Tray-Oberfläche wird sofort neu beschriftet. - Automatisierungssteuerung im Tray: alle aktuell aktiven Codex-Automatisierungen ausschalten, nur von CCC ausgeschaltete Automatisierungen wieder aktivieren oder Automatisierungen sofort beziehungsweise gestaffelt nacheinander einschalten. Der Abstand ist über `automation_stagger_delay_seconds` konfigurierbar (Standard: 60 Sekunden). - Thread-Postfachpflege: alle als gelesen markieren, ungelesene Threads älter als X Tage markieren und Threads nach einem getrennt einstellbaren Alter automatisch archivieren. Der Empty-Thread-Autofix wartet mindestens 300 Sekunden, damit neue CLI-/Desktop-Threads ihren ersten Schreibvorgang abschließen können. Änderungen werden bei Desktop- oder npm-Codex-CLI-Aktivität blockiert, unmittelbar vor Backup/Move erneut geprüft und nur mit Backups, atomarem State-Schreiben und transaktionaler Archivierung ausgeführt. @@ -309,6 +309,8 @@ $env:CARECENTER_SAFE_START_SOURCE = "C:\Pfad\zu\REL-PUB_safe-start-for-codex" build_exe.bat ``` +Dasselbe Muster gilt für die optionale [zombie-killer-tray](https://github.com/dev-bricks/zombie-killer-tray)-Integration (konservative Bereinigung verwaister MCP- und Language-Server-Prozesse, gestartet als eigener Subprozess über `zombie-killer-watch`): standardmäßig auf einen exakten Commit gepinnt, überschreibbar für einen lokalen Schwester-Checkout via `$env:CARECENTER_ZOMBIE_KILLER_SOURCE`. + ## CLI-Befehle @@ -328,6 +330,10 @@ python -m codex_logdatenbank_wartung.cli store-repair --level repair --execute python -m codex_logdatenbank_wartung.cli store-materials python -m codex_logdatenbank_wartung.cli safe-start-report python -m codex_logdatenbank_wartung.cli safe-start-install +python -m codex_logdatenbank_wartung.cli zombie-killer-report +python -m codex_logdatenbank_wartung.cli zombie-killer-install +python -m codex_logdatenbank_wartung.cli zombie-killer-watch +python -m codex_logdatenbank_wartung.cli zombie-killer-stop python -m codex_logdatenbank_wartung.cli schedule install --interval-minutes 180 ``` @@ -353,12 +359,13 @@ database: %USERPROFILE%\.codex\logs_2.sqlite Codex-Pfade werden aus `%LOCALAPPDATA%`, `%APPDATA%` und `CODEX_HOME` erkannt. Neue Installationen legen auch die CareCenter-Daten standardmäßig unter `%LOCALAPPDATA%\CareCenterForCodex` ab. Bestehende lokale Setups unter `C:\_Local_DEV\codex-maintenance\` werden als Legacy-Fallback automatisch weiterverwendet. Alle Pfade lassen sich in `config.json` überschreiben. -Die Runtime-MCP-Bereinigung ist über `reap_runtime_mcp_duplicates` standardmäßig -aktiv. Ihre konservativen Vorgaben sind ein konfigurierbares Mindestalter von 3600 Sekunden -(einer Stunde) für jeden Kandidaten-Root, 90 Sekunden Start-Cohort-Abstand, ein -30-Sekunden-Launcherfenster, mindestens zwei verschiedene -wiederholte MCP-Signierung und eine Sekunde CPU-Aktivitätsmessung. Alle Schwellen -lassen sich in `config.json` anpassen. +Die Runtime-MCP-Kandidatenerkennung ist über `reap_runtime_mcp_duplicates` +standardmäßig aktiv. Ihre konservativen Vorgaben sind ein konfigurierbares +Mindestalter von 3600 Sekunden (einer Stunde) für jeden Kandidaten-Root, 90 +Sekunden Start-Cohort-Abstand, ein 30-Sekunden-Launcherfenster, mindestens zwei +verschiedene wiederholte MCP-Signaturen und eine Sekunde CPU-Aktivitätsmessung. +Für einen Kill muss zusätzlich der Parent tot sein; eine aktive Store-App-Server- +Cohort wird nie beendet. Alle Schwellen lassen sich in `config.json` anpassen. Die Runtime-Waisenbereinigung behält aus Kompatibilitätsgründen das Präfix `reap_companion_orphans`. Für `companion_orphan_min_age_seconds` gilt eine feste @@ -382,9 +389,9 @@ größere Werte verlängern sie. - Safe Auto-Maintain schließt Codex erst, wenn der gesamte Prozessbaum im Leerlauf ist. - Der Safe-Abbruch stoppt nur das Warten vor dem Schließen von Codex; laufende Datenbankoperationen werden nicht hart unterbrochen. - Der Wächter beendet inaktive Ghosts ohne Renderer nur nach der konfigurierten Altersschwelle. -- Die Runtime-MCP-Bereinigung behält immer den neuesten Start-Cohort und überspringt Kandidaten, deren CPU-Zähler noch steigen. -- Die Runtime-Waisenbereinigung verlangt einen toten Parent sowie Alters- und CPU-Leerlaufbelege. Ein abgelöster `codex exec` bleibt zusätzlich ausgeschlossen, solange ein CPU-, Rollout- oder ausstehendes Output-Lebenszeichen vorliegt; ohne `--output-last-message`-Vertrag wird er fail-closed ausgeschlossen. Inaktive `language_server*`-Prozesse mit totem Parent bleiben Bereinigungsziele. -- Der Codex-Desktop-App-Server, fremde Kindprozesse, aktive Codex-CLI-Arbeit und aktive Desktop-Arbeit sind von Prozessbeendigungen ausgeschlossen. Der breite read-only Detektor behandelt Desktop- und npm-CLI-Arbeit dennoch als Blocker für Thread-Store-Mutationen. +- Die Runtime-MCP-Kandidatenerkennung behält immer den neuesten Start-Cohort und überspringt Kandidaten, deren CPU-Zähler noch steigen; ein lebender Parent ist ein unbedingtes Nicht-Kill-Kriterium. +- Die Runtime-Waisenbereinigung verlangt einen toten Parent sowie Alters- und CPU-Leerlaufbelege. Ein abgelöster `codex exec` bleibt zusätzlich ausgeschlossen, solange ein CPU-, Rollout- oder ausstehendes Output-Lebenszeichen vorliegt; ohne `--output-last-message`-Vertrag wird er fail-closed ausgeschlossen. Inaktive MCP-Nodes und gängige Language-Server mit totem Parent bleiben Bereinigungsziele; die letzte bekannte Parent-Identität landet im Audit-Log. +- Der Codex-Desktop-App-Server, fremde Kindprozesse, aktive Codex-CLI-Arbeit und aktive Desktop-Arbeit sind von Prozessbeendigungen ausgeschlossen. Der breite read-only Detektor behandelt Desktop- und npm-CLI-Aktivität dennoch als Blocker für Thread-Store-Mutationen. - Destruktive Pfade wie Store-Reset, Admin-Reparatur, Neuinstallation und Reboot sind Vorschläge oder ausdrückliche Nutzeraktionen, keine automatischen Überraschungen. - Der [CareCenter-Gesundheitsaustauschvertrag v1](CARE_CENTER_EXCHANGE_CONTRACT.md) definiert einen datensparsamen, ausschließlich lesenden Schnappschuss für @@ -441,7 +448,7 @@ CareCenter for Codex gewährleistet vollständige Lizenztransparenz und strikte - **Hauptanwendung:** Lizenziert unter der freien und permissiven [MIT-Lizenz](LICENSE). - **GUI-Subsystem:** Entwickelt mit **PySide6** (`>=6.7`), dynamisch eingebunden unter vollständiger Einhaltung der **GNU Lesser General Public License v3 (LGPL-3.0-only)**. Es werden keine Qt6/PySide6-Quelltexte modifiziert oder in proprietärer Form verteilt. Nutzer behalten die Freiheit, die installierten PySide6-Laufzeitbibliotheken zu ersetzen oder neu zu binden. - **Konfigurations-Engine:** Basiert auf **tomlkit** unter der **MIT-Lizenz**. -- **Build- & Integrationswerkzeuge:** Safe-Start-Integration (`safe-start-for-codex`, MIT), PyInstaller-Kompilierung (GPLv2 mit PyInstaller-Ausnahme) und Hatchling (MIT). +- **Build- & Integrationswerkzeuge:** Safe-Start-Integration (`safe-start-for-codex`, MIT), zombie-killer-tray-Integration (`zombie-killer-tray`, MIT), PyInstaller-Kompilierung (GPLv2 mit PyInstaller-Ausnahme) und Hatchling (MIT). - **Audit- & Invarianten-Dokumentation:** Ein detaillierter Prüfbericht aller Laufzeit-, Entwicklungs- und Standardbibliotheks-Abhängigkeiten sowie der 10 Governance- und Sicherheits-Laufzeitinvarianten ist in [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) dokumentiert. Das historische Textformat wird in [THIRD_PARTY_LICENSES.txt](THIRD_PARTY_LICENSES.txt) weitergeführt. diff --git a/THIRD_PARTY_LICENSES.txt b/THIRD_PARTY_LICENSES.txt index 7b5bc7d..96abc20 100644 --- a/THIRD_PARTY_LICENSES.txt +++ b/THIRD_PARTY_LICENSES.txt @@ -35,6 +35,8 @@ CareCenter for Codex is licensed under the MIT License. See `LICENSE` and `NOTIC | Package | Declared use | Constraint / revision | Version checked | License metadata | Source | |---|---|---:|---:|---|---| | safe-start-for-codex | Optional integration and EXE build | commit `dcb369a64f403f6551bcb3bac16565c56ec79474` | 1.1.3 | MIT | https://github.com/dev-bricks/safe-start-for-codex | +| zombie-killer-tray | Optional integration and EXE build | commit `039b4f2c7acd69063b39d6168c757b0b2fca2438` | 0.1.0 | MIT | https://github.com/dev-bricks/zombie-killer-tray | +| psutil | Watcher PID/create_time verification (declared explicitly, not just transitive via zombie-killer-tray) | `>=7.2,<8` | 7.2.0 | BSD-3-Clause | https://pypi.org/project/psutil/ | | PyInstaller | Optional Windows EXE build | `>=6.10.0` | 6.21.0 | GPLv2-or-later with PyInstaller's special exception | https://pypi.org/project/pyinstaller/ | | altgraph | Graph module for PyInstaller | `>=0.17.4` | 0.17.4 | MIT | https://pypi.org/project/altgraph/ | | packaging | Version handling for build | `>=24.0` | 24.2 | Apache-2.0 OR BSD-2-Clause | https://pypi.org/project/packaging/ | @@ -91,6 +93,12 @@ SPDX: MIT URL: https://github.com/dev-bricks/safe-start-for-codex Notice: Pinned integration for safe startup gating, burst protection, and desktop process pacing. +zombie-killer-tray +License: MIT +SPDX: MIT +URL: https://github.com/dev-bricks/zombie-killer-tray +Notice: Pinned integration for conservative cleanup of orphaned MCP and language-server processes, launched as an independent watch subprocess. + PyInstaller License: GPL-2.0-or-later WITH Bootloader-exception SPDX: GPL-2.0-or-later WITH Bootloader-exception diff --git a/llms.txt b/llms.txt index 0046b88..2751b82 100644 --- a/llms.txt +++ b/llms.txt @@ -77,6 +77,7 @@ Environment variables: Verification: - python -m compileall -q src tests +- python -m ruff check src tests - python -m pytest -q - Current local verification: 414 collected tests (413 passed, 1 skipped) pass on Python 3.12 (2026-09-21) @@ -92,5 +93,3 @@ Public documentation: - THIRD_PARTY_LICENSES.txt: legacy text license inventory - STORE_LISTING.md, PRIVACY_POLICY.md, SUPPORT.md: Store release materials - SECURITY.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md: community health files - -Last-checked: 2026-09-21 diff --git a/pyproject.toml b/pyproject.toml index ab8f3ff..755f009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,12 @@ dev = [ ] build = [ "safe-start-for-codex @ git+https://github.com/dev-bricks/safe-start-for-codex.git@dcb369a64f403f6551bcb3bac16565c56ec79474", + # T-20260926-212716751: zombie-killer-tray#4 (src/-Paketierung) merged as 039b4f2. + "zombie-killer-tray @ git+https://github.com/dev-bricks/zombie-killer-tray.git@039b4f2c7acd69063b39d6168c757b0b2fca2438", + # Review finding: declared explicitly (not just hoped-for transitively via + # zombie-killer-tray) -- stop_zombie_killer_watch()/launch's double-start + # guard fail-closed without it, so it must not be an implicit assumption. + "psutil>=7.2,<8", "pyinstaller>=6.10.0", "altgraph>=0.17.4", "packaging>=24.0", diff --git a/src/codex_logdatenbank_wartung/cli.py b/src/codex_logdatenbank_wartung/cli.py index 0c993a9..bdec241 100644 --- a/src/codex_logdatenbank_wartung/cli.py +++ b/src/codex_logdatenbank_wartung/cli.py @@ -283,6 +283,65 @@ def cmd_safe_start_install(args: argparse.Namespace) -> int: return 0 if result.status == "ok" else 1 +def cmd_zombie_killer_report(args: argparse.Namespace) -> int: + import json as _json + + from .zombie_killer_integration import build_zombie_killer_status + + config = load_config(args) + status = build_zombie_killer_status(config) + if args.json: + print(_json.dumps(status.to_dict(), ensure_ascii=False, indent=2)) + else: + print(status.to_text()) + return 0 + + +def cmd_zombie_killer_install(args: argparse.Namespace) -> int: + import json as _json + + from .zombie_killer_integration import install_zombie_killer_package + + result = install_zombie_killer_package(target=args.target) + if args.json: + print(_json.dumps(result.to_dict(), ensure_ascii=False, indent=2)) + else: + print(result.to_text()) + return 0 if result.status == "ok" else 1 + + +def cmd_zombie_killer_watch(args: argparse.Namespace) -> int: + import json as _json + + from .zombie_killer_integration import launch_zombie_killer_watch + + config = load_config(args) + result = launch_zombie_killer_watch( + config, + interval_seconds=args.interval, + min_age_seconds=args.min_age, + ) + if args.json: + print(_json.dumps(result.to_dict(), ensure_ascii=False, indent=2)) + else: + print(result.to_text()) + return 0 if result.status == "ok" else 1 + + +def cmd_zombie_killer_stop(args: argparse.Namespace) -> int: + import json as _json + + from .zombie_killer_integration import stop_zombie_killer_watch + + config = load_config(args) + result = stop_zombie_killer_watch(config) + if args.json: + print(_json.dumps(result.to_dict(), ensure_ascii=False, indent=2)) + else: + print(result.to_text()) + return 0 if result.status in ("ok", "not-running") else 1 + + def cmd_mark_runs_read(args: argparse.Namespace) -> int: from .thread_hygiene import maintain_threads @@ -549,6 +608,51 @@ def build_parser() -> argparse.ArgumentParser: ) safe_start_install_parser.set_defaults(func=cmd_safe_start_install) + zombie_killer_parser = subparsers.add_parser( + "zombie-killer-report", + help="zombie-killer-tray-Status prüfen (letzter Bereinigungszyklus).", + ) + zombie_killer_parser.add_argument("--json", action="store_true", help="Status als JSON ausgeben.") + zombie_killer_parser.set_defaults(func=cmd_zombie_killer_report) + + zombie_killer_install_parser = subparsers.add_parser( + "zombie-killer-install", + help="zombie-killer-tray installieren oder aktualisieren.", + ) + zombie_killer_install_parser.add_argument( + "--target", + default=None, + help="Optionales pip-Ziel. Ohne Angabe: lokale Schwesterquelle, sonst commit-gepinnte GitHub-Quelle.", + ) + zombie_killer_install_parser.add_argument( + "--json", action="store_true", help="Ergebnis als JSON ausgeben." + ) + zombie_killer_install_parser.set_defaults(func=cmd_zombie_killer_install) + + zombie_killer_watch_parser = subparsers.add_parser( + "zombie-killer-watch", + help="zombie-killer-tray als eigenen Watch-Subprozess starten (verwaiste MCP-/Language-Server-Prozesse).", + ) + zombie_killer_watch_parser.add_argument( + "--interval", type=int, default=None, + help="Prüfintervall in Sekunden (Default: config.zombie_killer_watch_interval_seconds).", + ) + zombie_killer_watch_parser.add_argument( + "--min-age", type=int, default=None, + help="Mindestalter in Sekunden, bevor ein Kandidat beendet wird (Default: config.zombie_killer_min_age_seconds).", + ) + zombie_killer_watch_parser.add_argument( + "--json", action="store_true", help="Ergebnis als JSON ausgeben." + ) + zombie_killer_watch_parser.set_defaults(func=cmd_zombie_killer_watch) + + zombie_killer_stop_parser = subparsers.add_parser( + "zombie-killer-stop", + help="Stop a zombie-killer-tray watch subprocess started via zombie-killer-watch.", + ) + zombie_killer_stop_parser.add_argument("--json", action="store_true") + zombie_killer_stop_parser.set_defaults(func=cmd_zombie_killer_stop) + mark_runs_parser = subparsers.add_parser( "mark-runs-read", help="Automations-Ergebnisse als gelesen markieren (Ungelesen-Zähler leeren). " diff --git a/src/codex_logdatenbank_wartung/config.py b/src/codex_logdatenbank_wartung/config.py index 129a3b1..0330891 100644 --- a/src/codex_logdatenbank_wartung/config.py +++ b/src/codex_logdatenbank_wartung/config.py @@ -166,6 +166,14 @@ class MaintenanceConfig: safe_start_catchup_min_period_hours: int = 24 safe_start_storm_window_minutes: int = 10 safe_start_storm_release_threshold: int = 3 + # zombie-killer-tray bleibt ebenfalls ein eigenständiges Werkzeug -- läuft als + # eigener Subprozess (T-20260926-212716751), nicht als In-Process-Import. + # Deckt verwaiste MCP-/Language-Server-Prozesse produktübergreifend ab; der + # bestehende Runtime-MCP-Reaper (processes.py/watchdog.py) bleibt unverändert + # auf Codex-Companion-Prozesse fokussiert -- beide ergänzen sich, keine Regel + # wird dupliziert. + zombie_killer_watch_interval_seconds: int = 600 + zombie_killer_min_age_seconds: int = 1800 # Abstand fuer CareCenter-eigene gestaffelte Automations-Freigaben. # Safe Start selbst nutzt seine eigene config.json (Default dort: 3 sofort, dann 5 Minuten). automation_stagger_delay_seconds: int = 60 @@ -327,6 +335,17 @@ def safe_start_config_file(self) -> Path: """Pfad zur Safe-Start-Konfiguration.""" return Path(self.safe_start_config_path).expanduser() + @property + def zombie_killer_state_dir(self) -> Path: + """zombie-killer-tray-Arbeitsverzeichnis unterhalb von CODEX_HOME. + + zombie-killer-tray schreibt seinen Laufzeitzustand (`zombie_events.jsonl`, + `zombie_worker_errors.log`) in sein aktuelles Arbeitsverzeichnis, nicht an + einen fest codierten Pfad -- dieser Ordner wird als `cwd` an den + Subprozess übergeben (siehe `launch_zombie_killer_watch`). + """ + return self.codex_home / "zombie-killer-tray" + @property def config_toml_path(self) -> Path: """Pfad zu config.toml (MCP-Server, Plugins, Einstellungen).""" diff --git a/src/codex_logdatenbank_wartung/processes.py b/src/codex_logdatenbank_wartung/processes.py index 3cdaa4f..f817ec3 100644 --- a/src/codex_logdatenbank_wartung/processes.py +++ b/src/codex_logdatenbank_wartung/processes.py @@ -239,7 +239,45 @@ def find_codex_processes_by_executable( _LANGUAGE_SERVER_NAME_PATTERN = re.compile( r"^language_server(?:[._-]|$)", re.IGNORECASE ) -RuntimeOrphanKind = Literal["companion_app_server", "language_server", "codex_exec"] +_LANGUAGE_SERVER_MARKERS = ( + "typescript-language-server", + "tsserver", + "pyright-langserver", + "pylsp", + "jedi-language-server", + "rust-analyzer", + "clangd", + "gopls", + "yaml-language-server", + "bash-language-server", + "vscode-json-languageserver", + "lua-language-server", + "sourcekit-lsp", + "jdtls", + "zls", +) +_MCP_SERVER_MARKERS = ( + "code-assist-mcp", + "@modelcontextprotocol", + "model-context-protocol", + "mcp-server", + "mcp_server", +) +# Review finding (T-20260926-212716751): these broad separator+"mcp" markers +# used to be plain substrings, so "acme_mcpayments.exe" (an unrelated payments +# tool) or "battle-mcp-launcher.exe" (a game) also matched "_mcp"/"-mcp" +# anywhere in the name. Requiring "mcp" to end at a separator or the string's +# end -- matching real names like "...-mcp", "...-mcp.exe", "mcp server.log" +# -- keeps the intended catch-all for ad-hoc "-mcp" server binaries +# without matching "mcp" as a mid-word fragment of something else. +_MCP_BROAD_TOKEN_PATTERN = re.compile(r"[-_ ]mcp(?:[/\\. ]|$)") +RuntimeOrphanKind = Literal[ + "companion_app_server", "language_server", "mcp_server", "codex_exec" +] + +# Last known identity is used only for audit output after a parent disappears; +# it never makes a process eligible for termination. +_PARENT_HISTORY: dict[int, tuple[ProcessInfo, str]] = {} def _is_companion_app_server(process: ProcessInfo) -> bool: @@ -256,11 +294,18 @@ def _is_companion_app_server(process: ProcessInfo) -> bool: def runtime_orphan_kind(process: ProcessInfo) -> RuntimeOrphanKind | None: - """Klassifiziere nur die eng definierten Runtime-Waisen-Zieltypen.""" + """Klassifiziere nur allowlist-basierte Runtime-Waisen-Zieltypen.""" if _is_companion_app_server(process): return "companion_app_server" - if _LANGUAGE_SERVER_NAME_PATTERN.match(process.name): + haystack = f"{process.executable} {process.command_line}".lower() + if _LANGUAGE_SERVER_NAME_PATTERN.match(process.name) or any( + marker in haystack for marker in _LANGUAGE_SERVER_MARKERS + ): return "language_server" + if any(marker in haystack for marker in _MCP_SERVER_MARKERS) or _MCP_BROAD_TOKEN_PATTERN.search( + haystack + ): + return "mcp_server" if process.name.lower() == "codex.exe" and _CODEX_EXEC_PATTERN.search( process.command_line ): @@ -303,6 +348,16 @@ def _parent_is_dead( return False +def parent_is_dead(process: ProcessInfo, processes_by_pid: dict[int, ProcessInfo]) -> bool: + """Public safety predicate shared by every mutating reaper.""" + return _parent_is_dead(process, processes_by_pid) + + +def parent_snapshot(parent_pid: int) -> tuple[ProcessInfo, str] | None: + """Return the last observed parent identity for audit purposes only.""" + return _PARENT_HISTORY.get(parent_pid) + + def is_companion_orphan(process: ProcessInfo, *, min_age_seconds: int = 300) -> bool: """Erkennt verwaiste Companion-app-server-Prozesse (codex-plugin-cc #277). @@ -332,6 +387,9 @@ def find_companion_orphans( """ provider = provider or windows_processes processes = provider() + observed_at = datetime.now().isoformat(timespec="seconds") + for process in processes: + _PARENT_HISTORY[process.pid] = (process, observed_at) processes_by_pid = {process.pid: process for process in processes} return [ process diff --git a/src/codex_logdatenbank_wartung/watchdog.py b/src/codex_logdatenbank_wartung/watchdog.py index 0463852..267d03e 100644 --- a/src/codex_logdatenbank_wartung/watchdog.py +++ b/src/codex_logdatenbank_wartung/watchdog.py @@ -44,6 +44,8 @@ ProcessProvider, find_companion_orphans, find_runtime_mcp_duplicate_roots, + parent_is_dead, + parent_snapshot, runtime_orphan_kind, tree_pids, windows_processes, @@ -109,10 +111,18 @@ def _has_recent_session_rollout( def _log_runtime_orphan_kill(process: ProcessInfo, criterion: str) -> None: command_line = " ".join(process.command_line.splitlines()) + parent = parent_snapshot(process.parent_pid) + parent_info = parent[0] if parent else None + parent_seen = parent[1] if parent else None _LOGGER.warning( - "orphan_kill pid=%d command_line=%r criterion=%s", + "orphan_kill pid=%d command_line=%r parent_pid=%d parent_name=%r " + "parent_command_line=%r parent_last_seen=%r criterion=%s", process.pid, command_line, + process.parent_pid, + parent_info.name if parent_info else None, + " ".join(parent_info.command_line.splitlines()) if parent_info else None, + parent_seen, criterion, ) @@ -248,6 +258,14 @@ def reap_runtime_orphans( ok, _ = killer(orphan.pid) else: try: + # Review finding (T-20260926-212716751): NOT a tree-kill. Every + # PID reaped here was individually vetted (dead parent, min + # age, two idle CPU snapshots) -- a descendant was never + # checked against those same criteria, so killing its whole + # subtree would terminate processes no criterion actually + # covers. A genuinely orphaned descendant becomes reap-able + # on its own in a later cycle once IT independently satisfies + # dead-parent/age/CPU-idle. subprocess.run( ["taskkill", "/F", "/PID", str(orphan.pid)], check=True, @@ -316,6 +334,10 @@ def reap_runtime_mcp_duplicates( config, "runtime_mcp_min_matching_roots", 2 ), ) + # Duplicate signatures are only a read-only candidate signal. A live + # app-server parent is an active cohort, not a zombie; never kill it here. + initial_by_pid = {process.pid: process for process in initial_processes} + roots = [root for root in roots if parent_is_dead(root, initial_by_pid)] if not roots or not execute: return len(roots) @@ -355,6 +377,7 @@ def reap_runtime_mcp_duplicates( for root in roots: ok, _ = killer(root.pid) if ok: + _log_runtime_orphan_kill(root, "kind=mcp_server,parent=dead,duplicate-cohort") reaped += 1 return reaped @@ -367,7 +390,10 @@ def kill_tree(root: ProcessInfo) -> int: timeout=15, **no_window_kwargs(), ) - return int(completed.returncode == 0) + ok = completed.returncode == 0 + if ok: + _log_runtime_orphan_kill(root, "kind=mcp_server,parent=dead,duplicate-cohort") + return int(ok) except (OSError, subprocess.TimeoutExpired): return 0 diff --git a/src/codex_logdatenbank_wartung/zombie_killer_integration.py b/src/codex_logdatenbank_wartung/zombie_killer_integration.py new file mode 100644 index 0000000..923e8c1 --- /dev/null +++ b/src/codex_logdatenbank_wartung/zombie_killer_integration.py @@ -0,0 +1,533 @@ +"""Optionale zombie-killer-tray-Integration für CareCenter. + +CareCenter's eigener Runtime-MCP-Reaper (`processes.py`/`watchdog.py`) bleibt +unverändert und zielt gezielt auf Codex-Companion-Prozesse; zombie-killer-tray +deckt den breiteren, produktübergreifenden Fall ab (verwaiste MCP- und +Language-Server-Prozesse beliebiger Agenten-Frameworks, nicht nur Codex) und +läuft als eigener Subprozess -- exakt das Muster aus `safe_start_integration.py` +(git-gepinnte Abhängigkeit, `python -m ...`-Start, keine In-Process- +Kopplung). T-20260926-212716751 / T-20260926-368033290 (c). +""" + +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +from collections.abc import Callable +from dataclasses import asdict, dataclass +from pathlib import Path + +from .config import MaintenanceConfig + +# T-20260926-212716751: zombie-killer-tray#4 (src/-Paketierung) merged as 039b4f2. +ZOMBIE_KILLER_PACKAGE_SPEC = ( + "zombie-killer-tray @ " + "git+https://github.com/dev-bricks/zombie-killer-tray.git" + "@039b4f2c7acd69063b39d6168c757b0b2fca2438" +) +ZOMBIE_KILLER_SOURCE_ENV = "CARECENTER_ZOMBIE_KILLER_SOURCE" +CREATE_NO_WINDOW = 0x08000000 + + +@dataclass(slots=True) +class ZombieKillerInstallResult: + status: str + target: str + command: list[str] + message: str + stdout: str = "" + stderr: str = "" + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + def to_text(self) -> str: + lines = [ + f"Status: {self.status}", + f"Ziel: {self.target}", + "Befehl: " + " ".join(self.command), + self.message, + ] + if self.stdout.strip(): + lines.append("Ausgabe:") + lines.append(self.stdout.strip()) + if self.stderr.strip(): + lines.append("Fehlerausgabe:") + lines.append(self.stderr.strip()) + return "\n".join(lines) + + +@dataclass(slots=True) +class ZombieKillerLaunchResult: + status: str + command: list[str] + message: str + state_dir: str + pid: int | None = None + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + def to_text(self) -> str: + lines = [ + f"Status: {self.status}", + "Befehl: " + " ".join(self.command), + self.message, + f"Statusordner: {self.state_dir}", + ] + if self.pid is not None: + lines.append(f"PID: {self.pid}") + return "\n".join(lines) + + +@dataclass(slots=True) +class ZombieKillerStopResult: + status: str + message: str + pid: int | None = None + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + def to_text(self) -> str: + lines = [f"Status: {self.status}", self.message] + if self.pid is not None: + lines.append(f"PID: {self.pid}") + return "\n".join(lines) + + +@dataclass(slots=True) +class ZombieKillerStatus: + available: bool + state_dir: str + last_cycle_at: float | None + last_cycle_count: int | None + notes: list[str] + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + def to_text(self) -> str: + availability = "installiert" if self.available else "nicht installiert" + lines = [ + f"zombie-killer-tray: {availability}", + f"Statusordner: {self.state_dir}", + ] + if self.last_cycle_at is not None: + lines.append( + f"Letzter Zyklus: {self.last_cycle_at} (bereinigt: {self.last_cycle_count})" + ) + if self.notes: + lines.append("Hinweise:") + lines.extend(f"- {note}" for note in self.notes) + return "\n".join(lines) + + +def _no_window_kwargs() -> dict[str, object]: + if os.name == "nt": + return {"creationflags": CREATE_NO_WINDOW} + return {} + + +def _zombie_killer_importable() -> bool: + try: + __import__("zombie_killer_tray.killer") + except Exception: + return False + return True + + +def _local_zombie_killer_source() -> Path | None: + env_path = os.environ.get(ZOMBIE_KILLER_SOURCE_ENV) + if env_path: + candidate = Path(env_path).expanduser() + if (candidate / "pyproject.toml").exists(): + return candidate + + project_root = Path(__file__).resolve().parents[2] + sibling = project_root.parent / "REL-PUB_zombie-killer-tray" + if (sibling / "pyproject.toml").exists(): + return sibling + return None + + +def zombie_killer_install_target() -> str: + """Bevorzuge die lokale Schwesterquelle, sonst die commit-gepinnte GitHub-Quelle.""" + local_source = _local_zombie_killer_source() + if local_source is not None: + return str(local_source) + return ZOMBIE_KILLER_PACKAGE_SPEC + + +def _pip_command_candidates() -> list[list[str]]: + candidates: list[list[str]] = [] + if not getattr(sys, "frozen", False): + candidates.append([sys.executable, "-m", "pip"]) + candidates.extend((["py", "-3", "-m", "pip"], ["python", "-m", "pip"])) + + unique: list[list[str]] = [] + seen: set[tuple[str, ...]] = set() + for candidate in candidates: + key = tuple(candidate) + if key not in seen: + unique.append(candidate) + seen.add(key) + return unique + + +def _resolve_watch_python_executable( + *, runner: Callable[[list[str]], subprocess.CompletedProcess[str]] | None = None +) -> str | None: + """Resolve a REAL python.exe path -- never `py.exe`, the Python Launcher. + + Review finding (T-20260926-212716751): Windows has no exec(), so + launching the watcher via `["py", "-3", ...]` spawns py.exe as a WRAPPER + process that itself spawns the real interpreter as ITS OWN child. The + PID `subprocess.Popen` hands back is the launcher's, not the worker's -- + in a frozen build (`sys.executable` is the packaged host app, unusable + as an interpreter, so `py -3` was the only remaining candidate), + `stop_zombie_killer_watch` was signalling the launcher while the actual + watcher kept running, unreachable, as an orphan. + + Not frozen: `sys.executable` already IS a real interpreter, used + directly. Frozen: resolve the real path once via `-c "import sys; + print(sys.executable)"` through each launcher candidate, so the actual + watch subprocess is spawned directly against that resolved path. + """ + if not getattr(sys, "frozen", False): + return sys.executable + run = runner or _run_install_command + for candidate in (["py", "-3"], ["python3"], ["python"]): + try: + completed = run([*candidate, "-c", "import sys; print(sys.executable)"]) + except OSError: + continue + path = completed.stdout.strip() if completed.stdout else "" + if completed.returncode == 0 and path and Path(path).is_file(): + return path + return None + + +def _run_install_command(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + check=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + + +def install_zombie_killer_package( + *, + target: str | None = None, + runner: Callable[[list[str]], subprocess.CompletedProcess[str]] | None = None, +) -> ZombieKillerInstallResult: + """Installiere oder aktualisiere zombie-killer-tray über pip. + + Bewusst eine explizite Nutzeraktion -- der Tray/CLI-Befehl nutzt dieselbe + Funktion; automatisch wird hier nichts nachinstalliert (Muster identisch + zu `install_safe_start_package`). + """ + chosen_target = target or zombie_killer_install_target() + run = runner or _run_install_command + attempts: list[ZombieKillerInstallResult] = [] + for pip_command in _pip_command_candidates(): + command = [*pip_command, "install", "--upgrade", chosen_target] + try: + completed = run(command) + except OSError as exc: + attempts.append( + ZombieKillerInstallResult( + status="failed", + target=chosen_target, + command=command, + message=str(exc), + ) + ) + continue + status = "ok" if completed.returncode == 0 else "failed" + result = ZombieKillerInstallResult( + status=status, + target=chosen_target, + command=command, + message=( + "zombie-killer-tray wurde installiert oder aktualisiert." + if status == "ok" + else f"pip endete mit Code {completed.returncode}." + ), + stdout=completed.stdout or "", + stderr=completed.stderr or "", + ) + if status == "ok": + return result + attempts.append(result) + + if attempts: + last = attempts[-1] + return ZombieKillerInstallResult( + status="failed", + target=chosen_target, + command=last.command, + message="zombie-killer-tray konnte nicht installiert werden.", + stdout=last.stdout, + stderr=last.stderr or last.message, + ) + return ZombieKillerInstallResult( + status="failed", + target=chosen_target, + command=[], + message="Kein Python/pip-Befehl gefunden.", + ) + + +def _zombie_killer_env(config: MaintenanceConfig) -> dict[str, str]: + env = os.environ.copy() + local_source = _local_zombie_killer_source() + if local_source is not None: + src = str(local_source / "src") + old_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = src if not old_pythonpath else src + os.pathsep + old_pythonpath + return env + + +def _watch_pid_file(state_dir: Path) -> Path: + return state_dir / "watch.pid" + + +def _write_watch_pid_file(state_dir: Path, pid: int, create_time: float | None) -> None: + payload = {"pid": pid, "create_time": create_time} + _watch_pid_file(state_dir).write_text(json.dumps(payload), encoding="utf-8") + + +def _read_watch_pid_file(state_dir: Path) -> tuple[int, float | None] | None: + pid_file = _watch_pid_file(state_dir) + if not pid_file.exists(): + return None + try: + payload = json.loads(pid_file.read_text(encoding="utf-8")) + raw_create_time = payload.get("create_time") + return ( + int(payload["pid"]), + float(raw_create_time) if raw_create_time is not None else None, + ) + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): + return None + + +def _process_create_time(pid: int) -> float | None: + try: + import psutil + except ImportError: + return None + try: + return psutil.Process(pid).create_time() + except psutil.Error: + return None + + +def _verify_watch_process(pid: int, expected_create_time: float | None) -> bool | None: + """True: `pid` is verifiably the same zombie-killer-tray watcher incarnation + that was recorded. False: gone, or the PID was reused by something else. + None: cannot verify at all (psutil unavailable or inaccessible) -- + callers MUST treat this as "do not touch" (review finding: `stop` used + to fail-open on None, so a stale, verification-less PID file could + terminate an unrelated process that happened to reuse the PID).""" + try: + import psutil + except ImportError: + return None + try: + proc = psutil.Process(pid) + actual_create_time = proc.create_time() + cmdline = " ".join(proc.cmdline()) + except psutil.Error: + return False + if expected_create_time is not None and abs(actual_create_time - expected_create_time) > 2.0: + return False # a different incarnation -- the pid was reused + return "zombie_killer_tray" in cmdline + + +def launch_zombie_killer_watch( + config: MaintenanceConfig, + *, + interval_seconds: int | None = None, + min_age_seconds: int | None = None, + apply: bool = True, + popen: Callable[..., subprocess.Popen[str]] | None = None, +) -> ZombieKillerLaunchResult: + """Starte `python -m zombie_killer_tray watch` als eigenen, langlebigen + Subprozess. + + `apply=False` startet im Vorschau-/Preview-Modus (kein `--yes`): der + Watcher protokolliert Kandidaten, beendet aber nie einen echten + Prozess. Default `True` fuer den produktiven Einsatz; Tests, die nur + den Lebenszyklus (ueberlebt der Watcher, funktioniert stop) pruefen + wollen, MUESSEN `apply=False` setzen -- sonst reapt der echte + Subprozess auf der Testmaschine tatsaechlich alte, qualifizierende + Waisen (Review-Fund T-20260926-212716751). + + KEIN `--parent-pid`: zombie-killer-tray beendet den watch-Prozess, + sobald die dort per `--parent-pid` angegebene PID stirbt. Ein CLI-Aufruf + wie `zombie-killer-watch` beendet sich selbst, sobald + `cmd_zombie_killer_watch` zurueckkehrt; wuerde diese eigene, kurzlebige + PID als `--parent-pid` durchgereicht, stuerbe der Watcher innerhalb von + rund einer Sekunde nach dem Start. Der Lebenszyklus wird stattdessen + unabhaengig ueber eine PID-Datei verwaltet (`stop_zombie_killer_watch`). + + Verweigert einen zweiten Start, solange die bestehende PID-Datei einen + noch laufenden (oder nicht verifizierbaren) Watcher beschreibt -- + andernfalls ueberschreibt ein zweiter Aufruf `watch.pid` und der erste + Watcher wird unerreichbar (Review-Fund). + + Laufzeitzustand (`zombie_events.jsonl`, `zombie_worker_errors.log`) + landet im zombie-killer-eigenen Statusordner unterhalb von CODEX_HOME + (via `cwd=`) -- zombie-killer-tray selbst entscheidet anhand seines + Arbeitsverzeichnisses, wohin es schreibt; CareCenter liest hier nichts + direkt in den laufenden Prozess hinein, sondern nur die JSONL-Datei + danach (siehe `build_zombie_killer_status`). + """ + state_dir = config.zombie_killer_state_dir + state_dir.mkdir(parents=True, exist_ok=True) + + existing = _read_watch_pid_file(state_dir) + if existing is not None: + existing_pid, existing_create_time = existing + verified = _verify_watch_process(existing_pid, existing_create_time) + if verified is not False: + return ZombieKillerLaunchResult( + status="already-running" if verified else "verification-unavailable", + command=[], + message=( + f"Ein Watcher laeuft bereits (PID {existing_pid}); " + "zombie-killer-stop nutzen, um ihn zu beenden." + if verified + else "Bestehende PID-Datei kann nicht verifiziert werden (psutil fehlt?); " + "vor einem erneuten Start pruefen." + ), + state_dir=str(state_dir), + pid=existing_pid, + ) + _watch_pid_file(state_dir).unlink(missing_ok=True) # stale/dead -- safe to clear + + python_executable = _resolve_watch_python_executable() + if python_executable is None: + return ZombieKillerLaunchResult( + status="failed", + command=[], + message="Kein echter Python-Interpreter gefunden (py.exe-Launcher wird bewusst " + "nicht als Watch-Prozess verwendet, siehe _resolve_watch_python_executable).", + state_dir=str(state_dir), + ) + + args = [ + "watch", + *(["--yes"] if apply else []), + "--interval", str(interval_seconds or config.zombie_killer_watch_interval_seconds), + "--min-age", str(min_age_seconds or config.zombie_killer_min_age_seconds), + ] + command = [python_executable, "-m", "zombie_killer_tray", *args] + env = _zombie_killer_env(config) + run = popen or subprocess.Popen + try: + process = run(command, cwd=str(state_dir), env=env, close_fds=True, **_no_window_kwargs()) + except OSError as exc: + return ZombieKillerLaunchResult( + status="failed", command=command, message=str(exc), state_dir=str(state_dir) + ) + + pid = getattr(process, "pid", None) + pid_int = int(pid) if isinstance(pid, int) else None + if pid_int is None: + return ZombieKillerLaunchResult( + status="failed", + command=command, + message="Subprozess lieferte keine PID.", + state_dir=str(state_dir), + ) + _write_watch_pid_file(state_dir, pid_int, _process_create_time(pid_int)) + return ZombieKillerLaunchResult( + status="ok", + command=command, + message="zombie-killer-tray wurde als eigener, langlebiger Watch-Subprozess gestartet.", + state_dir=str(state_dir), + pid=pid_int, + ) + + +def stop_zombie_killer_watch(config: MaintenanceConfig) -> ZombieKillerStopResult: + """Stop the watch subprocess started by `launch_zombie_killer_watch`, + identified via its PID file rather than any parent/child relationship.""" + state_dir = config.zombie_killer_state_dir + existing = _read_watch_pid_file(state_dir) + if existing is None: + return ZombieKillerStopResult(status="not-running", message="Keine PID-Datei gefunden.") + pid, create_time = existing + + verified = _verify_watch_process(pid, create_time) + if verified is None: + # FAIL-CLOSED (review finding): cannot verify this PID is really our + # watcher (psutil missing/inaccessible) -- refuse to touch it rather + # than blindly signalling a possibly-unrelated, reused PID. + return ZombieKillerStopResult( + status="verification-unavailable", + message="Kann PID nicht verifizieren (psutil fehlt?); Prozess wurde NICHT beendet.", + pid=pid, + ) + if verified is False: + _watch_pid_file(state_dir).unlink(missing_ok=True) + return ZombieKillerStopResult( + status="not-found", message="PID gehoert nicht (mehr) zu zombie-killer-tray.", pid=pid + ) + + try: + os.kill(pid, signal.SIGTERM) + except OSError: + _watch_pid_file(state_dir).unlink(missing_ok=True) + return ZombieKillerStopResult(status="already-stopped", message="Prozess lief nicht mehr.", pid=pid) + + _watch_pid_file(state_dir).unlink(missing_ok=True) + return ZombieKillerStopResult(status="ok", message="zombie-killer-tray wurde beendet.", pid=pid) + + +def _last_cycle_event(state_dir: Path) -> dict[str, object] | None: + events_path = state_dir / "zombie_events.jsonl" + if not events_path.exists(): + return None + last: dict[str, object] | None = None + try: + with events_path.open("r", encoding="utf-8", errors="replace") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(record, dict) and "cycle_at" in record: + last = record + except OSError: + return None + return last + + +def build_zombie_killer_status(config: MaintenanceConfig) -> ZombieKillerStatus: + state_dir = config.zombie_killer_state_dir + notes: list[str] = [] + available = _zombie_killer_importable() + if not available: + notes.append("zombie-killer-tray-Paket nicht importierbar; nutze vorhandenes Audit-Log.") + + last_cycle = _last_cycle_event(state_dir) + return ZombieKillerStatus( + available=available, + state_dir=str(state_dir), + last_cycle_at=float(last_cycle["cycle_at"]) if last_cycle else None, + last_cycle_count=int(last_cycle["count"]) if last_cycle and "count" in last_cycle else None, + notes=notes, + ) diff --git a/tests/test_docs_contracts.py b/tests/test_docs_contracts.py index 9cd91f5..e5d5d20 100644 --- a/tests/test_docs_contracts.py +++ b/tests/test_docs_contracts.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +import tomllib from pathlib import Path from codex_logdatenbank_wartung.cli import build_parser @@ -22,6 +23,43 @@ LOCAL_LINK = re.compile(r"(? None: + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + contributing = (ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8") + requirement = pyproject["project"]["requires-python"] + match = re.fullmatch(r">=(\d+\.\d+)", requirement) + + assert match is not None, requirement + assert contributing.count(f"Python {match.group(1)}+") == 2 + assert "Python 3.10+" not in contributing + + +def test_llms_has_one_last_checked_source() -> None: + llms = (ROOT / "llms.txt").read_text(encoding="utf-8") + dates = re.findall(r"(?im)^>?\s*Last-checked:\s*(\d{4}-\d{2}-\d{2})\s*$", llms) + verification_dates = re.findall( + r"(?im)^(?:> Test suite:|- Current local verification:).*?" + r"(\d{4}-\d{2}-\d{2})\)?\s*$", + llms, + ) + + assert len(dates) == 1 + assert verification_dates == [dates[0], dates[0]] + + +def test_ruff_command_is_shared_by_ci_and_documented_verification_paths() -> None: + command = "python -m ruff check src tests" + workflow = (ROOT / ".github" / "workflows" / "tests.yml").read_text(encoding="utf-8") + contributing = (ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8") + llms = (ROOT / "llms.txt").read_text(encoding="utf-8") + + assert workflow.count(f"run: {command}") == 1 + assert contributing.count(f"`{command}`") == 2 + assert command in contributing + assert f"- {command}" in llms + assert 'python-version: ["3.12", "3.13"]' in workflow + + def test_active_docs_describe_runtime_boundary_and_manual_release_gate() -> None: for path in ACTIVE_DOCS: content = path.read_text(encoding="utf-8") diff --git a/tests/test_processes.py b/tests/test_processes.py index 6518449..d2c018f 100644 --- a/tests/test_processes.py +++ b/tests/test_processes.py @@ -14,6 +14,7 @@ find_runtime_mcp_duplicate_roots, is_companion_orphan, process_type, + runtime_orphan_kind, tree_pids, windows_processes, ) @@ -206,6 +207,49 @@ def test_runtime_orphan_finder_requires_dead_parent_and_minimum_age() -> None: assert too_young == [] +def test_runtime_orphan_finder_covers_mcp_nodes_and_common_language_servers() -> None: + now = datetime.fromisoformat("2026-08-30T03:00:00") + parent = ProcessInfo(19848, "node.exe", command_line="node host.js", created_at="2026-08-29T01:00:00") + mcp = ProcessInfo( + 82003, + "node.exe", + command_line="node C:/tools/ellmos-controlcenter-mcp/dist/index.js", + parent_pid=19848, + created_at="2026-08-29T02:00:00", + ) + language_server = ProcessInfo( + 82004, + "node.exe", + command_line="node typescript-language-server --stdio", + parent_pid=19848, + created_at="2026-08-29T02:00:00", + ) + + assert runtime_orphan_kind(mcp) == "mcp_server" + assert runtime_orphan_kind(language_server) == "language_server" + assert find_companion_orphans(provider=lambda: [parent, mcp, language_server], min_age_seconds=1800, now=now) == [] + assert [item.pid for item in find_companion_orphans( + provider=lambda: [mcp, language_server], min_age_seconds=1800, now=now + )] == [82003, 82004] + + +def test_runtime_orphan_finder_broad_mcp_markers_do_not_misclassify_unrelated_names() -> None: + """Review finding (T-20260926-212716751): `_MCP_SERVER_MARKERS` includes + broad substrings (' mcp', '-mcp', '_mcp') with no further vetting. Any + unrelated process whose name happens to contain one of these as a + substring must NOT be classified (and therefore never made reap-eligible) + as an MCP server.""" + unrelated = [ + ProcessInfo(90001, "acme_mcpayments.exe", command_line="acme_mcpayments.exe --daemon"), + ProcessInfo(90002, "battle-mcp-launcher.exe", command_line="battle-mcp-launcher.exe --fullscreen"), + ] + for process in unrelated: + assert runtime_orphan_kind(process) is None, ( + f"{process.name!r} was misclassified as an MCP server via a broad " + "substring marker" + ) + + def _desktop_runtime_generations() -> list[ProcessInfo]: store_root = ( r"C:\Program Files\WindowsApps\OpenAI.Codex_26.707.3563.0_x64__2p2nqsd0c76g0" diff --git a/tests/test_watchdog.py b/tests/test_watchdog.py index 761ad95..4231c76 100644 --- a/tests/test_watchdog.py +++ b/tests/test_watchdog.py @@ -497,6 +497,63 @@ def killer(pid: int) -> tuple[bool, str]: assert killed_pids == [701, 702, 703] +def test_runtime_mcp_duplicate_candidate_with_live_parent_is_not_reaped() -> None: + from unittest.mock import patch + + from codex_logdatenbank_wartung.processes import ProcessInfo + + parent = ProcessInfo(700, "codex.exe", command_line="codex app-server") + root = ProcessInfo(701, "node.exe", command_line="node mcp-server", parent_pid=700) + with patch( + "codex_logdatenbank_wartung.watchdog.find_runtime_mcp_duplicate_roots", + return_value=[root], + ): + killed_pids: list[int] = [] + reaped = reap_runtime_mcp_duplicates( + make_config(reap_runtime_mcp_duplicates=True, runtime_mcp_activity_sample_seconds=0.0), + execute=True, + provider=lambda: [parent, root], + killer=lambda pid: (killed_pids.append(pid) or True, "ok"), + ) + + assert reaped == 0 + assert killed_pids == [] + + +def test_runtime_orphan_log_keeps_last_parent_identity(caplog) -> None: + from codex_logdatenbank_wartung.processes import ProcessInfo + + now = datetime.now() + parent = ProcessInfo(710, "node.exe", command_line="node session-host.js") + orphan = ProcessInfo( + 711, + "node.exe", + command_line="node ellmos-controlcenter-mcp/server.mjs", + parent_pid=710, + cpu_ticks=4, + created_at=(now - timedelta(hours=2)).isoformat(), + ) + # A prior read captured the parent identity; the next two reads see the + # actual orphan and provide the required stable/idle evidence. + from codex_logdatenbank_wartung.processes import find_companion_orphans + + find_companion_orphans(provider=lambda: [parent, orphan], min_age_seconds=0, now=now) + snapshots = iter([[orphan], [orphan]]) + killed: list[int] = [] + with caplog.at_level(logging.WARNING, logger="CareCenterForCodex.watchdog"): + reaped = reap_runtime_orphans( + make_config(), + provider=lambda: next(snapshots), + killer=lambda pid: (killed.append(pid) or True, "ok"), + sleeper=lambda _seconds: None, + ) + + assert reaped == 1 + assert killed == [711] + assert "parent_name='node.exe'" in caplog.text + assert "session-host.js" in caplog.text + + def test_runtime_mcp_default_kill_uses_complete_process_tree() -> None: from unittest.mock import patch diff --git a/tests/test_zombie_killer_integration.py b/tests/test_zombie_killer_integration.py new file mode 100644 index 0000000..a5bcfdf --- /dev/null +++ b/tests/test_zombie_killer_integration.py @@ -0,0 +1,415 @@ +from __future__ import annotations + +import contextlib +import json +import os +import signal +import subprocess +import sys +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from codex_logdatenbank_wartung.config import MaintenanceConfig +from codex_logdatenbank_wartung.zombie_killer_integration import ( + ZOMBIE_KILLER_PACKAGE_SPEC, + ZOMBIE_KILLER_SOURCE_ENV, + build_zombie_killer_status, + install_zombie_killer_package, + launch_zombie_killer_watch, + stop_zombie_killer_watch, + zombie_killer_install_target, +) + + +def make_config(tmp_path: Path) -> MaintenanceConfig: + codex_home = tmp_path / ".codex" + return MaintenanceConfig(database_path=str(codex_home / "logs_2.sqlite")) + + +def test_zombie_killer_state_dir_is_under_codex_home(tmp_path: Path) -> None: + config = make_config(tmp_path) + assert config.zombie_killer_state_dir == config.codex_home / "zombie-killer-tray" + + +def test_install_target_falls_back_to_pinned_github_spec_without_local_source( + monkeypatch, +) -> None: + monkeypatch.delenv(ZOMBIE_KILLER_SOURCE_ENV, raising=False) + monkeypatch.setattr( + "codex_logdatenbank_wartung.zombie_killer_integration._local_zombie_killer_source", + lambda: None, + ) + assert zombie_killer_install_target() == ZOMBIE_KILLER_PACKAGE_SPEC + + +def test_install_target_prefers_local_source_env_override(tmp_path: Path, monkeypatch) -> None: + local = tmp_path / "local-zkt" + local.mkdir() + (local / "pyproject.toml").write_text("[project]\nname='x'\n", encoding="utf-8") + monkeypatch.setenv(ZOMBIE_KILLER_SOURCE_ENV, str(local)) + assert zombie_killer_install_target() == str(local) + + +def test_install_zombie_killer_package_reports_ok_on_zero_exit() -> None: + calls: list[list[str]] = [] + + def fake_runner(command: list[str]) -> subprocess.CompletedProcess[str]: + calls.append(command) + return subprocess.CompletedProcess(command, returncode=0, stdout="installed", stderr="") + + result = install_zombie_killer_package(target="some-target", runner=fake_runner) + + assert result.status == "ok" + assert result.target == "some-target" + assert calls, "runner must have been invoked at least once" + assert calls[0][-3:] == ["install", "--upgrade", "some-target"] + + +def test_install_zombie_killer_package_reports_failed_on_nonzero_exit() -> None: + def fake_runner(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(command, returncode=1, stdout="", stderr="boom") + + result = install_zombie_killer_package(target="some-target", runner=fake_runner) + + assert result.status == "failed" + assert "boom" in result.stderr + + +def test_launch_zombie_killer_watch_invokes_module_with_expected_args(tmp_path: Path) -> None: + config = make_config(tmp_path) + captured: dict[str, object] = {} + + def fake_popen(command, **kwargs): + captured["command"] = command + captured["cwd"] = kwargs.get("cwd") + captured["env"] = kwargs.get("env") + return SimpleNamespace(pid=4242) + + result = launch_zombie_killer_watch( + config, interval_seconds=42, min_age_seconds=99, popen=fake_popen + ) + + assert result.status == "ok" + assert result.pid == 4242 + assert result.state_dir == str(config.zombie_killer_state_dir) + command = captured["command"] + assert "-m" in command + assert "zombie_killer_tray" in command + assert "watch" in command + assert command.index("watch") > command.index("zombie_killer_tray") + assert "--interval" in command and "42" in command + assert "--min-age" in command and "99" in command + assert "--yes" in command + assert "--parent-pid" not in command, ( + "must NOT tie the watcher's lifetime to this (necessarily short-lived) " + "caller's own PID -- it would die within ~1s of being spawned (review finding)" + ) + assert captured["cwd"] == str(config.zombie_killer_state_dir) + assert Path(captured["cwd"]).is_dir(), "launch must create the state dir before spawning" + pid_payload = json.loads((config.zombie_killer_state_dir / "watch.pid").read_text(encoding="utf-8")) + assert pid_payload["pid"] == 4242 + + +def test_launch_uses_preview_mode_without_apply(tmp_path: Path) -> None: + """Review finding: a test (or any caller) that only wants to exercise the + watcher's lifecycle, not its actual reap behaviour, must be able to omit + --yes -- otherwise a real watch subprocess can terminate real, qualifying + orphans on whatever machine runs it.""" + config = make_config(tmp_path) + + def fake_popen(command, **kwargs): + return SimpleNamespace(pid=1) + + result = launch_zombie_killer_watch(config, apply=False, popen=fake_popen) + assert result.status == "ok" + assert "--yes" not in result.command + + +def test_launch_refuses_a_second_start_while_the_first_watcher_is_verified_alive( + tmp_path: Path, monkeypatch +) -> None: + """Review finding: a second `watch` used to silently overwrite watch.pid, + orphaning the first watcher (still running, now unreachable via stop).""" + config = make_config(tmp_path) + state_dir = config.zombie_killer_state_dir + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "watch.pid").write_text( + json.dumps({"pid": 424242, "create_time": 123.0}), encoding="utf-8" + ) + monkeypatch.setattr( + "codex_logdatenbank_wartung.zombie_killer_integration._verify_watch_process", + lambda pid, create_time: True, + ) + called = False + + def fake_popen(command, **kwargs): + nonlocal called + called = True + return SimpleNamespace(pid=1) + + result = launch_zombie_killer_watch(config, popen=fake_popen) + + assert result.status == "already-running" + assert result.pid == 424242 + assert called is False, "must never spawn a second watcher over a verified-alive one" + assert json.loads((state_dir / "watch.pid").read_text(encoding="utf-8"))["pid"] == 424242 + + +def test_launch_refuses_a_second_start_when_verification_is_unavailable( + tmp_path: Path, monkeypatch +) -> None: + """Fail-closed applies to the double-start guard too: if we cannot tell + whether the existing PID is still our watcher, refuse rather than risk a + duplicate -- same direction as stop's fail-closed fix.""" + config = make_config(tmp_path) + state_dir = config.zombie_killer_state_dir + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "watch.pid").write_text( + json.dumps({"pid": 424242, "create_time": 123.0}), encoding="utf-8" + ) + monkeypatch.setattr( + "codex_logdatenbank_wartung.zombie_killer_integration._verify_watch_process", + lambda pid, create_time: None, + ) + called = False + + def fake_popen(command, **kwargs): + nonlocal called + called = True + return SimpleNamespace(pid=1) + + result = launch_zombie_killer_watch(config, popen=fake_popen) + + assert result.status == "verification-unavailable" + assert called is False + + +def test_launch_proceeds_when_the_existing_pid_file_is_stale(tmp_path: Path, monkeypatch) -> None: + config = make_config(tmp_path) + state_dir = config.zombie_killer_state_dir + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "watch.pid").write_text( + json.dumps({"pid": 424242, "create_time": 123.0}), encoding="utf-8" + ) + monkeypatch.setattr( + "codex_logdatenbank_wartung.zombie_killer_integration._verify_watch_process", + lambda pid, create_time: False, + ) + + def fake_popen(command, **kwargs): + return SimpleNamespace(pid=99999) + + result = launch_zombie_killer_watch(config, popen=fake_popen) + + assert result.status == "ok" + assert result.pid == 99999 + assert json.loads((state_dir / "watch.pid").read_text(encoding="utf-8"))["pid"] == 99999 + + +def test_resolve_python_executable_uses_sys_executable_when_not_frozen() -> None: + from codex_logdatenbank_wartung.zombie_killer_integration import ( + _resolve_watch_python_executable, + ) + + assert _resolve_watch_python_executable() == sys.executable + + +def test_resolve_python_executable_resolves_through_the_launcher_when_frozen(monkeypatch) -> None: + """Review finding: in a frozen build, `sys.executable` is the packaged + host app, not an interpreter, and launching via the bare `py -3` + launcher would hand back the LAUNCHER's pid, not the worker's. The real + interpreter path must be resolved once via the launcher's own stdout.""" + from codex_logdatenbank_wartung.zombie_killer_integration import ( + _resolve_watch_python_executable, + ) + + monkeypatch.setattr(sys, "frozen", True, raising=False) + real_path = sys.executable + + def fake_runner(command: list[str]) -> subprocess.CompletedProcess[str]: + assert command[:2] == ["py", "-3"] + return subprocess.CompletedProcess(command, returncode=0, stdout=real_path + "\n", stderr="") + + resolved = _resolve_watch_python_executable(runner=fake_runner) + assert resolved == real_path + + +def test_launch_zombie_killer_watch_falls_back_to_config_defaults(tmp_path: Path) -> None: + config = make_config(tmp_path) + config.zombie_killer_watch_interval_seconds = 777 + config.zombie_killer_min_age_seconds = 888 + captured: dict[str, object] = {} + + def fake_popen(command, **kwargs): + captured["command"] = command + return SimpleNamespace(pid=1) + + launch_zombie_killer_watch(config, popen=fake_popen) + + command = captured["command"] + assert "777" in command + assert "888" in command + + +def test_launch_zombie_killer_watch_reports_failure_when_no_python_found(tmp_path: Path) -> None: + config = make_config(tmp_path) + + def failing_popen(command, **kwargs): + raise OSError("no interpreter") + + result = launch_zombie_killer_watch(config, popen=failing_popen) + + assert result.status == "failed" + assert "no interpreter" in result.message + + +def test_build_zombie_killer_status_reads_last_cycle_from_events_log(tmp_path: Path) -> None: + config = make_config(tmp_path) + state_dir = config.zombie_killer_state_dir + state_dir.mkdir(parents=True) + events = state_dir / "zombie_events.jsonl" + with events.open("w", encoding="utf-8") as handle: + handle.write(json.dumps({"cycle_at": 111.0, "apply": False, "count": 0}) + "\n") + handle.write(json.dumps({"cycle_at": 222.0, "apply": True, "count": 3}) + "\n") + handle.write(json.dumps({"event": "broker-superseded-idle", "root_pid": 5}) + "\n") + + status = build_zombie_killer_status(config) + + assert status.last_cycle_at == 222.0 + assert status.last_cycle_count == 3 + assert status.state_dir == str(state_dir) + + +def test_build_zombie_killer_status_handles_missing_log(tmp_path: Path) -> None: + config = make_config(tmp_path) + + status = build_zombie_killer_status(config) + + assert status.last_cycle_at is None + assert status.last_cycle_count is None + + +def test_stop_reports_not_running_without_a_pid_file(tmp_path: Path) -> None: + config = make_config(tmp_path) + result = stop_zombie_killer_watch(config) + assert result.status == "not-running" + + +def test_stop_reports_not_found_for_a_stale_pid_reused_by_something_else(tmp_path: Path) -> None: + pytest.importorskip("psutil") + config = make_config(tmp_path) + state_dir = config.zombie_killer_state_dir + state_dir.mkdir(parents=True, exist_ok=True) + # PID of the current test process itself -- definitely alive, definitely + # NOT a zombie-killer-tray process (wrong cmdline AND, deliberately, a + # create_time far from its real one), so this exercises the reuse check + # refusing to kill an unrelated process that happens to reuse the pid. + (state_dir / "watch.pid").write_text( + json.dumps({"pid": os.getpid(), "create_time": 1.0}), encoding="utf-8" + ) + + result = stop_zombie_killer_watch(config) + + assert result.status == "not-found" + assert not (state_dir / "watch.pid").exists(), "stale/wrong pid file must be cleaned up" + + +def test_stop_fails_closed_when_verification_is_unavailable(tmp_path: Path, monkeypatch) -> None: + """Review finding (blocking): stop used to fail-OPEN when verification + returned None (psutil unavailable) -- it killed the PID anyway. A stale, + verification-less PID file could then terminate an unrelated process + that happened to reuse the PID. It must instead refuse and leave the + process untouched.""" + config = make_config(tmp_path) + state_dir = config.zombie_killer_state_dir + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "watch.pid").write_text( + json.dumps({"pid": 999999999, "create_time": 123.0}), encoding="utf-8" + ) + killed: list[int] = [] + monkeypatch.setattr( + "codex_logdatenbank_wartung.zombie_killer_integration._verify_watch_process", + lambda pid, create_time: None, + ) + monkeypatch.setattr(os, "kill", lambda pid, sig: killed.append(pid)) + + result = stop_zombie_killer_watch(config) + + assert result.status == "verification-unavailable" + assert killed == [], "must NOT signal the pid when verification is unavailable (fail-closed)" + assert (state_dir / "watch.pid").exists(), "an unresolved pid file is left in place, not deleted" + + +def test_stop_reports_already_stopped_for_a_verified_but_dead_pid(tmp_path: Path, monkeypatch) -> None: + config = make_config(tmp_path) + state_dir = config.zombie_killer_state_dir + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "watch.pid").write_text( + json.dumps({"pid": 999999999, "create_time": 123.0}), encoding="utf-8" + ) + monkeypatch.setattr( + "codex_logdatenbank_wartung.zombie_killer_integration._verify_watch_process", + lambda pid, create_time: True, + ) + monkeypatch.setattr(os, "kill", lambda pid, sig: (_ for _ in ()).throw(OSError("gone"))) + + result = stop_zombie_killer_watch(config) + + assert result.status == "already-stopped" + assert not (state_dir / "watch.pid").exists() + + +@pytest.mark.timeout(30) +def test_real_watch_subprocess_outlives_its_launcher_and_stop_terminates_it( + tmp_path: Path, +) -> None: + """No mocked Popen -- a genuinely real subprocess, spawned exactly the way + production code does it. This is the direct regression test for the + review finding: the old design passed --parent-pid , and zombie-killer-tray's watch_parent thread kills the watcher + within ~1s of whatever PID it was given exiting. Since THIS test process + is what would have been passed as --parent-pid, and it obviously keeps + running throughout this test, the old bug would never have reproduced + here either -- which is exactly why the original review used a real, + separately-invoked CLI process to catch it, and why this test proves the + fix by asserting on the watcher's presence/absence, not merely that + --parent-pid is absent from the command (already covered separately). + """ + psutil = pytest.importorskip("psutil") + pytest.importorskip("zombie_killer_tray") + config = make_config(tmp_path) + + # apply=False (preview/no --yes): review finding -- with --yes, this real + # subprocess would actually reap real, qualifying orphan processes on + # whatever machine runs this test (including CI). Lifecycle behaviour + # (survives its launcher, responds to stop) does not require apply=True. + result = launch_zombie_killer_watch( + config, interval_seconds=3, min_age_seconds=30, apply=False + ) + assert result.status == "ok", result.message + assert "--yes" not in result.command + pid = result.pid + assert pid is not None + + try: + time.sleep(2.0) + assert psutil.pid_exists(pid), ( + "the watcher must still be running 2s after launch returned -- " + "with the old --parent-pid design it would already be dead" + ) + + stop_result = stop_zombie_killer_watch(config) + assert stop_result.status == "ok" + assert stop_result.pid == pid + + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and psutil.pid_exists(pid): + time.sleep(0.2) + assert not psutil.pid_exists(pid), "the watcher must actually exit after being stopped" + finally: + if psutil.pid_exists(pid): + with contextlib.suppress(OSError): + os.kill(pid, signal.SIGTERM)