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)