Make local inference limits and workstation setup reproducible

This commit is contained in:
admin committed 2026-10-23 13:27:50 +02:00
1 parent 7006991843
commit 8544abd2d4
28 files changed
+1022 -21

No files matched your search

+1 -1
View File
@@ -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
+12
View File
@@ -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:
+24 -5
View File
@@ -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
+3
View File
@@ -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
+1
View File
@@ -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.
+51
View File
@@ -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
}
]
}
}
}
+66
View File
@@ -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).
+4
View File
@@ -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.
+170
View File
@@ -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).
+6
View File
@@ -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,
+18 -2
View File
@@ -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
+6 -6
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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"
+5 -1
View File
@@ -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[@]}"
)
+11
View File
@@ -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"
+55
View File
@@ -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"
+91
View File
@@ -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()
+198
View File
@@ -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()
@@ -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
@@ -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
+1 -1
View File
@@ -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
+2
View File
@@ -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/**' \
+1 -1
View File
@@ -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):
+118
View File
@@ -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()
+146
View File
@@ -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()
+9 -1
View File
@@ -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'