Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion .empire/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,56 @@ Relay** — wer beide bedienen will, startet zwei Harness-Prozesse mit zwei Iden
| `buzz.exe` in Git Bash nicht gefunden | Sidecar liegt nicht im PATH | mit vollem Pfad aufrufen |
| Argumente kommen verstümmelt an | PowerShell 5.1 zerlegt CLI-Argumente | Buzz-CLI-Kommandos in Git Bash ausführen |
| Antwort erscheint doppelt | Harness liefert bei mehreren Tool-Runden mehrere Posts | kosmetisch; `BUZZ_AGENT_MAX_ROUNDS` senken |
| `systemctl is-active` grün, Agent trotzdem stumm | Prozessstatus sagt nichts über Relay-Draht, LLM-Kontingent oder Provider-Fehler | Liveness-Probe: `bash /opt/buzz-agents/liveness-probe.sh --no-push` |
| Log wächst nicht, obwohl der Agent arbeitet | `RUST_LOG=buzz_acp=info` protokolliert nur Lebenszyklus — Nachrichtenverarbeitung erzeugt **keine** Zeile (gemessen: Antwort in 3 s, 0 Log-Zeilen) | Das Log ist **kein** Liveness-Signal. Immer die Probe fragen. |
| Log nach der Rotation ist „binary file matches" | `copytruncate` kürzt die Datei auf 0, der offene Schreib-FD behält seinen Offset → NUL-Loch am Anfang | normal, kein Datenverlust; `grep -a` bzw. `less` benutzen |
| Agent stumm, im Kanal steht `429`/`404`/`422` | freies Modell am Rate-Limit, verschwunden oder schema-inkompatibel | `bash /opt/buzz-agents/sentry-set-model.sh <modell>` — prüft das neue Modell mit einer echten Antwort und rollt bei Stille selbst zurück |
| Nach einem Reboot fehlt ein Heartbeat, der Agent lebt aber | Timer-Lauf fiel in die Startphase | `OnBootSec=5min` + `Persistent=true` im Timer holen den Lauf nach |

---

## 8. Gerät wieder abnehmen
## 8. Dauerbetrieb eines Server-Agenten (buzz#77)

Ein Gerät, das Laptops überleben soll, braucht mehr als eine systemd-Unit. Referenz ist
`Sentry` auf adas-hetzner; die Werkzeuge liegen in `.empire/tools/` und werden nach
`/opt/buzz-agents/` kopiert.

**Der Kern in einem Satz: Prozessstatus ist kein Lebenszeichen.** Gemessen am 2026-08-01
beantwortete Sentry eine echte Mention in 3 Sekunden, ohne dabei eine einzige Log-Zeile zu
schreiben — und umgekehrt kann derselbe Prozess munter laufen, während der Relay-Draht ab,
das Kontingent leer oder der Provider auf 429 steht. Weder `systemctl is-active` noch das
Log können den Unterschied zeigen. Nur eine echte Antwort kann es.

| Baustein | Was | Wo |
|---|---|---|
| Log-Rotation | `daily`, `rotate 7`, `compress`, **`copytruncate`** (Pflicht: `append:`-Ziele vertragen kein Umbenennen) | `/etc/logrotate.d/buzz-agents` |
| Liveness-Probe | postet alle 30 min eine Mention mit Einmal-Token in `diag-liveness` und akzeptiert nur eine Antwort **von genau diesem Pubkey** mit diesem Token | `.empire/tools/sentry-liveness-probe.sh` |
| Zeitgeber | systemd-Timer, `OnBootSec=5min`, `Persistent=true` | `.empire/tools/buzz-sentry-liveness.timer.example` |
| Alarm | Kuma-Push-Monitor `sentry-liveness`, Toleranz 2 h, `maxretries=0` | `.empire/tools/kuma-add-sentry-liveness.mjs` |
| Modellwechsel | setzt Modell, startet neu, **beweist** mit einer echten Antwort, rollt bei Stille selbst zurück | `.empire/tools/sentry-set-model.sh` |

Drei Entscheidungen, die nicht willkürlich sind:

- **Eigene Identität für die Probe.** Sie signiert mit einem eigenen Schlüssel auf Sentrys
Allowlist (`/opt/buzz-agents/liveness.conf`, `chmod 600`). Munirs Owner-Key bleibt auf
seinem Gerät — Schlüssel verlassen ihr Gerät nicht (§4.2).
- **Toleranz 2 h, nicht 30 min.** Jede Probe kostet einen echten LLM-Aufruf, und freie
Modelle antworten gelegentlich mit 429. Bei knapper Toleranz weckt der Wächter Munir bei
jedem Provider-Schluckauf — und ein Wächter, dem man nicht glaubt, ist schlechter als
keiner. 2 h verzeiht drei Fehlschläge in Folge und meldet echte Stille am selben Tag.
- **Kein eigener Alarm für `429`/`404`.** Ein Modellausfall macht den Agenten stumm, und
Stille misst die Probe bereits. Ein zweiter Detektor für dieselbe Wirkung wäre doppelte
Pflege und ein zweiter Fehlalarm-Weg.

**Die Toleranz gehört ins `interval`, nicht in `maxretries`.** Gemessen (adas-empire#79):
ein `status=down` auf einen Monitor mit `maxretries=1` erzeugt `status=2` (PENDING) mit
`important=0` — es geht **keine** Benachrichtigung raus. Erst der nächste fällige Beat nach
`retryInterval` macht daraus DOWN. Bei `retryInterval == interval` verdoppelt das die Zeit
bis zum Alarm, lautlos.

---

## 9. Gerät wieder abnehmen

1. Harness stoppen (`systemctl disable --now buzz-<rolle>` bzw. Prozess beenden).
2. `buzz-admin remove-member --pubkey <hex>` — die Identität verliert den Relay-Zugang
Expand Down
42 changes: 42 additions & 0 deletions .empire/tools/buzz-sentry-liveness.timer.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Liveness-Probe fuer Sentry (buzz#77) — Vorlage fuer /etc/systemd/system/.
#
# Zwei Dateien, beide nach /etc/systemd/system/ kopieren, dann:
# systemctl daemon-reload
# systemctl enable --now buzz-sentry-liveness.timer
# systemctl list-timers buzz-sentry-liveness.timer
#
# Warum systemd-Timer und nicht Cron: derselbe Ort wie die Unit, die er ueberwacht,
# gleiche Log-Senke, und `Persistent=true` holt einen wegen Neustart verpassten Lauf
# nach — ohne das waere jeder Reboot ein Loch im Heartbeat und damit ein Fehlalarm.
#
# ---------- /etc/systemd/system/buzz-sentry-liveness.service ----------
# [Unit]
# Description=Liveness-Probe: antwortet der Buzz-Agent "Sentry" noch?
# Documentation=https://github.com/munirad7s/buzz/issues/77
# After=network-online.target buzz-sentry.service
# Wants=network-online.target
#
# [Service]
# Type=oneshot
# ExecStart=/bin/bash /opt/buzz-agents/liveness-probe.sh
# StandardOutput=append:/opt/buzz-agents/logs/liveness.log
# StandardError=append:/opt/buzz-agents/logs/liveness.log
# TimeoutStartSec=300
#
# ---------- /etc/systemd/system/buzz-sentry-liveness.timer ----------
# [Unit]
# Description=Sentry-Liveness alle 30 Minuten
#
# [Timer]
# OnBootSec=5min
# OnUnitActiveSec=30min
# AccuracySec=1min
# Persistent=true
#
# [Install]
# WantedBy=timers.target
#
# OnBootSec=5min statt sofort: nach einem Reboot braucht der Relay-Stack einen Moment,
# und eine Probe, die in die Startphase hineinlaeuft, meldet einen Ausfall, den es
# nicht gibt. Das Log liegt unter /opt/buzz-agents/logs/*.log und faellt damit unter
# die bestehende logrotate-Regel /etc/logrotate.d/buzz-agents.
150 changes: 150 additions & 0 deletions .empire/tools/kuma-add-sentry-liveness.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Legt EINEN Uptime-Kuma-Push-Monitor "sentry-liveness" an (buzz#77) — additiv,
// idempotent über den Monitornamen. Bestehende Monitore und die Statuspage
// werden nicht angefasst.
//
// Dieser Monitor ist der Wachhund über dem Dauer-Agenten "Sentry" auf adas-hetzner.
// Solange `.empire/tools/sentry-liveness-probe.sh` beweist, dass Sentry auf eine echte
// Mention antwortet, kommt ein Heartbeat. Bleibt er aus, ist der Agent stumm — und das
// ist der Zustand, den weder `systemctl is-active` noch das Log zeigen können.
//
// Warum das Script und nicht ein Klick in der UI: der Monitor ist Teil des
// Wächters, nicht Dekoration. Wer ihn versehentlich löscht, muss ihn exakt so
// wiederherstellen können — inklusive Intervall und Benachrichtigungen.
//
// Kuma hat keine REST-API zum Anlegen von Monitoren, nur Socket.IO. Muster
// übernommen von /opt/agency/monitoring/provision/kuma-add-funnel-e2e.mjs.
//
// Env: KUMA_URL (default http://kuma:3001), KUMA_ADMIN_USER, KUMA_ADMIN_PASSWORD,
// KUMA_PUSH_SENTRY_LIVENESS_TOKEN (Push-Token, `openssl rand -hex 8`).
//
// Aufruf auf adas-hetzner (Wegwerf-Container, nutzt die node_modules des
// monitoring-Provision-Ordners — socket.io-client liegt dort schon):
// cd /opt/agency/monitoring && set -a && . ./.env && set +a
// docker run --rm --memory 128m --network monitoring_monitoring-internal \
// -v /opt/agency/monitoring/provision:/app:ro \
// -v /tmp/kuma-add-sentry-liveness.mjs:/app/kuma-add-sentry-liveness.mjs:ro \
// -w /app -e KUMA_ADMIN_USER -e KUMA_ADMIN_PASSWORD -e KUMA_PUSH_SENTRY_LIVENESS_TOKEN \
// node:22-alpine node kuma-add-sentry-liveness.mjs
import { io } from "socket.io-client";

const URL = process.env.KUMA_URL ?? "http://kuma:3001";
const USER = process.env.KUMA_ADMIN_USER;
const PASS = process.env.KUMA_ADMIN_PASSWORD;
const TOKEN = process.env.KUMA_PUSH_SENTRY_LIVENESS_TOKEN;
if (!USER || !PASS) {
console.error("[kuma] KUMA_ADMIN_USER/KUMA_ADMIN_PASSWORD fehlen");
process.exit(1);
}
if (!TOKEN) {
console.error("[kuma] KUMA_PUSH_SENTRY_LIVENESS_TOKEN fehlt");
process.exit(1);
}

// interval = maximale Sekunden zwischen zwei Pushes, bis Kuma DOWN meldet.
// Die Probe läuft alle 30 Minuten -> 7200 s (2 h) Toleranz. Bewusst NICHT knapp:
// jede Probe kostet einen echten LLM-Aufruf, und das freie Modell antwortet
// gelegentlich mit 429. Bei knapper Toleranz würde der Wächter Munir bei jedem
// Provider-Schluckauf wecken — und ein Wächter, dem man nicht glaubt, ist
// schlechter als keiner. 2 h heißt: drei aufeinanderfolgende Fehlschläge werden
// verziehen, echte Stille meldet sich noch am selben Tag.
//
// maxretries = 0 — Abweichung von den neun älteren Push-Monitoren, die alle
// maxretries=1 tragen. Gemessen am 2026-08-01 (adas-empire#79): ein
// `status=down`-Push auf einen Monitor mit maxretries=1 erzeugt einen Heartbeat
// mit status=2 (PENDING) und important=0 — es geht KEINE Benachrichtigung raus.
// Erst der nächste fällige Beat nach retryInterval macht daraus DOWN. Bei
// retryInterval == interval verdoppelt das die Zeit bis zum Alarm still.
// Die Toleranz gehört ins interval, nicht in einen unsichtbaren Retry.
//
// notificationIDList: 2 = telegram-adas-agency, 3 = mail-resend-munir — dieselbe
// Kombination wie alle bestehenden Push-Monitore.
const MONITOR = {
name: "sentry-liveness",
type: "push",
interval: 7200,
retryInterval: 7200,
resendInterval: 0,
maxretries: 0,
timeout: 20,
pushToken: TOKEN,
notificationIDList: { 2: true, 3: true },
ignoreTls: false,
upsideDown: false,
expiryNotification: false,
maxredirects: 10,
accepted_statuscodes: ["200-299"],
dns_resolve_type: "A",
dns_resolve_server: "1.1.1.1",
proxyId: null,
mqttUsername: "",
mqttPassword: "",
mqttTopic: "",
mqttSuccessMessage: "",
method: "GET",
headers: null,
body: null,
httpBodyEncoding: "json",
conditions: [],
parent: null,
};

const fail = (msg, detail) => {
console.error(`[kuma] FEHLER ${msg}:`, JSON.stringify(detail));
process.exit(1);
};

const socket = io(URL, {});
const call = (ev, ...args) => new Promise((res) => socket.emit(ev, ...args, (r) => res(r)));
setTimeout(() => fail("timeout", "60s ohne Abschluss"), 60000).unref?.();

let connectErrors = 0;
socket.on("connect_error", (e) => {
if (++connectErrors >= 5) fail("connect_error", e.message);
});
socket.on("connect", async () => {
const login = await call("login", { username: USER, password: PASS, token: "" });
if (!login.ok) fail("login", login);

const listPromise = new Promise((res) => socket.once("monitorList", (l) => res(l)));
await call("getMonitorList");
const existing = Object.values((await listPromise) ?? {});
const found = existing.find((m) => m.name === MONITOR.name);
if (found) {
const cur = await call("getMonitor", found.id);
if (!cur.ok) fail("getMonitor", cur);
// Nicht nur der Push-Token wird nachgezogen, sondern auch das Alarmverhalten.
// Ein Monitor, dessen interval/maxretries jemand in der UI verstellt hat, ist
// still stumm — genau der Fehlermodus, gegen den dieses Ticket gebaut wurde.
const want = {
pushToken: TOKEN,
interval: MONITOR.interval,
retryInterval: MONITOR.retryInterval,
maxretries: MONITOR.maxretries,
notificationIDList: MONITOR.notificationIDList,
};
const drift = Object.entries(want).filter(([k, v]) =>
k === "notificationIDList"
? JSON.stringify(Object.keys(v).sort()) !==
JSON.stringify(Object.keys(cur.monitor[k] ?? {}).filter((n) => cur.monitor[k][n]).sort())
: cur.monitor[k] !== v,
);
if (drift.length) {
const upd = await call("editMonitor", { ...cur.monitor, ...want });
if (!upd.ok) fail("editMonitor", upd);
console.log(
`[kuma] push "sentry-liveness" existiert (id=${found.id}) — nachgezogen: ${drift
.map(([k]) => k)
.join(", ")}`,
);
} else {
console.log(`[kuma] push "sentry-liveness" existiert (id=${found.id}) — Konfiguration ok`);
}
console.log(`MONITOR_ID=${found.id}`);
process.exit(0);
}
const r = await call("add", MONITOR);
if (!r.ok) fail("add", r);
console.log(`[kuma] push "sentry-liveness" angelegt (id=${r.monitorID})`);
console.log(`MONITOR_ID=${r.monitorID}`);
process.exit(0);
});
128 changes: 128 additions & 0 deletions .empire/tools/sentry-liveness-probe.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Liveness-Probe fuer den Dauer-Agenten "Sentry" auf adas-hetzner (buzz#77).
#
# WARUM PROZESS-STATUS NICHT REICHT
# ---------------------------------
# `systemctl is-active buzz-sentry` sagt nur, dass ein Prozess laeuft. Gemessen am
# 2026-08-01: der Agent beantwortete eine echte Mention in 3 Sekunden und schrieb
# dabei KEINE EINZIGE ZEILE ins Log (RUST_LOG=buzz_acp=info protokolliert nur
# Lebenszyklus-Ereignisse). Umgekehrt gilt dasselbe: der Prozess kann munter
# weiterlaufen, waehrend der Relay-Draht abgerissen, das LLM-Kontingent erschoepft
# oder der Provider auf 429/422 steht. Weder Prozess noch Log koennen den
# Unterschied zeigen — nur eine echte Antwort kann es.
#
# WAS SIE MISST
# -------------
# Den ganzen Pfad: Relay-Verbindung -> Kanal-Abo -> Allowlist -> Harness -> LLM ->
# Antwort zurueck in den Kanal. Sie postet eine Mention mit einem Einmal-Token und
# akzeptiert nur eine Antwort, die (a) von GENAU Sentrys Pubkey kommt und (b) das
# Token enthaelt. Ein "irgendwer hat irgendwas geschrieben" zaehlt nicht.
#
# EIGENE IDENTITAET, NICHT MUNIRS
# -------------------------------
# Die Probe signiert mit einem eigenen Schluessel (`/opt/buzz-agents/liveness.conf`,
# chmod 600), der auf Sentrys Allowlist steht. Munirs Owner-Key bleibt auf seinem
# Geraet — Schluessel verlassen ihr Geraet nicht (.empire/ONBOARDING.md §4.2).
#
# EIGENER KANAL
# -------------
# Sie laeuft in `diag-liveness`, nicht in Arbeitskanaelen. Sonst waere der Kanal, in
# dem Menschen mitlesen, zu 90 % Probenverkehr.
#
# bash sentry-liveness-probe.sh # Probe + Kuma-Heartbeat
# bash sentry-liveness-probe.sh --no-push # nur Probe
#
# Exit 0 = Sentry hat geantwortet. Exit 1 = jeder Fehlschlag, mit REASON= in der
# Ausgabe. Fehlschlag pusht NICHT: das Ausbleiben ist der Alarm (Kuma-Push-Monitor
# "sentry-liveness"). Ein einzelner Fehlschlag alarmiert dabei bewusst NICHT sofort
# — siehe Toleranz-Begruendung im Kopf von kuma-add-sentry-liveness.mjs.
set -uo pipefail

CONF="${SENTRY_LIVENESS_CONF:-/opt/buzz-agents/liveness.conf}"
BUZZ_BIN="${BUZZ_BIN:-/opt/buzz-agents/bin/buzz}"
STATUS_FILE="${SENTRY_LIVENESS_STATUS:-/opt/buzz-agents/logs/liveness-status.json}"
SENTRY_PUBKEY="${SENTRY_PUBKEY:-67160ec6fba47d29d6629a3a8aca00a5a0d90fe2c240ac9ff75460664bf3629f}"
DIAG_CHANNEL="${SENTRY_DIAG_CHANNEL:-7e06a1bc-8b3a-4dc5-8658-05a53073aabb}"
TIMEOUT_SEC="${SENTRY_LIVENESS_TIMEOUT:-180}"
KUMA_PUSH_BASE="${KUMA_PUSH_BASE:-https://status.adas.jetzt/api/push}"
PUSH=1
[ "${1:-}" = "--no-push" ] && PUSH=0

started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

write_status() {
# Statusdatei ist Diagnose fuer Menschen, nicht der Alarmweg. Der Alarm ist der
# ausbleibende Kuma-Push — eine Datei, die niemand liest, alarmiert niemanden.
printf '{"ts":"%s","result":"%s","reason":"%s","detail":"%s","latency_s":%s}\n' \
"$started_at" "$1" "${2:-}" "$(printf '%s' "${3:-}" | tr -d '"' | tr '\n' ' ')" "${4:-null}" \
> "$STATUS_FILE" 2>/dev/null || true
}

fail() {
write_status "FAIL" "$1" "${2:-}"
echo "RESULT=FAIL"
echo "REASON=$1"
[ -n "${2:-}" ] && echo "DETAIL=$2"
[ -n "${3:-}" ] && echo "FIX=$3"
echo "KUMA=kein Push — das Ausbleiben des Heartbeats ist der Alarm"
exit 1
}

[ -r "$CONF" ] || fail "conf-missing" "$CONF nicht lesbar" "Identitaet der Probe anlegen (siehe buzz#77)"
# shellcheck disable=SC1090
set -a; . "$CONF"; set +a
[ -n "${BUZZ_PRIVATE_KEY:-}" ] || fail "no-private-key" "BUZZ_PRIVATE_KEY fehlt in $CONF"
[ -x "$BUZZ_BIN" ] || fail "buzz-cli-missing" "$BUZZ_BIN nicht ausfuehrbar"
export BUZZ_PRIVATE_KEY
export BUZZ_RELAY_URL="${BUZZ_LIVENESS_RELAY_URL:-https://buzz.adas.casa}"

TOKEN="lp$(date -u +%Y%m%d%H%M%S)$(head -c 3 /dev/urandom | od -An -tx1 | tr -d ' \n')"
echo "TOKEN=$TOKEN"

t0=$(date +%s)
send_out="$("$BUZZ_BIN" --format json messages send --channel "$DIAG_CHANNEL" \
--mention "$SENTRY_PUBKEY" \
--content "Liveness-Probe $TOKEN — antworte nur mit: OK $TOKEN" 2>&1)"
send_id="$(printf '%s' "$send_out" | jq -r 'select(.accepted == true) | .event_id // empty' 2>/dev/null)"
[ -z "$send_id" ] && fail "send-failed" "Relay hat die Probe nicht angenommen: $(printf '%s' "$send_out" | head -c 300)" \
"Relay erreichbar? Mitgliedschaft der Probe-Identitaet noch gueltig?"
echo "SENT=$send_id"

# Antwort einsammeln. Akzeptiert wird ausschliesslich: Absender == Sentry UND Token
# im Text. Ohne die Absenderpruefung wuerde die Probe das eigene Echo als Lebenszeichen
# verkaufen — der Detektor koennte dann nie rot werden.
deadline=$(( t0 + TIMEOUT_SEC ))
reply=""
while [ "$(date +%s)" -lt "$deadline" ]; do
sleep 10
msgs="$("$BUZZ_BIN" --format json messages get --channel "$DIAG_CHANNEL" --limit 20 2>/dev/null)"
reply="$(printf '%s' "$msgs" | jq -r --arg pk "$SENTRY_PUBKEY" --arg tok "$TOKEN" \
'.[]? | select(.pubkey == $pk) | select(.content | contains($tok)) | .id' 2>/dev/null | head -1)"
[ -n "$reply" ] && break
done
latency=$(( $(date +%s) - t0 ))

[ -z "$reply" ] && fail "no-reply" \
"Sentry hat in ${TIMEOUT_SEC}s nicht geantwortet — Prozess kann laufen und trotzdem stumm sein" \
"journalctl -u buzz-sentry -n 50 und /opt/buzz-agents/logs/sentry.log pruefen; haeufigste Ursache: LLM-Kontingent (429) oder Relay-Abriss"
echo "REPLY=$reply"
echo "LATENCY=${latency}s"

if [ "$PUSH" -eq 0 ]; then
write_status "OK" "" "no-push" "$latency"
echo "KUMA=uebersprungen (--no-push)"
echo "RESULT=OK"
exit 0
fi

[ -z "${KUMA_PUSH_SENTRY_LIVENESS_TOKEN:-}" ] && fail "kuma-token-missing" \
"KUMA_PUSH_SENTRY_LIVENESS_TOKEN fehlt in $CONF" \
"Monitor anlegen: .empire/tools/kuma-add-sentry-liveness.mjs"

code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 \
"${KUMA_PUSH_BASE}/${KUMA_PUSH_SENTRY_LIVENESS_TOKEN}?status=up&msg=$(printf 'sentry antwortet in %ss' "$latency" | jq -sRr @uri)" 2>&1)"
[ "$code" != "200" ] && fail "kuma-push-failed" "HTTP $code" "Kuma/Monitor sentry-liveness pruefen"
write_status "OK" "" "" "$latency"
echo "KUMA=heartbeat gepusht"
echo "RESULT=OK"
exit 0
Loading
Loading