The runbook index collapses to three live endpoints plus a retired-row
locator: a payload swap is an Eras row on the endpoint's vault page, never
a page of its own. Restated shapes, digests and measured numbers are
replaced with links to [[frank-archon]], [[frank-dragoon]], [[frank-zealot]]
and scripts/<svc>.env, which is the authority for every value.
Carries the frank-zealot instrument correction from d457985: on the PrismML
fork build 10777 the per-request timings object only reaches you on
/v1/chat/completions — the raw /completion path answers 500 on a greedy
ignore_eos run.
60 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-archonLLM 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 TWO LLM services:
frank-dragoon(flash-server container on the gfx1151 iGPU) andfrank-zealot(llama.cpp CUDA on the RTX 3090). 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 | Payload | Port |
|---|---|---|---|---|
| Dankserver | frank-archon |
radiance, digest-pinned, root unit, TP=2 | publisher qwen3.8-next-flash-fp8-iq4r-moe.rad |
7770 |
| Dankclaw | frank-dragoon |
halogen-flash-server 0.16.2, digest-pinned, root unit | qwen38-flash-next-v2-abliterated.hgn + n-gram + vision tower |
7770 |
| Dankclaw | frank-zealot |
llama.cpp PrismML fork build 10777, root unit — the only loader for this payload | Ternary-Bonsai-2-9B PQ2_0 + Q8_0 mmproj | 7771 |
Payload identity, shape, eras, laws and rollback live in ONE page each:
frank-archon, frank-dragoon, frank-zealot. scripts/<service>.env
is the authority for every number; a page that disagrees with it is the bug.
A retired payload is a row in that page's Eras table — there is no page
per attempt and no separate swap-history file.
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). The managed skill library is distilled there one page per skill-cluster:LLM/index.md→ Cross-Cutting References, plusOperations/index.md,Agent/index.md, and the Infrastructure/Dankclaw/FrankBook-Pro service pages (serverAppConfigs-docs/log.mdcarries the wave plan and results)tests/— bats suite + Playwright e2e
Git Workflow — trunk-based (fleet law, aligned 2026-10-18)
All edits flow through git. Never edit files on a remote machine directly.
main is the only long-lived branch: no develop, no release branches, no
back-merges. Frank is the only dev; this repo and dank/command-center follow
the same regime.
- Branch fresh —
git fetch, then branch off the currentorigin/main(<type>/<slug>, type ∈ feat|fix|docs|chore). If main moves under you, merge it into your branch — never rebase. - Source the live config from the target machine
- Commit the fetched version here (redact secrets into
.env) - Edit locally
- Test —
./tests/run.shmust pass - Push the branch and open a PR against
main— origin is Gitea on Dankclaw. Merge with--no-ff; never merge a PR without an explicit, current OK from Frank. The moment the PR is open, open it in Frank's browser (xdg-open <PR-URL>— a Firefox tab on the workstation); never make him hunt for it in Gitea. Releases: tagvX.Y.Zstraight onmain. - Pull on the target (
deploy.shdoes this) - Deploy —
scripts/deploy.shplans and dispatches only changed services
Attribution footer (issues and PRs) — every issue or PR body you open MUST end with:
---
cwd: <working directory> · session: <omp session id>
Session id from /status; cwd is that session's own working directory — not the build checkout, when they differ (note the build worktree in the body instead). Machine-made artifacts stay attributable to the box and session that spawned them.
Deployment
./scripts/deploy.sh dankclaw
./scripts/deploy.sh dankserver
./scripts/deploy.sh dankclaw --service searxng
./scripts/deploy.sh dankserver --service frank-archon
./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 files — every endpoint (frank-dragoon/frank-zealot/frank-archon)
by its unit/env/serve trio in scripts/ — so
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.
deploy.sh <target> with no --service is a fleet-wide DRAIN, not a sync.
It also walks every per-service revision marker under the deploy-state dir and
restarts each service whose marker is behind HEAD and whose paths changed
since that marker — so a backlog accumulated by service-scoped deploys fires
all at once, and one failing health gate aborts the run with every later
service left pending. Name the service unless a drain is what you want
(./scripts/deploy.sh dankserver --service frank-archon).
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. The gate runs the conditionalfrankbookPlaywright project (the*arr/qBittorrent walk,tests/e2e/specs/19-frankbook.spec.ts) ONLY when a frankbook blast-radius path changed since origin/main — that spec,frankbook-pro/**,shared/homepage/services.json, or thedankclaw/Caddyfileserving the arr vhosts — so a sleeping FrankBook-Pro never fails an unrelated deploy. The default project set excludes it everywhere else (E2E_INCLUDE_FRANKBOOK=1opts in; seeserverAppConfigs-docs/Architecture/E2E Suite.md).
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 configured and delivering as of
2026-09-17: a Grafana datasource and the llm-fleet dashboard are
provisioned from shared/grafana/provisioning/, shared/prometheus/rules/ llm-fleet.yml loads 5 alert rules + 1 recording rule, and firing rules are
delivered: Prometheus → Alertmanager (dankclaw/observability/quadlet/ alertmanager.container, loopback :9093) → host-side Signal relay
(scripts/signal-relay.py, user unit, 192.168.0.143:18093 — the host LAN
IP, the one bind a container can reach as host.containers.internal;
iptables admits loopback, the host and the podman bridge subnet only) → signal-cli account 318311.
Prometheus targets read down for structural reasons (HTML-scraped "probes",
Gitea metrics off); the remedy is a blackbox exporter. frank-zealot was down for
a different reason until 2026-10-18: llama.cpp will not serve /metrics at all
without --metrics. 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 including vault link integrity,
script syntax (shell + python), LLM unit integrity, LLM secret hygiene,
deployment flow, service-scoped planning, homepage config/pages/deploy/checker,
and e2e_scaffold — which fails if a Playwright spec file's number prefix is in
no project, because such a spec silently never runs.
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. - A token passed as
curl -H "Authorization: Bearer …"is world-readable. It lands in/proc/<pid>/cmdline, so anyps/pgrep -aon the host prints it — that leaked an HF token into a session transcript on 2026-09-16, while merely checking whether a download was still running. Pass secrets out of band (curl -K <mode-600 file>carrying the header, orHF_TOKENin the environment forhf/hf_transfer) and neverpgrep -aa fetch command that carries an auth header.
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.
Model Swaps (fleet law, 2026-10-18)
Swap the model. Do not instrument it. A payload change is: edit the trio
(env / serve script / unit) → scripts/deploy.sh <host> --service <svc> →
/health answers → one chat turn, one tool call, and one vision probe on an
endpoint that serves images. That is the entire procedure.
- No benchmarking unless Frank orders it. No pre-swap baseline battery, no A/B arms, no decode/prefill ladders, no acceptance tables, no numbers written into any page afterwards. Data exists only when he asks for it, and then only in the file he names.
- It doesn't work → roll back. Revert the commit, redeploy, done. No post-mortem page, no investigation log, no trial-drop-in forensic tour. If the fault is not obvious, one line in the endpoint page's Laws section is the whole record.
- What still gates a change:
./tests/run.shbefore commit, the deployer's per-service health gate, and the Playwright e2e gate. Those are the fleet's evidence. A bench is not evidence of correctness and buys nothing a swap needs.
When a bench IS ordered
STOP the production service first — these servers serve real users, and a
bench beside a live service starves the GPU and corrupts the result. That
covers any GPU experiment, not just benchmarks, including a bare
2-rank RCCL/torch.distributed probe: on 2026-09-16 one was run against a
live service "because it only needs 200 MB of VRAM" and the host went hard
down ~20 minutes later (no ARP reply, 100 % ICMP loss, power cycle required;
a hung AQL dispatch wedging the driver is the plausible mechanism). Telling
an off machine from a wedged one: ip neigh show <ip> from a peer — FAILED
means off or NIC dead; a merely wedged kernel usually still answers ARP.
- Stop and mask (self-healing would restart it):
sudo systemctl stop frank-dragoon frank-zealoton Dankclaw,sudo systemctl stop frank-archonon Dankserver, thenmaskeach.maskFAILS on a relinked unit (the file in/etc/systemd/system/is itself a symlink into the checkout); stop alone is enough for the window, and the deploy restores the unit — re-deploy after benching rather than unmasking. - Bench against the service itself. No scratch ports on an LLM host — one
process owns the card. Use the runtime's own harness (
llama-bench; a live llama.cpp server also reports honesttimingsper request, includingdraft_n/draft_n_accepted— but on frank-zealot's PrismML fork build 10777 that object only reaches you on/v1/chat/completions: a greedyignore_eosrun on the raw/completionpath there answers 500 "The model produced output that does not match the expected Content-only format" (measured 2026-10-18). halogen has no/completionpath at all, so its rates are wall-clock around/v1/chat/completionsplus the boot ledger. Generate ≥128 tokens: at 8,inter_tokenis first-token noise and run-to-run spread swamps every real difference. - Unmask, start, verify
curl http://192.168.0.<host>:7770/health. Retire every bench drop-in the same session (systemctl show <svc> -p DropInPathsmust come back empty) and write the numbers into the file Frank named —/tmpand the journal are not records.
Instrument traps that survive from the era when this was routine:
gufo bench cannot rank speculative decode and inverts the ranking (its
corpus reports acceptance=0.933 ± 0.000 with an identical trace sha256
across draft widths — the same predictable text every run); llama.cpp's
Server-Timing carries the unit in the name half (ttft;dur=402.485),
so splitting on = keys the dict ttft;dur and every lookup silently
returns nothing; halogen 0.16.2 answers /completion, /ping, /props and
/tokenize with 404 and sends no Server-Timing; and the first request
after a boot is a warmup artifact (measured 2.6 t/s prefill then 1,258 t/s on
the next), so never report request #1.
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.
Documentation Policy (fleet law, 2026-10-18)
One fact, one owner. A page owns exactly one thing — an endpoint, a
service, or a technique — and every other file links to it with [[Page]].
Restating a shape, a digest, a port list, or a measured number in a second
file is a defect: it is the next thing to go stale.
- One page per endpoint, forever:
serverAppConfigs-docs/LLM/<endpoint>.md— current shape, an Eras table (window | payload | runtime | shape | replaced because | rollback), the Laws the endpoint taught, the wire contract, and the files that own it. A payload swap edits the shape block and appends one Era row. It never creates a page, and the retired era's page goes to.AGENTTRASH/once its rollback path and its laws are in the table. log.mdrecords incidents and laws — anything with a causal story (why the host went down, why a knob resets at reboot, why the RAM was the fault). A model swap is not an incident: it is an Era row and nothing else.AGENTS.mdcarries operator-critical numbers only — port, context ceiling, slot shape, unit/env/serve trio — plus the cross-cutting laws.- Index pages are locators: one row, one clause, no numbers.
- A measured number is written once: in the env-file header beside the serve script that consumes it, and in the endpoint page. Every other page cites it by link.
- Technique pages (spec decode, KV math, offload budgets, PP bandwidth) stay technique pages; they stop carrying per-endpoint throughput tables.
Model Config (harness)
The harness reaches the fleet through .omp/agent/models.yml in the
operator's home — /home/blank/.omp/agent/models.yml on the dev box, which is
where the harness runs; there is no copy on the servers. Each provider is one
HOST:PORT; each model id is the served alias. Official model ids are
the StarCraft unit names (2026-10-08), on the wire since 2026-10-09, and
since 2026-10-10 they are also the SERVICE names: frank-archon →
192.168.0.235:7770 (FRANK_ARCHON_SERVED_NAME), frank-dragoon →
192.168.0.143:7770 (HALOGEN_MODEL_ID), frank-zealot →
192.168.0.143:7771 (ALIAS). The 2026-10-10 cutover renamed the units,
scripts, env files, containers, deploy services and runbooks to match —
the three former service names are fully retired and exist nowhere in
the repo; historical text in runbooks uses the current names. Verify a
rename on /v1/models, never
on a chat response: radiance echoes the requested model name, so a 200
proves nothing about the served id (llama.cpp and halogen ignore it).
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. Two
ports on one host need two providers — the file cannot express "same host,
different port".
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 provider the file does not define fails at call time. As
of 2026-10-06 all three endpoints accept images (input: [text, image]) — frank-dragoon returned to vision with the flash-server v2 swap
(the v2 vision tower loads beside the checkpoint; the text-only halogen
27B window closed 2026-10-06) — while
frank-archon takes them natively (radiance runs the checkpoint's
27-layer Qwen-VL tower — patching, pad-token expansion and 3-part rotary
are the engine core; --mm-max-patches available),
and frank-zealot through
the publisher's own Q8_0 mmproj passed as --mmproj — Q8_0 is the
publisher's requirement for concurrent image requests (the card states the
BF16 tower can crash llama-server when several image requests run at once,
and this endpoint runs several slots). frank-zealot's contextWindow is
262144 — the PER-SLOT window (--ctx-size 524288 --parallel 2 since the
2026-10-18 reslice to exact f16 KV), which EQUALS the model's
native window; llama.cpp answers a longer prompt with a 400 naming the limit,
never a truncation, so the harness must compact before it.
Note also
that
the reasoning channel differs by runtime: llama.cpp AND SGLang (qwen3
parser, live-verified) emit reasoning_content, vLLM emits reasoning —
models.yml sets reasoningContentField per endpoint to match.
There is no audio
input anywhere in the fleet: that capability left with the FLM NPU
endpoint on 2026-09-11, returned 2026-09-17 with the Gemma4 projector's
audio encoder, and left again with the Gemma4 retirement on 2026-10-01
(neither the MiMo nor the Qwen3.5 tower carries audio).
Each endpoint has ONE page in the vault: shape, Eras, Laws, wire contract, rollback. The numbers below are only what a consumer or a deploy gate needs; the env file beside the serve script is the authority, and a page that disagrees with it is the bug.
frank-dragoon — halogen flash-server, Dankclaw iGPU, :7770
- Owns:
scripts/frank-dragoon.env,scripts/serve-frank-dragoon.sh,scripts/frank-dragoon.service, containerfrank-dragoon-halogen, image digest-pinnedghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84…. Engine stdout (the boot ledger: weights, KV pool, clamp) goes to/home/dank/logs/frank_dragoon.log, NOT the journal. - Shape:
HALOGEN_CTX=200704is the per-request ceiling (a longer prompt is a 400, never a truncation);HALOGEN_KV_SLOTS=2over ONEHALOGEN_KV_POOL_POSITIONS=401408pool — pool positions buy concurrency, the per-request window never moves;HALOGEN_SPEC_ADAPT=0keeps the MTP drafter always armed (the adaptive default disarms it and halves decode); server sampler 1.0/0.95/20. - Harness contract:
models.ymlcontextWindow: 155648= ceiling − 45,056. The clamp ismin(45056, window − est − 4096)and the chars/4 estimate under-counts, so a harness window equal to the ceiling makes every long request a guaranteed 400. Reasoning arrives inreasoning_content; the template rejectsreasoning_effortoutsidexhigh|medium|low; images are OpenAIimage_urlparts (probe ≥256 px);response_formatunsupported;/completion,/ping,/props,/tokenizeare 404. - Page: frank-dragoon.
frank-zealot — llama.cpp (PrismML fork), RTX 3090, :7771
- Owns:
scripts/frank-zealot.env,scripts/serve-frank-zealot.sh,scripts/frank-zealot.service; fork buildllama-prism-b10777is the ONLY loader (the payload carries the fork's custom PQ2_0 tensor type plusprism.hadamard.*dequant metadata — stock llama.cpp refuses it, and a loader ignoring that metadata dequantizes wrong); payloadBonsai2-9B-MTP/…PQ2_0.gguf+ the publisher's Q8_0 mmproj (the BF16 tower can crash the server with concurrent image requests). - Shape:
--ctx-size 524288 --parallel 2= two conversations at the model's native 262,144,CACHE_TYPE_K/V=f16, batch 4096 / ubatch 2048,SPEC_TYPE=none,--flash-attn on,--jinja,--metrics. - Harness/gate contract:
contextWindow: 262144is the per-slot window (n_ctx_slot = CTX_SIZE / PARALLEL), and llama.cpp answers a longer prompt with a 400 rather than truncating.--metricsis mandatory or Prometheus reads 501 = DOWN beside a healthy service. Presence penalty has no GGUF metadata key, so it must reach argv. This endpoint is the fleet's compaction hub. - Page: frank-zealot.
frank-archon — radiance, two R9700s TP=2, Dankserver :7770
- Owns:
scripts/frank-archon.env,scripts/serve-frank-archon.sh,scripts/frank-archon.service,scripts/ttm-preflight.sh, templatescripts/qwen38-froggeric-v22.5.jinja; image digest-pinnedstilldeadcode/radiance@sha256:4c3f4845…; container name = unit name. - Shape: TP=2 over
wht6,--max-num-seqs 3,--max-model-len 200000,expert_tieredplacement, 44,928 MiB pinned host pool per rank, VRAM pair--vram-weights-mib 25600 --vram-kv-mib 5120(ONE budget: weights + kv ≤ 30,969 MiB/card),fp8KV, MTP depth 2, MNBT 2048, prefix cache 8 GiB host- 64 GiB disk, n-gram table pinned to RAM.
- Harness/gate contract:
models.ymlcontextWindowMUST quote 200,000 (over-length is refused, never truncated); the ~147 GiB pinned-RAM ledger is why this is a root unit, andttm-preflight.shraisesttm.pages_limititself because the kernel resets that knob to half of RAM on every boot;/healthbinds only after the full load, so a bare health gate is honest. - Page: frank-archon.
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. - That wall was physical, and it was fixed on 2026-09-20 by a board swap.
Both paths that put two cards on CPU root ports were: an M.2→PCIe x4 riser
into a CPU-attached M.2 slot, or a board whose CPU lanes bifurcate to
x8/x8. dankserver took the second: ASUS ProArt B850-Creator WiFi NEO,
PCIEX16_1+PCIEX16_2at x8/x8 Gen5 on root ports00:01.1/00:01.3, every bridge nowRouting+and both endpointsReqEn+— the exact precondition the old test plan named. Bandwidth was never the constraint (a 27B TP=2 allreduce at batch 4 is ≈10 MB per token-step, ≈1.4 ms at Gen4 x4 and under 0.4 ms at Gen5 x8). Proven on the same day. vLLM's R4D one-shot allreduce runs TP=2 here and frank-medium serves on it; llama.cpp's RCCL path was built too (GGML_HIP_RCCL=ON) and its tensor mode measured — real collective, slower shape. Note the collective choice matters: TheRock's RCCL builds lack gfx1201 device code (invalid device function), which is why R4D is the path vLLM uses at all. Evidence matrix and the measured three-arm verdict: Dual-R9700-Cooperation. - 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. - The software path that puts both cards on ONE model without a collective:
llama.cpp's layer split. On the old board vLLM could not do it in any of its
parallelisms — TP needs an allreduce, PP needs the same p2p primitive, and both
died on the card that could not dispatch an RCCL kernel.
--split-mode layerin llama.cpp needs no collective: layers are distributed in order and only the boundary activation crosses PCIe, once per forward pass. Measured here: 1991 t/s prefill / 18.8 tok/s decode layer-split, against 272 / 26.3 tensor (butterfly fallback). This was whatfrank-mediumran until 2026-09-20, when the board swap made collectives legal and radiance TP=2 beat it on every axis (95.8 vs 45.7 t/s decode, 0.21 vs 0.72 s TTFT, 259 vs 72 t/s burst-4) — see frank-medium-radiance-mxfp4; the llama shape is the rollback target frank-medium-llamacpp-dflash2. - Layer split is also the cross-vendor answer — and
-otis what cannot be combined with it. On Dankclaw (2026-09-16) a CUDA 3090 + Vulkan iGPU layer split ran a real two-device graph:-dev CUDA0/Vulkan1 -sm layer -ts 0.15/0.85held 14,994 MiB on the 3090 and gave 27.2 tok/s with 268.5 t/s prefill at 64k depth (iGPU alone: 25.8 / 231.8). What does NOT work is per-tensor placement across devices:--override-tensor <group>=<device>setshas_tensor_overrides(), a REQUIRED term of the scheduler'spipeline_parallelflag (llama-context.cpp:433 — the only place in the tree that reads it), and that flag is what setsn_copiesinggml_backend_sched_new; withn_copies == 1a pre-allocated weight on a non-primary device has no backend and the graph aborts (ggml-backend.cpp:941, reported with the leaf's own op name(NONE)).=CPUis the only override target that works, because the CPU backend is last in the list. Pure Vulkan changes nothing here — the gate is the override, not the driver mix. See x4-pair-experiment. - A host↔3090 link at x4 measures 6.72 GB/s, and that is a design
constraint, not a detail.
pcie-link-test.cuon Dankclaw (2026-09-16, services stopped): pinned H2D 6.72 GB/s, D2H 6.60 GB/s — 85 % of the Gen4 x4 ceiling — with async no better than sync and pageable within 1 % of pinned, i.e. the copies are link-limited rather than staging-limited (the usual "pageable costs 2x" rule does not apply here). The card is x16-capable and the Strix Halo port beneath it is x8-capable (LnkCap: 16GT/s, Width x8on00:03.1), so the x4 lives in the adapter or cable; the link idles at 2.5 GT/s x4 and trains up under load. Consequence for any engine that streams weights across it: a 3 MiB slab costs 474 us, and a 111 GiB A3B model needs ~1.70 GiB of active experts per token ⇒ 3.7 tok/s (4.6 with a 20 % resident cache), against 20.46 tok/s for the same model with the experts computed in host RAM by llama.cpp (-ot exps=CPU, whence the link carries activations only) and 25–26.5 on the iGPU. Streaming over PCIe loses; computing in RAM does not care. FreeToken — expert LRU in host RAM, exactly this streaming shape — also loads only modelopt block-FP8 safetensors forqwen3_5_moe, so a Q4_K_M GGUF is unloadable there before bandwidth is even the question. See x4-pair-experiment.
ROCm / GPU
- Layer split needs no collective — layers go in order and only the boundary activation crosses PCIe, so it is the fallback wherever a collective cannot be dispatched (and on an RCCL-less fork it beats tensor mode, which falls back to a butterfly allreduce). Pin the spec-decode drafter to one device: an unpinned draft under tensor mode aborts the KV build, and on a split its home is the card holding the target's last layers. Deployed history: frank-medium-llamacpp-dflash2.
-
- **Bundling a ROCm runtime next to a llama.cpp binary:
cp -r SRC DST/is a - trap.** With a missing
DST, cp createsDSTas a copy ofSRCand drops - one directory level. rocBLAS and hipBLASLt look for their device code at a
- fixed depth (
../lib/rocblas/library,../lib/hipblaslt/library/<arch>), - and the flattened copy fails at request time, not load time: the engine
- reads the weights, answers
/healthand dies on the first completion with - `Cannot read .../TensileLibrary.dat: No such file or directory for GPU arch
- gfx1201`. Declare the destination, then copy the contents.
- **Bundling a ROCm runtime next to a llama.cpp binary:
- 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 GTT aperture (~114 GiB), not the
rocm-smi "dedicated" figure. The Radeon 8060S (gfx1151) reports
116,732 MiB (
llama-server --list-devices;gtt_total=122402963456in/sys/class/drm/card1/device/). The older "64 GiB, SVM-backed" lesson was already stale — the CIRU Orca predecessor held 92.6 GiB of weights on the iGPU.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-dragoon — 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. - A sysfs raise is a countdown to the next reboot.
ttm.pages_limitdefaults to half of RAM, so the manual raise to 200 GiB (2026-10-05) vanished with the 2026-10-12 23:17 POWER FAILURE on dankserver: the cap fell to 125,988 MiB, frank-archon's pinned-ledger preflight (floor 150,000 MiB) refused the start, andRestart=alwaysre-ran that refusal 6,919 times in ~20 h — a unit inactivating (auto-restart)that exits 1 in 50 ms with no container and no engine log is a PREFLIGHT refusal, not an engine fault. Repo law now:scripts/ttm-preflight.sh(sourced byserve-frank-archon.sh) raises the knob toFRANK_ARCHON_TTM_TARGET_MIBitself, re-reads, and refuses by name only after a write the kernel rejected. Never prescribe a manual sysfs write as the recovery procedure for a service that can boot before a human reaches it. - Silent RAM corruption outranks every software theory. frank-archon's ~40k-token output corruption (2026-10-04/05, repetition/garbage in long agent conversations) was marginal DDR5 at 4800 MT/s on four DIMMs, fixed by 4600. Consumer non-ECC memory raises no MCE for a silent flip, and a short in-band pattern check (60 s, 8 GiB of 246 GiB) is not a stability proof — combined, those two instruments said "ruled out" while the RAM was the cause. "4800 is the JEDEC floor on 5600-rated DIMMs" is a rating, not a margin guarantee. When the fault is data-shaped (NaN states, garbage tokens) and a software revert doesn't move it, price in the RAM before the runtime — and remember a revert tested under the same marginal hardware is itself confounded. See the runbook's 2026-10-05 section.
Runtime-specific guards
- Rootless podman cannot RAISE rlimits, and
--ulimit nofile=1048576dies at container create, not at runtime. runc reportserror setting rlimit type 7: operation not permitted(type 7 = RLIMIT_NOFILE) when the requested hard limit exceeds the host process's hard limit — on Dankclaw that ceiling is 524288. Inherited-equal limits are fine, somemlock=-1:-1works iff the unit grantsLimitMEMLOCK=infinity(systemd is privileged; the value flows through podman to runc without any raise). Upstream halogen's own run lines pass NO--ulimitat all — copy vendor run lines before adding flags (measured 2026-10-05 on the frank-dragoon cutover). - 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. - A GGUF's custom tensor type picks the runtime — read the histogram before
promising a file to a loader.
Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.ggufis ftype 115 with 311 tensors of type 103 (GGML_TYPE_Q8_0_ROCMFPX); stock llama.cpp and gufo refuse it at load, so ROCmFPX was not a preference but the only door. Counting tensor types out of the GGUF KV section takes one python read and settles a runtime argument in minutes. The build identity matters as much as the fork: the 2026-08-25 ROCmFPX builds SIGSEGV inggml_compute_forward_set_rowson this same file, soscripts/build-rocmfpx.shpins commit36e9acdby SHA. - Speculative-decode flags move as a SET. Handing the ROCmFPX fork
--model-draftwith no--spec-type(the naive way to build a "no MTP" bench arm) segfaulted at load, core dumped, no diagnostic (2026-10-02). A baseline arm must strip--model-draftAND every--spec-draft-*AND--spec-type, or it measures a crash instead of the absence of drafting. - An orphaned scratch server taxes the whole host. A bench child left
alive by a client-side exception kept ~26 GiB of weights resident on a
121 GiB box, pushed
availableto 3 GiB, and every later rate was suspect. Record the child pid and kill it in afinally, then provefreecame back before believing a number. Kill by pid — neverpkill -f llama-server, which also hits frank-zealot.
llama.cpp serving
- MTP is a property of the FILE, not of the flags. The
-mtpGGUF build carries the head asblk.<n>.nextn.*(qwen35.nextn_predict_layers = 1);--spec-type draft-mtpthen setsload_mtp, and the draft context runs on the MAIN model's weights (shares_model), so there is no second copy of a 12 GiB model in VRAM and no-mdargument. The plain build has no MTP tensors at all, and pointing--model-draftat the file would load it twice. - What can be chained, what cannot, and what a chain costs on a hybrid.
--spec-type a,bbuilds every enabled implementation and chains drafting through them, in a fixed internal order (ngram types first, then the drafters — CLI order is irrelevant). The gate isctx_dft: draftless types (the ngram family) are added unconditionally, whiledraft-mtp,draft-dflash,draft-dsparkanddraft-eagle3all require a draft CONTEXT. So a chain may hold one context-backed drafter plus any number of ngram types —draft-dflash,ngram-modis legal and measured (2.6x the drafter alone on prompt-echo traffic, byte-identical to it on prose at the fleet's 48/64 window) — butdraft-mtp,draft-dflashdies at load: "context type MTP requested but model doesn't contain MTP layers", because MTP builds its context against whatever model the draft context holds. Two context-backed types cannot share one process, and MTP's head only loads when MTP is the draft model. A LEGAL chain can still lose on a recurrent model:draft-mtp,ngram-modon frank-dragoon's qwen35 hybrid measured −15 % prose / −7 % block-copy (2026-10-03) — adding the ngram layer logsrecurrent state rollback is not compatible with 'ngram-mod' - disabling rollback support, and MTP verification depends on that rollback, so acceptance drops on ALL traffic even where the matcher fires. Measure chains per architecture, never by analogy. - A drafter's KV scales with the TARGET's context, and its window policy
decides the price. The draft context is built at
llama_n_ctx(ctx_tgt)with no flag to cap it, so a drafter declaring NO sliding window caches the whole window on--spec-draft-deviceand can OOM the first draft graph capture AFTER/healthhas answered. Readsliding_windowout of the drafter's GGUF header before sizing, and prefer a skewed-tsover a context cut. Worked example: frank-medium-llamacpp-dflash2. - A draft context bills its own f16 KV over the WHOLE POOL, and on a
single-card endpoint that squeeze can cost more than the drafter wins. With
--parallel Nthe drafter pays the pool, not the slot, plus its own compute buffers. So the MTP question is never "does the file carry the head" but "what do draft KV + buffers cost against what the batch knobs leave for compute" — a per-shape answer. Measure the arm, never the flag; the booted arm table is on frank-zealot. - A quant's scale overhead belongs in its byte rate; doubling a measured figure to "get the f16 number" invents 6.25 %. q8_0 is 1.0625 B/element (32 codes + one 2-byte scale per 32-block), f16 is exactly 2.0 ⇒ the ratio is 1.882×, not 2×. Derive byte rates from element count × block layout, then check them against the boot ledger — this repo carried an invented 2× number for weeks. Current KV math: frank-zealot.
--ubatch-sizehas an OPTIMUM, not a plateau, and the curve is BACKEND-SPECIFIC — never carry it across hosts. Its compute buffers land about TWICE as heavy on the card that holds the output layers and the drafter, so a split balanced at one batch is unbalanced at another: re-measure the balance after any batch change. The logical--batch-sizeis nearly free. A large-batch allocation can fail on BUFFER CONTIGUITY while totals still fit — no totals model can see that. Ladders: frank-medium-llamacpp-dflash2 (R9700 split) and frank-zealot (CUDA 3090).- On the CUDA 3090 that same knob has no interior optimum up to 2048. A
prompt shorter than the ubatch cannot see the knob at all — that is the
control that proves the knob, not the traffic, moved the number. When a bench
IS ordered, A/B it by restarting the SERVICE through a transient
FRANK_ZEALOT_ENV_FILEdrop-in, never a scratch port: one card holds one server. Numbers: frank-zealot. - One
-tsstep is one LAYER, and a hybrid stack's layers are not the same size (a DeltaNet boundary and a full-attention boundary can differ by ~1 GiB each with its own KV), so balance by measurement. An affine fit reproduces the deployed point and overstates the gaps in between. frank-medium-llamacpp-dflash2. - ngram-mod prints no startup banner. The load log shows the drafter's
adding speculative implementation 'draft-dflash'and nothing at all for the matcher, so a chain can look unengaged while it is working. Identify it behaviourally (draft_n> 0 only where text repeats) or by the per-taskdraft acceptance = ... mean len = ...line. It costs no VRAM (a 64 MiB host-RAM hash table) and its window decides which traffic it can see: too short and it proposes on ordinary prose fragments, which costs verification without acceptance. --paralleldivides the pool, so MORE conversations cost WINDOW, and the per-slot number is the contract — notctx-size.n_ctx_slot = CTX_SIZE / PARALLEL, and llama.cpp refuses a prompt longer than the SLOT with a 400 even when the pool has room, somodels.ymlcontextWindowmust follow every reslice. Both slot directions cost or buy window, never pool; shorter slots decode slightly faster (less attention to scan, one fewer recurrent state). Measured reslices: frank-zealot.- Price a VRAM budget from two boots, never from a formula — and an mmproj
costs ~1.5× its file size (the encoder's activation and compute buffers ride
with it). Subtracting two booted rows isolates it exactly; extrapolating
weights+KV+"a bit for buffers" is what leaves 1.1 GiB free when you meant 2.7.
Then solve
KV ≤ card − non-KV − headroomand round to a multiple ofPARALLEL × 4096. The two-boot table: frank-zealot. imagesandimage_urlare NOT interchangeable: only the content part reaches the model.--mmprojloads,usage.prompt_tokensrises, and the model still invents an answer if the client used the legacy top-levelimagesfield on/v1/chat/completions— frank-zealot answered "Brown." for a solid-red PNG that way and "Red." through the OpenAIimage_urlpart. The failure is silent because a hallucinated color is a plausible answer. Probe vision with a solid color and expect an exact word.--jinjais whattoolsneeds. On 2026-09-16 a subagent died with500 tools param requires --jinja flag, from the ik_llama.cpp runtime that then served frank-dragoon: every tool-using agent on that endpoint was broken and the failure read like a model fault. Mainline llama.cpp 0.4.1 defaults--jinjaon and the serve script pins it; halogen also acceptstoolsand returnstool_calls. Check tool support before routing a role at an endpoint.- Quantized KV on CUDA needs
GGML_CUDA_FA_ALL_QUANTS=ON— the same option and the same ~12x prefill collapse described in the ROCm note above. f16 KV needs nothing; frank-zealot ran f16 for that reason until 2026-09-17, whenbuild-cudawas rebuilt with the option ON so the endpoint could take q8_0 KV. - llama-bench separates a LIST with
/and SWEEP values with,. Both-devand-tsfollow this:-dev CUDA0,Vulkan1is two single-device configurations, never one two-device run, and-ts 0.85,0.15is two sweep values each becoming[0.85, 0, …]/[0.15, 0, …], i.e. device 0 takes everything. Write-dev A/B -ts x/y. This artifact produced a wrong fleet conclusion on 2026-09-16 — "CUDA + Vulkan cannot serve one model", with two bogus OOMs that were really "all 111 GiB on the 24 GiB card" — and the bench table's owndevcolumn was printing the truth the whole time. Any placement-dependent number needs an independent check: sample the other device's memory (nvidia-smi) while the run is loaded. - A model's own template can REJECT the harness's thinking level. Qwen3.8's
template accepts only
xhigh,mediumandlowforreasoning_effortand raises on anything else — HTTP 500, not a clamp — so the harness's levels must be folded onto that set inmodels.yml(compat.reasoningEffortMap). A role whose level is unmapped fails on its FIRST request, and the failure names Jinja, not the model: every subagent onfrank-zealotdied withUnexpected reasoning effort highuntilhigh: xhighwas added on 2026-09-16. Halogen accepts the wider set, so the two entries legitimately differ. - The vision projector is NOT billed in llama.cpp's own fit check. The
engine prints
[mtmd] estimated worst-case memory usage of mmproj is 873.10 MiBand then fits WITHOUT it: frank-zealot's Bonsai-2 boot projected 21,439 MiB and settled at 22,664 (2026-10-17). Price a 3090-class shape from settlednvidia-smiafter a real boot, never frommodel + context + compute— the projector (and any drafter context) lands after/healthwould already have answered. Same failure class as the DSpark drafter OOM. - The FIRST request after a boot is a warmup artifact. Measured on Bonsai-2/3090: 2.6 t/s prefill and 37.7 t/s decode on request #1, then 1,258 t/s prefill and 54.7 t/s (per stream, two concurrent) on the next — a 3× spread on the same shape. Benchmark the second request or later, or the graph-capture cost reads as a model fault.
- Thinking tokens count against
max_tokens, so a short-budget vision probe reads as blindness. The Bonsai-2/image_urlprobe atmax_tokens: 64returnedcontent: ""withfinish_reason: "length"and half a thought inreasoning_content— the image HAD reached the model (322 prompt tokens vs 42 for the text alone). At 512 tokens the same image answers "Red". Budget ≥512 for any thinking model you probe for vision. - Quantized KV is not the only dtype law a fork build can hide. Vendor
builds carry their own compile options: frank-zealot's PrismML prebuilt
served
--cache-type-v q8_0under flash attention at the exact 34,816 B/token (theGGML_CUDA_FA_ALL_QUANTSdoor), so a build decision must be proven by the KV buffer line in the boot ledger, not by the build flags you remember passing. - A llama.cpp endpoint is invisible to Prometheus unless
--metricsis passed. Without it the engine answers 501 Not Implemented on/metrics, so the scrape fails while/healthanswers 200 andLlmEndpointDownfires beside a healthy service — that is howfrank-zealotsat red across four payload generations (the oldest retained sample ofup{job="frank-zealot"}, 2026-10-11, is already 0). halogen serves/metricsnatively, so the asymmetry reads as "only the 3090 is broken". Diagnose withlastErrorfrom/api/v1/targets, not the alert list, and note Alertmanager has no persistence: a reboot re-stampsstartsAton an alert that has been firing for weeks. - A leftover bench drop-in silently hijacks the next deploy, and no health
gate sees it. The sanctioned bench-window method — a transient
Environment=FRANK_<SVC>_ENV_FILE=/tmp/<arm>.envdrop-in, the arm envsourceing the repo env and then overriding it — outlives the bench unless it is retired. On 2026-10-18 a leftoverfrank-zealot.service.d/ptq1.conf— the arm Frank had ordered at 02:36 and measured from 03:14 — made the cutover deploy boot a different model at a different shape (3 slots of 133,888, not 3 × 262,144) while/healthanswered 200,/v1/modelsechoed the alias (a flag, not a fact) and the Playwright gate passed 33/33. After deploying any recently-benched LLM service:systemctl show <svc> -p DropInPaths(must be empty), thentr '\0' ' ' </proc/$(pidof llama-server)/cmdline, then the boot ledger — and retire drop-ins to~/.AGENTTRASH/<date>-<topic>/+daemon-reload. - A bench verdict that never reaches the repo is indistinguishable from a
rogue arm. Same window: PTQ1_0 served production for 24 minutes with real
numbers (~48 t/s decode, 801 t/s prefill at 141k), but the figures lived only
in
/tmpenv files and journald, so the next agent on the host read the surviving drop-in as an unfinished experiment and reported a hijack. Write the measurement into the runbook and the arm script's header in the session that takes it —/tmpand the journal are not records.
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 with GPU access (
/dev/kfd+/dev/driviaAddDevice,GroupAdd=keep-groups, plus the udev rule that makes those nodes world-writable — user-unit spawns carry no supplementary groups). It must NOT pass--cpu: the gfx1151 ROCm wheels exist and the unit's--cpuera ended 2026-09-16, when a stale device-less container was found restarted 9,146 times (RuntimeError: No HIP GPUs are available). - SearXNG outbound traffic must stay on the Gluetun tunnel; do not move its ports off the gluetun service.
- Dankclaw boots
iommu=ptsince 2026-10-18. The boot line is tracked indankclaw/host/gruband moves ONLY throughscripts/set-dankclaw-bootline.sh(diff, install,update-grub, verifygrub.cfg,--reboot) — never by hand-editing/etc/default/grub. It replacedamd_iommu=off(2026-09-11), adopted on the vendor's 13–16 % prefill figure and never measured here: the local A/B on byte-identical traffic says identity mode costs nothing (+2.0 %, 1,616 t/s cold prefill), so the XDNA2 NPU returned for free —amdxdnabindsc6:00.1(PASID width 10, identity group 26) and the olderr=-19only ever asked for an enabled IOMMU. AMD PerfOpt (iommu tree 2026-09-24, Linux 7.4) still skips the page walk outright for the privileged iGPU, but it arms only in the identity domain and 26.04 caps at7.0.0-38. - 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). - A dead rootless podman pause process breaks ALL container management on the
host, and
podman system migratepanics in 5.7.0. Symptoms: everypodmancommand fails withinvalid internal status … could not find any running process, every container start fails binding podman's bridge addresses (Error starting server failed to bind udp listener on 10.89.x.1:53: Cannot assign requested address), and everyRestart=alwaysquadlet loops. Container storage is intact; what is gone is/run/user/<uid>/libpod/tmp/alive(a UUID token, not a pid) and the rootless netns holding the bridges — deleting the stale token is not enough, andpodman system migratesegfaults inteardownNetworkBackend. Repair: reboot (/run/userclears and all 28 quadlets return on their own — checkLinger=yesandWantedBy=default.targetfirst). Seen 2026-09-16, hours into heavy GPU work on the same host — but the trigger is NOT established (journalctl -krecorded no OOM kill; the kernel had no other word on it). Treat the pause process as host state that can die for its own reasons, and reach for the reboot when it does. - A container cannot reach a
Network=hostservice at the host's LAN IP. netavark DNATs published ports only: from a bridge container192.168.0.143:<published>answers while192.168.0.143:<host bind>and the gateway10.89.1.1are both refused. Usehost.containers.internal(169.254.1.2). This is what 502'dhermes.franksdank.clouduntil the Caddyfile upstream was changed on 2026-09-16 (its vhost comment had claimed the LAN IP was open from that bridge — it never was). - A
*.gguffilter misses mradermacher's payloads: large quants ship as.partNofMfragments.mradermacher/Qwen3.8-Flash-Next-Uncensored-i1-GGUFcarries Q4_K_M (37.00 + 37.00 + 36.97 = 110.97 GiB) and Q4_K_S (35.00 + 35.00- 34.09 = 104.09 GiB) as parts, while the smaller rungs (Q2_K, Q3_K_, IQ3_,
IQ4_XS) are single files. Fetch the parts, verify each against the Hub's LFS
sha256, concatenate in numeric order — that is how the deployed 111 GiB file
was assembled. Listing the Hub tree and filtering
endswith(".gguf")makes Q4_K_S look like it does not exist.
- 34.09 = 104.09 GiB) as parts, while the smaller rungs (Q2_K, Q3_K_, IQ3_,
IQ4_XS) are single files. Fetch the parts, verify each against the Hub's LFS
sha256, concatenate in numeric order — that is how the deployed 111 GiB file
was assembled. Listing the Hub tree and filtering
- A fetch script must
-fand verify, or it reports success on an access error.curl -o FILE URLexits 0 on HTTP 401/403 and writes the error body as the "artifact": a gated repo yielded three 153-byte shards with identical sha256 values and a cheerfulDOWNLOAD_DONEin 20 seconds for 104 GiB. Use-f/--fail-with-body, compare each file's byte count to the Hub's declared size, and check the LFS sha256 —scripts/download-frank-medium-gguf.shalready does all three; ad-hoc fetches tend not to. Gated repos (e.g.orcarouter/Qwen3.8-Flash-Next-Uncensored-GGUF, same 104.09 GiB Q4_K_S) return 403 even with the host's HF token until that account accepts the gate.
Runbook Index
One page per live endpoint; every payload swap it has ever run is a row in that page's Eras table, with the rollback path in the same row. There is no per-attempt page and no per-swap ledger anywhere else.
| Service | Page |
|---|---|
frank-archon — radiance, 2× R9700 TP=2, Dankserver |
serverAppConfigs-docs/LLM/frank-archon.md |
frank-dragoon — halogen flash-server, Dankclaw iGPU |
serverAppConfigs-docs/LLM/frank-dragoon.md |
frank-zealot — llama.cpp (PrismML fork), RTX 3090 |
serverAppConfigs-docs/LLM/frank-zealot.md |
frank-tts — cloned-voice speech, Dankclaw |
serverAppConfigs-docs/Dankclaw/Frank TTS.md |
Dankclaw kernel posture (iommu=pt since 2026-10-18) |
serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md |
| Deployment flow + incidents | serverAppConfigs-docs/LLM/deployment.md |
Retired endpoints (not payloads) keep their own pages — frank-medium,
frank-npu, the pre-name Dankclaw/Dankserver eras — because no live page owns
them; serverAppConfigs-docs/index.md is the locator for every page in the
vault. 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. |
| Changing a model/payload | Swap it — edit the trio, deploy, health + one chat/tool/vision probe. No benches unless Frank orders one; if it misbehaves, revert. |
| Writing a doc | One owner per fact — edit the endpoint/service page; everywhere else links. Never restate a number. |
| Benchmarking (only when ordered) | 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. |