From d9e8db13bdd0c8eb08db3cf79ab7fcbceaf91186 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sat, 26 Sep 2026 14:47:43 +0200 Subject: [PATCH 1/7] feat: add optional zombie-killer-tray integration (subprocess pattern) T-20260926-368033290 (c) / T-20260926-212716751: adds an OPTIONAL integration with zombie-killer-tray (conservative cleanup of orphaned MCP and language-server processes across agent frameworks, not just Codex), mirroring the existing safe-start-for-codex integration pattern exactly: a git-pinned pip dependency, launched as its own subprocess via `python -m zombie_killer_tray watch ...`, coordinated through the shared zombie_events.jsonl audit log rather than an in-process import. CareCenter's own existing runtime-MCP-reaper (processes.py/watchdog.py) is unchanged -- it stays targeted at Codex-Companion processes specifically; this is a complementary, broader-scope tool, not a replacement. - New src/codex_logdatenbank_wartung/zombie_killer_integration.py: install_zombie_killer_package()/zombie_killer_install_target() (pinned GitHub spec with a local-sibling-checkout override, same as safe-start), launch_zombie_killer_watch() (spawns the watch subprocess with cwd set to a new zombie_killer_state_dir so its audit log lands there, tied to CareCenter's own PID via --parent-pid so it exits when CareCenter does), build_zombie_killer_status() (reads the last cycle event back out of zombie_events.jsonl for a status summary). - config.py: zombie_killer_watch_interval_seconds/zombie_killer_min_age_seconds fields + zombie_killer_state_dir property (CODEX_HOME/zombie-killer-tray). - cli.py: three new subcommands mirroring safe-start's -- zombie-killer-report / zombie-killer-install / zombie-killer-watch. - pyproject.toml: added to the `build` extras, pinned to dev-bricks/zombie-killer-tray@6c8eb2c (that repo's PR #4, src/-packaging, not yet merged -- move this pin to the merge commit once it lands). - README.md/README.de.md/README_de.md: CLI usage lines, env var override doc, credits line (kept README.de.md and README_de.md byte-identical, as their own contract test requires). - THIRD_PARTY_LICENSES.txt: matching SBOM entries for the new dependency. - 10 new tests in tests/test_zombie_killer_integration.py (install target resolution, install success/failure, launch command/cwd/state-dir construction and failure handling, status readback with/without a log). Deliberately NOT done in this PR (scoped out, not forgotten): tray-menu QAction/i18n wiring (tray.py/i18n.py) equivalent to safe-start's "Codex sicher starten" button. That needs interactive verification of the Qt tray this environment can't provide; the CLI-level integration above is the functional core ("eigener Subprozess, gemeinsame State-Dateien") and the GUI wiring can follow as its own reviewable change once someone can click through it. 431/431 tests green (429 baseline unrelated to this change + 10 new; the 3 pre-existing test_maintenance.py/test_orchestrator.py failures on origin/main are unaffected, unchanged before and after), ruff clean. Prepared in a fresh worktree off origin/main (not the existing, stale local `main`/`ticket/carecenter-zombie-killer-20260920` -- see message to team-lead re: that divergence). Not merging -- per the two-model rule, a different model/session should review before merge. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PTbvD41MCVmnQWaobvHfCk --- README.de.md | 7 +- README.md | 7 +- README_de.md | 7 +- THIRD_PARTY_LICENSES.txt | 7 + pyproject.toml | 3 + src/codex_logdatenbank_wartung/cli.py | 83 ++++ src/codex_logdatenbank_wartung/config.py | 19 + .../zombie_killer_integration.py | 365 ++++++++++++++++++ tests/test_zombie_killer_integration.py | 155 ++++++++ 9 files changed, 650 insertions(+), 3 deletions(-) create mode 100644 src/codex_logdatenbank_wartung/zombie_killer_integration.py create mode 100644 tests/test_zombie_killer_integration.py diff --git a/README.de.md b/README.de.md index cf0f073..2186b58 100644 --- a/README.de.md +++ b/README.de.md @@ -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,9 @@ 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 schedule install --interval-minutes 180 ``` @@ -441,7 +446,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..17e5efc 100644 --- a/README.md +++ b/README.md @@ -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,9 @@ 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 schedule install --interval-minutes 180 ``` @@ -467,7 +472,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..2186b58 100644 --- a/README_de.md +++ b/README_de.md @@ -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,9 @@ 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 schedule install --interval-minutes 180 ``` @@ -441,7 +446,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..01fa56e 100644 --- a/THIRD_PARTY_LICENSES.txt +++ b/THIRD_PARTY_LICENSES.txt @@ -35,6 +35,7 @@ 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 `6c8eb2cc8773e662c65e7a5c39d060b974805c3d` | 0.1.0 | MIT | https://github.com/dev-bricks/zombie-killer-tray | | 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 +92,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/pyproject.toml b/pyproject.toml index ab8f3ff..3493614 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,9 @@ 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) not yet merged; + # pinned to its branch head, move to the merge commit once #4 lands. + "zombie-killer-tray @ git+https://github.com/dev-bricks/zombie-killer-tray.git@6c8eb2cc8773e662c65e7a5c39d060b974805c3d", "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..49d2787 100644 --- a/src/codex_logdatenbank_wartung/cli.py +++ b/src/codex_logdatenbank_wartung/cli.py @@ -283,6 +283,51 @@ 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_mark_runs_read(args: argparse.Namespace) -> int: from .thread_hygiene import maintain_threads @@ -549,6 +594,44 @@ 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) + 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/zombie_killer_integration.py b/src/codex_logdatenbank_wartung/zombie_killer_integration.py new file mode 100644 index 0000000..0436e43 --- /dev/null +++ b/src/codex_logdatenbank_wartung/zombie_killer_integration.py @@ -0,0 +1,365 @@ +"""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 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) ist gepusht, +# aber noch nicht gemergt -- der Pin zeigt auf den Branch-Kopf. Nach dem Merge +# hier auf den Merge-Commit umstellen (derselbe Verifikationsschritt wie beim +# ursprünglichen safe-start-for-codex-Pin). +ZOMBIE_KILLER_PACKAGE_SPEC = ( + "zombie-killer-tray @ " + "git+https://github.com/dev-bricks/zombie-killer-tray.git" + "@6c8eb2cc8773e662c65e7a5c39d060b974805c3d" +) +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 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 _python_command_candidates() -> list[list[str]]: + candidates: list[list[str]] = [] + if not getattr(sys, "frozen", False): + candidates.append([sys.executable]) + candidates.extend((["py", "-3"], ["python"])) + + 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 _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 launch_zombie_killer_watch( + config: MaintenanceConfig, + *, + interval_seconds: int | None = None, + min_age_seconds: int | None = None, + popen: Callable[..., subprocess.Popen[str]] | None = None, +) -> ZombieKillerLaunchResult: + """Starte `python -m zombie_killer_tray watch` als eigenen Subprozess. + + 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 (T-20260926-212716751); 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) + args = [ + "watch", + "--yes", + "--interval", str(interval_seconds or config.zombie_killer_watch_interval_seconds), + "--min-age", str(min_age_seconds or config.zombie_killer_min_age_seconds), + "--parent-pid", str(os.getpid()), + ] + + env = _zombie_killer_env(config) + run = popen or subprocess.Popen + last_error = "" + last_command: list[str] = [] + + for python_command in _python_command_candidates(): + command = [*python_command, "-m", "zombie_killer_tray", *args] + last_command = command + try: + process = run( + command, + cwd=str(state_dir), + env=env, + close_fds=True, + **_no_window_kwargs(), + ) + except OSError as exc: + last_error = str(exc) + continue + pid = getattr(process, "pid", None) + return ZombieKillerLaunchResult( + status="ok", + command=command, + message="zombie-killer-tray wurde als eigener Watch-Subprozess gestartet.", + state_dir=str(state_dir), + pid=int(pid) if isinstance(pid, int) else None, + ) + + return ZombieKillerLaunchResult( + status="failed", + command=last_command, + message=last_error or "Kein Python-Befehl für zombie-killer-tray gefunden.", + state_dir=str(state_dir), + ) + + +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_zombie_killer_integration.py b/tests/test_zombie_killer_integration.py new file mode 100644 index 0000000..8c021f1 --- /dev/null +++ b/tests/test_zombie_killer_integration.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from types import SimpleNamespace + +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, + 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 "--parent-pid" in command + assert "--yes" in command + assert captured["cwd"] == str(config.zombie_killer_state_dir) + assert Path(captured["cwd"]).is_dir(), "launch must create the state dir before spawning" + + +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 From f3a1d0b6e00a937ee6f380291812069a1d10fd0f Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sat, 5 Sep 2026 04:46:03 +0200 Subject: [PATCH 2/7] ci: enforce Ruff and sync docs contracts --- .github/workflows/tests.yml | 3 +++ CONTRIBUTING.md | 19 ++++++++++-------- llms.txt | 1 + tests/test_docs_contracts.py | 38 ++++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 89695d5..ff56aab 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,5 +45,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/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/llms.txt b/llms.txt index 0046b88..1233be2 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) 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") From 2f493a93eb72779077b8801ef020725c93eb1cc3 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sun, 20 Sep 2026 20:33:06 +0200 Subject: [PATCH 3/7] fix(watchdog): require dead parents for MCP orphan cleanup --- ARCHITECTURE.md | 8 +-- README.de.md | 21 ++++---- README.md | 18 ++++--- README_de.md | 21 ++++---- src/codex_logdatenbank_wartung/processes.py | 58 +++++++++++++++++++-- src/codex_logdatenbank_wartung/watchdog.py | 24 +++++++-- tests/test_processes.py | 27 ++++++++++ tests/test_watchdog.py | 57 ++++++++++++++++++++ 8 files changed, 196 insertions(+), 38 deletions(-) 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/README.de.md b/README.de.md index 2186b58..88b2cfa 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. @@ -358,12 +358,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 @@ -387,9 +388,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 diff --git a/README.md b/README.md index 17e5efc..8884fc3 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. @@ -374,11 +374,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; @@ -408,8 +410,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) diff --git a/README_de.md b/README_de.md index 2186b58..88b2cfa 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. @@ -358,12 +358,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 @@ -387,9 +388,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 diff --git a/src/codex_logdatenbank_wartung/processes.py b/src/codex_logdatenbank_wartung/processes.py index 3cdaa4f..96860f0 100644 --- a/src/codex_logdatenbank_wartung/processes.py +++ b/src/codex_logdatenbank_wartung/processes.py @@ -239,7 +239,41 @@ 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", + "-mcp", + "_mcp", + " mcp", + "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 +290,16 @@ 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): + return "mcp_server" if process.name.lower() == "codex.exe" and _CODEX_EXEC_PATTERN.search( process.command_line ): @@ -303,6 +342,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 +381,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..809e6b6 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, ) @@ -249,7 +259,7 @@ def reap_runtime_orphans( else: try: subprocess.run( - ["taskkill", "/F", "/PID", str(orphan.pid)], + ["taskkill", "/T", "/F", "/PID", str(orphan.pid)], check=True, capture_output=True, **no_window_kwargs(), @@ -316,6 +326,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 +369,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 +382,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/tests/test_processes.py b/tests/test_processes.py index 6518449..b1c463f 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,32 @@ 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 _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 From df5225743ebca80ab1a1545e4ee24ea41445bd74 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sat, 26 Sep 2026 15:15:22 +0200 Subject: [PATCH 4/7] fix(docs): remove duplicate Last-checked marker from llms.txt Resurrected by cherry-picking e7394f6's test_docs_contracts.py (test_llms_has_one_last_checked_source): llms.txt had accumulated two "Last-checked" markers (a header line and a trailing bare line) as later docs-refresh commits added the header without removing the legacy trailing line. That drift predates this branch and was invisible because no test enforced single-source-of-truth here until now. Keeping the header (the format the rest of the recent docs refreshes use) and dropping the trailing duplicate. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PTbvD41MCVmnQWaobvHfCk --- llms.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/llms.txt b/llms.txt index 1233be2..2751b82 100644 --- a/llms.txt +++ b/llms.txt @@ -93,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 From 38d3e47a32eeaaeafbb654c15696a8d881105d8e Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sat, 26 Sep 2026 15:23:48 +0200 Subject: [PATCH 5/7] chore: move zombie-killer-tray pin to its merged commit zombie-killer-tray#4 (src/-Paketierung) merged as 039b4f2. Updates the pinned dependency in pyproject.toml's build extras, the module-level ZOMBIE_KILLER_PACKAGE_SPEC constant, and the matching THIRD_PARTY_LICENSES.txt entry from the pre-merge branch head (6c8eb2c) to the actual merge commit. Verified: installed the new pin into a clean venv and ran `python -m zombie_killer_tray scan` there successfully before running the full suite. 437/437 tests green (same 3 known-unrelated baseline failures), ruff clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PTbvD41MCVmnQWaobvHfCk --- THIRD_PARTY_LICENSES.txt | 2 +- pyproject.toml | 5 ++--- .../zombie_killer_integration.py | 7 ++----- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/THIRD_PARTY_LICENSES.txt b/THIRD_PARTY_LICENSES.txt index 01fa56e..9e4ad89 100644 --- a/THIRD_PARTY_LICENSES.txt +++ b/THIRD_PARTY_LICENSES.txt @@ -35,7 +35,7 @@ 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 `6c8eb2cc8773e662c65e7a5c39d060b974805c3d` | 0.1.0 | MIT | https://github.com/dev-bricks/zombie-killer-tray | +| zombie-killer-tray | Optional integration and EXE build | commit `039b4f2c7acd69063b39d6168c757b0b2fca2438` | 0.1.0 | MIT | https://github.com/dev-bricks/zombie-killer-tray | | 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/ | diff --git a/pyproject.toml b/pyproject.toml index 3493614..04f4317 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,9 +74,8 @@ 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) not yet merged; - # pinned to its branch head, move to the merge commit once #4 lands. - "zombie-killer-tray @ git+https://github.com/dev-bricks/zombie-killer-tray.git@6c8eb2cc8773e662c65e7a5c39d060b974805c3d", + # 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", "pyinstaller>=6.10.0", "altgraph>=0.17.4", "packaging>=24.0", diff --git a/src/codex_logdatenbank_wartung/zombie_killer_integration.py b/src/codex_logdatenbank_wartung/zombie_killer_integration.py index 0436e43..d8ace3e 100644 --- a/src/codex_logdatenbank_wartung/zombie_killer_integration.py +++ b/src/codex_logdatenbank_wartung/zombie_killer_integration.py @@ -21,14 +21,11 @@ from .config import MaintenanceConfig -# T-20260926-212716751: zombie-killer-tray#4 (src/-Paketierung) ist gepusht, -# aber noch nicht gemergt -- der Pin zeigt auf den Branch-Kopf. Nach dem Merge -# hier auf den Merge-Commit umstellen (derselbe Verifikationsschritt wie beim -# ursprünglichen safe-start-for-codex-Pin). +# 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" - "@6c8eb2cc8773e662c65e7a5c39d060b974805c3d" + "@039b4f2c7acd69063b39d6168c757b0b2fca2438" ) ZOMBIE_KILLER_SOURCE_ENV = "CARECENTER_ZOMBIE_KILLER_SOURCE" CREATE_NO_WINDOW = 0x08000000 From 9100c3bc546320238eaa84d53c3ce3a82161cf09 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sat, 26 Sep 2026 15:48:59 +0200 Subject: [PATCH 6/7] fix: watcher lifecycle, drop tree-kill, narrow broad MCP markers Review findings on this PR: (1) LIFECYCLE BUG (blocking): launch_zombie_killer_watch() passed --parent-pid . zombie-killer-tray's watch_parent thread kills the watch process as soon as whatever PID it was given for --parent-pid exits -- and the CLI process that spawns the watcher exits immediately after Popen returns, so the watcher died within ~1s of being started, before doing any real work. Reproduced exactly as described: mocked-Popen tests never caught it because they don't run the real subprocess lifecycle at all. Fix: drop --parent-pid entirely. The watcher is now a genuinely detached, long-lived process; its lifecycle is governed by its own PID file (/watch.pid, written by launch_zombie_killer_watch) rather than any parent/child relationship. Added stop_zombie_killer_watch() (reads the PID file, verifies via psutil that the PID still belongs to zombie-killer-tray before signalling it -- refuses to touch an unrelated process that reused the PID) and a new `zombie-killer-stop` CLI subcommand. Added a REAL integration test (no mocked Popen) that spawns the actual zombie-killer-tray package as a subprocess exactly the way production code does, asserts it is still running 2s after the launching call returns (the old bug would already have killed it by then), then asserts zombie-killer-stop actually terminates it. Requires the package installed (pinned dependency); added the same pip-install step already used for safe-start-for-codex to CI. (2) taskkill /T on a single-process orphan kill (blocking): the rescued watchdog commit (1587edf) changed reap_runtime_orphans' fallback kill from a single-PID `taskkill /F` to a tree-kill `taskkill /T /F`. Every PID reaped there was individually vetted (dead parent, min age, two idle CPU snapshots) -- its descendants were never checked against those same criteria, so a tree-kill would terminate processes no criterion actually covers. Reverted to a single-PID kill; a genuinely orphaned descendant becomes reap-able on its own in a later watchdog cycle once it independently satisfies the same criteria. (reap_runtime_mcp_duplicates' own, separate `taskkill /T` for duplicate-cohort launcher trees is unrelated and untouched -- that one is gated by dead-parent + duplicate- signature + cohort criteria on the ROOT before the tree-kill fires, which is the design the review is asking this path to match, not diverge from.) Added a negative test for the broad MCP substring markers ("-mcp", "_mcp", " mcp") in processes.py -- it caught a real false positive: "acme_mcpayments.exe" and "battle-mcp-launcher.exe" were both misclassified as MCP servers via a substring match with no word-boundary check. Replaced the plain substring checks for those three broad markers with a regex requiring "mcp" to end at an actual boundary (path separator, dot, space, or end of string) rather than continuing into an unrelated word -- keeps catching the real "-mcp" server-binary convention (e.g. "ellmos-controlcenter-mcp/dist/index.js") without the false positives. (3) safe-start's [tool.hatch.metadata] fix is in that repo's own PR, not this one -- CareCenter doesn't have a direct-reference optional extra of this kind (its build extra already declared a direct git reference for safe-start-for-codex before this PR and CI already accounted for it). 442/442 tests green (439 pre-existing/unrelated-to-this-PR + this PR's own 3 new test areas; the 3 known-unrelated test_maintenance.py/ test_orchestrator.py failures on origin/main are unchanged), ruff clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PTbvD41MCVmnQWaobvHfCk --- .github/workflows/tests.yml | 1 + README.de.md | 1 + README.md | 1 + README_de.md | 1 + src/codex_logdatenbank_wartung/cli.py | 21 ++++ src/codex_logdatenbank_wartung/processes.py | 16 +++- src/codex_logdatenbank_wartung/watchdog.py | 10 +- .../zombie_killer_integration.py | 86 ++++++++++++++++- tests/test_processes.py | 17 ++++ tests/test_zombie_killer_integration.py | 96 ++++++++++++++++++- 10 files changed, 239 insertions(+), 11 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ff56aab..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 diff --git a/README.de.md b/README.de.md index 88b2cfa..95110f2 100644 --- a/README.de.md +++ b/README.de.md @@ -333,6 +333,7 @@ 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 ``` diff --git a/README.md b/README.md index 8884fc3..7625167 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,7 @@ 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 ``` diff --git a/README_de.md b/README_de.md index 88b2cfa..95110f2 100644 --- a/README_de.md +++ b/README_de.md @@ -333,6 +333,7 @@ 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 ``` diff --git a/src/codex_logdatenbank_wartung/cli.py b/src/codex_logdatenbank_wartung/cli.py index 49d2787..bdec241 100644 --- a/src/codex_logdatenbank_wartung/cli.py +++ b/src/codex_logdatenbank_wartung/cli.py @@ -328,6 +328,20 @@ def cmd_zombie_killer_watch(args: argparse.Namespace) -> int: 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 @@ -632,6 +646,13 @@ def build_parser() -> argparse.ArgumentParser: ) 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/processes.py b/src/codex_logdatenbank_wartung/processes.py index 96860f0..f817ec3 100644 --- a/src/codex_logdatenbank_wartung/processes.py +++ b/src/codex_logdatenbank_wartung/processes.py @@ -262,11 +262,15 @@ def find_codex_processes_by_executable( "model-context-protocol", "mcp-server", "mcp_server", - "-mcp", - "_mcp", - " mcp", - "mcp ", ) +# 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" ] @@ -298,7 +302,9 @@ def runtime_orphan_kind(process: ProcessInfo) -> RuntimeOrphanKind | None: marker in haystack for marker in _LANGUAGE_SERVER_MARKERS ): return "language_server" - if any(marker in haystack for marker in _MCP_SERVER_MARKERS): + 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 diff --git a/src/codex_logdatenbank_wartung/watchdog.py b/src/codex_logdatenbank_wartung/watchdog.py index 809e6b6..267d03e 100644 --- a/src/codex_logdatenbank_wartung/watchdog.py +++ b/src/codex_logdatenbank_wartung/watchdog.py @@ -258,8 +258,16 @@ 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", "/T", "/F", "/PID", str(orphan.pid)], + ["taskkill", "/F", "/PID", str(orphan.pid)], check=True, capture_output=True, **no_window_kwargs(), diff --git a/src/codex_logdatenbank_wartung/zombie_killer_integration.py b/src/codex_logdatenbank_wartung/zombie_killer_integration.py index d8ace3e..58efc5c 100644 --- a/src/codex_logdatenbank_wartung/zombie_killer_integration.py +++ b/src/codex_logdatenbank_wartung/zombie_killer_integration.py @@ -13,6 +13,7 @@ import json import os +import signal import subprocess import sys from collections.abc import Callable @@ -82,6 +83,22 @@ def to_text(self) -> str: 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 @@ -261,6 +278,10 @@ def _zombie_killer_env(config: MaintenanceConfig) -> dict[str, str]: return env +def _watch_pid_file(state_dir: Path) -> Path: + return state_dir / "watch.pid" + + def launch_zombie_killer_watch( config: MaintenanceConfig, *, @@ -268,7 +289,19 @@ def launch_zombie_killer_watch( min_age_seconds: int | None = None, popen: Callable[..., subprocess.Popen[str]] | None = None, ) -> ZombieKillerLaunchResult: - """Starte `python -m zombie_killer_tray watch` als eigenen Subprozess. + """Starte `python -m zombie_killer_tray watch` als eigenen, langlebigen + Subprozess. + + KEIN `--parent-pid`: Review-Fund (T-20260926-212716751) -- 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 `command_zombie_killer_watch` zurueckkehrt; wuerde + diese eigene, kurzlebige PID als `--parent-pid` durchgereicht, stuerbe + der Watcher innerhalb von rund einer Sekunde nach dem Start, bevor er + ueberhaupt einen Zyklus lief. Der Lebenszyklus des Watchers wird + stattdessen unabhaengig ueber eine PID-Datei verwaltet + (`stop_zombie_killer_watch()`), nicht ueber die Lebenszeit des + aufrufenden Prozesses. Laufzeitzustand (`zombie_events.jsonl`, `zombie_worker_errors.log`) landet im zombie-killer-eigenen Statusordner unterhalb von CODEX_HOME @@ -284,7 +317,6 @@ def launch_zombie_killer_watch( "--yes", "--interval", str(interval_seconds or config.zombie_killer_watch_interval_seconds), "--min-age", str(min_age_seconds or config.zombie_killer_min_age_seconds), - "--parent-pid", str(os.getpid()), ] env = _zombie_killer_env(config) @@ -307,12 +339,15 @@ def launch_zombie_killer_watch( last_error = str(exc) continue pid = getattr(process, "pid", None) + pid_int = int(pid) if isinstance(pid, int) else None + if pid_int is not None: + _watch_pid_file(state_dir).write_text(str(pid_int), encoding="utf-8") return ZombieKillerLaunchResult( status="ok", command=command, - message="zombie-killer-tray wurde als eigener Watch-Subprozess gestartet.", + message="zombie-killer-tray wurde als eigener, langlebiger Watch-Subprozess gestartet.", state_dir=str(state_dir), - pid=int(pid) if isinstance(pid, int) else None, + pid=pid_int, ) return ZombieKillerLaunchResult( @@ -323,6 +358,49 @@ def launch_zombie_killer_watch( ) +def _is_our_watch_process(pid: int) -> bool | None: + """True/False if verifiable, None if psutil is unavailable (best-effort: + zombie-killer-tray always pulls psutil in as its own dependency, so this + is only missing if the extra itself was never installed).""" + try: + import psutil + except ImportError: + return None + try: + return "zombie_killer_tray" in " ".join(psutil.Process(pid).cmdline()) + except psutil.Error: + return False + + +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.""" + pid_file = _watch_pid_file(config.zombie_killer_state_dir) + if not pid_file.exists(): + return ZombieKillerStopResult(status="not-running", message="Keine PID-Datei gefunden.") + try: + pid = int(pid_file.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + pid_file.unlink(missing_ok=True) + return ZombieKillerStopResult(status="not-running", message="PID-Datei unlesbar; entfernt.") + + verified = _is_our_watch_process(pid) + if verified is False: + pid_file.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: + pid_file.unlink(missing_ok=True) + return ZombieKillerStopResult(status="already-stopped", message="Prozess lief nicht mehr.", pid=pid) + + pid_file.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(): diff --git a/tests/test_processes.py b/tests/test_processes.py index b1c463f..d2c018f 100644 --- a/tests/test_processes.py +++ b/tests/test_processes.py @@ -233,6 +233,23 @@ def test_runtime_orphan_finder_covers_mcp_nodes_and_common_language_servers() -> )] == [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_zombie_killer_integration.py b/tests/test_zombie_killer_integration.py index 8c021f1..984c29a 100644 --- a/tests/test_zombie_killer_integration.py +++ b/tests/test_zombie_killer_integration.py @@ -1,10 +1,16 @@ from __future__ import annotations +import contextlib import json +import os +import signal import subprocess +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, @@ -12,6 +18,7 @@ build_zombie_killer_status, install_zombie_killer_package, launch_zombie_killer_watch, + stop_zombie_killer_watch, zombie_killer_install_target, ) @@ -94,10 +101,14 @@ def fake_popen(command, **kwargs): 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 "--parent-pid" 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" + assert (config.zombie_killer_state_dir / "watch.pid").read_text(encoding="utf-8") == "4242" def test_launch_zombie_killer_watch_falls_back_to_config_defaults(tmp_path: Path) -> None: @@ -153,3 +164,86 @@ def test_build_zombie_killer_status_handles_missing_log(tmp_path: Path) -> None: 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: + 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, so this exercises the cmdline check + # refusing to kill an unrelated process that happens to have reused the pid. + (state_dir / "watch.pid").write_text(str(os.getpid()), 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_reports_already_stopped_for_a_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("999999999", encoding="utf-8") + monkeypatch.setattr( + "codex_logdatenbank_wartung.zombie_killer_integration._is_our_watch_process", + lambda pid: None, # simulate psutil unavailable/inconclusive -- still must not crash + ) + + 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) + + result = launch_zombie_killer_watch(config, interval_seconds=3, min_age_seconds=30) + assert result.status == "ok", result.message + 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) From 2a7f51e75e45a1d89a193b60b74f9ab7cba48884 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sat, 26 Sep 2026 16:08:48 +0200 Subject: [PATCH 7/7] fix: fail-closed stop, double-start guard, real watcher PID, preview test Re-review findings, all 4 addressed: (1) stop was fail-open on unverifiable PIDs. _is_our_watch_process() returned None when psutil was unavailable, and stop treated "not False" as "safe to kill" -- so a stale, unverifiable PID file could terminate an unrelated process that happened to reuse the PID. Replaced with _verify_watch_process(pid, expected_create_time), and stop now refuses (status="verification-unavailable", process untouched) whenever verification returns None, never falling through to os.kill(). Declared psutil explicitly in pyproject.toml's build extra (was only relying on it arriving transitively via zombie-killer-tray). Also added the create_time cross-check the review recommended: the PID file now stores {"pid", "create_time"} (written from the real watcher's own psutil.Process(pid).create_time() right after spawn), and verification compares it against the live process's actual create_time -- catches PID reuse even when the cmdline substring check alone might not (e.g. a same-named relaunch). (2) A second `zombie-killer-watch` silently overwrote watch.pid, orphaning the first (still-running) watcher. launch_zombie_killer_watch() now reads and verifies any existing PID file before spawning: a confirmed-alive watcher refuses a second start (status="already-running"); an unverifiable one refuses too, fail-closed, same direction as (1) (status="verification-unavailable"); only a confirmed-dead/stale entry is cleared and a fresh watcher started. (3) The integration test ran the real watch subprocess with --yes, meaning it could actually reap real, qualifying orphan processes on whatever machine ran it (including CI). Added apply: bool = True to launch_zombie_killer_watch() (--yes only when apply is True) and switched the integration test to apply=False -- proves the lifecycle fix (survives its launcher, responds to stop) without ever being armed to terminate anything. (4) In a frozen (PyInstaller) build, sys.executable is the packaged host app, not an interpreter, so launching via the `py -3` Python Launcher was the only remaining candidate -- but py.exe spawns the real interpreter as its OWN child process (Windows has no exec()), so the PID Popen returned was the launcher's, not the worker's. Killing the launcher on stop never touched its child, leaving it running as an unreachable orphan. Replaced the multi-candidate launch loop with _resolve_watch_python_executable(): not frozen -> sys.executable directly (already a real interpreter); frozen -> resolve the real path ONCE via each launcher candidate's own `-c "import sys; print(sys.executable)"` stdout, then spawn the watch subprocess directly against that resolved path, never through py.exe. Added a unit test simulating the frozen case with a mocked runner. 449/449 tests green (6 new: preview-mode, 2 double-start-guard directions, fail-closed-stop, frozen-resolution unit test, plus the already-existing areas re-verified; same 3 known-unrelated baseline failures unchanged), ruff clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PTbvD41MCVmnQWaobvHfCk --- THIRD_PARTY_LICENSES.txt | 1 + pyproject.toml | 4 + .../zombie_killer_integration.py | 253 ++++++++++++------ tests/test_zombie_killer_integration.py | 184 ++++++++++++- 4 files changed, 353 insertions(+), 89 deletions(-) diff --git a/THIRD_PARTY_LICENSES.txt b/THIRD_PARTY_LICENSES.txt index 9e4ad89..96abc20 100644 --- a/THIRD_PARTY_LICENSES.txt +++ b/THIRD_PARTY_LICENSES.txt @@ -36,6 +36,7 @@ CareCenter for Codex is licensed under the MIT License. See `LICENSE` and `NOTIC |---|---|---:|---:|---|---| | 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/ | diff --git a/pyproject.toml b/pyproject.toml index 04f4317..755f009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,10 @@ 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/zombie_killer_integration.py b/src/codex_logdatenbank_wartung/zombie_killer_integration.py index 58efc5c..923e8c1 100644 --- a/src/codex_logdatenbank_wartung/zombie_killer_integration.py +++ b/src/codex_logdatenbank_wartung/zombie_killer_integration.py @@ -178,20 +178,37 @@ def _pip_command_candidates() -> list[list[str]]: return unique -def _python_command_candidates() -> list[list[str]]: - candidates: list[list[str]] = [] +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): - candidates.append([sys.executable]) - candidates.extend((["py", "-3"], ["python"])) - - 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 + 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]: @@ -282,111 +299,187 @@ 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. - KEIN `--parent-pid`: Review-Fund (T-20260926-212716751) -- 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 `command_zombie_killer_watch` zurueckkehrt; wuerde - diese eigene, kurzlebige PID als `--parent-pid` durchgereicht, stuerbe - der Watcher innerhalb von rund einer Sekunde nach dem Start, bevor er - ueberhaupt einen Zyklus lief. Der Lebenszyklus des Watchers wird - stattdessen unabhaengig ueber eine PID-Datei verwaltet - (`stop_zombie_killer_watch()`), nicht ueber die Lebenszeit des - aufrufenden Prozesses. + `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 (T-20260926-212716751); CareCenter - liest hier nichts direkt in den laufenden Prozess hinein, sondern nur die - JSONL-Datei danach (siehe `build_zombie_killer_status`). + 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", + *(["--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 - last_error = "" - last_command: list[str] = [] + 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) + ) - for python_command in _python_command_candidates(): - command = [*python_command, "-m", "zombie_killer_tray", *args] - last_command = command - try: - process = run( - command, - cwd=str(state_dir), - env=env, - close_fds=True, - **_no_window_kwargs(), - ) - except OSError as exc: - last_error = str(exc) - continue - pid = getattr(process, "pid", None) - pid_int = int(pid) if isinstance(pid, int) else None - if pid_int is not None: - _watch_pid_file(state_dir).write_text(str(pid_int), encoding="utf-8") + pid = getattr(process, "pid", None) + pid_int = int(pid) if isinstance(pid, int) else None + if pid_int is None: return ZombieKillerLaunchResult( - status="ok", + status="failed", command=command, - message="zombie-killer-tray wurde als eigener, langlebiger Watch-Subprozess gestartet.", + message="Subprozess lieferte keine PID.", state_dir=str(state_dir), - pid=pid_int, ) - + _write_watch_pid_file(state_dir, pid_int, _process_create_time(pid_int)) return ZombieKillerLaunchResult( - status="failed", - command=last_command, - message=last_error or "Kein Python-Befehl für zombie-killer-tray gefunden.", + status="ok", + command=command, + message="zombie-killer-tray wurde als eigener, langlebiger Watch-Subprozess gestartet.", state_dir=str(state_dir), + pid=pid_int, ) -def _is_our_watch_process(pid: int) -> bool | None: - """True/False if verifiable, None if psutil is unavailable (best-effort: - zombie-killer-tray always pulls psutil in as its own dependency, so this - is only missing if the extra itself was never installed).""" - try: - import psutil - except ImportError: - return None - try: - return "zombie_killer_tray" in " ".join(psutil.Process(pid).cmdline()) - except psutil.Error: - return False - - 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.""" - pid_file = _watch_pid_file(config.zombie_killer_state_dir) - if not pid_file.exists(): + 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.") - try: - pid = int(pid_file.read_text(encoding="utf-8").strip()) - except (OSError, ValueError): - pid_file.unlink(missing_ok=True) - return ZombieKillerStopResult(status="not-running", message="PID-Datei unlesbar; entfernt.") + pid, create_time = existing - verified = _is_our_watch_process(pid) + 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: - pid_file.unlink(missing_ok=True) + _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 ) @@ -394,10 +487,10 @@ def stop_zombie_killer_watch(config: MaintenanceConfig) -> ZombieKillerStopResul try: os.kill(pid, signal.SIGTERM) except OSError: - pid_file.unlink(missing_ok=True) + _watch_pid_file(state_dir).unlink(missing_ok=True) return ZombieKillerStopResult(status="already-stopped", message="Prozess lief nicht mehr.", pid=pid) - pid_file.unlink(missing_ok=True) + _watch_pid_file(state_dir).unlink(missing_ok=True) return ZombieKillerStopResult(status="ok", message="zombie-killer-tray wurde beendet.", pid=pid) diff --git a/tests/test_zombie_killer_integration.py b/tests/test_zombie_killer_integration.py index 984c29a..a5bcfdf 100644 --- a/tests/test_zombie_killer_integration.py +++ b/tests/test_zombie_killer_integration.py @@ -5,6 +5,7 @@ import os import signal import subprocess +import sys import time from pathlib import Path from types import SimpleNamespace @@ -108,7 +109,132 @@ def fake_popen(command, **kwargs): ) assert captured["cwd"] == str(config.zombie_killer_state_dir) assert Path(captured["cwd"]).is_dir(), "launch must create the state dir before spawning" - assert (config.zombie_killer_state_dir / "watch.pid").read_text(encoding="utf-8") == "4242" + 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: @@ -173,13 +299,17 @@ def test_stop_reports_not_running_without_a_pid_file(tmp_path: Path) -> None: 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, so this exercises the cmdline check - # refusing to kill an unrelated process that happens to have reused the pid. - (state_dir / "watch.pid").write_text(str(os.getpid()), encoding="utf-8") + # 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) @@ -187,15 +317,44 @@ def test_stop_reports_not_found_for_a_stale_pid_reused_by_something_else(tmp_pat assert not (state_dir / "watch.pid").exists(), "stale/wrong pid file must be cleaned up" -def test_stop_reports_already_stopped_for_a_dead_pid(tmp_path: Path, monkeypatch) -> None: +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("999999999", encoding="utf-8") + (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._is_our_watch_process", - lambda pid: None, # simulate psutil unavailable/inconclusive -- still must not crash + "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) @@ -223,8 +382,15 @@ def test_real_watch_subprocess_outlives_its_launcher_and_stop_terminates_it( pytest.importorskip("zombie_killer_tray") config = make_config(tmp_path) - result = launch_zombie_killer_watch(config, interval_seconds=3, min_age_seconds=30) + # 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