From 8544abd2d49b1532bd1ea1ff388a93d86ae0c4ef Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Fri, 23 Oct 2026 13:27:50 +0200 Subject: [PATCH] Make local inference limits and workstation setup reproducible --- .gitea/workflows/ci.yaml | 2 +- CHANGELOG.md | 12 ++ README.md | 29 ++- config/config.env.example | 3 + config/llama.env.example | 1 + config/models.lock.json | 51 +++++ docs/INFERENCE_LIMITS.md | 66 ++++++ docs/MIGRATION.md | 4 + docs/NEW_WORKSTATION.md | 170 +++++++++++++++ docs/WORKSTATION.md | 6 + install.sh | 20 +- libexec/hermes-stack-doctor | 12 +- libexec/hermes-stack-llama-profile | 2 +- libexec/hermes-stack-llama-report | 2 +- libexec/hermes-stack-llama-restart | 2 +- libexec/hermes-stack-llama-run | 6 +- libexec/hermes-stack-llama-watchdog | 11 + libexec/hermes-stack-model | 55 +++++ scripts/llama_watchdog.py | 91 ++++++++ scripts/prepare-workstation.py | 198 ++++++++++++++++++ .../user/hermes-stack-llama-watchdog.service | 9 + .../user/hermes-stack-llama-watchdog.timer | 11 + systemd/user/llama-qwen.service | 2 +- tests/smoke.sh | 2 + tests/test_llama.py | 2 +- tests/test_llama_watchdog.py | 118 +++++++++++ tests/test_workstation.py | 146 +++++++++++++ uninstall.sh | 10 +- 28 files changed, 1022 insertions(+), 21 deletions(-) create mode 100644 config/models.lock.json create mode 100644 docs/INFERENCE_LIMITS.md create mode 100644 docs/NEW_WORKSTATION.md create mode 100755 libexec/hermes-stack-llama-watchdog create mode 100755 libexec/hermes-stack-model create mode 100644 scripts/llama_watchdog.py create mode 100755 scripts/prepare-workstation.py create mode 100644 systemd/user/hermes-stack-llama-watchdog.service create mode 100644 systemd/user/hermes-stack-llama-watchdog.timer create mode 100644 tests/test_llama_watchdog.py create mode 100644 tests/test_workstation.py diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index d28286a..c71dcc6 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -12,6 +12,6 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install test dependencies - run: pacman -Syu --noconfirm bash shellcheck ripgrep python jq + run: pacman -Syu --noconfirm bash shellcheck ripgrep python python-yaml jq - name: Run smoke tests run: ./tests/smoke.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fdf8ab..d1fb59f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# 2026-10-23 — Routineprofil und Begrenzung lokaler Inferenz + +- Qwen3.5-4B als Alltagsmodell, 27B als explizite Alternative und bestehendes + CPU-Kompressionsmodell mit festen Revisionen, Größen und SHA-256 erfasst. +- Offline-/Download-Vorbereitung, portable Profile und gezielte Hermes- + Konfigurationsübernahme mit Backups und optionalem Lösen lokaler Cronbindungen. +- 2048-Tokens-Serverstandard, Hermes-Requestlimits und unabhängiger 15-Sekunden- + Slot-Watchdog gegen zu lange oder endlose Modellantworten ergänzt. +- `hermes-stack-model` ohne Pfad, mit Status, Vorprüfung und Fehler-Rückweg. +- Neuinstallation, Migration, Modellwahl, Aktivierung und Grenzen dokumentiert; + Tests für Prüfsummen, Konfigurationsschutz, Modellwechsel und Recovery. + # 2026-10-21 — Erfolgreiche Hermes-Prüfung ohne Versionswechsel - Ein unveränderter Commit nach erfolgreichem `hermes update` gilt als Erfolg: diff --git a/README.md b/README.md index 2a866d6..3f6f4e6 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,14 @@ Reproduzierbares Benutzer-Deployment für Hermes Agent auf Arch Linux mit: - optionalem CUDA-Build von llama-server, llama-cli und llama-bench - Qwen-/RTX-3080-Profilen mit prüfbarem Wechsel und Rückweg -Stand: 2026-10-21. Für deine **bestehende Workstation** zuerst +Stand: 2026-10-23. Die Anleitung für das **aktuelle Setup auf einem neuen Rechner** +steht in [NEW_WORKSTATION.md](docs/NEW_WORKSTATION.md): feste Modellrevisionen und +Prüfsummen, Vorbereitung der Profile und Hermes-Limits, Aktivierung und Rückweg. +Für deine **bestehende Workstation** zuerst [`docs/WORKSTATION.md`](docs/WORKSTATION.md) lesen. Der Schnellstart unten ist für -eine neue Maschine. Hardware-Leistungsgewinne sind noch nicht auf deiner GPU -gemessen; die kurze Funktions- und VRAM-Abnahme vom 12.10.2026 ist in -[RTX3080.md](docs/RTX3080.md) dokumentiert. Der Installer richtet keinen +eine neue Maschine. Das Routineprofil nutzt Qwen3.5-4B; der kurze lokale Texttest +vom 23.10.2026 erreichte rund 130–136 Tokens/s. Das 27B-Modell bleibt optional. +Frühere VRAM-Messungen stehen in [RTX3080.md](docs/RTX3080.md). Der Installer richtet keinen Gitea-Server ein und pusht keine Änderungen. Das Repository enthält **keine Zugangsdaten, Signal-Sitzung oder @@ -61,7 +64,9 @@ hermes gateway install --force ``` In Hermes als lokalen OpenAI-kompatiblen Endpunkt `http://127.0.0.1:8080/v1` -und als Modell-ID `qwen38-27b-local` wählen. Bestehende Hermes-Konfigurationen, +und als Modell-ID `hermes-local` wählen, wenn die aktuellen Routine-/Large-Profile +nach [NEW_WORKSTATION.md](docs/NEW_WORKSTATION.md) vorbereitet wurden. Der ältere +HF-Schnellstart verwendet weiterhin `qwen38-27b-local`. Bestehende Hermes-Konfigurationen, Profile, Skills und Memories werden nicht durch generische Vorgaben ersetzt. Für Neuinstallationen diese Einstellungen interaktiv vornehmen oder die eigenen Daten gemäß Migration sicher übernehmen. Der Alias allein setzt kein Kontextlimit. @@ -83,6 +88,7 @@ Maintenance aktivieren: systemctl --user enable --now \ hermes-stack-hermes-update.timer \ hermes-stack-health.timer \ + hermes-stack-llama-watchdog.timer \ hermes-stack-signal-health.timer \ hermes-stack-signal-update-check.timer ``` @@ -116,12 +122,25 @@ Abschließend: Ohne Optionen installiert das Skript nur die versionierten Dateien und legt fehlende Beispielkonfigurationen an. Bestehende lokale Konfigurationen werden nicht überschrieben. +`hermes-stack-model` wird über `~/.local/bin` ohne Pfadangabe aufrufbar. Der +separate Helfer `scripts/prepare-workstation.py` stellt auf Wunsch die fixierten +Modelldateien bereit und übernimmt gezielt die aktuellen Hermes-Einstellungen. Ersetzte Wrapper und Units werden vorher gesichert. Eine vorhandene hermes-gateway.service wird nicht neu erzeugt. Ein Modell wird erst beim echten Start des llama-Dienstes geladen, nicht bei der Konfigurationsprüfung. ## RTX 3080 und Qwen +Die aktuellen Schutzmaßnahmen gegen endlose Modellantworten und die explizite +Umschaltung zwischen Routine- und großem Modell stehen in +[INFERENCE_LIMITS.md](docs/INFERENCE_LIMITS.md). + +```bash +hermes-stack-model status +hermes-stack-model routine +hermes-stack-model large +``` + ```bash ~/.local/libexec/hermes-stack-llama-run --check ~/.local/libexec/hermes-stack-llama-profile list diff --git a/config/config.env.example b/config/config.env.example index 9c113a4..a9ca145 100644 --- a/config/config.env.example +++ b/config/config.env.example @@ -7,6 +7,9 @@ HERMES_BRANCH="main" GATEWAY_SERVICE="hermes-gateway.service" LLAMA_SERVICE="llama-qwen.service" LLAMA_URL="http://127.0.0.1:8080" +# Independent slot watchdog. Active requests only; updates hold the same lock. +LLAMA_MAX_REQUEST_SECONDS=600 +LLAMA_MAX_OUTPUT_TOKENS=2048 # 1: inaktiven Dienst wieder starten. HTTP-Timeouts allein starten nie neu. # Standard 0 verhindert unerwünschte Modellstarts während Wartung/Tuning. LLAMA_HEALTH_AUTORESTART=0 diff --git a/config/llama.env.example b/config/llama.env.example index 4edff48..1ae5c89 100644 --- a/config/llama.env.example +++ b/config/llama.env.example @@ -8,6 +8,7 @@ LLAMA_ALIAS="qwen38-27b-local" LLAMA_MMPROJ="" LLAMA_HOST="127.0.0.1" LLAMA_PORT="8080" +LLAMA_MAX_OUTPUT_TOKENS=2048 # Standard für eine gemeinsam mit dem Desktop genutzte GPU: 64K / 4096 MiB. # Fit ist ein Planungsziel; die freie Reserve auf der Zielmaschine messen. diff --git a/config/models.lock.json b/config/models.lock.json new file mode 100644 index 0000000..892f0a2 --- /dev/null +++ b/config/models.lock.json @@ -0,0 +1,51 @@ +{ + "llama_cpp_commit": "d81235049384534c167caea52b85a694f6103d14", + "models": { + "routine": { + "repo": "unsloth/Qwen3.5-4B-GGUF", + "revision": "e87f176479d0855a907a41277aca2f8ee7a09523", + "directory": "qwen35-4b/e87f176479d0855a907a41277aca2f8ee7a09523", + "files": [ + { + "name": "Qwen3.5-4B-Q4_K_M.gguf", + "sha256": "00fe7986ff5f6b463e62455821146049db6f9313603938a70800d1fb69ef11a4", + "size": 2740937888 + }, + { + "name": "mmproj-F16.gguf", + "sha256": "cd88edcf8d031894960bb0c9c5b9b7e1fea6ebee02b9f7ce925a00d12891f864", + "size": 672423616 + } + ] + }, + "large": { + "repo": "ggml-org/Qwen3.8-27B-GGUF", + "revision": "0669b98607d47046c7c2b3f801011d54a08cfccf", + "directory": "qwen38-27b/0669b98607d47046c7c2b3f801011d54a08cfccf", + "files": [ + { + "name": "Qwen3.8-27B-Q4_K_M.gguf", + "sha256": "31629f53165ab6a7dad8c9847dcfd1fdf55829dac1e6e748f4a68581b0033d34", + "size": 18973870432 + }, + { + "name": "mmproj-Qwen3.8-27B-Q8_0.gguf", + "sha256": "2e968a6af97ce35d8971890b257b9b7edabf20ad91450501fa53162a19ee33eb", + "size": 629247008 + } + ] + }, + "compression": { + "repo": "ggml-org/Qwen3-1.7B-GGUF", + "revision": "daeb8e2d528a760970442092f6bf1e55c3b659eb", + "directory": "qwen3-1.7b-compression", + "files": [ + { + "name": "Qwen3-1.7B-Q4_K_M.gguf", + "sha256": "d2387ca2dbfee2ffabce7120d3770dadca0b293052bc2f0e138fdc940d9bc7b5", + "size": 1282439264 + } + ] + } + } +} diff --git a/docs/INFERENCE_LIMITS.md b/docs/INFERENCE_LIMITS.md new file mode 100644 index 0000000..f6d240c --- /dev/null +++ b/docs/INFERENCE_LIMITS.md @@ -0,0 +1,66 @@ +# Lokale Inferenz begrenzen + +Die Workstation verwendet `hermes-local` als stabilen API-Modellnamen. Das +Routineprofil lädt Qwen3.5-4B Q4_K_M (Unsloth-Revision +`e87f176479d0855a907a41277aca2f8ee7a09523`), das große Profil weiterhin das +vorhandene Qwen3.8-27B. Die tatsächliche Auswahl steht in +`~/.config/hermes-stack-maintenance/llama.env`. + +`hermes-stack-model routine` bzw. `hermes-stack-model large` schaltet explizit um +und beendet dabei laufende Gateway-Anfragen. Beide Dateien bleiben erhalten. +Es wird nur ein Hauptmodell gleichzeitig geladen. Die lokalen profilspezifischen +Konfigurationen `llama-routine.env` und `llama-large.env` enthalten absolute +Modellpfade. `scripts/prepare-workstation.py` prüft bzw. lädt die fest versionierten +Modelldateien und erzeugt fehlende Profile. Die vollständige Neuinstallation ist +in [NEW_WORKSTATION.md](NEW_WORKSTATION.md) beschrieben. Der normale Installer +allein lädt keine Gewichte und erzeugt diese Profile nicht. + +Die alten Aliasse `qwen38-27b-local` und `qwen36-27b-local` bleiben als +Kompatibilitätsnamen für bestehende Sitzungen erreichbar. Sie bezeichnen dann +ebenfalls das aktiv ausgewählte Modell; die tatsächliche Modelldatei zeigt +`/props.model_path`. Neue Sitzungen und entbundene Cronjobs verwenden `hermes-local`. + +## Drei Schutzebenen + +- Der Runner setzt `--n-predict 2048` als Serverstandard. Das ist kein harter + Schutz gegen abweichende Clientparameter. +- In Hermes bekommt der lokale Custom-Provider `extra_body.max_tokens: 2048`, + `t_max_predict_ms: 540000` und ein Request-Timeout von 600 Sekunden. Der native + Generierungszeitgeber von llama.cpp prüft nur bei Zeilenumbrüchen und ersetzt + deshalb keine unabhängige Überwachung. `agent.run_budget_seconds: 1800` begrenzt + einen ganzen Auftrag einschließlich mehrerer Modellaufrufe und Wiederholungen. +- `hermes-stack-llama-watchdog.timer` prüft alle 15 Sekunden `/slots`. Bleibt + dieselbe Anfrage 600 Sekunden beobachtet aktiv oder überschreitet sie 2048 + Tokens, startet die Überwachung den Hauptmodellserver neu. Dieses letzte + Auffangnetz hat eine Verzögerung von bis zu einem Prüfintervall nach erstmaliger + Beobachtung. Die Tokenzahl kann zwischen zwei Prüfungen überschritten werden. + +Der Watchdog startet keine absichtlich gestoppten Dienste. Einzelne fehlgeschlagene +Proben führen nicht zum Neustart; erst 600 Sekunden zusammenhängende Fehler bei +demselben aktiven Server. Wartung, Modellwechsel und Updater verwenden dieselbe +Sperre. Neustarts haben mindestens 600 Sekunden Abstand. Ein Bootwechsel setzt +monotone Zeitstempel zurück. Der Status speichert keine Prompts oder Antworten; +Diagnosen stehen ausschließlich im lokalen Journal, ohne Chatbenachrichtigung. + +Die Grenzen stehen in `config.env` (Watchdog), `llama.env` (Serverstandard) und +`~/.hermes/config.yaml` (Hermes). Bei Änderungen diese Werte gemeinsam pflegen. +Grenzen gelten pro Hauptmodell; das CPU-Kompressionsmodell bleibt getrennt. + +## Prüfung und Rücknahme + +``` +systemctl --user status hermes-stack-llama-watchdog.timer +journalctl --user -u hermes-stack-llama-watchdog.service +cat ~/.local/state/hermes-stack-maintenance/llama-watchdog.json +``` + +Die Bereitstellung sichert vorherige Konfigurationen unter +`~/.local/state/hermes-stack-maintenance/inference-limits-*`. Ein Rückwechsel +auf `large` behält die Schutzmaßnahmen bei. Zum vollständigen Rückbau Timer +deaktivieren, die gesicherten Konfigurationen wiederherstellen und Modellserver +sowie Gateway neu starten. Cronjob-Modellbindungen sind über `hermes cron edit +JOB_ID --model MODELL --provider custom` wiederherstellbar. + +Quellen: [llama.cpp Serverparameter](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md), +[Qwen3.5-4B](https://huggingface.co/Qwen/Qwen3.5-4B), +[verwendete GGUF-Dateien](https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/tree/e87f176479d0855a907a41277aca2f8ee7a09523). diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index b12cc64..c91ca16 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -1,5 +1,9 @@ # Migration auf eine neue Arch-Linux-Maschine +Für den aktuellen Routine-/Large-Betrieb samt Limits zuerst +[NEW_WORKSTATION.md](NEW_WORKSTATION.md) verwenden. Die folgenden Abschnitte +ergänzen die Übernahme persönlicher Daten und die ältere manuelle Modellkonfiguration. + Das Gitea-Repository reproduziert Programme, Units und Wartungslogik. Persönliche Hermes-Daten, Secrets, Signal-Geräteidentität und Modellgewichte werden separat übertragen. diff --git a/docs/NEW_WORKSTATION.md b/docs/NEW_WORKSTATION.md new file mode 100644 index 0000000..e7d3115 --- /dev/null +++ b/docs/NEW_WORKSTATION.md @@ -0,0 +1,170 @@ +# Aktuelles Workstation-Setup neu installieren + +Stand: 23.10.2026. Diese Anleitung stellt den aktuellen lokalen Modellbetrieb +wieder her: Qwen3.5-4B für den Alltag, Qwen3.8-27B als explizite Alternative, +Qwen3-1.7B für Kompression auf der CPU, 64K Kontext sowie Antwort- und Zeitlimits. +Die Modelle werden nacheinander betrieben; der Kompressionsserver läuft separat. + +## 1. Voraussetzungen und Deployment + +Ziel ist Arch Linux mit systemd-User-Sitzung, einer funktionierenden NVIDIA-/CUDA- +Installation und ausreichend RAM und VRAM. Die kurze Abnahme erfolgte auf einer +RTX 3080 mit 10 GB VRAM und 64 GB RAM. Der Installer richtet keine Grafiktreiber +ein. Für andere GPUs muss der CUDA-Build angepasst und die Speicherbelegung +neu geprüft werden. Das Routineprofil erzwingt vollständigen GPU-Offload. + +```bash +git clone https://gitea.jr-it-services.de/johannes/hermes-arch-stack.git +cd hermes-arch-stack +./install.sh --install-packages --install-hermes +``` + +Für einen CUDA-Quellbuild des hier geprüften llama.cpp-Standes: + +```bash +sudo pacman -Syu --needed base-devel cmake ninja cuda openssl +./scripts/install-llama-cpp.sh \ + --ref d81235049384534c167caea52b85a694f6103d14 --cuda-arch 86 +``` + +`86` ist für die RTX 3080, auf anderer Hardware die passende Architektur setzen. +Alternativ eine vorhandene kompatible `llama-server`-Binary verwenden; später +beim Vorbereiten `--server-bin /usr/bin/llama-server` ergänzen. Der Quellbuild +setzt einen neuen Compiler/CUDA-Stand voraus und wurde hier nicht erneut gebaut. + +Das Deployment installiert `hermes-stack-model` nach `~/.local/bin`. Falls dieses +Verzeichnis in einer neuen Shell noch nicht im PATH steht, einmal in die eigene +Shell-Konfiguration aufnehmen und eine neue Shell starten: + +```bash +export PATH="$HOME/.local/bin:$PATH" +``` + +## 2. Eigene Hermes-Daten und Plattformen + +Bei einer neuen Hermes-Installation zuerst `hermes setup` ausführen. Für die +Übernahme der vorhandenen Chats, Skills, Plugins, Cronjobs und Zugangsdaten +stattdessen die privaten Daten gemäß [MIGRATION.md](MIGRATION.md) wiederherstellen. +Das Repository enthält diese Daten nicht. Die gesamte persönliche Konfiguration +kann aus dem Quellcode allein nicht wiederhergestellt werden. + +Vorbereitung auf der alten Maschine: Gateway und Maintenance-Timer kontrolliert +anhalten und die privaten Hermes-Daten, `~/.config/hermes-stack-maintenance/`, +gegebenenfalls `~/HermesWorkspace/` und die Signal-Geräteidentität verschlüsselt +sichern. Auch private lokale Plugins und eigene Skills berücksichtigen. +Signal auf dem neuen Rechner separat verknüpfen oder die Geräteidentität nur +auf einem Rechner gleichzeitig betreiben. Secrets gehören nicht nach Gitea. + +Nach dem Import absolute Pfade auf alten Benutzernamen prüfen. Besonders +`terminal.docker_volumes` und `HERMES_REPO`/`HERMES_ENV` können noch auf das alte +Home zeigen. Private Konfigurationsdateien gezielt bearbeiten und nicht blind +alte systemd-Units samt Drop-ins über die neue Installation kopieren. + +Wenn das bisherige Docker-Terminal übernommen wird, Docker installieren und +starten, dem Benutzer die erforderlichen Docker-Rechte geben und neu anmelden. +Das eigene Arbeitsverzeichnis anlegen, das Netzwerk `hermes-net` bereitstellen +und das in der persönlichen Hermes-Konfiguration angegebene Container-Image +laden. Die alten DNS-Adressen sind nur im ursprünglichen Netzwerk gültig. +Der Modellhelfer verändert Terminal-, Netzwerk-, Plugin- und Auth-Einstellungen +nicht. + +## 3. Modelle und Schutzmechanismen vorbereiten + +Während dieser Konfigurationsänderung muss der Gateway gestoppt sein: + +```bash +systemctl --user stop hermes-gateway.service +python3 scripts/prepare-workstation.py \ + --download --models routine,large,compression \ + --select routine --configure-hermes --unpin-local-cron +``` + +Bei noch nicht installierter Gateway-Unit ist deren Stop-Fehler unkritisch. +Die Vorbereitung startet selbst keine Dienste. Sie prüft vor Änderungen, dass +kein Gateway läuft, und teilt das Maintenance-Lock mit Updater und Watchdog. + +Alle fünf Modelldateien zusammen benötigen rund **22,6 GiB**. Wer zunächst nur +das Alltagsmodell möchte, verwendet `--models routine`. Für Routine plus CPU- +Kompression `--models routine,compression`. Ohne `--download` müssen die Dateien +bereits im erwarteten Modellverzeichnis liegen; das ermöglicht eine Offline- +Übernahme. `--model-dir /anderer/absoluter/pfad` unterstützt eine andere Ablage. + +`config/models.lock.json` hält für jede Datei Repository, vollständige Revision, +Dateigröße und SHA-256 fest. Downloads erfolgen ausschließlich von der fixierten +Revision, werden zunächst als `.part` gespeichert und erst nach erfolgreicher +Prüfung umbenannt. Beschädigte vorhandene Dateien werden gemeldet und behalten. +Fehlgeschlagene Downloads können erneut gestartet werden; bei einem falschen +Partial-Download muss die betroffene `.part` vor erneutem Download entfernt werden. + +Der Helfer erzeugt fehlende Dateien `llama-routine.env`, `llama-large.env` und +`compression.env` für den jeweiligen Benutzernamen. Bereits vorhandene Profile +und deren Tuning bleiben erhalten. `--select routine` übernimmt das vorbereitete +Routineprofil ausdrücklich als aktive `llama.env` und sichert deren alten Stand. +Soll nach einer Migration ein Modellpfad geändert werden, vorhandene Profile +vorher gezielt sichern und anpassen oder zur Neuerzeugung aus dem Konfigurations- +verzeichnis verschieben. Das gilt auch für `LLAMA_SERVER_BIN`. + +`--configure-hermes` aktualisiert mit Backup die lokalen Provider-, Vision- und +optional Kompressionseinstellungen, das 30-Minuten-Auftragsbudget und die +600-Sekunden-/2048-Token-Limits. Fremde Provider, Terminal, Plugins und andere +Einstellungen bleiben bestehen. `--unpin-local-cron` löst nur bekannte lokale +Modellbindungen über die Hermes-CLI; eigene Cloud-Provider bleiben gebunden. +Die Option legt keine Cronjobs an. Vorhandene Cronjobs kommen aus dem privaten +Hermes-Backup. Details und Grenzen des Watchdogs: [INFERENCE_LIMITS.md](INFERENCE_LIMITS.md). + +## 4. Prüfen und aktivieren + +```bash +~/.local/libexec/hermes-stack-llama-run --check +HERMES_LLAMA_CONFIG="$HOME/.config/hermes-stack-maintenance/compression.env" \ + ~/.local/libexec/hermes-stack-llama-run --check +hermes config check +hermes gateway install --force +systemctl --user daemon-reload +systemctl --user enable --now llama-qwen.service +systemctl --user enable --now llama-compression.service +# Nach Einrichtung von signal-cli und dessen Konfiguration: +# systemctl --user enable --now signal-cli.service +systemctl --user enable --now hermes-gateway.service +systemctl --user enable --now \ + hermes-stack-llama-watchdog.timer \ + hermes-stack-health.timer \ + hermes-stack-hermes-update.timer +``` + +Bei einer Vorbereitung ohne Kompressionsmodell dessen Prüfung und Dienst +weglassen. Signal-Wartungstimer erst nach der Signal-Einrichtung aktivieren. +Ein lokaler Modellserver kann beim Laden kurz HTTP 503 liefern. + +```bash +curl -fsS http://127.0.0.1:8080/health +hermes-stack-model status +systemctl --user status hermes-stack-llama-watchdog.timer +~/.local/libexec/hermes-stack-doctor +``` + +Danach eine deutsche Textantwort, ein Bild und einen Werkzeugaufruf in Hermes +prüfen. Der bisherige Live-Test erreichte bei kurzen Textanfragen etwa +130–136 Tokens/s; das ist kein Benchmark für lange Kontexte oder Bildaufgaben. +Eine erzwungene lange Antwort endete bei genau 2048 Tokens, und ein reiner +Tool-Call wurde korrekt als JSON erzeugt. Ein zehnminütiger echter Hänger wurde +nicht absichtlich erzeugt; dessen Watchdog-Recovery ist automatisiert simuliert. + +## Alltag und Rückweg + +```bash +hermes-stack-model # aktives Modell anzeigen +hermes-stack-model routine # schnelles Alltagsmodell +hermes-stack-model large # größeres Modell für bewusste Einzelnutzung +``` + +Ein Wechsel stoppt laufende Gateway-Anfragen, prüft die Kandidatenkonfiguration, +sichert `llama.env`, wartet nach dem Modellstart auf `/health` und startet den +Gateway wieder. Bei Fehlern wird die vorherige Konfiguration zurückkopiert und +die Wiederaufnahme der Dienste versucht; Journal und Dienststatus kontrollieren. +Beide Modelle verwenden den stabilen API-Alias `hermes-local` und dieselben Limits. + +Automatischer Wechsel zwischen den Modellen findet nicht statt. Das große Modell +bleibt auf der 10-GB-Karte langsam. Für Betrieb ohne Anmeldung optional +`sudo loginctl enable-linger "$USER"` setzen. Vollständige Rücknahme und Sicherungs- +orte stehen in [INFERENCE_LIMITS.md](INFERENCE_LIMITS.md). diff --git a/docs/WORKSTATION.md b/docs/WORKSTATION.md index 8adb525..790673b 100644 --- a/docs/WORKSTATION.md +++ b/docs/WORKSTATION.md @@ -1,5 +1,11 @@ # Bestehende Workstation behutsam übernehmen +Aktueller Stand seit 23.10.2026: Qwen3.5-4B als Routineprofil und Qwen3.8-27B +als Alternative mit gemeinsamem Alias `hermes-local`, Token-/Zeitlimits und +Slot-Watchdog. Einrichtung und Migration: [NEW_WORKSTATION.md](NEW_WORKSTATION.md), +[INFERENCE_LIMITS.md](INFERENCE_LIMITS.md). Die nachfolgende Profilumstellung +beschreibt den früheren 27B-Betrieb und ersetzt diese aktuelle Auswahl nicht. + Nicht blind den Schnellstart für eine neue Maschine ausführen. Vorhandene Hermes-/Signal-Konfiguration bleibt privat und wird nicht durch das Repo ersetzt. Das aktuelle Deployment enthält deine gezeigten Anpassungen; weitere lokale, diff --git a/install.sh b/install.sh index c891a22..8de5f77 100755 --- a/install.sh +++ b/install.sh @@ -9,6 +9,8 @@ INSTALL_LLAMA=0 LLAMA_REF="" ENABLE_TIMERS=0 SKIP_BROWSER=1 +USER_BIN_ON_PATH=0 +[[ ":$PATH:" != *":$HOME/.local/bin:"* ]] || USER_BIN_ON_PATH=1 usage() { sed -n '/^## Installationsoptionen/,/^## /p' "$ROOT_DIR/README.md" | @@ -43,7 +45,7 @@ export PATH="$HOME/.local/bin:$PATH" if ((INSTALL_PACKAGES)); then # Vollständige Aktualisierung statt partieller Arch-Aktualisierung. packages=( - bash git curl xz jq zstd tar ripgrep ffmpeg coreutils util-linux \ + bash git curl xz jq zstd tar ripgrep ffmpeg coreutils util-linux python python-yaml \ jre-openjdk-headless ) if ((INSTALL_LLAMA)); then @@ -83,9 +85,19 @@ for file in "$ROOT_DIR"/systemd/user/*; do done printf 'Vorherige Deployment-Dateien: %s\n' "$deploy_backup" +# Public command entry point; never depend on adding libexec to PATH. +mkdir -p "$HOME/.local/bin" "$deploy_backup/bin" +command_target="$HOME/.local/bin/hermes-stack-model" +if [[ -e "$command_target" || -L "$command_target" ]]; then + cp -a -- "$command_target" "$deploy_backup/bin/" +fi +ln -sfn -- ../libexec/hermes-stack-model "$command_target" + for file in "$ROOT_DIR"/libexec/*; do install -m 0755 "$file" "$libexec_dir/$(basename "$file")" done +install -d -m 0755 "$HOME/.local/lib/hermes-stack" +install -m 0644 "$ROOT_DIR/scripts/llama_watchdog.py" "$HOME/.local/lib/hermes-stack/llama_watchdog.py" for file in "$ROOT_DIR"/systemd/user/*; do install -m 0644 "$file" "$unit_dir/$(basename "$file")" @@ -121,7 +133,7 @@ systemctl --user daemon-reload if command -v hermes >/dev/null 2>&1 && [[ ! -e "$unit_dir/hermes-gateway.service" ]]; then if [[ -d "$HOME/.hermes/hermes-agent/.git" ]]; then hermes gateway install || - printf 'Gateway-Unit konnte noch nicht erzeugt werden; nach `hermes setup` erneut ausführen.\n' >&2 + printf 'Gateway-Unit konnte noch nicht erzeugt werden; nach hermes setup erneut ausführen.\n' >&2 fi fi @@ -129,9 +141,13 @@ if ((ENABLE_TIMERS)); then systemctl --user enable --now \ hermes-stack-hermes-update.timer \ hermes-stack-health.timer \ + hermes-stack-llama-watchdog.timer \ hermes-stack-signal-health.timer \ hermes-stack-signal-update-check.timer fi printf '\nDeployment installiert. Nächster Schritt:\n %s\n' \ "$libexec_dir/hermes-stack-doctor" +if ((USER_BIN_ON_PATH == 0)); then + printf 'Für Befehle ohne Pfad ~/.local/bin in den Shell-PATH aufnehmen.\n' +fi diff --git a/libexec/hermes-stack-doctor b/libexec/hermes-stack-doctor index 312d6f8..0efe93a 100755 --- a/libexec/hermes-stack-doctor +++ b/libexec/hermes-stack-doctor @@ -15,25 +15,25 @@ bad() { printf '✗ %s\n' "$*"; failures=$((failures + 1)); } source "$CONFIG_FILE" for command in git curl jq tar zstd systemctl hermes; do - command -v "$command" >/dev/null 2>&1 && ok "$command vorhanden" || bad "$command fehlt" + if command -v "$command" >/dev/null 2>&1; then ok "$command vorhanden"; else bad "$command fehlt"; fi done for script in "$HOME/.local/libexec"/hermes-stack-*; do [[ -f "$script" ]] || continue - bash -n "$script" && ok "Syntax: ${script##*/}" || bad "Syntaxfehler: ${script##*/}" + if bash -n "$script"; then ok "Syntax: ${script##*/}"; else bad "Syntaxfehler: ${script##*/}"; fi done if [[ -d "$HERMES_REPO/.git" ]]; then ok "Hermes-Git-Checkout vorhanden" dirty="$(git -C "$HERMES_REPO" status --porcelain --untracked-files=all | grep -vxF '?? .install_method' || true)" - [[ -z "$dirty" ]] && ok "Hermes-Checkout sauber" || bad "Hermes-Checkout enthält lokale Änderungen" + if [[ -z "$dirty" ]]; then ok "Hermes-Checkout sauber"; else bad "Hermes-Checkout enthält lokale Änderungen"; fi else bad "Hermes-Git-Checkout fehlt" fi -systemctl --user is-active --quiet "$GATEWAY_SERVICE" && ok "Gateway aktiv" || bad "Gateway inaktiv" -hermes gateway status >/dev/null 2>&1 && ok "Gateway gesund" || bad "Gateway-Status fehlgeschlagen" +if systemctl --user is-active --quiet "$GATEWAY_SERVICE"; then ok "Gateway aktiv"; else bad "Gateway inaktiv"; fi +if hermes gateway status >/dev/null 2>&1; then ok "Gateway gesund"; else bad "Gateway-Status fehlgeschlagen"; fi if curl --fail --silent --max-time 5 "$LLAMA_URL/health" | jq -e '.status == "ok"' >/dev/null 2>&1; then @@ -51,7 +51,7 @@ set -u if [[ -n "${SIGNAL_HTTP_URL:-}" ]]; then status="$(curl --silent --output /dev/null --max-time 5 --write-out '%{http_code}' \ "${SIGNAL_HTTP_URL%/}/api/v1/check" || true)" - [[ "$status" =~ ^2[0-9][0-9]$ ]] && ok "signal-cli HTTP erreichbar" || bad "signal-cli HTTP nicht erreichbar" + if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then ok "signal-cli HTTP erreichbar"; else bad "signal-cli HTTP nicht erreichbar"; fi fi printf '\nFehler: %d\n' "$failures" diff --git a/libexec/hermes-stack-llama-profile b/libexec/hermes-stack-llama-profile index 332d993..badfc3d 100755 --- a/libexec/hermes-stack-llama-profile +++ b/libexec/hermes-stack-llama-profile @@ -24,7 +24,7 @@ flock -n 9 || { exit 1 } -# shellcheck disable=SC1090 +# shellcheck disable=SC1090,SC1091 source "$config_dir/llama.env" [[ -n "${LLAMA_PROFILE:-}" ]] || { printf 'Profilbetrieb ist nicht konfiguriert.\n' >&2 diff --git a/libexec/hermes-stack-llama-report b/libexec/hermes-stack-llama-report index 5ebe8cf..95b9ce6 100755 --- a/libexec/hermes-stack-llama-report +++ b/libexec/hermes-stack-llama-report @@ -3,7 +3,7 @@ set -Euo pipefail config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance" if [[ -r "$config_dir/config.env" ]]; then - # shellcheck disable=SC1090 + # shellcheck disable=SC1090,SC1091 source "$config_dir/config.env" fi printf '\nGPU (Gesamtspeicher, belegt, frei in MiB):\n' diff --git a/libexec/hermes-stack-llama-restart b/libexec/hermes-stack-llama-restart index a088a84..892ce67 100755 --- a/libexec/hermes-stack-llama-restart +++ b/libexec/hermes-stack-llama-restart @@ -8,7 +8,7 @@ if [[ "${1:-}" != --jobs-finished || $# -ne 1 ]]; then fi config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance" -# shellcheck disable=SC1090 +# shellcheck disable=SC1090,SC1091 source "$config_dir/config.env" exec 8>"${XDG_RUNTIME_DIR:-/tmp}/hermes-stack-hermes-update.lock" diff --git a/libexec/hermes-stack-llama-run b/libexec/hermes-stack-llama-run index 6caddde..d8f2953 100755 --- a/libexec/hermes-stack-llama-run +++ b/libexec/hermes-stack-llama-run @@ -27,7 +27,7 @@ while (($#)); do done if [[ -n "${LLAMA_PROFILE:-}" && -r "$config_dir/llama-profile.env" ]]; then - # shellcheck disable=SC1090 + # shellcheck disable=SC1090,SC1091 source "$config_dir/llama-profile.env" fi [[ -z "$profile_override" ]] || LLAMA_PROFILE="$profile_override" @@ -70,6 +70,9 @@ if [[ -n "${LLAMA_MMPROJ:-}" ]]; then fi [[ -z "${LLAMA_ALIAS:-}" ]] || model_args+=(--alias "$LLAMA_ALIAS") +# Server defaults are not a wall-clock limit; the independent slot watchdog is. +[[ "${LLAMA_MAX_OUTPUT_TOKENS:-2048}" =~ ^[1-9][0-9]*$ ]] || exit 2 + profile_args=() if [[ -n "${LLAMA_PROFILE:-}" ]]; then reasoning_budget=512 @@ -107,6 +110,7 @@ args=( "${model_args[@]}" --host "${LLAMA_HOST:-127.0.0.1}" --port "${LLAMA_PORT:-8080}" + --n-predict "${LLAMA_MAX_OUTPUT_TOKENS:-2048}" "${profile_args[@]}" "${LLAMA_ARGS[@]}" ) diff --git a/libexec/hermes-stack-llama-watchdog b/libexec/hermes-stack-llama-watchdog new file mode 100755 index 0000000..1f674c1 --- /dev/null +++ b/libexec/hermes-stack-llama-watchdog @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 +config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance" +set -a +# shellcheck disable=SC1091 +source "$config_dir/config.env" +set +a +exec 9>"${XDG_RUNTIME_DIR:-/tmp}/hermes-stack-hermes-update.lock" +flock -n 9 || exit 0 +exec python3 "$HOME/.local/lib/hermes-stack/llama_watchdog.py" diff --git a/libexec/hermes-stack-model b/libexec/hermes-stack-model new file mode 100755 index 0000000..b15824f --- /dev/null +++ b/libexec/hermes-stack-model @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Explicit model selection. Stops current gateway requests; keeps both model files. +set -Eeuo pipefail +umask 077 +config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance" +usage() { + printf 'Aufruf: %s [status|routine|large] (Modellwechsel beendet laufende Anfragen)\n' "$0" +} +case "${1:-}" in + ''|status) + # shellcheck disable=SC1091 + source "$config_dir/llama.env" + printf 'Aktives Modell: %s\nKonfiguration: %s/llama.env\n' "$LLAMA_MODEL" "$config_dir" + exit 0 ;; + -h|--help) usage; exit 0 ;; + routine|large) selected="$1" ;; + *) usage >&2; exit 2 ;; +esac +[[ $# -eq 1 ]] || exit 2 +exec 9>"${XDG_RUNTIME_DIR:-/tmp}/hermes-stack-hermes-update.lock" +flock -n 9 || { printf 'Maintenance läuft.\n' >&2; exit 1; } +runner="$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/hermes-stack-llama-run" +candidate="$config_dir/llama-$selected.env" +HERMES_LLAMA_CONFIG="$candidate" "$runner" --check +backup="$(mktemp "$config_dir/llama.env.before-model-switch.XXXXXX")" +cp -p -- "$config_dir/llama.env" "$backup" +systemctl --user stop hermes-gateway.service +rollback() { + local rc=$? + trap - EXIT + if ((rc != 0)); then + install -m 0600 "$backup" "$config_dir/llama.env" || true + systemctl --user restart llama-qwen.service || true + systemctl --user start hermes-gateway.service || true + printf 'Modellwechsel fehlgeschlagen; vorherige Konfiguration zurückkopiert. Dienststatus prüfen.\n' >&2 + fi + exit "$rc" +} +trap rollback EXIT +install -m 0600 "$candidate" "$config_dir/llama.env" +ready=0 +if systemctl --user restart llama-qwen.service; then + for ((i=0; i<90; i++)); do + if curl -fsS --max-time 2 http://127.0.0.1:8080/health | jq -e '.status == "ok"' >/dev/null 2>&1; then + ready=1; break + fi + sleep 2 + done +fi +if ((ready == 0)); then + exit 1 +fi +systemctl --user start hermes-gateway.service +trap - EXIT +printf 'Modellprofil %s aktiv. Vorherige Konfiguration: %s\n' "$selected" "$backup" diff --git a/scripts/llama_watchdog.py b/scripts/llama_watchdog.py new file mode 100644 index 0000000..2855562 --- /dev/null +++ b/scripts/llama_watchdog.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Bound a busy llama.cpp slot even if the client loses its cancellation path. + +Only task IDs, monotonic timestamps and service invocation IDs are persisted. +The wrapper holds the same maintenance lock as the updater and restart helper. +""" +import json +import os +from pathlib import Path +import subprocess +import time +from urllib.request import urlopen + + +def observe(previous, slots, invocation, now, seconds, tokens): + old = previous if previous.get("invocation") == invocation else {} + active = {} + reasons = [] + for slot in slots: + if not slot.get("is_processing"): + continue + if "id_task" not in slot: + raise ValueError("busy slot without task ID") + key = f'{slot["id"]}:{slot["id_task"]}' + started = old.get("active", {}).get(key, now) + if started > now: + started = now + active[key] = started + decoded = max((item.get("n_decoded", 0) for item in slot.get("next_token", [])), default=0) + if now - started >= seconds: + reasons.append(f"slot {slot['id']}: request exceeded {seconds}s") + if decoded > tokens: + reasons.append(f"slot {slot['id']}: output exceeded {tokens} tokens") + return {"invocation": invocation, "active": active}, reasons + + +def systemctl(*args): + return subprocess.run(["systemctl", "--user", *args], check=True, + capture_output=True, text=True, timeout=35).stdout.strip() + + +def main(): + service = os.environ.get("LLAMA_SERVICE", "llama-qwen.service") + url = os.environ.get("LLAMA_URL", "http://127.0.0.1:8080").rstrip("/") + seconds = int(os.environ.get("LLAMA_MAX_REQUEST_SECONDS", "600")) + tokens = int(os.environ.get("LLAMA_MAX_OUTPUT_TOKENS", "2048")) + if seconds <= 0 or tokens <= 0: + raise ValueError("watchdog limits must be positive") + state_dir = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "hermes-stack-maintenance" + state_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + path = state_dir / "llama-watchdog.json" + try: + previous = json.loads(path.read_text()) + except (OSError, ValueError): + previous = {} + boot = Path("/proc/sys/kernel/random/boot_id").read_text().strip() + if previous.get("boot") != boot: + previous = {} + status = systemctl("show", service, "--property=ActiveState", "--property=InvocationID") + info = dict(line.split("=", 1) for line in status.splitlines()) + if info.get("ActiveState") != "active": + return # Respect explicit stops and maintenance; never start an inactive server. + invocation = info["InvocationID"] + now = time.monotonic() + try: + with urlopen(url + "/slots", timeout=5) as response: + slots = json.load(response) + if not isinstance(slots, list): + raise ValueError("invalid slot response") + state, reasons = observe(previous, slots, invocation, now, seconds, tokens) + except (OSError, ValueError, KeyError, TypeError): + # A single failed probe is not evidence of a hung model. Require a full + # request budget of consecutive failures within the same server process. + since = previous.get("unreachable_since", now) if previous.get("invocation") == invocation else now + state = {"invocation": invocation, "active": previous.get("active", {}), "unreachable_since": since} + reasons = [f"slot endpoint unreachable for {seconds}s"] if now - since >= seconds else [] + last_restart = previous.get("last_restart", -1e12) + state["last_restart"] = last_restart + if reasons and now - last_restart >= max(300, seconds): + print("llama watchdog: " + "; ".join(reasons) + "; restarting " + service, flush=True) + systemctl("restart", "--no-block", service) + state = {"invocation": invocation, "active": {}, "last_restart": now} + state["boot"] = boot + pending = path.with_suffix(".pending") + pending.write_text(json.dumps(state) + "\n") + pending.chmod(0o600) + pending.replace(path) + + +if __name__ == "__main__": + main() diff --git a/scripts/prepare-workstation.py b/scripts/prepare-workstation.py new file mode 100755 index 0000000..0cbccde --- /dev/null +++ b/scripts/prepare-workstation.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Prepare the documented local model setup; never start services or send messages.""" +import argparse +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[1] +ALIASES = ["hermes-local", "qwen35-4b-local", "qwen38-27b-local", "qwen36-27b-local"] +LOCAL_URL = "http://127.0.0.1:8080/v1" + + +def verify(path, item): + if not path.is_file() or path.stat().st_size != item["size"]: + return False + with path.open("rb") as model: + return hashlib.file_digest(model, "sha256").hexdigest() == item["sha256"] + + +def ensure_model(group, model_dir, download=False): + directory = model_dir / group["directory"] + for item in group["files"]: + path = directory / item["name"] + if path.exists(): + if not verify(path, item): + raise ValueError(f"Prüfsumme falsch: {path}; vorhandene Datei bleibt unverändert") + else: + if not download: + raise FileNotFoundError(f"Modell fehlt: {path}; mit --download bereitstellen") + directory.mkdir(parents=True, exist_ok=True) + partial = path.with_name(path.name + ".part") + url = f'https://huggingface.co/{group["repo"]}/resolve/{group["revision"]}/{item["name"]}' + subprocess.run(["curl", "-fL", "--retry", "3", "--connect-timeout", "15", + "--max-time", "14400", "--continue-at", "-", url, + "--output", str(partial)], check=True) + if not verify(partial, item): + raise ValueError(f"Download-Prüfsumme falsch: {partial}; nicht aktiviert") + partial.replace(path) + print(f"SHA256 OK: {path.name}", flush=True) + return [directory / item["name"] for item in group["files"]] + + +def write_config(path, text, replace=False): + if path.exists(): + if path.read_text() == text: + return + if not replace: + print(f"Vorhandene Konfiguration beibehalten: {path}") + return + backup = path.with_name(path.name + f".before-setup-{time.time_ns()}") + shutil.copy2(path, backup) + backup.chmod(0o600) + print(f"Sicherung: {backup}") + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as pending: + pending.write(text) + staging = Path(pending.name) + staging.chmod(0o600) + staging.replace(path) + + +def profile_config(name, paths, server_bin=""): + args = ["--ctx-size", "65536", "--parallel", "1", "--jinja"] + if name == "compression": + args += ["--rope-scaling", "yarn", "--rope-scale", "2", "--yarn-orig-ctx", "32768", + "--reasoning-budget", "0", "--no-reasoning-preserve", "--n-gpu-layers", "0", + "--device", "none", "--no-op-offload", "--no-kv-offload", + "--threads", "8", "--threads-batch", "8"] + else: + args += ["--reasoning", "auto", "--reasoning-budget", "128", "--no-reasoning-preserve", + "--threads", "6", "--threads-batch", "16"] + if name == "routine": + # Fits on the verified 10-GiB workstation. Explicit layers disable fit. + args += ["--n-gpu-layers", "99", "--fit", "off"] + else: + args += ["--n-gpu-layers", "auto", "--fit", "on", "--fit-target", "4096"] + args += ["--sleep-idle-seconds", "300", "--image-min-tokens", "1024", + "--cors-origins", "localhost"] + args += ["--flash-attn", "auto", "--cache-type-k", "q8_0", "--cache-type-v", "q8_0", "--no-ui"] + settings = {"LLAMA_SERVER_BIN": server_bin, "LLAMA_MODEL": str(paths[0]), + "LLAMA_MMPROJ": str(paths[1]) if len(paths) > 1 else "", "LLAMA_HF_REPO": "", + "LLAMA_ALIAS": "qwen3-1.7b-compression" if name == "compression" else ",".join(ALIASES), + "LLAMA_HOST": "127.0.0.1", "LLAMA_PORT": "8081" if name == "compression" else "8080", + "LLAMA_PROFILE": "", "LLAMA_MAX_OUTPUT_TOKENS": "2048"} + return ("# Generated from config/models.lock.json; no service started.\n" + + "".join(f"{key}={shlex.quote(value)}\n" for key, value in settings.items()) + + "LLAMA_ARGS=(\n " + shlex.join(args) + "\n)\n") + + +def configure_hermes(config, compression=False): + config.setdefault("model", {}).update(default="hermes-local", provider="custom", base_url=LOCAL_URL) + config.setdefault("agent", {}).update(run_budget_seconds=1800, budget_warning_ratio=0.8) + config.setdefault("providers", {}).setdefault("custom", {}).update( + request_timeout_seconds=600, stale_timeout_seconds=180) + entries = config.setdefault("custom_providers", []) + matches = [entry for entry in entries if entry.get("base_url", "").rstrip("/") == LOCAL_URL] + if not matches: + matches = [{"name": "Local (127.0.0.1:8080)", "base_url": LOCAL_URL}] + entries.extend(matches) + for entry in matches: + entry["model"] = "hermes-local" + entry.setdefault("models", {}).update({name: {} for name in ALIASES}) + entry.setdefault("extra_body", {}).update(max_tokens=2048, t_max_predict_ms=540000) + auxiliary = config.setdefault("auxiliary", {}) + auxiliary.setdefault("vision", {}).update(provider="main", model="hermes-local", + base_url=LOCAL_URL, api_key="local-no-key", timeout=300, reasoning_effort="none", max_concurrency=1) + if compression: + auxiliary.setdefault("compression", {}).update(provider="custom", model="qwen3-1.7b-compression", + base_url="http://127.0.0.1:8081/v1", api_key="local-no-key", timeout=300, reasoning_effort="none") + config.setdefault("compression", {}).update(enabled=True, threshold=0.5, + threshold_tokens=24000, target_ratio=0.2, protect_last_n=10, protect_first_n=3, + context_timeout_seconds=120, context_total_ceiling_seconds=600) + return config + + +def maintenance_config(text): + for key, value in {"LLAMA_SERVICE": "llama-qwen.service", "LLAMA_URL": "http://127.0.0.1:8080", + "MODEL_NAME": "hermes-local", "LLAMA_MAX_REQUEST_SECONDS": "600", + "LLAMA_MAX_OUTPUT_TOKENS": "2048"}.items(): + setting = f"{key}={shlex.quote(value)}" + pattern = rf"^{key}=.*$" + text = re.sub(pattern, lambda _: setting, text, flags=re.MULTILINE) if re.search(pattern, text, re.MULTILINE) else text + "\n" + setting + "\n" + return text + + +def unpin_local_jobs(path): + if not path.exists(): + return + for job in json.loads(path.read_text()).get("jobs", []): + if (job.get("model") in ALIASES and job.get("provider") == "custom" + and job.get("base_url") in (None, "", LOCAL_URL)): + subprocess.run(["hermes", "cron", "edit", job["id"], "--unpin"], check=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--download", action="store_true", help="Fehlende, fest versionierte GGUF-Dateien herunterladen") + parser.add_argument("--models", default="routine,large,compression", help="Kommagetrennt: routine,large,compression") + parser.add_argument("--server-bin", default="", help="Leer: verwalteter CUDA-Build oder llama-server im PATH") + parser.add_argument("--model-dir", type=Path, default=Path.home() / ".local/share/hermes-stack/models") + parser.add_argument("--config-dir", type=Path, default=Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "hermes-stack-maintenance") + parser.add_argument("--hermes-config", type=Path, default=Path(os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))) / "config.yaml") + parser.add_argument("--select", choices=["routine", "large"], help="Aktive llama.env vorbereiten; kein Dienststart") + parser.add_argument("--configure-hermes", action="store_true", help="Gezielte Hermes-/Maintenance-Einstellungen mit Backup übernehmen") + parser.add_argument("--unpin-local-cron", action="store_true", help="Lokale Cron-Modellbindungen über Hermes-CLI lösen") + options = parser.parse_args() + names = list(dict.fromkeys(options.models.split(","))) + manifest = json.loads((ROOT / "config/models.lock.json").read_text())["models"] + if not names or any(name not in manifest for name in names): + parser.error("--models erlaubt nur routine,large,compression") + if options.select and options.select not in names: + parser.error("--select muss in --models enthalten sein") + if options.configure_hermes and "routine" not in names and "large" not in names: + parser.error("--configure-hermes benötigt ein Hauptmodell") + if options.select or options.configure_hermes or options.unpin_local_cron: + state = subprocess.check_output(["systemctl", "--user", "show", "hermes-gateway.service", + "--property=ActiveState", "--value"], text=True).strip() + if state not in ("inactive", "failed", ""): + parser.error("Gateway zuerst mit systemctl --user stop hermes-gateway.service anhalten") + # Import before downloads, so a missing dependency does not waste bandwidth. + if options.configure_hermes: + import yaml + lock_path = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "hermes-stack-hermes-update.lock" + with lock_path.open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + profiles = {} + for name in names: + paths = ensure_model(manifest[name], options.model_dir, options.download) + profiles[name] = profile_config(name, paths, options.server_bin) + for name, text in profiles.items(): + filename = "compression.env" if name == "compression" else f"llama-{name}.env" + write_config(options.config_dir / filename, text) + if options.select: + source = options.config_dir / f"llama-{options.select}.env" + write_config(options.config_dir / "llama.env", source.read_text(), replace=True) + if options.configure_hermes: + text = options.hermes_config.read_text() if options.hermes_config.exists() else "" + config = yaml.safe_load(text) or {} + configure_hermes(config, "compression" in names) + write_config(options.hermes_config, yaml.safe_dump(config, allow_unicode=True, sort_keys=False, width=1000), replace=True) + maintenance = options.config_dir / "config.env" + text = maintenance.read_text() if maintenance.exists() else (ROOT / "config/config.env.example").read_text() + write_config(maintenance, maintenance_config(text), replace=True) + if options.unpin_local_cron: + unpin_local_jobs(options.hermes_config.parent / "cron/jobs.json") + print("Vorbereitung abgeschlossen. Keine Dienste gestartet. Siehe docs/NEW_WORKSTATION.md.") + + +if __name__ == "__main__": + main() diff --git a/systemd/user/hermes-stack-llama-watchdog.service b/systemd/user/hermes-stack-llama-watchdog.service new file mode 100644 index 0000000..8186b8a --- /dev/null +++ b/systemd/user/hermes-stack-llama-watchdog.service @@ -0,0 +1,9 @@ +[Unit] +Description=Enforce local model request duration and output limits +After=llama-qwen.service + +[Service] +Type=oneshot +ExecStart=%h/.local/libexec/hermes-stack-llama-watchdog +TimeoutStartSec=45 +UMask=0077 diff --git a/systemd/user/hermes-stack-llama-watchdog.timer b/systemd/user/hermes-stack-llama-watchdog.timer new file mode 100644 index 0000000..d340aa3 --- /dev/null +++ b/systemd/user/hermes-stack-llama-watchdog.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Check local model request limits every 15 seconds + +[Timer] +OnBootSec=1min +OnUnitActiveSec=15s +AccuracySec=1s +Unit=hermes-stack-llama-watchdog.service + +[Install] +WantedBy=timers.target diff --git a/systemd/user/llama-qwen.service b/systemd/user/llama-qwen.service index c4fc6ac..0b449b3 100644 --- a/systemd/user/llama-qwen.service +++ b/systemd/user/llama-qwen.service @@ -1,5 +1,5 @@ [Unit] -Description=Local llama.cpp Qwen model server +Description=Hermes local main model (routine/large) After=network-online.target graphical-session.target StartLimitIntervalSec=300 StartLimitBurst=3 diff --git a/tests/smoke.sh b/tests/smoke.sh index 667a69e..7e5a324 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -21,6 +21,8 @@ else fi python3 "$ROOT_DIR/tests/test_llama.py" || failures=$((failures + 1)) +python3 "$ROOT_DIR/tests/test_llama_watchdog.py" || failures=$((failures + 1)) +python3 "$ROOT_DIR/tests/test_workstation.py" || failures=$((failures + 1)) python3 "$ROOT_DIR/tests/test_hermes_update.py" || failures=$((failures + 1)) if (cd -- "$ROOT_DIR" && rg -n --hidden --glob '!tests/smoke.sh' --glob '!tests/test_llama.py' --glob '!.git/**' \ diff --git a/tests/test_llama.py b/tests/test_llama.py index 64f90e2..0c463cc 100644 --- a/tests/test_llama.py +++ b/tests/test_llama.py @@ -16,7 +16,7 @@ OPTIONS = """--model --hf-repo --mmproj --alias --host --port --ctx-size --paral --jinja --reasoning --reasoning-budget --reasoning-preserve --no-reasoning-preserve --flash-attn --threads --threads-batch --n-gpu-layers --fit --fit-target --cache-type-k --cache-type-v --sleep-idle-seconds --image-min-tokens --cors-origins --no-ui --rope-scaling --rope-scale --yarn-orig-ctx ---device --no-op-offload --no-kv-offload""" +--device --no-op-offload --no-kv-offload --n-predict""" class LlamaTests(unittest.TestCase): diff --git a/tests/test_llama_watchdog.py b/tests/test_llama_watchdog.py new file mode 100644 index 0000000..a214df1 --- /dev/null +++ b/tests/test_llama_watchdog.py @@ -0,0 +1,118 @@ +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("watchdog", Path(__file__).resolve().parents[1] / "scripts/llama_watchdog.py") +watchdog = importlib.util.module_from_spec(spec) +spec.loader.exec_module(watchdog) + + +def busy(task=1, decoded=20): + return [{"id": 0, "id_task": task, "is_processing": True, + "next_token": [{"n_decoded": decoded}]}] + + +class WatchdogTests(unittest.TestCase): + def test_busy_request_is_allowed_then_expires_despite_progress(self): + state, reasons = watchdog.observe({}, busy(), "server1", 100, 600, 2048) + self.assertFalse(reasons) + state, reasons = watchdog.observe(state, busy(decoded=1000), "server1", 699, 600, 2048) + self.assertFalse(reasons) + _, reasons = watchdog.observe(state, busy(decoded=1100), "server1", 700, 600, 2048) + self.assertIn("600s", reasons[0]) + + def test_new_request_and_restarted_server_do_not_inherit_deadline(self): + state, _ = watchdog.observe({}, busy(), "server1", 100, 600, 2048) + for slots, invocation in [(busy(2), "server1"), (busy(), "server2")]: + _, reasons = watchdog.observe(state, slots, invocation, 900, 600, 2048) + self.assertFalse(reasons) + + def test_idle_slot_drops_old_deadline(self): + state, _ = watchdog.observe({}, busy(), "server1", 100, 600, 2048) + state, reasons = watchdog.observe(state, [{"id": 0, "is_processing": False}], "server1", 900, 600, 2048) + self.assertEqual(state["active"], {}) + self.assertFalse(reasons) + + def test_excess_output_is_stopped_even_when_client_overrides_default(self): + _, reasons = watchdog.observe({}, busy(decoded=2049), "server1", 100, 600, 2048) + self.assertIn("2048 tokens", reasons[0]) + + def test_prompt_and_generated_text_are_not_persisted(self): + slots = busy() + slots[0].update(prompt="private prompt", generated="private output") + state, _ = watchdog.observe({}, slots, "server1", 100, 600, 2048) + self.assertNotIn("private", str(state)) + + +class WatchdogIntegrationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.environment = patch.dict(watchdog.os.environ, {"XDG_STATE_HOME": self.temp.name, + "LLAMA_MAX_REQUEST_SECONDS": "600", "LLAMA_MAX_OUTPUT_TOKENS": "2048"}) + self.environment.start() + self.addCleanup(self.environment.stop) + self.path = Path(self.temp.name) / "hermes-stack-maintenance/llama-watchdog.json" + self.boot = Path("/proc/sys/kernel/random/boot_id").read_text().strip() + self.calls = [] + + def control(self, *args): + self.calls.append(args) + return "ActiveState=active\nInvocationID=test-server" if args[0] == "show" else "" + + def tick(self, now, slots=None, error=False): + response = io.BytesIO(json.dumps(slots or []).encode()) + with patch.object(watchdog, "systemctl", self.control), \ + patch.object(watchdog.time, "monotonic", return_value=now), \ + patch.object(watchdog, "urlopen", side_effect=OSError() if error else None, return_value=response): + watchdog.main() + + def restarts(self): + return [call for call in self.calls if call[0] == "restart"] + + def test_deadline_restarts_actual_configured_service_once(self): + self.tick(100, busy()) + self.tick(699, busy()) + self.assertFalse(self.restarts()) + self.tick(700, busy()) + self.assertEqual(self.restarts(), [("restart", "--no-block", "llama-qwen.service")]) + self.tick(701, busy(decoded=3000)) + self.assertEqual(len(self.restarts()), 1) + self.assertEqual(self.path.stat().st_mode & 0o777, 0o600) + + def test_only_persistent_probe_failure_triggers_recovery(self): + self.tick(100, error=True) + self.tick(699, error=True) + self.assertFalse(self.restarts()) + self.tick(700, error=True) + self.assertEqual(len(self.restarts()), 1) + + def test_successful_probe_resets_failure_deadline(self): + self.tick(100, error=True) + self.tick(500) + self.tick(700, error=True) + self.assertFalse(self.restarts()) + + def test_inactive_service_never_started_or_probed(self): + with patch.object(watchdog, "systemctl", return_value="ActiveState=inactive\nInvocationID=") as control, \ + patch.object(watchdog, "urlopen") as probe: + watchdog.main() + probe.assert_not_called() + self.assertEqual(control.call_count, 1) + + def test_reboot_clears_old_monotonic_deadlines_and_cooldown(self): + self.path.parent.mkdir() + self.path.write_text(json.dumps({"boot": "previous-boot", "invocation": "test-server", + "active": {"0:1": 100}, "last_restart": 10000, "unreachable_since": 50})) + self.tick(100, busy()) + self.tick(700, busy()) + self.assertEqual(len(self.restarts()), 1) + self.assertEqual(json.loads(self.path.read_text())["boot"], self.boot) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workstation.py b/tests/test_workstation.py new file mode 100644 index 0000000..76715ae --- /dev/null +++ b/tests/test_workstation.py @@ -0,0 +1,146 @@ +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("prepare", ROOT / "scripts/prepare-workstation.py") +prepare = importlib.util.module_from_spec(spec) +spec.loader.exec_module(prepare) + + +class PreparationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.data = b"test model weights" + self.item = {"name": "weights.gguf", "sha256": hashlib.sha256(self.data).hexdigest(), "size": len(self.data)} + self.group = {"repo": "owner/model", "revision": "a" * 40, "directory": "model/revision", "files": [self.item]} + + def test_download_uses_fixed_revision_and_publishes_only_verified_file(self): + def download(args, **kwargs): + self.assertIn("/resolve/" + "a" * 40 + "/weights.gguf", args[-3]) + Path(args[-1]).write_bytes(self.data) + with patch.object(prepare.subprocess, "run", side_effect=download): + paths = prepare.ensure_model(self.group, self.root, True) + self.assertEqual(paths[0].read_bytes(), self.data) + self.assertFalse(paths[0].with_name("weights.gguf.part").exists()) + + def test_corrupt_download_cannot_become_live_model(self): + with patch.object(prepare.subprocess, "run", side_effect=lambda args, **_: Path(args[-1]).write_bytes(b"broken")): + with self.assertRaisesRegex(ValueError, "nicht aktiviert"): + prepare.ensure_model(self.group, self.root, True) + self.assertFalse((self.root / "model/revision/weights.gguf").exists()) + + def test_existing_wrong_file_is_preserved_without_download(self): + path = self.root / "model/revision/weights.gguf" + path.parent.mkdir(parents=True) + path.write_bytes(b"user file") + with patch.object(prepare.subprocess, "run") as download, self.assertRaises(ValueError): + prepare.ensure_model(self.group, self.root, True) + download.assert_not_called() + self.assertEqual(path.read_bytes(), b"user file") + + def test_config_merge_preserves_unrelated_settings_and_external_provider(self): + config = {"terminal": {"backend": "docker"}, "plugins": {"enabled": ["private"]}, + "custom_providers": [{"name": "Other", "base_url": "https://other.invalid/v1", "model": "other"}]} + updated = prepare.configure_hermes(config, True) + self.assertEqual(updated["terminal"]["backend"], "docker") + self.assertEqual(updated["plugins"]["enabled"], ["private"]) + self.assertEqual(updated["custom_providers"][0]["model"], "other") + self.assertEqual(updated["custom_providers"][1]["extra_body"]["max_tokens"], 2048) + self.assertEqual(updated["auxiliary"]["compression"]["base_url"], "http://127.0.0.1:8081/v1") + + def test_existing_profiles_preserved_and_explicit_selection_backed_up(self): + path = self.root / "llama.env" + path.write_text("custom tuning\n") + prepare.write_config(path, "generated\n") + self.assertEqual(path.read_text(), "custom tuning\n") + prepare.write_config(path, "generated\n", replace=True) + prepare.write_config(path, "generated\n", replace=True) + self.assertEqual(len(list(self.root.glob("llama.env.before-setup-*"))), 1) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + + def test_profiles_quote_other_user_paths_and_do_not_use_remote_hf_start(self): + text = prepare.profile_config("routine", [self.root / "user space/model.gguf", self.root / "projector.gguf"]) + result = subprocess.run(["bash", "-c", text + '\nprintf "%s|%s|%s" "$LLAMA_MODEL" "$LLAMA_HF_REPO" "${LLAMA_ARGS[*]}"'], text=True, capture_output=True, check=True) + self.assertTrue(result.stdout.startswith(str(self.root / "user space/model.gguf") + "||")) + self.assertIn("--n-gpu-layers 99 --fit off", result.stdout) + + def test_prepare_whole_new_workstation_offline_without_home_override(self): + repository = self.root / "repository" + (repository / "config").mkdir(parents=True) + shutil.copy2(ROOT / "config/config.env.example", repository / "config") + (repository / "config/models.lock.json").write_text(json.dumps({"models": {"routine": self.group}})) + path = self.root / "models/model/revision/weights.gguf" + path.parent.mkdir(parents=True) + path.write_bytes(self.data) + config = self.root / "config/hermes-stack-maintenance" + hermes = self.root / "hermes/config.yaml" + args = ["prepare", "--models", "routine", "--model-dir", str(self.root / "models"), + "--config-dir", str(config), "--hermes-config", str(hermes), + "--configure-hermes", "--select", "routine"] + with patch.object(prepare, "ROOT", repository), patch("sys.argv", args), \ + patch.dict(os.environ, {"XDG_RUNTIME_DIR": str(self.root)}), \ + patch.object(prepare.subprocess, "check_output", return_value="inactive\n"): + prepare.main() + self.assertEqual((config / "llama.env").read_text(), (config / "llama-routine.env").read_text()) + self.assertIn("LLAMA_MAX_REQUEST_SECONDS=600", (config / "config.env").read_text()) + self.assertIn("run_budget_seconds: 1800", hermes.read_text()) + + def test_local_cron_unpin_does_not_touch_other_provider_jobs(self): + path = self.root / "jobs.json" + path.write_text(json.dumps({"jobs": [ + {"id": "local", "model": "qwen38-27b-local", "provider": "custom"}, + {"id": "cloud", "model": "qwen38-27b-local", "provider": "other"}, + {"id": "remote", "model": "qwen38-27b-local", "provider": "custom", "base_url": "https://other.invalid/v1"}]})) + with patch.object(prepare.subprocess, "run") as edit: + prepare.unpin_local_jobs(path) + edit.assert_called_once_with(["hermes", "cron", "edit", "local", "--unpin"], check=True) + + +class ModelSwitchTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.bin = self.root / "bin" + self.bin.mkdir() + self.config = self.root / "config/hermes-stack-maintenance" + self.config.mkdir(parents=True) + (self.config / "llama.env").write_text("LLAMA_MODEL=old.gguf\n") + (self.config / "llama-routine.env").write_text("LLAMA_MODEL=new.gguf\n") + shutil.copy2(ROOT / "libexec/hermes-stack-model", self.bin) + for name, body in {"hermes-stack-llama-run": "exit 0", + "systemctl": 'echo "$*" >>"$TEST_CALLS"; if [[ "$*" == *"restart llama-qwen"* && "${TEST_FAIL:-0}" == 1 ]]; then exit 1; fi', + "curl": "echo '{\"status\":\"ok\"}'"}.items(): + p = self.bin / name + p.write_text("#!/usr/bin/env bash\n" + body + "\n") + p.chmod(0o755) + self.env = dict(os.environ, PATH=str(self.bin) + ":" + os.environ["PATH"], + XDG_CONFIG_HOME=str(self.config.parent), XDG_RUNTIME_DIR=str(self.root), TEST_CALLS=str(self.root / "calls")) + + def test_successful_switch_through_path_entrypoint(self): + (self.root / "entrypoint").symlink_to(self.bin / "hermes-stack-model") + result = subprocess.run(["bash", str(self.root / "entrypoint"), "routine"], env=self.env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.config / "llama.env").read_text(), "LLAMA_MODEL=new.gguf\n") + self.assertIn("start hermes-gateway.service", (self.root / "calls").read_text()) + + def test_failed_switch_restores_configuration_and_still_attempts_gateway_start(self): + result = subprocess.run(["bash", str(self.bin / "hermes-stack-model"), "routine"], + env=dict(self.env, TEST_FAIL="1"), capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual((self.config / "llama.env").read_text(), "LLAMA_MODEL=old.gguf\n") + self.assertIn("start hermes-gateway.service", (self.root / "calls").read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/uninstall.sh b/uninstall.sh index 03b4251..cb2f191 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -6,6 +6,7 @@ set -Eeuo pipefail units=( hermes-stack-hermes-update.service hermes-stack-hermes-update.timer hermes-stack-health.service hermes-stack-health.timer + hermes-stack-llama-watchdog.service hermes-stack-llama-watchdog.timer hermes-stack-signal-health.service hermes-stack-signal-health.timer hermes-stack-signal-update-check.service hermes-stack-signal-update-check.timer llama-qwen.service llama-compression.service signal-cli.service @@ -16,6 +17,7 @@ systemctl --user disable --now llama-compression.service 2>/dev/null || true systemctl --user disable --now \ hermes-stack-hermes-update.timer \ hermes-stack-health.timer \ + hermes-stack-llama-watchdog.timer \ hermes-stack-signal-health.timer \ hermes-stack-signal-update-check.timer 2>/dev/null || true @@ -27,10 +29,16 @@ for file in \ hermes-stack-notify hermes-stack-hermes-update hermes-stack-health \ hermes-stack-signal-health hermes-stack-signal-update-check hermes-stack-signal-update \ hermes-stack-llama-run hermes-stack-llama-profile hermes-stack-llama-restart \ - hermes-stack-llama-report \ + hermes-stack-llama-report hermes-stack-llama-watchdog hermes-stack-model \ hermes-stack-signal-run hermes-stack-doctor; do rm -f -- "$HOME/.local/libexec/$file" done +rm -f -- "$HOME/.local/lib/hermes-stack/llama_watchdog.py" + systemctl --user daemon-reload +command_target="$HOME/.local/bin/hermes-stack-model" +if [[ -L "$command_target" && "$(readlink -- "$command_target")" == ../libexec/hermes-stack-model ]]; then + rm -f -- "$command_target" +fi printf 'Deployment-Dateien entfernt; Benutzerdaten und Konfigurationen wurden beibehalten.\n'