Files
serverAppConfigs/AGENTS.md
T
Frank f2277863ae docs: rewrite AGENTS.md — dedupe fleet history into lessons + runbook index
- 734 -> 412 lines; active topology table replaces the swap-history log
- retired-runtime detail collapsed into a runbook pointer table
  (serverAppConfigs-docs/LLM/ holds the full runbooks)
- halogen section condensed to invariants that survive config changes
  (pool/reserve matched pair, free-lies rule, client contract)
- new Key Lessons Learned section: ROCm/layer-split, HIP env collisions,
  mmap/PLE constraints, crash diagnosis, swap ordering, podman/quadlet
  gotchas, ops guards
- fixed the merged/broken row in the Summary table
2026-09-16 00:20:58 -07:00

413 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md — serverAppConfigs Agent Rules
## Overview
This repository is the single source of truth for the two homelab servers and
the FrankBook-Pro Mac mini. It tracks the Podman container stacks and the LLM
inference fleet. One repo, one checkout per host, one deploy script. All
services run from `/home/dank/serverAppConfigs` on each host (the 2026-08-22
merge absorbed the former `vllmScripts` repo, history preserved).
- **Dankserver** (192.168.0.235) — media/library stack (RomM, Copyparty,
Calibre-Web, Jellyfin, Plex) plus the `frank-medium` LLM service.
- **Dankclaw** (192.168.0.143) — AI/search/dev/observability stack (SearXNG
via Gluetun, ComfyUI, OpenWebUI, Hermes, Gitea, Grafana, Prometheus, Loki,
Alloy, Pi-hole) plus the `frank-large` LLM service. Caddy on Dankclaw
reverse-proxies both hosts.
- **FrankBook-Pro** (192.168.0.112) — macOS mini running the *arr stack
(Prowlarr, Sonarr, Radarr, Lidarr, Readarr, qBittorrent) as quadlets inside
its podman machine; all egress rides the Gluetun WireGuard netns. Deployed
via the `frankbook-pro` handler.
## Fleet topology (ACTIVE)
| Target | Service | Runtime | Model | Port |
|--------|---------|---------|-------|------|
| Dankserver | `frank-medium` | charlie12345/ROCmFPX llama.cpp (system unit) | kingjones777 Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF, 2x Radeon AI PRO R9700 layer split, 131072 ctx / 1 slot, MTP draft on ROCm1 | 7770 |
| Dankclaw | `frank-large` | halogen-flash-server 0.5.8 (rootless Quadlet) | peonist-ai/halogen-qwen3.8-flash-next w4b .hgn, 262144 ctx / 3 slots, vision tower on | 7770 |
Retired endpoints (`frank-npu`, `frank-small`, and every prior runtime) are in
the [runbook index](#runbook-index). The full swap history lives in
`serverAppConfigs-docs/LLM/index.md` — do not grow it here.
## Repository Layout
- `dankserver/` — compose.yaml + Caddyfile + .env.example for the media stack
- `dankclaw/` — compose.yaml + Caddyfile + .env.example for the AI stack
- `shared/` — homepage, Prometheus, Loki, Alloy, SearXNG configs
- `scripts/` — `deploy.sh` / `deploy-plan.py` / `deploy-remote.sh`, LLM serve
scripts, systemd units, env tunables, bench harnesses
- `old/` — retired LLM scripts (reference only)
- `serverAppConfigs-docs/` — knowledge base (Obsidian vault, OKF format)
- `tests/` — bats suite + Playwright e2e
## Git Workflow
All edits flow through git. Never edit files on a remote machine directly.
1. **Source** the live config from the target machine
2. **Commit** the fetched version here (redact secrets into `.env`)
3. **Edit** locally
4. **Test** — `./tests/run.sh` must pass
5. **Commit and push** — origin is Gitea on Dankclaw
6. **Pull** on the target (`deploy.sh` does this)
7. **Deploy** — `scripts/deploy.sh` plans and dispatches only changed services
## Deployment
```
./scripts/deploy.sh dankclaw
./scripts/deploy.sh dankserver
./scripts/deploy.sh dankclaw --service searxng
./scripts/deploy.sh dankserver --service frank-medium
./scripts/deploy.sh dankclaw --dry-run
```
The planner maps changed paths to services; the remote runner restarts only
the selected service and records its revision outside the checkout.
Documentation-only changes are a no-op. LLM services are selected only by
their own unit/env/serve files or the shared chat template — unrelated
container changes cannot interrupt inference. Set `FRANK_SUDO_PW` for headless
runs on a host without passwordless sudo; the value never enters the repo.
### Deployment completeness (MANDATORY)
A deploy is **not complete** when `deploy.sh` exits green. It requires:
1. `./tests/run.sh` — all seams green, and
2. the Playwright e2e suite (`tests/e2e/run.sh`) — runs **automatically** as
the `[3/3]` gate at the end of `deploy.sh` against the LIVE fleet; a
behavior-affecting deploy that fails it exits `DEPLOY INCOMPLETE`. Bypass
only with `--skip-e2e` in an emergency.
```mermaid
graph LR
A[Edit locally] --> B[Tests pass] --> C[Commit + push]
C --> D[deploy.sh plan] --> E[Target pulls ff-only]
E --> F[Dispatch selected service] --> G[Per-service health]
G --> H[Playwright e2e gate] --> I{green?}
I -->|yes| J[Deploy complete]
I -->|no| K[DEPLOY INCOMPLETE]
```
## Boot Persistence & Self-Healing (MANDATORY)
Every deployed, working Podman container MUST have a self-healing,
boot-persistent Quadlet unit. A container that runs only from `compose.yaml`
or a manual `podman run` dies silently at reboot — rootless Podman does not
auto-start bare containers, and `restart: unless-stopped` does not survive the
explicit stop that shutdown performs. Never report such a service as "up".
Unit requirements:
- **Boot persistence** — `[Install] WantedBy=default.target`, installed to
`~/.config/containers/systemd/<name>.container`, enabled in the user
manager, with linger on for the deploy user (`loginctl show-user dank -p
Linger` → `Linger=yes`).
- **Self-healing** — `Restart=always` (or `on-failure`) with a short
`RestartSec`; quadlet health check where the service has one.
- **Repo provenance** — the `.container` file lives in this repo
(`<host>/<svc>/quadlet/`), is installed by the deployer, and is covered by
the quadlet test seam. The host-side unit is generated state; the repo file
is the source of truth.
Before declaring a service deployed: `systemctl --user is-enabled <name>` →
`enabled`, `is-active` → `active`, and the container answers its health check.
A running container with no unit is a gap: write the unit, test it, deploy it.
This rule exists because reboots found gaps twice (2026-08-26: Caddy as a
bare compose container never returned; 2026-09-11: five observability
services had no units). The observability services now have units under
`dankclaw/observability/quadlet/` and their compose definitions were removed —
one owner per container name. The stack is RUNNING BUT UNCONFIGURED (no
Grafana datasources/dashboards, no Prometheus rules); see
`serverAppConfigs-docs/Infrastructure/Observability.md`.
## Testing
Tests are mandatory before commit (`bats` required on the dev machine):
```
./tests/run.sh
```
Seams: compose validity, env consistency (no leaked secrets), Caddyfile
validity, quadlet units, docs completeness, script syntax, LLM unit
integrity, LLM secret hygiene, deployment flow, service-scoped planning.
See `serverAppConfigs-docs/Architecture/Testing Policy.md`.
## Secret Rules
- Never commit real secrets. The live Gitea DB password and sudo password are
test-enforced leak checks.
- `.env` files are git-ignored; commit only `.env.example` with placeholders.
Exception: `scripts/*.env` LLM tunables (paths and numbers, never
credentials).
- Do not document passwords anywhere in the repo. SSH keys are the auth
mechanism; `FRANK_SUDO_PW` covers headless sudo and never enters the repo.
## External Exposure Policy (MANDATORY)
Every Caddy-proxied service is either behind the auth chain (Anubis :8923 →
Authentik embedded outpost) or explicitly internal-only — no public DNS name,
no vhost. New services default to internal-only until guarded.
When guarding a new vhost — provider first, then route:
1. Create the outpost proxy provider FIRST (`external_host` = exact vhost URL
with trailing slash; `internal_host = http://caddy:8017`). If the vhost
flips first, authenticated traffic hits the outpost error page.
2. Flip the vhost to `reverse_proxy anubis:8923` with
`header_up X-Real-Ip {remote_host}`, and add the post-auth
`@<svc> host <vhost>` handler on the `http://:8017` listener routing to
the real upstream.
3. Anubis guard set, Caddy TLS edge, and outpost provider list change
together in one deploy.
4. Service-to-service traffic targets internal addresses (`loki:3100`), never
the public hostname — non-browser clients cannot solve challenges.
Exempt categories (document the reason in the vhost comment): the IdP itself
(`auth.`), git smart-HTTP endpoints, services with their own complete auth.
## Deletion & Trash Policy (MANDATORY)
Never `rm`. Move deletions into trash so they stay recoverable:
1. In-repo files → `.AGENTTRASH/` beside the item's directory.
2. Elsewhere → dated staging dir `~/.AGENTTRASH/YYYYMMDD-topic`.
3. List staged items and sizes; wait for explicit user approval to purge.
`rm`, `rm -rf`, and `git clean -f` are forbidden without explicit permission.
`git * --no-verify` is forbidden. The trash is a temporary hold, not an
archive.
## Benchmarking Policy (MANDATORY)
**STOP the production service BEFORE benchmarking** — these servers serve
real users, and a bench beside a live service starves the GPU and corrupts
results. Use the runtime's own harness: `llama-bench` (llama.cpp), `vllm
bench` (vLLM), halogen's `bench`/`sweep` modes for frank-large. Rolling your
own bench requires asking first. Scratch ports are not used on LLM hosts — a
test process cannot share the single GPU anyway; validate against the service
itself.
1. Stop and mask (self-healing would restart it):
- Dankclaw `frank-large` (rootless — no sudo):
`systemctl --user stop frank-large && systemctl --user mask frank-large`
- Dankserver `frank-medium` (system unit):
`sudo systemctl stop frank-medium && sudo systemctl mask frank-medium`
2. Bench.
3. Unmask and start; verify `curl http://192.168.0.<host>:7770/health`.
**CRITICAL:** never `systemctl disable` a system unit on these hosts — it
deletes the unit file from `/etc/systemd/system/`, not just the symlink.
`mask` symlinks to `/dev/null` and is safe/reversible.
## Model Config (harness)
The harness reaches the fleet through `/home/dank/.omp/agent/models.yml`
(outside the repo). Each provider is one host endpoint; each model `id` is
the served alias: `frank-medium` → `192.168.0.235:7770`, `frank-large` →
`192.168.0.143:7770`. Per-role routing lives in
`/home/dank/.omp/agent/config.yml` under `modelRoles`.
Resolution rules: the default model resolves as a `defaultProvider` +
`defaultModel` pair (the provider must serve the model); a subagent override
uses a bare model `id`, which resolves only when unique across providers.
When renaming a service, retiring an endpoint, or moving a model: update the
provider block, model `id`, `contextWindow`, `maxTokens`, `input` in
`models.yml`, then fix every `modelRoles` entry that named the old endpoint —
a role pointing at a removed provider fails at call time. `frank-large`
declares `input: [text, image]` (halogen vision tower). There is **no audio
input anywhere in the fleet** (left with `frank-npu`).
## Active Services
### frank-large — halogen-flash-server (Dankclaw)
`ghcr.io/peonist-ai/halogen-flash-server:0.5.8` as a rootless Quadlet
(`dankclaw/frank-large/quadlet/frank-large.container` → `frank-large.service`
in the user manager), serving `peonist-ai/halogen-qwen3.8-flash-next`.
One container, two halves: engine on container loopback :8730 (unauthenticated,
NEVER published) and OpenAI front-end on :8731 published as host **7770**.
Deployed flags: `HALOGEN_CTX=262144`, `HALOGEN_KV_POOL_POSITIONS=401408`,
`HALOGEN_KV_SLOTS=3`, `HALOGEN_MAX_TOK=32768`, `HALOGEN_HOST_RESERVE_GIB=15`,
`HALOGEN_VISION_TOWER=1`. Everything else upstream default. Sizing history
and the flag-by-flag rationale live in the runbook — do not re-derive here.
Invariants that survive any config change:
- **Pool and reserve are a matched pair.** Budget = `121.2 host − 67.7
weights − reserve`; that one budget carries KV pool + prefill arena + the
1.97 GiB vision tower. The reserve is **not an allocation** — raising it
cannot free host RAM, it can only shrink the pool. To free host RAM, cut
`HALOGEN_KV_POOL_POSITIONS` or `HALOGEN_MAX_TOK`.
- **`free` lies on this host.** The ~68 GiB pinned weights count as
reclaimable page cache, so `free`/`MemAvailable`/Grafana overstate free
memory by the model size. Believe the server's startup ledger. A co-tenant
running the box dry looks like minutes of full-CPU stalls with no disk
activity — not a crash.
- **Client-visible contract:** no server-side sampler defaults (no
`temperature` ⇒ greedy); per-request budget defaults to 8192 and **includes
thinking** (running out mid-thought ⇒ `finish_reason: "length"`, empty
content); cap 65536; a request reserving prompt + `max_tokens` beyond the
pool queues, then fails `503 engine_busy` after the 3600 s timeout.
Runbook: `serverAppConfigs-docs/LLM/halogen-flash-server.md`.
### frank-medium — ROCmFPX llama.cpp (Dankserver)
charlie12345/ROCmFPX llama.cpp `c49ebdb`, built for gfx1201 with the fork's
self-contained ROCm 7.14 toolchain (RPATH-bundled — host ROCm packages never
participate). Serves `kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF`
(ftype 115; stock llama.cpp cannot load it) with `--split-mode layer` across
both R9700s, 131072 ctx / 1 slot, f16 KV, flash-attn on, MTP draft head
pinned to ROCm1, reasoning on. Env `scripts/frank-medium.env`, serve
`scripts/serve-frank-medium.sh`.
Runbook: `serverAppConfigs-docs/LLM/frank-medium-rocmfpx.md`.
## Key Lessons Learned
Durable operational lessons from the fleet's history. Full detail in the
linked runbooks.
**ROCm / GPU**
- **Layer split, never tensor, on the RCCL-less ROCmFPX builds.** Tensor
parallel falls back to a butterfly allreduce and collapses prefill ~7x
(measured 271.8 vs 1991 t/s). Pin the MTP draft head to ROCm1 — an
unpinned draft under tensor mode aborts the KV build.
- **mmap must stay on** for the big-PLE models: `--no-mmap` forces the
~50 GiB PLE table into anonymous memory → silent cgroup kill, dmesg is the
only evidence. mmap pages ARE page cache. Never pin the PLE tensor with
`-ot` (23 → 13.4 t/s).
- **HIP reads bare env vars** `TEMP`, `HOST`, `GPU_MAX_HEAP_SIZE`. Serve
scripts export env files with `set -a`, so a tunable named `TEMP` leaks
into HIP (`TEMP=1.0` sent the JIT cache to path `1.0` and segfaulted every
load, fixed 2026-08-13 by renaming to `TEMPERATURE`). Never name a tunable
these; check with
`strings /opt/rocm-*/lib/libamdhip64.so.* | grep -xE "TEMP|HOST|GPU_MAX_HEAP_SIZE"`.
- **`-DGGML_CUDA_FA_ALL_QUANTS=ON` is required** for q8_0 KV + flash
attention on CUDA builds; without it prefill collapses ~12x (95 vs 1300+
t/s, measured 2026-08-17).
- **Flash-Next MTP heads do not load on any current loader** — they need full
per-layer `blk.<il>.hc_attn_norm.weight` and ship only `blk.48` (verified
3x). Don't re-attempt without new loader support.
- **QSA flash-attn assert:** `GGML_ASSERT(a-type == GGML_TYPE_F32)` on
qwen4exp needs `scripts/patches/qwen4exp-qsa-f32-mask.patch` — but only on
the agentionai FAST layer set; the kingjones STRIX set works unpatched.
- **GPU budget on Dankclaw is the rocm-smi figure** (64 GiB, SVM-backed).
`free`/`dmidecode` show the CPU-side carve-out — intentional, do not
change.
**Load / crash diagnosis**
- Model load is slow (minutes cold); health returns 503 while loading; do
not kill the process. `deploy.sh` waits up to 20 minutes (`LLM_WAIT`). On
halogen, the FIRST request after a (re)start can go silent ~45–60 s
(health `engine_unresponsive`, 503s) and then self-recovers — do not
restart over it.
- Crash-loop procedure: (1) `journalctl -u <svc> -n 100` + kernel line via
`journalctl -k` (segfault at address 8 in `libamdhip64` = NULL unref in
HIP); (2) run serve script, then raw binary — different outcome isolates
the env file; (3) `gdb -batch -ex run -ex "bt 25" --args bash
scripts/serve-<svc>.sh` — llama-server buffers stdout and loses the log
tail on SIGSEGV; trust gdb, not the log; (4) `amdgpu: SVM mapping failed`
spam is a symptom, not a root cause.
- Journal signatures: client abort logs `W srv stop: cancel task, id_task =
N`; natural stop logs only `slot release` with `truncated = 0`. The server
never preempts a running generation; excess tasks queue when slots are
busy.
**Host memory / swap**
- Swap resize ordering: create and `swapon` the replacement BEFORE `swapoff`
of the old one, or live pages must land in RAM that `MemAvailable`
overstates. Dankclaw swap (64 GiB at `/swap.img`, fstab-driven) protects
the co-tenants, not frank-large — pinned weights don't swap, and paging
the KV pool is worse than the OOM it prevents.
- Host swap state is invisible to `deploy.sh` (no repo file) — check
`swapon --show` before reasoning about headroom.
**Runtime-specific guards**
- **Kairic Edge** (if ever re-deployed): the pinned CK commit needs
`third_party/composable_kernel/include/ck/config.h` generated manually
(untracked; `git clean` removes it), and its greedy-argmax fastpath rejects
non-greedy requests and reasoning budgets. Re-test chat before production.
- **FastMTP sidecar**: build must be commit `4df29be4` + the HauhauCS patch;
stock binaries fail the draft load with a tensor-shape mismatch.
**Ops**
- Never `pkill -f` a pattern that appears in your own command line — it kills
your ssh session. Use `pkill -x <exact-name>`.
- Both hosts allow passwordless sudo for the deploy user. Use plain `sudo` in
committed scripts (no `echo pw | sudo -S`); `FRANK_SUDO_PW` covers hosts
without it.
- ComfyUI on Dankclaw runs CPU-only (`--cpu`): no ROCm wheels for gfx1151,
and CUDA images are wrong for the hardware.
- SearXNG outbound traffic must stay on the Gluetun tunnel; do not move its
ports off the gluetun service.
- `amd_iommu=off` on Dankclaw (bought 13–16 % prefill for frank-large,
2026-09-11) permanently retired the XDNA2 NPU: the driver's SVA/PASID bind
needs an enabled IOMMU (`/dev/accel/accel0` fails `err=-19`). Revert path
in `serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md`.
- Rootless Quadlet containers run with `--replace --rm`: `podman logs` covers
only the CURRENT instance; history across recreations lives in
`journalctl --user -u <name>.service`. Read rootless storage WITHOUT sudo
(per-user storage — `sudo podman logs` finds nothing).
## Runbook Index
Active:
| Service | Runbook |
|---------|---------|
| frank-large (halogen-flash-server, Dankclaw) | `serverAppConfigs-docs/LLM/halogen-flash-server.md` |
| frank-medium (ROCmFPX, Dankserver) | `serverAppConfigs-docs/LLM/frank-medium-rocmfpx.md` |
| Dankclaw kernel posture (`amd_iommu=off`) | `serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md` |
| Deployment flow + incidents | `serverAppConfigs-docs/LLM/deployment.md` |
Retired (rollback targets and history):
| Runtime | Host | Window | Runbook |
|---------|------|--------|---------|
| kingjones STRIX Qwen3.8-Flash-Next ROCmFP4 | Dankclaw | 2026-08-30 → 09-11 | `kingjones-strix-rocmfp4.md` |
| agentionai ROCmFP4-FAST imatrix | Dankclaw | 2026-08-29 → 08-30 | `agentionai-fast-imatrix.md` |
| eemin Qwen3.8-27B ROCmI4 W4A4 MTP | Dankclaw | 2026-08-18 → 08-25 | `eemin-rocmi4-mtp.md` |
| Kairic Edge IU4 | Dankclaw | 2026-08-22 (same-day revert) | `kairic-edge.md` |
| CIRU ActiveFPX v3 / PromptForge | Dankclaw | through 2026-08-22 | `ciru-activefpx-promptforge.md` |
| DFlash2 Vulkan | Dankclaw | 2026-08-21 (same-day revert) | `dflash2-large-vulkan.md` |
| FastFlowLM NPU (`frank-npu`) | Dankclaw | twice, retired 2026-09-11 | `frank-npu.md`, `FastFlowLM NPU Inference.md` |
| FreeToken (official + Huihui FP8, `frank-small`) | Dankserver | 2026-09-02 → 09-15 | `freetoken-frank-small.md` |
| FastMTP patched llama.cpp | Dankserver | retired 2026-08-18 | `qwen3.8-spec-matrix.md` |
| ik_llama.cpp Ornith CRACK | Dankserver | retired 2026-09-02 | `ikllama-ornith-dankserver.md` |
Retired runbooks are under `serverAppConfigs-docs/LLM/`. Retired scripts live
in `old/` and `.AGENTTRASH/`.
## Why
Direct edits on the remote drift from version control, and future agents
won't know what changed. Git is the single source of truth. The two workloads
share two hosts, one Caddy, and one Gitea; one repo keeps them in one
history.
## Summary
| Context | Action |
|---------|--------|
| Editing a compose/Caddyfile/serve script | **Through git** — fetch, commit, edit, test, push, pull, deploy. Never edit on the remote. |
| Config exists only on remote | **Pull it into the repo first** — commit the fetched version (redacted) before editing. |
| Unstaged local changes | **Commit or discard** — check `git status` before starting work; ask if unsure. |
| Deploying a committed change | **`scripts/deploy.sh <target>`** — pushes, pulls ff-only, plans, dispatches, health-checks. |
| Container without a Quadlet unit | **Give it one** — boot-persistent, self-healing `.container` via git. |
| Benchmarking | **Stop and mask the service first**, bench, unmask, restart, verify health. |
| Deleting a file | **Move it to trash** — `.AGENTTRASH/` (in-repo) or `~/.AGENTTRASH/YYYYMMDD-topic`. Never `rm`. |
| Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. |
| Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. |