Compare commits

...
7 Commits
38 changed files with 1812 additions and 50 deletions

No files matched your search

+1 -1
View File
@@ -12,6 +12,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install test dependencies - 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 - name: Run smoke tests
run: ./tests/smoke.sh run: ./tests/smoke.sh
+38
View File
@@ -1,3 +1,41 @@
# 2026-10-23 — Explizites 64K-Kontextfenster für Hermes
- `model.context_length: 65536` passend zu beiden Hauptmodellprofilen gesetzt;
verhindert den beobachteten Alias-Fallback auf 256.000 Tokens.
- Bestehende CPU-Kompressionsschwelle von 24.000 Tokens beibehalten.
- Bereitstellung, Migration und Betriebsdokumentation aktualisiert; Regression
für alte Kontextwerte, Profilabgleich und wiederholte Konfiguration ergänzt.
# 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:
Der konfigurierte Stable-Kanal kann aktuell sein, obwohl `origin/main` weiter ist.
- Abschließende Meldungen erscheinen direkt im Journal des Update-Dienstes.
- Isolierte Regressionstests prüfen unveränderte/aktualisierte Commits,
Updater-Fehler und Gateway-Rollback ohne Netzwerk oder echte Dienstaufrufe.
# 2026-10-12 — VRAM-Reserve und portable CPU-Kompression
- `desktop-64k` mit 4096 MiB Fit-Zielmarge und 64K Kontext als Standard neuer
Konfigurationen ergänzt; frühere Profile bleiben verfügbar.
- Separaten CPU-Kompressionsdienst mit eigener portabler Konfiguration,
Geräte-/Op-/KV-Offload-Ausschluss und Installation ohne Aktivierung ergänzt.
- Migrationsschritte, tatsächliche Workstation-Abnahme und Prüfgrenzen dokumentiert.
- Bestehende lokale Konfigurationen und laufende Dienste bleiben beim Deployment
erhalten; Optionen werden vor dem Modellstart auf Unterstützung geprüft.
# 2026-09-10 — Cleanup-Reparatur und dauerhafte Modelle # 2026-09-10 — Cleanup-Reparatur und dauerhafte Modelle
- Korrigierte Cleanup-Skripte unter maintenance/ aufgenommen: kein globales - Korrigierte Cleanup-Skripte unter maintenance/ aufgenommen: kein globales
+54 -11
View File
@@ -5,6 +5,7 @@ Reproduzierbares Benutzer-Deployment für Hermes Agent auf Arch Linux mit:
- offizieller Hermes-Git-Installation unter `~/.hermes/hermes-agent` - offizieller Hermes-Git-Installation unter `~/.hermes/hermes-agent`
- von Hermes erzeugtem systemd-User-Service für den Messaging Gateway - von Hermes erzeugtem systemd-User-Service für den Messaging Gateway
- optionalem lokalen llama.cpp/OpenAI-kompatiblen Modellendpunkt - optionalem lokalen llama.cpp/OpenAI-kompatiblen Modellendpunkt
- optionalem CPU-Kompressionsserver für Hermes
- optionalem signal-cli HTTP-Daemon - optionalem signal-cli HTTP-Daemon
- robustem Hermes-Update mit Vorprüfung, Retry, Backup und Rollback - robustem Hermes-Update mit Vorprüfung, Retry, Backup und Rollback
- regelmäßigen Healthchecks und Selbstheilungsversuchen - regelmäßigen Healthchecks und Selbstheilungsversuchen
@@ -13,10 +14,16 @@ Reproduzierbares Benutzer-Deployment für Hermes Agent auf Arch Linux mit:
- optionalem CUDA-Build von llama-server, llama-cli und llama-bench - optionalem CUDA-Build von llama-server, llama-cli und llama-bench
- Qwen-/RTX-3080-Profilen mit prüfbarem Wechsel und Rückweg - Qwen-/RTX-3080-Profilen mit prüfbarem Wechsel und Rückweg
Stand: 2026-09-05. Für deine **bestehende Workstation** zuerst Stand: 2026-10-27. 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 [`docs/WORKSTATION.md`](docs/WORKSTATION.md) lesen. Der Schnellstart unten ist für
eine neue Maschine. Hardware-Leistungsgewinne sind noch nicht auf deiner GPU eine neue Maschine. Das Routineprofil erprobt Qwen3.5-9B IQ4_XS mit 64K Kontext. Das bisherige
gemessen. Es wird kein Gitea-Server angelegt und nichts automatisch gepusht. 4B-Modell mit 64K bleibt als `fallback` verfügbar. Details und Rückweg:
[QWEN9B_TRIAL.md](docs/QWEN9B_TRIAL.md). 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 Das Repository enthält **keine Zugangsdaten, Signal-Sitzung oder
Modellgewichte**. Diese Daten werden lokal angelegt und sind durch `.gitignore` Modellgewichte**. Diese Daten werden lokal angelegt und sind durch `.gitignore`
@@ -43,6 +50,8 @@ Danach die beim ersten Lauf erzeugten lokalen Konfigurationen bearbeiten:
```bash ```bash
$EDITOR ~/.config/hermes-stack-maintenance/config.env $EDITOR ~/.config/hermes-stack-maintenance/config.env
$EDITOR ~/.config/hermes-stack-maintenance/llama.env $EDITOR ~/.config/hermes-stack-maintenance/llama.env
# Bei Nutzung der lokalen Kompression:
$EDITOR ~/.config/hermes-stack-maintenance/compression.env
$EDITOR ~/.config/hermes-stack-maintenance/signal-cli.env $EDITOR ~/.config/hermes-stack-maintenance/signal-cli.env
$EDITOR ~/.hermes/.env $EDITOR ~/.hermes/.env
``` ```
@@ -56,7 +65,9 @@ hermes gateway install --force
``` ```
In Hermes als lokalen OpenAI-kompatiblen Endpunkt `http://127.0.0.1:8080/v1` 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. Profile, Skills und Memories werden nicht durch generische Vorgaben ersetzt.
Für Neuinstallationen diese Einstellungen interaktiv vornehmen oder die eigenen Für Neuinstallationen diese Einstellungen interaktiv vornehmen oder die eigenen
Daten gemäß Migration sicher übernehmen. Der Alias allein setzt kein Kontextlimit. Daten gemäß Migration sicher übernehmen. Der Alias allein setzt kein Kontextlimit.
@@ -78,6 +89,7 @@ Maintenance aktivieren:
systemctl --user enable --now \ systemctl --user enable --now \
hermes-stack-hermes-update.timer \ hermes-stack-hermes-update.timer \
hermes-stack-health.timer \ hermes-stack-health.timer \
hermes-stack-llama-watchdog.timer \
hermes-stack-signal-health.timer \ hermes-stack-signal-health.timer \
hermes-stack-signal-update-check.timer hermes-stack-signal-update-check.timer
``` ```
@@ -111,23 +123,46 @@ Abschließend:
Ohne Optionen installiert das Skript nur die versionierten Dateien und legt Ohne Optionen installiert das Skript nur die versionierten Dateien und legt
fehlende Beispielkonfigurationen an. Bestehende lokale Konfigurationen werden fehlende Beispielkonfigurationen an. Bestehende lokale Konfigurationen werden
nicht überschrieben. 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 Ersetzte Wrapper und Units werden vorher gesichert. Eine vorhandene
hermes-gateway.service wird nicht neu erzeugt. Ein Modell wird erst beim echten hermes-gateway.service wird nicht neu erzeugt. Ein Modell wird erst beim echten
Start des llama-Dienstes geladen, nicht bei der Konfigurationsprüfung. Start des llama-Dienstes geladen, nicht bei der Konfigurationsprüfung.
## RTX 3080 und Qwen ## 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).
Hermes verwendet zum aktiven Modell passend 64K Kontext für `routine` (9B),
`fallback` (4B) und `large` (27B). Der Modellschalter passt auch die
Kompressionsschwelle an: 24.000 Tokens.
```bash
hermes-stack-model status
hermes-stack-model routine
hermes-stack-model fallback # bisheriges 4B-/64K-Setup
hermes-stack-model large
```
```bash ```bash
~/.local/libexec/hermes-stack-llama-run --check ~/.local/libexec/hermes-stack-llama-run --check
~/.local/libexec/hermes-stack-llama-profile list ~/.local/libexec/hermes-stack-llama-profile list
~/.local/libexec/hermes-stack-llama-report ~/.local/libexec/hermes-stack-llama-report
``` ```
`baseline` behält deine bisherigen 64K Kontext / 1536 MiB Fit-Zielmarge. Für neue Konfigurationen ist `desktop-64k` der Standard: 64K Kontext,
`balanced` reduziert nur den Kontext auf 48K; `vram-plus` danach optional die 4096 MiB Fit-Zielmarge, Reasoning-Budget 128 ohne Reasoning-Preserve.
Marge auf 1280 MiB. `compact` bietet 32K bei 1536 MiB. Das sind Vergleichsprofile, Das priorisiert Spielraum für Browser und Desktop auf einer gemeinsam genutzten
keine Garantie gegen Desktop-Speicherdruck. Details, Messprotokoll und Rücknahme: GPU. Die früheren Profile bleiben für bewusste Vergleiche verfügbar.
[`docs/RTX3080.md`](docs/RTX3080.md). Bestehende Konfigurationen werden nicht umgestellt. Bei expliziten `LLAMA_ARGS`
und leerem `LLAMA_PROFILE` wirken die Profile nicht.
Der optionale Dienst `llama-compression.service` verwendet eine eigene
`compression.env`, Port 8081 und CPU-Ausführung einschließlich KV-/Op-Offload.
Für beide Server werden Modellgewichte separat bereitgestellt. Einrichtung,
Messgrenzen und Migration: [`docs/RTX3080.md`](docs/RTX3080.md).
Profilwechsel ändern zunächst nur eine separate Auswahl-Datei. Ein Neustart Profilwechsel ändern zunächst nur eine separate Auswahl-Datei. Ein Neustart
benötigt `hermes-stack-llama-restart --jobs-finished`. Der Healthcheck teilt das benötigt `hermes-stack-llama-restart --jobs-finished`. Der Healthcheck teilt das
@@ -139,7 +174,7 @@ Update-Lock und löst bei Backend-HTTP-Timeouts keine Inferenz-Neustarts mehr au
1. exklusives Lock und Prüfung auf lokale Änderungen 1. exklusives Lock und Prüfung auf lokale Änderungen
2. `origin/main`-Fetch mit begrenzten Retries und letztem HTTP/1.1-Fallback 2. `origin/main`-Fetch mit begrenzten Retries und letztem HTTP/1.1-Fallback
3. schneller Abschluss ohne Backup, wenn kein Update vorliegt 3. schneller Abschluss ohne Backup, wenn Checkout und gefetchter Branch identisch sind
4. komprimiertes Backup vor jeder Änderung 4. komprimiertes Backup vor jeder Änderung
5. Stoppen des Gateways und Aufruf von `hermes update` 5. Stoppen des Gateways und Aufruf von `hermes update`
6. einmaliger Retry bei eindeutig transientem Fetch-Fehler 6. einmaliger Retry bei eindeutig transientem Fetch-Fehler
@@ -148,9 +183,17 @@ Update-Lock und löst bei Backend-HTTP-Timeouts keine Inferenz-Neustarts mehr au
9. separater llama.cpp-Healthcheck ohne unsinnigen Hermes-Rollback 9. separater llama.cpp-Healthcheck ohne unsinnigen Hermes-Rollback
10. Aufbewahrung der drei neuesten Hermes-Backups 10. Aufbewahrung der drei neuesten Hermes-Backups
Den Zielstand wählt `hermes update` anhand des konfigurierten Update-Kanals.
Bei Stable kann der Checkout bereits aktuell sein, obwohl `origin/main` weiter
ist. Ein unveränderter Commit bei Updater-Exitcode 0 und gesundem Gateway gilt
deshalb als Erfolg. Dabei können Backup und Gateway-Neustart trotzdem stattfinden.
Ein fehlgeschlagener Updater bei unverändertem Commit bleibt ein Fehler.
Ein Fehler von Telegram oder Signal kann den Updateprozess niemals fehlschlagen Ein Fehler von Telegram oder Signal kann den Updateprozess niemals fehlschlagen
lassen. Detaillierte Zwischenstände landen im Journal; nur zusammengefasste lassen. Detaillierte Zwischenstände landen im Journal; nur zusammengefasste
Ergebnisse werden versendet. Ergebnisse werden versendet. Abschlussmeldungen stehen auch direkt im Journal
von `hermes-stack-hermes-update.service`. Diagnose, gezielte Installation und
Prüfung sind in [HERMES_UPDATE.md](docs/HERMES_UPDATE.md) beschrieben.
## Bedienung ## Bedienung
+20
View File
@@ -0,0 +1,20 @@
# Optionaler CPU-Kompressionsserver. Modellgewichte separat bereitstellen.
# Gleiche Binary-Auswahl wie beim Hauptmodell; bei Bedarf explizit setzen.
LLAMA_SERVER_BIN=""
LLAMA_MODEL="$HOME/.local/share/hermes-stack/models/qwen3-1.7b-compression/Qwen3-1.7B-Q4_K_M.gguf"
LLAMA_HF_REPO=""
LLAMA_MMPROJ=""
LLAMA_ALIAS="qwen3-1.7b-compression"
LLAMA_HOST="127.0.0.1"
LLAMA_PORT="8081"
LLAMA_PROFILE=""
LLAMA_ARGS=(
--ctx-size 65536 --rope-scaling yarn --rope-scale 2 --yarn-orig-ctx 32768
--parallel 1 --jinja --reasoning-budget 0 --no-reasoning-preserve
--n-gpu-layers 0 --device none --no-op-offload --no-kv-offload
--threads 8 --threads-batch 8 --flash-attn auto
--cache-type-k q8_0 --cache-type-v q8_0 --no-ui
)
# 64K-Kontext ist die übernommene Workstation-Konfiguration mit YaRN.
# Die Binary meldet Kontext über dem Trainingslimit; Qualität bei langen
# Kompressionsaufgaben auf dem Zielmodell gesondert prüfen.
+3
View File
@@ -7,6 +7,9 @@ HERMES_BRANCH="main"
GATEWAY_SERVICE="hermes-gateway.service" GATEWAY_SERVICE="hermes-gateway.service"
LLAMA_SERVICE="llama-qwen.service" LLAMA_SERVICE="llama-qwen.service"
LLAMA_URL="http://127.0.0.1:8080" 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. # 1: inaktiven Dienst wieder starten. HTTP-Timeouts allein starten nie neu.
# Standard 0 verhindert unerwünschte Modellstarts während Wartung/Tuning. # Standard 0 verhindert unerwünschte Modellstarts während Wartung/Tuning.
LLAMA_HEALTH_AUTORESTART=0 LLAMA_HEALTH_AUTORESTART=0
+1 -1
View File
@@ -9,5 +9,5 @@ LLAMA_HF_REPO=""
LLAMA_ALIAS="qwen38-27b-local" LLAMA_ALIAS="qwen38-27b-local"
LLAMA_HOST="127.0.0.1" LLAMA_HOST="127.0.0.1"
LLAMA_PORT="8080" LLAMA_PORT="8080"
LLAMA_PROFILE="baseline" LLAMA_PROFILE="desktop-64k"
LLAMA_ARGS=() LLAMA_ARGS=()
+4 -3
View File
@@ -8,10 +8,11 @@ LLAMA_ALIAS="qwen38-27b-local"
LLAMA_MMPROJ="" LLAMA_MMPROJ=""
LLAMA_HOST="127.0.0.1" LLAMA_HOST="127.0.0.1"
LLAMA_PORT="8080" LLAMA_PORT="8080"
LLAMA_MAX_OUTPUT_TOKENS=2048
# Erster Start unverändert mit 64K / 1536 MiB Fit-Zielmarge. # Standard für eine gemeinsam mit dem Desktop genutzte GPU: 64K / 4096 MiB.
# baseline bleibt Standard; vram-64k ist ein optionales Testprofil. # Fit ist ein Planungsziel; die freie Reserve auf der Zielmaschine messen.
LLAMA_PROFILE="baseline" LLAMA_PROFILE="desktop-64k"
LLAMA_ARGS=() LLAMA_ARGS=()
# HF lädt beim ersten echten Start herunter. Für offline/reproduzierbar: # HF lädt beim ersten echten Start herunter. Für offline/reproduzierbar:
# LLAMA_MODEL und optional LLAMA_MMPROJ setzen; LLAMA_HF_REPO leeren. # LLAMA_MODEL und optional LLAMA_MMPROJ setzen; LLAMA_HF_REPO leeren.
+68
View File
@@ -0,0 +1,68 @@
{
"llama_cpp_commit": "d81235049384534c167caea52b85a694f6103d14",
"models": {
"routine": {
"repo": "unsloth/Qwen3.5-9B-GGUF",
"revision": "3885219b6810b007914f3a7950a8d1b469d598a5",
"directory": "qwen35-9b/3885219b6810b007914f3a7950a8d1b469d598a5",
"files": [
{
"name": "Qwen3.5-9B-IQ4_XS.gguf",
"size": 5168653536,
"sha256": "7e918aeca06c52bcb528ea6b04b4ec957e75ee8c0a73138854c0dfcf371ea429"
},
{
"name": "mmproj-F16.gguf",
"sha256": "f70dc3509053962b0d0d3ee8a7eacebf5d60aa560cad78254ae8698516ae029f",
"size": 918166080
}
]
},
"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
}
]
},
"fallback": {
"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
}
]
}
}
}
+3 -1
View File
@@ -11,7 +11,9 @@ außerhalb des Caches abgelegt und mit cmp gegen die Quellen geprüft.
Nach Umstellung und kontrolliertem Neustart bestätigte das Serverlog beide Nach Umstellung und kontrolliertem Neustart bestätigte das Serverlog beide
neuen Pfade. Eine Textanfrage lieferte OK, der Healthcheck Result=success. neuen Pfade. Eine Textanfrage lieferte OK, der Healthcheck Result=success.
Profil baseline bleibt unverändert (65536 Kontext, 1536 MiB Fit-Zielmarge). Historisch blieb damals baseline unverändert (65536 Kontext, 1536 MiB Fit-Zielmarge).
Seit 12.10.2026 gilt die größere Desktop-Reserve; siehe
[aktuellen Workstation-Abgleich](WORKSTATION.md#workstation-abgleich-vom-2026-10-12).
Damit sind Modellstart, kurze Textinferenz und leichte Gesundheitsprüfung Damit sind Modellstart, kurze Textinferenz und leichte Gesundheitsprüfung
belegt; ein kompletter Bildjob und der Docker-Netzwerk-Canary wurden bei belegt; ein kompletter Bildjob und der Docker-Netzwerk-Canary wurden bei
dieser Wiederherstellung noch nicht erneut getestet. dieser Wiederherstellung noch nicht erneut getestet.
+69
View File
@@ -0,0 +1,69 @@
# Hermes-Update betreiben und prüfen
## Bereits aktuelle Stable-Version
Der Updater prüft zunächst den konfigurierten Remote-Branch (standardmäßig
`origin/main`). Den tatsächlichen Zielstand wählt anschließend `hermes update`
anhand des Hermes-Update-Kanals. Bei Stable kann der installierte Release-Commit
aktuell sein, obwohl auf `main` neuere Entwicklungscommits liegen.
Bis zur Korrektur vom 21.10.2026 endete dieser Fall trotz `Already up to date!`
und Updater-Exitcode 0 mit einem fehlgeschlagenen Wartungsdienst. Das Skript
forderte fälschlich einen Commitwechsel. Jetzt endet ein erfolgreicher Updater
mit unverändertem Commit nach den Konfigurations- und Gateway-Prüfungen
erfolgreich. Der Kanal wird durch diese Korrektur nicht geändert.
Ein fehlgeschlagener Updater bei unverändertem Commit liefert weiterhin Exitcode
1. Ein ungesunder Gateway löst weiterhin den vorhandenen Rollback aus. Der
separate llama-Endpunkt wird unabhängig geprüft; dessen Ausfall führt nicht zu
einem Hermes-Rollback.
## Nur den korrigierten Wrapper installieren
Im Repository-Verzeichnis ausführen, wenn gerade kein Update läuft. Vorher
laufende Hermes-Aufgaben beenden lassen. Diese gezielte Installation ersetzt nur
den Wrapper; `./install.sh` installiert dagegen alle versionierten Wrapper/Units.
```bash
make test
backup_dir="$(mktemp -d "$HOME/.local/state/hermes-update-wrapper.XXXXXXXX")"
cp -p ~/.local/libexec/hermes-stack-hermes-update "$backup_dir/"
printf 'Wrapper-Sicherung: %s\n' "$backup_dir"
install -m 0755 libexec/hermes-stack-hermes-update \
~/.local/libexec/hermes-stack-hermes-update
cmp libexec/hermes-stack-hermes-update \
~/.local/libexec/hermes-stack-hermes-update
```
Die Unit bleibt unverändert; dafür ist kein `daemon-reload` erforderlich.
Die vorherige Wrapper-Version liegt im ausgegebenen bzw. in `$backup_dir`
gespeicherten Sicherungsverzeichnis. Bei Bedarf dieselbe Datei mit `install
-m 0755` zurückkopieren, während kein Update läuft.
## Manueller Lauf und Diagnose
Der Lauf kann den Gateway kurz stoppen und neu starten. Er erzeugt nach dem
Remote-Vergleich gegebenenfalls auch dann ein Backup, wenn Stable bereits aktuell
ist. Standardmäßig werden die drei neuesten Hermes-Backups behalten.
```bash
systemctl --user start hermes-stack-hermes-update.service
systemctl --user show hermes-stack-hermes-update.service \
-p ActiveState -p SubState -p Result -p ExecMainStatus
systemctl --user is-active hermes-gateway.service
journalctl --user -u hermes-stack-hermes-update.service -n 100 --no-pager
journalctl --user -t hermes-stack-maintenance -n 100 --no-pager
```
Erwartet: `Result=success`, `ExecMainStatus=0` und ein aktiver Gateway. Als
`Type=oneshot` ist der Update-Dienst nach erfolgreichem Abschluss selbst wieder
`inactive/dead`. Die Meldung `Hermes ist im konfigurierten Update-Kanal bereits
aktuell` bestätigt den erfolgreichen Lauf ohne Versionswechsel.
Updater-Ausgaben liegen zusätzlich unter
`~/.local/state/hermes-stack-maintenance/hermes-update-*.log`.
Wrapper-Abschlussmeldungen stehen im Dienstjournal; die Benachrichtigung schreibt
zusätzlich unter dem Tag `hermes-stack-maintenance`.
Die Abnahme auf Archwork und ihre Prüfgrenzen stehen in
[VALIDATION.md](VALIDATION.md).
+93
View File
@@ -0,0 +1,93 @@
# Lokale Inferenz begrenzen
Die Workstation verwendet `hermes-local` als stabilen API-Modellnamen. Das
Routineprofil lädt Qwen3.5-9B IQ4_XS (Unsloth-Revision
`3885219b6810b007914f3a7950a8d1b469d598a5`), 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`, `fallback` bzw. `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`.
## Kontextgröße und Kompression
Das Routineprofil verwendet `--ctx-size 65536`, das bisherige 4B-Profil
(`fallback`) und das große Profil jeweils 65536. Der Modellschalter setzt
`model.context_length` und `compression.threshold_tokens` zusammen mit dem
Modell: 65536/24000 für alle drei Profile. Er sichert
beide Konfigurationen und stellt bei einem Wechsel-Fehler beide wieder her.
Legacy-Profile ohne `LLAMA_CONTEXT_TOKENS` werden als 64K behandelt; eigene
Profile müssen diese Metadaten passend zu `--ctx-size` setzen.
Die CPU-Kompression bleibt bei `threshold: 0.5` und der absoluten Schwelle
von 24.000 Tokens. `target_ratio: 0.2`,
Schutz der ersten drei und letzten zehn Nachrichten sowie die bestehenden
Zeitbudgets bleiben erhalten. Die Schwelle ist ein Auslöser, keine harte
Obergrenze für einzelne Nachrichten oder Werkzeugergebnisse.
Für eine bestehende Installation genügt nach Abschluss laufender Aufträge:
```bash
hermes-stack-model routine # 9B, 64K
hermes-stack-model fallback # 4B, 64K inklusive Kompressionsschwelle
```
Der Modellschalter startet Hauptmodell und Gateway kontrolliert neu. Bereits
laufende Anfragen werden beendet; CPU-Kompression läuft unverändert weiter.
Die aktuellen Messungen und der Rückweg stehen in [QWEN9B_TRIAL.md](QWEN9B_TRIAL.md).
## 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-9B](https://huggingface.co/Qwen/Qwen3.5-9B),
[verwendete GGUF-Dateien](https://huggingface.co/unsloth/Qwen3.5-9B-GGUF/tree/3885219b6810b007914f3a7950a8d1b469d598a5).
+16 -1
View File
@@ -1,5 +1,9 @@
# Migration auf eine neue Arch-Linux-Maschine # 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 Das Gitea-Repository reproduziert Programme, Units und Wartungslogik. Persönliche
Hermes-Daten, Secrets, Signal-Geräteidentität und Modellgewichte werden separat Hermes-Daten, Secrets, Signal-Geräteidentität und Modellgewichte werden separat
übertragen. übertragen.
@@ -56,7 +60,16 @@ betreiben. Für Parallelbetrieb die neue Maschine als separates Gerät verknüpf
inklusive CLI und Benchmark) oder vorhandene Binary verwenden. Die GGUF-Datei inklusive CLI und Benchmark) oder vorhandene Binary verwenden. Die GGUF-Datei
und einen ggf. benötigten Vision-Projektor übertragen oder beim ersten Start und einen ggf. benötigten Vision-Projektor übertragen oder beim ersten Start
über HF beziehen. Danach über HF beziehen. Danach
`~/.config/hermes-stack-maintenance/llama.env` anpassen. Modellgewichte gehören `~/.config/hermes-stack-maintenance/llama.env` anpassen.
Für eine Workstation mit Desktop auf derselben GPU `desktop-64k` (64K,
4096 MiB Fit-Zielmarge) als Ausgangspunkt verwenden. Den optionalen lokalen
Kompressionsserver über `compression.env` auf CPU konfigurieren und seine
GGUF-Datei separat übertragen. Die vollständige Einrichtung und Abnahme steht
unter [Desktop-Reserve und CPU-Kompression](RTX3080.md#desktop-reserve-und-cpu-kompression-seit-12102026).
Auch die eigenen `~/.config/hermes-stack-maintenance/`-Dateien sichern und
gezielt übertragen; lokale Pfade, Binary-Auswahl und Profil-Override auf der
neuen Maschine prüfen. Keine vollständige systemd-Konfiguration blind kopieren. Modellgewichte gehören
wegen Größe und Lizenz nicht in dieses Repository. wegen Größe und Lizenz nicht in dieses Repository.
Die RTX-3080-Profile sind für 10 GB VRAM und die bisherige CPU ausgelegt; auf Die RTX-3080-Profile sind für 10 GB VRAM und die bisherige CPU ausgelegt; auf
@@ -70,6 +83,8 @@ NVIDIA-/Kernelpakete. Keine Binärkopie des alten CUDA-Builds erzwingen.
hermes gateway install --force hermes gateway install --force
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user enable --now signal-cli.service llama-qwen.service systemctl --user enable --now signal-cli.service llama-qwen.service
# Nur nach Einrichtung der optionalen Kompression:
systemctl --user enable --now llama-compression.service
systemctl --user enable --now \ systemctl --user enable --now \
hermes-stack-hermes-update.timer \ hermes-stack-hermes-update.timer \
hermes-stack-health.timer \ hermes-stack-health.timer \
+176
View File
@@ -0,0 +1,176 @@
# Aktuelles Workstation-Setup neu installieren
Stand: 27.10.2026. Diese Anleitung stellt den aktuellen lokalen Modellbetrieb
wieder her: Qwen3.5-9B mit 64K für den Alltag als Erprobung, Qwen3.5-4B mit 64K als
Rückfallprofil, Qwen3.8-27B als explizite Alternative und Qwen3-1.7B für
Kompression auf der CPU 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,fallback \
--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 sieben Modelldateien zusammen benötigen rund **28,3 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-fallback.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. Dabei setzt er `model.context_length`
passend zur Auswahl: 65536 für alle drei Profile. Die
Kompressionsschwelle beträgt 24.000 Tokens. 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. Messungen zur aktuellen 9B-Erprobung stehen in
[QWEN9B_TRIAL.md](QWEN9B_TRIAL.md). Der historische 4B-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 # 9B/64K-Erprobung
hermes-stack-model fallback # bisheriges 4B/64K-Profil
hermes-stack-model large # größeres Modell für bewusste Einzelnutzung
```
Ein Wechsel stoppt laufende Gateway-Anfragen, prüft die Kandidatenkonfiguration,
sichert `llama.env` und die Hermes-Konfiguration, setzt Kontext und
Kompressionsschwelle passend, 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.
Alle Hauptmodelle 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).
+114
View File
@@ -0,0 +1,114 @@
# Qwen3.5-9B auf der RTX 3080 erproben
Stand: 27.10.2026. Ziel ist bessere Alltagsqualität bei vollständigem GPU-Offload
des Sprachmodells und weiter nutzbarem Hyprland-/Browser-Desktop. Die Freigabe
ist eine Erprobung, keine Langzeit-Stabilitätszusage oder Qualitätsbewertung.
## Profile und Schutzgrenzen
- `routine`: Qwen3.5-9B IQ4_XS, 65536 Kontexttokens, Q4_0-K/V-Cache,
vollständiger GPU-Offload (`--n-gpu-layers 99 --fit off`), Batch 512,
Microbatch 128. Der Bildprojektor läuft mit `--no-mmproj-offload` auf der CPU.
- `fallback`: das bisherige Qwen3.5-4B Q4_K_M mit 65536 Kontexttokens und
Q8_0-K/V-Cache. Auf Archwork wurde die aktive Ausgangskonfiguration unverändert
als `llama-fallback.env` übernommen. Eine Neuinstallation erzeugt sie aus dem Manifest.
- `large`: bisheriges Qwen3.8-27B-Profil für bewusste Einzelnutzung.
Die Gewichte und der Bildprojektor sind in `config/models.lock.json` mit fester
Hugging-Face-Revision, Dateigröße und SHA-256 fixiert. Der Projektor bleibt erhalten;
die Bildverarbeitung benötigt CPU-Zeit. IQ4_XS und der Q4_0-Kontextcache sparen
Speicher gegenüber Q4_K_M/Q8_0 und können die Antwortqualität beeinflussen.
Hermes und Server bleiben bei 65536 Kontexttokens; die CPU-Kompression beginnt
spätestens bei 24000 Tokens. Antwortlimit 2048 Tokens, Request-Watchdog und
bestehende Zeitbudgets bleiben bestehen. Die jetzige Hermes-Version verlangt
mindestens 64000 Kontexttokens: Eine 16K-Konfiguration wird beim Agentstart
abgelehnt, auch wenn der Modellserver selbst antwortet. Das wurde lokal geprüft.
Der Modellschalter lehnt bei vorhandener Hermes-Konfiguration Metadaten unter
dieser Grenze ab und stellt bei Fehlern die vorherige Modell- und Hermes-Konfiguration
wieder her.
## Messungen und verworfene Konfigurationen
Alle Speicherangaben sind MiB laut `nvidia-smi`; die freie Menge hängt von den
gleichzeitig laufenden Desktop-Anwendungen ab und ist nicht reserviert.
| Konfiguration | Beobachtung |
|---|---|
| 4B Q4_K_M, 64K, GPU-Projektor | Vergleichstest: mindestens 2288 MiB frei; kurze Textgenerierung ca. 136 Tokens/s |
| 9B Q4_K_M, 16K, GPU-Projektor, Standardpuffer | Nur ca. 900 MiB frei nach Start; verworfen |
| 9B Q4_K_M, 16K, CPU-Projektor, kleinere Puffer | Mindestens 1760 MiB frei; ca. 94 Tokens/s, Screenshot ca. 39 s; direktes API funktioniert, Hermes lehnt 16K ab |
| 9B Q4_K_M, 64K, CPU-Projektor, Q4_0-Cache | Nur ca. 1439 MiB frei nach Start; zugunsten kleinerer Gewichtsdatei verworfen |
| 9B IQ4_XS, 64K, CPU-Projektor, Q4_0-Cache, Batch 512/Microbatch 128 | Nach Start 1988 MiB frei; im Text-/Bild-/Werkzeug-/58315-Token-Test mindestens **1627 MiB frei**; ca. 73–101 Tokens/s |
Die ersten API-Tests prüfen deutsche Textantwort, Screenshot-Erkennung,
strukturierten Werkzeugaufruf (`add`, Argumente 17/25) und knapp 11000 Tokens
Eingabekontext. Der Werkzeugaufruf wird nur als JSON geprüft und nicht ausgeführt.
Die kurzen Beispiele belegen Funktionsfähigkeit, keinen Qualitätsgewinn gegenüber
4B. Die ursprüngliche Modellbewertung beruhte auf Hersteller-Benchmarks und muss
sich in Johannes' tatsächlichen Aufgaben bewähren.
Das endgültige IQ4_XS-Profil beantwortete die 58315-Token-Anfrage ohne
Abschneiden in ca. 22,4 Sekunden. Der strukturierte Werkzeugaufruf enthielt
korrekt `{"a":17,"b":25}`. Ein echter lokaler Hermes-Lauf mit 65536 Kontext,
20243 Zeichen Systemprompt und 19 Signal-Werkzeugbeschreibungen antwortete
in ca. 5,2 Sekunden mit „Modelltest erfolgreich“. Werkzeugausführung war für
diesen Lauf deaktiviert; es wurden keine Nachrichten an Kontakte versendet.
**Bildgrenze:** Der Screenshot brauchte mit dem CPU-Projektor ca. 38,9 Sekunden.
CPU-Modell und die Angabe 6.0G/10G wurden erkannt, im freien Text wurde aber die
Leistungsangabe 106 W fälschlich als „106 GB“ wiedergegeben. Die Bildverarbeitung
funktioniert technisch; fehlerfreie OCR oder ein Qualitätsgewinn gegenüber 4B
sind damit nicht belegt. Eine gezielte JSON-Nachfrage nach belegtem und gesamtem
VRAM lieferte anschließend korrekt `6.0`/`10.0` in ca. 38,9 Sekunden.
Die freien 1627 MiB sind ein gemessenes Minimum während
dieses kurzen Tests, keine dauerhafte Zusicherung bei zusätzlichen Anwendungen.
Repository-Prüfungen: 55 Python-Tests und Bash-Syntaxprüfungen bestanden;
Shellcheck ist auf der Workstation nicht installiert. Hauptmodell, Gateway und
CPU-Kompression waren abschließend `active/running`, jeweils ohne automatische
Neustarts. Im zugänglichen Kerneljournal seit Versuchsbeginn keine Treffer für
NVIDIA-/Xid-/Speicherfehler. Kein absichtlich erzeugter
UI-Absturz, keine Neustart- oder Langzeitabnahme.
## Bestehende 4B-Installation übernehmen
Die Vorbereitungshilfe überschreibt vorhandene Profil-Dateien absichtlich nicht.
Vor einer manuellen Migration deshalb die aktive `llama.env`, das bisherige
`llama-routine.env` und die Hermes-YAML separat sichern, die bisherige aktive
4B-Konfiguration als `llama-fallback.env` erhalten und das alte Routineprofil
in die Sicherung verschieben. Erst dann `prepare-workstation.py --download
--models routine` ausführen (gegebenenfalls `--server-bin /usr/bin/llama-server`).
Das erzeugt die neue Routinekonfiguration, ohne bereits umzuschalten.
Nach Installation des aktuellen Umschalters erfolgt die Aktivierung ausdrücklich
mit `hermes-stack-model routine`. Kein Modellwechsel durch `install.sh` allein.
## Umschalten und zurückrollen
```bash
hermes-stack-model status
hermes-stack-model fallback # ursprüngliches 4B-/64K-Setup wieder aktivieren
hermes-stack-model routine # 9B-Erprobung wieder aktivieren
```
Ein Wechsel beendet laufende Gateway-Anfragen. Der Umschalter sichert beide
Konfigurationen, übernimmt das Profil samt Kontext-/Kompressionsgrenze, wartet
auf den Modell-Healthcheck und startet den Gateway. Bei Startfehlern versucht er
die Rückkehr zum vorherigen Profil; danach Dienststatus prüfen.
Der Rückweg auf Archwork wurde während der Erprobung tatsächlich ausgeführt:
`llama.env` war danach bytegleich zum Ausgangsstand, die gesamte Hermes-YAML
semantisch identisch; ein lokaler Hermes-Lauf mit 19 Signal-Werkzeugbeschreibungen
antwortete mit „Modelltest erfolgreich.“. Es wurde keine Nachricht an einen
Kontakt gesendet.
Die vollständige lokale Ausgangssicherung liegt auf Archwork unter
`~/.local/state/hermes-stack-maintenance/qwen9b-20261027/` (private Hermes-YAML,
aktives Profil, altes Routineprofil, vorheriger Umschalter und Messberichte).
Diese privaten Dateien werden nicht eingecheckt. Der normale Rückweg braucht
keine Wiederherstellung der gesamten Sicherung, sondern nur den Fallback-Befehl.
Bei erneutem UI-Stottern, Grafikfehlern oder zu geringer Reserve zuerst
`hermes-stack-model fallback` verwenden. Bei einem dauerhaft unbedienbaren
Desktop lässt sich derselbe Befehl aus einer Textkonsole ausführen. Ein
automatischer Wechsel wegen neuer UI-Probleme ist nicht eingerichtet.
+105 -7
View File
@@ -1,5 +1,8 @@
# Qwen auf RTX 3080 10 GB / Hyprland # Qwen auf RTX 3080 10 GB / Hyprland
Aktuelle 9B-Erprobung mit 4B-Rückfallprofil: [QWEN9B_TRIAL.md](QWEN9B_TRIAL.md).
Die folgenden Fit-Profile und älteren Messungen betreffen den 27B-Betrieb.
Zielhardware: RTX 3080 mit 10 GiB, i9-13900KF, 64 GB RAM. Der Desktop und Zielhardware: RTX 3080 mit 10 GiB, i9-13900KF, 64 GB RAM. Der Desktop und
llama.cpp nutzen dieselbe GPU. Das 27B-Modell in Q4_K_M passt nicht vollständig llama.cpp nutzen dieselbe GPU. Das 27B-Modell in Q4_K_M passt nicht vollständig
in 10 GiB: CPU/RAM-Offload bleibt erforderlich. Mehr VRAM für Gewichte kann in 10 GiB: CPU/RAM-Offload bleibt erforderlich. Mehr VRAM für Gewichte kann
@@ -9,17 +12,22 @@ helfen; weder Voll-Offload noch eine bestimmte Beschleunigung werden versprochen
| Profil | Kontextlimit | Fit-Zielmarge | Einsatz | | Profil | Kontextlimit | Fit-Zielmarge | Einsatz |
| --- | ---: | ---: | --- | | --- | ---: | ---: | --- |
| `baseline` | 65536 | 1536 MiB | bisherige Startparameter, Referenz und Rückweg | | `desktop-64k` | 65536 | 4096 MiB | Standard für Neuinstallationen mit Desktop auf derselben GPU |
| `baseline` | 65536 | 1536 MiB | historische Vergleichsparameter |
| `vram-64k` | 65536 | 1280 MiB | optionaler Vergleich, kein Standard | | `vram-64k` | 65536 | 1280 MiB | optionaler Vergleich, kein Standard |
| `balanced` | 49152 | 1536 MiB | erster Vergleich, nur Kontext reduzieren | | `balanced` | 49152 | 1536 MiB | erster Vergleich, nur Kontext reduzieren |
| `vram-plus` | 49152 | 1280 MiB | zweiter Vergleich, kleinere Zielmarge | | `vram-plus` | 49152 | 1280 MiB | zweiter Vergleich, kleinere Zielmarge |
| `compact` | 32768 | 1536 MiB | Alternative für kürzere Sitzungen | | `compact` | 32768 | 1536 MiB | Alternative für kürzere Sitzungen |
Alle Profile: ein Slot, K/V-Cache q8_0, Flash Attention auto, GPU-Layer auto, Gemeinsam: ein Slot, K/V-Cache q8_0, Flash Attention auto, GPU-Layer auto,
Fit an, 6 Generierungs-/16 Batch-Threads, Jinja, Reasoning auto/512/preserve, Fit an, 6 Generierungs-/16 Batch-Threads, Jinja, Reasoning auto,
Image-Minimum 1024 Tokens und Idle-Sleep nach 300 Sekunden. Modellalias bleibt Image-Minimum 1024 Tokens und Idle-Sleep nach 300 Sekunden. Modellalias bleibt
`qwen38-27b-local`. Es werden keine Sampling-Werte oder Hermes-Memories geändert. `qwen38-27b-local`. Es werden keine Sampling-Werte oder Hermes-Memories geändert.
`desktop-64k` verwendet Reasoning-Budget 128 ohne Preserve wie die am
12.10.2026 geprüfte Workstation. Die älteren Vergleichsprofile behalten 512 mit
Preserve; sie ändern neben der Speicherplanung auch diesen Parameter.
Die Definitionen stehen zentral in `libexec/hermes-stack-llama-run`. Die Definitionen stehen zentral in `libexec/hermes-stack-llama-run`.
Die Konfiguration und Optionsnamen werden anhand der installierten Binary Die Konfiguration und Optionsnamen werden anhand der installierten Binary
geprüft. Nicht unterstützte Optionen führen zum Abbruch, nicht zum stillen geprüft. Nicht unterstützte Optionen führen zum Abbruch, nicht zum stillen
@@ -35,14 +43,15 @@ hier bewusst nicht als fertiges Profil vorgesehen.
## Messung statt Auslastungsziel ## Messung statt Auslastungsziel
1. Zuerst `baseline` mit deiner normalen Browser-/Hyprland-Nutzung messen. 1. Zuerst `desktop-64k` mit deiner normalen Browser-/Hyprland-Nutzung messen.
2. Nur bei erneutem Tuning: Jobs beenden, ein Vergleichsprofil wählen 2. Nur bei erneutem Tuning: Jobs beenden, ein Vergleichsprofil wählen
und kontrolliert neu starten. Für 64K Kontext ist dies `vram-64k`. und kontrolliert neu starten. Für 64K Kontext ist dies `vram-64k`.
3. Dieselben Aufgaben erneut ausführen: kurze Textantwort, längerer Agent-Task 3. Dieselben Aufgaben erneut ausführen: kurze Textantwort, längerer Agent-Task
mit Tools und eine repräsentative Bildaufgabe. Qualität mitbewerten. mit Tools und eine repräsentative Bildaufgabe. Qualität mitbewerten.
4. Kalten Start nach Idle-Sleep getrennt von warmem Prompt-Cache messen. 4. Kalten Start nach Idle-Sleep getrennt von warmem Prompt-Cache messen.
5. Erst bei genügend Spielraum `vram-plus` probieren. Bei Rucklern, OOM, 5. Erst bei genügend Spielraum `vram-plus` probieren. Bei Rucklern, OOM,
Grafikfehlern oder deutlich schlechterer Latenz zurück auf `baseline`. Grafikfehlern wieder die größere Reserve von `desktop-64k` verwenden.
Latenz und Qualität gegen die eigenen Anforderungen prüfen.
```bash ```bash
~/.local/libexec/hermes-stack-llama-profile list ~/.local/libexec/hermes-stack-llama-profile list
@@ -80,10 +89,10 @@ zwingend automatisch. Lange alte Sitzungen nicht ungeprüft fortsetzen; eine
frische Sitzung für den Vergleich verwenden. Das Deployment überschreibt deine frische Sitzung für den Vergleich verwenden. Das Deployment überschreibt deine
`config.yaml` bewusst nicht. `config.yaml` bewusst nicht.
## Rückkehr ## Zur größeren Desktop-Reserve zurückkehren
```bash ```bash
~/.local/libexec/hermes-stack-llama-profile baseline ~/.local/libexec/hermes-stack-llama-profile desktop-64k
~/.local/libexec/hermes-stack-llama-restart --jobs-finished ~/.local/libexec/hermes-stack-llama-restart --jobs-finished
``` ```
@@ -98,3 +107,92 @@ für Fit-Marge, Kontext, Cache und Idle-Sleep; geprüft am 2026-09-05. Maßgebli
für deinen Rechner bleibt `llama-server --help` seiner tatsächlich installierten für deinen Rechner bleibt `llama-server --help` seiner tatsächlich installierten
Version. Die Profile übernehmen die von dir gezeigten Modellparameter; sie Version. Die Profile übernehmen die von dir gezeigten Modellparameter; sie
sind noch kein auf deiner GPU gemessener Optimalwert. sind noch kein auf deiner GPU gemessener Optimalwert.
## Desktop-Reserve und CPU-Kompression seit 12.10.2026
Auf der RTX 3080 mit NVIDIA Open 615.78.08 standen vor den GUI-Abstürzen
NVKMS-Allokationsfehler und `NV_ERR_NO_MEMORY` im Kerneljournal; danach folgten
anhaltende GSP-Zustandsfehler und Abstürze neuer GPU-Anwendungen. VRAM-Druck ist
belegt, die vollständige Kausalkette zum Treiberfehler nicht. Eine spätere
Momentaufnahme mit freiem Speicher schließt frühere Lastspitzen nicht aus.
Die aktive Workstation-Konfiguration wurde auf 4096 MiB Fit-Zielmarge geändert.
Das Kompressionsmodell bekam zusätzlich zu `--n-gpu-layers 0` die Optionen
`--device none --no-op-offload --no-kv-offload`. Nur null GPU-Layer hatte zuvor
noch etwa 700 MiB VRAM belegt. `MemoryHigh`/`MemoryMax` begrenzen RAM, nicht VRAM.
Kurze produktive Abnahme am 12.10.2026: CPU-Kompression antwortet, Hauptmodell
verarbeitet einen Text mit 984 Eingabetokens und erkennt ein synthetisches
96×96-Bild. Mindestens 4096 MiB VRAM frei während dieser Tests; keine neuen
NVIDIA-/CUDA-OOM-Fehler im Prüfzeitraum. Kein Nachweis langfristiger
Treiberstabilität, maximaler Kontext-/Bildlast oder einer bestimmten Leistung.
Das Hauptmodell behält 64K Kontext und GPU-Visionprojektor. Bei weiterem
Speicherdruck zuerst Reserve messen; optional `--no-mmproj-offload` prüfen,
Kontextkürzungen mit Hermes abstimmen. Mehr CPU-Offload kann langsamer sein.
### Auf einer neuen Workstation einrichten
Nach `./install.sh` und Bereitstellung der Modellgewichte:
1. In `llama.env` Binary/Modell/Projektor setzen; `LLAMA_PROFILE="desktop-64k"`
und `LLAMA_ARGS=()` verwenden. Ein eventuell aus dem alten Rechner kopiertes
`llama-profile.env` kann die Profilwahl überschreiben. Über den Profilhelfer
bewusst `desktop-64k` wählen. Die lokale Modellvorlage verwendet `$HOME`.
2. In `compression.env` CPU-Binary bzw. die gemeinsame CUDA-Binary, Modellpfad
und Port prüfen. Die CUDA-Binary kann mit `--device none` auf CPU rechnen.
3. Beide Konfigurationen ohne Modellstart prüfen:
```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
```
4. Hermes' Hauptmodell auf `http://127.0.0.1:8080/v1`, Alias
`qwen38-27b-local`, und den separaten Kompressionsanbieter auf
`http://127.0.0.1:8081/v1`, Alias `qwen3-1.7b-compression`, konfigurieren.
Bestehende `config.yaml` übernehmen und mit der installierten Hermes-Version
abgleichen; keine Zugangsdaten oder allgemeine Modellantworten überschreiben.
5. Erst ohne laufende Backend-Aufträge aktivieren:
```bash
systemctl --user daemon-reload
systemctl --user enable --now llama-compression.service llama-qwen.service
```
Bei bereits laufenden Diensten aktiviert `enable --now` keine neuen
Einstellungen: nach Abschluss aller Anfragen gezielt neu starten.
6. Mit normalem Browser-/Desktopbetrieb Text- und Bildanfragen prüfen und
`nvidia-smi` im Sekundentakt beobachten. Für diese 10-GB-Klasse mindestens
etwa 3 GiB tatsächlich freie Reserve unter den eigenen Aufgaben anstreben;
4096 MiB Fit-Zielmarge ist nur der Ausgangspunkt. CPU-Kompression sollte
nicht als NVIDIA-Verbraucher erscheinen. Journal auf neue CUDA-OOM-, NVKMS-
und GSP-Fehler prüfen. Aufwachen nach Idle-Sleep separat testen.
Die Unit-Vorlage verwendet den üblichen Konfigurationspfad unter `~/.config`.
Bei abweichendem `XDG_CONFIG_HOME` den Environment-Pfad in der Unit ebenfalls
anpassen. Auch vorhandene `ExecStart`-Drop-ins vor der Übernahme prüfen.
Der Installer installiert den Kompressionsdienst, aktiviert ihn aber nicht und
lädt keine Kompressionsgewichte herunter. Fehlende `compression.env` wird
angelegt, eine bestehende Datei bleibt erhalten. Die alte Workstation nutzt
aktuell explizite Argumente im Hauptmodell und eine direkte Kompressions-Unit;
diese portable Repo-Variante bildet die wirksamen Optionen ab. Ein erneuter
Installerlauf ersetzt Units/Wrapper nach Sicherung, startet aber keine laufenden
Modellserver neu. Für bestehende Setups deshalb zuerst sichern und den geplanten
Dienstwechsel ohne laufende Anfragen durchführen.
### Rücknahme auf einer Zielmaschine
Vor Änderungen `llama.env`, `llama-profile.env` (falls vorhanden),
`compression.env` und bestehende Units/Drop-ins sichern. Die Installer-Sicherung
enthält ersetzte Units/Wrapper; unveränderte Konfigurationen separat sichern.
Bei Rücknahme die betroffenen Dateien gezielt wiederherstellen, `daemon-reload`
ausführen und die beiden Server nach Abschluss der Anfragen neu starten.
Die früheren Profile sind Vergleichsmöglichkeiten, kein bewiesener stabiler
Rückfallweg bei Speicherdruck.
Quellen: [llama.cpp: Serveroptionen](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md),
[ähnlicher NVIDIA-GSP-Bericht](https://github.com/NVIDIA/open-gpu-kernel-modules/issues/1408).
Der dort genannte Display-Wakeup-Auslöser ist lokal nicht bewiesen.
+52
View File
@@ -1,3 +1,55 @@
# Validierung vom 2026-10-23 — Kontextabgleich
- `make test`: Syntax-/Secret-Musterprüfung und **52 Funktionstests bestanden**
(26 llama-, zehn Watchdog-, elf Workstation- und fünf Updater-Tests).
ShellCheck fehlt lokal und wurde nicht ausgeführt.
- Regression prüft Ersatz eines veralteten 256.000-Token-Werts, Abgleich mit
beiden 65.536-Token-Hauptmodellprofilen, Erhalt der 24.000-Token-
Kompressionsschwelle und unveränderten zweiten Konfigurationslauf.
- `git diff --check`: erfolgreich.
- Auf Archwork ausschließlich `model.context_length: 65536` ergänzt;
semantischer YAML-Vergleich bestätigt, dass keine andere Einstellung geändert
wurde. Private Sicherung unter
`~/.local/state/hermes-stack-maintenance/context-64k-20261023-144440/config.yaml`.
- Gateway kontrolliert neu gestartet; Hauptmodell und CPU-Kompression ohne
Neustart. `hermes config get model.context_length` liefert `65536`.
- Neue lokale Hermes-Sitzung über den Gateway antwortet korrekt mit
`HERMES_OK 323`. Seit der Aktivierung keine erneute Kontext-Fallback-Warnung
im Hermes-Fehlerprotokoll. Bestehende Plugin-/Docker-Warnungen bleiben sichtbar.
- Kein langer 64K-Dialog, keine erzwungene Kompression und kein Test des großen
Modells für diese Änderung; der Live-Nachweis gilt für die neue kurze Sitzung
mit dem aktiven Routineprofil.
# Validierung vom 2026-10-21
- `make test`: Syntax- und Secret-Musterprüfung sowie **31 Funktionstests
bestanden** (26 vorhandene Tests und fünf neue Updater-Regressionstests).
- Neue isolierte Tests: unveränderter Stable-Commit bei neuerem `main`,
fehlgeschlagener Updater ohne Commitwechsel, erfolgreicher Commitwechsel,
Commitwechsel mit Updater-Restartwarnung und Gateway-Rollback.
Externe Programme werden simuliert; kein echtes Netzwerk oder systemd darin.
- `git diff --check`: erfolgreich. ShellCheck ist lokal nicht installiert.
- Korrigierter Wrapper auf Archwork installiert und bytegleich geprüft.
- Echter Update-Dienstlauf am 21.10.2026, 16:26 MESZ: Stable v0.22.3 blieb auf
Commit `5b147ab036d6d19131bff8d20701b5001eab844a`; Abschluss nach rund 47 Sekunden
mit `Result=success` und `ExecMainStatus=0`. Gateway danach `active/running`.
Die neue Erfolgsmeldung erschien im Journal des Update-Dienstes.
- Ein echter Versionswechsel und ein echter Rollback wurden bei dieser Abnahme
nicht ausgelöst. Ein vollständiger Installer-Lauf wurde nicht durchgeführt.
# Validierung vom 2026-10-12
- `make test`: Shell-Syntaxprüfung, Secret-Mustercheck und **26 isolierte
Funktionstests bestanden**, einschließlich separater CPU-Kompressionskonfiguration.
- `git diff --check`: erfolgreich.
- Neue Profil- und Kompressionsvorlagen mit der installierten llama.cpp-Binary
auf unterstützte Optionsnamen geprüft, ohne Modellstart.
- ShellCheck ist lokal nicht installiert; weiterhin über Gitea-CI vorgesehen.
- Die aktive Workstation-Konfiguration wurde vor der Repo-Änderung mit echten
Text-/Bildanfragen und mindestens 4096 MiB freiem VRAM geprüft. Die portablen
Installer-/Wrapperdateien wurden nicht erneut auf den laufenden Rechner
installiert. Keine neue Workstation eingerichtet und kein Langzeitnachweis.
# Validierung vom 2026-09-05 # Validierung vom 2026-09-05
Ausgeführt in einer isolierten Arbeitsumgebung, nicht auf der Workstation: Ausgeführt in einer isolierten Arbeitsumgebung, nicht auf der Workstation:
+45 -3
View File
@@ -1,5 +1,13 @@
# Bestehende Workstation behutsam übernehmen # Bestehende Workstation behutsam übernehmen
Aktueller Stand seit 27.10.2026: Erprobung von Qwen3.5-9B IQ4_XS mit 16K
Kontext als Routineprofil. Das bisherige Qwen3.5-4B-/64K-Profil bleibt mit
`hermes-stack-model fallback` erreichbar. Messungen und Rückweg:
[QWEN9B_TRIAL.md](QWEN9B_TRIAL.md). Einrichtung und Migration:
[NEW_WORKSTATION.md](NEW_WORKSTATION.md), [INFERENCE_LIMITS.md](INFERENCE_LIMITS.md).
Die nachfolgenden historischen Profilumstellungen beschreiben den früheren
27B-Betrieb und ersetzen diese aktuelle Auswahl nicht.
Nicht blind den Schnellstart für eine neue Maschine ausführen. Vorhandene Nicht blind den Schnellstart für eine neue Maschine ausführen. Vorhandene
Hermes-/Signal-Konfiguration bleibt privat und wird nicht durch das Repo ersetzt. Hermes-/Signal-Konfiguration bleibt privat und wird nicht durch das Repo ersetzt.
Das aktuelle Deployment enthält deine gezeigten Anpassungen; weitere lokale, Das aktuelle Deployment enthält deine gezeigten Anpassungen; weitere lokale,
@@ -60,7 +68,7 @@ LLAMA_ALIAS="qwen38-27b-local"
LLAMA_MMPROJ="" LLAMA_MMPROJ=""
LLAMA_HOST="127.0.0.1" LLAMA_HOST="127.0.0.1"
LLAMA_PORT="8080" LLAMA_PORT="8080"
LLAMA_PROFILE="baseline" LLAMA_PROFILE="desktop-64k"
LLAMA_ARGS=() LLAMA_ARGS=()
``` ```
@@ -90,7 +98,8 @@ der neue Standard: HTTP-Probleme werden gemeldet, nicht durch Neustarts behandel
## 3. Optimieren und zurücksetzen ## 3. Optimieren und zurücksetzen
Nun gemäß [RTX3080.md](RTX3080.md) jeweils nur ein Profil vergleichen. Zurück: Nun gemäß [RTX3080.md](RTX3080.md) jeweils nur ein Profil vergleichen. Zurück:
Profil `baseline` wählen und kontrolliert neustarten. Für vollständige Rückkehr Bei Speicherdruck die größere Reserve von `desktop-64k` verwenden. Die
früheren Vergleichsprofile bieten keine Stabilitätsgarantie. Für vollständige Rückkehr
alte llama.env und ggf. alte Unit/Wrapper aus der Deployment-Sicherung einzeln alte llama.env und ggf. alte Unit/Wrapper aus der Deployment-Sicherung einzeln
wiederherstellen, `daemon-reload` und Neustart nach Jobende. Nicht pauschal ein wiederherstellen, `daemon-reload` und Neustart nach Jobende. Nicht pauschal ein
Archiv über dein gesamtes Home extrahieren. Die Datei `llama-profile.env` hat nur Archiv über dein gesamtes Home extrahieren. Die Datei `llama-profile.env` hat nur
@@ -98,7 +107,7 @@ bei aktivem Profilbetrieb Vorrang; ihre vorherigen Stände liegen daneben.
## Workstation-Abgleich vom 2026-09-09 ## Workstation-Abgleich vom 2026-09-09
Alltagsprofil bleibt baseline: 65536 Kontext, Fit-Zielmarge 1536 MiB. Historischer Stand (durch den Abgleich vom 12.10.2026 abgelöst): baseline, 65536 Kontext, Fit-Zielmarge 1536 MiB.
Die zwei kurzen Texttests vom 5. September ergaben im Mittel rund 2,58 tok/s Die zwei kurzen Texttests vom 5. September ergaben im Mittel rund 2,58 tok/s
für baseline und 2,62 tok/s für vram-64k. Das belegt keinen belastbaren für baseline und 2,62 tok/s für vram-64k. Das belegt keinen belastbaren
Geschwindigkeitsvorteil. Im fünfminütigen Desktop-/Bildtest mit vram-64k Geschwindigkeitsvorteil. Im fünfminütigen Desktop-/Bildtest mit vram-64k
@@ -126,3 +135,36 @@ Ein echter Update-/Rollback-Lauf der neuen Fassung war damit noch nicht geprüft
Modell und Projektor liegen jetzt außerhalb von ~/.cache. Die korrigierten Modell und Projektor liegen jetzt außerhalb von ~/.cache. Die korrigierten
Cleanup-Skripte sind unter ~/Scripts installiert. Details und Prüfgrenzen: Cleanup-Skripte sind unter ~/Scripts installiert. Details und Prüfgrenzen:
[CLEANUP_RECOVERY.md](CLEANUP_RECOVERY.md). [CLEANUP_RECOVERY.md](CLEANUP_RECOVERY.md).
## Workstation-Abgleich vom 2026-10-12
Nach NVIDIA-Speicherallokationsfehlern und GUI-Abstürzen wurde der aktive
Hauptserver auf 4096 MiB Fit-Zielmarge bei unverändert 65536 Kontext umgestellt.
Die aktive `llama.env` enthält dafür explizite `LLAMA_ARGS` bei leerem
`LLAMA_PROFILE`; Profilwechsel wirken dort erst nach bewusster Umstellung auf
Profilbetrieb. Reasoning: auto, Budget 128, kein Preserve.
Die bestehende Kompressions-Unit wurde um `--device none --no-op-offload
--no-kv-offload` ergänzt. Der vorher mit null GPU-Layern weiterhin belegte
VRAM wurde freigegeben. Kurze Text-/Bildtests bestanden mit mindestens 4096 MiB
freiem VRAM und ohne neue NVIDIA-/CUDA-OOM-Fehler. Langzeitstabilität bleibt offen.
Diese aktiven Änderungen wurden separat installiert. Die neuen portablen
Repo-Dateien wurden danach aufgenommen und getestet, aber nicht erneut über
`install.sh` auf die Workstation ausgerollt. Der aktuelle Betrieb bleibt bei den
bereits geprüften Einstellungen. Details zur Übertragung und zu bestehenden
Units/Drop-ins stehen in [RTX3080.md](RTX3080.md).
## Hermes-Update-Korrektur vom 2026-10-21
Der korrigierte `hermes-stack-hermes-update` ist gezielt auf der Workstation
installiert und mit der Repository-Datei bytegleich geprüft. Der echte
Dienstlauf mit bereits aktuellem Stable v0.22.3 endete erfolgreich
(`Result=success`, `ExecMainStatus=0`); der Gateway war danach `active/running`.
Abschlussmeldungen erscheinen jetzt direkt im Dienstjournal.
Vorherige Wrapper-Version:
`~/.local/libexec/hermes-stack-hermes-update.before-noop-fix-20261021`.
Ein vollständiger Installer-Lauf war für diese Korrektur nicht erforderlich.
Betrieb und Fehlerdiagnose: [HERMES_UPDATE.md](HERMES_UPDATE.md).
+20 -2
View File
@@ -9,6 +9,8 @@ INSTALL_LLAMA=0
LLAMA_REF="" LLAMA_REF=""
ENABLE_TIMERS=0 ENABLE_TIMERS=0
SKIP_BROWSER=1 SKIP_BROWSER=1
USER_BIN_ON_PATH=0
[[ ":$PATH:" != *":$HOME/.local/bin:"* ]] || USER_BIN_ON_PATH=1
usage() { usage() {
sed -n '/^## Installationsoptionen/,/^## /p' "$ROOT_DIR/README.md" | sed -n '/^## Installationsoptionen/,/^## /p' "$ROOT_DIR/README.md" |
@@ -43,7 +45,7 @@ export PATH="$HOME/.local/bin:$PATH"
if ((INSTALL_PACKAGES)); then if ((INSTALL_PACKAGES)); then
# Vollständige Aktualisierung statt partieller Arch-Aktualisierung. # Vollständige Aktualisierung statt partieller Arch-Aktualisierung.
packages=( 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 jre-openjdk-headless
) )
if ((INSTALL_LLAMA)); then if ((INSTALL_LLAMA)); then
@@ -83,9 +85,19 @@ for file in "$ROOT_DIR"/systemd/user/*; do
done done
printf 'Vorherige Deployment-Dateien: %s\n' "$deploy_backup" 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 for file in "$ROOT_DIR"/libexec/*; do
install -m 0755 "$file" "$libexec_dir/$(basename "$file")" install -m 0755 "$file" "$libexec_dir/$(basename "$file")"
done 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 for file in "$ROOT_DIR"/systemd/user/*; do
install -m 0644 "$file" "$unit_dir/$(basename "$file")" install -m 0644 "$file" "$unit_dir/$(basename "$file")"
@@ -105,6 +117,8 @@ install_config_if_missing \
"$ROOT_DIR/config/config.env.example" "$config_dir/config.env" "$ROOT_DIR/config/config.env.example" "$config_dir/config.env"
install_config_if_missing \ install_config_if_missing \
"$ROOT_DIR/config/llama.env.example" "$config_dir/llama.env" "$ROOT_DIR/config/llama.env.example" "$config_dir/llama.env"
install_config_if_missing \
"$ROOT_DIR/config/compression.env.example" "$config_dir/compression.env"
install_config_if_missing \ install_config_if_missing \
"$ROOT_DIR/config/signal-cli.env.example" "$config_dir/signal-cli.env" "$ROOT_DIR/config/signal-cli.env.example" "$config_dir/signal-cli.env"
@@ -119,7 +133,7 @@ systemctl --user daemon-reload
if command -v hermes >/dev/null 2>&1 && [[ ! -e "$unit_dir/hermes-gateway.service" ]]; then if command -v hermes >/dev/null 2>&1 && [[ ! -e "$unit_dir/hermes-gateway.service" ]]; then
if [[ -d "$HOME/.hermes/hermes-agent/.git" ]]; then if [[ -d "$HOME/.hermes/hermes-agent/.git" ]]; then
hermes gateway install || 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
fi fi
@@ -127,9 +141,13 @@ if ((ENABLE_TIMERS)); then
systemctl --user enable --now \ systemctl --user enable --now \
hermes-stack-hermes-update.timer \ hermes-stack-hermes-update.timer \
hermes-stack-health.timer \ hermes-stack-health.timer \
hermes-stack-llama-watchdog.timer \
hermes-stack-signal-health.timer \ hermes-stack-signal-health.timer \
hermes-stack-signal-update-check.timer hermes-stack-signal-update-check.timer
fi fi
printf '\nDeployment installiert. Nächster Schritt:\n %s\n' \ printf '\nDeployment installiert. Nächster Schritt:\n %s\n' \
"$libexec_dir/hermes-stack-doctor" "$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" source "$CONFIG_FILE"
for command in git curl jq tar zstd systemctl hermes; do 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 done
for script in "$HOME/.local/libexec"/hermes-stack-*; do for script in "$HOME/.local/libexec"/hermes-stack-*; do
[[ -f "$script" ]] || continue [[ -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 done
if [[ -d "$HERMES_REPO/.git" ]]; then if [[ -d "$HERMES_REPO/.git" ]]; then
ok "Hermes-Git-Checkout vorhanden" ok "Hermes-Git-Checkout vorhanden"
dirty="$(git -C "$HERMES_REPO" status --porcelain --untracked-files=all | dirty="$(git -C "$HERMES_REPO" status --porcelain --untracked-files=all |
grep -vxF '?? .install_method' || true)" 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 else
bad "Hermes-Git-Checkout fehlt" bad "Hermes-Git-Checkout fehlt"
fi fi
systemctl --user is-active --quiet "$GATEWAY_SERVICE" && ok "Gateway aktiv" || bad "Gateway inaktiv" if systemctl --user is-active --quiet "$GATEWAY_SERVICE"; then ok "Gateway aktiv"; else bad "Gateway inaktiv"; fi
hermes gateway status >/dev/null 2>&1 && ok "Gateway gesund" || bad "Gateway-Status fehlgeschlagen" 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" | if curl --fail --silent --max-time 5 "$LLAMA_URL/health" |
jq -e '.status == "ok"' >/dev/null 2>&1; then jq -e '.status == "ok"' >/dev/null 2>&1; then
@@ -51,7 +51,7 @@ set -u
if [[ -n "${SIGNAL_HTTP_URL:-}" ]]; then if [[ -n "${SIGNAL_HTTP_URL:-}" ]]; then
status="$(curl --silent --output /dev/null --max-time 5 --write-out '%{http_code}' \ status="$(curl --silent --output /dev/null --max-time 5 --write-out '%{http_code}' \
"${SIGNAL_HTTP_URL%/}/api/v1/check" || true)" "${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 fi
printf '\nFehler: %d\n' "$failures" printf '\nFehler: %d\n' "$failures"
+8 -1
View File
@@ -15,6 +15,8 @@ log() {
notify_user() { notify_user() {
local text="$1" local text="$1"
# Keep the outcome in this service's journal as well as the notifier's journal.
printf '%s\n' "$text"
if [[ -x "$NOTIFIER" ]]; then if [[ -x "$NOTIFIER" ]]; then
"$NOTIFIER" "$text" || true "$NOTIFIER" "$text" || true
else else
@@ -226,7 +228,12 @@ mapfile -t old_backups < <(
((${#old_backups[@]} == 0)) || rm -f -- "${old_backups[@]}" ((${#old_backups[@]} == 0)) || rm -f -- "${old_backups[@]}"
new_commit="$(git -C "$HERMES_REPO" rev-parse HEAD)" new_commit="$(git -C "$HERMES_REPO" rev-parse HEAD)"
if [[ "$new_commit" == "$old_commit" ]]; then # The updater selects the configured channel (e.g. stable), whereas the fetch
# above only checks remote connectivity. Stable may legitimately stay on the
# same commit even when origin/main has advanced.
if [[ "$new_commit" == "$old_commit" ]] && ((update_exit == 0)); then
notify_user "Hermes ist im konfigurierten Update-Kanal bereits aktuell: $new_commit. Konfiguration und frisch gestarteter Gateway sind gesund."
elif [[ "$new_commit" == "$old_commit" ]]; then
notify_user "Hermes-Update wurde nicht installiert (Updater-Exitcode $update_exit); der bisherige Commit $new_commit und der frisch gestartete Gateway sind gesund." notify_user "Hermes-Update wurde nicht installiert (Updater-Exitcode $update_exit); der bisherige Commit $new_commit und der frisch gestartete Gateway sind gesund."
exit 1 exit 1
elif ((update_exit != 0)); then elif ((update_exit != 0)); then
+3 -3
View File
@@ -8,10 +8,10 @@ runner="$(dirname -- "${BASH_SOURCE[0]}")/hermes-stack-llama-run"
case "${1:-list}" in case "${1:-list}" in
list) list)
printf '%-12s %8s %10s\n' Profil Kontext Fit-MiB \ printf '%-12s %8s %10s\n' Profil Kontext Fit-MiB \
baseline 65536 1536 vram-64k 65536 1280 \ desktop-64k 65536 4096 baseline 65536 1536 vram-64k 65536 1280 \
balanced 49152 1536 vram-plus 49152 1280 compact 32768 1536 balanced 49152 1536 vram-plus 49152 1280 compact 32768 1536
exit 0 ;; exit 0 ;;
baseline|vram-64k|balanced|vram-plus|compact) selected="$1" ;; desktop-64k|baseline|vram-64k|balanced|vram-plus|compact) selected="$1" ;;
*) *)
printf 'Unbekanntes Profil. Verfügbare Profile: %s list\n' "$0" >&2 printf 'Unbekanntes Profil. Verfügbare Profile: %s list\n' "$0" >&2
exit 2 ;; exit 2 ;;
@@ -24,7 +24,7 @@ flock -n 9 || {
exit 1 exit 1
} }
# shellcheck disable=SC1090 # shellcheck disable=SC1090,SC1091
source "$config_dir/llama.env" source "$config_dir/llama.env"
[[ -n "${LLAMA_PROFILE:-}" ]] || { [[ -n "${LLAMA_PROFILE:-}" ]] || {
printf 'Profilbetrieb ist nicht konfiguriert.\n' >&2 printf 'Profilbetrieb ist nicht konfiguriert.\n' >&2
+1 -1
View File
@@ -3,7 +3,7 @@
set -Euo pipefail set -Euo pipefail
config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance" config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance"
if [[ -r "$config_dir/config.env" ]]; then if [[ -r "$config_dir/config.env" ]]; then
# shellcheck disable=SC1090 # shellcheck disable=SC1090,SC1091
source "$config_dir/config.env" source "$config_dir/config.env"
fi fi
printf '\nGPU (Gesamtspeicher, belegt, frei in MiB):\n' printf '\nGPU (Gesamtspeicher, belegt, frei in MiB):\n'
+1 -1
View File
@@ -8,7 +8,7 @@ if [[ "${1:-}" != --jobs-finished || $# -ne 1 ]]; then
fi fi
config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance" config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance"
# shellcheck disable=SC1090 # shellcheck disable=SC1090,SC1091
source "$config_dir/config.env" source "$config_dir/config.env"
exec 8>"${XDG_RUNTIME_DIR:-/tmp}/hermes-stack-hermes-update.lock" exec 8>"${XDG_RUNTIME_DIR:-/tmp}/hermes-stack-hermes-update.lock"
+9 -2
View File
@@ -27,7 +27,7 @@ while (($#)); do
done done
if [[ -n "${LLAMA_PROFILE:-}" && -r "$config_dir/llama-profile.env" ]]; then if [[ -n "${LLAMA_PROFILE:-}" && -r "$config_dir/llama-profile.env" ]]; then
# shellcheck disable=SC1090 # shellcheck disable=SC1090,SC1091
source "$config_dir/llama-profile.env" source "$config_dir/llama-profile.env"
fi fi
[[ -z "$profile_override" ]] || LLAMA_PROFILE="$profile_override" [[ -z "$profile_override" ]] || LLAMA_PROFILE="$profile_override"
@@ -70,9 +70,15 @@ if [[ -n "${LLAMA_MMPROJ:-}" ]]; then
fi fi
[[ -z "${LLAMA_ALIAS:-}" ]] || model_args+=(--alias "$LLAMA_ALIAS") [[ -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=() profile_args=()
if [[ -n "${LLAMA_PROFILE:-}" ]]; then if [[ -n "${LLAMA_PROFILE:-}" ]]; then
reasoning_budget=512
reasoning_preserve=--reasoning-preserve
case "$LLAMA_PROFILE" in case "$LLAMA_PROFILE" in
desktop-64k) ctx=65536; margin=4096; reasoning_budget=128; reasoning_preserve=--no-reasoning-preserve ;;
baseline) ctx=65536; margin=1536 ;; baseline) ctx=65536; margin=1536 ;;
vram-64k) ctx=65536; margin=1280 ;; vram-64k) ctx=65536; margin=1280 ;;
balanced) ctx=49152; margin=1536 ;; balanced) ctx=49152; margin=1536 ;;
@@ -86,7 +92,7 @@ if [[ -n "${LLAMA_PROFILE:-}" ]]; then
profile_args=( profile_args=(
--ctx-size "$ctx" --parallel 1 --jinja --ctx-size "$ctx" --parallel 1 --jinja
--reasoning auto --reasoning-budget 512 --reasoning-preserve --reasoning auto --reasoning-budget "$reasoning_budget" "$reasoning_preserve"
--flash-attn auto --threads 6 --threads-batch 16 --flash-attn auto --threads 6 --threads-batch 16
--n-gpu-layers auto --fit on --fit-target "$margin" --n-gpu-layers auto --fit on --fit-target "$margin"
--cache-type-k q8_0 --cache-type-v q8_0 --cache-type-k q8_0 --cache-type-v q8_0
@@ -104,6 +110,7 @@ args=(
"${model_args[@]}" "${model_args[@]}"
--host "${LLAMA_HOST:-127.0.0.1}" --host "${LLAMA_HOST:-127.0.0.1}"
--port "${LLAMA_PORT:-8080}" --port "${LLAMA_PORT:-8080}"
--n-predict "${LLAMA_MAX_OUTPUT_TOKENS:-2048}"
"${profile_args[@]}" "${profile_args[@]}"
"${LLAMA_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"
+90
View File
@@ -0,0 +1,90 @@
#!/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|fallback|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|fallback|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"
hermes_config="${HERMES_HOME:-$HOME/.hermes}/config.yaml"
hermes_backup=""
if [[ -f "$hermes_config" ]]; then
hermes_backup="$(mktemp "$config_dir/hermes.yaml.before-model-switch.XXXXXX")"
cp -p -- "$hermes_config" "$hermes_backup"
fi
rollback() {
local rc=$?
trap - EXIT
if ((rc != 0)); then
install -m 0600 "$backup" "$config_dir/llama.env" || true
if [[ -n "$hermes_backup" ]]; then
install -m 0600 "$hermes_backup" "$hermes_config" || true
fi
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
systemctl --user stop hermes-gateway.service
install -m 0600 "$candidate" "$config_dir/llama.env"
# Keep Hermes' actual context and compression trigger paired with the model.
# Legacy profiles without metadata use the original 64K window.
LLAMA_CONTEXT_TOKENS=65536
# shellcheck disable=SC1090
source "$candidate"
if [[ -n "$hermes_backup" ]]; then
python3 - "$hermes_config" "$LLAMA_CONTEXT_TOKENS" <<'PY'
import os
from pathlib import Path
import sys
import tempfile
import yaml
path = Path(sys.argv[1])
context = int(sys.argv[2])
if context < 64000:
raise ValueError("Hermes requires at least 64000 context tokens")
config = yaml.safe_load(path.read_text())
config.setdefault("model", {})["context_length"] = context
config.setdefault("compression", {})["threshold_tokens"] = min(24000, context * 3 // 8)
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as out:
yaml.safe_dump(config, out, allow_unicode=True, sort_keys=False, width=1000)
temporary = Path(out.name)
os.chmod(temporary, 0o600)
temporary.replace(path)
PY
fi
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 2>/dev/null | 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()
+206
View File
@@ -0,0 +1,206 @@
#!/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-9b-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=""):
context = 65536
args = ["--ctx-size", str(context), "--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 in ("routine", "fallback"):
# Explicit full offload: fail instead of silently moving layers to CPU.
args += ["--n-gpu-layers", "99", "--fit", "off"]
else:
args += ["--n-gpu-layers", "auto", "--fit", "on", "--fit-target", "4096"]
if name == "routine":
args += ["--no-mmproj-offload", "--batch-size", "512", "--ubatch-size", "128"]
args += ["--sleep-idle-seconds", "300", "--image-min-tokens", "1024",
"--cors-origins", "localhost"]
cache = "q4_0" if name == "routine" else "q8_0"
args += ["--flash-attn", "auto", "--cache-type-k", cache, "--cache-type-v", cache, "--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_CONTEXT_TOKENS": str(context), "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, context=65536):
if context < 64000:
raise ValueError("Hermes requires at least 64000 context tokens")
config.setdefault("model", {}).update(default="hermes-local", provider="custom",
base_url=LOCAL_URL, context_length=context)
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)
config.setdefault("compression", {})["threshold_tokens"] = min(24000, context * 3 // 8)
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,fallback", help="Kommagetrennt: routine,large,compression,fallback")
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", "fallback"], 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,fallback")
if options.select and options.select not in names:
parser.error("--select muss in --models enthalten sein")
if options.configure_hermes and not any(n in names for n in ("routine", "large", "fallback")):
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, 65536)
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
+20
View File
@@ -0,0 +1,20 @@
[Unit]
Description=Hermes auxiliary compression LLM (CPU)
After=default.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=simple
Environment=HERMES_LLAMA_CONFIG=%h/.config/hermes-stack-maintenance/compression.env
ExecStart=%h/.local/libexec/hermes-stack-llama-run
Restart=on-failure
RestartSec=30
Nice=10
MemoryHigh=8G
MemoryMax=12G
OOMPolicy=stop
TimeoutStopSec=60
[Install]
WantedBy=default.target
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Local llama.cpp Qwen model server Description=Hermes local main model (routine/large)
After=network-online.target graphical-session.target After=network-online.target graphical-session.target
StartLimitIntervalSec=300 StartLimitIntervalSec=300
StartLimitBurst=3 StartLimitBurst=3
+3
View File
@@ -21,6 +21,9 @@ else
fi fi
python3 "$ROOT_DIR/tests/test_llama.py" || failures=$((failures + 1)) 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/**' \ if (cd -- "$ROOT_DIR" && rg -n --hidden --glob '!tests/smoke.sh' --glob '!tests/test_llama.py' --glob '!.git/**' \
'(TELEGRAM_BOT_TOKEN|SIGNAL_ACCOUNT|API_KEY)=("|'"'"')?[A-Za-z0-9+]' .); then '(TELEGRAM_BOT_TOKEN|SIGNAL_ACCOUNT|API_KEY)=("|'"'"')?[A-Za-z0-9+]' .); then
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Run the actual maintenance script with isolated state and fake external tools."""
import os
from pathlib import Path
import shlex
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
RUNNER = ROOT / "libexec/hermes-stack-hermes-update"
class HermesUpdateTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="hermes-update-", dir=ROOT.parent)
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.bin = self.root / "bin"
self.bin.mkdir()
config = self.root / "config/hermes-stack-maintenance"
config.mkdir(parents=True)
(self.root / "hermes-agent/.git").mkdir(parents=True)
self.env = dict(os.environ, XDG_CONFIG_HOME=str(config.parent),
XDG_STATE_HOME=str(self.root / "state"),
XDG_RUNTIME_DIR=str(self.root), TEST_ROOT=str(self.root),
PATH=str(self.bin) + ":" + os.environ["PATH"],
TEST_UPDATE_EXIT="0", TEST_CHANGED="0", TEST_HEALTHY="1")
(config / "config.env").write_text(
f'HERMES_REPO={shlex.quote(str(self.root / "hermes-agent"))}\n'
f'NOTIFIER={shlex.quote(str(self.bin / "notify-test"))}\n'
'HERMES_REMOTE=origin\nHERMES_BRANCH=main\n'
'GATEWAY_SERVICE=test-gateway.service\n'
'GATEWAY_HEALTH_ATTEMPTS=1\nGATEWAY_HEALTH_DELAY=0\n'
'LLAMA_URL=http://unused.invalid\n')
self.program("git", '''case "$*" in
*"status --porcelain"*|*"fetch --no-tags"*) exit 0 ;;
*"rev-parse HEAD"*)
if [[ -f "$TEST_ROOT/changed" ]]; then printf 'c%.0s' {1..40};
else printf 'a%.0s' {1..40}; fi ;;
*"rev-parse refs/remotes/"*) printf 'b%.0s' {1..40} ;;
*) exit 99 ;;
esac
''')
self.program("hermes", '''case "$*" in
update)
[[ "$TEST_CHANGED" == 0 ]] || touch "$TEST_ROOT/changed"
echo "Simulated updater completion"
exit "$TEST_UPDATE_EXIT" ;;
"gateway status") [[ "$TEST_HEALTHY" == 1 ]] ;;
"config check"|"config migrate") exit 0 ;;
*) exit 99 ;;
esac
''')
self.program("systemctl", 'printf "%s\\n" "$*" >>"$TEST_ROOT/systemctl-calls"\n')
self.program("tar", 'printf "%s\\n" "$*" >>"$TEST_ROOT/tar-calls"\n')
self.program("curl", "echo '{\"status\":\"ok\"}'\n")
self.program("notify-test", 'printf "%s\\n" "$*" >>"$TEST_ROOT/notices"\n')
self.program("systemd-cat", "cat >/dev/null\n")
self.program("sleep", "exit 0\n")
def program(self, name, body):
path = self.bin / name
path.write_text("#!/usr/bin/env bash\nset -eu\n" + body)
path.chmod(0o755)
def run_update(self, **settings):
return subprocess.run(["bash", str(RUNNER)], env=dict(self.env, **settings),
capture_output=True, text=True, timeout=15)
def test_stable_unchanged_with_newer_main_succeeds(self):
result = self.run_update()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("bereits aktuell", result.stdout)
self.assertIn("restart test-gateway.service", (self.root / "systemctl-calls").read_text())
self.assertNotIn("zurückgerollt", (self.root / "notices").read_text())
def test_unchanged_failed_updater_still_fails(self):
result = self.run_update(TEST_UPDATE_EXIT="7")
self.assertEqual(result.returncode, 1, result.stderr)
self.assertIn("Updater-Exitcode 7", result.stdout)
def test_changed_commit_succeeds(self):
result = self.run_update(TEST_CHANGED="1")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("erfolgreich aktualisiert", result.stdout)
def test_changed_commit_with_restart_warning(self):
result = self.run_update(TEST_CHANGED="1", TEST_UPDATE_EXIT="7")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Updater-Restartwarnung", result.stdout)
def test_unhealthy_gateway_still_rolls_back(self):
result = self.run_update(TEST_HEALTHY="0")
self.assertEqual(result.returncode, 1, result.stderr)
self.assertIn("zurückgerollt", result.stdout)
self.assertIn("-xf", (self.root / "tar-calls").read_text())
if __name__ == "__main__":
unittest.main()
+27 -3
View File
@@ -13,9 +13,10 @@ RUNNER = ROOT / "libexec/hermes-stack-llama-run"
PROFILE = ROOT / "libexec/hermes-stack-llama-profile" PROFILE = ROOT / "libexec/hermes-stack-llama-profile"
RESTART = ROOT / "libexec/hermes-stack-llama-restart" RESTART = ROOT / "libexec/hermes-stack-llama-restart"
OPTIONS = """--model --hf-repo --mmproj --alias --host --port --ctx-size --parallel OPTIONS = """--model --hf-repo --mmproj --alias --host --port --ctx-size --parallel
--jinja --reasoning --reasoning-budget --reasoning-preserve --flash-attn --threads --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 --threads-batch --n-gpu-layers --fit --fit-target --cache-type-k --cache-type-v
--sleep-idle-seconds --image-min-tokens --cors-origins --no-ui""" --sleep-idle-seconds --image-min-tokens --cors-origins --no-ui --rope-scaling --rope-scale --yarn-orig-ctx
--device --no-op-offload --no-kv-offload --n-predict"""
class LlamaTests(unittest.TestCase): class LlamaTests(unittest.TestCase):
@@ -72,7 +73,8 @@ class LlamaTests(unittest.TestCase):
return result return result
def test_profile_values(self): def test_profile_values(self):
for name, ctx, margin in [("baseline", "65536", "1536"), for name, ctx, margin in [("desktop-64k", "65536", "4096"),
("baseline", "65536", "1536"),
("vram-64k", "65536", "1280"), ("vram-64k", "65536", "1280"),
("balanced", "49152", "1536"), ("balanced", "49152", "1536"),
("vram-plus", "49152", "1280"), ("vram-plus", "49152", "1280"),
@@ -85,6 +87,28 @@ class LlamaTests(unittest.TestCase):
self.assertEqual(args.count("--ctx-size"), 1) self.assertEqual(args.count("--ctx-size"), 1)
self.assertIn("qwen38-27b-local", args) self.assertIn("qwen38-27b-local", args)
def test_cpu_compression_template_uses_separate_config(self):
model = self.root / "compression model.gguf"
model.touch()
template = (ROOT / "config/compression.env.example").read_text()
template = template.replace('LLAMA_SERVER_BIN=""',
f'LLAMA_SERVER_BIN={shlex.quote(str(self.server))}')
template = template.replace(
'LLAMA_MODEL="$HOME/.local/share/hermes-stack/models/qwen3-1.7b-compression/Qwen3-1.7B-Q4_K_M.gguf"',
f'LLAMA_MODEL={shlex.quote(str(model))}')
config = self.config / "compression.env"
config.write_text(template)
self.env["HERMES_LLAMA_CONFIG"] = str(config)
self.run_tool(RUNNER)
args = self.record.read_text().splitlines()
self.assertEqual(args[args.index("--port") + 1], "8081")
self.assertEqual(args[args.index("--device") + 1], "none")
self.assertIn("--no-op-offload", args)
self.assertIn("--no-kv-offload", args)
self.assertIn(str(model), args)
self.assertNotIn("qwen38-27b-local", args)
self.assertNotIn("--fit-target", args)
def test_check_does_not_run_inference(self): def test_check_does_not_run_inference(self):
self.run_tool(RUNNER, "--check") self.run_tool(RUNNER, "--check")
self.assertFalse(self.record.exists()) self.assertFalse(self.record.exists())
+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()
+202
View File
@@ -0,0 +1,202 @@
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_context_and_compression_follow_selected_model(self):
for name, window, trigger in (("routine", 65536, 24000), ("fallback", 65536, 24000), ("large", 65536, 24000)):
with self.subTest(name=name):
config = {"model": {"context_length": 256000}, "compression": {"threshold_tokens": 24000}}
prepare.configure_hermes(config, context=window)
self.assertEqual(config["model"]["context_length"], window)
self.assertEqual(config["compression"]["threshold_tokens"], trigger)
text = prepare.profile_config(name, [self.root / "weights.gguf"])
self.assertIn(f"--ctx-size {window}", text)
before = json.dumps(config, sort_keys=True)
prepare.configure_hermes(config, context=window)
self.assertEqual(json.dumps(config, sort_keys=True), before)
def test_configuration_refuses_below_hermes_context_floor(self):
config = {"model": {"context_length": 65536}}
with self.assertRaisesRegex(ValueError, "at least 64000"):
prepare.configure_hermes(config, context=16384)
self.assertEqual(config, {"model": {"context_length": 65536}})
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"],
HERMES_HOME=str(self.root / "hermes"), XDG_CONFIG_HOME=str(self.config.parent), XDG_RUNTIME_DIR=str(self.root), TEST_CALLS=str(self.root / "calls"))
def test_switch_and_failure_keep_hermes_window_paired_with_model(self):
import yaml
directory = self.root / "hermes"
directory.mkdir()
path = directory / "config.yaml"
original = "model:\n context_length: 65536\ncompression:\n threshold_tokens: 24000\nterminal:\n backend: docker\n"
(self.config / "llama-routine.env").write_text("LLAMA_MODEL=new.gguf\nLLAMA_CONTEXT_TOKENS=64000\n")
for fail in (False, True):
path.write_text(original)
(self.config / "llama.env").write_text("LLAMA_MODEL=old.gguf\n")
result = subprocess.run(["bash", str(self.bin / "hermes-stack-model"), "routine"],
env=dict(self.env, TEST_FAIL=str(int(fail))), capture_output=True, text=True)
if fail:
self.assertNotEqual(result.returncode, 0)
self.assertEqual(path.read_text(), original)
self.assertEqual((self.config / "llama.env").read_text(), "LLAMA_MODEL=old.gguf\n")
else:
self.assertEqual(result.returncode, 0, result.stderr)
config = yaml.safe_load(path.read_text())
self.assertEqual(config["model"]["context_length"], 64000)
self.assertEqual(config["compression"]["threshold_tokens"], 24000)
self.assertEqual(config["terminal"]["backend"], "docker")
def test_hermes_rejects_too_small_context_and_restores_pair(self):
directory = self.root / "hermes"
directory.mkdir()
path = directory / "config.yaml"
original = "model:\n context_length: 65536\n"
path.write_text(original)
(self.config / "llama-routine.env").write_text("LLAMA_MODEL=new.gguf\nLLAMA_CONTEXT_TOKENS=16384\n")
result = subprocess.run(["bash", str(self.bin / "hermes-stack-model"), "routine"],
env=self.env, capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
self.assertIn("at least 64000", result.stderr)
self.assertEqual(path.read_text(), original)
self.assertEqual((self.config / "llama.env").read_text(), "LLAMA_MODEL=old.gguf\n")
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()
+12 -2
View File
@@ -6,14 +6,18 @@ set -Eeuo pipefail
units=( units=(
hermes-stack-hermes-update.service hermes-stack-hermes-update.timer hermes-stack-hermes-update.service hermes-stack-hermes-update.timer
hermes-stack-health.service hermes-stack-health.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-health.service hermes-stack-signal-health.timer
hermes-stack-signal-update-check.service hermes-stack-signal-update-check.timer hermes-stack-signal-update-check.service hermes-stack-signal-update-check.timer
llama-qwen.service signal-cli.service llama-qwen.service llama-compression.service signal-cli.service
) )
systemctl --user disable --now llama-compression.service 2>/dev/null || true
systemctl --user disable --now \ systemctl --user disable --now \
hermes-stack-hermes-update.timer \ hermes-stack-hermes-update.timer \
hermes-stack-health.timer \ hermes-stack-health.timer \
hermes-stack-llama-watchdog.timer \
hermes-stack-signal-health.timer \ hermes-stack-signal-health.timer \
hermes-stack-signal-update-check.timer 2>/dev/null || true hermes-stack-signal-update-check.timer 2>/dev/null || true
@@ -25,10 +29,16 @@ for file in \
hermes-stack-notify hermes-stack-hermes-update hermes-stack-health \ 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-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-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 hermes-stack-signal-run hermes-stack-doctor; do
rm -f -- "$HOME/.local/libexec/$file" rm -f -- "$HOME/.local/libexec/$file"
done done
rm -f -- "$HOME/.local/lib/hermes-stack/llama_watchdog.py"
systemctl --user daemon-reload 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' printf 'Deployment-Dateien entfernt; Benutzerdaten und Konfigurationen wurden beibehalten.\n'