Initial commit: autonomous maintenance agent for semprini stack

systemd-deployed daemon that drives the semprini-core stack-support agent
headless via Claude Code, monitors health via Uptime Kuma, applies safe
upgrades, and escalates to the operator over Matrix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 17:20:06 +12:00
co-authored by Claude Opus 4.8
commit e91b129841
9 changed files with 1090 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Secrets — hold the Matrix bot token + password
config.json
.bot-secrets
# Python
__pycache__/
*.pyc
# Runtime state (also lives under ~/.local/state by default)
state.json
*.log
+70
View File
@@ -0,0 +1,70 @@
# semprini-maintainer — Claude Instructions
## What this is
A standalone, always-on **autonomous maintenance agent** for the semprini.me
container stack, deployed as a systemd service (`semprini-maintainer`). It keeps
the stack healthy and current and talks to the operator over Matrix
(chat.semprini.me).
It is the *unattended driver* for the canonical **stack-support agent**, which
lives in the separate **semprini-core** repo. This project does not reimplement
that logic — it gathers state and invokes the stack-support agent headless via
Claude Code, then relays/acts on the agent's JSON result.
## Relationship to other projects
- **semprini-core** (`../semprini-core`) — the Enterprise Landing Zone this agent
maintains. At runtime the daemon reads, from the path in `config.json`
`repo_dir`:
- `agents/stack-support.agent.md` (injected as system prompt)
- `docs/stack-support-runbook.md`
- `core_stack/compose*.yml`
Keep the stack-support agent definition canonical **in semprini-core** — do not
fork a copy here.
- **Other projects** (e.g. `../semprini-blog`) that register Uptime Kuma monitors
are also maintained; their repos are exposed to the agent via `extra_dirs`.
## How it works (one screen)
- **Scope** = whatever **Uptime Kuma** tracks at check time, re-read every cycle
(never hard-coded). Kuma's heartbeat is the health signal — the daemon does not
re-probe. Kuma's root-owned `kuma.db` is read via a throwaway `keinos/sqlite3`
container mounting the `uptime-kuma-data` volume read-only.
- **Health loop** (5 min): down services confirmed over `down_confirmations`
cycles → invoke stack-support agent to remediate → report to Matrix.
- **Upgrade loop** (7 days): resolve containers behind tracked services → agent
researches + applies safe upgrades (test, pin, rollback); escalates
major/breaking ones to Matrix.
- **Matrix**: bot `@maintainer:semprini.me` DMs `@paul:semprini.me`. Escalations
are numbered questions; the reply is fed back to the agent to carry out.
- **Autonomy** (`config.json``autonomy`): `full` (default) | `fix-only` |
`notify`.
## Conventions / guardrails
- Runs as host user **`paul`** (in `docker` group), **not root** — Claude Code
refuses `--permission-mode bypassPermissions` under root.
- `maintainer.py` is **stdlib-only** — keep it dependency-free so it deploys
without a venv.
- Secrets live in `config.json` and `.bot-secrets`**never commit** (gitignored).
- Prefer editing existing files over adding new ones.
## Files
| File | Purpose |
|---|---|
| `maintainer.py` | The daemon (stdlib only). |
| `config.example.json` | Template — copy to `config.json`. |
| `register-matrix-bot.sh` | Creates `@maintainer` account + DM room; writes token to config. |
| `install.sh` | Syntax-check, install unit, enable + start. |
| `semprini-maintainer.service` | systemd unit (runs as `paul`). |
| `docs/architecture.md` | Architecture decision record. |
## Test without the service
```bash
python3 maintainer.py --config config.json --once health
python3 maintainer.py --config config.json --once upgrade
python3 maintainer.py --config config.json --once replies
```
+141
View File
@@ -0,0 +1,141 @@
# semprini-maintainer — autonomous stack maintenance agent
A standalone systemd service that keeps the container stack healthy and current,
and talks to the operator over Matrix (chat.semprini.me).
It is the *unattended driver* for the canonical stack-support agent that lives
in the **semprini-core** repo (`agents/stack-support.agent.md`): the daemon
gathers state and hands the judgement calls (diagnosis, remediation, upgrade
planning) to that agent, run headless via Claude Code.
## Relationship to semprini-core
This is a **separate project** from the stack it maintains. It does not bundle
its own copy of the stack logic — at runtime it reads, from the semprini-core
checkout pointed to by `repo_dir` in `config.json`:
- `agents/stack-support.agent.md` — injected as the agent's system prompt
- `docs/stack-support-runbook.md` — the remediation runbook
- `core_stack/compose*.yml` — the compose files it operates on
So **a semprini-core checkout and the running core stack must be present on the
host** (they are what this agent maintains). The canonical stack-support agent
definition stays in semprini-core to avoid drift. Other projects that register
Uptime Kuma monitors (e.g. `semprini-blog`) are also maintained — their repos
are made available to the agent via `extra_dirs`.
### Prerequisites
- Docker (host user in the `docker` group)
- Python 3 (stdlib only — no venv needed)
- [Claude Code](https://claude.com/claude-code) CLI on `PATH`
- A semprini-core checkout + the core stack running (incl. Uptime Kuma)
## What it does
| Cadence | Action |
|---|---|
| **Every 5 min** (`health_interval_seconds`) | Read Uptime Kuma to learn which services are tracked **right now** and their up/down status. If a service is confirmed down across `down_confirmations` cycles, invoke the stack-support agent to diagnose + remediate, then report to Matrix. |
| **Every 7 days** (`upgrade_interval_seconds`) | Resolve the containers behind tracked services, find the owning compose file, and have the agent research + apply upgrades (test, pin, rollback on failure). Breaking/major upgrades are escalated to Matrix for a decision. |
| **Continuous** (`matrix_poll_seconds`) | Poll the Matrix room for the admin's replies — to answer escalations, or to take ad-hoc instructions. |
| **Daily** (`heartbeat_interval_seconds`) | Quiet "N/M services up" heartbeat when all is well. |
### Scope is dynamic
Scope is **whatever Uptime Kuma is tracking at the moment the check runs** — the
monitor list is re-read every cycle, nothing is hard-coded. This deliberately
covers services from *other* projects that register Kuma monitors (e.g.
`semprini-blog`), not just `semprini-core`. Other project repos are made
available to the agent via `extra_dirs`.
Kuma's own heartbeat result is the health signal, so the daemon never
re-implements probes — it trusts the monitoring system already in the stack.
## Architecture
```
┌─────────────────────────────────────────┐
│ maintainer.py (daemon) │
Uptime Kuma ──┤ read monitors+status (sqlite in volume) │
(kuma.db) │ │
│ on down / on schedule: │
│ claude -p --append-system-prompt │──▶ stack-support
│ (agents/stack-support.agent.md) │ agent does the
│ │ diagnosis/fix
│ parse JSON result envelope │◀── + returns JSON
│ │
Matrix ◀─────┤ send summaries / ask questions │
(@paul) ─────┤ poll replies → resume escalations │
└─────────────────────────────────────────┘
```
The agent returns a small JSON envelope the daemon relays:
```json
{"status":"ok|action_taken|escalate|blocked","summary":"…","question":"…","options":["…"]}
```
`escalate` posts the question (numbered options) to Matrix and pauses upgrades
until the admin replies; the reply is fed back to the agent to carry out.
## Autonomy
Set `autonomy` in `config.json`:
- `full` *(default)* — remediate **and** apply safe upgrades autonomously
(test + auto-rollback); escalate only major/breaking upgrades.
- `fix-only` — remediate health issues; report upgrades but never apply them.
- `notify` — never change anything; only report to Matrix.
## Why it runs as `paul`, not root
- Claude Code refuses `--permission-mode bypassPermissions` under root.
- `paul` is in the `docker` group, so the agent can drive Docker/Compose.
- Kuma's root-owned `kuma.db` is read via a throwaway `keinos/sqlite3`
container mounting the named volume read-only — no host file access needed.
## Install
```bash
cd /home/paul/Dev/semprini-maintainer
# 1. Config
cp config.example.json config.json
# set repo_dir to the semprini-core checkout; edit paths/intervals if needed
# (defaults match this host)
# 2. Provision the Matrix bot + private room with the admin
./register-matrix-bot.sh
# then, one-time: accept the room invite in Element as @paul:semprini.me
# 3. Install + start the service
./install.sh
```
`config.json` and `.bot-secrets` hold the bot token/password and are gitignored.
## Operate
```bash
journalctl -u semprini-maintainer -f # logs
systemctl status semprini-maintainer
sudo systemctl restart semprini-maintainer
# One-off cycles without the service (for testing):
python3 maintainer.py --config config.json --once health
python3 maintainer.py --config config.json --once upgrade
python3 maintainer.py --config config.json --once replies
```
## Files
| File | Purpose |
|---|---|
| `maintainer.py` | The daemon. Stdlib only. |
| `config.example.json` | Template — copy to `config.json`. |
| `register-matrix-bot.sh` | Creates the `@maintainer` account + room, writes token to config. |
| `semprini-maintainer.service` | systemd unit (runs as `paul`). |
| `install.sh` | Syntax-check, install, enable, start. |
See [`docs/architecture.md`](docs/architecture.md) for the architecture
decision record.
+30
View File
@@ -0,0 +1,30 @@
{
"repo_dir": "/home/paul/Dev/semprini-core",
"extra_dirs": ["/home/paul/Dev"],
"agent_file": "agents/stack-support.agent.md",
"runbook_file": "docs/stack-support-runbook.md",
"claude_bin": "/home/paul/.local/bin/claude",
"claude_model": null,
"claude_extra_args": [],
"agent_timeout_seconds": 1800,
"kuma_volume": "uptime-kuma-data",
"sqlite_image": "keinos/sqlite3",
"autonomy": "full",
"health_interval_seconds": 300,
"upgrade_interval_seconds": 604800,
"matrix_poll_seconds": 30,
"down_confirmations": 2,
"heartbeat_interval_seconds": 86400,
"state_file": "/home/paul/.local/state/semprini-maintainer/state.json",
"matrix": {
"homeserver": "https://matrix.semprini.me",
"user_id": "@maintainer:semprini.me",
"access_token": "FILLED_BY_register-matrix-bot.sh",
"room_id": "FILLED_BY_register-matrix-bot.sh",
"admin_user_id": "@paul:semprini.me"
}
}
+63
View File
@@ -0,0 +1,63 @@
# Maintenance Agent (semprini-maintainer)
Autonomous, always-on agent that keeps the container stack healthy and current.
Implementation: this repository — see [`README.md`](../README.md). It maintains
the **semprini-core** stack (a separate repo) referenced via `repo_dir`.
## Purpose
Continuously maintain the running stack without an operator at the keyboard:
1. Detect and **remediate** service outages.
2. Periodically **check for and apply container upgrades** (with test + rollback).
3. **Communicate with the operator over Matrix** (chat.semprini.me), including
asking questions when an upgrade needs a human decision.
It is the unattended driver for the canonical stack-support agent
(`agents/stack-support.agent.md`) — it does not re-implement that logic, it
feeds the agent state and relays its results.
## Key decisions
- **Scope = Uptime Kuma's live monitor list.** Read every health cycle, never
hard-coded. Anything Kuma tracks is in scope — including services owned by
other projects (e.g. `semprini-blog`) that register Kuma monitors. This keeps
the maintained set and the monitored set identical by construction.
- **Health signal = Kuma heartbeat.** The daemon reads Kuma's own up/down
result rather than re-probing, reusing the stack's existing monitoring.
- **Kuma data access** via a throwaway `keinos/sqlite3` container mounting the
`uptime-kuma-data` volume read-only (the daemon user has Docker but not host
access to root-owned volume files).
- **Runner = Claude Code headless** (`claude -p`), with
`agents/stack-support.agent.md` injected as the system prompt. The agent
returns a JSON envelope (`status/summary/question/options`) the daemon acts on.
- **Runs as `paul`** (in the `docker` group), not root — Claude Code refuses
`bypassPermissions` as root.
- **Autonomy = full** by default: remediate and apply safe upgrades; escalate
only major/breaking upgrades to Matrix and resume on reply. Configurable to
`fix-only` or `notify`.
- **Matrix transport**: dedicated bot `@maintainer:semprini.me` (created via the
Synapse registration shared secret) in a private room with `@paul:semprini.me`.
## Cadence
| Loop | Default interval | Config key |
|---|---|---|
| Health check + remediation | 5 min | `health_interval_seconds` |
| Upgrade review | 7 days | `upgrade_interval_seconds` |
| Matrix reply polling | 30 s | `matrix_poll_seconds` |
| Quiet health heartbeat | daily | `heartbeat_interval_seconds` |
## Escalation flow
1. Agent returns `status=escalate` with a `question` + `options`.
2. Daemon posts a numbered question to Matrix and pauses upgrade cycles.
3. Admin replies (a number or free text).
4. Daemon feeds the decision back to the agent, which carries it out and reports.
## Operational notes
- The bot DM room invite must be accepted once by `@paul` in Element.
- Secrets (`config.json`, `.bot-secrets`) are gitignored.
- A single unanswered escalation blocks new upgrade cycles (health remediation
continues regardless).
Executable
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# install.sh — install & enable the semprini-maintainer systemd service.
#
# Run from this directory. Requires sudo for the systemd parts.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
UNIT="semprini-maintainer.service"
DEST="/etc/systemd/system/$UNIT"
echo "→ Pre-flight checks"
command -v docker >/dev/null || { echo "ERROR: docker not found"; exit 1; }
command -v python3 >/dev/null || { echo "ERROR: python3 not found"; exit 1; }
CLAUDE_BIN="$(python3 -c "import json;print(json.load(open('$DIR/config.json'))['claude_bin'])" 2>/dev/null || echo /home/paul/.local/bin/claude)"
[ -x "$CLAUDE_BIN" ] || echo "WARN: claude binary not executable at $CLAUDE_BIN"
if [ ! -f "$DIR/config.json" ]; then
echo "ERROR: config.json missing. Run:"
echo " cp $DIR/config.example.json $DIR/config.json"
echo " $DIR/register-matrix-bot.sh"
exit 1
fi
TOKEN="$(python3 -c "import json;print(json.load(open('$DIR/config.json'))['matrix']['access_token'])")"
case "$TOKEN" in
""|FILLED_BY_*) echo "ERROR: Matrix not provisioned. Run register-matrix-bot.sh first."; exit 1;;
esac
echo "→ Syntax-checking maintainer.py"
python3 -m py_compile "$DIR/maintainer.py"
echo "→ Installing $UNIT"
sudo cp "$DIR/$UNIT" "$DEST"
sudo systemctl daemon-reload
sudo systemctl enable "$UNIT"
sudo systemctl restart "$UNIT"
echo
echo "✓ Installed and started."
echo " Logs: journalctl -u $UNIT -f"
echo " Status: systemctl status $UNIT"
echo " Stop: sudo systemctl stop $UNIT"
Executable
+593
View File
@@ -0,0 +1,593 @@
#!/usr/bin/env python3
"""
semprini-maintainer — autonomous stack maintenance daemon.
Runs as a systemd service. Periodically:
* (frequent) reads Uptime Kuma to learn which services are tracked and their
up/down status, and — when something is down — invokes the
canonical stack-support agent (Claude Code, headless) to
diagnose and remediate fully autonomously.
* (infrequent) resolves the containers behind the tracked services, finds the
compose file that owns each, and invokes the stack-support
agent to research + apply upgrades (test, then rollback on
failure). Ambiguous / breaking upgrades are escalated to the
admin over Matrix and resumed once they reply.
All operator communication happens through the core stack's Matrix
(chat.semprini.me) via a dedicated bot account.
The scope ("which services to maintain") is whatever Uptime Kuma is tracking at
the moment the health check runs — it is re-read every cycle, never hard-coded.
Design notes
------------
* Pure Python stdlib only (urllib, sqlite via a throwaway container, subprocess)
so the service deploys without a venv.
* The daemon is intentionally "dumb": it gathers state and hands the fuzzy work
(mapping a monitor to a container, choosing a fix, planning an upgrade) to the
LLM agent defined in agents/stack-support.agent.md. The agent returns a small
JSON envelope which the daemon relays / acts on.
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
import traceback
import urllib.parse
import urllib.request
from datetime import datetime, timezone
# --------------------------------------------------------------------------- #
# Config / state
# --------------------------------------------------------------------------- #
DEFAULTS = {
"repo_dir": "/home/paul/Dev/semprini-core",
# Extra directories the agent may read/operate on (other projects that use
# the core stack, e.g. semprini-blog). Passed to claude via --add-dir.
"extra_dirs": ["/home/paul/Dev"],
"agent_file": "agents/stack-support.agent.md",
"runbook_file": "docs/stack-support-runbook.md",
"claude_bin": "/home/paul/.local/bin/claude",
"claude_model": None, # None = claude default
"claude_extra_args": [],
"agent_timeout_seconds": 1800,
"kuma_volume": "uptime-kuma-data",
"sqlite_image": "keinos/sqlite3",
# autonomy: "full" (fix + upgrade), "fix-only" (remediate, only report
# upgrades), or "notify" (never act, only report).
"autonomy": "full",
"health_interval_seconds": 300, # 5 min
"upgrade_interval_seconds": 604800, # 7 days
"matrix_poll_seconds": 30,
# A monitor must be seen DOWN this many consecutive cycles before we act,
# to ride out restarts / flapping.
"down_confirmations": 2,
# Send a quiet "all healthy" heartbeat to Matrix at most this often.
"heartbeat_interval_seconds": 86400, # daily
"state_file": "/home/paul/.local/state/semprini-maintainer/state.json",
"matrix": {
"homeserver": "https://matrix.semprini.me",
"user_id": "",
"access_token": "",
"room_id": "",
"admin_user_id": "@paul:semprini.me",
},
}
def load_config(path):
cfg = json.loads(json.dumps(DEFAULTS)) # deep copy
if path and os.path.exists(path):
with open(path) as fh:
user = json.load(fh)
for k, v in user.items():
if k == "matrix" and isinstance(v, dict):
cfg["matrix"].update(v)
else:
cfg[k] = v
return cfg
def load_state(cfg):
path = cfg["state_file"]
if os.path.exists(path):
try:
with open(path) as fh:
return json.load(fh)
except Exception:
log("WARN: could not parse state file, starting fresh")
return {
"last_health": 0,
"last_upgrade": 0,
"last_heartbeat": 0,
"down_streak": {}, # monitor name -> consecutive-down count
"matrix_since": None, # /sync pagination token
"pending": None, # outstanding question awaiting admin reply
}
def save_state(cfg, state):
path = cfg["state_file"]
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w") as fh:
json.dump(state, fh, indent=2)
os.replace(tmp, path)
def log(msg):
print(f"[{datetime.now(timezone.utc).isoformat()}] {msg}", flush=True)
# --------------------------------------------------------------------------- #
# Uptime Kuma — read tracked monitors + latest status
# --------------------------------------------------------------------------- #
KUMA_QUERY = (
"select json_group_array(json_object("
"'id', m.id, 'name', m.name, 'type', m.type, 'url', m.url, "
"'hostname', m.hostname, 'port', m.port, "
"'status', (select h.status from heartbeat h where h.monitor_id=m.id "
"order by h.time desc limit 1), "
"'msg', (select h.msg from heartbeat h where h.monitor_id=m.id "
"order by h.time desc limit 1))) "
"from monitor m where m.active=1 order by m.id;"
)
# Uptime Kuma heartbeat status codes.
KUMA_DOWN, KUMA_UP, KUMA_PENDING, KUMA_MAINT = 0, 1, 2, 3
def read_kuma_monitors(cfg):
"""Return list of active monitors with their latest status.
Reads kuma.db out of the named Docker volume via a throwaway sqlite
container (the file is root-owned; the daemon user only has docker access).
"""
script = f"cp /src/kuma.db /tmp/k.db && sqlite3 /tmp/k.db \"{KUMA_QUERY}\""
out = subprocess.run(
[
"docker", "run", "--rm",
"-v", f"{cfg['kuma_volume']}:/src:ro",
cfg["sqlite_image"], "sh", "-c", script,
],
capture_output=True, text=True, timeout=120,
)
if out.returncode != 0:
raise RuntimeError(f"kuma read failed: {out.stderr.strip()}")
data = out.stdout.strip() or "[]"
return json.loads(data)
def docker_ps():
out = subprocess.run(
["docker", "ps", "-a", "--format",
"table {{.Names}}\t{{.Status}}\t{{.Image}}"],
capture_output=True, text=True, timeout=60,
)
return out.stdout.strip()
# --------------------------------------------------------------------------- #
# Matrix client (client-server API over stdlib)
# --------------------------------------------------------------------------- #
def _matrix_req(cfg, method, path, body=None, params=None):
base = cfg["matrix"]["homeserver"].rstrip("/")
url = f"{base}{path}"
if params:
url += "?" + urllib.parse.urlencode(params)
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"Bearer {cfg['matrix']['access_token']}")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read().decode()
return json.loads(raw) if raw else {}
def _md_to_html(text):
"""Very small markdown -> HTML for Matrix formatted bodies."""
import html
esc = html.escape(text)
esc = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", esc)
esc = re.sub(r"`(.+?)`", r"<code>\1</code>", esc)
return esc.replace("\n", "<br>")
def matrix_send(cfg, text):
if not cfg["matrix"].get("room_id") or not cfg["matrix"].get("access_token"):
log(f"MATRIX (not configured) >> {text}")
return
txn = str(int(time.time() * 1000))
room = urllib.parse.quote(cfg["matrix"]["room_id"])
try:
_matrix_req(
cfg, "PUT",
f"/_matrix/client/v3/rooms/{room}/send/m.room.message/{txn}",
body={
"msgtype": "m.text",
"body": text,
"format": "org.matrix.custom.html",
"formatted_body": _md_to_html(text),
},
)
except Exception as e:
log(f"ERROR sending to Matrix: {e}")
def matrix_poll_admin(cfg, state):
"""Return new text messages from the admin in our room since last sync."""
if not cfg["matrix"].get("access_token"):
return []
params = {"timeout": "0"}
if state.get("matrix_since"):
params["since"] = state["matrix_since"]
try:
resp = _matrix_req(cfg, "GET", "/_matrix/client/v3/sync", params=params)
except Exception as e:
log(f"ERROR polling Matrix: {e}")
return []
state["matrix_since"] = resp.get("next_batch", state.get("matrix_since"))
messages = []
room_id = cfg["matrix"]["room_id"]
admin = cfg["matrix"]["admin_user_id"]
rooms = resp.get("rooms", {}).get("join", {})
room = rooms.get(room_id)
if not room:
return messages
for ev in room.get("timeline", {}).get("events", []):
if ev.get("type") != "m.room.message":
continue
if ev.get("sender") != admin:
continue
content = ev.get("content", {})
if content.get("msgtype") == "m.text":
messages.append(content.get("body", "").strip())
return messages
# --------------------------------------------------------------------------- #
# Stack-support agent invocation (Claude Code, headless)
# --------------------------------------------------------------------------- #
RESULT_PROTOCOL = """
When you have finished, end your reply with EXACTLY ONE fenced json block:
```json
{"status": "ok|action_taken|escalate|blocked",
"summary": "one or two sentence operator summary (markdown ok)",
"details": "optional longer detail",
"question": "optional — only if status is escalate: the decision you need",
"options": ["optional", "list", "of", "choices"]}
```
status meanings:
ok — everything healthy, nothing to do
action_taken — you fixed/changed something; describe it in summary
escalate — you need a human decision (set question + options)
blocked — hard blocker, cannot proceed safely (explain in summary)
""".strip()
def run_agent(cfg, task):
"""Invoke the canonical stack-support agent headless and return its output."""
agent_path = os.path.join(cfg["repo_dir"], cfg["agent_file"])
with open(agent_path) as fh:
agent_md = fh.read()
system = (
"You are the semprini-core Stack Maintenance Agent, running unattended "
"as a systemd service. The following is your canonical agent definition; "
"follow it as the source of truth, together with "
f"{cfg['runbook_file']} and CLAUDE.md in this repository.\n\n"
"You operate FULLY AUTONOMOUSLY per the CLAUDE.md sysadmin role: own the "
"fix end to end. Make the smallest safe change that restores service. "
"Pin versions after successful upgrades; auto-rollback on failure. "
"Only set status=escalate when a genuine human decision is required "
"(e.g. a major/breaking upgrade with trade-offs).\n\n"
"Services in scope are exactly those Uptime Kuma tracks. Other projects "
"(e.g. semprini-blog) live alongside semprini-core and use it for "
"IAM/reverse-proxy; maintain their containers too when they are tracked.\n\n"
"--- BEGIN agents/stack-support.agent.md ---\n"
f"{agent_md}\n"
"--- END agents/stack-support.agent.md ---\n\n"
f"{RESULT_PROTOCOL}"
)
cmd = [
cfg["claude_bin"], "-p", task,
"--append-system-prompt", system,
"--permission-mode", "bypassPermissions",
"--output-format", "text",
]
if cfg.get("claude_model"):
cmd += ["--model", cfg["claude_model"]]
for d in cfg.get("extra_dirs", []):
cmd += ["--add-dir", d]
cmd += list(cfg.get("claude_extra_args", []))
log(f"Invoking stack-support agent ({len(task)} char task)…")
proc = subprocess.run(
cmd, cwd=cfg["repo_dir"], capture_output=True, text=True,
timeout=cfg["agent_timeout_seconds"],
)
if proc.returncode != 0:
log(f"WARN: agent exited {proc.returncode}: {proc.stderr.strip()[:500]}")
return proc.stdout.strip()
def parse_agent_result(output):
"""Pull the trailing JSON envelope out of the agent's reply."""
blocks = re.findall(r"```json\s*(\{.*?\})\s*```", output, re.DOTALL)
if blocks:
try:
return json.loads(blocks[-1])
except Exception:
pass
# Fallback: try a bare trailing {...}
m = re.search(r"(\{[^{}]*\"status\"[^{}]*\})\s*$", output, re.DOTALL)
if m:
try:
return json.loads(m.group(1))
except Exception:
pass
return {"status": "unknown",
"summary": (output[-800:] or "no output from agent")}
def relay_result(cfg, state, header, result):
"""Post an agent result to Matrix and register any escalation."""
status = result.get("status", "unknown")
summary = result.get("summary", "")
icon = {"ok": "", "action_taken": "🔧", "escalate": "",
"blocked": "", "unknown": "⚠️"}.get(status, "")
msg = f"{icon} **{header}** — {status}\n{summary}"
if result.get("details"):
msg += f"\n\n{result['details']}"
if status == "escalate" and result.get("question"):
opts = result.get("options") or []
msg += f"\n\n**Decision needed:** {result['question']}"
if opts:
msg += "\n" + "\n".join(f" {i+1}. {o}" for i, o in enumerate(opts))
msg += "\n\n_Reply with a number or a short answer._"
state["pending"] = {
"header": header,
"question": result["question"],
"options": opts,
"asked_at": time.time(),
}
matrix_send(cfg, msg)
# --------------------------------------------------------------------------- #
# Cycles
# --------------------------------------------------------------------------- #
def status_label(code):
return {KUMA_DOWN: "DOWN", KUMA_UP: "UP", KUMA_PENDING: "PENDING",
KUMA_MAINT: "MAINTENANCE"}.get(code, f"?{code}")
def health_cycle(cfg, state):
log("Health cycle: reading Uptime Kuma…")
monitors = read_kuma_monitors(cfg)
streak = state.setdefault("down_streak", {})
down_now = []
for m in monitors:
name = m["name"]
if m.get("status") == KUMA_DOWN:
streak[name] = streak.get(name, 0) + 1
down_now.append(m)
else:
streak.pop(name, None)
confirmed = [m for m in down_now
if streak.get(m["name"], 0) >= cfg["down_confirmations"]]
log(f" {len(monitors)} tracked, {len(down_now)} down, "
f"{len(confirmed)} confirmed down")
if not confirmed:
# quiet heartbeat
if (time.time() - state.get("last_heartbeat", 0)
>= cfg["heartbeat_interval_seconds"]):
up = sum(1 for m in monitors if m.get("status") == KUMA_UP)
matrix_send(
cfg,
f"✅ **Daily health** — {up}/{len(monitors)} tracked services up.",
)
state["last_heartbeat"] = time.time()
return
if cfg["autonomy"] == "notify":
lines = "\n".join(
f" • **{m['name']}** ({m.get('url') or m.get('hostname')}): "
f"{m.get('msg') or 'down'}" for m in confirmed
)
matrix_send(cfg, f"⚠️ **Services down** (notify-only mode):\n{lines}")
return
incident = "\n".join(
f"{m['name']} | type={m['type']} | "
f"target={m.get('url') or (m.get('hostname') or '') + ':' + str(m.get('port') or '')} | "
f"last_msg={m.get('msg') or 'n/a'}"
for m in confirmed
)
task = (
"ROUTINE HEALTH CHECK — Uptime Kuma reports the following tracked "
"services DOWN (confirmed over multiple cycles):\n\n"
f"{incident}\n\n"
"Current containers:\n"
f"{docker_ps()}\n\n"
"For each down service: map the monitor target (a compose service "
"name / network alias) to its container, diagnose using the runbook, "
"and remediate autonomously. Re-verify recovery. If a service is down "
"by design or needs a human decision, set status=escalate."
)
result = parse_agent_result(run_agent(cfg, task))
relay_result(cfg, state, "Health remediation", result)
def resolve_tracked_images(cfg, monitors):
"""Best-effort map of tracked services -> running image (for context)."""
ps = subprocess.run(
["docker", "ps", "--format", "{{.Names}}\t{{.Image}}"],
capture_output=True, text=True, timeout=60,
).stdout.strip().splitlines()
name_to_image = {}
for line in ps:
if "\t" in line:
n, img = line.split("\t", 1)
name_to_image[n] = img
rows = []
for m in monitors:
target = (m.get("hostname") or m.get("url") or "").lower()
token = re.sub(r"^https?://", "", target).split("/")[0].split(":")[0]
matches = [f"{n} -> {img}" for n, img in name_to_image.items()
if token and token in n]
rows.append(f"{m['name']} (target={token or 'n/a'}): "
f"{', '.join(matches) or 'no obvious container match'}")
return "\n".join(rows)
def upgrade_cycle(cfg, state):
if cfg["autonomy"] == "notify":
autonomy_note = (
"Mode is NOTIFY-ONLY: do NOT change anything. Report available "
"upgrades and recommendations only; set status=escalate with the list."
)
elif cfg["autonomy"] == "fix-only":
autonomy_note = (
"Mode is FIX-ONLY for upgrades: research and report available "
"upgrades; do NOT apply them. Set status=escalate to ask which to apply."
)
else:
autonomy_note = (
"Mode is FULL: apply safe upgrades autonomously (pull, test for "
"stability, pin the new tag in the owning compose file, redeploy, "
"rollback on failure). For MAJOR/breaking upgrades with trade-offs, "
"set status=escalate with options instead of applying."
)
log("Upgrade cycle…")
monitors = read_kuma_monitors(cfg)
context = resolve_tracked_images(cfg, monitors)
task = (
"PERIODIC UPGRADE REVIEW. Scope = services Uptime Kuma currently tracks. "
"Tracked services and their running images:\n\n"
f"{context}\n\n"
"Use the upgrade-services skill (agents/skills/upgrade-services/SKILL.md). "
"For each tracked service: find the compose file that owns it (search "
"core_stack/compose*.yml and other project repos under the added dirs, "
"e.g. semprini-blog/docker-compose.yml), determine the current pinned "
"tag, check Docker Hub / GitHub for newer stable releases, and assess "
"breaking changes.\n\n"
f"{autonomy_note}\n\n"
"Summarise what is up to date, what you upgraded, and anything needing "
"a decision."
)
result = parse_agent_result(run_agent(cfg, task))
relay_result(cfg, state, "Upgrade review", result)
def handle_replies(cfg, state):
msgs = matrix_poll_admin(cfg, state)
if not msgs:
return
pending = state.get("pending")
for body in msgs:
log(f"Admin reply: {body!r}")
if not pending:
# Unprompted instruction from the admin — treat as an ad-hoc task.
task = (
"The operator sent this instruction over Matrix; act on it "
f"autonomously and report back:\n\n{body}"
)
result = parse_agent_result(run_agent(cfg, task))
relay_result(cfg, state, "Operator request", result)
pending = state.get("pending")
continue
# Resolve the outstanding escalation with the admin's answer.
answer = body
opts = pending.get("options") or []
if body.isdigit() and 1 <= int(body) <= len(opts):
answer = opts[int(body) - 1]
task = (
f"Earlier you escalated this decision:\n"
f" Question: {pending['question']}\n"
f" Options: {opts}\n\n"
f"The operator decided: {answer}\n\n"
"Carry out that decision now, autonomously, with full testing and "
"rollback on failure. Report the outcome."
)
state["pending"] = None
save_state(cfg, state)
result = parse_agent_result(run_agent(cfg, task))
relay_result(cfg, state, "Decision carried out", result)
pending = state.get("pending")
# --------------------------------------------------------------------------- #
# Main loop
# --------------------------------------------------------------------------- #
def main():
ap = argparse.ArgumentParser(description="semprini stack maintenance daemon")
ap.add_argument("--config",
default="/home/paul/Dev/semprini-maintainer/config.json")
ap.add_argument("--once", choices=["health", "upgrade", "replies"],
help="run a single cycle and exit (for testing)")
args = ap.parse_args()
cfg = load_config(args.config)
state = load_state(cfg)
if args.once:
log(f"Running single '{args.once}' cycle…")
{"health": health_cycle, "upgrade": upgrade_cycle,
"replies": handle_replies}[args.once](cfg, state)
save_state(cfg, state)
return
log("semprini-maintainer starting up.")
matrix_send(cfg, "🤖 **stack-maintainer online** — monitoring tracked "
"services via Uptime Kuma.")
while True:
now = time.time()
try:
handle_replies(cfg, state)
if now - state.get("last_health", 0) >= cfg["health_interval_seconds"]:
health_cycle(cfg, state)
state["last_health"] = now
# Hold upgrades while an escalation is unanswered.
if (not state.get("pending")
and now - state.get("last_upgrade", 0)
>= cfg["upgrade_interval_seconds"]):
upgrade_cycle(cfg, state)
state["last_upgrade"] = now
save_state(cfg, state)
except Exception as e:
log("ERROR in main loop:\n" + traceback.format_exc())
try:
matrix_send(cfg, f"⛔ **maintainer error**: {e}")
except Exception:
pass
time.sleep(cfg["matrix_poll_seconds"])
if __name__ == "__main__":
sys.exit(main())
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# register-matrix-bot.sh — provision the Matrix bot account the maintenance
# agent uses to talk to the operator, and a private room shared with the admin.
#
# Idempotent-ish: re-running re-uses the existing account (login still works if
# the password is unchanged) and creates a fresh room only if one isn't set.
#
# Requirements: run on the core-stack host, with the `user-synapse` container
# running. Uses the registration shared secret already in the synapse config.
#
# Usage:
# ./register-matrix-bot.sh # uses config.json next to this script
# CONFIG=/path/config.json ./register-matrix-bot.sh
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG="${CONFIG:-$DIR/config.json}"
SECRETS="$DIR/.bot-secrets" # gitignored: stores the bot password
if [ ! -f "$CONFIG" ]; then
echo "ERROR: $CONFIG not found. Copy config.example.json to config.json first."
exit 1
fi
jq_get() { python3 -c "import json,sys; print(json.load(open('$CONFIG'))$1)"; }
HOMESERVER="$(jq_get "['matrix']['homeserver']")"
BOT_USER_ID="$(jq_get "['matrix']['user_id']")"
ADMIN_USER_ID="$(jq_get "['matrix']['admin_user_id']")"
SYNAPSE_CONTAINER="${SYNAPSE_CONTAINER:-user-synapse}"
# localpart from @maintainer:semprini.me -> maintainer
BOT_LOCAL="${BOT_USER_ID#@}"; BOT_LOCAL="${BOT_LOCAL%%:*}"
echo "→ Homeserver : $HOMESERVER"
echo "→ Bot : $BOT_USER_ID"
echo "→ Admin : $ADMIN_USER_ID"
# 1. Password — reuse if we created one before, else generate and persist.
if [ -f "$SECRETS" ]; then
# shellcheck disable=SC1090
source "$SECRETS"
fi
if [ -z "${BOT_PASSWORD:-}" ]; then
BOT_PASSWORD="$(openssl rand -hex 32)"
umask 077; echo "BOT_PASSWORD=$BOT_PASSWORD" > "$SECRETS"
echo "→ Generated new bot password (saved to $SECRETS)"
fi
# 2. Create the account (no-op if it already exists).
echo "→ Registering account in $SYNAPSE_CONTAINER"
if docker exec "$SYNAPSE_CONTAINER" register_new_matrix_user \
-u "$BOT_LOCAL" -p "$BOT_PASSWORD" --no-admin \
-c /data/homeserver.yaml http://localhost:8008 2>&1 | tee /tmp/reg.out; then
:
fi
grep -qiE "already taken|User ID already" /tmp/reg.out && \
echo " (account already exists — continuing)"
# 3. Log in to obtain an access token.
echo "→ Logging in…"
LOGIN_JSON="$(curl -fsS -X POST "$HOMESERVER/_matrix/client/v3/login" \
-H 'Content-Type: application/json' \
-d "{\"type\":\"m.login.password\",
\"identifier\":{\"type\":\"m.id.user\",\"user\":\"$BOT_LOCAL\"},
\"password\":\"$BOT_PASSWORD\",
\"initial_device_display_name\":\"semprini-maintainer\"}")"
ACCESS_TOKEN="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['access_token'])" "$LOGIN_JSON")"
[ -n "$ACCESS_TOKEN" ] || { echo "ERROR: no access token"; exit 1; }
echo " got access token"
# 4. Ensure a private room exists with the admin invited.
ROOM_ID="$(jq_get "['matrix'].get('room_id','')" 2>/dev/null || echo "")"
case "$ROOM_ID" in
""|FILLED_BY_*|!*) : ;;
esac
if [[ "$ROOM_ID" != \!* ]]; then
echo "→ Creating maintenance room and inviting $ADMIN_USER_ID"
ROOM_JSON="$(curl -fsS -X POST "$HOMESERVER/_matrix/client/v3/createRoom" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"name\":\"Stack Maintenance\",
\"topic\":\"semprini-core autonomous stack maintenance\",
\"preset\":\"trusted_private_chat\",
\"is_direct\":true,
\"invite\":[\"$ADMIN_USER_ID\"]}")"
ROOM_ID="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['room_id'])" "$ROOM_JSON")"
echo " room: $ROOM_ID"
else
echo "→ Re-using existing room $ROOM_ID"
fi
# 5. Write token + room back into config.json.
python3 - "$CONFIG" "$ACCESS_TOKEN" "$ROOM_ID" <<'PY'
import json, sys
path, token, room = sys.argv[1], sys.argv[2], sys.argv[3]
cfg = json.load(open(path))
cfg.setdefault("matrix", {})
cfg["matrix"]["access_token"] = token
cfg["matrix"]["room_id"] = room
json.dump(cfg, open(path, "w"), indent=2)
print(f" wrote access_token + room_id to {path}")
PY
echo
echo "✓ Done."
echo " ACTION REQUIRED (one-time): in Element (chat.semprini.me) as"
echo " $ADMIN_USER_ID, accept the invite to the 'Stack Maintenance' room."
+31
View File
@@ -0,0 +1,31 @@
[Unit]
Description=semprini-core autonomous stack maintenance agent
Documentation=file:///home/paul/Dev/semprini-maintainer/README.md
After=docker.service network-online.target
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
# Runs as the host user (must be in the 'docker' group). NOT root: Claude Code
# refuses --permission-mode bypassPermissions when running as root.
User=paul
Group=paul
SupplementaryGroups=docker
WorkingDirectory=/home/paul/Dev/semprini-maintainer
Environment=HOME=/home/paul
Environment=PATH=/home/paul/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/bin/python3 /home/paul/Dev/semprini-maintainer/maintainer.py --config /home/paul/Dev/semprini-maintainer/config.json
Restart=always
RestartSec=30
# Give a long-running upgrade plenty of time before any stop is considered hung.
TimeoutStopSec=120
# Light hardening (kept permissive — the agent legitimately drives Docker).
NoNewPrivileges=false
StandardOutput=journal
StandardError=journal
SyslogIdentifier=semprini-maintainer
[Install]
WantedBy=multi-user.target