Compare commits

..
5 Commits
34 changed files with 1552 additions and 24 deletions

No files matched your search

+1 -1
View File
@@ -12,6 +12,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install test dependencies
run: pacman -Syu --noconfirm bash shellcheck ripgrep python jq
run: pacman -Syu --noconfirm bash shellcheck ripgrep python python-yaml jq
- name: Run smoke tests
run: ./tests/smoke.sh
+28
View File
@@ -1,3 +1,31 @@
# 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
+39 -7
View File
@@ -14,11 +14,15 @@ Reproduzierbares Benutzer-Deployment für Hermes Agent auf Arch Linux mit:
- optionalem CUDA-Build von llama-server, llama-cli und llama-bench
- Qwen-/RTX-3080-Profilen mit prüfbarem Wechsel und Rückweg
Stand: 2026-10-12. 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
eine neue Maschine. Hardware-Leistungsgewinne sind noch nicht auf deiner GPU
gemessen; die kurze Funktions- und VRAM-Abnahme vom 12.10.2026 ist in
[RTX3080.md](docs/RTX3080.md) dokumentiert. Der Installer richtet keinen
eine neue Maschine. Das Routineprofil erprobt Qwen3.5-9B IQ4_XS mit 64K Kontext. Das bisherige
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
@@ -61,7 +65,9 @@ hermes gateway install --force
```
In Hermes als lokalen OpenAI-kompatiblen Endpunkt `http://127.0.0.1:8080/v1`
und als Modell-ID `qwen38-27b-local` wählen. Bestehende Hermes-Konfigurationen,
und als Modell-ID `hermes-local` wählen, wenn die aktuellen Routine-/Large-Profile
nach [NEW_WORKSTATION.md](docs/NEW_WORKSTATION.md) vorbereitet wurden. Der ältere
HF-Schnellstart verwendet weiterhin `qwen38-27b-local`. Bestehende Hermes-Konfigurationen,
Profile, Skills und Memories werden nicht durch generische Vorgaben ersetzt.
Für Neuinstallationen diese Einstellungen interaktiv vornehmen oder die eigenen
Daten gemäß Migration sicher übernehmen. Der Alias allein setzt kein Kontextlimit.
@@ -83,6 +89,7 @@ Maintenance aktivieren:
systemctl --user enable --now \
hermes-stack-hermes-update.timer \
hermes-stack-health.timer \
hermes-stack-llama-watchdog.timer \
hermes-stack-signal-health.timer \
hermes-stack-signal-update-check.timer
```
@@ -116,12 +123,29 @@ Abschließend:
Ohne Optionen installiert das Skript nur die versionierten Dateien und legt
fehlende Beispielkonfigurationen an. Bestehende lokale Konfigurationen werden
nicht überschrieben.
`hermes-stack-model` wird über `~/.local/bin` ohne Pfadangabe aufrufbar. Der
separate Helfer `scripts/prepare-workstation.py` stellt auf Wunsch die fixierten
Modelldateien bereit und übernimmt gezielt die aktuellen Hermes-Einstellungen.
Ersetzte Wrapper und Units werden vorher gesichert. Eine vorhandene
hermes-gateway.service wird nicht neu erzeugt. Ein Modell wird erst beim echten
Start des llama-Dienstes geladen, nicht bei der Konfigurationsprüfung.
## RTX 3080 und Qwen
Die aktuellen Schutzmaßnahmen gegen endlose Modellantworten und die explizite
Umschaltung zwischen Routine- und großem Modell stehen in
[INFERENCE_LIMITS.md](docs/INFERENCE_LIMITS.md).
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
~/.local/libexec/hermes-stack-llama-run --check
~/.local/libexec/hermes-stack-llama-profile list
@@ -150,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
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
5. Stoppen des Gateways und Aufruf von `hermes update`
6. einmaliger Retry bei eindeutig transientem Fetch-Fehler
@@ -159,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
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
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
+3
View File
@@ -7,6 +7,9 @@ HERMES_BRANCH="main"
GATEWAY_SERVICE="hermes-gateway.service"
LLAMA_SERVICE="llama-qwen.service"
LLAMA_URL="http://127.0.0.1:8080"
# Independent slot watchdog. Active requests only; updates hold the same lock.
LLAMA_MAX_REQUEST_SECONDS=600
LLAMA_MAX_OUTPUT_TOKENS=2048
# 1: inaktiven Dienst wieder starten. HTTP-Timeouts allein starten nie neu.
# Standard 0 verhindert unerwünschte Modellstarts während Wartung/Tuning.
LLAMA_HEALTH_AUTORESTART=0
+1
View File
@@ -8,6 +8,7 @@ LLAMA_ALIAS="qwen38-27b-local"
LLAMA_MMPROJ=""
LLAMA_HOST="127.0.0.1"
LLAMA_PORT="8080"
LLAMA_MAX_OUTPUT_TOKENS=2048
# Standard für eine gemeinsam mit dem Desktop genutzte GPU: 64K / 4096 MiB.
# Fit ist ein Planungsziel; die freie Reserve auf der Zielmaschine messen.
+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
}
]
}
}
}
+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).
+4
View File
@@ -1,5 +1,9 @@
# Migration auf eine neue Arch-Linux-Maschine
Für den aktuellen Routine-/Large-Betrieb samt Limits zuerst
[NEW_WORKSTATION.md](NEW_WORKSTATION.md) verwenden. Die folgenden Abschnitte
ergänzen die Übernahme persönlicher Daten und die ältere manuelle Modellkonfiguration.
Das Gitea-Repository reproduziert Programme, Units und Wartungslogik. Persönliche
Hermes-Daten, Secrets, Signal-Geräteidentität und Modellgewichte werden separat
übertragen.
+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.
+3
View File
@@ -1,5 +1,8 @@
# 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
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
+39
View File
@@ -1,3 +1,42 @@
# 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
+21
View File
@@ -1,5 +1,13 @@
# 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
Hermes-/Signal-Konfiguration bleibt privat und wird nicht durch das Repo ersetzt.
Das aktuelle Deployment enthält deine gezeigten Anpassungen; weitere lokale,
@@ -147,3 +155,16 @@ 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).
+18 -2
View File
@@ -9,6 +9,8 @@ INSTALL_LLAMA=0
LLAMA_REF=""
ENABLE_TIMERS=0
SKIP_BROWSER=1
USER_BIN_ON_PATH=0
[[ ":$PATH:" != *":$HOME/.local/bin:"* ]] || USER_BIN_ON_PATH=1
usage() {
sed -n '/^## Installationsoptionen/,/^## /p' "$ROOT_DIR/README.md" |
@@ -43,7 +45,7 @@ export PATH="$HOME/.local/bin:$PATH"
if ((INSTALL_PACKAGES)); then
# Vollständige Aktualisierung statt partieller Arch-Aktualisierung.
packages=(
bash git curl xz jq zstd tar ripgrep ffmpeg coreutils util-linux \
bash git curl xz jq zstd tar ripgrep ffmpeg coreutils util-linux python python-yaml \
jre-openjdk-headless
)
if ((INSTALL_LLAMA)); then
@@ -83,9 +85,19 @@ for file in "$ROOT_DIR"/systemd/user/*; do
done
printf 'Vorherige Deployment-Dateien: %s\n' "$deploy_backup"
# Public command entry point; never depend on adding libexec to PATH.
mkdir -p "$HOME/.local/bin" "$deploy_backup/bin"
command_target="$HOME/.local/bin/hermes-stack-model"
if [[ -e "$command_target" || -L "$command_target" ]]; then
cp -a -- "$command_target" "$deploy_backup/bin/"
fi
ln -sfn -- ../libexec/hermes-stack-model "$command_target"
for file in "$ROOT_DIR"/libexec/*; do
install -m 0755 "$file" "$libexec_dir/$(basename "$file")"
done
install -d -m 0755 "$HOME/.local/lib/hermes-stack"
install -m 0644 "$ROOT_DIR/scripts/llama_watchdog.py" "$HOME/.local/lib/hermes-stack/llama_watchdog.py"
for file in "$ROOT_DIR"/systemd/user/*; do
install -m 0644 "$file" "$unit_dir/$(basename "$file")"
@@ -121,7 +133,7 @@ systemctl --user daemon-reload
if command -v hermes >/dev/null 2>&1 && [[ ! -e "$unit_dir/hermes-gateway.service" ]]; then
if [[ -d "$HOME/.hermes/hermes-agent/.git" ]]; then
hermes gateway install ||
printf 'Gateway-Unit konnte noch nicht erzeugt werden; nach `hermes setup` erneut ausführen.\n' >&2
printf 'Gateway-Unit konnte noch nicht erzeugt werden; nach hermes setup erneut ausführen.\n' >&2
fi
fi
@@ -129,9 +141,13 @@ if ((ENABLE_TIMERS)); then
systemctl --user enable --now \
hermes-stack-hermes-update.timer \
hermes-stack-health.timer \
hermes-stack-llama-watchdog.timer \
hermes-stack-signal-health.timer \
hermes-stack-signal-update-check.timer
fi
printf '\nDeployment installiert. Nächster Schritt:\n %s\n' \
"$libexec_dir/hermes-stack-doctor"
if ((USER_BIN_ON_PATH == 0)); then
printf 'Für Befehle ohne Pfad ~/.local/bin in den Shell-PATH aufnehmen.\n'
fi
+6 -6
View File
@@ -15,25 +15,25 @@ bad() { printf '✗ %s\n' "$*"; failures=$((failures + 1)); }
source "$CONFIG_FILE"
for command in git curl jq tar zstd systemctl hermes; do
command -v "$command" >/dev/null 2>&1 && ok "$command vorhanden" || bad "$command fehlt"
if command -v "$command" >/dev/null 2>&1; then ok "$command vorhanden"; else bad "$command fehlt"; fi
done
for script in "$HOME/.local/libexec"/hermes-stack-*; do
[[ -f "$script" ]] || continue
bash -n "$script" && ok "Syntax: ${script##*/}" || bad "Syntaxfehler: ${script##*/}"
if bash -n "$script"; then ok "Syntax: ${script##*/}"; else bad "Syntaxfehler: ${script##*/}"; fi
done
if [[ -d "$HERMES_REPO/.git" ]]; then
ok "Hermes-Git-Checkout vorhanden"
dirty="$(git -C "$HERMES_REPO" status --porcelain --untracked-files=all |
grep -vxF '?? .install_method' || true)"
[[ -z "$dirty" ]] && ok "Hermes-Checkout sauber" || bad "Hermes-Checkout enthält lokale Änderungen"
if [[ -z "$dirty" ]]; then ok "Hermes-Checkout sauber"; else bad "Hermes-Checkout enthält lokale Änderungen"; fi
else
bad "Hermes-Git-Checkout fehlt"
fi
systemctl --user is-active --quiet "$GATEWAY_SERVICE" && ok "Gateway aktiv" || bad "Gateway inaktiv"
hermes gateway status >/dev/null 2>&1 && ok "Gateway gesund" || bad "Gateway-Status fehlgeschlagen"
if systemctl --user is-active --quiet "$GATEWAY_SERVICE"; then ok "Gateway aktiv"; else bad "Gateway inaktiv"; fi
if hermes gateway status >/dev/null 2>&1; then ok "Gateway gesund"; else bad "Gateway-Status fehlgeschlagen"; fi
if curl --fail --silent --max-time 5 "$LLAMA_URL/health" |
jq -e '.status == "ok"' >/dev/null 2>&1; then
@@ -51,7 +51,7 @@ set -u
if [[ -n "${SIGNAL_HTTP_URL:-}" ]]; then
status="$(curl --silent --output /dev/null --max-time 5 --write-out '%{http_code}' \
"${SIGNAL_HTTP_URL%/}/api/v1/check" || true)"
[[ "$status" =~ ^2[0-9][0-9]$ ]] && ok "signal-cli HTTP erreichbar" || bad "signal-cli HTTP nicht erreichbar"
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then ok "signal-cli HTTP erreichbar"; else bad "signal-cli HTTP nicht erreichbar"; fi
fi
printf '\nFehler: %d\n' "$failures"
+8 -1
View File
@@ -15,6 +15,8 @@ log() {
notify_user() {
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
"$NOTIFIER" "$text" || true
else
@@ -226,7 +228,12 @@ mapfile -t old_backups < <(
((${#old_backups[@]} == 0)) || rm -f -- "${old_backups[@]}"
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."
exit 1
elif ((update_exit != 0)); then
+1 -1
View File
@@ -24,7 +24,7 @@ flock -n 9 || {
exit 1
}
# shellcheck disable=SC1090
# shellcheck disable=SC1090,SC1091
source "$config_dir/llama.env"
[[ -n "${LLAMA_PROFILE:-}" ]] || {
printf 'Profilbetrieb ist nicht konfiguriert.\n' >&2
+1 -1
View File
@@ -3,7 +3,7 @@
set -Euo pipefail
config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance"
if [[ -r "$config_dir/config.env" ]]; then
# shellcheck disable=SC1090
# shellcheck disable=SC1090,SC1091
source "$config_dir/config.env"
fi
printf '\nGPU (Gesamtspeicher, belegt, frei in MiB):\n'
+1 -1
View File
@@ -8,7 +8,7 @@ if [[ "${1:-}" != --jobs-finished || $# -ne 1 ]]; then
fi
config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance"
# shellcheck disable=SC1090
# shellcheck disable=SC1090,SC1091
source "$config_dir/config.env"
exec 8>"${XDG_RUNTIME_DIR:-/tmp}/hermes-stack-hermes-update.lock"
+5 -1
View File
@@ -27,7 +27,7 @@ while (($#)); do
done
if [[ -n "${LLAMA_PROFILE:-}" && -r "$config_dir/llama-profile.env" ]]; then
# shellcheck disable=SC1090
# shellcheck disable=SC1090,SC1091
source "$config_dir/llama-profile.env"
fi
[[ -z "$profile_override" ]] || LLAMA_PROFILE="$profile_override"
@@ -70,6 +70,9 @@ if [[ -n "${LLAMA_MMPROJ:-}" ]]; then
fi
[[ -z "${LLAMA_ALIAS:-}" ]] || model_args+=(--alias "$LLAMA_ALIAS")
# Server defaults are not a wall-clock limit; the independent slot watchdog is.
[[ "${LLAMA_MAX_OUTPUT_TOKENS:-2048}" =~ ^[1-9][0-9]*$ ]] || exit 2
profile_args=()
if [[ -n "${LLAMA_PROFILE:-}" ]]; then
reasoning_budget=512
@@ -107,6 +110,7 @@ args=(
"${model_args[@]}"
--host "${LLAMA_HOST:-127.0.0.1}"
--port "${LLAMA_PORT:-8080}"
--n-predict "${LLAMA_MAX_OUTPUT_TOKENS:-2048}"
"${profile_args[@]}"
"${LLAMA_ARGS[@]}"
)
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance"
set -a
# shellcheck disable=SC1091
source "$config_dir/config.env"
set +a
exec 9>"${XDG_RUNTIME_DIR:-/tmp}/hermes-stack-hermes-update.lock"
flock -n 9 || exit 0
exec python3 "$HOME/.local/lib/hermes-stack/llama_watchdog.py"
+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
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=Local llama.cpp Qwen model server
Description=Hermes local main model (routine/large)
After=network-online.target graphical-session.target
StartLimitIntervalSec=300
StartLimitBurst=3
+3
View File
@@ -21,6 +21,9 @@ else
fi
python3 "$ROOT_DIR/tests/test_llama.py" || failures=$((failures + 1))
python3 "$ROOT_DIR/tests/test_llama_watchdog.py" || failures=$((failures + 1))
python3 "$ROOT_DIR/tests/test_workstation.py" || failures=$((failures + 1))
python3 "$ROOT_DIR/tests/test_hermes_update.py" || failures=$((failures + 1))
if (cd -- "$ROOT_DIR" && rg -n --hidden --glob '!tests/smoke.sh' --glob '!tests/test_llama.py' --glob '!.git/**' \
'(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()
+1 -1
View File
@@ -16,7 +16,7 @@ OPTIONS = """--model --hf-repo --mmproj --alias --host --port --ctx-size --paral
--jinja --reasoning --reasoning-budget --reasoning-preserve --no-reasoning-preserve --flash-attn --threads
--threads-batch --n-gpu-layers --fit --fit-target --cache-type-k --cache-type-v
--sleep-idle-seconds --image-min-tokens --cors-origins --no-ui --rope-scaling --rope-scale --yarn-orig-ctx
--device --no-op-offload --no-kv-offload"""
--device --no-op-offload --no-kv-offload --n-predict"""
class LlamaTests(unittest.TestCase):
+118
View File
@@ -0,0 +1,118 @@
import importlib.util
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("watchdog", Path(__file__).resolve().parents[1] / "scripts/llama_watchdog.py")
watchdog = importlib.util.module_from_spec(spec)
spec.loader.exec_module(watchdog)
def busy(task=1, decoded=20):
return [{"id": 0, "id_task": task, "is_processing": True,
"next_token": [{"n_decoded": decoded}]}]
class WatchdogTests(unittest.TestCase):
def test_busy_request_is_allowed_then_expires_despite_progress(self):
state, reasons = watchdog.observe({}, busy(), "server1", 100, 600, 2048)
self.assertFalse(reasons)
state, reasons = watchdog.observe(state, busy(decoded=1000), "server1", 699, 600, 2048)
self.assertFalse(reasons)
_, reasons = watchdog.observe(state, busy(decoded=1100), "server1", 700, 600, 2048)
self.assertIn("600s", reasons[0])
def test_new_request_and_restarted_server_do_not_inherit_deadline(self):
state, _ = watchdog.observe({}, busy(), "server1", 100, 600, 2048)
for slots, invocation in [(busy(2), "server1"), (busy(), "server2")]:
_, reasons = watchdog.observe(state, slots, invocation, 900, 600, 2048)
self.assertFalse(reasons)
def test_idle_slot_drops_old_deadline(self):
state, _ = watchdog.observe({}, busy(), "server1", 100, 600, 2048)
state, reasons = watchdog.observe(state, [{"id": 0, "is_processing": False}], "server1", 900, 600, 2048)
self.assertEqual(state["active"], {})
self.assertFalse(reasons)
def test_excess_output_is_stopped_even_when_client_overrides_default(self):
_, reasons = watchdog.observe({}, busy(decoded=2049), "server1", 100, 600, 2048)
self.assertIn("2048 tokens", reasons[0])
def test_prompt_and_generated_text_are_not_persisted(self):
slots = busy()
slots[0].update(prompt="private prompt", generated="private output")
state, _ = watchdog.observe({}, slots, "server1", 100, 600, 2048)
self.assertNotIn("private", str(state))
class WatchdogIntegrationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.environment = patch.dict(watchdog.os.environ, {"XDG_STATE_HOME": self.temp.name,
"LLAMA_MAX_REQUEST_SECONDS": "600", "LLAMA_MAX_OUTPUT_TOKENS": "2048"})
self.environment.start()
self.addCleanup(self.environment.stop)
self.path = Path(self.temp.name) / "hermes-stack-maintenance/llama-watchdog.json"
self.boot = Path("/proc/sys/kernel/random/boot_id").read_text().strip()
self.calls = []
def control(self, *args):
self.calls.append(args)
return "ActiveState=active\nInvocationID=test-server" if args[0] == "show" else ""
def tick(self, now, slots=None, error=False):
response = io.BytesIO(json.dumps(slots or []).encode())
with patch.object(watchdog, "systemctl", self.control), \
patch.object(watchdog.time, "monotonic", return_value=now), \
patch.object(watchdog, "urlopen", side_effect=OSError() if error else None, return_value=response):
watchdog.main()
def restarts(self):
return [call for call in self.calls if call[0] == "restart"]
def test_deadline_restarts_actual_configured_service_once(self):
self.tick(100, busy())
self.tick(699, busy())
self.assertFalse(self.restarts())
self.tick(700, busy())
self.assertEqual(self.restarts(), [("restart", "--no-block", "llama-qwen.service")])
self.tick(701, busy(decoded=3000))
self.assertEqual(len(self.restarts()), 1)
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
def test_only_persistent_probe_failure_triggers_recovery(self):
self.tick(100, error=True)
self.tick(699, error=True)
self.assertFalse(self.restarts())
self.tick(700, error=True)
self.assertEqual(len(self.restarts()), 1)
def test_successful_probe_resets_failure_deadline(self):
self.tick(100, error=True)
self.tick(500)
self.tick(700, error=True)
self.assertFalse(self.restarts())
def test_inactive_service_never_started_or_probed(self):
with patch.object(watchdog, "systemctl", return_value="ActiveState=inactive\nInvocationID=") as control, \
patch.object(watchdog, "urlopen") as probe:
watchdog.main()
probe.assert_not_called()
self.assertEqual(control.call_count, 1)
def test_reboot_clears_old_monotonic_deadlines_and_cooldown(self):
self.path.parent.mkdir()
self.path.write_text(json.dumps({"boot": "previous-boot", "invocation": "test-server",
"active": {"0:1": 100}, "last_restart": 10000, "unreachable_since": 50}))
self.tick(100, busy())
self.tick(700, busy())
self.assertEqual(len(self.restarts()), 1)
self.assertEqual(json.loads(self.path.read_text())["boot"], self.boot)
if __name__ == "__main__":
unittest.main()
+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()
+9 -1
View File
@@ -6,6 +6,7 @@ set -Eeuo pipefail
units=(
hermes-stack-hermes-update.service hermes-stack-hermes-update.timer
hermes-stack-health.service hermes-stack-health.timer
hermes-stack-llama-watchdog.service hermes-stack-llama-watchdog.timer
hermes-stack-signal-health.service hermes-stack-signal-health.timer
hermes-stack-signal-update-check.service hermes-stack-signal-update-check.timer
llama-qwen.service llama-compression.service signal-cli.service
@@ -16,6 +17,7 @@ systemctl --user disable --now llama-compression.service 2>/dev/null || true
systemctl --user disable --now \
hermes-stack-hermes-update.timer \
hermes-stack-health.timer \
hermes-stack-llama-watchdog.timer \
hermes-stack-signal-health.timer \
hermes-stack-signal-update-check.timer 2>/dev/null || true
@@ -27,10 +29,16 @@ for file in \
hermes-stack-notify hermes-stack-hermes-update hermes-stack-health \
hermes-stack-signal-health hermes-stack-signal-update-check hermes-stack-signal-update \
hermes-stack-llama-run hermes-stack-llama-profile hermes-stack-llama-restart \
hermes-stack-llama-report \
hermes-stack-llama-report hermes-stack-llama-watchdog hermes-stack-model \
hermes-stack-signal-run hermes-stack-doctor; do
rm -f -- "$HOME/.local/libexec/$file"
done
rm -f -- "$HOME/.local/lib/hermes-stack/llama_watchdog.py"
systemctl --user daemon-reload
command_target="$HOME/.local/bin/hermes-stack-model"
if [[ -L "$command_target" && "$(readlink -- "$command_target")" == ../libexec/hermes-stack-model ]]; then
rm -f -- "$command_target"
fi
printf 'Deployment-Dateien entfernt; Benutzerdaten und Konfigurationen wurden beibehalten.\n'