The two replicas behind :7770 were on plain round-robin, which throws away prefix-cache hits: an agent loop re-sends its system prompt and file context every turn, and alternating replicas means every other turn pays a cold prefill. Measured with 90k-token contexts, warm-turn TTFT was 2676 ms. Pinning everything to one replica is not the answer either — a cold batch (the shape a benchmark or a burst of parallel subagents produces) then loses a card's worth of prefill: 16 requests took 30.1 s pinned against 18.6 s spread. So the policy routes by request shape. A continuation (more than one message — the agent-loop signature) is pinned to the replica that served its prefix; anything else spreads across the cards by counter. Measured: 19.97 s for the burst and 1095 ms warm-turn TTFT — the best of both, without a regression on either workload. The policy lives in proxy/routing.py, separate from the HTTP plumbing in proxy/affinity-proxy.py, so it can be tested without aiohttp, a network or a GPU. tests/frank_medium_proxy.bats covers it, and it earned its keep: the first version hashed fresh prefixes to pick a card, which sends every member of a same-prefix burst to one card (29.8 s, measured), and a stale index in the pin path returned 500 on every continuation. Both are now assertions. ROUTING=roundrobin and ROUTING=affinity stay available to re-measure the choice on this host; the default is adaptive.
23 KiB
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-mediumLLM 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-largeLLM 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-prohandler.
Fleet topology (ACTIVE)
| Target | Service | Runtime | Model | Port |
|---|---|---|---|---|
| Dankserver | frank-medium |
vllm-radiance 0.9.3 (ggz14/radiance-vllm-mxfp4 patches), 2x single-GPU rootless Quadlets behind an adaptive-routing proxy | just1moremodel Qwen3.8-27B-Uncensored-MXFP4-awq + Qwen3.8-27B-DFlash2-FP8 drafter, 2x Radeon AI PRO R9700 (one card per replica, TP=1), 114688 ctx / 4 seqs each | 7770 |
| Dankclaw | frank-large |
ik_llama.cpp master d85a1d7 (root systemd unit) | mradermacher Qwen3.8-Flash-Next-Uncensored i1-Q4_K_M GGUF, RTX 3090 (dense/attn/KV) + LPDDR (experts/PLE) hybrid, 262144 ctx / 1 slot, vision via mmproj | 7770 |
Retired endpoints (frank-npu, frank-small, and every prior runtime) are in
the 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 stackdankclaw/— compose.yaml + Caddyfile + .env.example for the AI stackshared/— homepage, Prometheus, Loki, Alloy, SearXNG configsscripts/—deploy.sh/deploy-plan.py/deploy-remote.sh, LLM serve scripts, systemd units, env tunables, bench harnessesold/— 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.
- Source the live config from the target machine
- Commit the fetched version here (redact secrets into
.env) - Edit locally
- Test —
./tests/run.shmust pass - Commit and push — origin is Gitea on Dankclaw
- Pull on the target (
deploy.shdoes this) - Deploy —
scripts/deploy.shplans 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:
./tests/run.sh— all seams green, and- the Playwright e2e suite (
tests/e2e/run.sh) — runs automatically as the[3/3]gate at the end ofdeploy.shagainst the LIVE fleet; a behavior-affecting deploy that fails it exitsDEPLOY INCOMPLETE. Bypass only with--skip-e2ein an emergency.
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(oron-failure) with a shortRestartSec; quadlet health check where the service has one. - Repo provenance — the
.containerfile 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.
.envfiles are git-ignored; commit only.env.examplewith placeholders. Exception:scripts/*.envLLM tunables (paths and numbers, never credentials).- Do not document passwords anywhere in the repo. SSH keys are the auth
mechanism;
FRANK_SUDO_PWcovers 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:
- 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. - Flip the vhost to
reverse_proxy anubis:8923withheader_up X-Real-Ip {remote_host}, and add the post-auth@<svc> host <vhost>handler on thehttp://:8017listener routing to the real upstream. - Anubis guard set, Caddy TLS edge, and outpost provider list change together in one deploy.
- 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:
- In-repo files →
.AGENTTRASH/beside the item's directory. - Elsewhere → dated staging dir
~/.AGENTTRASH/YYYYMMDD-topic. - 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.
- Stop and mask (self-healing would restart it):
- Dankclaw
frank-large(root unit since the 2026-09-15 ik cutover):sudo systemctl stop frank-large && sudo systemctl mask frank-large - Dankserver
frank-medium(system unit):sudo systemctl stop frank-medium && sudo systemctl mask frank-medium
- Dankclaw
- Bench.
- 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 — ik_llama.cpp (Dankclaw)
ik_llama.cpp master d85a1d7 as a root systemd unit (repo
scripts/frank-large.service symlinked into /etc/systemd/system/;
Restart=always), serving mradermacher/Qwen3.8-Flash-Next-Uncensored-i1-Q4_K_M
(111 GiB GGUF + mmproj-f16) on port 7770 with 262144 ctx / 1 slot, greedy
default, reasoning on (deepseek), vision via mmproj. Topology: the RTX 3090
(PCIe x4 in the M.2 dock) carries dense/attention/KV; ALL MoE expert tensors
and the ~33 GiB PLE table stay in LPDDR via CPU buffers. Deployed flags in
scripts/frank-large.env / scripts/serve-frank-large.sh; the serve script
refuses to start without GGML_CUDA_NO_PINNED_WEIGHTS=1 (the pinned-host
buffer incident starved co-tenants into unreachability until a power cycle).
Measured envelope (2026-09-15/16 campaign): prefill ~192 tok/s (1.8k-32k prompts, CPU VNNI compute ceiling), decode ~3.8-4.3 tok/s — gated by ~100 CPU↔GPU backend splits per token (GPU 95% idle, launch/sync-bound; thread count irrelevant). The halogen-era 30 tok/s decode bar is out of reach on this model+hardware; the runbook carries the full evidence chain, the mainline-llama.cpp comparison (1.0 tok/s — ruled out), and the tuning levers.
Runbook: serverAppConfigs-docs/LLM/frank-large-ik-uncensored.md.
Rollback: halogen-flash-server (quadlet archived in
~/.AGENTTRASH/20260915-ik-swap/ on dankclaw).
frank-medium — vllm-radiance MXFP4 (Dankserver, ACTIVE)
stilldeadcode/vllm-radiance:0.9.3 plus the ggz14/radiance-vllm-mxfp4
patches (pinned clone at /home/dank/GitPackages/radiance-vllm-mxfp4; its
patches are applied at every container start), serving
just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq with the
Qwen3.8-27B-DFlash2-FP8 block-diffusion drafter (SPEC=7).
The deployment is two single-GPU replicas behind an adaptive-routing proxy —
frank-medium-a (:7771, R9700 #1), frank-medium-b (:7772, R9700 #2) and
frank-medium-proxy (:7770, the fleet endpoint), three rootless Quadlets
under dankserver/frank-medium/quadlet/. Each replica is TP=1 at
114688 context / 4 sequences with vLLM-profiled KV (KV_MEM=0).
TP=2 across both cards cannot run on this host. RCCL's collective kernels
carry a hidden hostcall-buffer parameter, and ROCr refuses to dispatch them on
a device without PCIe atomics; the second R9700 sits behind the chipset
bridges 05:00.0/06:09.0, which advertise AtomicOpsCap: Routing- — a
read-only capability, so no software can enable atomics on that path. Not the
kernel version (7.0.0-28/-30/-31 identical), not the image, not the RCCL
build (a from-source rebuild fails identically). A slot on a CPU root port —
there is none free on this AM5 platform for a second x16 card — or a platform
with more CPU lanes restores TP=2. frank-medium.container stays in the repo
as the TP=2 target; the deploy handler does not install it.
Runbook: serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md.
Prior runtime: frank-medium-rocmfpx (llama.cpp era, same day).
Key Lessons Learned
Durable operational lessons from the fleet's history. Full detail in the linked runbooks.
PCIe / multi-GPU
- RCCL collectives need PCIe atomics on every participating GPU. RCCL's
kernels carry a hidden hostcall-buffer parameter; ROCr refuses to dispatch
them when the device has no atomics, and the failure surfaces as
hipErrorIllegalState("the operation cannot be performed in the present state") fromhipLaunchKernel, not as anything that names atomics. The value is consumed at driver probe time, so writing the AtomicOp Requester bit withsetpciat runtime changes nothing. Check the chain:lspci -vv -s <gpu>and every upstream bridge must showAtomicOpsCap: Routing+(aRouting-bridge is a hardware wall), and the endpoint's Device Control 2 (setpci -s <gpu> CAP_EXP+28.w) must have bit 6 set. AMD desktop platforms put one card on CPU root ports (atomics work) and the rest behind the chipset (usually not) — that asymmetry is what blocked frank-medium's TP=2. A one-card collective proves nothing: NCCL short-circuits atworld_size=1. - When a GPU collective dies, read the runtime, not the framework. The
message that identified the cause came from
AMD_LOG_LEVEL=3(Pcie atomics not enabled, hostcall not supported→AQL dispatch failed!), not from vLLM or RCCL.AMD_LOG_LEVELcosts one env var and prints the failing HIP call with its return code.
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-mmapforces 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 withset -a, so a tunable namedTEMPleaks into HIP (TEMP=1.0sent the JIT cache to path1.0and segfaulted every load, fixed 2026-08-13 by renaming toTEMPERATURE). Never name a tunable these; check withstrings /opt/rocm-*/lib/libamdhip64.so.* | grep -xE "TEMP|HOST|GPU_MAX_HEAP_SIZE". -DGGML_CUDA_FA_ALL_QUANTS=ONis 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.weightand ship onlyblk.48(verified 3x). Don't re-attempt without new loader support. - QSA flash-attn assert:
GGML_ASSERT(a-type == GGML_TYPE_F32)on qwen4exp needsscripts/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/dmidecodeshow 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.shwaits up to 20 minutes (LLM_WAIT). On halogen, the FIRST request after a (re)start can go silent ~45–60 s (healthengine_unresponsive, 503s) and then self-recovers — do not restart over it. - Crash-loop procedure: (1)
journalctl -u <svc> -n 100+ kernel line viajournalctl -k(segfault at address 8 inlibamdhip64= 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 failedspam is a symptom, not a root cause. - Journal signatures: client abort logs
W srv stop: cancel task, id_task = N; natural stop logs onlyslot releasewithtruncated = 0. The server never preempts a running generation; excess tasks queue when slots are busy.
Host memory / swap
- Swap resize ordering: create and
swaponthe replacement BEFOREswapoffof the old one, or live pages must land in RAM thatMemAvailableoverstates. 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) — checkswapon --showbefore reasoning about headroom.
Runtime-specific guards
- Kairic Edge (if ever re-deployed): the pinned CK commit needs
third_party/composable_kernel/include/ck/config.hgenerated manually (untracked;git cleanremoves 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 -fa pattern that appears in your own command line — it kills your ssh session. Usepkill -x <exact-name>. - Both hosts allow passwordless sudo for the deploy user. Use plain
sudoin committed scripts (noecho pw | sudo -S);FRANK_SUDO_PWcovers 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=offon 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/accel0failserr=-19). Revert path inserverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md.- Rootless Quadlet containers run with
--replace --rm:podman logscovers only the CURRENT instance; history across recreations lives injournalctl --user -u <name>.service. Read rootless storage WITHOUT sudo (per-user storage —sudo podman logsfinds nothing).
Runbook Index
Active:
| Service | Runbook |
|---|---|
| frank-large (halogen-flash-server, Dankclaw) | serverAppConfigs-docs/LLM/halogen-flash-server.md |
| frank-medium (vllm-radiance MXFP4, Dankserver) | serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.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. |