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 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:

{"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

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. Provision the Gitea token (lets the agent push + open PRs as claude-code)
./register-gitea-bot.sh

# 4. Install + start the service
./install.sh

config.json and .bot-secrets hold the bot token/password and are gitignored.

Pushing code & pull requests

When the agent changes tracked files in a repo (compose files, configs, scripts, docs) it does not commit to main — it branches, commits, pushes, and opens a pull request on git.semprini.me (Gitea) for the operator to review, then reports the PR URL to Matrix. Live remediation that must restore service now (e.g. docker compose up) still happens immediately; only the git change is gated behind the PR.

It authenticates as the claude-code user (an OIDC account in auth.semprini.me that is already a push-capable collaborator). register-gitea-bot.sh mints a Gitea personal access token via the gitea admin CLI inside the user-gitea container — git.semprini.me has password login and HTTP basic auth disabled, but a PAT still works for git-over-HTTPS and the API, so no SSH exposure or core-stack change is needed. The daemon hands the headless agent claude-code's git identity + credential (via GIT_CONFIG_*, scoped to git.semprini.me) and the Gitea API coordinates ($GITEA_TOKEN, $GITEA_API, $GITEA_REPO) so it can open the PR itself. Leaving gitea.token empty disables the workflow — the agent then edits/redeploys live without committing.

Operate

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.
register-gitea-bot.sh Mints the claude-code Gitea token for push + PRs, writes it to config.
semprini-maintainer.service systemd unit (runs as paul).
install.sh Syntax-check, install, enable, start.

See docs/architecture.md for the architecture decision record.

S
Description
No description provided
Readme
120 KiB
Languages
Python 71.1%
Shell 28.9%