refactor(skills): consolidate managed skill library 94 -> 25

The managed-skill library was regenerated: 94 narrow per-incident skills
replaced by 25 broader ones (radiance-*, llamacpp-*, homepage-*, fleet/
endpoint ops). Git renders this as mass delete + mass add because whole
directories changed name; old content stays in history at 12c5e88.

Surface check: the 25 on disk are exactly the skills the harness advertises
this session.
This commit is contained in:
Frank
2026-10-09 13:17:48 -07:00
parent 94c71a016f
commit a34322aeb0
119 changed files with 740 additions and 5816 deletions
@@ -1,71 +0,0 @@
---
name: amd-multigpu-cooperation-triage
description: "Decide whether two (or more) AMD GPUs on a host can work together on one model, or must run as independent single-GPU replicas, by checking PCIe atomic-op routing on the full path, the runtime's collective needs, and the non-collective alternatives."
---
# AMD Multi-GPU Cooperation Triage
When someone wants N GPUs "working together" on one model (TP/PP), decide *feasibility first* — the answer on consumer AMD platforms is often "no, and here is the $30 fix".
## 1. Check atomic-op routing on the whole path
RCCL collective and P2P kernels carry a hidden **hostcall buffer** parameter. ROCr only supplies it when the device reports PCIe atomics, which is read **once at driver probe** from a *queried hardware property*:
```cpp
void Device::checkAtomicSupport() { // rocclr/device/rocm/rocdevice.cpp
link_attrs.push_back({LinkAttribute::kLinkAtomicSupport, 0});
if (findLinkInfo(cpu_agent_info_->fine_grain_pool, &link_attrs))
if (link_attrs[0].second == 1) info_.pcie_atomics_ = true; // no else, no override
}
```
```bash
# Endpoint capability + negotiated link, and EVERY upstream bridge
for d in <gpu-btfs>; do sudo lspci -vv -s $d | grep -E 'LnkCap:|LnkSta:|AtomicOpsCap|AtomicOpsCtl'; done
lspci -tv # is the card on a CPU root port or behind the chipset?
```
- Endpoint must show `AtomicOpsCtl: ReqEn+`.
- Every hop to the CPU must show `AtomicOpsCap: Routing+`. `Routing-` is a **read-only capability** — no software can enable it, and `setpci ... CAP_EXP+28.w=0440` does not help because the driver already consumed the value at probe.
Failure signature (never names atomics): `AMD_LOG_LEVEL=3` → `Pcie atomics not enabled, hostcall not supported` → `AQL dispatch failed!` → `hipLaunchKernel: hipErrorIllegalState` on `ncclDevKernel_Generic_4`.
## 2. Map the board before recommending anything
Read the **vendor's techspec page**, not a review: which slots are CPU-attached vs chipset, each slot's *electrical* width (a "x16 slot" can be x1 mode), and which M.2 slots the CPU owns. Free CPU-attached M.2 slots are gold: an M.2→PCIe x4 riser puts a card on the CPU root complex for ~$30 instead of a ~$300 x8/x8 board swap. Bandwidth needed for TP allreduce is small: hidden_states ≈ 10 KB per token at batch 1, ~10 MB per token-step for a 27B at batch 4 → ~1.4 ms at Gen4 x4.
## 3. Prove what the runtime actually needs (do it OFF the production host)
Bare 2-rank probe, one device per rank, with a short PG timeout so a walled op raises instead of hanging:
```python
torch.distributed.init_process_group("nccl", init_method="tcp://127.0.0.1:PORT",
world_size=2, rank=r, timeout=timedelta(seconds=25))
# probe: broadcast, all_reduce, barrier, send/recv, all_gather
```
- Expect the chipset-attached rank to fail **every** op — including `send/recv`, which is what pipeline parallelism uses, so PP=2 is not an escape from a TP wall.
- `world_size=1` always succeeds; one-card success proves nothing.
- Run the probe with a *real file* (`torch.multiprocessing.spawn` re-executes it — piping a script into `python3 -` fails with `FileNotFoundError: '/<stdin>'`).
- **Stop and mask the serving unit first** (`systemctl stop X && systemctl mask X`; mask, never disable). A hung dispatch can wedge the GPU driver and take the host down — this has happened.
## 4. Don't be fooled by "collective-free" claims
Check the gate, not the feature list. vLLM's IPC custom allreduce (`custom_ar=True` when the ops are compiled) is gated by a **CUDA-sm-keyed table** (`CUSTOM_ALL_REDUCE_MAX_SIZES = {'9.0','10.0','10.3','10.7'}`) while ROCm reports e.g. `DeviceCapability(major=12, minor=0)` — so it is off on RDNA4 by a *code* limit, and `current_platform.is_rocm()` exclusions appear nearby. Even patching it is insufficient: TP init's `broadcast`/`barrier`/`all_gather` have no non-RCCL path and die the same way. **No RCCL kernel involving the atomics-less card can launch** ⇒ anything built on RCCL (TP, PP, P/D KV transfer) is out.
## 5. The option set, ranked
| Option | Works when | Cost | Trade |
|---|---|---|---|
| CPU-attached M.2 (x4) riser | a CPU-lane M.2 slot is free | ~$30 | TP=2 with x4 bandwidth — sufficient |
| CPU x8/x8 board | board supports CPU bifurcation | ~$300+ | best bandwidth, no riser |
| llama.cpp/ik_llama.cpp `--split-mode layer` | always (no collectives) | $0 | GGUF model; loses paged attention, continuous batching, spec decode |
| Independent TP=1 replicas + router | always | $0 | cards never share one model; best throughput today |
| Patch the custom-AR table | never | — | other collectives still die |
| P/D disaggregation | ROCm KV-transfer connector + collectives | — | out on this class of host |
## Pitfalls
- A one-card collective test proves nothing (NCCL short-circuits at `world_size=1`).
- Validation must come from the peer/host doing the work: probe reachability with `ip neigh show <ip>` — `FAILED`/`INCOMPLETE` means off or NIC dead, whereas a wedged kernel usually still answers ARP.
- Validate your own probe's naming before reporting a service outage (e.g. `systemctl --user is-active gitea` vs the real unit `gitea-server`).
@@ -1,25 +0,0 @@
---
name: anubis-guard-chain-flip
description: "Runbook for moving a Caddy-fronted service behind the Anubis + Authentik embedded-outpost guard chain on Dankclaw (provider-first ordering, validate gate, verification probes)."
---
# Anubis + Authentik guard-chain flip (Dankclaw)
Move a public vhost from `reverse_proxy <app>:<port>` onto: client → TLS edge → Anubis challenge (:8923) → Authentik embedded outpost decision → return to internal `http://:8017` listener → Host-matched handler → real upstream.
## Order (never reorder)
1. **Provider first.** Create the outpost ProxyProvider before touching routes, or authed traffic hits the outpost error page instead of the app:
- Write a Python snippet to a local /tmp file that imports `authentik.providers.proxy.models.ProxyProvider`, constructs it with `name`, `external_host="https://<vhost>/"` (exact, trailing slash), `internal_host="http://caddy:8017"`, sets `p.pk = None`, then `p.save()`.
- Pipe raw: `ssh -o BatchMode=yes dankclaw 'podman exec -i authentik-server ak shell' < /tmp/x.py`. Heredoc quoting through ssh SyntaxErrors; the raw-stdin pattern is the proven one (ak shell does `exec(sys.stdin.read())`).
2. **Route flip in ONE commit/deploy**: vhost becomes `reverse_proxy anubis:8923 { header_up X-Real-Ip {remote_host} }`; inside the `http://:8017` listener add `@<svc> host <vhost>` + `handle @<svc> { reverse_proxy <upstream> }`.
3. Deploy via the repo deploy flow (commit first; deploy pushes main itself).
## Verification (all three)
- Unauthenticated probe from client: `curl -s https://<vhost>/` → HTTP 200 challenge page, grep body for challenge markers (`challenge|not a bot|anubis`). A 502 here instead of the challenge = chain mis-wired.
- Post-auth routing proof: grep the LIVE config inside the container — `podman exec caddy sh -c "grep -n -A3 <handler> /etc/caddy/Caddyfile"` — after the deploy prints its reload line; confirm the reload actually ran, don't trust exit 0.
- Outpost reachability: `podman exec authentik-server getent hosts caddy` resolves the compose DNS alias (10.89.1.x) that internal_host dials.
## Pitfalls
- Probing `http://localhost:8017/` from the host returns HTTP 000 (connection refused): the post-auth listener lives in the caddy container's netns and is not bound on host loopback. Probe inside the netns or grep the config instead.
- Non-browser clients cannot solve the JS challenge. Before flipping, audit service-to-service traffic: ingestion/push paths (e.g. Alloy → `loki:3100`) must target internal addresses; any backend datasource URL pointing at a public hostname of a guarded service breaks the moment you flip.
- Exempt-by-construction categories that must stay unguarded: the IdP itself (`auth.` — its login flow can't sit behind a challenge), git smart-HTTP endpoints (Gitea git clients), services with their own complete auth. Document the exemption in the vhost comment.
@@ -1,20 +0,0 @@
---
name: arr-auth-posture-probe
description: "Verify a self-hosted *arr-style service's real auth posture by behavioral API probes instead of config-flag inference; includes the sed-masks-a-miss pitfall and wrong-host probe pitfall"
---
# Arr-auth posture probe
Ground truth for "is this arr/gate open?" comes from behavior, not config flags. `AuthenticationMethod=None` + `AuthenticationRequired=Enabled` in `config.xml` is ambiguous on paper; on the wire it means a login screen with **zero users** — first-account-wins.
## Recipe
1. Confirm which host runs the service before probing (`podman ps` on the target, not the box you ssh'd into first — arrs live on Dankserver, chat/searxng on Dankclaw).
2. Probe an API endpoint that bypasses the SPA shell, locally and through the vhost:
`curl -s -o /dev/null -w "%{http_code}" http://<host>:<port>/api/v3/system/status`
- SPA roots (`/`) always serve a 200 shell — never conclude "open" or "gated" from `/`.
- 401 = gated; 200 + JSON = API fully open (anyone on the net can drive it with curl).
3. For config truth, dump the whole file (`podman exec <svc> cat /config/config.xml`), not a piped `grep | sed -n 2p`: **`sed -n Np` exits 0 on zero matches**, so the pipeline hides a miss and you conclude "empty field" when the grep itself failed.
4. API keys from config.xml are local-only secrets; they stop being secret the moment a vhost serves the API unauthenticated.
## Decision framing for the user
When posture = gated-with-zero-users, present it as a decision menu (None behind the front-door wall vs External/authentikarr), not a silent change — first-account-wins means whoever logs in first owns the instance.
@@ -1,30 +0,0 @@
---
name: arr-stack-api-config
description: "Configure/download-client and indexer wiring for the *arr stack on Dankserver via live API calls — schema-first, test-before-enable; covers Sonarr/Radarr v3 vs Lidarr/Readarr v1 API split and Prowlarr v2 Applications/sync model."
---
# Arr-stack API config (Dankserver)
## API version split
- Sonarr/Radarr: `/api/v3/...`; Lidarr/Readarr/Prowlarr: `/api/v1/...`.
- `PUT /downloadclient` and `PUT /indexer/{id}` take a SINGLE resource object (GET it, patch `settings`, PUT whole object back). An array body fails with `Path: $ | BytePositionInLine: 1`.
- API keys live in each container's config: `podman exec <svc> grep -o "<ApiKey>[^<]*" /config/config.xml | cut -d">" -f2`.
## Schema-first probing (validators are free documentation)
Send the FULL schema template object with fields filled; v1 validators reject partials:
- `'Fields' must not be empty` -> send full schema copy, not `fields:[]`.
- `'Base Url' must not be empty` when keys were camelCase -> PascalCase required (`BaseUrl`, `ApiKey`).
- `targetType null` -> configContract not registered in that build (this Prowlarr build has no Prowlarr-plugin indexer; integration is Torznab-family only).
- `testingPackageId` in a PUT body downgrades validation failures to warnings.
## Prowlarr v2 model (inverted from v1)
Indexers live ON THE ARR SIDE; Prowlarr syncs indexer configs via `ApplicationSync` commands (`POST /api/v1/command {"name":"ApplicationSync","body":{"applicationId":N}}`). Applications settings keys must be PascalCase or sync throws NullConfig. Torznab feed paths can answer with the SPA HTML shell when absent — verify by byte count (HTML shell ~1946 bytes, 1 ms) vs XML, not just status code.
## DB as arbiter
When endpoint shapes are opaque, stop the container and edit `/opt/prowlarr/config/prowlarr.db` with `sudo python3` sqlite directly, then start. Indexers table columns are Id/Name/Implementation/Settings/ConfigContract/Enable/Priority/Added — the enable flag column is `Enable` (not EnableRss).
## Verification rules
- `POST /downloadclient` returns the COLLECTION, not the created object — verify via GET.
- Test endpoints return verdicts in the BODY at HTTP 200 (`{}` = pass, list = fail) — judge from bodies, not status codes.
- `syncLevel` is enum ApplicationSyncLevel — omit or use int 2; string rejected.
- Command POSTs (MoviesSearch etc.) run server-side and respond only when the work finishes: curl `-m` timeouts (HTTP 000) during search/grab are NOT failures; poll queue/history afterward instead of re-firing. Warm small commands (RefreshMonitoredDownloads) answer in ~20 ms — a hang means live work in progress, not a dead API.
@@ -1,29 +0,0 @@
---
name: arr-v1-api-validator-peeling
description: "When driving Sonarr/Radarr (v3) vs Lidarr/Readarr/Prowlarr (v1 .NET) APIs over HTTP: probe endpoints by status code, peel validators one live call at a time, and treat config.xml on disk as truth — v1 rejects literal enum strings that break AuthorizationMiddleware."
---
# *arr v1 API validator peeling
Use when creating root folders, profiles, or settings via the *arr HTTP APIs, or when a v1 (.NET) arr 500-sprawls after a config edit/restart.
## API version split
- Sonarr/Radarr: `/api/v3/...`. Lidarr/Readarr/Prowlarr: `/api/v1/...` (Prowlarr port 9696, NOT 8090 — that's Jackett).
- Probe endpoints by status code first: **empty body = endpoint absent**, not a failure. v1 has no `/api/v1/profile` endpoint at all.
## Peel validators one live call at a time
POST a minimal body and let the validator error name the next required field, instead of guessing payload shapes:
- `DefaultMetadataProfileId must be >0` → that field is required; fetch real ids from `GET /api/v1/metadataprofile` (returns rows like `(1,'Standard')`).
- `'Name' must not be empty` → include `name`.
- `Quality Profile does not exist` → the id you sent doesn't exist; list real ones first (`GET /api/v1/qualitydefinition` returns the quality catalog, ids ≠ profiles).
Then POST the full body with **existing** ids: `{"name":"music","path":"/music","defaultQualityProfileId":1,"defaultMetadataProfileId":1}`.
## Auth-config pitfall (v1 .NET apps)
- `<AuthenticationMethod>None</AuthenticationMethod>` + `<AuthenticationRequired>Enabled</AuthenticationRequired>` = self-contradictory: UI Save permanently disabled, and any save throws `DryIoc.ScopedItemException` in `AuthorizationMiddleware` → 500 on every page after restart.
- The valid no-auth representation is **absence of the `<AuthenticationRequired>` key**, not the literal string `Disabled`. Fix = delete the key from `/config/config.xml` (`sed -i "/<AuthenticationRequired>/d"`), restart container.
- Tell-tale: one v1 app boots clean while siblings 500-sprawl → diff their config.xml auth blocks; the healthy one shows the correct shape.
## General rules
- Container `State=running` ≠ app serving; read `podman logs --tail 12 <c>` before theorizing.
- When a UI gate blocks saving, go under it: these apps keep plaintext XML config on disk (`/config/config.xml`) — read/edit it directly via `podman exec`.
- API keys live in each container's `/config/config.xml` `<ApiKey>`; grep them live rather than trusting docs.
@@ -1,38 +0,0 @@
---
name: authentik-claim-cache-purge
description: "Fix persistent 403s at Caddy RBAC header gates after authentik group changes: purge the embedded outpost's proxysession claim cache and verify silent re-auth"
---
# Authentik outpost claim-cache purge
Use when a user gets persistent 403s at a Caddy `header_regexp` RBAC gate (e.g. `/admin`) even though their authentik group membership is correct, and re-login did not help.
## Mechanism (authentik 2026.8, this fleet)
- The embedded outpost does NOT re-evaluate groups per request. Each completed authorize writes one row to `authentik_providers_proxy_proxysession`; `session_data` JSON holds the claims (incl. `groups`) served for that browser session until the row expires (observed TTLs 1–24 h per row).
- A session minted before the group add keeps serving the old group list → gate 403s forever until expiry.
- The 403 happens at Caddy and generates ZERO authentik events — silence in `authentik_events_event` is expected.
- Re-login does not fix it: the browser's outpost cookie re-maps to the same stale row.
- `proxysession.user_id` is null; map rows to users via `session_data->'claims'->>'preferred_username'`.
## Diagnosis (run on dankclaw)
```bash
podman exec authentik-postgres psql -U authentik -d authentik -At -c "SELECT session_data->'claims'->>'preferred_username' AS user, session_data->'claims'->>'groups' AS cached_groups, expires FROM authentik_providers_proxy_proxysession ORDER BY expires;"
```
Stale row = `cached_groups` missing the new group while repo/Caddy gate expects it.
## Fix
```bash
podman exec authentik-postgres psql -U authentik -d authentik -At -c "WITH d AS (DELETE FROM authentik_providers_proxy_proxysession WHERE session_data->'claims'->>'preferred_username' IN ('<user>') RETURNING 1) SELECT 'deleted='||count(*) FROM d;"
```
- The user's authentik django login session is untouched → next visit re-authorizes SILENTLY (no password prompt) with fresh claims.
- NEVER rotate `AUTHENTIK_SECRET_KEY` for this — it invalidates every outpost session fleet-wide.
## Verification without the user's password
Simulate their browser: delete a TEST user's proxysession rows (e.g. `qa-e2e-admin`), keep its e2e storage state on disk, then run the admin spec bypassing setup (setup ALWAYS fresh-logins, which would mask the path):
```bash
cd tests/e2e && npx playwright test --project=admin --no-deps
```
Passing with no login prompt = silent re-auth proven. `--no-deps` is required because project dependencies would rerun setup.
## Doc
Mechanism + SQL live in `serverAppConfigs-docs/Infrastructure/Authentik.md`, section "Outpost claim cache (proxysession)" (commit ea0bba3).
@@ -1,46 +0,0 @@
---
name: authentik-flow-ui-automation
description: Drive the authentik two-stage login through real browser UI for E2E tests on Anubis+outpost-guarded routes; hardened against the Lit stage re-render race and Anubis PoW budgets
---
Drive authentik's two-stage login (identification → password) through a real browser UI for E2E tests on routes guarded by the Anubis + Authentik chain (Caddy → Anubis :8923 → outpost :9000). Proven in `tests/e2e/lib/login.ts` (`akLogin`) against authentik 2026.x.
## Hard rules
1. **Fresh context per login.** Never reuse a context or page across logins, and never retry a failed login inside the same context — a half-finished flow poisons authentik's `pending_user` stage and the next login hangs on a "Continue as <user>" interstitial that never completes. Each login = new `browser.newContext()`.
2. **Real Chrome UA.** Anubis denies obvious headless UAs; set `userAgent` to a real Chrome UA in `use:` (config-level) or `launchOptions`. Headless chromium + REAL_UA solves the PoW fine.
3. **Shadow-DOM locators.** authentik stages are Lit web components; locate inputs with `page.locator('input[name=username]')` etc. (Playwright pierces shadow roots) — never `page.$`.
## The Lit re-render race (the big one)
The flow executor re-renders a stage right after mount. A programmatic `fill()` can succeed and then be WIPED by the re-render; the submit then POSTs an empty form ("This field is required") and `waitForURL` times out while the page still shows the password stage with an empty field.
Hardened sequence per stage:
```ts
for (let attempt = 1; attempt <= 3; attempt++) {
await page.fill('input[name=password]', pass);
// VERIFY the value survived the re-render before submitting
if ((await page.inputValue('input[name=password]')) !== pass) continue;
await page.keyboard.press('Enter'); // native form path; avoids button re-render races
try {
await page.waitForURL(u => !u.pathname.includes('/if/flow/'), { timeout: 30_000 });
return; // past this stage
} catch { /* maybe wiped; retry */ }
}
throw new Error(`akLogin: stage never advanced after 3 attempts`);
```
- Submit with **Enter**, not a button click — the button can be re-created between snapshot and click.
- Verify-after-fill is the load-bearing step; without it you silently submit empty forms.
- The identification stage has the same race; apply the same fill-verify-Enter there.
## Anubis PoW budgets
The first guarded-vhost hit in a fresh context runs an Anubis proof-of-work; on a cold/loaded box it can outlive default assertion budgets. Give title/URL asserts after `goto` 60 s budgets. With `COOKIE_DOMAIN` set fleet-wide (Anubis ≥ v1.27), every later guarded vhost in the same context skips the challenge (~0.5 s per vhost in the crawl specs).
## See also
- `serverAppConfigs-docs/Architecture/E2E Suite.md` — project layout, credentials model (`tests/e2e/.env.local`, git-ignored), run commands, gotcha list.
- `serverAppConfigs-docs/Infrastructure/Anubis.md` — chain behavior, v1.27 gotchas (X-Real-Ip mandatory, DENY = HTTP 200 body).
- `arr-auth-posture-probe` for non-browser API-posture checks of guarded routes.
@@ -1,34 +0,0 @@
---
name: authentik-outpost-verification
description: "Verify authentik embedded-outpost and proxy-provider state on a live host by probing from inside the container network namespace; covers meta-header routing, rootless podman observation artifacts, and 2026.x schema quirks (renamed columns, ak shell swallowing piped stdin)."
---
# Authentik outpost/provider verification (live host)
## When to use
Guarded apps behind Anubis+authentik return an error page after authentication, or you need ground truth on outpost/provider state on a host running authentik 2026.x with podman quadlet units.
## Core insight: probe from inside the network namespace
- Rootless podman gives each container its own netns; **host `ss -ltn` cannot see listeners inside other containers' namespaces**. A missing listener on the host is an observation artifact, not a fault — verify by dialing *from inside* the namespace.
- Working probe (distroless images have no shell, so `podman exec sh` fails):
```
podman run --rm --network container:<container> --entrypoint /bin/busybox docker.io/library/busybox:latest \
wget -q -T 4 -t 1 -O /dev/null -S "<url>" 2>&1 | head -8
```
- Quote URLs over ssh: the remote shell is zsh and bare `?` in query strings triggers globbing (`no matches found`).
## Meta-header routing truth
- The outpost routes by **meta-header middleware**, not URL-path lookup. A request to `/outpost.goauthentik.io/login` **without** `X-authentik-meta-outpost/-provider/-app` headers is a path that genuinely does not exist → a 404 there is *correct behavior*, not a fault.
- Probe the auth hop exactly as Caddy sends it: full meta-header set (`X-authentik-meta-outpost: <uuid>`, `X-authentik-meta-provider: <pk>`, `X-authentik-meta-app: name=<app>`). If a full-meta-header request still 404s, the outpost's URL space is not mounted in the answering process — that is the real failure point, and it means the transport is aimed at a URL space that doesn't exist there (embedded outposts mount their URL space at `authentik_host`; check the outpost row's `_config.authentik_host` before theorizing).
- Boot transience: immediately after a restart the same probe can return 503 (configure task pending); poll until the answer changes before concluding.
## Schema quirks in 2026.x
- `ak shell` is interactive-only; piped stdin is swallowed (prints import banner, discards output). For live DB truth use:
```
podman exec authentik-postgres psql -U authentik -d authentik -x -c "select * from authentik_outposts_outpost"
```
- Columns are renamed vs older versions: `Provider.objects.filter(type=...)` raises FieldError (type moved to subclass tables); the outpost `enabled` column may not exist under that name; provider pk is `oauth2provider_ptr_id`. **Query with `select *` and stop guessing column names.**
- `podman inspect --format "{{.Config.NetMode}}"` errors on this podman version — the field is `.HostConfig.NetworkMode`.
## Decision fork when the auth hop 404s
A) point the guarded apps' auth hop at where auth actually lives (the vhost matching `authentik_host`), or B) flip the outpost to standalone (own container, own URL space, new quadlet unit + `AUTHENTIK_TOKEN`). This is an architecture decision for the user — present both with blast radius, don't pick silently.
@@ -0,0 +1,42 @@
---
name: authentik-provision-fleet-user
description: "Provision or audit an Authentik user on Dankclaw (auth.franksdank.cloud): ak shell ORM drive, group set, behavioral login verify, stale-claim caveat."
---
# Provision an Authentik user on Dankclaw
Source of truth: `serverAppConfigs-docs/Infrastructure/Authentik.md` ("User provisioning playbook"). Authentik objects are NOT repo-managed — no deploy, no file edits.
1. Inspect first, mirror the newest human user's groups:
```bash
ssh dankclaw 'podman exec -i authentik-server ak shell' <<'EOF' 2>&1 | sed -n '/objects imported/,$p'
from authentik.core.models import User, Group
for u in User.objects.all().order_by("username"):
print(u.username, u.email, sorted(u.groups.values_list("name", flat=True)))
print(sorted(Group.objects.values_list("name", flat=True)))
EOF
```
2. Create (idempotent guard; password goes in the heredoc = stdin, never argv, so it never lands in `ps`):
```python
from authentik.core.models import User, Group
u = User.objects.filter(username="X").first()
if not u:
u = User(username="X", email="X@franksdank.cloud", name="X", is_active=True)
u.set_password("...")
u.save()
for g in ("Non-Admin Users", "jellyfin_users", "openwebui_users"): # human-user precedent: ridley
u.groups.add(Group.objects.get(name=g))
```
3. Verify through the exact PasswordStage code path:
```python
from django.contrib.auth import authenticate
assert authenticate(request=None, username="X", password="...")
assert authenticate(request=None, username="X", password="nope") is None
```
## Rules
- No `User.create_user`; `is_superuser` is a group-derived property, not a constructor kwarg (raises `TypeError`). `path` defaults to `users`.
- Guarded-vhost access = any authenticated user (all proxy providers use implicit-consent); groups are consumed downstream: `homepage-admins` (Caddy `/admin` header gate), `openwebui_users`/`_admins` (roles claim), `jellyfin_users`/`_admins` (LDAP outpost). EXCLUDE admin groups from a plain user; ask before granting `homepage-admins`.
- Group add after a login → Caddy RBAC can 403 on the outpost's cached claims; purge that user's rows: `DELETE FROM authentik_providers_proxy_proxysession WHERE session_data->'claims'->>'preferred_username' = 'X';` (in the `authentik-postgres` container) — next visit re-authorizes silently.
- Jellyfin visibility rides the LDAP outpost cache; expect a lag.
- Look up flows by slug, not name. Do not rotate `AUTHENTIK_SECRET_KEY` to fix claim issues.
@@ -1,52 +0,0 @@
---
name: authentik-proxy-provider-app-link
description: "Diagnose and fix an authentik embedded-outpost proxy provider that never authenticates (login wall / raw flow served on the app host): the provider must be linked to an Application, have a non-null invalidation_flow, and an exact external_host match."
---
# authentik proxy provider not authenticating (login wall)
Symptom: a service behind the authentik embedded outpost (Caddy -> Anubis -> outpost -> app) never lets an authenticated user through. The browser is parked on a login page served ON THE APP'S OWN HOST, e.g. `https://app.example.com/if/flow/default-authentication-flow/?next=/` or `https://app.example.com/flows/-/default/authentication/`. The IdP session cookie lives on `auth.example.com` and is never sent to `app.example.com`, so login is demanded forever.
## Root causes (check in this order)
1. **Provider not linked to an Application (most common).** The embedded outpost's config API filters `ProxyProvider.objects.filter(application__isnull=False)`. A provider created with `ProxyProvider.objects.create(...)` and no Application is never returned to the outpost, so the outpost never loads it and the request falls through to raw flow serving.
- FIX: create an Application bound to the provider:
```python
app = Application.objects.create(name="Marreta", slug="marreta", launch_url="https://marreta.franksdank.cloud")
app.provider = proxy_provider; app.save()
proxy_provider.application = app; proxy_provider.save()
```
2. **Null `invalidation_flow`.** The Rust outpost serde requires a formatted UUID; a null value aborts `get_outpost` with `invalid type: null, expected a formatted UUID string at line 1 column N` and takes down EVERY provider on that outpost.
- FIX: `proxy_provider.invalidation_flow = InvalidationFlow.objects.get(name="default-provider-invalidation-flow")`.
3. **`external_host` mismatch.** Must equal the Host header the outpost sees. Trailing-slash mismatch (`https://x` vs `https://x/`) = no match. Match the fleet convention (no trailing slash).
4. **Missing `authentication_flow` / `authorization_flow`.** Set to the fleet defaults (`default-authentication-flow`, `default-provider-authorization-implicit-consent`).
## Diagnose
```bash
# 1. Compare broken vs working provider field set (ak shell, as the authentik user):
sudo -u authentik AUTHENTIK_POSTGRESQL_HOST=127.0.0.1 AUTHENTIK_POSTGRESQL_PORT=5432 \
AUTHENTIK_POSTGRESQL_NAME=authentik AUTHENTIK_POSTGRESQL_USER=authentik \
AUTHENTIK_POSTGRESQL_PASSWORD='<pw>' ak shell
```
```python
for p in ProxyProvider.objects.all():
print(p.name, p.external_host, bool(p.application), p.authentication_flow_id, p.authorization_flow_id, p.invalidation_flow_id)
```
```bash
# 2. The serde error (this is the smoking gun for cause #2):
journalctl --user -u authentik-outpost.service --since '-10 min' | grep -iE 'error|invalid|panic'
# 3. Confirm the outpost loaded the provider after the fix:
journalctl --user -u authentik-outpost.service --since '-2 min' | grep -i 'loaded app'
```
## Apply + restart
After any provider/application change: `systemctl --user restart authentik-server.service authentik-outpost.service` (rootless, user dank on Dankclaw). Then verify the OAuth dance:
```bash
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://app.example.com/
# expect 302 -> /outpost.goauthentik.io/start?rd=... (NOT a raw /flows/ URL)
```
## Provenance note
On the Dank fleet these providers are NOT repo-managed (no blueprint). Fixes applied via `ak shell` are live-only. Capture any new provider+application as a repo blueprint or setup script to avoid drift.
@@ -0,0 +1,76 @@
---
name: autoresearch-llm-bench-window
description: "How to run an iterative config-tuning window on a Dank-fleet LLM endpoint: harness pattern, noise-floor-first discipline, bracket mapping, and the frank-primary laws measured in the 2026-10-06 segment."
---
# Autoresearch bench window for a fleet LLM endpoint
Use when running (or re-running) an iterative config-tuning segment against a
Dank-fleet LLM endpoint (frank-primary/frank-large/frank-small): build the
harness, establish the noise floor, then bracket-map one flag at a time.
## Harness pattern (already built for frank-primary — reuse, don't rebuild)
Committed at `autoresearch.sh` (dev box) + `scripts/autoresearch-frank-primary.sh`
(dankserver) in serverAppConfigs. The pattern generalizes to any fleet endpoint:
1. Dev-box wrapper scp's the candidate env file + bench script to the target
host, then ssh-runs the bench. Never push the candidate config to origin
(the scp dirties the host checkout on purpose).
2. Host script, in order: stop the production unit (relinked units: `systemctl
stop`, NEVER mask — mask refuses a symlinked unit file) → boot a scratch
container (distinct name, e.g. `arbench`) on the SAME devices with the
candidate env — mirror the production podman contract exactly, differing
only in container name and a FRESH prefix-cache tier (wiped per run) so cold
measurements stay cold → poll the health endpoint until it answers (the
listener binds AFTER the full load, so a bare curl is honest; allow ~2-3 min
cold boot, 720s timeout) → run ONE self-contained deterministic request →
parse the engine's timings object into METRIC lines → tear down the scratch →
restore production.
3. The restore MUST be an EXIT trap (`restore() { podman rm -f scratch;
systemctl start <unit>; }`) so a failed bench never leaves the endpoint
down. Verify after EVERY run: `systemctl is-active`, health curl until it
returns JSON (200 right after start is NOT enough — it can bind 60-90s
later), and no scratch leftover.
4. Bench request: fixed prompt built from a sorted repo-corpus glob with a
fixed random seed (deterministic → stable prompt_n), greedy sampling
(temperature 0) for a fixed decode length, sent locally (127.0.0.1) to
remove LAN jitter. Read prefill/decode/draft-acceptance from the engine's
own timings object — never roll a custom bench without asking.
5. Scratch containers must die by teardown; never leave a script whose tail
stops/removes a production unit (the stale-probe-tail lesson).
## Discipline (the laws that make the numbers mean anything)
1. **Noise floor FIRST.** Before iterating, replicate an identical config 2-3x
and treat that spread as the floor. Fleet endpoints here: decode ±3%,
prefill ±2%. A single-run "win" under the floor is NOT a win — this exact
mistake cost a false 33.06 keep in the 2026-10-06 segment.
2. **One flag change per iteration**; log every run honestly (keep/discard/
crash); discards auto-revert the worktree.
3. **Bracket-map, don't climb.** Test both directions of every lever around
its deployed value, INCLUDING boot failures — crashes are envelope data
("0.82/2048/wht6/40960 is the seqs=3-viable quartet" came from four crash
walls). Test downward too: 40960 MiB host pool is a floor, not slack.
4. **Repro before recording a mechanism.** An anomalous result next to an
abandoned bench may be contamination (boot race). Re-run clean once; only
then name a law. The MNBT 2560 collapse (2.6 t/s) was reproduced clean to
rule this out.
5. **Watch for truncation.** predicted_n < max_tokens means the generation
died mid-run — that config is pathological regardless of the rate.
6. **When the sweep ties, tie-break on real-traffic properties** (e.g. draft
acceptance for multi-turn workloads), never on probe artifacts, and prefer
the config that matches the documented runbook.
## frank-primary specifics (2026-10-06 segment, commit c80c250)
- MNBT=2048 is a RESONANCE POINT at MTP-on shapes, not an optimum: 2560
collapses decode to 2.6-7.7 t/s (acceptance normal, verify stalls ~130ms/step,
truncated output), 1536 truncates at 57/256, 4096 won't boot at seqs=3. Do
not re-test.
- HOST_POOL_MIB 40960 is the load-bearing floor (36864 crashes boot).
- Spec depth 1-3 and seqs 2-3 all tie within noise; 0 and 4 are real
regressions (29.57 / 24.57 t/s vs 31.72 baseline).
- Levers known flat: GPU_HEADROOM, HOST_POOL upward, maxlen above 200k.
- Final kept config = Frank's original (spec=3, seqs=2, maxlen=200k, wht6,
0.82, 2048, 40960, fp8 KV, TP=2), baseline decode 31.72 t/s at 43.6k depth.
@@ -1,69 +0,0 @@
---
name: bench-llama-server-params
description: "Benchmark llama.cpp server-side parameters (--threads-batch, --ubatch-size, --spec-type) using HTTP API with max_tokens=1 for prefill, max_tokens=16384+ for decode"
---
# Benchmarking llama.cpp Server-Side Parameters
## Problem
`llama-bench` benchmarks the bench binary itself, not the running server.
It cannot measure the effect of server-side parameters like `--threads-batch`,
`--ubatch-size`, `--spec-type`, or `--parallel`.
## Solution: HTTP-Based Benchmarking
### Prefill throughput (prompt processing)
1. Start the server with the target parameter value
2. Send a large prompt via the chat API with `max_tokens=1`
3. Measure `prompt_tokens / wall_time` from the response usage
`max_tokens=1` is correct here — you want only prompt processing time,
not generation. The server crashes on `max_tokens=0`.
### Decode throughput (generation)
1. Start the server with the target parameter value
2. Send a prompt that generates a long response
3. Use `max_tokens=16384` minimum (16k) — **never `max_tokens=1`** for decode
benchmarking. One token tells you nothing about sustained generation rate.
4. Measure `predicted_per_second` from the response `timings` object, or
compute `completion_tokens / wall_time`
### Why at least 16k tokens for decode
- Decode throughput varies over the generation (warmup, cache growth, MoE
routing). A single token is noise.
- Speculative decoding acceptance rates stabilize over thousands of tokens.
- Short generations finish before the draft head reaches steady state.
- 16k tokens gives a stable average at ~22 tok/s (≈12 min per run).
### Key Pattern
```python
# Decode benchmark — measure generation throughput
body = json.dumps({
"model": "<alias>",
"messages": [{"role": "user", "content": "<prompt that generates 10k+ tokens>"}],
"max_tokens": 16384, # 16k minimum for decode; use max_tokens=1 ONLY for prefill
"temperature": 0, # deterministic for reproducibility
"stream": False
})
# Measure wall time from request send to response receipt
# Get predicted_per_second from timings, or completion_tokens / wall_time
```
### When to Use
- `--threads-batch` (prefill): use `max_tokens=1`
- `--ubatch-size` (decode): use `max_tokens=16384+`
- `--spec-type` / speculative decoding: use `max_tokens=16384+` (decode only)
- Any server-side parameter that `llama-bench` cannot target
### Benchmarking policy (MANDATORY)
1. **STOP the production service** before running any benchmark
2. **Mask** the service (`systemctl mask`, never `disable` — on these hosts
`disable` deletes the unit file from `/etc/systemd/system/`)
3. Run the bench
4. **Unmask + restart** the service when done
5. **Verify health** before reporting results
### Example Scripts
- `scripts/bench-threads-batch-http.py` — prefill sweep (max_tokens=1)
- `scripts/bench-decode-threads-batch.py` — decode sweep (max_tokens=256+)
- `scripts/bench-spec-decode.py` — speculative decoding config sweep
@@ -0,0 +1,29 @@
---
name: closed-engine-runtime-investigation
description: "Recipe for diagnosing/tuning a closed-source LLM runtime (halogen-class) on this fleet: docs, env-lever enumeration from the binary, ledger scraping, cache-assisted A/B under the bench law."
---
# Investigating a closed-source LLM runtime (halogen-class)
Use when a containerized closed-source engine (e.g. halogen on frank-large) underperforms and the repo docs don't explain it. Fleet law: stop the production unit BEFORE any GPU experiment (`sudo systemctl stop <unit>`; mask FAILS on relinked units — stop alone, redeploy after).
## 1. Ground truth before flags
- Boot ledger + per-request timings live in `podman logs <container>` (container-lifetime only) UNLESS the serve script captures stdout on disk (foreground `podman run`). A `podman run -d ... > LOG` redirect captures ONLY the container ID — a one-line hex file is the tell.
- `serve_api:`-style log lines carry per-request `prompt/prefill/rounds/commit/detok` — build the depth-vs-rate curve from REAL traffic before any bench. Shallow-healthy + deep-collapsed = a depth-linear term (KV reads), not a broken engine.
- If the engine's public repo exists, read its FLAGS/docs (e.g. `docs/FLAGS.md`) AND the bench tool source (e.g. `tools/halogen-bench.py`) — bench tools reveal which regimes the published numbers never measured (halogen's tg rows are shallow-only).
## 2. Enumerate the undocumented levers
The binary self-announces every env var it reads. The image often lacks `strings`:
```
podman run --rm --entrypoint /bin/sh <image> -c \
'grep -aoE "<PREFIX>_[A-Z0-9_]+" /usr/local/bin/<engine> | sort -u'
```
Classify by kernel family (attention vs linear-state vs GEMV) against the suspected term. Only test levers with public precedent (e.g. issue #12 proved HALOGEN_FA_TQ) or an obvious name match; defaults are the publisher's measured winners — an A/B must show >=~15% to adopt.
## 3. Cache-assisted A/B (cheap reps)
Boot each probe arm as its own container (env is read at startup) with the prompt cache ON (explicit MB >= one full-entry at ~70 KB/token): rep 1 pays the cold prefill, rep 2+ resumes (~2 s) — depth measurements then cost decode-time only. Publish the probe on host LOOPBACK (127.0.0.1:<scratch>), run the driver ON the host (python3 stdlib is enough), read the engine's own `timings` (prompt_ms/cache_n/predicted_ms/draft_n_accepted). Never benchmark beside a live engine on the same GPU.
## 4. Adopt or revert
- Winner: env line into the serve env + test-seam update + docs, then `deploy.sh <target> --service <svc>` (restart restores everything; no unmask needed after stop-only).
- Loser: `podman rm -f` probes, start the unit, revert nothing.
- Either way: record the measured before/after in the runbook; report the depth behavior upstream if the publisher invites benchmark data — their bench gaps (depth, carve-topologies) are exactly what issues like #12 close.
@@ -1,35 +0,0 @@
---
name: command-center-dotfiles-import
description: "Runbook for deploying the command-center repo (dotfiles env layer + OMP harness) to fleet machines via full-replica rsync and deploy.sh, including override-package mechanics and ssh/sshpass transport gotchas; executed end-to-end on blankpad 2026-09-09."
---
# Command-center fleet deployment (repo = deployment)
Repo `~/.omp` is itself the deployment: packages are home-relative trees under `dotfiles/<pkg>/`; `scripts/deploy.sh` symlinks live files into the repo checkout. `git pull` + `deploy.sh` is the whole propagation story.
## Transport decision (agreed with Frank, executed 2026-09-09)
- Ship via **full-replica rsync of `/home/dank/.omp/` including `.git`** to `<user>@<host>:/home/<user>/.omp/`, NOT clone-from-Gitea — `main` can be ahead of `origin/main`; push is a user-only trigger, so clone would ship stale state.
- Deploy runs on-target as the target user: `bash /home/<user>/.omp/scripts/deploy.sh` (resolves repo via `git -C $(dirname $(realpath $0)) rev-parse --show-toplevel`, so `.git` must be present at target path — full-replica transport guarantees this).
## Pre-overwrite audit (MANDATORY before first sync on a non-blank host)
Diff every file that `deploy.sh --check` reports as DRIFT against the repo copy BEFORE letting quarantine-and-replace run. Host-specific divergence belongs in an override package, not in quarantine:
1. Pull divergent live files from target (`ssh ... 'cat <path>' > local-staged-copy`).
2. Secret-audit staged copies (grep token/secret/password/key shapes) before anything enters git.
3. Compose `dotfiles/zz-<hostname>/` on the source machine from those live copies; commit to repo first.
4. Ship package dir via rsync, then run deploy sync — override deploys LAST and wins over base packages.
## deploy.sh mechanics (verified on blankpad)
- `--check` = pure report (ADOPT/LINK/DRIFT lines), writes nothing. Run it first, always.
- Default mode = sync: adopt identicals silently, link new, quarantine-and-replace divergents into `.quarantine/<host>/<ts>/` (gitignored; never rm).
- **Override-target bug (fixed cc2c652):** `zz-<host>` packages nest base-package dirs internally (`zz-blankpad/niri/.config/...`); target derivation must strip the nested package segment, else overrides deploy to phantom `$HOME/niri/.config/...` while real targets keep pointing at canonical copies (visible symptom: check shows 0 actions but `readlink ~/.zshrc` points at base `zsh/.zshrc`).
- systemd units auto-enable only when ExecStart binary resolves on that host; dconf package loads via `dconf load /`, never symlinks.
- `core.hooksPath=.githooks` is armed by deploy.sh per machine — set it once post-transfer even before first sync.
## ssh/sshpass transport gotchas (bit us live)
- Resolve target via mDNS FQDN (`avahi-resolve -4 -n <host>.local`); bare hostname may not resolve. Drop-not-refuse on port 22 = sshd down or firewall default-drop, not auth failure — needs local/console fix on target.
- `SSHPASS='...' sshpass -e ssh ...` inline per invocation; env passed via tool `env` param did not propagate to the sshpass child process.
- Remote login shell may be **fish**: never send bash syntax (`for/do/done`, `$?`) bare through `ssh host 'cmd'`; wrap as `bash /path/script.sh` or `bash -c "..."`.
- File shipping: `ssh 'cat > /remote/path' < localfile` (stdin redirect). A here-doc attached to the ssh command is consumed by `cat` — the intended script body becomes the file content, silently. Verify shipped bytes with md5sum compare on both ends; do NOT compare `/dev/stdin` after `cat` drained it (false DIVERGED).
## Verification of a completed deploy
Re-run `deploy.sh --check` → expect exactly zero actions/quarantined/skipped = idempotent steady state. Spot-check `readlink` on override files points into `dotfiles/zz-<host>/`, not base packages.
@@ -1,64 +0,0 @@
---
name: compose-stack-bringup-groundtruth
description: "Bring up a compose-defined container stack on a rootless podman host by verifying reality first (mounts, images, in-image interfaces) instead of trusting compose env/flag guesses; includes the copyparty ac-edition argv-interface recipe and boot-persistence audit."
---
# Compose-stack bring-up with ground truth (rootless podman hosts)
Use when standing up (or debugging) a compose-defined service stack on a rootless
podman host where the stack has never run, or answers with surprise statuses
(SIGSEGV at boot, HTTP 500 on `/`, "manifest unknown").
## Procedure
1. **Audit reality before pressing buttons.** `podman ps -a` runs per-user —
check every namespace (`sudo podman ps -a`) and native units
(`systemctl list-units`) before declaring a service absent; a service may run
as a native systemd unit or snap, not a container. Compare compose mount source
paths against the real filesystem (`ls /mnt/...`): compose files often name
phantom paths that never existed (e.g. `/mnt/media/*` vs the real LUKS array at
`/mnt/entropy`). Fix compose through git first, deploy second.
2. **Pre-create bind-mount sources.** Rootless podman fails container creation
with `statfs /opt/<svc>/...: no such file or directory` when a bind source is
missing. `sudo mkdir -p` + `chown 1000:1000` every compose volume source
before the first `podman-compose up -d`.
3. **Staged bring-up.** Infra first (`up -d mariadb redis`), poll healthchecks via
`podman inspect --format "{{.State.Health.Status}}"`, then `up -d` the rest.
4. **Never guess image tags — query ground truth.** ghcr.io answers **403 on the
bearer-token request for nonexistent/private repos** ("manifest unknown" / token
len 0 = repo or tag does not exist, not an auth problem). Query the registry
API (`ghcr.io/token?scope=repository:<ns>/<repo>:pull` then
`/v2/<ns>/<repo>/tags/list`) before pinning a tag.
5. **Read the interface from inside the image** before guessing flags/envs:
`podman exec <ctr> cat /z/cpp.sh`, `cat /z/initcfg`,
`podman exec <ctr> python3 -m copyparty --help-accounts`. A service answering
HTTP 500 with its own styled error page is *running* — it is misconfigured,
not down. Copyparty ac edition ground truth: entrypoint
`/bin/ash /z/cpp.sh -c /z/initcfg` then `exec python3 -m copyparty "$@"`;
accounts/volumes are **argv flags** (`-a USER:PASS`, `-v SRC:DST:PERMS`), not
`COPAY_*` envs (zero `COPAY_` references in the package). With no accounts and
no volumes configured it serves cwd read/write for everyone — an
unauthenticated world-writable share of the whole mount.
6. **Beware remote-shell quoting traps.** Remote zsh eats bare `===`/`?` tokens
(glob qualifiers/globs); grep patterns with nested quotes over ssh can be
quote-eaten into silent zero-match. A zero-match from a mangled pattern is not
absence — re-verify the target exists before concluding it.
7. **Boot-persistence audit last.** On rootless podman hosts with
`podman-user-generator`, containers started via podman-compose get generated
`container-<name>.service` units at first boot (evidence: an existing unit for
one container proves the generator works). Verify after first reboot:
`systemctl --user is-enabled <name>.service`.
## Gotchas
- jellyfinetype SIGSEGV at boot with `user: "1000:1000"` = unmapped uid inside the
rootless userns (host uid 1000 == container uid 0); drop the user line and let it
run as container-root (= host deploy user).
- `up -d --force-recreate` applies compose changes that change command/env; probe with
a poll loop (`for i in $(seq...)`) because fresh containers answer `000`/refused for
the first seconds while indexing.
@@ -1,72 +0,0 @@
---
name: compose-to-quadlet-cutover
description: "Convert a compose-only service to a boot-persistent rootless Quadlet unit on the Dankclaw/Dankserver fleet — checklist plus the traps (CMD-SHELL healthchecks in distroless images, --format docker, EnvironmentFile HOME=/root, loopback publish posture)"
---
# Compose service → boot-persistent Quadlet unit (this fleet)
Use when a service in `dankclaw/compose.yaml` (or `dankserver/`) must survive a
reboot. Precedents to copy: `dankclaw/{caddy,gitea,searxng,open-webui,seerr}/quadlet/`
and `dankclaw/observability/quadlet/` (five units, 2026-09-11).
## Checklist
1. **Unit file** at `dankclaw/<svc>/quadlet/<name>.container`, mirroring the
compose service line for line: `Image=`, `ContainerName=<the compose name>`
(Caddy and scrape configs address containers by name), `Network=dankclaw_default`,
`User=0:0` when the image's own user cannot write a rootless-owned bind mount,
`Volume=` for each mount (`:ro` preserved), `Exec=` for the compose `command:`,
`PublishPort=`, `HealthCmd`/`HealthInterval`/`HealthTimeout`/`HealthRetries`/
`HealthStartPeriod`, `[Service] Restart=always` + `RestartSec=10`,
`[Install] WantedBy=default.target`. Never `systemctl --user enable` the
generated unit — `[Install]` plus linger IS the boot persistence.
2. **Exposure**: publish on `127.0.0.1` unless something off-host genuinely
needs it. Caddy reaches containers by name over the shared network, and the
homepage status timer probes loopback, so the 0.0.0.0 publish is almost
always gratuitous and is a guard-chain bypass.
3. **Handler** in `scripts/deploy-remote.sh`: `install_quadlet <dir> <name>` →
`systemctl --user restart <name>.service` → `wait_for_health` on the
loopback endpoint. Rebuild locally built images first
(`build_compose_image`), and seed any per-service env file (see traps).
4. **Planner**: map `dankclaw/<svc>/*` and `shared/<svc>/*` to the service in
`scripts/deploy-plan.py`; keep dependencies ordered in `SERVICE_ORDER`.
Add a `scripts/test-changed.sh` case *above* the generic
`dankclaw/*/quadlet/*.container` fallback.
5. **Retire the compose entry** in the same change — two owners for one
container name is how a stale duplicate comes back.
6. **Seam test** `tests/<svc>_quadlet.bats`: boot persistence, restart policy,
no wildcard publish, container name still matching Caddy/scrape references,
health command, deployer installs+restarts+waits, never `rm`, never
`enable`. Migrate any older assertion that read the compose file.
7. **Deploy** per service (`./scripts/deploy.sh dankclaw --service <name>`),
then prove: `is-active`/`is-enabled` = active/generated, container health
`healthy`, the endpoint answers, `list-dependencies default.target` shows the
unit, and off-host probes on the previously published ports now refuse.
## Traps that cost real time
- **`HealthCmd` = CMD-SHELL.** podman runs it as `/bin/sh -c`; a distroless
image has no shell, so the check fails forever. `--health-cmd` will not take
exec form either (a JSON array is stored as one literal argument). Put
`HEALTHCHECK ... CMD ["binary","arg"]` in the image and **build with
`--format docker`** — the default OCI format has no HEALTHCHECK field and
drops it with only a warning.
- **Never point a unit's `EnvironmentFile` at the repo root `.env`.** That file
is a gluetun env file carrying `HOME=/root`: systemd sets HOME for the
service, podman then looks for its config under `/root`, and the unit dies
with `stat /root/.config: permission denied`. Use a per-service file under
`/home/dank/Containers/<svc>/env`, seeded by the deploy handler with a
single-variable filter and validated so a missing secret fails loudly.
- **`Network=host` exporters** that a containerized Prometheus scrapes must
bind the host's LAN address (containers cannot reach the host loopback) plus
`127.0.0.1` for the homepage probe — two `--web.listen-address` flags, never
the wildcard.
- **alloy's UI** listens on the container's loopback and 1.x refuses the `ui`
config block, so the unit must pass `--server.http.listen-addr=0.0.0.0:12345`
before its port can be published; without it the service has no reachable
health endpoint and its status card reads down forever.
- **Locally built images** need `Pull=never` and `Image=localhost/<repo>/<name>:tag`;
registry-prefixed refs do not resolve from local storage on these hosts.
- Compose-managed containers are replaced in place by `podman run --replace
--rm`; verify afterwards that the container no longer carries
`io.podman.compose.project`.
@@ -1,82 +0,0 @@
---
name: container-config-drift-crashloop
description: "Diagnose a container that crash-loops or stays unhealthy while its compose/unit definition looks correct: config drift kept alive by a restart policy, with the inspect commands, the Devices-vs-CreateCommand false negative, and the compose-to-Quadlet migration order."
---
# A container that restarts forever while its definition is correct
## When to use
A container is crash-looping or perpetually "unhealthy", but the file that
defines it (compose service, unit, deploy script) looks right. Also when a
service has been quietly down for days and nobody noticed, because a restart
policy kept something running.
The cause is usually **config drift**: the running container was created from an
older definition, and the restart policy restarts it instead of recreating it.
A compose/unit edit does not touch an existing container.
## Diagnose (5 minutes, in this order)
1. **Who started it?** The label names the owner, not your guesswork:
```
podman inspect <name> --format 'unit={{index .Config.Labels "PODMAN_SYSTEMD_UNIT"}} restart={{.HostConfig.RestartPolicy.Name}} created={{.Created}}'
```
`podman-compose@…` means compose owns it, whatever unit files exist.
Compare `created` with when the definition last changed. A container older
than the edit that should have replaced it is the bug.
2. **What is it ACTUALLY running?** Read the argv, not the summary fields:
```
podman inspect <name> --format '{{.Config.CreateCommand}}'
```
Trap: `{{.HostConfig.Devices}}` prints an empty list even when `--device`
flags are present — a false negative that can send you hunting the wrong
root cause. `CreateCommand` is the truth; grep it for the flag you care
about (`--device`, `--group-add`, `-e`, mounts).
3. **What does the process say?** `podman logs <name> | grep -iE 'error|cannot|Runtime'`.
A capability error (`No HIP GPUs are available`) alongside a missing device
flag in step 2 confirms drift rather than a code or image fault. Count the
damage: `podman inspect --format '{{.RestartCount}}'`.
## Fix
Quickest unblock: recreate from the definition (`podman compose up -d
--force-recreate <svc>`), which is what should have happened automatically.
Durable fix, when the host is governed by a repo that requires boot-persistent
units (this one does): migrate the service to a Quadlet. Order that worked:
1. build the image and `install` the unit file to `~/.config/containers/systemd/`
2. stop/disable the legacy unit and **move its file to `~/.AGENTTRASH/`** — a
leftover `~/.config/systemd/user/<svc>.service` collides by name with the
unit the Quadlet generator produces
3. `podman rm -f <container>` — the stale container holds the name the unit's
container needs, and it carries the old config
4. `systemctl --user daemon-reload && systemctl --user restart <svc>.service`
5. delete the compose service so only one owner remains per container name
## Verify the capability, not the status
`Up` and even `healthy` can be true of a half-configured container. Check the
thing that was broken, from inside:
```
podman exec <name> python3 -c "import torch; print(torch.cuda.is_available())"
ss -ltn | grep <port> # confirm the bind addresses, not the intent
```
And confirm boot persistence the way the generator exposes it:
`systemctl --user is-enabled <svc>.service` → `generated` is healthy; an
explicit `enable` fails on generated units.
## Related reading in this repo
- AGENTS.md "Boot Persistence & Self-Healing" — the rule this enforces
- `serverAppConfigs-docs/Dankclaw/ComfyUI.md` — the 9,146-restart incident
- `serverAppConfigs-docs/Infrastructure/Observability.md` — same class, twice
@@ -1,33 +0,0 @@
---
name: dankclaw-guard-chain-layer-diagnosis
description: "When a user reports a guarded dankclaw page (homepage/vhost) broken but the server may be healthy: verify each layer of the Anubis→Authentik→Caddy chain server-side and isolate client-side vs server-side."
---
# Dankclaw Guard-Chain Layer Diagnosis
Use when a user reports a guarded page (e.g. `www.franksdank.cloud` homepage) "broken / links gone / not loading" and you must determine whether the fault is server-side or client-side/session.
## Chain topology
`Browser → Caddy TLS edge → Anubis :8923 → Caddy :8016 → Authentik outpost → Caddy :8017 (@home / @data handlers) → app/static`
- Public vhosts `reverse_proxy anubis:8923` with `header_up X-Real-Ip {remote_host}`.
- Post-auth `http://:8017` listener routes by `@<svc> host <vhost>`.
- Homepage static: `/srv/homepage/` (index.html, app.js, style.css, services.json, status.json, deployed.js).
## Layer-by-layer checks (run from the host via ssh)
1. **Caddy unit + guard containers**: `systemctl --user is-active caddy.service`; `podman ps --format '{{.Names}}\t{{.Status}}' | grep -iE 'anubis|authentik|caddy'` — expect `Up ... (healthy)`.
2. **Anubis challenge page validity**: `curl -sk https://www.franksdank.cloud/ --resolve www.franksdank.cloud:443:192.168.0.143` — a healthy challenge contains `"anubis_challenge"` JSON with `difficulty` and the main JS. If present, the guard serves fine.
3. **Authentik outpost errors**: `podman logs --tail 40 authentik-outpost 2>&1 | grep -iE 'error|exception|traceback|500|provider'` — routine dramatiq task logs are normal.
4. **@home / @data from INSIDE the caddy container** (the only way to reach 8016/8017):
`podman exec caddy sh -c 'wget -q -S -T5 --header "Host: www.franksdank.cloud" -O- http://127.0.0.1:8017/'`
and the same for `/services.json`, `/status.json`. Expect 200 + body.
5. **Files on disk**: `ls -la /srv/homepage/` and validate `status.json` parses (`python3 -c "import json;json.load(open('/srv/homepage/status.json'))"`).
## Gotchas (cost me calls)
- **BusyBox wget in the caddy container**: NO `-H` flag. Use `--header "Host: ..."`.
- **8016/8017 live in the caddy container's netns**: `curl http://127.0.0.1:8017` from the HOST returns `000` (connection refused) — EXPECTED, not a fault. Test from inside the container.
- **anubis container is distroless**: `podman exec anubis sh` fails (`sh: executable not found`). Inspect via its logs / the served challenge page instead.
- **x-real-ip masking**: Anubis logs `x-real-ip: 10.89.1.22` (Caddy container podman IP) for ALL clients — rootless-podman published-port SNAT. Pre-existing; clients indistinguishable by IP; clearance is cookie-based. Do NOT mistake this for the bug.
- **Homepage render**: `app.js` does `Promise.all([fetch services.json, fetch status.json])`; on ANY fetch failure the catch sets `#summary` to `status unavailable: <err>` and the grid stays EMPTY — which a user reads as "links gone / not loading." A 30-service status.json with a 9-service services.json renders fine (stateByName lookup).
## Decision
If every layer above is healthy (challenge valid, outpost clean, @home/@data 200, files render), the fault is client-side/session. STOP thrashing the live system (repeated failed browser challenges share the masked 10.89.1.22 identity). Get the authenticated view: ask for the exact symptom (challenge spinning? Authentik login loop? empty grid? "status unavailable" text?) or the Authentik login to drive the page in a real browser.
@@ -0,0 +1,13 @@
---
name: dankclaw-homepage-asset-verify
description: "Add/verify homepage assets on Dankclaw: Caddy @data allowlist, deploy copy step, e2e panel census, live content-type check."
---
Every new top-level homepage asset directory (e.g. `shared/homepage/img/`, `shared/homepage/sfx/`) needs FOUR coordinated changes, all in one commit:
1. **Caddy allowlist** — `dankclaw/Caddyfile` `@data path` matcher must include `/<dir>/*`. Without it the post-auth `@home` catch-all answers with `index.html` (`200 text/html` for a PNG/OGG — silent breakage).
2. **Deploy copy** — `scripts/deploy-remote.sh` `deploy_homepage()` copies `shared/homepage/<dir>/` with the `cp -r ... /srv/homepage/` merge pattern beside the fonts/img/sfx blocks.
3. **Test pins** — `tests/homepage_caddy.bats` grep-pins the new `@data` path; if the asset changes panel rendering, the e2e census in `tests/e2e/specs/10-homepage.spec.ts` must count the new render style (it derives expected names from services.json: `.panel .name` for normal cards, `.mi-name` for fused Model Information cards — a new fused component breaks the panel census otherwise, which is exactly how the 2026-10-08 Protoss deploy failed its e2e gate).
4. **Deploy both services** — `deploy.sh dankclaw --service homepage` and `--service caddy` (Caddyfile change), then live-verify from a browser tab: `fetch('/<dir>/<file>')` must return `200` with the correct `content-type` (`image/jpeg`, `audio/ogg`), not `text/html`.
Checker note: `status.json` names follow `services.json` automatically (the deployed checker re-reads it via the homepage deploy's restart of homepage-status.timer), so renaming cards needs no checker change.
@@ -0,0 +1,21 @@
---
name: defuse-shell-rc-secrets
description: "Find literal API keys/tokens in shell rc files and replace them with 600-perm secret files + loader lines, syncing the canonical tool token file."
---
# Defuse shell-rc secrets
Symptom: `export KEY=...` literal in `.zshrc`/`.bashrc` — world-readable in every process env, one `git add -A` from a dotfiles repo.
## Procedure (repeat per host)
1. Sweep: `grep -n "export.*KEY\|export.*TOKEN" ~/.zshrc ~/.zprofile ~/.profile ~/.bashrc 2>/dev/null` (run per host; when batching hosts in one ssh compound command, label which output came from which host — misattribution wastes turns).
2. Extract WITHOUT echoing: `mkdir -p ~/.config/secrets && chmod 700` it, then `sed -n 's/^export NAME="\(.*\)"$/\1/p' ~/.zshrc > ~/.config/secrets/<name>` (quote variant and unquoted variant are different sed patterns — verify byte counts with `wc -c`, a 0-byte file means the pattern missed).
3. `chmod 600` every secret file; sync any canonical tool file the ecosystem expects (e.g. `~/.cache/huggingface/token`).
4. Replace the rc literal (hashline `PUT N.=N:` range — never echo the secret in the op): `[ -f ~/.config/secrets/<name> ] && export NAME="$(cat ~/.config/secrets/<name>)"`.
5. Verify: `zsh -n ~/.zshrc` (or `bash -n`); keep a dated `.bak` of the rc on remote hosts; shred any /tmp transfer copies immediately (`shred -u`).
## Laws
- A token pasted into chat lands in transcripts and model context — treat chat as a leak channel; give the user the `read -rs` snippet instead.
- scp of a secret FILE (encrypted channel, no argv) is fine; the token is never a command-line argument.
- After replacing a legacy HF token, probe scope non-destructively: `HfApi().whoami()` (legacy read tokens show `type: access_token` without canWrite; the decisive probe is `create_repo(exist_ok=True)` on the real target repo).
- Verify extraction byte-count before deleting/trusting; a successful-looking sed can write an empty file.
@@ -0,0 +1,46 @@
---
name: detached-fleet-job-launch
description: Launch a long-running remote job on Dank fleet hosts so it survives ssh/logind teardown
---
# Detached long-running jobs on Dank fleet hosts
## Law
`ssh host 'setsid nohup CMD & '` is NOT detached. systemd-logind deactivates
`session-N.scope` when the ssh command returns and SIGKILLs the whole cgroup:
no journal entry, no OOM trace, python block-buffers stdout so the log stays
0 bytes. setsid/nohup do not escape the cgroup.
## Canonical launch
```
ssh host 'export XDG_RUNTIME_DIR=/run/user/$(id -u); \
systemd-run --user --collect --unit=NAME \
-p SendSIGKILL=no \
-p StandardOutput=append:/home/dank/logs/NAME.log \
-p StandardError=append:/home/dank/logs/NAME.log \
sh -c "CMD -u; echo \$? > /home/dank/logs/NAME.rc"'
```
- transient user unit = outside every session scope; survives ssh teardown
(requires `Linger=yes`, true for `dank` on both hosts)
- `echo $? > …rc` inside the unit = completion sentinel the unit itself writes
- `-u` / unbuffered output so the log is live
- verify launch in the same ssh call: `systemctl --user is-active NAME.service`
## Watcher (one async job, never poll)
```
ssh host 'while [ ! -f /home/dank/logs/NAME.rc ]; do sleep 60; done; \
echo RC=$(cat /home/dank/logs/NAME.rc); tail -25 /home/dank/logs/NAME.log'
```
Run it async with timeout 0; the result is injected on completion.
## Forensics when a detached job died
1. `ps -p PID` — gone.
2. Log 0 bytes → buffered stdout lost at SIGKILL.
3. `journalctl --since … | grep -E "session-[0-9]+.*(Deactivated|Removed session)"` —
timestamps matching the death window confirm the scope kill (vs OOM, which
leaves kernel lines).
## Notes
- zsh remote shells: `echo ===X===` fails (`=cmd` expansion); quote sentinels.
- The HF upload of 2026-10-08 (113.7 GiB, hf_xet) died this way ~2 min in;
relaunch as above succeeded.
@@ -1,72 +0,0 @@
---
name: e2e-arr-rawip-reachability-triage
description: "Triage an e2e admin-SSO failure on a *arr raw IP (192.168.0.112:8989) by probing from the box the browser runs on, instead of assuming the Mac is asleep and burning retries waking it."
---
# e2e raw-IP *arr failures — probe from the browser's box, not a peer
`tests/e2e/specs/11-admin.spec.ts` navigates the real browser to
`http://192.168.0.112:8989/` (Sonarr's forwarded IP on FrankBook-Pro). When it
fails with `ERR_CONNECTION_TIMED_OUT` the instinct is "the Mac is asleep".
Sometimes true. Not always — and waking it is not the only recovery.
## The decisive question
**Who cannot reach the port?** Playwright runs on the dev box (blankpad), not on
a fleet host. A healthy probe from dankclaw proves nothing about the browser's
path.
```bash
# 1. From the box that RUNS the browser:
curl -s -o /dev/null -w '%{http_code}\n' --max-time 7 http://192.168.0.112:8989/
# 2. From a LAN peer, same port:
ssh dankclaw 'for p in 8989 7878 9696; do curl -s -o /dev/null -w "$p %{http_code}\n" \
--max-time 6 http://192.168.0.112:$p/; done'
# 3. Liveness of the Mac itself (ICMP, not TCP):
ping -c 2 -W 3 192.168.0.112
```
## Reading the matrix
| peer probe | blankpad probe | ping | meaning |
|---|---|---|---|
| 200 | 200 | ok | transient — re-run the spec |
| timeout | timeout | fails | Mac asleep; wake, ports return in seconds |
| **200** | **rc=28** | **ok** | **path problem, not sleep** — ICMP allowed, TCP to those ports blocked. Waking and re-running cannot help |
## If it is the third row, confirm the Mac is not at fault
Through the dankclaw jump (`ssh dankclaw "ssh dank@192.168.0.112 ..."`):
```bash
pgrep -fl socat # forwarders bound to 192.168.0.112:port
netstat -an | grep -c 8989 # listening
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8989/ # 200 = app fine
/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate
```
Forwarders up + loopback 200 + `Firewall is disabled` ⇒ the Mac is innocent. The
block is between the dev box and the Mac, so stop optimising the Mac.
## What to do instead
Don't hold a deploy hostage to a known-environmental red gate:
1. Verify the deployed service **directly** — its own ledger/health/real
completion — and state that the e2e gate was skipped or partially red, and
why (`deploy.sh --skip-e2e` prints exactly that warning, so the skip is
recorded, not hidden).
2. Report the residual gap plainly: *"29/30 e2e pass; 11-admin fails on Sonarr's
raw IP from the dev box; peer probe returns 200, so the deploy is verified by
other means and the gate gap is a dev-box network path issue."*
3. Do not weaken or skip the spec to make it green — it is guarding a real
SSO-plus-RBAC contract.
## Note on the sleep case
The classic flake is real: with the Mac asleep, ports refuse from **everywhere**
including peers, ping latency runs ~150 ms, and an SSH/ping wake restores
200/302 within seconds. That is the cheap first check — just don't stop there
when the peer probe is already green.
@@ -0,0 +1,19 @@
---
name: fandom-wiki-media-fetch
description: Fetch audio/image media from a Fandom wiki via its MediaWiki API when the wiki HTML pages 403 curl or hang headless browsers.
---
Goal: get direct media URLs (and files) from a starcraft.fandom.com-style Fandom wiki.
Why this route: Fandom HTML pages return a ~5 KB JS shell to `curl` (403 via `read ...:raw`) and time out headless Chromium (`browser.open`/`goto` >30 s on quotation megapages). The MediaWiki API is plain JSON and answers plain curl.
Steps:
1. Find the content page's wikitext:
`curl -sL -A "Mozilla/5.0" "https://<wiki>.fandom.com/api.php?action=parse&page=<Page_Name>&prop=wikitext&format=json&formatversion=2"`
Top-level pages are often tabber/transclusion shells — follow the `{{:Subpage}}` / `{{TemplateName}}` references with the same call (e.g. `StarCraft_unit_quotations` → `/Protoss` → `Template:QuotesSC1Zealot`).
2. Extract media names from the wikitext. StarCraft quotes use `{{sm2|File.ogg|displayed quote}}` inside `{{UnitQuoteBox|select=...}}` fields — match the quote text to pick the exact file.
3. Resolve direct CDN URLs (works for images too):
`curl -sL -A "Mozilla/5.0" ".../api.php?action=query&prop=imageinfo&iiprop=url|size&format=json&formatversion=2&titles=File:A.ogg|File:B.jpg"`
→ `query.pages[].imageinfo[0].url` = `https://static.wikia.nocookie.net/...` — downloadable with plain `curl -sfL`.
4. To discover art on a unit page: `action=query&prop=images&imlimit=100&titles=<Page>`, filter titles (icon/art/portrait/Head/Render), then resolve the keepers via imageinfo. Note splash/head art naming: `File:Zealot SC2 Art1.jpg`, `File:Archon SC2 Head1.jpg`; some obvious guesses (SC1-era units' SC2 splash) do not exist — verify with imageinfo, absent pages return `missing` with no `imageinfo`.
5. Verify downloads (`file sfx/*.ogg` → `Ogg data, Vorbis audio`) — a 403 body saves as a success with `-o` and no `-f`.
@@ -1,91 +0,0 @@
---
name: fleet-container-ownership-audit
description: "Audit a rootless-Podman fleet for containers that will not survive a reboot: owner-label detection, the stale-container and inspect-field traps, and the migration shape that fixes them."
---
# Finding the containers that will not survive a reboot
Use when auditing a Podman fleet for boot persistence, when a service is
"up" but stopped working at some past reboot, or when a compose-managed stack is
being converted to Quadlet units. This is the *detection* half; the conversion
checklist is separate.
## The one-line test
Every running container on a boot-persistent host should carry a
`PODMAN_SYSTEMD_UNIT` label. Anything without one is compose-only or bare, and
rootless Podman does not restart bare containers after the explicit stop that
shutdown performs — so it will be missing tomorrow morning.
```bash
for c in $(podman ps --format '{{.Names}}'); do
printf '%-26s %s\n' "$c" "$(podman inspect $c --format '{{index .Config.Labels "PODMAN_SYSTEMD_UNIT"}}')"
done
```
Compare the result against the host's installed units
(`ls ~/.config/containers/systemd/*.container`) and the repo's
`<host>/<service>/quadlet/`. Three outcomes, three meanings:
- **label present** → the unit owns it; boot persistence comes from `[Install]
WantedBy=default.target` plus `loginctl show-user <user> -p Linger` = yes.
- **label absent, compose service exists** → the classic gap: it dies at reboot.
Also check the publish address while you are there; compose forms often bind
the wildcard, which puts an unauthenticated UI/API on every interface.
- **label present AND a compose service with the same name** → a second owner.
The runtime is fine, but a stray `podman compose up` can create a duplicate
that fights the unit for the container name and the port. Remove the compose
service and leave an explanatory comment; that is the repo's convention.
## Traps that make this hard to see
- **Editing a compose service does not replace a running container.**
`restart: unless-stopped` will happily keep an old container — old image, old
device list, old mounts — alive forever. One instance ran 9,146 restart
attempts on stale config before anyone looked. If the container predates the
edit, recreate it explicitly; do not debug the service.
- **`podman inspect --format '{{.HostConfig.Devices}}'` is not a device check.**
It can read empty on a container that demonstrably has the devices. Read the
real argv with `{{.Config.CreateCommand}}` and, decisively, probe from inside:
`podman exec <c> python3 -c 'import torch; print(torch.cuda.is_available())'`.
- **A monitoring target's failure string can prove a fix.** A `/metrics` scrape
that flips from `connection refused` to `unsupported Content-Type "text/html"`
means the service came up and only the scrape is wrong. Read the error, not
just up/down — and never "fix" a red probe by deleting the job, because an
absent scrape and a healthy one look identical.
- **A hand-written `~/.config/systemd/user/<svc>.service` that shells out to
`podman compose up` collides by name with the Quadlet the generator produces
for `<svc>.container`.** Retire it to a dated trash directory before
installing the unit.
- **`is-enabled` saying `generated` is the healthy state** for Quadlet units;
`systemctl --user enable` fails on them.
## Migration shape that works
1. Unit file in the repo at `<host>/<service>/quadlet/<service>.container`:
`Image=` with the `localhost/` prefix for locally built images, `Pull=never`,
explicit `PublishPort` (loopback unless the service is the documented LAN
exception), `AddDevice=`/`GroupAdd=keep-groups` where a GPU is needed,
`Restart=always`, `[Install] WantedBy=default.target`.
2. A healthcheck **as a file**, never inline: the podman 5.x v1 generator
splits `HealthCmd` on spaces, so `python3 -c "..."` becomes a quoting puzzle
that fails silently.
3. `deploy_<svc>()` in the remote handler: install the unit, retire any
compose-era unit into `~/.AGENTTRASH/`, `podman rm -f <container>` so the
unit's own can take the name, daemon-reload, restart, wait for health.
4. Delete the compose service (one owner per container name) and leave a comment
naming the unit, so the next reader does not re-add it.
5. Planner: add a path rule (`<host>/<dir>/**` -> service) **and** a
`service_is_allowed` entry, or the shared dispatcher rejects the service
before anything runs.
6. Migrate the seams: tests that read the compose file for devices, command
flags or groups must read the unit instead — and resolve file paths from the
tooling's own declarations, never from `find ... | head -1`.
## When the last service leaves a compose file
The repo's compose-validity seam requires at least one service, so a file whose
services have all migrated fails its own test. Either keep the file as
comments and relax that seam deliberately, or delete the file and fix the
planner's compose-diff path — decide with the owner, because the deploy tooling
reads it.
@@ -1,117 +0,0 @@
---
name: fleet-llm-payload-purge-refresh
description: "Safely purge and refresh LLM model payloads on the Dank fleet (Dankclaw/Dankserver): protect live service payloads, confirm upstream re-fetchability, fingerprint before deleting, and use each service's own refresh path (halogen's first-start HALOGEN_DOWNLOAD vs frank-small's plain-GGUF fetch) — including the empty-bind-mount-dir trap."
---
# Purge and refresh LLM model payloads (Dank fleet)
Use when asked to clear / reclaim / refresh model caches on a host that is
*serving* users, or to prove that a payload is up to date.
## 1. Inventory before touching anything
```bash
du -sh /home/dank/models/* | sort -h # what is actually there
```
Then find which of those the live services mount — never delete a payload a unit
mounts without an explicit, in-this-conversation instruction:
```bash
grep -aE "^(Volume|Environment=.*(MODEL|CHECKPOINT|MODELS))" \
/home/dank/.config/containers/systemd/*.container \
/etc/systemd/system/frank-*.service
```
On Dankclaw the protected set has been: `models/halogen` (frank-large's .hgn
checkpoint dir), `models/Qwen3.8-27B-GSQ-RCO-IQ3_S-mtp` (frank-small's GGUF +
mmproj). ComfyUI uses `/home/dank/comfyui-data/models`, **not**
`models/checkpoints` — verify, because `models/checkpoints` looks like cache but
has held Flux weights whose upstream is licence-gated.
## 2. Confirm each payload is re-fetchable BEFORE deleting it
Query the Hub anonymously; a gated repo 403s even with the host token:
```bash
curl -s "https://huggingface.co/api/models/<repo>/tree/main?recursive=true&blobs=true" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d),'entries'); [print(e.get('size'),e['path']) for e in d[:5]]"
```
An error/empty result means it is NOT safe to delete. Also filter by **quant
name**, never by `endswith('.gguf')` — mradermacher ships large quants as
`.partNofM` fragments which that test hides.
## 3. Fingerprint, then delete
Record byte sizes (and sha256 when the file matters) **before** removal: that is
the only way to answer "did the refresh actually change anything" afterwards.
Per this repo's policy `rm` needs explicit operator permission; otherwise stage
into `~/.AGENTTRASH/<date>-<topic>/`. Note that a same-filesystem trash reclaims
nothing — say so rather than implying space was freed. Root-owned leftovers (a
trashed swapfile) need `sudo`, and check `swapon --show` first.
## 4. Refresh, per service
**frank-large (halogen)** — the engine fetches for you. Clear the directory but
leave it **existing and empty**, then start the unit:
```bash
rm -rf /home/dank/models/halogen/* # keep the directory itself
systemctl --user start frank-large.service
journalctl --user -u frank-large.service -f | grep -a halogen:
```
The engine sees the checkpoint absent and runs `HALOGEN_DOWNLOAD`
(`peonist-ai/halogen-qwen3.8-flash-next`) into the mount via `huggingface_hub`,
resuming if interrupted. **Trap:** podman refuses a bind mount whose source
directory is missing — `Error: statfs /home/dank/models/halogen: no such file or
directory`, with the unit looping on `Restart=on-failure`. `mkdir -p` the empty
directory and restart. ~124 GiB at 130-240 MB/s ≈ 10-20 min, then load.
**frank-small (llama.cpp)** — no self-fetch; the serve script refuses to start
with weights missing, so pull them yourself and verify:
```bash
DEST=/home/dank/models/Qwen3.8-27B-GSQ-RCO-IQ3_S-mtp # repo ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF
# files: Qwen3.8-27B-GSQ-RCO-IQ3_S-mtp.gguf + mmproj-Qwen3.8-27B-BF16.gguf
curl -fL -C - --retry 10 -o "$DEST/<file>" \
"https://huggingface.co/<repo>/resolve/main/<file>" # -f is mandatory
sha256sum "$DEST"/*; head -c 4 "$DEST"/*.gguf # compare to the LFS oid + GGUF magic
```
`curl` exits 0 on HTTP 401/403 and writes the error body as the artifact — always
`-f`, always compare byte counts to the Hub's declared size, always check the LFS
sha256. Public repos need no token: omit `-H "Authorization: Bearer ..."` from
argv entirely (it is world-readable in `/proc/<pid>/cmdline`).
## 5. Verify, then record
```bash
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:7770/health # frank-large
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:7771/health # frank-small
curl -s http://127.0.0.1:7770/v1/models; curl -s http://127.0.0.1:7771/v1/models
# a REAL completion on both, then halogen's boot ledger:
journalctl --user -u frank-large.service | grep -aE "weights locked|kv pool:|left for everything"
```
Also sweep the vhosts (`www`, `grafana`, `prometheus`, `hermes`, `marreta`,
`gitea`, `loki` — `curl -sk --resolve <host>:443:192.168.0.143`) because a purge
touches state the whole host shares.
## 6. Things that bite
- **Sidecars are versioned in place.** Equal file sizes are not proof the repo is
unchanged, and a stale local sidecar is invisible from its name. Compare local
sizes against the Hub's declared sizes; that is how a 2.31 GiB → 2.40 GiB
overlay update was caught.
- **Do not run a 100 GB+ re-fetch concurrently with a big iGPU benchmark.** The
A* run commits ~116 GiB of host memory and leaves ~10 GiB available; adding
cache churn there risks the OOM/state failures this fleet has already seen.
- **Reboot is the repair of last resort** if rootless podman's pause process dies
during the work (`invalid internal status … could not find any running
process`); `podman system migrate` panics in 5.7.0.
- Because a peer session may be committing to `serverAppConfig` concurrently,
re-read a doc region immediately before editing it, and check that the edit
response's neighbouring lines are the ones you expect.
@@ -1,73 +0,0 @@
---
name: fleet-model-cache-purge-refresh
description: "Clear and re-fetch model payloads on a Dank-fleet host that serves production LLM endpoints — inventory, protect live payloads, verify every fetch, restore the service, and use the refresh to detect upstream updates."
---
Clearing a model cache on a serving host is a two-sided operation: the operator wants no stale
payload, and the host must keep serving. Do it in this order, on the target host.
## 1. Inventory before touching anything
```bash
du -sh /home/dank/models/* | sort -h # payload dirs, biggest last
grep -rlE "models/|<model-name>" /home/dank/.config/containers/systemd/ /etc/systemd/system/*.service
```
Classify every dir: **live service payload** (referenced by a unit/env), **retired runtime
payload**, **unreferenced model dir**, **tool/script dir**, **logs** (evidence — keep). Check the
service env files (`scripts/*.env`) for `MODEL_DIR`/`MODEL` paths, not just unit `Volume=` lines:
a payload can be mounted by path and named by env. `models/logs` and small tool dirs are not cache.
## 2. Record fingerprints first
`ls -l --block-size=1 <dir>` gives sizes; for the LLM payloads compare each to the Hub's declared
size (`/api/models/<repo>/tree/main?recursive=true&blobs=true`). They can differ — see §6. If a
file is irreplaceable upstream (delisted revision, gated repo) say so before deleting it.
## 3. Protect live payloads by delete-then-refetch, never delete-only
Deleting a live payload leaves the endpoint unable to start. Know the recovery path *before*
deleting: some engines fetch their own weights (halogen: `HALOGEN_DOWNLOAD`, triggered when the
checkpoint is absent), others need a manual fetch (llama.cpp GGUF services: the serve script
refuses to start without the weights).
**Bind-mount trap:** podman refuses a mount whose source directory is missing —
`Error: statfs /home/dank/models/<dir>: no such file or directory`, with the unit looping on
`Restart=on-failure`. Recreate the directory **empty** (`mkdir -p`), and the engine's first-start
fetch runs.
## 4. Fetch with verification (never trust an exit code)
`curl -o FILE URL` exits 0 on 401/403 and writes the error body — a gated repo produced three
153-byte shards with identical sha256s and a cheerful DONE line. Always: `-fL -C -`, compare the
byte count against the Hub's declared size, and compare `sha256sum` against the LFS oid. Public
repos means **no token in argv** (it is world-readable in `/proc/<pid>/cmdline`).
**Parts trap:** mradermacher-style repos ship large quants as `.part1ofN … .partNofN` — a filter on
`endswith('.gguf')` makes those quants look absent. Fetch the parts, verify each, then
`cat part1 part2 part3 > name.gguf` in numeric order; the assembled file's size equals the sum
(concatenation is deterministic, so per-part hashes verify the whole).
## 5. Restore, then verify behaviour — not health alone
Put the quadlet back (`mv` from trash + `systemctl --user daemon-reload`), `reset-failed`, `start`;
root units need `sudo`. Watch the engine's own fetch/load lines in the journal, then check
`/health`, `/v1/models`, and a **real completion** on every restored endpoint. Read the engine's
memory ledger (kv pool / weights / blocks left) rather than assuming the config still fits.
## 6. A refresh is also an update check
Clearing and re-fetching only guarantees freshness *at fetch time* — if upstream has not changed,
the bytes come back identical, which is itself the finding. Compare recorded fingerprints against
the new local sizes and the Hub's declared sizes: sidecars are often versioned **in place**, so a
stale copy is invisible from its file name (observed: `overlay.hgn` 2,477,677,120 → 2,572,466,560
while the main checkpoint returned byte-identical).
## 7. Close out
- Clear test payloads once the benchmark using them is done, and keep the payload the *next*
experiment needs.
- Leave the operator's decision points explicit: unreferenced-but-licensed dirs, other services'
caches, and whether they want the payloads absent at their next run or fresh as of now.
- Record sizes/verdicts in the vault (runbook + `log.md`); never `rm` without explicit permission,
and remember a same-filesystem trash reclaims no space.
@@ -1,79 +0,0 @@
---
name: fleet-monitoring-gap-audit
description: "Audit a git-declared homelab fleet for monitoring gaps: find services that should exist but silently don't (compose-only drift), classify each down target as outage vs probe defect, and check exporter bind addresses."
---
# Auditing a homelab fleet's monitoring for what *should* be there
Use after any reboot, or whenever Prometheus/Targets looks wrong on a fleet whose
services are declared in a git repo. The two failure modes this catches are
different, and only the first announces itself.
## 1. A service that should exist and doesn't is invisible
A missing exporter shows up as **nothing**: no failing target if the job was
configured for a host that no longer serves, no alert because the series is
simply absent. So check existence, not health:
```bash
# Every container the host actually runs, including dead ones
podman ps -a --format '{{.Names}} {{.Status}}'
# Every container that is supposed to be boot-persistent
ls ~/.config/containers/systemd/*.container
systemctl --user is-enabled <name>.service # expect: generated
```
A container in `ps -a` that is `Exited (…)` **and has no unit** is the classic
case: rootless Podman does not restart bare containers after the stop that
shutdown performs, so a compose-only service dies at the first reboot and stays
dead. Check the compose file for the name — if it is there and has no unit, the
repo's own rule is being violated. Fix by writing the unit (mirror the same
service on the other host), deleting the compose entry (one owner per container
name), and making sure the deploy path can reach it:
- planner: service in the target's service set + a path rule for its directory
- dispatcher allowlist: `<target>:<service>`
- handler: install from `"$TARGET/..."`, never a hardcoded peer's path
## 2. Classify every down target — do not report a count
`targets?state=active` gives health **and** the last error. Two down targets can
mean opposite things:
| Symptom | Meaning |
|---|---|
| `connection refused` | the service is not listening → real outage (check `podman ps`, logs, exit codes) |
| `404` on `/metrics` | the exporter path is wrong or metrics are disabled → config defect, service fine |
| `unsupported Content-Type …` | you are scraping an HTML *page* as if it were metrics → the probe is inert; Prometheus 3 refuses HTML. A "blackbox-style probe" needs a blackbox exporter |
| target absent entirely | see §1 |
```bash
curl -s "http://127.0.0.1:9090/api/v1/targets?state=active" | python3 -c "
import json,sys
for t in json.load(sys.stdin)['data']['activeTargets']:
if t['health'] != 'up':
print(t['labels']['job'], t['scrapeUrl'], (t['lastError'] or '')[:80])"
# and probe each by hand before believing the scraper
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://<target>/metrics
```
Say which are down and why. "7 of 11 up" without the four reasons is how a real
outage hides behind three config defects — that is exactly what one fleet did:
its crash-looping GPU service was one of four failures reported as a number.
## 3. Check the exposure posture while you are in there
An exporter that binds the wildcard publishes an unauthenticated API on every
interface. Expect exactly two listeners per host-exporter — loopback for
host-local probes, the host's own LAN address for the Prometheus container:
```bash
ss -ltn | grep <port> # must show the two explicit addresses, never 0.0.0.0
```
## 4. Leave the decisions that are not yours
Fix mechanical drift (missing unit, wrong path, wildcard bind). Do **not**:
disable a probe to make a dashboard green, or "fix" a crash-looping service whose
correct posture depends on what the owner wants — capture the evidence (restart
count, exit code, last error line) and hand it over intact.
@@ -1,97 +0,0 @@
---
name: fleet-second-llm-endpoint
description: "Add a second LLM endpoint to a host that already serves one on the Dank fleet (new port, unit shape, co-tenancy budget, harness provider/roles, monitoring, cutover, verification) — with the port-bind race, the reasoning_effort template trap, and the tools/--jinja trap that silently break subagents."
---
# Adding a second LLM endpoint to a Dank fleet host
Use when a host must serve two models at once (e.g. an iGPU runtime plus a
second card), or when one endpoint is moving and a new one takes the freed
hardware. Rebuilt from the 2026-09-16 Dankclaw split (halogen `frank-large` on
:7770 + llama.cpp `frank-small` on :7771).
## 0. Decide the shape before touching hardware
- **One provider per HOST:PORT** in `~/.omp/agent/models.yml` (on the box where
the harness runs, not on the servers). A second endpoint is a new provider
block, not a second model in the existing one.
- **Read the budget as an inequality, not a wish.** For a unified-memory runtime
the engine's own fit check is the arbiter (`budget = host − pinned weights −
reserve`; `need = KV + arena + vision`); for a discrete card, weight the KV by
counting only the full-attention layers — and remember `--parallel N` DIVIDES
`--ctx-size`, it does not multiply.
- **A co-tenant is a budget line, not a footnote.** Free host RAM by changing
the POOL or the ARENA (the reserve is a fit-check term, never an allocation),
and drop optional charges (a vision tower is ~2 GiB) rather than hoping.
## 1. Repo first, and retire in the same commit
1. New service files: `scripts/<svc>.env` (documented tunables), `.service`
(root unit for a native binary; a Quadlet only for a container),
`serve-<svc>.sh` (defaults + hard guards for missing artifacts).
2. Planner: add the service to `SERVICE_ORDER` AND the target's service set in
`scripts/deploy-plan.py`, map its paths (including "the unit/env/serve trio
selects only me"), add it to the inventories in `deploy.sh` and
`deploy-remote.sh`, and the dispatch case.
3. Retire what the cutover obsoletes into `.AGENTTRASH/` with a suffix — and
**check what pointed at it**: bench harnesses, a chat template only the old
runtime read, and `scripts/test-changed.sh` mappings are the usual victims
(a mapping to a test file retired earlier fails the pre-commit wrapper
silently for weeks).
4. Seams: one bats file per service asserting the *invariants* (`pool == 2 ×
ctx`, KV bytes ≤ card budget, port separation), not just constant values.
A negative grep must strip comments (`grep -vE '^\s*#' file | grep -q ...`)
or it will match the prose that explains the very thing you removed.
## 2. The port-bind race (rootless podman)
pasta binds the published port at container CREATE. A predecessor that has just
stopped still holds it, so a naive handler starts a doomed container:
```
Deactivated successfully <- predecessor gone
pasta: Listen failed for HOST TCP port */PORT: Address already in use
exit 126 <- only Restart=on-failure saves you
```
Order the handler: **retire the old manager's unit → stop THIS service's own
unit (no-op on a first cutover) → wait for every socket on the port to clear
(listener, accepted, TIME_WAIT) → install → start.** Without the second step a
redeploy deadlocks against its own gate.
## 3. Wire the surfaces, and refuse impossible signals
- Harness roles: point the small/fast + image roles at the new endpoint, and
move the vision role with the vision tower (only one endpoint should own
each capability).
- `shared/homepage/services.json`: a card per endpoint, probed from the
monitor's own host (loopback is legal there; the LAN address otherwise).
- Prometheus: only add a job if the runtime actually exposes Prometheus text.
Halogen publishes `/health`, `/cache` and `/v1/*` and nothing else — scraping
it would be red by construction, so the job is REMOVED with the reason written
down rather than left as a permanent red.
## 4. Cutover, then verify by driving it
```bash
./scripts/deploy.sh <target> --service <large> --service <small> # order matters
```
Then, per endpoint: `/health`; `/v1/models` reports the alias; a chat completion
returns content AND `reasoning_content`; **a request carrying `tools` returns
`tool_calls`**; an image request is answered (or refused with a named reason);
the runtime's own speculation counters move; and the unit is `enabled` /
`generated` with linger on. Read the engine's boot ledger for a silent clamp.
Finally spend one real request through the harness per changed role — routing
labels lie, the servers' own journals do not: `journalctl -u <svc>` shows which
endpoint actually served it.
## Traps that look like model faults
| symptom | cause |
|---|---|
| `500 tools param requires --jinja flag` | the endpoint's runtime lacks the Jinja chat-template path, so every tool-using agent on it is broken. mainline llama.cpp defaults it on — pin it |
| `500 Jinja Exception: Unexpected reasoning effort <level>` | the model's own template has a narrower accepted set (Qwen3.8: xhigh/medium/low only) than the harness's levels; fold them in `compat.reasoningEffortMap` |
| the same request works by hand but a subagent dies | subagents run at a different thinking level — check both |
| the container dies instantly although the predecessor "stopped" | the port race above |
@@ -1,64 +0,0 @@
---
name: fleet-service-precommit-iteration
description: "Iterate on a service that lives in the serverAppConfigs git checkout on a fleet host (dankclaw/dankserver) before committing: ship files for live testing, keep the checkout pullable, then commit and deploy — covers the untracked-files pull trap that recurs every session."
---
# Iterating on a fleet service before you commit
This repo's contract is git → `deploy.sh` → host. But live testing needs the new
file *on the host* before it is committed, and the naive way (copy it into the
host checkout) blocks the very deploy that would install it. This is the loop
that works, plus the traps that cost real time.
## The loop
1. Edit in the local repo. Run the seams: `./tests/run.sh`.
2. Ship the working files to the host **for testing**:
`cat local/file | ssh <host> 'cat > /home/dank/serverAppConfigs/<path>'`
(then `chmod +x` if it is executed — scp/`cat >` drops the exec bit, and a
container entrypoint mounted from that path fails with status 126, "command
found but not executable").
3. Test against the live service; iterate until it works.
4. **Before committing**, prove the host copy is byte-identical to what you are
about to commit: `sha256sum` locally and on the host. If they differ, you are
about to lose work in step 5.
5. Commit and push.
6. Clean the host checkout so the deploy can pull:
- untracked hand copies → move them out of the checkout
(`mkdir -p /tmp/handcopy && mv <paths> /tmp/handcopy/`);
- modified tracked files → `git checkout -- <paths>` (this *discards* the
hand copy, which is safe only because of step 4);
- confirm with `git status --short` printing nothing.
7. `./scripts/deploy.sh <target> --service <svc>` — it pushes, pulls ff-only,
installs units/config, restarts, waits for health, then runs the Playwright
e2e gate.
## Traps, in the order they bite
- **The pull aborts on hand copies.** `deploy.sh` pulls with ff-only and git
refuses: *"The following untracked working tree files would be overwritten by
merge"*. Any untracked file in the checkout whose path the pull wants to
create stops the deploy — which is exactly the file you just shipped. Always
do step 6.
- **`git checkout --` silently eats uncommitted work.** Without step 4 you can
destroy the only copy of an edit that exists nowhere else.
- **A runtime mounts files *straight out of the checkout*.** Here
`dankserver/frank-medium/**` and `shared/grafana/provisioning/**` are mounted
read-only into containers, so a hand copy takes effect at the next container
restart with no deploy. Test that way, then let the deploy make it official.
- **Root-owned files from a container write** (e.g. a patched `.so` produced
inside a build container) can land in the checkout as `root:root`; `chown` them
back to the deploy user before git operations.
- **A restart is not a verification.** Health endpoints answer from a stale
process, a proxied service can be served by a peer, and a provisioned
dashboard may live in unified storage rather than the SQLite table you grep.
Verify the *specific* claim: the new code path's own output, the file's
checksum on the host, the API that serves the resource.
## Why not keep a scratch copy on the host?
Because the deployed artifact must be the committed one. Iterating outside git
(the `/tmp` loop is fine for *probes*) is for measurement scripts; anything the
service actually runs has to land in the checkout, and the checkout has to stay
pullable. Two directories, one rule: probes may live anywhere, runtime state
lives in git.
@@ -1,27 +0,0 @@
---
name: flm-npu-pack-registration
description: "Runbook for registering a custom FastFlowLM NPU2 model pack on a tag that isn't in FLM's built-in catalog (Dankclaw :7771), including the LFS-pointer and vision-weight traps and the NPU hwctx contention rule."
---
# FLM custom-tag pack registration (Dankclaw NPU endpoint)
Use when adding a non-catalog NPU2 pack (e.g. an extraction or fine-tuned model) to the FLM registry served by `frank-npu` on port 7771. Companion diagnosis skill: `flm-tag-load-failure-diagnosis`.
## Registry facts
- User registry: `/home/dank/.config/flm/model_list.json`, shape `{"model_path": "models", "models": {"<tag>": {"<size>": {name, url, file_url, size, flm_min_version, files[], vlm, default_context_length, max_prefill_len, details{format:"NPU2", family, ...}, label[]}}}}}`.
- Serve resolves tags through `FLM_CONFIG_PATH` at startup; `flm list` with the env var set lists custom tags (`⏬` = not downloaded).
## Procedure
1. Back up the registry JSON first (`cp -a`).
2. Insert the entry via a python json edit + atomic `os.replace`. Set `files` to the FULL HF file list — **including `vision_weight.q4nx`** even for text-only packs: the qwen3.5-family loader opens it unconditionally and a missing file makes the first request HANG silently (no error log).
3. **Do not use `flm pull <tag>` for custom entries** — it throws `json.exception out_of_range key '<tag>:<size>' not found` while building the download list. Place files manually into `/home/dank/.config/flm/models/<PackName>/` with:
`curl -sfL "https://huggingface.co/<repo>/resolve/main/<file>?download=true" -o <file>`
Plain `raw/main/<file>` returns 135-byte LFS pointer stubs, not weights — verify byte counts against the HF tree API before trusting a download.
4. Kernel packs: copy from the same-shape family pack (shape-match `config.json` on hidden_size/num_hidden_layers/head_dim first) into `/opt/fastflowlm/share/flm/xclbins/<PackName>/` via `sudo cp -a` (root-owned dir).
5. Deploy/restart the serve unit (deploy path, not ad-hoc restarts) so the new process loads with all files present.
6. Verify with a REAL completion request on 7771 (`model: "<tag>:<size>"`) — `/v1/models` alone proves registration, not loadability.
## Hard constraints
- Only ONE `flm serve` process can hold the NPU hardware context. A second concurrent serve process fails every load with `DRM_IOCTL_AMDXDNA_CREATE_HWCTX err=-22` (aie2 mgmt msg opcode 0x2 status 0x2000003). Never run scratch/verify instances while production holds the NPU — verify via the deployed service instead.
- Tag switching happens INSIDE one serve process; first request after a model switch pays ~6–11 s cold-load cost (visible in `usage.load_duration`).
- Over ssh, never `pkill -f "<pattern>"` with a pattern present in your own command line — it kills your own session; use bracket-guarded patterns (`[q]wen3.5:4b`) or exact PIDs.
@@ -1,34 +0,0 @@
---
name: flm-tag-load-failure-diagnosis
description: "Diagnose FLM (FastFlowLM) NPU serve failures where a tag listed in /v1/models answers \"Failed to load model: No such file ...xclbin\" — missing kernel-pack symlink under FLM_XCLBIN_PATH is the root cause; includes per-request switch-cost behavior."
---
# FLM tag load-failure diagnosis (Dankclaw sight+sound endpoint)
## Symptom
`POST /v1/chat/completions` on the FLM serve endpoint answers **HTTP 200** with a flat error body `{"error":"Failed to load <tag> model!"}` and no `choices`. `/v1/completions` instead returns a nested OpenAI-shaped `server_error` (code 500) also under HTTP 200. A status-code-only client reads this as a successful empty completion — always check for the `error` key.
Two distinct causes, distinguish via `journalctl -u frank-default-sightsound.service`:
1. **Unknown tag** (not in the registry): immediate error, nothing loaded. Correct behavior; not a bug.
2. **Registered tag whose kernel pack is missing**: FLM resolves the tag to its pack dir, downloads missing weight files on the fly (first request hangs ~90–110 s downloading), then fails with `Failed to load model: No such file '/home/dank/.config/flm/xclbins/<PackName>/<kernel>.xclbin'`. Subsequent attempts fail fast (~2 s).
## Root cause of case 2
The serve script exports `FLM_XCLBIN_PATH=/home/dank/.config/flm/xclbins` (user dir), so **every** kernel lookup resolves under the user dir — but that dir only contains symlinks for the packs someone linked. A tag listed in `/v1/models` is NOT proof it is loadable on this host.
## Fix
One symlink per missing pack, run on Dankclaw:
```
ln -s /opt/fastflowlm/share/flm/xclbins/<PackName> /home/dank/.config/flm/xclbins/<PackName>
```
Verified 2026-08-30: `Qwen3.5-4B-NPU2` — after the symlink, first request loaded and answered in 7.4 s wall (cold load + generate).
## Switch-cost behavior (measured same day)
- The `model` field routes per-request; no restart needed to switch between registry tags.
- A request whose model differs from the resident one triggers a full reload ("Loading model:" in journal before prefill): measured 7–11 s wall for an 8-token completion on ~4B-class packs. Same-tag requests are fast; alternating tags pays the swap each time.
- Single-lane NPU: concurrent requests queue FIFO behind the current lock holder (`NPU Locked!` → `NPU Lock Released!`).
## Probe recipe (repeatable)
1. `curl -s http://192.168.0.143:52626/v1/models` — registry listing is authoritative for *registered* tags, not loadability.
2. On a failed load, grep the journal for `Failed to load model` (use `--full`) — the quoted xclbin path names the missing pack dir exactly.
3. Compare `ls /home/dank/.config/flm/xclbins/` (symlinks = installed) against `/opt/fastflowlm/share/flm/xclbins/` (all available packs); diff = fixable with symlinks.
@@ -1,95 +0,0 @@
---
name: frank-medium-batch-split-retune
description: "Retune frank-medium (Dankserver, llama.cpp layer split across 2x R9700) batch/ubatch geometry and the tensor-split balance: sweep with scripts/bench-batch-ubatch.sh, re-measure the split at the deployed shape behind a VRAM settle gate, re-fit the seam, deploy, verify."
---
# frank-medium batch geometry + split rebalance
Use when changing `--batch-size`, `--ubatch-size`, `--ctx-size`, `--parallel` or the
model on frank-medium, or when someone reports that prefill "does not respond" to
those knobs. The batch and the tensor split are **coupled**: the compute buffers are
not symmetric across the two cards, so any batch change moves the balance point.
## Order of work (do not skip the second half)
1. **Sweep the batch geometry with llama-bench**, which is the only tool that can hold
a shape constant across dozens of configs:
`scripts/bench-batch-ubatch.sh`, defaults `-p 65536 -n 128 -d 65536`, one invocation
per cell, `-r 3 --no-warmup`.
2. **Re-measure the split at the DEPLOYED shape** (262144, two slots, drafter loaded) by
loading the real service with a per-config env override and reading `rocm-smi`. A
llama-bench number cannot decide the split: it has no `--parallel`, sizes its own
context as `n_prompt + n_gen + n_depth`, and cannot load the drafter.
3. **Re-fit the balance seam** in `tests/frank_medium_unit.bats` on the new deployed
point, then **deploy** and verify.
## 1. The sweep
```bash
# from a transient unit, never a shell: the deploy's LimitMEMLOCK=infinity is
# load-bearing for ROCm staging buffers and an SSH shell gets 8 MiB here
sudo systemd-run --unit=fm-bench --collect --pipe --property=Type=oneshot \
--property=LimitMEMLOCK=infinity --property=User=dank \
--setenv=OUT=/home/dank/models/logs/bench-bub \
--setenv=BATCHES=6144 --setenv=UBATCHES="256 384 512 768 1024 1536 2048" \
/home/dank/serverAppConfigs/scripts/bench-batch-ubatch.sh
```
The script stops `frank-medium`, runs one `llama-bench` per cell into `cells.tsv`
(appended as it goes, so progress is readable), samples VRAM per cell, and restarts
the service from an EXIT trap. A re-run skips cells that already have a `bench.json`.
Read `n_ubatch` out of each `bench.json` to prove the knob was applied — if it does not
track the request, nothing else in the table means anything. The VRAM columns scaling
monotonically with ubatch is the independent confirmation.
Expected shape (2026-09-16, 2x R9700, UD-Q8_K_XL + DFlash2, 64k prefill at 64k depth):
an optimum at **ubatch 512** (~1193 tok/s) with losses on both sides (256 -> 1046,
384 -> 1068, 2048 -> 1077). The logical batch is throughput- and VRAM-neutral
(2048..8192 within 2 %), and decode never moves (15.88 tok/s at depth 65536 on every
cell). Test **both** ends of the range: sampling only upward makes a shallow basin look
like a plateau.
Sanity rule: the same ubatch measured under different logical batches should agree to
under 1 tok/s. If it does not, the table is noise and the first suspect is a stray
GPU user, not the knob.
## 2. The split, at the deployed shape
`/tmp/measure-ub512b.sh` (pattern; rewrite per run) copies `scripts/frank-medium.env`
to a temp file with `sed` overrides, launches `scripts/serve-frank-medium.sh` with
`FRANK_MEDIUM_ENV_FILE=<temp>`, polls `/health` for `"status":"ok"`, reads
`rocm-smi --showmeminfo vram --csv`, kills, and repeats per split. Run it under
`systemd-run --pipe --property=LimitMEMLOCK=infinity --property=User=dank`.
**The settle gate is mandatory.** Between configs, wait until `rocm-smi` shows BOTH
cards under ~1 GiB used before the next load. amdgpu does not return ~26 GiB when the
process dies — a 180 s sleep fails, and even 600 s has failed while the process is still
alive inside the unit. Skipping the gate produces
`error loading model: unable to allocate ROCm0 buffer`, which is indistinguishable from
"this split does not fit" and will send you chasing a phantom OOM.
Free bytes to expect, and the arithmetic behind them:
`card = weights_share + KV_share + constant + ubatch_term`, where the ubatch term costs
the drafter-carrying card roughly **1.85x** what it costs the other (measured 2.41 GiB
against 1.30 GiB going 1536 -> 512). Balance by measurement only: one `-ts` step is one
layer, worth ~0.43 GiB on a DeltaNet boundary and ~1.43 GiB on an attention one, so the
reachable minimum gap is about one DeltaNet layer, never zero.
2026-09-16 reference: 36,29 @1536 -> 4.71 / 4.91; 36,29 @512 -> 6.01 / 7.32;
**35,30 @512 -> 6.44 / 6.90 (deployed)**.
## 3. Seam, deploy, verify
- Re-fit both ubatch coefficients in `tests/frank_medium_unit.bats` from the measured
1536 -> 512 deltas at the deployed shape, and re-anchor the per-card constants so the
model reproduces the new point. Check that neighbouring splits still FAIL the balance
gate, or the seam has stopped discriminating. Keep the floor at 1.5 GiB/card and the
slack at one DeltaNet layer.
- Do not pin a literal split elsewhere: the unit test asserts the *shape* (two shares
summing to the model's 65 blocks, ROCm1 the smaller).
- `./tests/run.sh`, commit, `./scripts/deploy.sh dankserver --service frank-medium`.
- Verify live: the flags in `/proc/<pid>/cmdline`, `/slots` = 2 x 131072, VRAM matching
the pre-deploy measurement, a tool call, two concurrent requests, `draft acceptance`
lines on BOTH slots, and 0 ROCm errors in the journal. The VRAM figure sits ~0.2 GiB
lower once the service has served traffic than straight after load.
@@ -0,0 +1,22 @@
---
name: frank-primary-decode-tuning
description: "Tune frank-primary (radiance TP=2) decode throughput: benchmark harness usage, flag levers, known optima and boot-failure walls."
---
# frank-primary decode tuning
Harness: `bash autoresearch.sh` (dev box) scp's `scripts/frank-primary.env` + `scripts/autoresearch-frank-primary.sh` to dankserver, stops prod (`systemctl stop frank-primary`, NEVER mask — relinked unit), boots scratch container `arbench` on :7770 (fresh prefix-cache tier at `$STATE_DIR/kvcache/arbench` for cold prefill), polls health 720s, sends ONE greedy completion (fixed 140k-char repo-corpus prompt -> 43,627 tokens, 256-token decode), parses `timings` into METRIC lines, tears down, restores prod via EXIT trap. ~4 min/run; decode noise ~0.3%.
Metric: `METRIC decode_tps` = `timings.predicted_per_second` at 43.6k depth.
## Optima (measured 2026-10-06, keep these)
- `FRANK_PRIMARY_SPEC_TOKENS=2` (unimodal: 1->31.89, 2->32.40, 3->31.72, 4->24.57 t/s; acceptance collapses past 3)
- `FRANK_PRIMARY_MAX_SEQS=3` with `MAX_MODEL_LEN=200000` (600k KV; +4.2% total; 4 slots regresses)
- `FRANK_PRIMARY_EXPERT_VS_CACHE=0.82` bracket-confirmed (0.78 fails boot, 0.84 -8.9%, 0.87 collapse)
- `TP_WIRE=wht6`, `MAX_BATCHED_TOKENS=2048` — REQUIRED at seqs=3: bf16 wire and MNBT 4096 both fail boot (VRAM)
## Non-levers (decode-neutral, don't spend runs)
GPU_HEADROOM_MIB (48 = neutral), HOST_POOL_MIB 49152 (neutral), MAX_MODEL_LEN any change (regresses: bigger KV steals resident weights), prefix-cache tiers (prefill-side).
## Rules
One flag per iteration; keep only > +1% decode over best; a boot failure is `crash` (trap restores prod). Constraint floor: MAX_SEQS*MAX_MODEL_LEN >= 400000.
@@ -1,57 +0,0 @@
---
name: frankbook-qbittorrent-e2e-verify
description: "Verify the FrankBook-Pro gluetun→qBittorrent grab pipeline end-to-end (egress gate inside gluetun, generic SID login, legal test torrent, category path check, data-safe cleanup) after any tunnel/qB/category change"
---
# FrankBook-Pro qBittorrent E2E verification
Use after any change to the FrankBook-Pro torrent pipeline — WireGuard credential rotation, qB version change, gluetun reconfig, category re-seeding. Proves the full grab path (tunnel egress → qB download → category save path → clean removal). All commands run from Dankware via `ssh frankbook-pro`; inside the Mac shell export `PATH="/usr/local/bin:/opt/homebrew/bin:$PATH"` for `podman machine ssh`.
## 1. Egress gate — MUST run inside gluetun
```bash
podman machine ssh 'podman exec gluetun wget -qO- https://ipinfo.io/json'
```
- Require `org` = AS9009 (M247 — ProtonVPN Panama PA-12 config). Judge by org, never country alone.
- NEVER run this one ssh-context out (e.g. bare `wget` on the Mac host): it silently returns the residential Comcast IP and reads as a hard-gate leak that does not exist. If a check shows a residential IP, first re-run inside the container before diagnosing.
## 2. Login — generic cookie parse
The QPW fetch is flaky (~1 in N returns empty); assert length before use. An empty-password run once produced a Forbidden loop that cost hours.
```bash
QPW=$(podman machine ssh 'cat /home/core/Containers/qbittorrent/env' | grep QBIT_WEBUI_PASSWORD | cut -d= -f2)
[ ${#QPW} -eq 20 ] || exit 1
SID=$(curl -si --data-urlencode "username=admin" --data-urlencode "password=$QPW" \
http://127.0.0.1:8080/api/v2/auth/login \
| grep -oE '[A-Za-z_]*SID[A-Za-z0-9_]*=[^;[:space:]]*' | head -1)
curl -b "$SID" ... # pass the WHOLE match
```
- qB 4.x cookie name is plain `SID`; qB 5.x uses `QBT_SID_<port>`. Hard-coding either name breaks the other version's parse (sidlen=0 → 403 cascades).
- Parse-bug 403s never reach qB as failed logins — no ban accrues. Check server-side truth in the log: `/Volumes/COMPLETE/qbittorrent-data/qBittorrent/config/qBittorrent/logs/qbittorrent.log` (`WebAPI login success/failure` + source IP). Repeated REAL failures accrue toward qB's 5-failures/3600s IP ban.
## 3. Add a legal test torrent
Blender open-movie torrents (~300 MB, e.g. Big Buck Bunny) via `POST /torrents/add` with the raw `.torrent` file (`-F "file=@..."`) and `category=Finished Torrents`. Never leave a real-looking grab behind.
## 4. Poll and verify
Poll `GET /torrents/info` (~20 s): state `downloading` → `moving` (INCOMPLETE→COMPLETE move) → `stalledUP`/`uploading` at progress=1. `save_path` must be `/Volumes/COMPLETE/Finished Torrents` (qB 4.x auto-creates unknown categories at DefaultSavePath; 5.x did NOT inherit category paths — one more reason for the 4.6.7 pin). Confirm on disk: `ls "/Volumes/COMPLETE/Finished Torrents/"`. Throughput ~17 MB/s is normal for the tunnel.
## 5. Cleanup — delete only your artifact
```bash
curl -s -o /dev/null -w "%{http_code}" -b "$SID" \
--data-urlencode "hashes=<literal-hash>" \
--data-urlencode "deleteFiles=true" \
http://127.0.0.1:8080/api/v2/torrents/delete # expect 200
```
- Target the known hash literally — the user's real grabs share the directory and MUST be untouched. Verify: hash absent from `/torrents/info`, your dir gone, all other entries intact.
## Version-coupling constraints
- qB is pinned to 4.6.7 (readarr's EOL client rejects qB ≥ 5's `204` login at runtime). Do not unpin while readarr exists.
- After ANY qB version flip, regenerate the `WebUI\Password_PBKDF2` line fresh (same password, 100k iterations) — a 5.x-written hash line makes 4.6.7 reject every login. Rewrite while qB is stopped.
@@ -1,55 +0,0 @@
---
name: freetoken-build-dankclaw
description: "Build FreeToken on Dankclaw (Ubuntu 24.04) with CUDA — installs nvidia-cuda-toolkit, sets CUDA_HOME=/usr, overrides nvcc/CUDA version mismatch"
---
# FreeToken Build on Dankclaw
Dankclaw is Ubuntu 24.04 with ROCm already installed. FreeToken builds for **CUDA** (not ROCm) because the kernel package (`_pinned_tensor`) links against `cudart`.
## Prerequisites (install if missing)
```bash
export PATH="/home/dank/.local/bin:$PATH"
export CUDA_HOME=/usr
export FREETOKEN_ALLOW_CUDA_MISMATCH=1
# CUDA toolkit (if not present):
sudo apt-get install -y nvidia-cuda-toolkit
# UV (if not present):
curl -LsSf https://astral.sh/uv/install.sh | sh
```
## Key environment variables
|Variable|Value|Why|
|---|---|---|
|`CUDA_HOME`|`/usr`|Ubuntu CUDA toolkit package places nvcc at `/usr/bin/nvcc` and libcudart at `/usr/lib/x86_64-linux-gnu/`|
|`FREETOKEN_ALLOW_CUDA_MISMATCH=1`|Set|nvcc is 12.4 but torch 2.11.0+cu130 ships CUDA 13.0 — safe to override|
|`PATH`|`/home/dank/.local/bin:$PATH`|uv installs here|
## Build procedure
```bash
cd /home/dank
git clone https://github.com/FlashML-org/FreeToken.git
cd FreeToken
uv venv && source .venv/bin/activate
uv pip install -e ".[accel]"
```
## Verification
```bash
ft --version # → freetoken version 0.1.2
python3 -c "import torch; print(torch.__version__)" # → 2.11.0+cu130
python3 -c "from freetoken.kernel import _pinned_tensor; print(_pinned_tensor.__file__)"
python3 -c "import flashinfer; print(flashinfer.__version__)" # → 0.6.18
python3 -c "import sgl_kernel; print(sgl_kernel.__version__)" # → 0.4.5
```
## Notes
- `torch.cuda.is_available()` may return `False` with "Can't initialize NVML" warning — this is the management library, not CUDA runtime. The kernels are built and linked correctly.
- The NVIDIA driver modules may or may not be loaded; they don't affect the build.
- ROCm is present on Dankclaw but FreeToken's kernel build specifically requires CUDA (links `cudart`).
@@ -1,39 +0,0 @@
---
name: freetoken-dankclaw-build
description: "Build FreeToken on Dankclaw — installs CUDA toolkit, sets CUDA_HOME, handles nvcc/CUDA version mismatch"
---
## Build FreeToken on Dankclaw
### Prerequisites
- CUDA toolkit: `sudo apt install nvidia-cuda-toolkit` (gives nvcc at /usr/bin, libcudart at /usr/lib/x86_64-linux-gnu/)
- uv: installed at /home/dank/.local/bin/uv (or install via `curl -LsSf https://astral.sh/uv/install.sh | sh`)
- CMake, git, Python >= 3.10 — all present
### Build steps
```bash
cd /home/dank
git clone https://github.com/FlashML-org/FreeToken.git # or cd into existing
cd FreeToken
uv venv && source .venv/bin/activate
export CUDA_HOME=/usr
export FREETOKEN_ALLOW_CUDA_MISMATCH=1 # nvcc 12.4 + torch cu130 is safe
uv pip install -e ".[accel]"
```
### Verification
```bash
ft --version
python3 -c "
from freetoken.checkpoint import ftw
import torch
t = torch.randn(3,4,dtype=torch.float16)
print(ftw._dtype_str(t.dtype), ftw._elsize(t.dtype))
"
```
### Key notes
- FTW format is dtype-agnostic: FP16, BF16 (default), FP32, FP8 all work
- GGUF support: only Gemma4GGUFForCausalLM
- GGML types: F32/F16/BF16 (native views), Q4_0/Q6_K (dequant), Q8_0 (listed, no dequant)
- The NVIDIA driver modules may not be loaded at build time (NVML warning) — not a problem for the build itself
@@ -1,36 +0,0 @@
---
name: freetoken-hf-model-swap
description: "Swap the FreeToken model behind frank-default-small on Dankserver: preflight the HF quant format against the loader matrix BEFORE downloading, then download, repoint MODEL_DIR, deploy, and verify with a REAL completion (health 200 is not enough)."
---
# FreeToken HF Model Swap (frank-default-small, Dankserver)
Swap the model behind `frank-default-small` (FreeToken v0.1.2, Dankserver, RTX 3090) by downloading a HF safetensors checkpoint into a model dir, repointing `MODEL_DIR` in `scripts/frank-default-small.env`, and deploying. Verify with a REAL completion — health 200 is NOT enough.
## Preflight — verify quant format BEFORE downloading
FreeToken v0.1.2 loads ONLY modelopt-style quant configs for `qwen3_5_moe`. Every compressed-tensors FP8 checkpoint fails (live-verified 2026-09-01: two candidates, 72 GB downloaded, both unloadable). Check before any multi-GB download:
```bash
curl -sL https://huggingface.co/<org>/<repo>/raw/main/config.json | \
python3 -c "import json,sys; c=json.load(sys.stdin); q=c.get('quantization_config',{}); print(q.get('quant_method'), q.get('weight_block_size'), list((q.get('weight_scale') or ['?','?'])) if isinstance(q.get('weight_scale'),list) else q.get('weight_scale_inv') is not None)"
```
Decision matrix:
- `quant_method: "fp8"` + `weight_block_size: [128,128]` + `weight_scale_inv` → ✅ modelopt block-fp8 (the Huihui/official-Qwen format; only proven FP8 path)
- `quant_method: "compressed-tensors"` → ❌ FAILS. Per-channel aborts at expert-source resolution (`Missing MoE expert source layers: {'gate_up'}` when expert tensor naming is nonstandard); block-fp8 128×128 aborts in `_dequant_fp8_weight` (multiplies `weight × weight_scale` directly; block scales don't broadcast → shape RuntimeError)
- No quant config (BF16) → loads but 35B = ~70 GB weights >> 62 GB system RAM → sustained swap-thrash that locks out SSH (2026-09-01 outage). Never deploy BF16 for a 35B-class model on this box; a clean preflight compares disk size × precision against free RAM + 24 GB VRAM headroom.
- Also check: architecture `qwen3_5_moe`, expert tensor naming `mlp.experts.{N}.gate_proj/up_proj/down_proj` (read `model.safetensors.index.json` weight_map on HF if unsure — nonstandard prefixes defeat the MoE expert-source provider), config.json ships the chat template you want (FreeToken reads it from the checkpoint), disk space ≥ checkpoint size.
If the checkpoint fails the matrix, STOP — do not download, do not touch the running service. Find a compliant quant or use a different runtime.
## Swap steps
1. Download into `/home/dank/models/<name>/` via `snapshot_download` with the freetoken venv python (background the SSH call; poll until file count matches the index).
2. Edit `scripts/frank-default-small.env`: `MODEL_DIR`, description line (name, quant, arch, disk size, host, date), header comment. Keep F84 pool numbers unless the weight mass or KV dtype changed (FP8 35B ≈ 35 GB: `MODEL_CACHE_SIZE=2048`, `NUM_TOKENS=453005`, reasoning `on`; BF16 66 GB fits nothing — see preflight).
3. Run `./tests/run.sh`, commit, push to Gitea origin (git-only flow; never edit on the host).
4. `./scripts/deploy.sh dankserver --service frank-default-small`. The LLM_WAIT window is up to 20 min; load of ~35 GB FP8 is minutes warm.
5. **Verify for real** — the deploy health gate has reported "healthy" while a doomed worker was still starting (twice). Check journal for Uvicorn startup complete, then `curl /v1/models` (root = your model dir, ctx 262144) and a REAL completion: reasoning must parse into `reasoning_content` and content must answer correctly with a natural `finish_reason: stop`. Give reasoning models enough `max_tokens` to finish thinking (a tight budget yields empty content + `finish: length` — that is correct behavior, not a failure).
6. If load fails: journal shows the exact loader error, service exits cleanly (no swap-thrash), `Restart=on-failure` re-loop is harmless. Revert env via `git checkout <last-good-commit> -- <three files>`, redeploy, verify.
7. Update the runbook (`serverAppConfigs-docs/LLM/freetoken-frank-default-small.md` quant matrix + history), `log.md`, and AGENTS.md fleet table; stage superseded model dirs to `~/.AGENTTRASH/<date>-topic/` on the host and list sizes for the user — never `rm`.
@@ -1,49 +0,0 @@
---
name: freetoken-rocm-build
description: "Verify and execute FreeToken build on ROCm hosts — check gfx1151, ROCm 6.3, hipcc, and dependency wheel availability (torch 2.11, sgl-kernel 0.4.5, flashinfer)"
---
# FreeToken ROCm Build
## When to use
Verify FreeToken can build on an ROCm host (Dankclaw, similar systems) before committing to the build path.
## Steps
1. **Check hardware**
```
rocminfo | grep 'Name.*gfx' # verify GPU architecture
lsmod | grep amdgpu # driver loaded
ls /opt/rocm/bin/ | grep hipcc # compiler available
```
2. **Check build deps**
```
cmake --version # needs >=3.20
python3 --version # needs >=3.10
gcc --version # needs >=9
ninja --version # optional but recommended
```
3. **Verify wheel availability**
```
pip index versions torch # needs 2.11-2.12 range
pip index versions sgl-kernel # needs 0.4.5
pip index versions flashinfer-python # needs >=0.6
```
Check PyPI for `rocm` in wheel filenames, or use PyTorch ROCm index.
4. **Build**
```
git clone https://github.com/FlashML-org/FreeToken.git && cd FreeToken
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[accel]" # installs fi + sgl extras
```
## Known issues
- nvcc missing on Dankclaw — not needed for ROCm build, only for CUDA JIT
- sgl-kernel at versioned endpoint may return 404 — use unversioned `pip index versions`
- triton 3.6.0 pins to Linux only
## CUDA alternative
If building for CUDA: install `sudo pacman -S cuda` for nvcc, or use pre-built CUDA wheels that ship pre-compiled kernels.
@@ -1,94 +0,0 @@
---
name: grafana-prometheus-provisioning-as-code
description: "Add or change Grafana datasources/dashboards and Prometheus scrape jobs/alert rules in this homelab repo as code — including the planner+copier+seam triad, the silent failure modes, and the API-based verification that avoids two false negatives."
---
# Observability as code (Grafana + Prometheus, Dankclaw fleet)
Use when adding a scrape target, an alert rule, a datasource, or a dashboard to
this homelab. Everything lives in git; Grafana and Prometheus only ever read
what the deployer installs.
## The triad — every change touches three places
1. **Config in the repo**
- `shared/prometheus/prometheus.yml` — `scrape_configs`, and `rule_files:`
pointing at `/etc/prometheus/rules/*.yml`
- `shared/prometheus/rules/<name>.yml` — alert/recording rules
- `shared/grafana/provisioning/datasources/prometheus.yml` — datasource
- `shared/grafana/provisioning/dashboards/{provider.yml,<name>.json}`
2. **The copier in `scripts/deploy-remote.sh`** — `copy_prometheus_config`
installs config **and** rules into `/opt/prometheus/config/` (the container
mounts that to `/etc/prometheus`); `copy_grafana_provisioning` installs into
`/opt/grafana/provisioning/` (the grafana unit already mounts it read-only).
Paths are absolute and `run_privileged`.
3. **The planner in `scripts/deploy-plan.py`** — a `shared/<group>/**` change
must select the service that reads it (`shared/prometheus` → prometheus,
`shared/grafana` → grafana). Missing mapping = the file deploys nowhere and
nothing reports an error.
Add a seam in `tests/observability_quadlet.bats` for each change: the file
exists, the config references it, the deployer copies it, and — where two files
must agree (target vs published port) — that they do.
## Reach targets by container name, never the loopback publish
Datasources use `http://prometheus:9090` and scrape jobs for co-located
services use `prometheus:9090` / `loki:3100`. Published ports are
`127.0.0.1`-only by design (see the exposure rules), so a browser-facing URL or
a published port in a datasource is wrong from inside the network. LLM
endpoints are the exception: they are on other hosts, so use their LAN address.
## Three failure modes that produce no error message
- **A rule file nothing loads evaluates nothing.** Prometheus reports no
problem for a `rule_files` entry that matches nothing, or for a rules file
nobody references. Ship both together and assert the reference in a seam.
- **A provisioned dashboard is not in the legacy `dashboard` table** on the
current Grafana. Reading `grafana.db` for it returns empty and looks like a
failed provision. It lives in unified storage behind
`dashboard.grafana.app`; query the API instead.
- **Lifetime averages are not per-request evidence.** e.g. an engine's
"Prefix cache hit rate: 29 %" is every prompt it ever saw, including
benchmarks. Measure the behaviour you care about with a targeted probe.
## Verify through the channel the consumer uses
```bash
# Prometheus: config and rules parse/load
ssh dankclaw 'podman exec prometheus promtool check rules /etc/prometheus/rules/<name>.yml'
ssh dankclaw 'curl -s "http://127.0.0.1:9090/api/v1/rules?type=alert" \
| python3 -c "import json,sys; print([r[\"name\"] for g in json.load(sys.stdin)[\"data\"][\"groups\"] for r in g[\"rules\"]])"'
ssh dankclaw 'curl -s "http://127.0.0.1:9090/api/v1/targets?state=active" | ...' # every target health=up
ssh dankclaw 'curl -s "http://127.0.0.1:9090/api/v1/alerts" | ...' # 0 firing when healthy
# Grafana: provisioned objects and a live query (admin/admin on this install)
ssh dankclaw 'curl -s -u admin:admin "http://127.0.0.1:3002/api/search?type=dash-db"'
ssh dankclaw 'curl -s -u admin:admin "http://127.0.0.1:3002/api/dashboards/uid/<uid>"'
ssh dankclaw 'curl -s -u admin:admin \
"http://127.0.0.1:3002/api/datasources/proxy/uid/prometheus/api/v1/query?query=count(up==1)"'
```
A target that is `up` proves reachability, not data: also run one query for a
series you expect and confirm it returns a sample.
## Grafana facts for this install
- No UI-configured state: `editable: false` on the datasource, fixed
`uid: prometheus` so dashboards reference it without a lookup.
- Dashboards re-read from disk every 30 s (`updateIntervalSeconds`), so a JSON
edit appears without a restart — but the file must still be *installed*, which
means a deploy.
- There is **no Alertmanager and no contact point**: rules evaluate, alerts show
on `/alerts`, in the `ALERTS` metric and on a dashboard panel, and reach
nobody. Say that plainly in docs rather than implying observability.
## Naming metrics
Never write a series name from memory into a config, a rule, or a dashboard —
one shipped comment here named a metric that does not exist. Dump the live
names first and paste them:
```bash
ssh dankserver 'curl -s http://127.0.0.1:7770/metrics | grep -E "^vllm:" | awk "{print \$1}" | sed "s/{.*//" | sort -u'
```
@@ -1,92 +0,0 @@
---
name: grafana-provisioning-as-code
description: "Provision a Grafana datasource or dashboard as code on the Dankclaw fleet (shared/grafana/provisioning, deployer copier, planner, seams) and verify it through the Grafana API — including the unified-storage trap that makes the grafana.db check a false negative."
---
# Grafana provisioning as code (Dankclaw fleet)
Use when adding or changing a Grafana datasource or dashboard on this fleet, or
when a dashboard "does not appear" after a deploy.
## Shape of the thing
Grafana on Dankclaw is an **empty install configured entirely from git**. The
quadlet mounts `/opt/grafana/provisioning` read-only into the container, so a
provisioned artefact exists in exactly three places:
```
shared/grafana/provisioning/datasources/<name>.yml # repo: source of truth
shared/grafana/provisioning/dashboards/provider.yml # dashboard loader config
shared/grafana/provisioning/dashboards/<name>.json # dashboards
| deploy-remote.sh: copy_grafana_provisioning()
v
/opt/grafana/provisioning/... # host: installed state
| read-only mount
v
/etc/grafana/provisioning/... # generated: inside container
```
Nothing is ever clicked into `grafana.db`. A UI-configured datasource would be
invisible to review, unknown to anyone reading the repo, and lost with the data
volume.
## Procedure
1. Write the files under `shared/grafana/provisioning/`.
- Datasource: `apiVersion: 1`, a **fixed `uid`** (dashboards reference it by
uid, so a random one breaks them), `access: proxy`, and the URL by
**container name** (`http://prometheus:9090`) — the published port is
loopback-only and unreachable from inside the network.
- Diagram: `provider.yml` with `allowUiUpdates: true` (three-person homelab;
failing an operator who wants a different rate window is the wrong trade)
and a JSON dashboard under a stable `uid`.
2. Add the file(s) to `copy_grafana_provisioning()` in
`scripts/deploy-remote.sh` (it `mkdir -p`s the two directories and `cp`s each
file — follow `copy_prometheus_config` for style).
3. Planner: `shared/grafana/**` → `grafana` in `scripts/deploy-plan.py`; verify
with `python3 scripts/deploy-plan.py --target dankclaw --paths <file>`.
4. Add/update a seam in `tests/observability_quadlet.bats`: the JSON must parse,
the datasource URL must be the container name, the unit must mount
`/opt/grafana/provisioning`, and the deployer must call the copier. Then
`./tests/run.sh`.
5. Apply: `./scripts/deploy.sh dankclaw --service grafana` (pulls, installs the
provisioning tree, restarts Grafana, then runs the e2e gate).
## Verify — and the trap that makes the obvious check lie
A **provisioned dashboard does not appear in the legacy `dashboard` table** of
`grafana.db` on the current Grafana build; dashboards live in unified storage
behind the `dashboard.grafana.app` API group. The datasource *does* land in
`data_source`, and that asymmetry is easy to misread as "datasource worked,
dashboard failed". A `sqlite3`/python query against `dashboard` is a **false
negative**, not evidence.
Check in this order instead:
```bash
# 1. provisioning actually ran (these loggers name themselves)
podman logs --since 10m grafana | grep -iE "inserting datasource|provision dashboards"
# 2. the dashboard exists (uid is what you set in the JSON)
curl -s -u admin:admin "http://127.0.0.1:3002/api/search?type=dash-db"
# 3. the datasource really reaches Prometheus (proves the whole chain)
curl -s -u admin:admin \
"http://127.0.0.1:3002/api/datasources/proxy/uid/<uid>/api/v1/query?query=up"
```
Grafana publishes on loopback `:3002` only; the off-host path is the guarded
vhost. Its admin login is still the default `admin:admin` — which is why every
assertion above needs credentials, and which is worth changing.
## Before writing any panel or config comment
Grep the metric names out of the live endpoint; naming a series that does not
exist has already shipped in this repo once (`vllm:prefix_cache_hit_rate` — the
real pair is `vllm:prefix_cache_hits_total` / `vllm:prefix_cache_queries_total`,
whose *rates* give the hit rate). A comment naming an unqueryable metric, or a
panel that silently renders "No data", is worse than no panel:
```bash
curl -s http://192.168.0.235:7770/metrics | awk '{print $1}' | sed 's/{.*//' | sort -u | grep vllm
```
@@ -1,46 +0,0 @@
---
name: gui-hang-remote-diagnosis
description: "Diagnose a frozen GUI/VT-switch failure over SSH by reading kernel hung-task evidence, decoding the lock-holder stack, and deciding recoverability"
---
# GUI Hang Remote Diagnosis
Use when the desktop is frozen, VT switches do nothing, but SSH still works.
## Sequence
1. **Find the smoking gun** — kernel hung-task reporter already logged it:
```bash
journalctl -b -p err --since "-2 hours" --no-pager | tail -80
```
Look for `INFO: task X blocked for more than N seconds` and `blocked on a semaphore likely last held by task Y:PID` (modern kernels name the lock holder outright).
2. **Get full stacks** for victim and holder:
```bash
journalctl -b -k --since "<hang minute -5>" --no-pager | tail -120
cat /proc/<holder_pid>/status | head -5 # State: R (spinning) vs D (blocked)
cat /proc/<victim_pid>/status | head -5
```
- Victim `D` + holder `R` = livelock/deadlock with lock held.
- `voluntary_ctxt_switches` in the millions on an `R` kthread = spinning, not progressing.
3. **Prove whether hardware or software** — GPU/CPU still alive? No Xid errors?
```bash
nvidia-smi --query-gpu=driver_version,pstate,temperature.gpu,clocks_event_reasons.active --format=csv
journalctl -b -k --no-pager | grep -i xid
```
If those answer instantly with no Xid, it's a driver lock issue, not hardware.
4. **Find the trigger window**: block start = (first hung message timestamp − reported seconds). Then inspect user-session events:
```bash
journalctl -b --since "<start-2m>" --until "<start+1m>" --no-pager | grep -v audit
```
5. **Enumerate collateral**: any other `D` tasks (`ps -eo pid,stat,comm | awk '$2 ~ /D/'`), USB workers, etc.
## Recoverability verdict
- Compositor in `D` state → unkillable; spinning kernel thread holding the lock → cannot be poked.
**Only exit is reboot**: `sync && systemctl reboot --force --force` (double-force skips stop-jobs that wait forever on D-state PIDs).
- If holder is `D` (sleeping on something that can complete, e.g. storage), recovery may be possible by clearing that dependency first.
## After diagnosing
- Map stack symbols to driver internals to name the exact subsystem (e.g. `DifrPrefetchEventDeferredWork` → NVKMS DIFR prefetch).
- Check upstream: search the exact symbol names + driver version; propose targeted off-switches (`NVreg_RegistryDwords`, module params from `/proc/driver/nvidia/params`) rather than blanket driver swaps.
@@ -1,67 +0,0 @@
---
name: halogen-flash-server-ops
description: "Operate the frank-large halogen-flash-server deployment on Dankclaw (model swaps, restarts, memory sizing, health and client verification, rollback) — use for anything touching frank-large after the 2026-09-11 runtime swap"
---
# halogen-flash-server ops (frank-large, Dankclaw)
frank-large is a **rootless Quadlet container** since 2026-09-11:
`dankclaw/frank-large/quadlet/frank-large.container` in the serverAppConfigs repo,
installed to `~/.config/containers/systemd/`, generated unit `frank-large.service`
in the **user** manager. Full runbook: `serverAppConfigs-docs/LLM/halogen-flash-server.md`.
## Never use sudo systemctl on it
It is a user unit. `sudo systemctl stop frank-large` does nothing useful.
```bash
systemctl --user status frank-large.service
podman logs frank-large | head -40 # version banner + memory ledger
curl -s http://127.0.0.1:7770/health # 200 only after the engine PONGs
curl -s http://127.0.0.1:7770/v1/models # must list frank-large
```
## Deploy / change flags
1. Edit the `.container` file in the repo (all tunables are inline `Environment=` lines).
2. `./tests/run.sh` (the quadlet seam covers GPU devices, memlock, ipc/seccomp, the single published port, writable weights mount).
3. Commit, push, then `./scripts/deploy.sh dankclaw --service frank-large`.
The handler retires the old root unit (moving its symlink to the home trash), pulls and
re-tags the image to the `localhost/` alias, installs the unit, restarts it, and gates on
the download when the checkpoint is absent. Never `systemctl --user enable` a generated unit.
## Sizing a swap
Read the startup ledger before and after any change: it prints the locked weights, the KV
pool it chose, the working set, the BIOS iGPU carve-out, and the corrected free-memory
figure. `free`, MemAvailable and every monitor built on them overstate free memory by the
size of the model (~68 GiB) because the kernel counts the locked weights as reclaimable
page cache.
- KV is ~7.2 GiB per 262,144 positions; the prefill arena is 16.7 GiB at `HALOGEN_MAX_TOK=32768`.
- The device budget stops near 47 GiB *with the weights pinned*, and lower on a machine
carrying more besides this server. `HALOGEN_KV_POOL_FIT=1` lowers the pool to fit and
prints what it chose; a refused start needs a smaller `HALOGEN_KV_POOL_POSITIONS`
(`HALOGEN_KV_SLOTS` will not help — slots share one pool).
- Long prompts crawling with the disk busy = file cache, not memory: lower the pool or
raise `HALOGEN_HOST_RESERVE_GIB`.
## Client contract traps
- No server-side sampler defaults: a request without `temperature` decodes GREEDY.
- The per-request budget defaults to 8192 and **includes thinking**; a reasoning model
plus a small budget returns EMPTY content with `finish_reason: "length"`. Cap is 65536
(a 400 above it), and `max_tokens`/`max_completion_tokens`/`max_output_tokens` must agree.
- `response_format` is a 400 ("no constrained decoding") — as it is on FreeToken/frank-small.
Any caller that needs structured output or a tiny budget needs a different backend.
- A request reserves prompt + `max_tokens` positions against the pool; larger requests queue
and then fail with `503 engine_busy` after 3600 s.
## Verification after any change
Run a real completion (never just `/health`), a vision probe if the tower is enabled
(image content part with a `data:` URL; `http(s)` URLs are refused), then one call from the
client that consumes it. Rollback: the llama.cpp era is in `.AGENTTRASH/` (`.llama.cpp`),
the STRIX payload and ROCmFPX build stay on disk — but remove the Quadlet from
`~/.config/containers/systemd/` before restoring the root unit, or both claim port 7770.
@@ -1,26 +0,0 @@
---
name: halogen-kv-pool-fit
description: "Resize halogen-flash-server's KV pool or prefill arena without getting silently clamped or OOMing the boot: pool, arena and vision share one device budget, HALOGEN_HOST_RESERVE_GIB only scales the fit-check ceiling and is NOT an allocation, HALOGEN_KV_POOL_FIT only ever lowers, and the sizing warning prints only when a clamp happened."
---
# halogen-flash-server KV pool / arena resizing
One device budget covers KV pool + prefill arena + vision tower:
```
budget = host RAM (121.2) - resident weights (67.7) - HALOGEN_HOST_RESERVE_GIB
need = KV (~28 KiB/position, ~11.0 GiB at 401408 as reserved) + arena + vision (1.97)
arena = 3.80 GiB + 8.40 GiB x (HALOGEN_MAX_TOK / 16384)
```
Measured ledger (Dankclaw, engine's own startup print): 262144/32768/r24 → 96.3 held, 12.1 left, 2081 blocks; 524288/16384/r24 → 94.6/13.8/3742; 524288/32768/r15 → 103.5/7.1/310 (stall NOTE prints); 524288/32768/r16 → identical (reserve is NOT an allocation); **401408/32768/r15 (deployed 2026-09-13) → 100.0/9.1/1396, NOTE gone**.
Procedure:
1. Edit `HALOGEN_KV_POOL_POSITIONS` / `HALOGEN_MAX_TOK` in `dankclaw/frank-large/quadlet/frank-large.container` only. Never raise `HALOGEN_HOST_RESERVE_GIB` — it only moves the fit-check ceiling (at 524288 the ceiling was 16; engine needs budget ≥ 36.5 there; 18 clamps to 262144).
2. Over budget → `HALOGEN_KV_POOL_FIT` silently LOWERS the pool and deploy still reports healthy. Way over → arena alone OOMs the boot while the unit still reads ACTIVE (max_tok 65536 did this). Always `podman logs frank-large | grep 'kv pool:'` and match the reserved count to the requested one; /health proves nothing about pool size.
3. Under budget → nothing prints; absence of WARNING is not evidence of headroom.
4. After restart, FIRST request can silence the engine ~45-60 s ("has not answered PING", /health engine_unresponsive) — one-time pool first-touch, self-recovers; HealthStartPeriod=300s covers it. Do not restart the unit. A completion retried after that window succeeds instantly.
To free host memory: cut the pool (262144 hands back ~3.6 from 401408) or the arena (MAX_TOK 16384 = 8.4). Host-left gain comes in below the KV delta (engine keeps freed space as working slack): the 401408 trim modelled +3.4, measured +2.0 GiB but 4.5x contiguous blocks.
Omp client config on blankpad: frank-large `maxTokens` must be ≤ 65536 (server 400s above the cap) and prompt+max_tokens must fit the pool or the request queues 3600 s then 503s.
@@ -1,18 +0,0 @@
---
name: hermes-config-home-drift
description: "Diagnose Hermes webui/agent model-connection failures caused by HERMES_HOME config-home drift (image VOLUME anonymous volumes resetting on recreation) — verify which home each runtime reads, resolve provider→endpoint with the app's own resolver code, then probe the endpoint."
---
# Hermes config-home drift diagnosis
Symptom: hermes-webui/agent "cannot connect" to models even though the model endpoints answer curl.
1. **Find the real config home per runtime.** `podman exec <container> printenv HERMES_HOME` for each hermes container. If webui reads `/opt/data` (image default) while the gateway reads `/data`, the webui runs the image-default template config (`provider: auto` → openrouter.ai with no key = connect failure).
2. **Know the recreation trap.** Image `VOLUME` points get a fresh anonymous volume on every container recreation (`podman run --replace --rm`) — edits inside such a mount are lost on restart/redeploy. Compare volume ids before/after a restart to confirm.
3. **Fix:** pin `Environment=HERMES_HOME=/data` in the quadlet unit (shared state root), deploy via service-scoped deployer, restart.
4. **Verify with the app's own code**, not guesses:
- `from api import config as C; C.resolve_model_provider("<model-id>")` → returns `(model_id, provider, base_url)`; bare ids map to owning provider by scanning the `providers:` dict models allow-list; `@provider:model` syntax forces an explicit provider.
- `hermes_cli.providers.resolve_user_provider(name, cfg["providers"])` → ProviderDef with real `base_url`; accepts `api`/`url`/`base_url` keys on named provider entries.
5. **Probe the endpoint with the exact served model id** (FLM/NPU endpoints route only by exact registry tag and answer HTTP 200 with `{"error":"Failed to load <tag> model!"}` for unknown tags — status-code checks miss this).
Key gotcha: named providers (`providers:` dict) are a catalog + connection source; `custom:<slug>` connections resolve only via `custom_providers[]` entries. If neither holds an entry for the provider name, the request has no endpoint and fails to connect.
@@ -1,21 +0,0 @@
---
name: hermes-live-config-edit
description: "Runbook for changing Hermes (Dankclaw) live config.yaml — model routing, providers, aux routes — including backup, apply, restart, and verification steps"
---
# Editing live Hermes config on Dankclaw
Hermes-webui/gateway read `/home/dank/Containers/hermes/data/config.yaml` (dir is `drwx------` uid 109999; all reads/edits need `sudo`). The repo file `dankclaw/hermes/config.yaml.example` is a sanitized backup and can lag live — always diff the live file first; never treat the example as truth.
## Procedure
1. **Backup first:** `sudo cp -a .../config.yaml .../config.yaml.bak-YYYYMMDD` (in-dir trash `.AGENTTRASH/` also exists for removals).
2. **Apply change** with surgical `sudo sed -i` or full-file write; YAML comments in the live file are tool-generated — preserve them.
3. **Sweep stale references** across the whole data dir, not just config.yaml: `sudo grep -rln "<old-id>" /home/dank/Containers/hermes/data --include="*.yaml" --include="*.json"`. Session files/MEMORY.md are historical — leave them.
4. **Derived caches:** `webui/models_cache.json` is keyed by a config fingerprint (`_source_fingerprint.config_yaml` mtime/size); after edits it's stale. Either let it regenerate lazily or trash it (mv to data dir `.AGENTTRASH/`) — never hand-edit.
5. **Restart the model-consuming units** (they parse config at startup): `systemctl --user restart hermes-webui.service hermes.service`. `hermes-dashboard.service` is unaffected by routing changes.
6. **Verify behaviorally**, not by status codes: one tiny completion per endpoint with the exact served id — POST `/v1/chat/completions` on 7770 (Dankclaw, llama.cpp serves alias `frank-large`), on Dankserver 7770 (FreeToken REQUIRES an explicit `model` field), and on 7771/v1 (FLM routes by exact registry tag, e.g. `gemma4-it:e4b`; unknown tags answer HTTP 200 with `{"error":"Failed to load <tag> model!"}` — status-code checks silently pass).
7. **Sync the repo backup** (`dankclaw/hermes/config.yaml.example`) to the live shape, redacting secrets (password_hash, phone account stay live-only), then run `./tests/run.sh` before commit.
## Gotchas
- `provider: custom` + `base_url` entries route by base_url and send the model id verbatim; named providers (`dankclaw-large`, `dankserver-small`, `dankclaw-npu`) resolve via their `api:` URL.
- Renaming a served alias anywhere (serve scripts, env ALIAS) MUST propagate to this config in the same change or Hermes requests 404 on nonexistent models — this exact gap caused the 2026-09-05 outage.
@@ -1,87 +0,0 @@
---
name: hermes-local-aux-routing
description: "Fix a broken Hermes auxiliary route (title generation, compression, vision, etc.) when the target is a local OpenAI-compatible backend — diagnose by probing the real call path in-container, then repoint the transport"
---
# Hermes auxiliary routing against local backends
Use when a Hermes feature that calls an auxiliary task fails against a local
endpoint on this fleet (Frank-small on Dankserver :7770, frank-large on Dankclaw
:7770). The usual cause is a request shape the local runtime does not implement,
not a routing mistake.
## Diagnose with the real call path
Do not guess from the config. Run the real function inside the container — it
uses the caller's actual kwargs, the config-driven provider resolution and the
real response parsing:
```bash
base64 -w0 /tmp/probe.py | ssh dank@192.168.0.143 \
'base64 -d > /tmp/probe.py && systemctl --user restart hermes.service && sleep 20 && \
podman cp /tmp/probe.py systemd-hermes:/tmp/probe.py && \
podman exec -i systemd-hermes /opt/hermes/.venv/bin/python /tmp/probe.py'
```
Copy the file AFTER any restart (`--replace --rm` discards the container layer),
and import the app's own constants so the probe cannot drift from production:
```python
import sys; sys.path.insert(0, "/opt/hermes")
from agent.title_generator import _TITLE_PROMPT_TEMPLATE, _TITLE_RESPONSE_FORMAT, _clean_title, _extract_title_text
from agent.auxiliary_client import call_llm
resp = call_llm(task="title_generation", messages=[...], max_tokens=64,
temperature=0.3, extra_body={"response_format": _TITLE_RESPONSE_FORMAT})
```
Pass `api_mode=` / extra `extra_body` keys to A/B a fix before touching config.
(The CLI at `/opt/hermes/hermes` uses the system python and dies on
`No module named 'yaml'`; use `/opt/hermes/.venv/bin/python`.)
## What actually breaks, and the fix
- **`response_format` (json_object / json_schema) is a hard 400** on every local
backend here — neither FreeToken nor halogen implements constrained decoding.
Config cannot override it: `call_llm` merges the task's `extra_body` first and
then `update()`s the caller's, so the hardcoded one wins. Only temperature and
max_tokens have reactive retry paths.
- **Fix: move that one task to the Responses transport**, which never forwards
`response_format`:
```yaml
auxiliary:
title_generation:
provider: dankserver-small
model: frank-small
api_mode: codex_responses # real per-task key
extra_body:
reasoning:
effort: none # mandatory, see below
```
- **Disable thinking for any cap-limited task.** The adapter omits reasoning
only when the config says `reasoning.enabled: false`; note that the
`reasoning_effort: none` shorthand produces exactly that, which leaves the
server default (thinking ON) and the 64-token title budget is spent on the
think — empty content, `finish_reason: length`. The explicit
`extra_body.reasoning.effort: none` instead sends
`{"effort":"none","summary":"auto"}`, which the local /v1/responses honors.
- Both local runtimes also answer `/v1/responses` and `/v1/messages`, so no
proxy is needed to use the alternate transport.
## Verify and land it
1. Re-run the probe with **no** `api_mode`/reasoning kwargs — it must pass from
config alone; that is the production path.
2. Keep the live file (`/home/dank/Containers/hermes/data/config.yaml`, owned by
uid 109999, needs sudo) and the repo example
`dankclaw/hermes/config.yaml.example` in step; the example is a sanitized
backup and lags otherwise.
3. `./tests/run.sh`, commit, push — a Hermes config change is docs+example only,
no service deploy needed.
## Gotchas
- `auxiliary.<task>.enabled: false` is checked BEFORE `apply_instant_title`, so
disabling a broken title task also removes the derived title — it does not
degrade gracefully the way a failure does.
- A local call can also fail at the model level: a 200 with an error-text body,
or a chatty non-title answer. Always read the raw content, not the status.
@@ -1,20 +0,0 @@
---
name: hermes-webui-secret-rotation
description: Rotate the Hermex (hermes-webui) password on Dankclaw and verify auth behaviorally — covers the dual EnvironmentFile gotcha where only dankclaw/.env is effective at container create.
---
# Hermex webui secret rotation (Dankclaw)
Auth contract (verified live 2026-08-31): `POST /api/auth/login` with JSON `{"password":"..."}` → 200 + signed HttpOnly `hermes_session` cookie (24 h TTL) on match, 401 on miss. No username field. `/health` answers 200 unauthenticated and `/` answers 302 — never verify auth via those; always probe login with a deliberately wrong password (expect 401) and the real one (expect 200).
## Rotation procedure
1. `ssh -o BatchMode=yes dankclaw 'bash -s'` with a stdin script (zsh on the host mangles inline `${...}` even inside single-quoted ssh args — never interpolate curl `-w %{http_code}` inline; pipe the whole probe as a heredoc'd bash script).
2. Update BOTH env files with `sed -i "s/^HERMES_WEBUI_PASSWORD=.*/HERMES_WEBUI_PASSWORD=<new>/"`:
- `/home/dank/serverAppConfigs/.env` ([Service] EnvironmentFile)
- `/home/dank/serverAppConfigs/dankclaw/.env` — **the effective one**: the quadlet unit's `[Container] EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env` is what the container reads at create; rotating only the root file leaves the running container serving the old secret.
3. `systemctl --user restart hermes-webui.service` (unit recreation re-reads env; no deploy needed if the unit file itself didn't change).
4. Verify: wrong-pw login → 401, right-pw → 200, from both host loopback and a LAN client at `http://192.168.0.143:8787` (webui publishes on the LAN interface since commit 4443897; off-network ingress is SSH tunnel only — user `tunnel`, port **22**, key-only auth, forward remote end localhost:8787).
## Gotchas
- `sudo podman exec` fails exit 125 here: root's namespace does not see rootless containers — run `podman exec hermes-webui printenv HERMES_WEBUI_PASSWORD` unprivileged (ssh user owns the env files, mode 600).
- If rotating because a secret leaked into transcripts/logs, rotate in place via a script that fetches the live value from the running container (`printenv`) instead of transcribing it.
@@ -1,94 +0,0 @@
---
name: heterogeneous-accelerator-split-triage
description: "Decide whether splitting one model across two unequal accelerators on a single host (fast small-VRAM GPU + big slow unified-memory device, or a GPU over a narrow PCIe link) is worth building — with the KV/prefill arithmetic, the probes that settle it, and the doors that are already closed."
---
# Heterogeneous accelerator split — triage before building
Use when someone asks "could we split model X across device A and device B" on one host, when the devices differ in memory size/bandwidth (e.g. 24 GB GDDR6X vs an APU sharing LPDDR), or when a link is narrow (PCIe x4). Produces a go/no-go with arithmetic instead of enthusiasm.
## 1. Characterise the link (bytes vs latency)
```bash
lspci -vv -s <bdf> | grep -E "LnkCap:|LnkSta:" # capability vs actual
nvidia-smi --query-gpu=pcie.link.gen.max,pcie.link.gen.current,pcie.link.width.current --format=csv
```
Expect **idle-downshift**: a Gen4 x4 dock can idle at Gen1 x4 (~1 GB/s) and only upshift under load. Measure while the device is busy, or you will size the design against the wrong number.
Compute the per-hop cost against the hidden state (`n_embd x 2 B` for f16): a 5120-dim model moves 10 KiB/hop = ~1.3 µs at Gen4 x4. Then:
- bytes per token = hops/token x hidden state. Typically ~1 MiB/token for 2 hops/layer x 64 layers -> ~0.3 % of a 40 ms token. **Bytes are almost never the constraint.**
- the real budget line is **hop COUNT**: every device boundary is a host-side sync. A runtime that splits per *op* can hit 100 splits/token and pin the GPU at ~5 % SM. A runtime that batches per-backend graphs is far cheaper — measure `graph splits` in the loader log, do not assume.
**Design rule: partition by where the weights live, never by where the activations flow.** Resident weights, activations crossing.
## 2. Get the workload's real bottleneck (KV is usually it)
KV per position x context = bytes read per token, because attention reads the whole cached context every token:
```
KV/token = kv_bytes_per_position * context
```
Compare that against (a) expert/weight traffic per token (read once per token per layer) and (b) total token time. At long context or on a hybrid model with few attention layers but many positions, **KV dominates**. Then:
- the highest-leverage placement is **KV cache on the fastest-memory device, together with its attention compute** — never KV alone (separating them puts `KV/token` over the link, which is fatal: GBs across a 7.88 GB/s link = hundreds of ms/token);
- check the KV + attention weights actually fit: `kv_positions_per_slot * slots * bytes_per_position + attention_trunk <= VRAM`.
## 3. What placement never buys
Prefill is compute-bound and expert weights are read **once** per batch, so moving them to faster memory buys nothing while adding a serial stage. Expect a regression, and size it from calibration: on Strix Halo + RTX 3090 (Qwen3.8-Flash-Next, ik_llama.cpp) moving 12.5 % of the expert mass to the GPU gave **decode +12 %, prefill 192 -> 32 tok/s**.
A `1/(f_big/BW_slow + f_fast/BW_fast)` harmonic calculation reproduces that decode gain to within ~2 points, so it is usable for decode projections — and it assumes the stages are sequential (true at batch 1: no pipelining).
## 4. Is the second device even a separate pool?
On a UMA APU, check before assuming additivity:
```bash
cat /sys/class/drm/card*/device/mem_info_vram_total # BIOS carve-out (small)
cat /sys/class/drm/card*/device/mem_info_gtt_total # aperture onto SYSTEM RAM
rocm-smi --showmeminfo all | grep -i gtt # used == what the CPU also sees
```
A large GTT window means the "GPU memory" is the *same* LPDDR under one memory controller: adding a stage there adds a compute engine, not capacity or bandwidth. Only the discrete card's VRAM is additive. Fencing more (`amdgpu.gttsize`, BIOS UMA size) just removes bytes from CPU-visible RAM.
## 5. Speculative decoding as the "cheap" split
A drafter on the *other* device is bandwidth-free (token ids per round, tens of bytes), so it is tempting. Before proposing it:
- the drafter needs the target's **exact tokenizer** for a plain draft model (verify by comparing GGUF `tokenizer.ggml.tokens`, not by trusting the family name), or it must be a head **trained for that target** (EAGLE/DFlash/MTP are conditioned on the target's hidden states and are not portable between models);
- a plain drafter costs **K sequential forwards per round**, so it must be 10-50x faster than the target. Compute the break-even: with `r = t_draft/t_target`, `rate_ratio = A/(K*r + 1)`, `A = (1-p^(K+1))/(1-p)`. At `r ≈ 0.4-0.5` (a drafter only ~2x faster) break-even needs `p ≈ 0.7+` at K=3;
- always compare against the **incumbent**, not the no-draft baseline: a target already running its own MTP head (e.g. ~1.7 tokens committed/round) is a high bar;
- check the runtime can even accept an external drafter. Closed engines may enumerate a fixed drafter set and 400 anything else (`DRAFTER_CAP`-style capability gates keyed on the loaded checkpoint) — that is a design boundary, not a config gap. In llama.cpp the portable mechanism is `--spec-draft-device/-devd` (draft gets its own device list) plus `-ngld`/`-otd`/`-ctkd-ctvd`.
## 6. Expressing a split (llama.cpp, the usual vehicle)
```bash
-dev CUDA0,Vulkan0 -sm layer -ts F0,F1 # contiguous layer ranges (simplest)
-ot "\.attn_.*=CUDA0" -ot "\.exps\.=Vulkan0" # per-tensor, by ROLE (what KV co-location needs)
```
- buffer types are matched by name at load (`ggml_backend_buft_name`), so device names work as `-ot` values;
- `-cmoe` / `-ncmoe` are **CPU-only**; device-side MoE placement is `-ot`;
- several backends in one binary is fine (CUDA + Vulkan is the practical pair; HIP + CUDA do not coexist), and `GPU_BACKEND_A=OFF` in the CMakeCache of the existing build tells you what needs adding;
- contiguous ranges are the *wrong* split when KV bandwidth dominates, because they drag half the attention layers (and their KV) back to the slow pool.
## 7. Hazards checklist
- paged/sidecar tables (huge n-gram/embedding tables) must stay where they are read from; moving them is pure loss;
- keep `mmap` on for models with huge embedding tables (anonymous memory gets the process cgroup-killed);
- CUDA graph capture can abort on unusual graph shapes (hyper-connections etc.) — check before assuming optimisations apply;
- the second device's brand-new backend may not implement the arch's ops at all: make the first measurement a **baseline of the runtime on that device alone**, because a split's arithmetic is worthless if the baseline is worse than the incumbent engine;
- a split consumes the fast device entirely — check what else it was serving before trading it away;
- benchmark/experiment discipline on production hosts: stop and mask first.
## 8. Verdict shape
Rank and report, and prefer the boring answer when it wins:
1. **Two independent endpoints** (zero shared traffic) — often the real answer to "what's a useful combination", especially over a narrow link;
2. **One process, weights-resident, KV co-located with attention on the fast pool** — promising, quantifiable (+% decode at long context), needs a dual-backend build and a baseline measurement;
3. **Hot-expert cache / per-token weight streaming** — affordable only if the miss set per token is small (a few experts; check `misses x bytes x tok/s` against link bandwidth) and overlapped, and it injects PCIe latency into the critical path; no mainstream runtime implements a dynamic expert cache;
4. list explicitly what does *not* help: TP/collectives across unequal devices, KV on the far device, model loading over the link, prefill-motivated placements.
@@ -0,0 +1,26 @@
---
name: hf-checkpoint-profile
description: "Profile a Hugging Face safetensors checkpoint (sizes, dtypes, tensor-name space, gated/license) with compact HTTP-range reads — safe for subagents on small context slots."
---
# Profile an HF safetensors checkpoint cheaply
Goal: inventory a repo (files/bytes, dtype histogram, tensor-name patterns, per-group GiB, config keys, license/access) without pulling gigabytes and without overflowing a subagent's context slot.
## Endpoints (all public, no token needed even for gated models)
- `https://huggingface.co/api/models/<id>?blobs=true` → `siblings` (name+size), `gated`, `private`, `downloads`, `safetensors` metadata, `tags`.
- `https://huggingface.co/api/models/<id>` for tags/base model; `https://huggingface.co/api/models?author=<x>&limit=100` to find sibling repos (canonical id check: cards often link both `X-1.5-Y` and `X-Y`).
- `https://huggingface.co/api/models/<id>/resolve/main/<file>` for `config.json`, `model.safetensors.index.json`, `chat_template.jinja`, `LICENSE`, `generation_config.json`; `/raw/main/<file>` for text.
## dtype + per-group bytes without downloading shards
Parse the safetensors header over HTTP Range: first 8 bytes = little-endian u64 header length, then that many bytes of JSON (`{name: {dtype, shape, data_offsets}}`). Fetch every shard header with a thread pool (131 shards ≈ seconds), then bytes = prod(shape) × dtype size. Collapse names with `re.sub(r"\.\d+\.", ".N.", k)` (and `\.shard_\d+` → `.shard_N`) to get the pattern space; group-sum by prefix to see where the mass sits (experts / ngram-PLE / MTP / vision / trunk).
Compare the key set against the base model's index to prove "full-weight finetune, identical names" (a zero-diff key set means per-tensor recipes written for the base apply verbatim, and it reveals fused vs per-expert layout, PLE/ngram shards, MTP head, vision tower, absence of `quantization_config`).
## Guardrails
- Run python scripts from a clean directory: a stray `/tmp/inspect.py` shadows stdlib `inspect` and kills `concurrent.futures` imports.
- Never put an HF token on a command line (`/proc/<pid>/cmdline` is world-readable); use `curl -K <mode-600 file>`.
- `curl -f` / `--fail-with-body` and verify byte counts — a 401/403 writes the error body as the "artifact" and exits 0.
## Subagent context budget
Subagents on a 131k-token slot die from `exceed_context_size_error` when told to read whole doc pages. Give grep-only instructions (`| head -40`, `-o`, `-c`), demand a line cap ("report in ≤250 lines, no verbatim dumps"), and prefer `git clone --depth 5` + local grep to web-page reads. A peer scout that overflows returns exit 1 with the 400 in the harness log and NO structured output — assume nothing was learned and redo the fact inline.
@@ -1,98 +0,0 @@
---
name: hf-gguf-payload-fetch-verify
description: "Inventory, fetch, hash-verify and assemble a Hugging Face GGUF payload (including mradermacher's .partNofM fragments and gated mirrors) before pointing an LLM service at it — on the Dank fleet or anywhere else."
---
# Fetch and verify an HF GGUF payload
Use before any model swap where the weights come from Hugging Face, especially
large (>32 GiB) quantised models. The failure modes below all look like success.
## 1. Inventory — query by quant name, never by extension
```bash
curl -s "https://huggingface.co/api/models/<org>/<repo>/tree/main?recursive=true&blobs=true" \
| python3 -c "
import json,sys
for e in json.load(sys.stdin):
p=e.get('path','')
if 'Q4_K_S' in p: # the QUANT, not '.gguf'
print(p, e.get('size',0), (e.get('lfs') or {}).get('oid','-'))
"
```
**Large quants ship as `.partNofM` fragments.** `mradermacher/*-i1-GGUF` repos
hold e.g. Q4_K_M as `part1of3/2of3/3of3` (37.00+37.00+36.97 = 110.97 GiB) and
Q4_K_S the same way, while the smaller rungs (Q2_K, Q3_K_*, IQ3_*, IQ4_XS) are
single `.gguf` files. Filtering on `endswith('.gguf')` therefore makes Q4_K_S
look like it does not exist — do not conclude a quant is missing from an
extension-filtered listing.
Check both mradermacher repos: `…-i1-GGUF` (imatrix) and `…-GGUF` (plain), and
the model's own repo. Gated mirrors exist (`orcarouter/Qwen3.8-Flash-Next-Uncensored-GGUF`
carries the same ladder) and return **403 even with a valid HF token** until
that account accepts the gate — prefer the ungated copy rather than chasing access.
## 2. Fetch — `-f` is mandatory, secrets stay out of argv
```bash
# token out of band: mode-600 config, never -H on the command line
printf 'header = "Authorization: Bearer %s"\n' "$(cat ~/.cache/huggingface/token)" > /tmp/hf.curlrc
chmod 600 /tmp/hf.curlrc
curl -fL -C - --retry 10 --retry-delay 5 --retry-max-time 21600 -K /tmp/hf.curlrc \
-o "$DEST/$name" "$BASE/$name"
```
- `curl -o FILE URL` **exits 0 on HTTP 401/403** and writes the error body as the
artifact. A gated repo yields identical small files (153 bytes) that hash the
same and a `DOWNLOAD_DONE` in seconds for a 104 GiB payload.
- A token in `-H "Authorization: Bearer …"` is visible in `/proc/<pid>/cmdline`
to any `ps`/`pgrep -a` on the host. Never `pgrep -a` a running fetch.
## 3. Verify, then assemble
```bash
# per part: byte count must equal the Hub's declared size, sha256 must equal lfs.oid
sha256sum "$PARTS"/*.part* | sort
# concatenate in NUMERIC order, then verify the total and the container magic
cat "$PARTS"/X.gguf.part1of3 "$PARTS"/X.gguf.part2of3 "$PARTS"/X.gguf.part3of3 > "$OUT"
stat -c%s "$OUT" # must equal the sum of the part sizes
head -c 4 "$OUT" # must print GGUF
```
The assembled file has no published hash (only the parts do), so the per-part
LFS oids plus an exact total are the verification chain. If a size check fails,
suspect your own arithmetic before the payload: the sum of the parts is the
expected value, and it is easy to mis-add by hand (`python3 -c "print(a+b+c)"`).
## 4. Housekeeping
- Failure artifacts (error pages) and stray directories: move to
`~/.AGENTTRASH/YYYYMMDD-topic`, never `rm`; list them and wait for approval to
purge.
- Keep the `.partNofM` fragments after assembly when disk allows: they are the
only copies that can be re-verified against the Hub. Note the total they cost
and ask before purging.
- Never delete the payload being replaced as a side effect of a swap: upstreams
delist quants (both mradermacher and orcarouter have delisted Q4_K_M for
Qwen3.8-Flash-Next-Uncensored), and the local file may be the last copy.
## 5. Waiting for a long fetch
Do not `pgrep -f <script>` where the pattern appears in your own command line —
the loop matches itself and spins its full iteration count while the download is
fine. Watch the artifact instead:
```bash
while [ "$(stat -c%s "$DEST/$name" 2>/dev/null || echo 0)" -lt "$EXPECTED" ]; do sleep 30; done
```
## 6. Before choosing a smaller quant
A smaller quant buys memory and ~3–5 % decode, and costs quality. It does **not**
change what the runtime can express: on llama.cpp, per-tensor cross-device
placement is refused whenever `--override-tensor` is set
(`has_tensor_overrides()` gates `pipeline_parallel`, which sets `n_copies`), and a
layer split can only move contiguous layer ranges. State which of the two you are
buying — headroom or a design change — before spending 100 GiB of bandwidth.
@@ -0,0 +1,40 @@
---
name: hf-large-artifact-publication
description: "Publish a large quantized artifact + model card (and an optional anonymized corpus dataset) to HF Hub from dankserver: staging set, HF-style card spine, upload gates, transient-unit launcher, sentinel watching, card-metadata and scrub laws."
---
# HF large-artifact publication (dankserver)
Publish a big model artifact (100+ GiB) + card, and optionally an anonymized corpus dataset, from dankserver under `frank8465`.
## Staging
- Dev box `~/Projects/hf-swift15-upload/` (author card + scripts here), `scp -r` synced to `/home/dank/hf-swift15-upload/` on dankserver. Keep staging + logs + `.rc` sentinels after publication as receipts.
- Payload set: artifact, `README.md` card, `scripts/` (recipe/drivers/probes), `artifacts/` (gate JSONs, per-position `.rows`, manifests, run logs).
- Secret-scan the whole payload before arming: credential regexes (hf_/ghp_/pat, private keys, Bearer values, AKIA/sk-), LAN IPs, hostnames, domains, emails. Verdict each hit — variable *references* and upstream test fixtures are not leaks; check real values in context.
## Card law (HF validator)
- Custom licenses MUST be `license: other` + `license_name:` + `license_link:` (mirror the base model's card trio — fetch `https://huggingface.co/api/models/<base>` and read `cardData`). A raw non-SPDX `license:` value raises `ValueError: Invalid metadata in README.md` inside `upload_file` — AFTER all local gates have run.
- Card spine: what-the-format-is + engine pin digest, recipe table, quality gates (KL/top-1/PPL + per-position data shipped), proof hardware, exact `podman run` line, license chain, manifest table with the artifact's sha256 receipt, "In plain English:" block per section.
- Quote the SHIPPED filename (build name), not a prettified one — a hub-side rename of a 100 GiB LFS file costs a full re-upload.
## Upload mechanics
- venv `/home/dank/venvs/radcorpus` (huggingface_hub 1.28 + hf_xet); `HF_XET_HIGH_PERFORMANCE=1`; token from `~/.cache/huggingface/token` (see defuse-shell-rc-secrets pattern: `~/.config/secrets/<name>` 600 + rc loader, mirrored to the cache file). NEVER token in argv/chat.
- Script gates, in order: sha256 of live artifact vs recorded digest (`gate 1 : MATCH`), `create_repo(..., private=False, exist_ok=True)`, small files first, artifact last, post-upload census — **compare remote sizes to local and FAIL the RC on any mismatch** (this caught a stale card mid-campaign).
- hub 1.28 API traps: `upload_file`/`create_commit` kwargs are keyword-only; no `rename_file`, `hf_hub_url`, `HfApi.commit_api` (use `CommitOperationAdd`/`CommitOperationDelete` from `huggingface_hub` top-level if needed); `whoami` hides `canWrite` for legacy tokens — probe write scope by `create_repo(exist_ok=True)` on the real target.
- zsh-over-ssh: never `echo ===` (triggers `=cmd` expansion); quote it.
## Launcher + watchers
`setsid nohup` over ssh dies at session teardown. Use a transient user unit:
```
systemd-run --user --collect --unit=hf-upload -p SendSIGKILL=no \
-p StandardOutput=append:/home/dank/logs/hf-upload.log \
-p StandardError=append:/home/dank/logs/hf-upload.log \
sh -c "cd <staging> && <venv>/bin/python -u <uploader>.py --go; echo \$? > /home/dank/logs/hf-upload.rc"
```
Sentinel: background ssh loop polling the `.rc` file (60 s period, bounded), tails the log on appearance. Progress bars are `\r`-fed — `tr '\r' '\n' | tail` to read them.
## Corpus dataset variant
- License audit first: read `urls.txt`/manifest, fetch each source card (`license` in cardData). Share-alike sources (CC BY-SA) → publish derived corpus CC BY-SA with attribution; model weights unaffected.
- Fleet-harvested slices = agent-session transcripts: scrub with deterministic substring-class rules (NOT word-boundary: catches `dankclaw_default`, `FRANKBOOK_SVCS`, `franksdank/alloy` image namespace): LAN IPs→10.x, hostnames→server-a/b/client-c, domain→example.com (catch `\.`-escaped forms + bare brand), `/home/<user>`, emails→user@example.com. Residual probes run on OUTPUT; whitelist self-replacements + audited fixture noise.
- Publish original hashes, disclose non-byte-identity, cross-link model card ↔ dataset card, verify via `raw/main/README.md` fetches.
- Dataset repo needs dataset-write scope (fine-grained token; access tokens 403). `repo_type="dataset"` everywhere; 45 MB rides in ~60 s.
@@ -0,0 +1,21 @@
---
name: homepage-smoke-verify-loop
description: "Smoke-verify Dankclaw homepage changes locally before commit/deploy: isolated static tree + fake status + browser census, then deploy gate."
---
# Homepage smoke-verify loop (serverAppConfig)
Verify `shared/homepage/` changes against a real browser BEFORE commit/deploy.
## Procedure
1. `node --check shared/homepage/app.js` and `python3 -c "import json; json.load(open('shared/homepage/services.json'))"`.
2. Isolated tree: `rm -rf /tmp/hp-smokeN && cp -r shared/homepage /tmp/hp-smokeN`.
3. Fabricate runtime files in the tree:
- `status.json`: `{"checked":"x","services":[{"name":<every services.json name>,"state":"up"}]}` (set one model `down` to exercise the red-pulse path).
- `deployed.js`: `window.DEPLOYED_AT="smoke"`.
4. Serve: `(cd /tmp/hp-smokeN && nohup python3 -m http.server 8099 >/dev/null 2>&1 & echo $! > /tmp/.hp-pid)`.
- If reusing port 8099: `pkill -f "http.server 8099"` FIRST — a second server on a busy port fails silently and the OLD tree keeps serving.
5. Browser census via eval tab on `http://127.0.0.1:8099/?v=<epoch>` — ALWAYS cache-bust with `?v=`; python http.server gets heuristically disk-cached by the browser. Assert: broken images (`.app-art,.mi-art` naturalWidth==0), computed styles (`getComputedStyle` for palette/fonts/clipPath), presence of new classes, `document.querySelectorAll('.docs section').map(s=>s.id)` for doc order, regex text census for banned words.
6. Screenshot full page + `selector` shots; inspect visually.
7. `./tests/run.sh` (expect only the known frank-primary MAX_SEQS failure), commit, `./scripts/deploy.sh dankclaw --service homepage` (runs Playwright e2e gate), then repeat the census against `https://www.franksdank.cloud/?v=<epoch>`.
8. Cleanup: kill server, move `/tmp/hp-smokeN` to `~/.AGENTTRASH/YYYYMMDD-homepage-smoke/` (never `rm`).
@@ -1,32 +0,0 @@
---
name: homepage-status-probe-namespace-triage
description: "Triage a homepage status card (or any host-side TCP probe) reporting \"down\" for a service that is actually up: decide by network namespace whether the probe target is reachable at all before calling it an outage."
---
# Goal
Decide, in one pass, whether a `down` card on the homepage status page (`scripts/homepage-status.py`, run by `homepage-status.timer` on Dankclaw, probes TCP/HTTP from the **host** netns) is a real outage or a structurally unreachable probe target. The checker only has three states — `wip` | `up` | `down`; there is no "unknown", so an unprobeable service can never render honestly and must be dropped or re-pointed, not "fixed".
# Why it goes wrong
A host-vantage TCP probe can only reach a listener that is **published to the host** (rootless podman publishes via rootlessport/pasta; unpublished ports are invisible from the host netns). Three recurring false-down shapes on this fleet:
1. **TLS-only app probed with `method:"http"`.** Plex Media Server 1.43 serves TLS only on `32400`; a plain-HTTP GET gets a TLS alert → curl exit 52 "empty reply from server", code `000` → card says down while PMS answers `200` over HTTPS. Fix = `"method":"tcp"` (connect is the honest liveness signal; the public URL stays HTTP because Caddy re-keys).
2. **Retired port.** The Hermes card probed `127.0.0.1:8090` after the compose→quadlet move moved the dashboard to `9119` and the webui to `8787`; nothing listened on 8090, so a fully healthy stack read `down`.
3. **Unpublished port / wrong netns.** `gitea-postgres` (`db:5432`, net `systemd-gitea`), `searxng-valkey` (`valkey:6379`, net `systemd-searxng`), and `alloy` (OTLP 12345) publish **no** ports. Container DNS aliases are netns-scoped, so from the host these are unreachable *by construction* — no probe value makes them honest.
# Procedure
1. Read the card's probe target in `shared/homepage/services.json`; note method (`tcp` vs `http`).
2. Ask: does that port have a **host-visible listener**? `podman ps -a --format '{{.Names}}|{{.Status}}'`, then per container `podman inspect <c> --format '{{json .NetworkSettings.Networks}} {{.HostConfig.NetworkMode}}'` and `podman port <c>` (empty output = published nothing → structurally unprobeable from the host).
3. Probe from the **correct vantage**. From a bridge-net container, the host is its gateway IP (`/proc/net/route`, e.g. `10.89.1.1` on `dankclaw_default`), *not* `host.containers.internal` (`169.254.1.2`) — that pasta alias belongs to the default `podman` net (`10.88.0.0/16`, gw `10.88.0.1`) and is not routed from other nets. Probe with bash `/dev/tcp` (no `python3`/`ip` in these images), and escape the loop var so it expands **inside** `podman exec bash -c`.
4. Classify: real outage (container exited — read `State.ExitCode`: 137 = SIGKILL, 143 = SIGTERM/manual stop, `OOMKilled=false` rules out the OOM killer) vs false down (running + unreachable target).
5. Fix accordingly: re-point or re-method the probe for a real HTTP/TLS mismatch; **drop** cards whose only exposure is an internal netns (or accept permanent false-downs and say so); never invent a probe target for them.
6. Verify by re-running the checker path, not by eyeballing: after deploy, re-read `/srv/homepage/status.json` and diff the `down` list before/after.
# Watch out
- Deploying this file is service-scoped: `./scripts/run.sh` (233 tests) → commit → `./scripts/deploy.sh dankclaw --service homepage`; the deployer *pushes* `main` first, so any already-committed local work rides along — say so before pushing.
- The checker writes `/srv/homepage/status.json` world-readable (0644) so Caddy can serve it; keep that mode or Caddy 403s the page.
- Removing a card's `probe` key without code change crashes the checker (`svc["probe"]` KeyError) — dropping the whole entry is the only zero-code fix.
- Bare compose services with `restart=unless-stopped` (node-exporter on both hosts) do **not** survive reboot: rootless podman does not autostart bare containers, and `deploy-remote.sh` refuses to `compose up` Dankserver's compose services by design → they need a quadlet unit, not a restart.
@@ -0,0 +1,15 @@
---
name: homepage-theme-smoke
description: "Locally smoke-test the Dankclaw homepage theme (style.css/app.js/docs.html) in the managed headless browser, with the stale-server traps that fake the result."
---
Goal: prove a homepage theme change on a throwaway tree before commit/deploy.
Protocol:
1. Stage a fresh copy: `cp -r shared/homepage/. /tmp/hp-smoke-<rand>/`, drop `.AGENTTRASH` dirs, and write a fake `status.json` (`{"generated_at":"x","services":[{"name":...,"state":"up"}]}`) — app.js fetches services.json + status.json and renders NOTHING without both.
2. Serve: `cd <tree> && nohup python3 -m http.server 8099 --bind 127.0.0.1 &`.
3. STALE-LISTENER TRAP (fakes every census): a survivor server from an older wave keeps answering on 8099 — `ss -ltn | grep 8099` proves a listener exists, not WHICH tree it serves. After every (re)start verify identity: `for p in $(pgrep -f "http.server 8099"); do readlink /proc/$p/cwd; done` must print the NEW tree; then `curl -s localhost:8099/<file> | grep -c <new-marker>`. `kill` the stale pid — never trust a `||`-guarded restart. Also: `pkill -f "http.server 8099"` from a shell whose own command line contains that string kills the shell; kill by pid.
4. Browser census: navigate with `?v=<epoch>` cache-bust (browser disk-caches http.server responses); `waitForSelector('.mi-card', ...)` before evaluating — rendering is async (fetch). Geometry checks: rect-gap to 1px for bottom-pinned chips; screenshot for visual proof. `screenshot({path})` may not write — use the returned omp-sshots artifact.
5. In headless pages `new Audio()` is detached: prove voice playback via `tab.requests()` (206 on the /sfx file) + the `mi-speaking` class, not a DOM `<audio>` query.
6. Live-domain check after deploy: the homepage is at `/`, the docs page ONLY at `/docs/` — `/docs.html` falls through the SPA fallback and serves the homepage shell. Anubis adds a challenge: `waitForSelector` with 30-45 s timeout.
7. Deploy loop: `./tests/run.sh` → commit/push → `./scripts/deploy.sh dankclaw --service homepage` (runs the Playwright e2e gate) → live census with fresh `?v=`.
@@ -1,34 +0,0 @@
---
name: html-change-report
description: Generate and open a self-contained dark-theme HTML change report (Keep a Changelog categories + code samples) for a git repo time window; use when Frank asks for a rendered report of recent work.
---
# HTML change report (Keep a Changelog + review aids)
Produce a self-contained HTML report of repo work in a time window and open it in Firefox.
## Data gathering
- Window commits: `git log --since="24 hours ago" --pretty='%h|%ad|%s' --date=format:%H:%M` (split on `|`).
- Metrics: `git diff --shortstat <first>^..HEAD`, per-area file counts via `git diff --name-only … | awk -F/ '{print $1"/"$2}' | sort | uniq -c | sort -rn`.
- Bucket commits into phase/theme rows by time-of-day for the timeline table.
## Report structure (standard: Keep a Changelog 1.1.0)
1. Executive summary (metrics + 3-5 bullets).
2. Full commit timeline table, phase-color-tagged (border-left per phase).
3. `git show <hash>` command list per theme — Frank uses this for his review pass.
4. Changes by category: Added/Changed/Fixed/Removed/Security; omit empty categories; cite commit hashes per item. Fixed = old behavior was a bug; Changed = intentional.
5. 4-6 notable code excerpts: read real files, pick load-bearing/instructive code, note file:lines + commits + why it matters. HTML-escape everything (`html.escape`).
6. Verification evidence table (what / how / result).
7. Incidents & open risks.
8. Actions required from Frank (numbered, effort hints).
## HTML requirements
- Self-contained: inline CSS only, NO CDNs (must work from file:// offline). Dark theme CSS vars (#1a1b26 bg family works).
- Sticky top nav, section anchors, `<pre class=code><code>` blocks, commit hashes as `.tag` chips.
- Build via python (eval kernel): subprocess for git data, string concat for CSS (avoid f-string brace pain), f-strings for rows.
- Literal `{}` in HTML text (e.g. `fallbackChains: {}`) inside an f-string is a SyntaxError — build that fragment by concatenation or double the braces.
## Delivery + verification
- Write to `$HOME/Reports/<repo>-<window>-<date>.html` (outside repo, tree stays clean; create the dir — on a workstation account that is /home/blank/Reports, on dank hosts /home/dank/Reports).
- Validate: python HTMLParser feed (parse errors) + open in headless browser (`write` JSON to xd://browser: open → run with `document.querySelectorAll` counts) → close tab. Element counts are sufficient proof; a fullPage screenshot of a long report returns a multi-MB raw pixel buffer dump — only screenshot short pages.
- Open for Frank: `nohup firefox --new-tab file:///<path> >/dev/null 2>&1 & disown`.
@@ -1,29 +0,0 @@
---
name: ik-llama-hybrid-moe-performance
description: Diagnose and tune ik_llama.cpp hybrid CPU/GPU MoE decode/prefill on the dankclaw 3090+Strix Halo frank-large deployment; includes the barrier-split root-cause method and the systemd-unit test-interference trap
---
# ik_llama.cpp hybrid MoE performance campaign (dankclaw frank-large)
Use when tuning or debugging decode/prefill speed of the ik_llama.cpp hybrid deployment (RTX 3090 + Strix Halo LPDDR, CPU-resident MoE experts) on dankclaw, or when a config experiment gives surprising numbers.
## Measured envelope (2026-09-16)
- Decode 3.8-4.3 tok/s, flat across thread counts (T1 ≈ T16). Prefill ~192 tok/s at 1.8k-32k prompts = the CPU AVX512-VNNI compute ceiling for CPU experts (batch amortizes expert reads; compute-bound, NOT memory-bound).
- Decode root cause: ~100 CPU↔GPU backend splits/token; GPU at 5% SM during decode (launch/sync-bound over the PCIe x4 dock); T16 profile shows ~90% of cycles in `pause` (ggml_barrier spin) because threads wait on the serial critical path. Memory placement (mmap/anon/THP/deferred) does NOT matter — all variants land 3.8-4.6.
- CUDA-graph capture aborts: qwen4exp's hc broadcasts use per-layer `ggml_repeat_4d` whose CUDA path does a capture-illegal op. Until ik gains capture-safe repeat/hc ops (mainline PR #28901 direction), graphs stay dead. Mainline llama.cpp itself runs 1.0 tok/s on this model (CPU MoE kernels 4x slower than ik iqk) — do not switch for speed.
- MTP/speculative impossible for mradermacher i1 GGUFs: the NextN head is stripped (no blk.48 tensors).
## Tuning levers, ranked
1. `-ncmoe 42` puts 6 layers' experts on the 3090: decode 4.54, prefill (small prompt) 32 — but VRAM 22/24.6 GiB at 262k ctx; needs an OOM-margin decision from the operator.
2. `--prefetch-experts` is a no-op when the page cache is resident.
3. `-thp` huge-mmap usually fails (`Cannot allocate memory`) and falls back silently.
## Traps
- The frank-large root systemd unit has Restart=always: `pkill`ing llama-server respawns it in ~4 s. For manual experiments: `sudo systemctl stop frank-large.service` first (root unit — NOT `systemctl --user`, that is stale halogen-era docs); verify `pgrep -xc llama-server` == 1 and 'HTTP server listening' in the current log before trusting any measurement.
- Any `-ot`/`-ncmoe` override disables mmap for the whole model load in ik (`create_tensors` returns false).
- `-ncmoe N` without `GGML_CUDA_NO_PINNED_WEIGHTS=1` page-locks ~108 GiB (co-tenant killer — the 2026-09-15 incident).
- `--defer-experts` defers only PLE; under `-ngl 999` the experts still target CUDA0 → cudaMalloc OOM (~79.7 GiB on a 24 GiB card).
- Never run `-j16` builds concurrently with decode measurements — both results are garbage.
@@ -1,43 +0,0 @@
---
name: imagegen-openwebui
description: Runbook for running high-quality local image generation via Open WebUI imagegen API endpoint.
---
# Open WebUI Image Generation Runbook
## Overview
Use this procedure to generate images locally using the Open WebUI image generation endpoint.
This approach leverages local ComfyUI integration configured in Open WebUI.
## Endpoint
POST to the imagegen endpoint of your Open WebUI instance.
### Example Request
```python
import requests
url = "http://<host>:<port>/api/images/generations"
headers = {"Authorization": "Bearer <your_token>"}
data = {
"model": "imagegen",
"prompt": "A desired image description.",
"n": 1,
"size": "1024x1024"
}
response = requests.post(url, headers=headers, json=data)
```
### Example Response
Returns an object with base64-encoded image data or a URL.
## Best Practices
- Keep prompts descriptive for best results.
- Use the default model unless configured otherwise.
- Verify that ComfyUI is running and connected before invoking.
- Check Open WebUI logs for troubleshooting if generation fails.
## Common Issues
- **Connection refused**: Ensure Open WebUI and ComfyUI are running.
- **Timeout errors**: Large image sizes may take longer; check ComfyUI resources.
- **Authentication errors**: Verify API token validity in the request headers.
@@ -1,92 +0,0 @@
---
name: incident-window-reconstruction
description: "Reconstruct which command/config was running at the instant an incident symptom first appeared, from on-disk artifacts (log mtimes + per-run durations + journal first-failure time) when memory and /tmp are gone."
---
# Incident window reconstruction
Use when the question is **"what was running when it broke?"** — an incident,
outage, resource blow-up, or unexplained service death — and the answer must
come from artifacts, not recollection. Typical triggers: a host went down during
an experiment, a service died mid-run, someone asks for the parameters in effect
at the moment of failure.
## Principle
Three independent clocks, cross-checked — never recollection:
1. **Per-run duration** — every script wraps commands in `time`, so the log
holds `real 4m19.129s`.
2. **Log mtime** — the last write of that log.
3. **Journal first-failure instant** — the first error line from the affected
service.
`start ≈ mtime − real` for the *last* run in a log, and each run nests inside
the previous one's window. The run whose `[start, end]` contains the journal's
first-failure instant is the answer.
## Collect
```bash
# 1. ordering and clocks of every artifact (log mtimes)
ssh <host> 'ls -l --time-style=+%m-%d_%H:%M:%S <logdir>/*.log | awk "{print \$6, \$7}"'
# 2. durations + per-run section markers + the exact command lines
ssh <host> 'grep -ah "^real" <logdir>/*.log'
ssh <host> 'for f in <names>; do echo "## $f"; \
grep -aE "^####|^\+ /path/to/binary" <logdir>/$f.log \
| sed "s/\(.\{130\}\)/\1\n /g"; \
grep -aE "^real|_EXIT=" <logdir>/$f.log; done'
# 3. first failure of the affected service, and what preceded it
ssh <host> 'journalctl --user --since "<date> <HH:MM>" | grep -a "invalid internal status" | head -2'
ssh <host> 'journalctl --user --since "<date> <HH:MM>" | grep -aiE "Started|Created container" | tail -6'
```
## Traps (all hit for real)
- **`/tmp` does not survive a reboot.** Launch experiment scripts from a
persistent path (e.g. `~/models/...`) and log beside them; if the repair path
is a reboot (common), `/tmp` scripts *and* any sampler output written there are
gone. On the Dank fleet: `/home/dank/models/logs/<tag>.log` survived while
`/tmp/x4*.sh` did not.
- **A log's mtime is not proof the run finished.** A log truncated (or rotated)
while a script still holds the fd keeps growing from that point; a 6-line log
with an `ALL_DONE` marker means the earlier bulk was removed, not that nothing
ran.
- **Long command lines are display-truncated by agent tools.** Wrap before
reading: `sed 's/\(.\{130\}\)/\1\n /g'`. Otherwise you will report
parameters that were never used.
- **Off-by-one file names silently skip evidence.** `x4b.log` vs
`x4b-experiment.log` in a `for f in …; do [ -f $f.log ]` loop made half the
artifacts invisible. List the directory and match exactly.
- **Overlap is a hypothesis, not an observation.** Two resource-heavy
invocations in the same minute may or may not have been resident at once;
say so explicitly unless a sampler or cgroup counter proves it.
- **Do not invent the cause.** Reconstruct the *window* (evidence) and keep the
*mechanism* separate: grep the proof that would establish it (`journalctl -k |
grep -i oom` for a killed process, driver logs for a GPU fault). If it is not
there, publish "trigger not established" — an invented cause is worse than an
open question because the next agent plans against it.
## Fleet-specific sensitivity
On this fleet, container healthchecks shell out to the **`podman` CLI**, so the
first `invalid internal status … could not find any running process` in the user
journal is an *early, per-minute* indicator of rootless-podman state death — and
it timestamps the incident to within one healthcheck interval. That is how the
18:58:30 Dankclaw break was pinned to the bench still loading at the time.
## Worked example (2026-09-16, Dankclaw)
| evidence | value |
|---|---|
| log mtimes | x4e-run 18:58:29, x4f-run 18:59:56 |
| durations | E1 `real 4m53.946s` (llama-cli spam), F `real 5m24.785s` (exit 143 = killed) |
| journal | first `invalid internal status` 18:58:30 |
| ⇒ in flight | **F**, launched 18:54:31 (inside its 18:54:31–18:59:56 window) |
Answer delivered: the exact flags of F, plus the note that the preceding
`llama-cli` had written 3.19 GB of spinner lines and that the `/tmp` VRAM sampler
was gone — so the overlap of the two 111 GiB-class invocations stayed marked as
[INFERENCE] rather than becoming a claim.
@@ -1,36 +0,0 @@
---
name: lan-client-split-dns-fix
description: Fix a LAN client timing out on *.franksdank.cloud because it resolves the public WAN IP (no 443 forward) instead of the Pi-hole LAN answer; diagnose by resolver and fix with a systemd-resolved route-only drop-in.
---
# LAN client split-DNS fix (*.franksdank.cloud)
Symptom: a LAN machine gets `connection timeout` (curl rc=28) on `https://*.franksdank.cloud`, or LAN-only names return NXDOMAIN, while the server is actually healthy.
## Root cause
Public DNS for `franksdank.cloud` = WAN IP `71.227.227.172`, which has **no 443 port-forward** (filtered). Pi-hole `192.168.0.143` returns the LAN answer `192.168.0.143` via `addn-hosts`. If the client's resolver is using an external server (1.1.1.1/quad9), it gets the WAN answer → SYN to a filtered port → timeout.
## Diagnose (fast)
```bash
dig +short www.franksdank.cloud @192.168.0.143 # expect 192.168.0.143
dig +short www.franksdank.cloud @1.1.1.1 # expect 71.227.227.172 (the trap)
resolvectl status | grep -iE 'Current DNS|DNS Server' # which server is active?
# prove the server is fine by pinning the LAN IP:
curl -sk --resolve www.franksdank.cloud:443:192.168.0.143 https://www.franksdank.cloud/ -o /dev/null -w '%{http_code}\n' # 200
```
## Fix (systemd-resolved route-only drop-in)
```bash
sudo mkdir -p /etc/systemd/resolved.conf.d
sudo tee /etc/systemd/resolved.conf.d/franksdank-lan.conf >/dev/null <<'EOF'
[Resolve]
DNS=192.168.0.143
Domains=~franksdank.cloud ~franksdank.casa
EOF
sudo systemctl restart systemd-resolved
resolvectl flush-caches
```
The `~` (route-only) prefix sends `*.franksdank.cloud` ONLY to Pi-hole and never to the default-route servers. Verify: `getent hosts www.franksdank.cloud` → 192.168.0.143.
## Note
This is a per-client fix. Every LAN client that must reach the homelab by name needs Pi-hole as its DNS (or this drop-in). The server side needs no change.
@@ -1,70 +0,0 @@
---
name: live-llm-benchmark-hygiene
description: "Measure and tune a live LLM server without fooling yourself: workload-matched corpora, salted runs against warm prefix caches, turn-level clients, engine ledger for capacity, soak tests, and reboot verification."
---
# Benchmarking a live LLM server honestly
Use when measuring or tuning a served model (vLLM, llama.cpp, halogen, radiance,
any OpenAI-compatible endpoint). The point is to avoid the four confounds that
turned a night's numbers into fiction before each one was fixed.
## 1. Match the corpus to the workload
Synthetic token soup (`vllm bench serve --dataset-name random`) under-reports
speculative decoding badly: measured 2.9-3.6 tok/update against 3.86 on real
code, and acceptance — not step time — is what moves throughput. For a coding
fleet, build the corpus from the repo's own sources: file body plus an
instruction that requires reading it. Then `tok/update` and TPOT mean something.
## 2. Salt every run so the prefix cache cannot help
vLLM runs with `--enable-prefix-caching`, and a repeated corpus silently
measures a warm cache. Put a unique per-run token in the first bytes (it is both
the engine's cache key and, if a proxy routes on it, the routing key). Without
this, the second run of any comparison is not comparable to the first.
## 3. Measure turns, not only batches
A batch of distinct prompts parallelises prefill and hides the real cost of a
conversation: an agent loop re-sends its context every turn. Two workloads need
two clients:
- **batch/throughput** — `vllm bench serve --dataset-name custom --dataset-path
<jsonl>` with `--custom-output-len`, several concurrency levels.
- **sequential turns** — a small client that sends turn after turn with growing
history and a large shared prefix, timing **TTFT of the first streamed chunk**
per turn. This is the only measurement that exposes prefix-cache behaviour.
## 4. Read the engine's own ledger for capacity
Do not infer memory from the card's size. The server prints the truth at boot:
```
Available KV cache memory: 5.44 GiB
GPU KV cache size: 122,636 tokens
Maximum concurrency for 114,688 tokens per request: 1.07x
```
That is the number to quote for "how many concurrent conversations fit".
Related trap: the arena/KV budget is **not monotonic in chunk size** — on one
host, available KV was 5.44 GiB at `CHUNK=8192`, 4.22 GiB at 16384 and 4.16 GiB
at 4096 (where it would not even boot). Never assume "smaller chunk frees KV";
measure three points before treating a knob as a lever.
## 5. Soak before believing a tuning change
Run several families concurrently for 15-30 minutes and count failures, not just
latency: a nightly soak returned 155/155 answers with zero failovers while only
25% of turns hit the cache — which is how the capacity ceiling was found as a
*latency* effect rather than an error. Report hit/miss split, medians, and the
min/max, because the median of a bimodal distribution describes neither mode.
## 6. Verification that is not the benchmark
- Run the client the fleet actually uses (e.g. `omp --model <alias> -p
--no-tools`) — a config file that parses is not a config that works, and a
non-obvious API mode (raw `/v1/completions`) may still be fine because the
client templates its own prompt.
- A deployed inference unit is not proven until an **unattended reboot** brings
it back: verify `is-active`, `/health`, and one real completion afterwards.
@@ -1,87 +0,0 @@
---
name: live-llm-telemetry-no-bench
description: "Measure a LIVE LLM endpoint's real prefill/decode/spec-decode performance and its PCIe cost from the runtime's own journald telemetry plus nvidia-smi dmon, without stopping the service or running a benchmark beside it — for hosts whose rules forbid benchmarking a serving GPU."
---
# Live LLM telemetry without benchmarking
Use when asked "how is the model performing?" on a host that SERVES production
inference, where benchmarking policy forbids running `llama-bench`/`vllm bench`
beside the live service. Instead of a synthetic bench, mine the runtime's own
per-request telemetry — it measures the real workload and costs nothing.
## 1. Where the numbers live
| Runtime | Unit kind | Log command |
|---|---|---|
| llama.cpp (mainline, `-lv 3`) | root systemd unit | `sudo journalctl -u <unit> --since "-3h" --no-pager` |
| halogen-flash-server | rootless Quadlet | `journalctl --user -u <unit> --no-pager` (**never** sudo — per-user storage) |
llama.cpp emits, per completed task:
```
I slot print_timing: id 0 | task 82 | prompt eval time = 339.51 ms / 57 tokens ( 5.96 ms per token, 167.89 tokens per second)
I slot print_timing: id 0 | task 82 | eval time = 428.49 ms / 36 tokens (12.24 ms per token, 81.68 tokens per second)
I slot print_timing: id 0 | task 82 | total time = 768.00 ms / 93 tokens
I slot print_timing: id 0 | task 82 | graphs reused = 84
I slot print_timing: id 0 | task 82 | draft acceptance = 0.75758 ( 25 accepted / 33 generated), mean len = 3.27
I slot print_timing: id 0 | task 97 | n_gen = 188, tg = 62.06 t/s, tg_3s = 62.39 t/s
```
halogen-flash-server emits one line per request:
```
serve_api: mtp 28 tok in 0.66s = 42.45 t/s | 16 rounds, commit 1.75/round | prompt 58, prefill 0.92s | detok 24us/tok
```
Grep both with `grep -E "print_timing|release|serve_api: mtp"`. Long lines get
truncated in a terminal — redirect to a file/artifact and read it whole.
## 2. Read the envelope, not one sample
- `prompt eval` on a SHORT prompt is overhead-dominated (57 tok @ 168 t/s) — use
the longest prompt available for the prefill figure (1106 tok @ 1013 t/s).
- `eval` (decode) and `draft acceptance` are content-dependent: measured
acceptance 0.46-0.76 on one unchanged build. Never quote a single task as
"the model's speed", and never compare a live sample to a deploy-time
baseline without a masked `llama-bench`.
- `graphs reused` > 0 and `selected slot by LCP similarity` prove prefix-cache
reuse is working; `prompt 53 (48 cached), prefill 0.05s` vs `1.58s` cold is
prefix caching caught in the act.
- A runtime's own window metric (`tg_3s`) is a cross-check on your arithmetic.
## 3. Attribute PCIe without touching the model
Run the counter in the background and issue ONE ordinary request:
```bash
ssh host 'nvidia-smi dmon -s t -c 16 -o T & sleep 0.5; \
curl -s -m 60 -o /dev/null -w "http=%{http_code} wall=%{time_total}s\n" \
http://127.0.0.1:7771/v1/chat/completions \
-H "Content-Type: application/json" \
-d "{\"model\":\"<alias>\",\"messages\":[{\"role\":\"user\",\"content\":\"...\"}],\"max_tokens\":200}"; \
wait; sudo journalctl -u <unit> --since "-1min" --no-pager | grep print_timing'
```
`-s t` = `rxpci`/`txpci` in MB/s. Measured on a fully-VRAM-resident 27B on a
3090: peak ~230 MB/s against a Gen4 x4 link worth ~7,900 MB/s = ~3%, and 0 at
idle. Decode traffic is essentially the sampled-logits stream, so on a 4-lane
link the transport is a non-factor — say so with the measured number instead of
hand-waving.
## 4. Traps
- **`wait` or you lose the telemetry.** If the remote command exits while the
curl is backgrounded, ssh closes the session and the client dies: the journal
shows `W srv stop: cancel task, id_task = N` and NO `print_timing` line, while
curl still reports a plausible status. Always end the compound command with
`wait`.
- **Do not run a GPU workload to "get a reading".** One max_tokens=200 request
is production traffic and is fine; anything that pins the card for a while is
a bench and needs the stop-and-mask procedure first.
- **Check which instance you are reading.** Quadlet units run `--replace --rm`
and root units get restarted: `systemctl status <unit> | head -12` for the
start time; `task N` IDs increment per request and jump across restarts.
- **The engine's own ledger outranks your model of it** — quote `KV self size`,
`n_slots`, `n_ctx_slot` and the VRAM figure from the log/NVML rather than
arithmetic.
@@ -1,51 +0,0 @@
---
name: llama-bench-fleet-sweep
description: "Run a llama-bench sweep (batch/ubatch, ncmoe, split, or any dimension) on a Dank-fleet LLM host correctly: stop the service, run from a transient unit, one invocation per cell, sample VRAM, restore and verify — including the llama-bench vs llama-server traps that have already produced wrong numbers here."
---
# llama-bench sweeps on a Dank-fleet LLM host
Use when tuning a llama.cpp service's serve-time parameters (batch/ubatch, `-ncmoe`, `-sm`/`-ts`, KV type) offline, i.e. any question of the form "how large may X be / what does X buy".
Reference implementation: `scripts/bench-batch-ubatch.sh` (repo) — read it before writing another harness.
## Preconditions
1. **Stop the service** — Benchmarking Policy in AGENTS.md is mandatory: a bench beside a live service starves the same GPU and corrupts results. Never bench against the live port.
2. **Run from a transient unit, not a shell:**
```bash
sudo systemd-run --unit=<name> --collect --no-block --property=Type=oneshot \
--property=LimitMEMLOCK=infinity --property=User=dank \
/home/dank/serverAppConfigs/scripts/<harness>.sh
```
The deploy's unit sets `LimitMEMLOCK=infinity` for the ROCm staging buffers; an SSH shell gets 8 MiB (`ulimit -l`), which fails the load for a reason unrelated to what you are measuring. Passwordless sudo is available on both hosts.
## The traps (each one has produced a wrong number)
- **`/` vs `,`.** llama-bench splits `/` = devices *within one configuration*, `,` = *sweep values*. A legitimate llama-server `--tensor-split 36,29` becomes `-ts 36/29`; the comma form silently benches each card alone. Same for `--device ROCm0,ROCm1` → `-dev ROCm0/ROCm1`.
- **No `-c`.** `n_ctx = n_prompt + n_gen + n_depth`. The context is a consequence of the test shape, so a bench can NEVER prove that a configuration's activation buffers fit beside the *deployed* KV — that requires starting the real service at the real context and slot count.
- **A test that cannot allocate calls `exit(1)`**, killing the whole process. One invocation per cell, or one infeasible value loses every later cell — and infeasibility is usually what you are looking for.
- **No `--spec-*` and no `--parallel`.** Decode figures are the *drafterless* baseline, and slots are never exercised. `-d <n>` is what puts KV in front of the measurement: a `tg` instance runs with `n_prompt = 0`, i.e. at ~zero depth otherwise.
- **A cell that looks fast because its allocation was smaller than intended is indistinguishable from a fast cell** unless you sample VRAM. Sample `rocm-smi --showmeminfo vram --csv` every 2 s for the cell's whole life and report min-free per card.
- **`local a=$1 b=$x` expands `$x` before the builtin assigns anything** — under `set -u` that is `b: unbound variable`, and it fires *after* your harness has already stopped the production service. Declare in two statements.
- **Ranges exist:** `-ub 512-2048+256` == `512,768,...,2048`.
## Shape of a good harness
- One `llama-bench` invocation per cell; append one TSV row per cell as it completes, so progress is readable mid-run and an interrupted sweep resumes (skip cells whose `bench.json` is non-empty).
- Write `bench.json` per cell (`-o json`), plus `bench.log` (`--progress`), plus the VRAM trace.
- **Stop the service in the harness and restore it from a `trap ... EXIT`** — and still verify the service afterwards. A harness that crashes mid-run leaves production down.
- Match the deployment's load flags exactly (`-ngl`, `-sm`, `-ts`, `-fa`, `-ctk/-ctv`, `-t`), or you are benching a different service. Note `-ts`/`-dev` take slashes here while the unit takes commas.
## Verify before burning hours
Dry-run the harness against a **stub** `llama-bench` (`BIN=/tmp/stubdir`, plus `SERVICE=<nonexistent-name>` so the real service is untouched) with a tiny grid, including one cell forced to `exit 3`: prove the TSV row, the failure capture, and the resume logic before the real sweep. Then validate the real flag set in ~1 s by pointing `-m` at a nonexistent file — argument errors surface *before* the model load.
## Interpreting results
- A prefill difference smaller than the run-to-run spread is not a result; check each cell's own `stddev_ts`.
- Single-sequence decode is usually flat across batch/ubatch — that knob is prefill-side.
- Batch ≥ ubatch always (llama.cpp clamps ubatch to batch, so smaller-batch cells silently duplicate).
- Report what the harness cannot see (drafter, slots, real context) rather than implying the sweep settled it.
@@ -1,109 +0,0 @@
---
name: llama-bench-host-sweep
description: "Run a llama-bench sweep (batch/ubatch, split, quant, depth) on a live fleet LLM host without fooling yourself: what llama-bench cannot express, the separator trap, per-cell isolation, the memlock/transient-unit requirement, and the dry-run that must precede a multi-hour run."
---
# Running a llama-bench sweep on a fleet host
Use when a bench must answer a serving question on one of the LLM hosts (Dankclaw
frank-large/frank-small, Dankserver frank-medium) — batch/ubatch sizing, split
choice, KV type, depth behaviour.
Repo policy applies: **stop the production service first** (AGENTS.md Benchmarking
Policy). The harness below does that for you and restores it.
## 1. What llama-bench cannot express — decide this BEFORE designing the sweep
| Not available | Consequence |
|---|---|
| `-c/--ctx-size` | `n_ctx = n_prompt + n_gen + n_depth` (llama-bench.cpp). The context is a consequence of the test shape, so **the deployment's real allocation is never tested** (e.g. a 16 GiB KV at 262144 over 2 slots). Only starting the real unit answers "does it fit". |
| `--parallel` | single sequence only; no slot shape, no concurrency effect. |
| `--spec-*` | no drafter. Any `tg` number is the **autoregressive baseline**, not the served rate. Useful as a control, never as the deployed figure. |
| warmup per config | a warmup run is itself a full-size run; at 64k prompts `--no-warmup` buys a second sample for the same wall clock. |
`-d N` is a depth PREFIX: it puts N tokens of KV in place before the measured run and
is cached across reps through a `llama_state_seq_*` save/restore, so the fill is paid
once per cell and is not part of the measured number. Without `-d`, a `tg` test runs at
~zero depth and the number is meaningless for long conversations.
## 2. Two traps that silently change the experiment
- **Separators.** `/` separates devices *within one configuration*; `,` separates
configurations to sweep. `-ts` and `-dev` both follow this. `-ts 36,29` parses as the
single value `36` → everything lands on one card and you have benched a different
machine. llama-server takes the comma form for the same pair. List-valued args also
accept ranges: `-ub 512-2048+256`.
- **memlock.** The deploy units set `LimitMEMLOCK=infinity` because the ROCm backend
pins staging buffers; a plain SSH shell on these hosts has soft=hard=8 MiB
(`ulimit -l`), so a load can fail for a reason unrelated to the model. Run the sweep
inside a transient unit:
```bash
sudo systemd-run --unit=fm-bench --collect --no-block \
--property=Type=oneshot --property=LimitMEMLOCK=infinity --property=User=dank \
/home/dank/serverAppConfigs/scripts/bench-batch-ubatch.sh
```
This also survives an ssh disconnect, unlike a foreground remote process.
## 3. One invocation per cell
A test that cannot run calls `exit(1)` immediately (failed depth/prompt/gen run), so a
single cross-product invocation loses **every later cell** the moment one ubatch fails to
allocate — and infeasibility is usually one of the questions being asked.
Drive the grid from a shell loop: one llama-bench call per (b, ub), continue on a
non-zero exit, record why, and skip cells whose output file already exists and is
non-empty (that makes an interrupted sweep resumable). Write rows to a TSV as the sweep
proceeds so progress is readable mid-run.
## 4. Sample VRAM per cell, covering the load
Batch/ubatch sizes the activation buffers, so a cell that looks fast because its
allocation was silently smaller must be distinguishable from one that is genuinely fast.
Poll `rocm-smi --showmeminfo vram --csv` every 2 s for the cell's whole life and keep the
peak (minimum free) per card. Correlate by taking the window between the cell's start and
end.
## 5. Always restore the service from a trap
`trap <restart> EXIT` in the harness, plus a manual check afterwards. A harness that
stops a production unit and then dies leaves inference down; this fleet has had that
happen.
## 6. Dry-run before a multi-hour run
Stub the binary so the whole path is exercised without loading a 29 GiB model:
```bash
# /tmp/benchstub/llama-bench prints the -o json shape and exits non-zero for one ubatch
SERVICE=frank-medium-dryrun BIN=/tmp/benchstub MODEL=/tmp/benchstub/m.gguf OUT=/tmp/bub-test \
N_PROMPT=8 N_GEN=4 N_DEPTH=0 REPS=1 BATCHES="2048 4096" UBATCHES="512 1024" \
bash scripts/bench-batch-ubatch.sh
```
`SERVICE=<nonexistent>` means the trap and the stop are never reached, so the real
service is untouched. Check that a FAILED cell still produces a row with the right column
count before trusting the table.
## 7. Read the result sanely
- Bracket the sweep: run the reference cell (the deployed configuration) first and
again last, so drift over hours is visible.
- Sanity-check one number against the runbook's prior figures before believing the grid.
- Remember what the grid does NOT prove: it did not run the drafter, the slots, or the
deployment's context. The winner still has to be applied and verified live.
## Pitfall while writing such a harness
`local b=$1 ub=$2 dir="$OUT/b${b}_ub${ub}"` expands **all** the builtin's arguments
before the builtin assigns any of them, so under `set -u` this aborts with
`b: unbound variable`. Declare in two statements:
```bash
local b=$1 ub=$2
local dir="$OUT/b${b}_ub${ub}" sampler start rc wall
```
Ref: `scripts/bench-batch-ubatch.sh` in serverAppConfigs (the fleet's implementation of
all of the above).
@@ -1,58 +0,0 @@
---
name: llama-cpp-spec-decode-ab
description: "Measure speculative-decoding configurations (drafter vs MTP vs ngram-mod vs chains) on a live llama.cpp endpoint without fooling yourself: shape taxonomy, negative controls, unit-stopped protocol, window sweeps, and the traps that produce false verdicts."
---
# Spec-decoding A/B on a live llama.cpp endpoint
Use when asked "is X faster than Y?", "can these be chained?", or "should we use MTP/ngram/drafter".
Verdicts here are **traffic-shaped**: a proposer can only draft text it has seen, so a single prompt shape answers the wrong question.
## Step 0 — settle chaining in the source, before spending GPU time
`common/speculative.cpp` (`common_speculative_init_result`) builds `configs` and gates each type:
- **draftless** (ngram family: simple, map-k, map-k4v, mod, cache) → added unconditionally
- **context-backed** (`draft-mtp`, `draft-dflash`, `draft-dspark`, `draft-eagle3`) → require `params.draft.ctx_dft != nullptr`
Consequences: one context-backed drafter + any number of ngram types chains fine; **two context-backed types never do**
(`draft-mtp,draft-dflash` → `context type MTP requested but model doesn't contain MTP layers`).
Internal order is fixed (ngram first, then drafters), so CLI order is irrelevant.
## Step 1 — shapes (the part that decides the answer)
| Shape | Prompt | Who it can favour |
|---|---|---|
| prose | 1k filler + "reply with one sentence" | model-backed drafters only |
| echo | ~1.5k passage of UNIQUE lines + "repeat verbatim" | ngram (output IS the prompt) |
| structured | repetitive output, no prompt overlap | ngram, if spans really repeat |
| identity | short deterministic question | controls for lossless verification |
Design the echo passage so no long span repeats *inside* it — otherwise you measure the matcher against itself.
For `structured`, use lines that genuinely repeat: unique ids per line make it a negative control, not a repetition test.
## Step 2 — protocol
1. Stop the unit (`sudo systemctl stop <svc>`), `pkill -x llama-server`; wait for the port.
2. Per config: write an env file with overrides, start the serve script with `FRANK_*_ENV_FILE`, poll `/health` until ok, then measure.
3. **Restore the unit in a `finally`** and shout if it does not come back healthy. A crashed harness that leaves production down is worse than no harness.
4. Compare greedy text across configs per shape; warn loudly on any mismatch (speculation must be lossless) and on any EMPTY answer (an empty answer makes the comparison vacuous).
## Step 3 — read the right counter
- Per request: `timings.draft_n` / `timings.draft_n_accepted`, `predicted_per_second`.
- Cumulative: `llamacpp:spec_decode_num_drafts_total` / `..._accepted_tokens_total`.
- **`draft_n == 0` while tokens flow = the proposer is not engaged**, even when every flag looks right.
- A config with zero drafting is a useful *negative control*: it proves the other config's drafts came from the proposer under test.
## Traps that produce false verdicts
- **Thinking eats the answer.** Echo/structured prompts with thinking on put the copy in `reasoning_content`; `content` is empty, so identity checks compare `''` to `''`. Disable per request: `chat_template_kwargs: {"enable_thinking": false}`.
- **No banner.** ngram-mod prints nothing at startup; the journal shows only the drafter's `adding speculative implementation`. Never conclude "chain not engaged" from the load log.
- **Windows change the verdict.** ngram-mod `--spec-ngram-mod-n-min/--spec-ngram-mod-n-max` decide which traffic it can see: too short → proposals on ordinary prose fragments, rejected, verification still paid (measured 43.9 → 36.0 tok/s). Sweep the window in the same run as the configs.
- **Unused-tensor warnings are normal**: with `draft-dflash` on an MTP-carrying GGUF, `blk.<n>.nextn.*` is `unused ... ignoring` — correct, not a truncated file.
- **Harness hygiene**: absolute script paths (a relative path plus `cd` to its own dir double-counts the prefix), `pkill -x` never `-f`.
## Interpreting
Report `decode tok/s` per shape plus acceptance (`accepted/draft_n`) and mean accepted length; a config that wins on echo and loses on prose is a traffic decision, not a winner. Then wire the winner into the deployed env file with the measured table as its justification comment, and assert it in the test seam.
@@ -1,73 +0,0 @@
---
name: llama-flash-attn-qsa-fix
description: Restore --flash-attn on for qwen4exp models when QSA indexer path aborts with GGML_ASSERT F32 error
---
## Problem
`--flash-attn on` aborts graph build for qwen4exp models:
```
GGML_ASSERT(a->type == GGML_TYPE_F32)
at ggml/src/ggml.c:5371, backtrace through build_attn_qsa
```
Root cause: `build_attn_qsa` calls `ggml_fill` on the F16-cast kq mask returned by `get_kq_mask()` when flash attn is enabled, but `ggml_fill` requires F32.
## Solution
Apply the QSA F32-mask patch that mirrors the MLA sparse path (`llm_graph_context::build_attn`):
1. Use the F32 base mask (`inp->self_kq_mask`) instead of the F16-cast version for the top-k surgery
2. Cast to F16 only at the attention call when `flash_attn` is enabled
### Patch hunks (unified diff format)
```diff
@@ -560,9 +565,13 @@ ggml_tensor * llama_model_qwen4exp::graph::build_attn_qsa(
ggml_build_forward_expand(gf, mctx_cur->cpy_v(ctx0, v_cur, v_idxs, il));
}
- ggml_tensor * kq_mask = inp->get_kq_mask();
+ // get_kq_mask() returns the F16-converted mask when flash attention is
+ // on, but ggml_fill/set_rows require F32. Mirror the MLA sparse path
+ // (llm_graph_context::build_attn): run the top-k mask surgery on the F32
+ // base mask and cast to the attention type only at the end.
+ ggml_tensor * kq_mask = inp->self_kq_mask;
// prepare new kq mask - starts filled with -INFINITY
ggml_tensor * kq_mask_all = ggml_fill(ctx0, kq_mask, -INFINITY);
@@ -592,9 +601,12 @@ ggml_tensor * llama_model_qwen4exp::graph::build_attn_qsa(
ggml_tensor * q = q_cur;
ggml_tensor * k = mctx_cur->get_k(ctx0, il);
ggml_tensor * v = mctx_cur->get_v(ctx0, il);
- ggml_tensor * cur = build_attn_mha(q, k, v, nullptr, kq_mask_top_k, nullptr, nullptr, kq_scale, il);
+ // flash attention consumes an F16 mask; softmax consumes F32 as-is
+ ggml_tensor * kq_mask_attn = inp->cparams.flash_attn ? ggml_cast(ctx0, kq_mask_top_k, GGML_TYPE_F16) : kq_mask_top_k;
+
+ ggml_tensor * cur = build_attn_mha(q, k, v, nullptr, kq_mask_attn, nullptr, nullptr, kq_scale, il);
cb(cur, "kqv_out", il);
return cur;
}
```
## Procedure
1. **Apply patch to fork:**
```bash
cd /home/dank/GitPackages/ROCmFPX
git apply /path/to/qwen4exp-qsa-f32-mask.patch
make -j16 llama-server
```
2. **Validate live (on test port):**
```bash
sudo systemctl stop frank-default-large
nohup ./build-qwen38/bin/llama-server \n --model ... --flash-attn on --ctx-size 32768 --fit off -t 16 -tb 16 \n --port 7999 > /tmp/fa-test.log 2>&1 &
sleep 90 && grep 'model loaded' /tmp/fa-test.log && curl http://127.0.0.1:7999/health
```
3. **Verify completion:** Send a real `/v1/chat/completions` request — if it completes without asserts, the fix works.
4. **Deploy production:** Commit config (FLASH_ATTN=on), push, pull on target, restart service.
## Verification
- Model loads without asserts
- Health endpoint returns 200
- Real completion succeeds (finish: "stop")
- Journal clean: no `GGML_ASSERT`, no `cudaMalloc`, no `missing tensor`
- Flash attention engaged (check server logs for "flash attention" or timing improvement vs off)
## Known Constraints
- Works only with charlie12345/ROCmFPX build-qwen38 (PR #27742 qwen4exp merged)
- Does NOT work with CIRU runtime, upstream llama.cpp, or stock binaries
- ctx-size cap remains 32768 for full-model GPU residency (87 GiB model + KV buffer OOMs beyond)
@@ -0,0 +1,50 @@
---
name: llama-server-arch-verify
description: Verify llama.cpp binary supports a model architecture without relying on binary grep
---
# llama-server Arch Verification
## Problem
`grep -ac <arch> llama-server` returns 0 for ALL architecture names (including ones the binary is actively serving). The arch strings are stored in a packed/constructed form that grep can't catch.
## Solution
Verify arch support via the source registration table, not binary grep:
1. **Check the source registration** (`src/llama-arch.cpp` or `src/llmarch.h`):
```bash
grep -n "gemma4\|qwen3_5\|<arch>" src/llama-arch.cpp
```
If the arch is registered in the source at the commit the binary was built from, it's supported.
2. **Verify the binary is current**:
```bash
stat -c "%y" /path/to/llama-server
git log -1 --format=%cd <commit>
```
The binary mtime should be after the commit date.
3. **Check for model-specific source files**:
```bash
ls src/models/<arch>.cpp src/models/<arch>-*.cpp 2>/dev/null
```
4. **Check for spec-type support** (for MTP/draft models):
```bash
/path/to/llama-server --help 2>&1 | grep "spec-type\|model-draft"
```
## KV Cache Types
The build's allowed `--cache-type-k` / `--cache-type-v` values:
`f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1`
**No true fp8 KV dtype** — `q8_0` is the "FP8 class" (1.0625 B/elem, 8.5 effective bits).
## Separate Draft Model (MTP assistant)
For models where the MTP head is a SEPARATE file (not in-file like Swift):
```bash
--spec-type draft-mtp \
--spec-draft-n-max 3 \
--spec-draft-model /path/to/mtp-assistant.gguf
```
The `--spec-draft-model` flag (aka `-md`, `--model-draft`) loads the separate draft file.
@@ -1,97 +0,0 @@
---
name: llamacpp-cross-backend-device-split
description: "Decide whether two unlike devices (NVIDIA CUDA + AMD Vulkan, or any two backends) can serve one llama.cpp model, and measure the attempt honestly on a serving host — the two scheduler failure modes, what is actually expressible, the dual-backend build recipe, and the measurement traps that make results meaningless."
---
# Cross-backend device splits in llama.cpp
Use when someone proposes serving ONE model across two devices of different
backend families (NVIDIA CUDA + AMD Vulkan/HIP, or a dGPU + an APU), and you
need to know whether it is expressible before spending a build and a service
outage on it.
## Answer first: it is not expressible
A weight tensor in one *device* buffer cannot be consumed by an op the scheduler
placed on another device. Two observed failure modes (mainline llama.cpp
`37b53fd`, 2026-09-16):
| attempt | result |
|---|---|
| per-tensor: `-ot "exps=Vulkan1"` with `-sm none -mg 0 -dev CUDA0,Vulkan1` | `ggml-backend.cpp:941: pre-allocated tensor (blk.0.ffn_down_exps.weight) in a buffer (Vulkan1) that cannot run the operation (NONE)` — scheduler abort |
| per-layer: `-sm layer -ts a,b -dev CUDA0,Vulkan1`, or layer-scoped `-ot` | model load fails, message is only `failed to load model` |
| device + **CPU**: `-ot "exps=CPU"` | **works** — the CPU backend is the universal fallback and host buffers can be copied |
The rule: **the CPU is the only legitimate partner in a heterogeneous split.**
Everything else must be single-vendor. On a CUDA+AMD host that means a card can
be the trunk *or* the offload host, never the complement of an AMD-resident
peer — the same wall the R9700 pair hits from the RCCL/atomics direction.
If the answer is "not expressible", stop and consider the expressible
alternatives in this order: (1) two independent endpoints, one model per device;
(2) single-vendor layer split (`-sm layer`, no collective needed); (3)
device + CPU per-tensor offload.
## Build the dual-backend binary
Needs both toolchains in one tree; Vulkan needs `glslc` + `libvulkan` headers
(already present on this fleet's hosts).
```bash
cmake -B build-dual -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=ON \
-DGGML_CUDA=ON -DGGML_VULKAN=ON -DCMAKE_CUDA_ARCHITECTURES=<sm> \
-DGGML_CCACHE=ON -DLLAMA_CURL=OFF
cmake --build build-dual --target llama-bench llama-cli llama-server -j $(nproc)
```
**Device names are not what you expect.** `--list-devices` on an
NVIDIA-plus-APU host lists the discrete card under BOTH backends (e.g. `CUDA0`
and `Vulkan0`) and the APU last (`Vulkan1`). The iGPU is `Vulkan1`, not
`Vulkan0`. Valid `-ot` buffer types are whatever that list prints; a wrong name
makes llama.cpp print the valid set and exit, so the error is self-diagnosing.
## Measure it without fooling yourself
- **`tg` in llama-bench runs with `n_prompt = 0`.** A plain `-n 64` row is a
~zero-depth decode: no KV traffic at all. Anything about context cost or KV
placement must use `-d/--n-depth`, which fills the cache before timing.
- `-p`/`-n` lists build separate instances (prefill instances with `n_gen = 0`,
generation instances with `n_prompt = 0`), and `n_ctx = n_prompt + n_gen`.
- Compare at **depth-matched** settings, and remember prefill into a deep cache
is the slow case (e.g. 456 tok/s fresh vs 232 tok/s into 64k).
- Sample the actual engines during the run (`/sys/class/drm/card*/device/gpu_busy_percent`,
`nvidia-smi`) — an idle device or a 100 % APU is the difference between a real
measurement and a silent CPU fallback.
- Watch the **host memory** figure, not just tok/s. A Vulkan-resident model lives
in device buffers (committed, not reclaimable), whereas an mmap'd CPU-placed
tensor is page cache — the same model can commit 116 GiB one way and 95 GiB
the other. On a host with co-tenants, that decides deployability.
## Serving-host discipline
- **Stop and mask every LLM service first** (repo benchmarking policy). For a
rootless Quadlet, move the `.container` out of
`~/.config/containers/systemd/` and `daemon-reload` — the generator re-creates
the unit from that file on every reload, so `mask` is not the lever.
- **`systemctl mask` is refused for a relinked root unit** whose
`/etc/systemd/system/<svc>.service` is a symlink into the repo: it exits with
`File ... already exists and is a symlink to ...`. Stop it and ensure nothing
restarts it, or move the symlink aside.
- Root units need `sudo systemctl start`; without it you get
`Access denied as the requested operation requires interactive
authentication` (polkit), which reads like a permissions bug.
- Export the pinning guard: **mainline is `GGML_CUDA_NO_PINNED`** (ik_llama.cpp
was `GGML_CUDA_NO_PINNED_WEIGHTS`). Without it a CPU-overridden tensor can land
in a pinned `CUDA_Host` buffer — the failure mode that page-locked 108 GiB and
starved a host's co-tenants until a power cycle.
- Restore: move the quadlet back, `daemon-reload`, `reset-failed`, start, then
verify `/health`, `/v1/models` **and the engine's own boot ledger** (e.g.
halogen's `kv pool:` lines, which say whether the configured pool was clamped).
## Calibration point
On a 121 GiB Strix Halo host with a 3090: a 177B MoE (112 GiB GGUF, 12 of 48
layers carrying KV) ran **32.6 tok/s shallow / 25.8 tok/s at 64k depth / 456 tok/s
prefill** with everything on the iGPU via Vulkan, and **12.1 tok/s** with the
same trunk but experts on the CPU. If a proposed split cannot beat the
iGPU-only number at matched depth, it is not worth the failure modes above.
@@ -1,62 +0,0 @@
---
name: llamacpp-device-split-feasibility
description: "Decide which multi-device arrangements llama.cpp can actually express on a heterogeneous host (mixed vendors, iGPU+dGPU), and measure them without fooling yourself — covers the expressibility matrix, llama-bench's /-vs-, device/split syntax, independent placement proof, and the stop/restore path."
---
# When to use
Asked "can we split this model across GPU A and GPU B?" on a host where the devices are mixed (NVIDIA + AMD, iGPU + dGPU) or where the obvious arrangement is "weights on device X, different weights on device Y". Also use before *believing* any earlier "it cannot be done" conclusion about a multi-device split — that verdict is easy to produce by accident.
# 1. Classify the arrangement before running anything
llama.cpp expresses exactly two things across devices, and they are not interchangeable:
| arrangement | how | expressible? |
|---|---|---|
| **Layer split** — layers 0..n on dev A, the rest on dev B | `-sm layer -ts <shareA>/<shareB>` | **yes**, no collectives needed; works cross-vendor (CUDA + Vulkan measured) |
| **Per-tensor placement** — e.g. attention+KV on A, experts on B | `-ot <regex>=<dev>` | **NO.** `--override-tensor` sets `has_tensor_overrides()`, a required term of the scheduler's `pipeline_parallel` flag (the only read site is `llama-context.cpp`, in the `pipeline_parallel = ...` expression). That flag sets `n_copies = parallel ? MAX : 1` in `ggml_backend_sched_new`; with `n_copies == 1` a pre-allocated weight on a non-primary device has no backend and the graph aborts at `ggml-backend.cpp` (`pre-allocated tensor (...weight) in a buffer (...) that cannot run the operation (NONE)` — `(NONE)` is the leaf's own op name). `-ot x=CPU` works only because the CPU backend is last in the list. |
So: if the plan needs *tensors* on a specific device, stop — the answer is no, for any vendor mix, and pure-Vulkan vs CUDA+Vulkan changes nothing. If it needs *layers*, it is expressible and worth measuring.
Layer-split arithmetic to check before promising anything:
- A whole layer carries its experts, if the model is MoE. Compute GiB/layer, not GiB/param-group.
- Only some layers carry KV (e.g. every 4th for hybrid attention). A share of layers is therefore only a share of KV — a contiguous range rarely holds the layers you care about.
- Big non-repeating tensors (per-layer-token tables etc.) land on the main device and cannot be moved by `-ts`; they cap how small the primary device's footprint can get.
# 2. Get the harness syntax right, or every number is fiction
`llama-bench` writes `-dev <dev0/dev1/...>` and `-ts <ts0/ts1/..>`: **`/` separates values within one configuration, `,` separates configurations to sweep.**
- `-dev CUDA0,Vulkan1` = **two single-device runs**, never a two-device run.
- `-ts 0.85,0.15` = two sweep values, each becoming `[0.85, 0, 0, …]` → device 0 takes everything.
Write `-dev A/B -ts x/y`. `llama-cli`/`llama-server` use the classic comma form — which is why a failure seen only under llama-bench is a harness bug, not an engine limit.
Cheap pre-flight: run with the intended flags and read the tool's own reported parameters (llama-bench's table has a `dev` column). If a row says one device, your intent was not parsed.
# 3. Prove placement — never trust the label
Sample the non-primary device *while the run is loaded*:
```bash
( for i in $(seq 1 60); do nvidia-smi --query-gpu=memory.used --format=csv,noheader; sleep 10; done ) > /tmp/vram.txt &
```
Expect roughly (layers × GiB/layer + that device's KV) resident. Tens of MiB means the split did not happen. Cross-check: several "different" configurations that all produce the same tok/s to within 0.2 % are probably all the same single-device run.
# 4. Measure at the depth that matters
- `llama-bench`'s `tg` runs with `n_prompt = 0`, i.e. ~zero depth. Use `-n 32 -d 65536` (or deeper) or the KV term — the entire reason for the split — is absent.
- Compare against the **single-device baseline on the fast-memory device**, and against whatever production runtime already serves the model. A split that wins by a few percent at 64k depth may lose at 200k, because the KV term grows with context and prefill cost grows with the target depth.
# 5. Stop / restore on a host that serves
- Stop and mask the production units first (benchmarking policy). For rootless quadlets the lever is the `.container` file (move it aside + `daemon-reload`); `Restart=always` otherwise recreates the service mid-run. `systemctl mask` is refused for a unit that is a symlink into a repo checkout.
- After restoring, verify `/health`, `/v1/models`, and the boot ledger numbers — and keep a memory-headroom note: the same model can come back with less contiguous host memory than before (a reboot re-fragments), which decides whether the next co-tenant fits.
- Landmine seen on a rootless-podman host after a big GPU run: a dead rootless **pause process** takes *all* container management down (every `podman` command: `invalid internal status … could not find any running process`; container starts then fail because the rootless netns holding the bridges is gone: `failed to bind udp listener on 10.89.x.1:53`). `podman system migrate` panics (5.7.0, `teardownNetworkBackend`). Repair is a reboot. Before rebooting, check `loginctl show-user <u> -p Linger` and that units carry `[Install] WantedBy=default.target`, so everything returns by itself.
- Do not publish a cause you did not check: `journalctl -k | grep -i oom` decides whether "memory pressure killed it" is a finding or a guess. An invented narrative is worse than an open question.
# 6. Report shape that survived review
State (a) which arrangement was tested and that the harness parsed it, (b) the placement evidence, (c) the depth-matched numbers next to the single-device baseline, (d) the memory cost on the borrowing device, and (e) whether the split can reach its own premise (KV on the fast device, long context) — not just that it runs.
@@ -1,168 +0,0 @@
---
name: llamacpp-drafter-kv-split-sizing
description: "Size a llama.cpp multi-GPU layer-split deployment that carries a speculative drafter: read the drafter's KV law from its GGUF header, then find the split balance by measurement (the batch asymmetry, the ctx/floor trade, and the OOM-vs-page-fault signatures)."
---
# Sizing a llama.cpp layer-split with a speculative drafter
Use when a multi-GPU llama.cpp deployment carries a draft model (`--spec-draft-model`)
and the question is *where do the layers go and how big can the context be*. The two
effects below decide it, and neither is visible in the flags.
## 1. Read both GGUF headers first
The drafter's own properties set the memory bill. `llama-server` prints almost none of
them, and the loader's ledger is not printed at default verbosity.
Minimal GGUF KV reader (no tooling needed) — write `ggufmeta.py`:
```python
import struct, sys
def rd(path):
f = open(path, 'rb')
assert f.read(4) == b'GGUF'
ver = struct.unpack('<I', f.read(4))[0]
struct.unpack('<Q', f.read(8)) # tensor count
nk = struct.unpack('<Q', f.read(8))[0] # kv count
def s():
n = struct.unpack('<Q', f.read(8))[0]
return f.read(n).decode('utf-8', 'replace')
# 0=UINT8 1=INT8 2=UINT16 3=INT16 4=UINT32 5=INT32 6=FLOAT32 7=BOOL
# 8=STRING 9=ARRAY 10=UINT64 11=INT64 12=FLOAT64
SCALAR = {0:1, 1:1, 2:2, 3:2, 4:4, 5:4, 6:4, 7:1, 10:8, 11:8, 12:8}
FMT = {0:'<B',1:'<b',2:'<H',3:'<h',4:'<I',5:'<i',6:'<f',7:'<?',10:'<Q',11:'<q',12:'<d'}
keys = {}
for _ in range(nk):
k = s()
t = struct.unpack('<I', f.read(4))[0]
if t == 8:
v = s()
elif t in SCALAR:
v = struct.unpack(FMT[t], f.read(SCALAR[t]))[0]
else:
et = struct.unpack('<I', f.read(4))[0] # element type FIRST
n = struct.unpack('<Q', f.read(8))[0] # then the count
if et == 8:
v = ('arr_str', [s() for _ in range(n)])
elif et in SCALAR:
v = ('arr', et, n); f.read(n * SCALAR[et])
else:
raise Exception('unsupported arr elem type %d' % et)
keys[k] = v
return ver, keys
for p in sys.argv[1:]:
ver, k = rd(p)
arch = k.get('general.architecture')
print('=====', p, 'v%d' % ver, 'arch:', arch)
for key in sorted(k):
if key.startswith('tokenizer.') or isinstance(k[key], tuple):
continue
if key.startswith(arch + '.') or not key.startswith(('general.', 'quantize.')):
print(' ', key, '=', k[key])
```
The three format traps, each of which cost a debugging session:
1. **The KV stream has NO alignment.** GGUF v3's alignment (from `general.alignment`,
default 32) applies only to the *tensor data section* that follows. Do not pad
between KV pairs.
2. **Array layout is `type(9) -> element_type(u32) -> count(u64)`.** Count-then-element
is wrong and yields a `MemoryError`/`UnicodeDecodeError` a few pairs later.
3. **The type enum is not the order you expect**: `7=BOOL`, `9=ARRAY`, and the 64-bit
types are `10/11/12`. Getting `3=INT16` (2 bytes) or `5=INT32` (4 bytes) wrong shifts
the whole stream.
Fields that matter: `*.block_count`, `*.block_size`, `*.attention.head_count_kv`,
`*.attention.key_length`/`value_length`, `*.attention.sliding_window`,
`*.context_length`.
## 2. The drafter's KV is sized from the TARGET
`common/speculative.cpp` builds the draft context with:
```cpp
// the draft context holds as many tokens per sequence as the target context
cparams.n_ctx = llama_n_ctx(ctx_tgt);
```
There is **no flag** to cap it (the `--spec-draft-*` surface has none). Therefore:
```
draft KV bytes = ctx_tgt x block_count x 2 (K,V) x head_count_kv x key_length x dtype
```
*unless* the drafter declares a sliding window, in which case the window bounds it.
Measured on the same 262144-token target: a sidecar with `sliding_window = 2048` cost
~40 MiB of draft KV; a sidecar of the same 5-layer shape with **no** window cost
**5.0 GiB** (20 KiB/position). Swapping between them invalidated an already-balanced
layer split.
**Failure signature:** the engine loads the whole model, answers `/health`, and dies on
the FIRST draft decode with an allocation error (`ROCm error: out of memory` /
`cudaMalloc failed`) inside `common_speculative_impl_draft_dflash::process`. An
allocation failure *after* a successful load is a sizing failure, not a broken drafter
-- and under a self-healing unit it looks like a crash-loop instead.
## 3. A bigger batch is asymmetric across the split
`--ubatch-size` is the physical batch the graph is built at, so it sets the compute
buffers. Those buffers are **~2x heavier on the card that holds the output layers and
the drafter's graphs** than on the other(s). Measured, 2 GPUs at 262144 ctx:
| ubatch | output/drafter card | other card |
|---|---|---|
| 512 -> 1536 | +~3.3 GiB | +~1.7 GiB |
Consequence: **a split balanced at one batch is unbalanced at another.** A split at
1:1 or a hand-measured skew must be re-measured after any change to `--ubatch-size`,
`--ctx-size` or `--parallel`.
## 4. Find the balance by measurement, not arithmetic
The per-step cost is not derivable from the parameter count (KV lands only on the
attention layers, and buffer placement is per-device). One `-ts` step is worth roughly
1.5 GiB of load at ~6k batch / 262k ctx on 30 GiB-class cards — enough to bracket, not
to solve. Procedure:
1. Copy the env file to a scratch path and run the serve script against it
(`SERVICE_ENV_FILE=/tmp/test.env bash scripts/serve-<svc>.sh`) rather than editing
the deployed file. Do it with the unit stopped — one GPU, one process.
2. Bracket: try a step, read `rocm-smi --showmeminfo vram` for both cards after a real
generation, and step again. Three points is usually enough to land within ~0.1 GiB.
3. Prefer the balanced split, because the busier card's headroom is what bounds the
context:
```
ctx_extra ~= (headroom_target - measured_free) / (per-position cost on the busier card)
```
where per-position cost = the card's share of the target's KV + the drafter's KV if
that card carries it. A balanced split therefore MAXIMISES ctx for a given floor.
4. Verify with the full battery before committing: `/health`, `/v1/models`, a real
completion, a tool call, two concurrent long prompts (one per slot), `slots`
reporting the expected `n_ctx` per slot, and the per-task `draft acceptance = ...`
line per slot.
## 5. Keep the two failure signatures apart
| Signature | Meaning |
|---|---|
| loads, `/health` ok, first decode OOMs | sizing: drafter KV, batch buffers, or KV vs ctx (section 2) |
| `SDMA0 ... page fault` + `illegal memory access` + `ggml_abort` | a GPU fault in the cross-card boundary copy — the layer split's hot path. **Not** an OOM; headroom does not prevent it |
The second one is why a rebalance should be validated by a request battery and not just
by `rocm-smi` numbers.
## Ops traps
- A server started over ssh (including under a supervisor/`hub`-style runner) **outlives
stopping that runner** — the remote process keeps the port, and the next start fails
with `couldn't bind HTTP server socket`. `pkill -x <exact-binary-name>` first.
- `systemctl start` of a root unit from a non-root ssh session fails with
`Access denied ... requires interactive authentication`: use sudo.
- An old crash's `ROCm error` lines remain in `journalctl -u <svc>` forever. Check the
current instance's `ActiveEnterTimestamp`/`NRestarts` before treating a grep count as
a live fault.
@@ -0,0 +1,28 @@
---
name: llamacpp-endpoint-model-swap
description: "Swap the model on a dank-fleet llama.cpp endpoint (frank-secondary pattern): fetch, env edit, spec flags, deploy through git, rewrite the bats seam, verify VRAM with the engine's own numbers."
---
# llama.cpp endpoint model swap (dank fleet)
Use when replacing the model/shape on a llama.cpp endpoint (e.g. frank-secondary on the RTX 3090). The engine's boot numbers are the verdict — do NOT commit to a feasibility verdict from head math; boot it and read `nvidia-smi`.
## Sequence
1. **Geometry first, from the loaded model's own log** (`--log-verbosity 4`, or the HF `config.json` under `text_config` for multimodal): layers, `n_head_kv` (an ARRAY — per-layer, e.g. gemma4 has 8 on sliding layers, 2 on full-attention), head_dim, `sliding_window_pattern`, `n_swa`, `attention_k_eq_v`. KV scales only with full-attention layers; sliding layers cost a fixed window-size ring. Single-value HF fields lie about per-layer heterogeneity.
2. **Fit the KV rate law to the engine, not to arithmetic.** Run once with `--log-verbosity 4` and read the `llama_kv_cache` / `sched_reserve` buffer MiB lines. Known law for master 5fc4f3c: bytes/elem = (K bytes + V bytes) **x2** when the file declares `attention_k_eq_v` (6.125 at f16 K + q8_0 V, 2.125 at q8_0/q8_0) — k_eq_v is a compute-graph mapping and does NOT shrink the cache. Full cache = n_full x n_head_kv x head_dim x rate x ctx; SWA cache = n_swa x window x n_head_kv_swa x head_dim x rate. Both reproduce the printed MiB exactly; if your formula does not, your law is wrong, not the engine.
3. **Probe the binary's real capabilities** before promising flags: `--help | grep -A2 cache-type-k` (this fleet's build has NO fp8; q8_0 is the "FP8 class"), `--help | grep -A2 verbosity` (`-lv, --log-verbosity N`; ship `VERBOSITY` as an env tunable + `--log-verbosity "$VERBOSITY"` so VRAM forensics land in the journal), `--help | grep model-draft` for separate drafters. NEVER `grep -c <archname>` on the packed binary to check arch support — it returns 0 for every arch; check the source tree at the build's commit.
4. **Fetch with `-f` + byte-count check** into `/home/dank/models/<name>/`.
5. **Edit the repo trio** (`scripts/serve-<svc>.sh`, `scripts/<svc>.env`, `~/.omp/agent/models.yml`): spec flags move as a SET (`SPEC_TYPE=none` strips `--spec-type` AND `--spec-draft-model`/`--spec-draft-*`); a drafter with its own arch file (gemma4-assistant) needs `SPEC_DRAFT_FILE`, and its draft KV is built against the TARGET's full window — price it from a boot pair. `models.yml` `contextWindow` = per-slot window (`CTX_SIZE/PARALLEL`); recompute `maxTokens` for the compaction clamp `min(maxTokens, ctx - estimate - 4096)`.
6. **Rewrite the bats seam in the same commit** (`tests/<svc>_unit.bats`): payload path, slot geometry, KV pair, spec state, batch pair, verbosity, mmproj opt-out, reasoning flags — all argv-asserted against the stub engine; where the contract is a repo value (batch pair, verbosity) assert argv == env value so a retune moves one file.
7. **Deploy through git** (no direct remote edits). If the remote tree is dirty: `git stash push <files>` on the host, `git pull --ff-only`, land the change as a commit. A pull that aborts leaves the host at the OLD revision while the service runs the NEW edits — verify `git log -1` on the host.
8. **Verify the engine, not your math**: `/proc/<pid>/cmdline` (argv is the contract), `/v1/models` (`n_ctx`, `ftype`, `n_ctx_train`), `nvidia-smi --query-gpu=memory.used,memory.free`, `/health` 200, a vision probe (solid color >=256 px via `image_url`, expect the exact word), and one generation probe reading `timings.predicted_per_second`. Record the numbers in the env comment + runbook, then push and ff-pull the host.
9. **Cutover AGENTS.md in the same commit**: fleet-topology table row, the `### <svc>` section, the `Model Config` contextWindow/vision note, the runbook index ACTIVE row, a RETIRED row for the superseded era. The new docs page must be linked from the TOP-LEVEL `serverAppConfigs-docs/index.md`.
## Gotchas
- A systemd restart reporting `inactive` in the first seconds is usually model-load time; check `journalctl -u <svc>` for `model done`/`server is listening` before declaring failure.
- Rollback = the previous payload stays on disk; one `MODEL_FILE` + shape edit back.
- A thinking model can consume the whole token budget in `reasoning_content` (empty `content`, `finish_reason: length`) — probe with a budget that clears thinking.
- **Bats vacuity trap**: `serve_argv` REWRITES `$STAGE/test.env` from the shipped env, so a test that mutates the file (`sed -i`, append) then calls `serve_argv` asserts on regenerated content — a tautology. Tests that mutate must exec via a `run_stage` helper running the file as-is. Symptom: the assertion fails only after you fix the vacuity, and the env dump shows the mutation gone.
- `env NAME=value cmd` needs valid identifiers; a flag-like token (`nvidia-modprobe=true`) is only legal as an `env` ARGUMENT, not as a shell assignment prefix.
@@ -1,113 +0,0 @@
---
name: llamacpp-layer-split-rdna4
description: "Serve one model across two (or more) AMD RDNA4/ROCm GPUs on a host where RCCL cannot dispatch collectives — mainline llama.cpp built for gfx1201 with a bundled ROCm runtime, --split-mode layer, DFlash2/MTP speculative decoding, and the verification that proves each layer works"
---
# llama.cpp layer-split serving on RDNA4 (gfx1201)
Use when one model must use two AMD GPUs whose interconnect cannot carry a
collective (no PCIe atomic routing on one card's path), or when a host has no
usable RCCL. The shape: ONE llama-server process, `--split-mode layer` across
both cards, spec decode from a drafter GGUF or the model's own MTP head.
## When to reach for this
- vLLM cannot help: TP needs a per-layer allreduce and **PP's stage handoff is a
device-backend `send`/`recv`** (`vllm/distributed/parallel_state.py`,
`send_tensor_dict` → tensor handles "on the device backend"), i.e. the same
RCCL primitive that fails on an atomics-less card. Verify with
`sudo lspci -vv -s <gpu> | grep AtomicOpsCtl` (want `ReqEn+`) and
`lspci -tv` (a `Routing-` bridge is a hardware wall).
- Confirming the wall: any RCCL kernel launch on that card returns
`hipErrorIllegalState` with `AMD_LOG_LEVEL=3` printing
`Pcie atomics not enabled, hostcall not supported` → `AQL dispatch failed!`.
A single-card test proves nothing (NCCL short-circuits at world_size=1).
- **Never run a multi-GPU collective probe beside a live service** on these
hosts: stop and mask it first, or use a host that serves nobody.
## Build (pin the ref, bundle the runtime)
```bash
git clone https://github.com/ggml-org/llama.cpp && cd llama.cpp
git checkout --detach <ref> # pin; do not float
# ROCm toolchain: system ROCm or a TheRock nightly tarball, e.g.
# https://rocm.nightlies.amd.com/tarball-multi-arch/therock-dist-linux-gfx120X-all-<ver>.tar.gz
ROCM=$HOME/.local/share/rocm-<ver>
cmake -S . -B build-rdna4 -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER=$ROCM/llvm/bin/clang -DCMAKE_CXX_COMPILER=$ROCM/llvm/bin/clang++ \
-DCMAKE_CXX_FLAGS="-I$ROCM/include" -DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DGGML_HIP=ON -DGGML_HIP_RCCL=OFF -DGGML_CUDA=OFF -DGGML_VULKAN=OFF \
-DCMAKE_HIP_ARCHITECTURES=gfx1201 -DGPU_TARGETS=gfx1201 \
-DLLAMA_BUILD_SERVER=ON -DLLAMA_BUILD_WEBUI=OFF -DBUILD_SHARED_LIBS=OFF \
-DCMAKE_PREFIX_PATH=$ROCM -DROCM_DIR=$ROCM
cmake --build build-rdna4 -j $(nproc) --target llama-server llama-cli llama-bench
patchelf --set-rpath '$ORIGIN/../lib' build-rdna4/bin/llama-*
```
- `-DGGML_HIP_RCCL=OFF` is deliberate: with RCCL off, only `--split-mode tensor`
degrades (butterfly fallback); layer split is unaffected.
- **Bundle device libraries at the exact depth the runtime looks for.** Copy
`$ROCM/lib/rocblas/library/` → `build/lib/rocblas/library/` and
`$ROCM/lib/hipblaslt/library/` → `build/lib/hipblaslt/library/` with
`mkdir -p DEST && cp -r SRC/. DEST/`. `cp -r SRC DEST/` with a missing DEST
flattens one level and the failure appears at REQUEST time, not load time
(`Cannot read .../lib/rocblas/library/TensileLibrary.dat`).
- Build only the arch you own. A sibling-arch binary (gfx1200 on gfx1201)
links, loads the model, then segfaults.
- Verify: `build-rdna4/bin/llama-server --version` and
`--list-devices` (expect both cards; an iGPU can enumerate as a third).
## Serve
```bash
HIP_VISIBLE_DEVICES=0,1 llama-server \
--model <target.gguf> --alias <fleet-name> --host 0.0.0.0 --port 7770 \
--device ROCm0,ROCm1 --split-mode layer --tensor-split 1,1 \
--gpu-layers 999 --fit off --flash-attn on \
--ctx-size <n> --parallel 1 --cache-type-k f16 --cache-type-v f16 \
--jinja --metrics --cache-prompt \
--reasoning on --reasoning-format deepseek --reasoning-budget -1 \
--spec-type draft-dflash --spec-draft-model <dflash2-q8_0.gguf> --spec-draft-n-max 7
```
- `--fit off` + an explicit `--ctx-size`: auto-fit must not resize a context
you set on purpose. `--parallel N` DIVIDES `--ctx-size`; it does not multiply.
- Spec decode choices: `--spec-type draft-dflash` with a DFlash2 GGUF (the
drafter's own metadata selects the DFlash2 path — the init line prints
`block_size=8, n_extract=5, sample_from_anchor=true`); `n_max <= block_size-1`.
Or `draft-mtp` for a model whose GGUF carries `blk.<n>.nextn.*` (no draft
file; the head loads from the main weights). Types can be chained
(`draft-mtp,draft-dflash`). With `draft-dflash`, the target's `nextn`
tensors log as "unused tensor ... ignoring" — that is correct, not a broken
download (check bytes with the fetch script's `--verify-only`).
- Layer split puts the drafter on the card holding the target's last layers
(`--spec-draft-device ROCm1`); under tensor mode an unpinned draft joins the
meta-device KV allocation and aborts the KV build.
## Verify (each layer separately)
1. Load: `n_ctx_slot` in the log matches `--ctx-size`; `/health` answers.
2. Both cards: `rocm-smi --showmeminfo vram` — each card holds roughly half the
weights; the busier one also carries the drafter. Compare against
`model_bytes/2 + drafter + ctx*64KiB/2 + ~2 GiB` (qwen35 hybrid: only its
full-attention layers take KV, 64 KiB/token at f16).
3. **Spec decode is live**: `curl /metrics | grep spec_decode_num_drafts_total`
and `..._accepted_tokens_total` (their ratio is acceptance); a request's
`timings.draft_n` / `draft_n_accepted` gives it per call. Drafts stuck at 0
with tokens flowing means the drafter is not running.
4. Real completion through the fleet alias, then a deep prompt (a 140k-token
prompt proves a raised window is real, not just declared).
5. Prefix cache: repeat the same prompt — `timings.prompt_n` should collapse to
a handful of tokens.
## Pitfalls that cost real time
- A healthy-looking build that dies on the first request is almost always the
bundled-library depth above.
- Serve scripts that `source` an env file AFTER defaults cannot be overridden
from the command line; override the env-file PATH instead.
- Qwen3.8 chat templates RAISE on `reasoning_effort=high` (500). Probe
low/medium/high/xhigh before declaring an endpoint ready, and map the
harness's levels onto the supported set.
- One slot at a large context is the concurrency limit: concurrent agent loops
queue, and first-turn TTFT then includes the wait.
@@ -1,70 +0,0 @@
---
name: llamacpp-layer-split-vram-rebalance
description: "Re-measure and set the --tensor-split VRAM balance for a multi-GPU llama.cpp server when the batch, context, slots or spec drafter change, instead of interpolating between splits."
---
Re-balancing is needed whenever a knob moves weight between cards: `--ubatch-size` (compute buffers), a spec drafter (weights + draft KV), `--ctx-size`/`--parallel` (KV), or a different drafter with a different window policy. A split balanced at one configuration is unbalanced at another — and the second card's asymmetry (output layers + drafter graphs) is what makes it non-obvious.
## 0. Stop the service first
Serving a user? Stop it, then free the port, before loading test instances. Do not bench beside a live server.
```bash
sudo systemctl stop <svc> # root unit; for a rootless quadlet use systemctl --user
pkill -x llama-server && sleep 3 # a manual/child run holds the port and the VRAM
```
`pkill -f <pattern>` is forbidden here: it matches your own command line and kills the ssh session. `mask` FAILS on a unit that is a symlink into a repo checkout (`File ... already exists and is a symlink to ...`); a `stop` is enough because nothing else restarts it.
## 1. Measure one split per load
The serve script is env-driven, so point it at a copy of the env file with `TENSOR_SPLIT` patched and read the allocation back from `rocm-smi`. Load -> sample -> kill, with NO requests sent: this touches allocation only, not the compute path.
```bash
WORK=/tmp/split.env; cp scripts/serve-<svc>.env $WORK
export FRANK_MEDIUM_ENV_FILE=$WORK # the script's own override hook
for ts in 39,26 38,27 37,28 36,29 35,30; do
sed -i "s|^TENSOR_SPLIT=.*|TENSOR_SPLIT=$ts|" $WORK
nohup bash scripts/serve-<svc>.sh >/tmp/split.log 2>&1 &
for _ in $(seq 1 150); do
[ "$(curl -s -o /dev/null -m 2 -w '%{http_code}' http://127.0.0.1:PORT/health)" = 200 ] && break
pgrep -x llama-server >/dev/null || break
sleep 2
done
python3 -c "
import subprocess,re
CAP=34208743424 # one card, as rocm-smi reports it
o=subprocess.run(['rocm-smi','--showmeminfo','vram'],capture_output=True,text=True).stdout
u=[int(x) for x in re.findall(r'VRAM Total Used Memory \\(B\\): (\\d+)',o)][:2]
print('$ts', f'{(CAP-u[0])/2**30:.2f}', f'{(CAP-u[1])/2**30:.2f}')"
pkill -x llama-server; sleep 6
done
```
Sample ~6 s after `/health` returns 200 — the last allocations land after listening. Run it under `nohup` and poll the log from a separate call: a load is 30-90 s, which exceeds a single command's patience.
## 2. Read the table, do not interpolate
Pick the split with the smallest inter-card gap, then confirm it clears any per-card floor (e.g. 1.5 GiB) with room to spare. **A `-ts` step is ONE LAYER, and hybrid stacks do not have uniform layers.** Measured on qwen35 (16 x (3 Gated DeltaNet + 1 Gated Attention)): 0.43 GiB on a DeltaNet boundary, 1.43 GiB on a full-attention one (weights + that layer's own f16 KV). So the gap can jump 2.9 GiB in a single step, and the best integer split is ~0.2 GiB from balanced while its neighbours are 1+ GiB off. Interpolating between two measured splits produces a split nobody should deploy.
Total free VRAM is invariant across splits (only its distribution moves) — use that to sanity-check a suspicious row.
## 3. Check the drafter's window before blaming the split
A draft context is built with `n_ctx = llama_n_ctx(ctx_tgt)` and there is no flag to cap it, so a drafter declaring NO sliding window caches the WHOLE target window on `--spec-draft-device`. DFlash2 (`sliding_window 2048`) = ~40 MiB; DSpark (no window) = 20 KiB/position = 5.0 GiB at 262144 — a four-layer swing in the balance point. Read `sliding_window` out of the drafter's GGUF header before sizing, and prefer a skewed `-ts` over cutting `--ctx-size`.
## 4. If the repo has a fitted VRAM model, refit it — with the same unit convention
Match the existing expression literally (e.g. `1442 * 1024 * ubatch` means ~1.4 MB per ubatch unit, not 1442 bytes). Fit so the deployed point reproduces `rocm-smi` to ~0.05 GiB, keep the physics (weights + proportional KV + a window-bounded draft KV + ubatch-scaled buffers), and treat a NEGATIVE fitted constant as a unit bug. State in the comment where the model is conservative: a uniform per-layer cost overstates gaps in a hybrid stack, and a fitted ubatch coefficient makes any larger-batch verdict an extrapolation, not a measurement.
## 5. Verify behaviourally after deploy, not just by flags
- `/slots` -> the expected `(id, n_ctx)` pairs.
- The drafter's init lines from `journalctl -u <svc>`: implementation name, `n_max`, `block_size` — read the NUMBERS, since DFlash2 and DSpark log under the same `draft_dflash` symbol.
- `rocm-smi` free VRAM at the deployed split (the fitted prediction and the real figure should agree to ~0.05 GiB).
- One tool call and two CONCURRENT requests, one per slot, then `journalctl -o cat | grep 'slot print_timing' | grep -o 'slot print_timing: id *[01].*draft acceptance.*'` to prove drafting is live on BOTH slots (`journalctl` default output truncates these lines; `-o cat` plus `grep -o` gets them whole).
- `grep -c 'ROCm error\|illegal memory\|ggml_abort'` on the current instance must be 0.
## Faults to watch, not to tune away
A cross-card boundary copy can fault (`amdgpu SDMA0 UTCL2 page fault` -> `ROCm error: an illegal memory access was encountered` -> `ggml_abort`) on a split that loads and answers `/health` perfectly. It is not an OOM — more headroom does not prevent it — so treat a split that has faulted as off-limits and re-measure rather than re-tuning. Also: a load that succeeds and then dies on the FIRST draft decode with an OOM is a sizing failure (the draft KV), not a broken drafter.
@@ -1,132 +0,0 @@
---
name: llamacpp-mtp-vision-endpoint-bringup
description: "Bring up a new llama.cpp (mainline) OpenAI endpoint on a single CUDA GPU with MTP speculative decoding and vision — GGUF header preflight by HTTP range, hybrid-architecture KV arithmetic, the -mtp / --spec-type draft-mtp contract, --parallel vs ctx math, and the verification battery that proves MTP, tools and vision actually work."
---
# llama.cpp MTP + vision endpoint bring-up (single CUDA GPU)
Use when standing up or swapping a mainline llama.cpp endpoint for a GGUF that
may ship an MTP (NextN) head and a vision projector. Verified end to end on
Dankclaw 2026-09-16 (RTX 3090 24 GiB, Qwen3.8-27B-GSQ-RCO IQ3_S `-mtp` +
BF16 mmproj, 131072 ctx / 1 slot, port 7771).
## 1. Preflight the GGUF WITHOUT downloading it
GGUF metadata lives in the header, and HuggingFace honours ranged GETs, so a
few MB answers every sizing question before a 13 GB transfer:
```python
import urllib.request
req = urllib.request.Request(url, headers={"Range": "bytes=0-8000000", "User-Agent": "curl/8"})
buf = urllib.request.urlopen(req, timeout=180).read()
```
Parse: magic `GGUF`, u32 version, u64 n_tensors, u64 n_kv, then key/value pairs
(string = u64 len + bytes; ARRAY = u32 elem type + u64 count; BOOL is 1 byte —
the type hand-written parsers forget). Walk arrays by byte size even when you
only want a summary: a naive parser desynchronises on `tokenizer.ggml.tokens`
and lands gigabytes off.
Read these keys: `general.architecture`, `<arch>.block_count`,
`<arch>.context_length`, `<arch>.nextn_predict_layers`, `<arch>.head_count_kv`,
`<arch>.key_length` / `value_length`, `<arch>.full_attention_interval`,
`<arch>.ssm.state_size` / `group_count` / `time_step_rank`, and for the
projector file `clip.projector_type` (e.g. `qwen3vl_merger`) plus
`clip.has_vision_encoder`.
## 2. Decide the file from `nextn_predict_layers`
**MTP is a property of the FILE, not of the flags.** If the release ships an
`-mtp` build, that build carries the head as `blk.<n>.nextn.*` (a few hundred
MB larger); the plain build has no MTP tensors at all.
With the `-mtp` file, llama.cpp needs only `--spec-type draft-mtp`: it sets
`load_mtp` and the draft context runs on the MAIN model's weights
(`shares_model`, `LLAMA_CONTEXT_TYPE_MTP`). Do NOT add `--model-draft` — that
loads the whole file a second time (12 GiB twice on a 24 GiB card). A separate
`-md` is only correct for standalone MTP-only sidecars.
Confirm the flag exists in the deployed build before writing the script:
`grep -rn "common_speculative_all_types_str" common/speculative.cpp` and
`llama-server --help | grep -A4 spec-type`.
## 3. KV arithmetic (do this before choosing ctx)
Only **full-attention** layers take KV. For a hybrid arch (Qwen3-Next/3.8 style
`16 x (3 x Gated DeltaNet + 1 x Gated Attention)`):
```
attn_layers = block_count / full_attention_interval
bytes/token = attn_layers x n_head_kv x head_dim x 2 (K,V) x bytes_per_elem
f16 = 2 B | q8_0 = 1.0625 B | q4_0 = 0.5625 B
```
The linear-attention layers hold constant-size per-sequence state instead
(~0.16 GiB per sequence at 48 layers / 128 d_state / 48 v-heads in f32), and
the MTP draft context costs its own `n_ctx x (1 layer x n_head_kv x head_dim x
2 x 2 B)` ≈ 4 KiB/token for a 1-layer head.
VRAM budget: weights + mmproj + MTP draft KV + DeltaNet state + ~1.5 GiB of
CUDA context and compute buffers. On a 24 GiB card that leaves ~9.5 GiB for KV,
which at 64 KiB/token is ~155k tokens total.
**`--parallel N` DIVIDES `--ctx-size` between slots** — it does not multiply.
`-np 1 -c 131072` is one 131k conversation; `-np 4 -c 131072` is four 32k ones.
## 4. Flags that matter
- `-ngl 999`, `-fa on`, `-ctk/-ctv f16` for the usual shape.
- Quantized KV (`q8_0`, `q4_0`) requires a build with
`-DGGML_CUDA_FA_ALL_QUANTS=ON`; without it CUDA prefill collapses ~12x. f16
needs nothing, so prefer it unless the context demand is real.
- `--jinja` — pin it explicitly even when the build defaults it on: the GGUF's
own template supplies `reasoning_effort`, `preserve_thinking` and the
tool-call grammar, and a future default flip must not silently remove tools.
- `--metrics` if anything scrapes `/metrics`, `--alias` to set the served id,
`--temp 0.0` for a greedy-by-default fleet contract,
`--reasoning-format deepseek --reasoning-budget -1` to project thinking into
`reasoning_content`.
## 5. Serve-script and unit invariants
Fail loudly before loading VRAM: check the binary, the model file and the
mmproj exist, and `exit 1` with a named message otherwise — a missing weight
must not become a `Restart=on-failure` crash loop that reads like a runtime
fault. For a native CUDA binary the unit is a ROOT systemd unit
(`[Install] WantedBy=multi-user.target`, `Restart=on-failure`,
`RestartSec=10`, `LimitMEMLOCK=infinity`); a container would be a Quadlet.
## 6. Verification battery (prove each claim, do not infer)
1. `/health` 200, and `/v1/models` reports the intended alias.
2. A chat completion returns `content` AND `reasoning_content`.
3. A request carrying `tools` returns `tool_calls` — a RUNTIME property: the
ik_llama.cpp runtime answered `500 tools param requires --jinja flag`.
4. `GET /metrics` after a generation: `llamacpp:spec_decode_num_draft_tokens_total`
and `..._accepted_tokens_total` both > 0, acceptance 0.6-0.95 ⇒ MTP engaged,
not merely configured. The slot timing lines in the journal say the same.
5. One image request (`data:` URL or bare base64) ⇒ an answer that reflects the
image; confirm the journal printed a multimodal projector load.
6. `nvidia-smi` memory used is close to the modelled total, and `n_slots` /
`n_ctx_slot` in the load log match the intended shape.
## Traps that cost real time
- **A template can reject the client's thinking level.** Qwen3.8 accepts only
`xhigh|medium|low` for `reasoning_effort` and raises otherwise — HTTP 500
naming Jinja, not the model. Map every harness level onto the accepted set
(e.g. `compat.reasoningEffortMap: {minimal: low, low: low, medium: medium,
high: xhigh, max: xhigh}`) and probe each level with curl before routing
roles at the endpoint.
- **Thinking counts against `max_tokens`.** A budget that runs out mid-thought
returns `finish_reason: "length"` with empty `content` — not a broken
endpoint, not a vision failure. Raise the budget.
- **Never point `--model-draft` at the `-mtp` file** (step 2).
- **Any `-ot` / `-ncmoe`-style override can silently disable mmap** for the
whole load, turning a mapped model into anonymous memory.
- On a host that publishes ports through **pasta**, the replacement must not
start until the predecessor released the port: poll
`ss -tan "sport = :<port>"` (listener, accepted AND TIME_WAIT) or the
container dies with `Address already in use`.
- On a shared host, read the serving engine's own boot ledger for device
budget vs need: a pool that silently clamped still answers a green `/health`.
@@ -1,88 +0,0 @@
---
name: llamacpp-multigpu-layer-split-cutover
description: "Cut a fleet LLM endpoint over to llama.cpp layer-split across multiple GPUs when cross-GPU collectives are impossible (RCCL/PCIe-atomics wall), including the build recipe, the bundling trap that fails on the first request, DFlash2/MTP wiring, and the measurement battery that proves the split and the drafter are real."
---
# llama.cpp layer-split cutover (no-collective multi-GPU)
Use when one model must serve from TWO+ cards and the box cannot dispatch a
cross-card collective (second card behind a chipset bridge with
`AtomicOpsCap: Routing-`, so ROCr refuses RCCL's hostcall-buffer kernels).
Decide with one line before designing anything:
```bash
sudo lspci -vv -s <card2> | grep AtomicOpsCtl # ReqEn- ⇒ no collectives, ever
```
`ReqEn-` kills vLLM entirely — tensor parallelism needs an allreduce **and
pipeline parallelism needs an RCCL `send`/`recv` of the hidden state**
(`vllm/distributed/parallel_state.py`: metadata on gloo, tensors on the device
backend). llama.cpp's `--split-mode layer` needs no collective: layers are
distributed in order, only the boundary activation crosses PCIe once per
forward pass.
## 1. Pin the runtime by FEATURE, not by "latest"
Write down which features the deployment needs, then find the ref that has all
of them and pin the 40-hex sha in the build script. On 2026-09-16 that was
DFlash2 (`ggml-org/llama.cpp#27342`, detected from the draft GGUF:
`selector_top_k > 0`, spec type stays `draft-dflash`) and qwen35 MTP. The
ROCmFPX/r9700-toolbox images/fork tips predate both — verify by listing the
built image:
```bash
ls <venv>/lib/python*/site-packages/vllm/v1/worker/gpu/spec_decode/ # dflash/ vs dflash2/
```
## 2. Build self-contained for one arch
- Private ROCm toolchain (TheRock nightly) with `ROCM_PATH`/`HIP_PATH` exported,
`-DCMAKE_HIP_ARCHITECTURES=<gfx only>` (a sibling arch links, loads and then
segfaults), `-DGGML_HIP_RCCL=OFF` when there is no atomics path, `-DGGML_NATIVE=OFF`.
- Bundle the runtime into `build/lib` and set `--set-rpath '$ORIGIN/../lib'`.
- **TRAP — `cp -r SRC DST/` flattens when `DST` does not exist.** rocBLAS and
hipBLASLt look for device code at a FIXED depth (`../lib/rocblas/library`,
`../lib/hipblaslt/library/<arch>`). Flattened, the engine loads every GiB of
weights, answers `/health`, and dies on the **first request** with
`Cannot read .../TensileLibrary.dat: No such file or directory for GPU arch`.
`mkdir -p DST && cp -r SRC/. DST/`.
## 3. Deploy as an env + serve script + unit trio
One env file holds every tunable with the sizing arithmetic in comments; the
serve script defaults each var, refuses to start when the runtime or payload is
missing, and builds an args array (spec flags assembled conditionally so the
same script serves `draft-dflash` with a file and `draft-mtp` in-model). Root
systemd unit: `User=<deploy user>`, `LimitMEMLOCK=infinity`,
`Restart=on-failure`, `WantedBy=multi-user.target`, plus an
`Environment=…_ENV_FILE=<repo path>` so the repo file is the single source.
Deploy handler must: refuse when runtime/payload is missing; retire the previous
runtime's units (into trash, never `rm`); **wait for the port to be released**
(a retired reverse proxy or container holds it); then enable/restart and gate on
`/health`.
DRLaf: `--split-mode layer --device ROCm0,ROCm1 --tensor-split 1,1`,
`--parallel 1`, f16 KV, `--fit off`. Pin the drafter with `--spec-draft-device`
to the card holding the target's last layers. `--spec-draft-n-max` must be
<= the draft's `block_size - 1` (DFlash2: 8 ⇒ 7).
## 4. Prove it, do not assert it
- **The split is real**: raise `--ctx-size` by N tokens and watch `rocm-smi` —
a layer split adds ~N·(bytes/token)/2 to EACH card (both cards carry the KV
for their own attention layers), and neither card's TOTAL can equal the model
file, which is the arithmetic that rules out replication.
- **The drafter is live**: `timings.draft_n`/`draft_n_accepted` per request, and
the cumulative `llamacpp:spec_decode_num_drafts_total` — the flags can be
right while the draft model silently failed to load. The loader's init line
(`block_size=`, `n_extract=`, `mask_token_id=`) identifies the drafter family.
- **Watch for a 500 on `reasoning_effort`**: Qwen3.8-family chat templates
accept only low/medium/xhigh and RAISE on `high`; map every harness level or
subagents 500 on their first request.
- **Re-point the monitors in the same change**: a runtime swap changes the
metric namespace (`vllm:` → `llamacpp:`), and rules/dashboard panels naming
series that no longer exist draw nothing forever without erroring. Retire
what cannot be expressed (llama.cpp exports no KV-fill gauge and no latency
histogram) and name the consequence instead (queue depth, spec-decode-off).
- One slot is a concurrency policy, not a bug: `--parallel` DIVIDES the context.
@@ -0,0 +1,27 @@
---
name: llamacpp-serve-unit-boot-loop
description: "Diagnose a Dank-fleet LLM systemd unit that boot-loops: classify 203/EXEC vs model-load OOM vs payload gate, using journal signatures and nvidia-smi/rocm-smi."
---
# llama.cpp / fleet serve unit boot loop
A fleet LLM unit (`frank-large`, `frank-secondary`, `frank-primary`) restarts every `RestartSec`. Read the journal FIRST, act second:
```
ssh <host> 'sudo systemctl status <svc> --no-pager | head -12; sudo journalctl -u <svc> --since "-6 min" -o cat --no-pager | tail -40'
```
## Classify by signature
| Signature | Cause | Fix |
|---|---|---|
| `status=203/EXEC` + `Failed at step EXEC spawning` + `Mem peak: ~1.9M` + GPU flat at 1 MiB | the `ExecStart` script never ran: missing shebang, lost exec bit, or path moved by the deploy (git pull mid-restart) | `head -1 <script>` must be `#!/usr/bin/env bash`; `git ls-files -s <script>` must show `100755`; commit the fix, redeploy |
| `status=203/EXEC` right after a deploy, then self-heals | git replaced the file mid-checkout while the restart loop ran | ignore if the newest attempts succeed; verify `podman ps --format '{{.CreatedAt}}'` identity |
| Engine prints weights loading, then dies | VRAM/disk budget, engine flags | read the engine's own ledger (verbosity 4) for KV/compute-buffer MiB; price from two boots, never a formula |
| Health 503, no error, minutes of silence | normal cold load (big PLE/mmap) | do not kill; the listener binds after the full load |
## Rules
- Judge the newest container/attempt only: `podman ps -a --format '{{.ID}} {{.CreatedAt}}'`, `journalctl --since`; a mid-stop container still serves old logs.
- `systemctl is-active` is `failed` after every intentional stop on foreground-podman units (exit 143) — cosmetic.
- The unit's `Description` is the last-deployed text, not the repo's. A stale Description is not an incident; the next `relink_unit` fixes it — never restart a serving endpoint for cosmetics.
- Fixing the script = commit + `./scripts/deploy.sh <host> --service <svc>`; never edit on the host (a direct `scp` leaves drift that blocks the next deploy's ff-only pull with "local changes would be overwritten"; repair with `git checkout -- <file>`).
@@ -1,77 +0,0 @@
---
name: llamacpp-vram-accounting
description: "Produce and interpret a per-device VRAM/memory breakdown for a llama.cpp or halogen LLM deployment on the Dank fleet: we/weights vs KV vs compute pool, discrete vs UMA accounting, and the measurement traps that make the numbers lie."
---
# llama.cpp / halogen VRAM accounting (Dank fleet)
Use when asked *where the memory went* for an LLM service on Dankclaw/Dankserver: "what's the VRAM breakdown", "why does it not fit", "how much headroom is left for the co-tenants", or before sizing a card for a new model.
## 0. Decide which kind of memory you are talking about
| device | kind | read it with |
|---|---|---|
| RTX 3090 (Dankclaw) | discrete GDDR6X | `nvidia-smi --query-gpu=memory.total,memory.used,memory.free --format=csv,noheader` |
| gfx1151 iGPU (Dankclaw) | **UMA** — "VRAM" is host RAM | `/sys/class/drm/card1/device/mem_info_gtt_{total,used}` (real, 114 GiB) vs `mem_info_vram_{total,used}` (2 GiB carve-out window) |
| R9700s (Dankserver) | discrete | rocm-smi figure is authoritative (64 GiB claimed, SVM-backed) |
Per-process truth on NVIDIA: `nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv,noheader`.
**Never** add the iGPU's GTT to host RAM as if they were separate pools, and never quote a UMA engine's total as "VRAM": halogens's 95.4 GiB is 68 GiB of **pinned host pages** (mlock + page cache) plus ~33.7 GiB inside the device budget. `free -g` is the arbiter for what the co-tenants have left.
## 1. Get the engine's own ledger first
```bash
podman logs frank-large 2>&1 | grep -aiE "weights pinned|kv pool|left for everything|overlay|vision|budget"
```
Halogen prints the numbers that matter: weights pinned (and how many contiguous 2 MiB blocks remain), the device budget (`host RAM − resident weights − reserve`), the KV pool size and pool-vs-working split, and the final "left for everything else" block count. Contiguous-block count is the honest fragmentation signal — 240 blocks / 12.3 GiB after a reboot vs 475 / 14.7 GiB before means the same model did not come back into the same hole.
## 2. For llama.cpp: the print sites exist, but no artifact here has them
- `src/llama-model.cpp:1827` — per-backend `model buffer size` (unconditional `LLAMA_LOG_INFO`)
- `src/llama-context.cpp:692` — `compute buffer size`
- plus a KV self-size line at context creation
Traps verified on 2026-09-16:
- **`llama-bench` suppresses the loader's INFO** — its logs contain only the device banner and the results table, so a bench matrix leaves *no* memory accounting behind.
- **`llama-server` under systemd did not put them in the journal either** — `sudo journalctl -u frank-small | grep -c "model buffer size"` returned **0** for the unit's entire history. Check with `grep -c` before claiming a log has the breakdown.
- Recipe that works: launch the model **once** through `llama-cli` (or `llama-server`) and keep the first ~200 lines; add `-v` if the level filters them.
## 3. Otherwise compose it — arithmetic on measured tensor sizes
Read the GGUF's tensor table once (names + shapes are enough) and group by role, e.g. Flash-Next (111 GiB):
| group | size | per layer |
|---|---|---|
| `ffn_{gate,up,down}_exps` | 75.0 GiB | **1.5625 GiB** (experts travel with their layer) |
| `per_layer_token_embd` (PLE) | 32.8 GiB | one table |
| everything else (attn/GDN/etc) | 3.2 GiB | 0.067 GiB |
And KV ≈ 2 KiB per position per f16 attention layer (12 attention layers on Flash-Next => ~24 KiB/position; halogen's ledger says ~28 KiB/position including block scratch). Multiply by the *derived* context (see §5).
Then take the measured per-device total as the constraint and let the remainder be compute/graph scratch. Vendor delta is real and sizeable: same 7 layers + KV on the 3090 came to **12.80 GiB under Vulkan** and **14.99 GiB under CUDA** — ~3.3 GiB vs ~1.1 GiB of graph/compute pool.
## 4. Interpretation traps
- **mmap PLE is reclaimable; `-ot` makes it not.** With everything on the iGPU the 32.8 GiB PLE sits in page cache (reclaimable). In a layer split, where `-ot` is disallowed, it becomes a pinned device buffer in GTT. "Move layers to the card" therefore converts reclaimable cache into pinned device memory and does not free room for co-tenants.
- **A served model's footprint is not its weight size.** frank-small: 22,582–22,592 MiB of 24,576 for an 11.29 GiB model — weights + f16 KV at 131,072 × 1 slot + BF16 projector + CUDA compute pool.
- **"Usable" is total minus driver/container overhead** (~24.1 GiB on a 24,576 MiB card).
- Do the capacity subtraction *before* designing: 12 attention layers × 1.62 GiB = 19.4 GiB, plus 200k f16 KV (9.2 GiB) = 28.6 GiB > 24.1 — which is why a KV-on-VRAM design on this pair is only feasible to ~64k context.
## 5. Context lengths: derive, never remember
`llama-bench` never logs the context; it computes (`tools/llama-bench/llama-bench.cpp:1293`):
```c
cparams.n_ctx = n_prompt + n_gen + n_depth;
```
Defaults: `n_prompt 512`, `n_gen 128`, `n_depth 0`, `n_batch 2048`, `n_ubatch 512`, `type_k`/`type_v` **F16**, `flash_attn AUTO`, `split_mode LAYER`, `reps 5`.
So `-p 512,16384 -n 64` = contexts **576 / 16,448 / 64**, and `-n 32 -d 65536` = **66,080 / 65,568**. A `tg` row runs with `n_prompt = 0`, which is why a plain `tg` number is ~zero depth: `-d` is the only thing that puts KV in the picture. Cross-check by reading the table's own test column (`pp512 @ d65536`, `tg32 @ d65536`).
## 6. Reporting shape that survives review
Label every number: **measured** (nvidia-smi / sysfs / engine ledger line), **derived** (arithmetic on tensor sizes + derived context), or **inference** (e.g. depth extrapolation). State the context each memory figure was taken at, and whether KV was resident — 12.8 GiB at 65,568 tokens is not the same claim as 12.8 GiB at 200k.
@@ -0,0 +1,27 @@
---
name: llm-bench-noise-discipline
description: "Noise-floor discipline for single-run LLM endpoint A/B benchmarks: the ±3% law, replication-before-keeping, and bracket-confirmation of tuned values."
---
# LLM Bench Noise Discipline
Single-run A/B benchmarks against an LLM endpoint's own timings API have a much larger run-to-run noise floor than the metric's instantaneous precision suggests. On frank-primary (radiance, 256-token greedy decode at 43.6k-token prompt depth), three runs of the IDENTICAL config measured 33.06 / 32.03 / 30.90 t/s — a ~3% spread.
## The ±3% law
- Treat any single-run delta under ~3% as noise, regardless of the harness's reported confidence multiplier.
- Before believing a "keep", run the same config a second time (no edit, pure replication). If the replication drops below the noise band, flag the lucky draw as suspect (`flag_runs`) so best-metric math stops trusting it.
- Noise sources: TP all-reduce wire loss (wht6), page-cache warm/cold of the 51 GiB n-gram table, container boot ordering, expert-pool hit-rate warm-up.
## Bracket before trusting an optimum
A "tuned" value is only confirmed when BOTH neighboring steps are measured: one side may crash (boot wall) and the other may regress. Example at seqs=3: EXPERT_VS_CACHE 0.78 crash / 0.82 optimum / 0.84 = -9% (0.87 collapses to 0% per runbook); MNBT 4096 crash at seqs=3 while the seqs=2 A/B showed it bootable.
## Boot failures are data, not waste
A scratch arm that never reaches /health (720s timeout) maps the viable envelope. Three crashes at seqs=3 (0.78 weights-share, 4096-token chunks, bf16 wire) prove the production trio wht6/2048/0.82 is exactly what makes the 3-slot shape fit.
## Outside-noise signals found at this endpoint
- SPEC_TOKENS=4: -23% decode, draft acceptance 0.831->0.497 (the 4th draft token is rejected too often to pay its verification).
- SPEC_TOKENS=0: -6.8%; MTP drafting always pays.
- KV shape seqs=4/maxlen=100k (same 400k total): -11%; slot overhead is not paid by freed VRAM.
## Harness pattern that worked
stop prod -> boot scratch container on the SAME two GPUs with candidate argv -> poll /health -> ONE self-contained greedy request built from a fixed-seed repo corpus (deterministic prompt_n) -> parse timings -> tear down -> restore prod via trap (never mask a relinked unit; stop only). Fresh prefix-cache dir per run so the cold prefill is cold.
@@ -1,41 +0,0 @@
---
name: llm-fleet-model-swap
description: "Swap the model/runtime behind frank-default-large/small on Dankclaw/Dankserver via the git flow, with pre-deploy live validation and the port-holder health-check trap check."
---
# LLM fleet model swap (frank-default-large / frank-default-small)
Use when switching model, quant, or llama.cpp/FreeToken runtime for a `frank-default-*` service in `~/Projects/serverAppConfigs`.
## 0. Ground truth first
- Read the HF model card (runtime pin, hard constraints, recommended args) + repo files `scripts/frank-default-<size>.env`, `scripts/serve-frank-<size>.sh`, `scripts/frank-default-<size>.service`, and the runbook under `serverAppConfigs-docs/LLM/`.
- Check the host before downloading anything: `ls /home/dank/models/`, `df -h /home`, `du -sh` — bench-era or prior-deployment files often already exist.
## 1. Artifact integrity
- List repo files: `curl -s https://huggingface.co/api/models/<repo>/tree/main?recursive=true` → `.lfs.oid` holds the sha256.
- On-host file: compare size AND `sha256sum` against the LFS oid before trusting any existing copy (hash runs ~few min for >100 GiB; run it in background).
- New download on Dankserver: `/home/dank/freetoken/.venv/bin/hf download <repo> --local-dir ...`.
## 2. Runtime (if the card pins a fork/build)
- Clone + build with the card's exact cmake line, **plus `-DLLAMA_BUILD_WEBUI=OFF`** — WebUI asset provisioning (npm / HF llama-ui bucket) aborts llama.cpp link at 98% on these hosts; API server unaffected.
- Verify every flag our serve script passes exists in the new binary before touching the service:
`llama-server --help | grep -q -- "<flag>"` for each; list MISSes.
## 3. Config via git only
- Edit `scripts/frank-default-<size>.env` (LLAMA_BIN, MODEL_DIR, MODEL_FILE, MMPROJ_PATH) + serve script header/defaults; `bash -n` the script; `./tests/run.sh` must pass; commit; push; `git pull --ff-only` on the target.
- Do NOT edit configs on the remote. Renaming a model dir (`mv`) is host state, allowed.
## 4. Live validation (no scratch ports — GPU/RAM can't host two big models)
- Ensure the old unit is truly gone: `systemctl is-active` may hang in `stop-sigterm` because llama.cpp never preempts an in-flight generation; poll until the PID exits before launching.
- Stop service, run the real serve script manually (`setsid nohup bash scripts/serve-frank-<size>.sh > /tmp/x.log 2>&1 &` — plain `ssh 'nohup ... &'` dies with its ssh when foregrounded; keep ssh under timeout or it gets killed mid-stop).
- Verify: health 200; a real `/v1/chat/completions` (FreeToken REQUIRES explicit `"model"` field; llama.cpp servers don't care); reasoning flows (`reasoning_content` non-empty; content may be empty with finish=length if the thinking eats the budget).
## 5. systemd handoff + THE TRAP
- `./scripts/deploy.sh <host> --service <svc>` — but a leftover manual server holding :7770 makes the health check pass while the UNIT crash-loops on bind.
- Always confirm ownership: `systemctl show <svc> -p MainPID` equals the single `pgrep -x llama-server` (or the ft serve PID); only then trust "healthy after Ns".
- If a real long-context task is queued/running, do the handoff anyway (client aborts are recoverable; drift is not) — or defer cosmetic-only redeploys.
## 6. Close-out (mandatory)
- Docs: active runbook updated (build recipe, profile, validation numbers incl. real-workload prefill/decode), superseded runbook gets RETIRED banner kept as rollback record, `LLM/index.md` mermaid + active list, AGENTS.md fleet table + Runtime section + history chain, work log entry in `serverAppConfigs-docs/log.md`.
- `~/.omp/agent/models.yml`: contextWindow must equal the REAL per-slot context — llama.cpp logs `n_ctx_slot` per prompt; parallel N clamps `n_ctx_train` (e.g. 262144/2 = 131072/slot). Overselling causes silent client-side overflow.
- Rollback = one env commit (paths + LLAMA_BIN); keep prior binary + payload on disk.
@@ -1,50 +0,0 @@
---
name: llm-model-swap-fit-preflight
description: "Preflight an LLM model swap for hardware fit before deploying; catch the silent swap-thrash outage mode (large RAM OOM, self-restarting + boot-enabled units) and recover."
---
# LLM Model-Swap Fit Preflight
Never deploy a model swap without confirming the checkpoint FITS, and know the
failure mode when it doesn't: it is a silent, self-sustaining thrash, not a crash.
## Preflight (before deploy)
1. Get the checkpoint's on-disk/weight size AND precision from the HF card
(`Precision: BF16` = 2 bytes/param; FP8/Q8 ≈ 1 byte; Q4 ≈ 0.5 byte).
35B params: BF16 ≈ 66–70 GB, FP8/Q8 ≈ 33–35 GB, Q4 ≈ 17–20 GB.
2. Compare against the HOST'S USABLE RAM (not just VRAM) for runtimes that
stream weights from RAM (FreeToken host-RAM experts, llama.cpp CPU offload,
PP/worker splits).
3. Budget RAM for weights + KV pool + OS + page cache. If weights alone exceed
RAM, ABORT — pool tuning shrinks KV, it does not shrink weights.
## The self-sustaining thrash (why 'it'll just OOM and recover' is wrong)
- Large swap (tens of GB) → kernel PAGES instead of OOM-killing → sshd/user
space starved indefinitely; no recovery window opens by itself.
- systemd `Restart=on-failure|always` → crash-reload-rethrash loop.
- Unit `enabled` + `WantedBy=multi-user.target` → a reboot re-thrashes on boot
until the bad config is masked/reverted. A plain power-cycle does NOT fix it.
## Recovery (needs a shell; may need console / SysRq-to-GRUB)
```
sudo systemctl mask --now <svc> # stop + break the reboot trap FIRST
cd <checkout> && git fetch origin && git reset --hard origin/main # pick up revert
grep MODEL_DIR <env> # confirm fixed target
sudo systemctl unmask <svc> && sudo systemctl restart <svc>
curl -s <health> # then verify with a REAL completion
```
## Deploy-script caveat
`deploy.sh`/health-gate 'healthy after Ns' = systemd ACTIVE, not model LOADED.
Always confirm load with a real completion (curl /v1/chat/completions), not
status alone.
## GPU quant note
RTX 3090 (Ampere) has no FP8/FP4 tensor cores: FP8 weights run via upcast but
still must fit in RAM/VRAM. Choose quant by SIZE fit, then accept the compute
path the card supports.
@@ -1,100 +0,0 @@
---
name: llm-replica-routing-prefix-cache
description: "Route requests across multiple LLM replicas by workload shape (pin agent-loop continuations for prefix-cache hits, spread batches for parallel prefill), with the measurement traps and the capacity wall that no routing policy fixes."
---
# Prefix-cache-aware routing for multi-replica LLM serving
Use when several single-GPU inference replicas sit behind one endpoint and
clients send long shared prefixes (agent loops, chat with a big system prompt,
RAG with a fixed context).
## The two failure modes, both measurable
Measure with a **sequential-turn client**, not only a batch benchmark: a batch
of distinct prompts parallelises prefill across replicas, so round-robin looks
great there and hides the problem.
1. **Round-robin loses every other cache hit.** The loop resends the same
prefix each turn; alternating replicas means half the turns prefill from
scratch. Measured: 90k-token contexts, warm-turn TTFT 2676 ms round-robin
vs 1096 ms pinned.
2. **Pinning everything loses a card.** A cold batch pays one card's prefill
throughput: 16 requests in 18.6 s spread vs 30.1 s pinned.
## The rule that gets both
Route by request shape:
- **Continuation** (request carries more than one message — the signature of an
agent loop re-sending its context) -> pin to the replica that served that
prefix before.
- **Anything else** (one-shot question, batch member, parallel subagent) ->
spread across replicas with a **counter**, not with a hash of the prefix.
Measured on the same host: 19.97 s burst *and* 1095 ms warm turns.
Also add **failover**: a replica that restarts is invisible to a health table
refreshed every 10 s, so a request routed to it returns 502 even though the
other replica is up. On connection failure, demote it immediately and retry the
request on a live replica before writing anything to the client (the OpenAI
endpoints are stateless, so re-sending a request that never reached an engine
is safe; never retry after streaming started). Measured: six requests during a
replica stop, six 200s, one logged failover, no client-visible error.
## The capacity wall that no policy fixes
Per-card KV is finite and **a running sequence and its cached prefix draw on
the same pool**. With N families on M cards, one card carries two of them, and
its budget must hold both cached prefixes plus whichever sequence is
generating. Three concurrent 50-80k-token families against 122,636 tokens per
card thrash regardless of how requests are placed:
| policy | result |
|---|---|
| counter placement | heavy 25.4 s median (23 % cached), mediums 34.7 / 45.2 s |
| load-balanced placement (new families to the card carrying less context) | no improvement — reverted |
| one large request admitted per card | no improvement — reverted |
| smaller/larger prefill chunk | *costs* KV in both directions (a sweet spot exists; find it) |
So: measure placement and admission before shipping them, and when the working
set exceeds aggregate KV, say so — the levers are capacity (another card),
fewer concurrent large contexts (client-side context compaction), or a different
platform. Routing cannot rescue arithmetic.
## Measurement traps
- **A lifetime engine counter is not a per-request verdict.** "Prefix cache hit
rate: 21 %" is an average over every prompt the engine ever saw, including
benchmark traffic that never repeats. Probe the question directly instead:
send the same long prompt to an idle replica twice — 22.3 s cold then 1.4 s
warm is what a working cache looks like (and it holds with a varying tail).
- **Salt your corpora.** Two runs sharing a prefix measure a warm cache, not
the thing under test; a unique leading line per run makes the prefix new.
- **Record the sample size.** A 14-minute arm and an 8-minute arm are not a
comparison, and medians from five turns say little.
- **Check the tool's own requirements**: `vllm bench serve` needs
`--tokenizer <local path>` (plus the model directory mounted) or it tries to
resolve the served alias on HuggingFace and dies before sending a request —
a run that produces no numbers and no obvious error.
- **Verify metric names against the live endpoint** before writing dashboards
or alerts: guessing one that does not exist is invisible until someone needs
the graph.
## Test shape that catches it
Drive the routing function directly with synthetic request bodies (keep the
policy in a stdlib-only module so tests need no HTTP framework):
- a batch of single-turn requests -> assert both replicas are used and
consecutive picks alternate;
- a growing conversation (same leading bytes, longer history) -> assert every
turn agrees with the first turn's replica;
- each mode with a replica marked down -> assert the live one is chosen, and
-1 when none is;
- the fixed comparison modes (`roundrobin`, `affinity`) kept behind an env var
so the choice can be re-measured rather than argued.
One more: the identity of a conversation is its **first N KiB** of prompt. If
the tested prefix is shorter than that window its varying tail falls inside it,
turns hash differently, and pinning silently never happens.
@@ -1,67 +0,0 @@
---
name: llm-serving-benchmark-methodology
description: "Measure and A/B a self-hosted multi-replica LLM endpoint honestly: corpus construction, per-arm salting, repeated samples, engine-counter semantics, and the traps that make results meaningless."
---
# Bench a multi-replica LLM deployment so the numbers mean something
Use when measuring or tuning a self-hosted LLM endpoint (vLLM, llama.cpp,
radiance/MXFP4), especially behind a proxy in front of several replicas.
## Set up a client that can actually send requests
`vllm bench serve` needs `--tokenizer <local model path>` with the model
directory mounted. Given only `--model <served-alias>` it tries to resolve that
alias on HuggingFace and dies before the first request — a run that looks like
it worked and produced nothing. Confirm with a single prompt before benching.
## Build the workload from real material
`--dataset-name random` generates token soup whose draft acceptance is nothing
like production (measured 2.9-3.6 tok/update against 5.8 on code), so it
mis-ranks speculative-decoding settings. Generate a JSONL corpus from the
repository's own sources — `{"prompt": "<instruction>\n\n<file body>"}` — and
fix output length with `--custom-output-len`. Keep two corpus shapes:
- **distinct prompts** (batch behaviour, prefill parallelism)
- **a shared long prefix with a varying tail** (conversation turns, where
prefix caching decides everything)
## Salt every arm
Put a unique marker in the first bytes of every prompt, different per A/B arm.
Without it the second arm silently measures the first arm's warm cache and both
arms look identical.
## Measure the same workload before and after
Keep the workload fixed and change one knob. Repeat each arm — run-to-run
variance of 5-10 % is normal, and a single sample nearly made me ship the wrong
setting. Report medians per family/class, not one aggregate number.
## Read engine counters correctly
- vLLM's `Prefix cache hit rate:` line is a **lifetime average** across every
prompt that replica ever saw. It cannot answer "is this conversation served
warm?". Send the same prompt twice to an idle engine and compare TTFTs
instead (measured: 22.3 s cold → 1.4 s warm for a 50k-token prefix).
- `GPU KV cache usage` tells you the pool is not full; it does not tell you a
prefix survived. Capacity per card is the boot-time ledger line
(`GPU KV cache size: N tokens`), not the card's VRAM.
- Reserve memory is not allocation: a bigger reserve does not free anything for
anyone else, it only removes cache.
## Route-shape traps
A cold batch of distinct prompts prefills in parallel across replicas; the same
conversation's next turn only pays off if it lands where its prefix is cached.
These pull in opposite directions, so measure both shapes separately and route
by request shape (spread single-turn, pin continuations).
## When the answer is "no"
Write down rejected hypotheses with their numbers and the mechanism. Placement
balance, concurrency admission and chunk-size tweaks all looked obvious and all
measured flat or worse on this fleet; the wall turned out to be arithmetic
(cached prefixes plus live sequences share one pool). A runbook that records
that saves the next operator a night.
@@ -1,96 +0,0 @@
---
name: niri-output-rotation-pinning
description: "Rotate/re-arrange niri monitors (portrait transforms, positions, scale) persistently and verify — covers the output-block-resets-defaults trap and auto-layout reflow that flings unpinned outputs, plus the counter-clockwise transform convention."
---
# Rotating and pinning outputs on niri
Verified on blankpad, niri 26.04, 2026-09-15 (two PM1601 externals + eDP-1 laptop panel).
## 0. Ground truth first
```bash
niri msg outputs # connector names, current mode, scale, transform, logical pos/size
niri --version
```
Niri output **names are connector names** (`eDP-1`, `DP-2`, `HDMI-A-1`), not EDID model strings. The EDID strings printed before them in `niri msg outputs` are decoration — use the parenthesized name.
On blankpad the live config is split by `include "./cfg/*.kdl"` from `~/.config/niri/config.kdl`, and `cfg/display.kdl` is a **symlink into the dotfiles repo** (`~/.omp/dotfiles/zz-blankpad/niri/.config/niri/cfg/display.kdl`). Edit the repo path, not the symlink target path — they are the same file, but the repo path is the one that gets committed. Check with `ls -ld`.
## 1. Transform direction is counter-clockwise
```bash
niri msg output <NAME> transform --help # "Transform to set, counter-clockwise"
```
| value | effect |
|---|---|
| `90` | content rotated 90° CCW ⇒ the panel's **top bezel sits on the LEFT** |
| `270` | top bezel on the RIGHT |
| `180` | upside down |
| `flipped-90` / `flipped-270` | mirrored variants |
`90` vs `270` is a physical-stand fact no tool can read (portable monitors expose no orientation in EDID/MCCS). Pick one, apply it, and give the user the one-liner to flip — don't stall on the question.
## 2. Two traps that bite when you edit the config
1. **An `output "X" { … }` block resets every property you don't restate to its default.** Pinning position on `eDP-1` without repeating `scale 1.25` silently drops the laptop panel to scale 1.0. Restate `scale` (and `mode` if you care) in every block you touch.
2. **Niri re-runs auto-layout for outputs with no pinned position.** Pinning only the externals shoved `eDP-1` from `0,0` to `3936,0` (laptop became rightmost). If you want the pre-existing left-to-right order, pin every output's position.
Positions are logical pixels at each output's own scale. Original order preserved example:
```kdl
output "DP-2" {
scale 1
transform "90"
position x=1536 y=0
}
output "HDMI-A-1" {
scale 1
transform "90"
position x=2736 y=0
}
output "eDP-1" {
scale 1.25 // MUST restate: dashed-out by the block otherwise
position x=0 y=0
}
```
x-offsets chain: `eDP-1` logical width 1536 → DP-2 at 1536 → +1200 (portrait width) → HDMI-A-1 at 2736.
## 3. Apply and verify
```bash
niri validate -c ~/.config/niri/config.kdl # exit 0, "config is valid"
niri msg action load-config-file # live reload; load-config-file, NOT reload-config
sleep 1
niri msg outputs | grep -E '^Output|Transform|Logical|Scale'
```
Success looks like `Logical size: 1200x1920` (swapped) plus `Transform: 90° counter-clockwise` on each rotated panel, with positions matching the intended chain.
One-off rotation without touching config (temporary, forgotten on next config change):
```bash
niri msg output DP-2 transform 90
```
## 4. Commit
Dotfiles repo is `dank/command-center` (`main`); push to the local Gitea is permitted.
```bash
cd ~/.omp && git pull --ff-only
git add dotfiles/zz-blankpad/niri/.config/niri/cfg/display.kdl
git commit -m "blankpad(niri): <what changed>"
git push
```
## Pitfalls
- `reload-config` is not a niri subcommand — it's `niri msg action load-config-file`.
- Never SIGUSR1 a terminal to "reload"; niri config reloads do not touch ghostty themes.
- No man page for niri on this host; `niri <sub> --help` and `/usr/share/doc/niri/default-config.kdl` are the docs.
@@ -1,28 +0,0 @@
---
name: noctalia-config-remote-edit
description: "Edit live Noctalia v5 shell config on a fleet host over SSH: which config layer is live, why noctalia msg fails from ssh context, and the systemd-run --pipe workaround that works."
---
# Noctalia config edit over SSH (v5 native, both fleet hosts)
## Current architecture (since 2026-09-10 unification)
Both the workstation and blankpad run the bare `noctalia` v5 binary as the desktop shell (launch: niri `spawn-at-startup "noctalia"`). The old Quickshell-hosted v4 architecture (`qs -c noctalia-shell`, JSON config) is retired on both hosts. Live config layer = TOML only:
- Hand-edit declarative layer: `~/.config/noctalia/<name>.toml` (e.g. `config.toml`).
- Runtime state file `~/.local/state/noctalia/settings.toml` loads LAST and overrides declarative values for the same keys — never hand-edit it; UI/IPC writes land there.
## Why msg fails remotely, and the workaround that works
`noctalia msg <command>` resolves the running instance via the D-Bus session bus. sshd-spawned non-login shells lack `DBUS_SESSION_BUS_ADDRESS`, so plain ssh exec prints `error: noctalia is not running` even when the shell IS running. Do not conclude "shell dead" from this error.
Working remote procedure (runs in systemd user-manager context where the bus address exists):
```
systemd-run --user --pipe --wait noctalia msg <command> [args]
```
Verified: `msg status` returns live JSON state; unknown panel ids return an error listing all valid panel ids (clipboard, control-center, launcher, polkit, session, setup-wizard, test, tray-drawer, wallpaper).
## Verified v5 msg verb grammar (keybinds-safe)
`panel-toggle <id>`, `session lock|suspend|lock-and-suspend|logout|reboot|shutdown`, `volume-up [step]`, `volume-down`, `volume-mute`, `mic-mute`, `media next|previous|playPause` (graceful no-op with rc 0 when no active player), `brightness-up [target] [step]`, `brightness-down`, `settings-open [context]`, `settings-toggle [context]`, `config-reload`, `wallpaper-get [connector]`, `wallpaper-set <path>`, `wallpaper-next`.
## Wallpaper directory semantics
Empty/unset `[wallpaper] directory` resolves to XDG Pictures (`$HOME/Pictures`) via user-dirs.dirs; an explicit directory key overrides. Fleet default pattern: point `directory` at the repo-tracked wallpapers dir so git-tracked wallpapers serve the shell without copying files. After writing config.toml over ssh, the file watcher hot-reloads automatically; `config-reload` via systemd-run --pipe forces it immediately. Note: the *current* wallpaper stays the built-in default asset until the next rotation or an explicit `wallpaper-next`; `wallpaper-get` then reports a path inside the configured directory = confirmation the config took effect.
## Lock chain (unified)
swayidle-lock.service ExecStart on both hosts: `/usr/bin/swayidle -w timeout 300 '/usr/bin/noctalia msg session lock' before-sleep '...' after-resume '...'`. `msg` resolves via session bus, so no WAYLAND_DISPLAY pinning is needed in unit context (niri's own /proc/PID/environ lacks WAYLAND_DISPLAY but spawn-at-startup children still receive a working display connection empirically — verified by qs-hosted autostart working from boot pre-unification).
@@ -1,67 +0,0 @@
---
name: noctalia-custom-palette-recolor
description: "Recolor a live Noctalia desktop (accent, terminal palette, terminal foreground) by cutting over to a custom palette instead of editing the community-palette cache, then re-rendering and reloading templates."
---
# Noctalia custom-palette recolor
Recolor the running Noctalia shell + all themed apps without restarting the session.
## Why custom, never the community cache
- `~/.local/state/noctalia/community-palettes/*.json` is a **sync cache**. `~/.local/state/noctalia/community-palettes/.catalog/palettes.json` carries an `md5` per palette and noctalia re-fetches from `https://api.noctalia.dev/palettes`. Edits there get reverted.
- Custom palettes live at `~/.config/noctalia/palettes/<Name>.json` (`NOCTALIA_CONFIG_HOME` then `XDG_CONFIG_HOME` honored) and are never synced. Missing/invalid file → silent fallback to the builtin palette.
- Palette JSON shape = the community file's shape: `dark`/`light` maps of `mPrimary`, `mOnPrimary`, `mSecondary`, `mTertiary`, `mError`, `mSurface`, `mSurfaceVariant`, `mOutline`, `mShadow`, `mHover` plus a `terminal` block (`foreground`, `background`, `normal.{black..white}`, `bright.{...}`, `cursor`, `cursorText`, `selectionFg`, `selectionBg`). NOTE: `noctalia theme -o` emits M3 snake_case tokens (`on_primary_container`, …) — that is the *generator* format, not this one. Copy an existing community JSON and mutate it with `jq`.
## Procedure
1. Read the live scheme first — never assume: `noctalia msg color-scheme-get` → e.g. `community Gruber Darker`; `noctalia msg theme-mode-get`.
2. Back up settings: `cp -a ~/.local/state/noctalia/settings.toml /tmp/noctalia-settings-backup-$(date +%s).toml`.
3. Build the palette from the current one (jq keeps structure; no hand-editing 2 KB of JSON):
```sh
mkdir -p ~/.config/noctalia/palettes
jq '.dark.mPrimary = "#f43841"
| .dark.terminal.normal.yellow = "#f43841"
| .dark.terminal.bright.yellow = "#ff4f58"
| .dark.terminal.foreground = "#f43841"' \
"$HOME/.local/state/noctalia/community-palettes/Gruber%20Darker.json" \
> ~/.config/noctalia/palettes/"Gruber Red.json"
```
Prefer a hex already present in the palette (its own `mError` red / accent) so the theme stays internally consistent. Set BOTH `normal` and `bright` variants, and both `dark` and `light` if the user toggles modes.
4. Switch (persists `theme.source = custom` + `theme.custom_palette`):
`noctalia msg color-scheme-set custom "Gruber Red"` → `ok`; confirm with `noctalia msg color-scheme-get`.
5. Re-render every configured template: `noctalia msg templates-apply`. Regenerates `~/.config/ghostty/themes/noctalia`, `~/.config/gtk-{3,4}.0/noctalia.css`, kcolorscheme, foot, wezterm, starship, etc. Some of those paths are symlinked into the dotfiles repo → they show up as modified files worth committing.
6. Reload surfaces:
- Compositor (focus border, `noctalia.kdl`): `niri msg action load-config-file`. `reload-config` is NOT a valid niri subcommand.
- Ghostty: see the reload trap below.
## Verification
- `ghostty +show-config | grep -E '^(foreground|palette)'` — proves the theme file resolves into the config (e.g. `foreground = #f43841`, `palette 3/11` red).
- `noctalia config validate` → `✓ Config is valid`.
- Visual: `noctalia msg screenshot-fullscreen` saves to `~/Pictures/screenshot_<ts>.png` (it ALSO overwrites the clipboard with the PNG — warn the user). `read` that PNG to confirm accent/toggle/border color.
- `grep -c <old-hex> ~/.config/noctalia/palettes/*.json` → 0 for no leftovers.
## Track it in the dotfiles repo (command-center `~/.omp`)
`scripts/deploy.sh` is a stow-equivalent: packages are home-relative trees under
`dotfiles/<pkg>/`, and deploying replaces live files with symlinks into the repo. Mirror that
shape for the new palette so `git pull` + `deploy.sh` is the whole propagation story:
```sh
mkdir -p ~/.omp/dotfiles/noctalia/.config/noctalia/palettes
mv ~/.config/noctalia/palettes/"<Name>.json" ~/.omp/dotfiles/noctalia/.config/noctalia/palettes/
ln -s "$HOME/.omp/dotfiles/noctalia/.config/noctalia/palettes/<Name>.json" \
~/.config/noctalia/palettes/"<Name>.json"
noctalia msg color-scheme-set custom "<Name>" # re-reads through the symlink; expect ok
```
Do the link by hand for one file instead of running `deploy.sh` — sync mode quarantines and
replaces every divergent live file, which is broader than a single-palette change.
## Traps
- **NEVER `kill -USR1` ghostty** to reload. It does not reload config on this fleet — it kills/respawns the terminal (took out Frank's window 2026-09-13; he said "stop killing the terminal"). Correct: the default keybind `ctrl+shift+,` = `reload_config`, per `ghostty +show-config --default`. Existing windows keep old colors until reloaded; new windows inherit immediately.
- Palette hex values are NOT a proxy for what the user sees: in `Gruber Darker` the *accent* (`mPrimary`) is yellow `#ffdd33`, so "change the yellow" means the accent too, not just terminal index 3.
- Terminal text turned into a saturated color makes ANSI 1/9 indistinguishable from default text (grep highlights flatten) — flag it and offer a lighter red.
- Contrast sanity: `#f43841` on `#181818` ≈ 4.6:1 (dark-on-color beats white-on-color here, ~3.9:1, so keep `mOnPrimary` dark).
- Revert = `noctalia msg color-scheme-set community "<original name>"`; the community cache is untouched by this whole flow.
- `ghostty +show-config` prints only the delta from Ghostty defaults — a key whose value equals
the default (e.g. `font-size = 12`) simply does not appear. Cross-check with
`ghostty +show-config --default` before concluding a key is unset.
- Generated theme files that are symlinked into the dotfiles repo (`gtk-3.0`/`gtk-4.0`
`noctalia.css`, ghostty theme) show up as tracked modifications after `templates-apply`;
commit them together with the new palette JSON so every machine gets the same recolor.
@@ -1,32 +0,0 @@
---
name: noctalia-reference
description: "Quick reference for Noctalia desktop environment: config paths, IPC commands, app templates, palette, and migration checklist for Niri-based setups."
---
# Noctalia Desktop Environment
Noctalia is a Niri-based desktop environment at `/usr/bin/noctalia`.
## Config
- Main: `~/.config/noctalia/config.toml`
- Niri config: includes `noctalia.kdl` for display settings
## IPC
Direct `noctalia msg <command>` replaces `qs -c noctalia-shell ipc call` wrapper.
Key commands: `panel-toggle`, `session`, `lock`, `launcher`.
## App Templates
`/usr/share/noctalia/assets/templates/` contains: gtk, ghostty, alacritty, hyprland, sway, wezterm, helix, btop, starship, qt, kde, cava, scroll, mango, umbriel, foot, emacs, kitty.
## Palette
Generate from image: `noctalia theme [image]`
## Migration Checklist
- [ ] Niri keybinds → `noctalia msg` commands
- [ ] Alacritty → import noctalia.toml theme
- [ ] Ghostty → `theme = noctalia`
- [ ] GTK 3/4 → update accent palette (#b267e4 purple)
## Text Antialiasing
- **Ghostty**: `font-subpixel-render = "off"` to disable subpixel AA
- **Alacritty**: `font.antialias = false` in `alacritty.toml`
@@ -1,45 +0,0 @@
---
name: noctalia-template-offline-render
description: "Recover from missing noctalia-generated theme files (niri noctalia.kdl include, ghostty/alacritty noctalia theme) using the offline noctalia theme --render CLI when msg templates-apply is a no-op"
---
# Noctalia template offline render
## Failure signature
- niri: `failed to read included config from ~/.config/niri/noctalia.kdl: No such file or directory`; desktop runs but with DEFAULT config (niri 26.04 falls back, does not exit): no custom keybinds/rules, no noctalia autostart, default output placement.
- ghostty: `theme "noctalia" not found, tried ~/.config/ghostty/themes/noctalia`.
- alacritty: import of `~/.config/alacritty/themes/noctalia.toml` missing.
## Root cause pattern
Upstream dotfiles commits theme switches (niri include, ghostty `theme = noctalia`, alacritty import) that depend on noctalia TEMPLATE-GENERATED files. Those files are per-host outputs (rendered from the local palette) and are intentionally NOT in the repo. On any host where `templates-apply` never ran, they don't exist.
## Fix (offline, works while a session runs)
Built-in templates live at /usr/share/noctalia/assets/templates/ and are declared in builtin.toml. Render each needed one with the live palette:
```bash
# niri include (plain host-local file; do NOT symlink into repo — regenerated per host)
noctalia theme --theme-json ~/.config/noctalia/colors.json \
--render "/usr/share/noctalia/assets/templates/niri/niri.kdl:$HOME/.config/niri/noctalia.kdl"
# ghostty
mkdir -p ~/.config/ghostty/themes && \
noctalia theme --theme-json ~/.config/noctalia/colors.json \
--render "/usr/share/noctalia/assets/templates/ghostty/ghostty:$HOME/.config/ghostty/themes/noctalia"
# alacritty
mkdir -p ~/.config/alacritty/themes && \
noctalia theme --theme-json ~/.config/noctalia/colors.json \
--render "/usr/share/noctalia/assets/templates/alacritty/alacritty.toml:$HOME/.config/alacritty/themes/noctalia.toml"
```
All template tokens (primary, surface, error, primary_container, …) resolve from the flat 16-key colors.json; container tokens are derived, never blank.
## Verify
1. `niri validate` → "config is valid" (rc 0)
2. `ghostty +validate-config` → rc 0
3. On next login: journal shows `loaded config from config.kdl` with NO parse WARN, custom output placement (compare `niri msg outputs` vs display.kdl), noctalia autostarted (`pgrep noctalia`).
## Traps
- `noctalia msg templates-apply` on 5.0.1 returns "ok" but applies NOTHING (opt-in config schema this build does not act on). Do not trust its exit code.
- Niri config errors at boot are NOT fatal — the compositor keeps running on defaults, which looks like "some settings silently stopped working", not a crash.
- Regenerate after any palette change; the rendered files are static snapshots.
@@ -0,0 +1,24 @@
---
name: omp-harness-window-fit-audit
description: "Audit an omp harness model entry against an engine's prompt+max_tokens fit check; explains and fixes 400 \"max_tokens does not fit\" at 60-85 % of the declared window."
---
Use when a local endpoint returns 400 `max_tokens N does not fit: prompt is P tokens and the context is C, leaving room for R` on omp, typically at 65-85 % of the declared window.
## The three numbers that must agree
1. Engine hard ceiling: prompt_tokens + max_tokens ≤ window (halogen `HALOGEN_CTX`/slot, llama.cpp per-slot `ctx_size/parallel`, radiance `--max-model-len`). A 400, never truncation.
2. Harness clamp: `max_tokens = min(maxTokens, contextWindow − est − 4096)`, est = chars/4 over the conversation+tools. The dump in `~/.omp/logs/http-400-requests/*.json` proves it: est = contextWindow − 4096 − max_completion_tokens.
3. Compaction fires only at `compaction.thresholdPercent` × contextWindow (config.yml; ignore stale reserveTokens/70 % comments).
## The trap
chars/4 under-counts the rendered chat template by ~3 % on code/agent traffic. If contextWindow == engine ceiling, requests with est > 4096/0.03 ≈ 137k are guaranteed 400s below the compaction threshold (65-85 % of a 200k window). maxTokens ≤ 16,384 hides this; 45,056 exposes it.
## Fix procedure
1. Compute required slack: maxTokens + 0.10 × contextWindow (robust to est error).
2. Raise the engine ceiling to ≥ contextWindow + slack (native window is the natural choice: Qwen3.8-Flash-Next 262,144). Halogen: pool must stay slots × CTX (`HALOGEN_KV_POOL_POSITIONS`, ~28 KiB/position); check memory ledger.
3. Keep harness contextWindow below the ceiling by ≥ slack (217,088 = 212×1024 for a 262,144 ceiling with maxTokens 45,056).
4. Verify compaction itself fits its target (remoteEndpoint slot ≥ thresholdPercent × contextWindow − keepRecentTokens(20k) + overhead).
5. Update env + seam test + models.yml + AGENTS.md together; a single-file change re-creates the drift.
## Verification
Probe a prompt at ≥ 180k tokens with max_tokens = the clamped value; expect completion, not 400.
@@ -1,60 +0,0 @@
---
name: omp-per-model-sampler-config
description: "Set per-model sampler params and preserve_thinking in OMP's models.yml via compat.extraBody, and verify the exact wire body with an echo-capture profile."
---
# OMP Per-Model Sampler Config (`compat.extraBody`)
`~/.omp/agent/models.yml` has **no per-model sampler keys**. Sampling is global (`config.yml`: `temperature`, `topP`, `topK`, `minP`, `presencePenalty`, `repetitionPenalty`; default `-1` = **not sent**). The per-model hook is `compat.extraBody`.
## Why it works
`applyOpenAIExtraBody` runs **last** in the openai-completions builder — after `applyChatCompletionsCompatPolicy` — via `Object.assign(params, extraBody)`. So extraBody overrides global settings and compat-policy output.
## Recipe
Use **wire names**, not OMP camelCase:
```yaml
- id: frank-large
compat:
reasoningEffortMap:
max: xhigh
extraBody:
temperature: 1.0
top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
repetition_penalty: 1.0
preserve_thinking: true
```
## Two traps
1. **`preserve_thinking` is not user-settable.** `qwenPreserveThinking` is auto-derived in `buildOpenAICompat` from the *auto-detected* `thinkingFormat` + local baseUrl, **before** `applyCompatOverrides`. Setting `compat.thinkingFormat: qwen` in models.yml does **not** enable it. Custom-named models (`frank-large`) match no Qwen id pattern → use `extraBody: {preserve_thinking: true}`.
2. **Never put `chat_template_kwargs` in `extraBody`.** `Object.assign` is shallow — it replaces the whole object and silently drops the `reasoning_effort` kwarg the compat policy set. Top-level `preserve_thinking` is honoured by halogen; use that.
## Gotcha: silent greedy decode
halogen-flash-server has **no server-side sampler defaults** — `temperature` absent or `0` = greedy. If OMP sends nothing, the endpoint decodes greedy while looking configured.
## Verify the exact wire body (echo-capture)
Don't trust the YAML — capture what OMP actually emits.
1. Echo server on `127.0.0.1:18099` that dumps the JSON body to a file and returns a canned `chat.completion` (include `reasoning_content` so thinking paths exercise).
2. Copy the **real** models.yml into a throwaway profile with only the baseUrl swapped:
```bash
sed 's|http://<host>:7770/v1|http://127.0.0.1:18099/v1|' \
~/.omp/agent/models.yml > ~/.omp/profiles/wiretest/agent/models.yml
```
3. Run and inspect:
```bash
omp --profile wiretest --model "<provider>/<model>" -p "hi" \
--no-tools --no-session --no-extensions --no-skills --no-rules
jq 'del(.messages, .tools)' last-body.json
```
Confirm every intended field is present with the right value.
## Verify a field is *honoured*, not just accepted
Unknown fields often return 200 anyway. Prove behavior with a **prompt-token delta**: replay an assistant turn carrying a long `reasoning_content`, then compare `usage.prompt_tokens` with the flag true vs false. A large delta means the field changed template rendering; zero delta means it was ignored.
## Cleanup
Trash the probe profile and echo server (`profiles/.AGENTTRASH`), never `rm`.
@@ -0,0 +1,29 @@
---
name: openwebui-picker-acl-repair
description: "Fix OpenWebUI 0.6 \"No results found\" / Model not found in the model picker for non-admin users (Dankclaw fleet) after a connection rebuild."
---
# OpenWebUI 0.6 picker ACL repair (Dankclaw)
Symptom: admin sees models in OpenWebUI; regular users get "No results found" in the picker, or chat answers "Model not found". Cause: an OpenAI-connection rebuild (config keys `openai.api_base_urls` / `openai.api_configs` in the `config` table) that dropped the ACL pair for the model.
## Law
A non-admin user sees an external OpenAI-connection model only if BOTH exist in `webui.db`:
1. `model` table (singular) row: `id` == upstream model id, `base_model_id` NULL, `is_active` 1, `meta` JSON (description etc.).
2. `access_grant` row: `(resource_type='model', resource_id=<model id>, principal_type='user', principal_id='*', permission='read')`.
The aggregation (`utils/models.py get_filtered_models`) drops info-less models for `role=user`; admins bypass. `get_all_models` responses are served from `model_ids` in `api_config` when non-empty (no upstream fetch). An alias row (`id` != upstream, `base_model_id` set) renders in the picker but chat fails "Model not found" — id must equal the upstream id.
## Steps
1. Reproduce as the affected role: in a browser tab, `fetch("/api/models", {headers:{Authorization:"Bearer "+localStorage.token}})` → `data: []` while an admin API key (`api_key` table, column `key`, `sk-…`) lists the model.
2. DB at `/home/dank/Containers/open-webui/data/webui.db` (host; no sqlite3 CLI on Dankclaw — use host `python3` heredoc over ssh; container path `/app/backend/data`).
3. Insert/repair the pair (uuid4 id, epoch-ms `created_at`):
- `model`: id/name = upstream id, base_model_id NULL, is_active 1, user_id = admin uid (role='admin' in `user` table), meta `{"description": "…"}`.
- `access_grant`: shape above.
4. Cache: `MODELS_CACHE_TTL` defaults to 1 s — no restart needed for lists; a restart (`systemctl --user restart open-webui`, rootless) only if config edits were cached oddly. An OpenWebUI restart invalidates browser JWTs (401) — expect a silent SSO re-round-trip; kill stale tabs first so OAuth `state` cookies don't race (MismatchingStateError → clear cookies/storage, one clean flow).
5. Verify end-to-end: pick the model in the UI, send a short exact-match prompt, confirm the reply; check `/api/models` as the user role lists the id.
## Related knobs
- User API keys (User menu → Settings → Account → API keys → Show → Create new secret key) require `user.permissions` JSON `features.api_keys=true` in `config` (default false).
- New SSO signups land as role `pending` (`ui.default_user_role=pending`) — activate: `UPDATE user SET role='user' WHERE email LIKE …`. Table is `user`, singular; no `active` column.
- Anubis gates `ai.franksdank.cloud` for headless browsers (webdriver detection): drive direct `:3005` for UI work, or emulate a desktop Chrome UA.
@@ -1,122 +0,0 @@
---
name: pcie-link-capability-probe
description: "Measure a GPU-to-host PCIe link the way a weight-streaming engine experiences it, and convert the result into a tokens/s ceiling (streamed expert weights) versus the compute-in-RAM alternative — before recommending or rejecting an offload design."
---
# PCIe link capability probe
Use when someone asks "is the link fast enough to run model X with engine Y (on one GPU)?"
and Y keeps some weights in host RAM — MoE expert offload with an LRU GPU cache, layer
paging, weight streaming. The answer is arithmetic once the link is measured, and the
*shape* of the engine decides whether the link matters at all:
| engine shape | what crosses PCIe per token | link matters? |
|---|---|---|
| streams weights (expert LRU, paging) | the missed weights (~GB/token) | **yes — decisive** |
| computes offloaded weights in RAM (llama.cpp `-ot exps=CPU`) | activations only (~KB) | no; host RAM bandwidth decides |
| everything resident on the GPU | nothing | no |
## 1. Establish the hardware ceiling (never trust the card alone)
```bash
lspci -tv | grep -iA2 vga # which bridge the GPU hangs off
d=$(lspci -D | grep -i nvidia | awk '{print $1}' | head -1)
sudo lspci -vv -s "$d" | grep -E 'LnkCap:|LnkSta:' # the CARD's capability + trained state
sudo lspci -vv -s 00:03.1 | grep -E 'LnkCap:|LnkSta:' # the PLATFORM PORT (this is the real limit)
```
- `LnkCap` of the **upstream port** = the electrical ceiling. A card with `Width x16` under
an `x8` port cannot exceed x8; an x4 riser/cable caps both.
- `LnkSta` at idle usually shows a downgraded speed (PCIe ASPM downshift, e.g.
`2.5GT/s (downgraded)`) — that is NOT the loaded rate. Trust the measurement in step 2.
- Gen4 usable payload: x4 **7.877 GB/s**, x8 15.75, x16 31.5 (128b/130b encoding).
## 2. Measure it (the program that did the job)
Stop **all** production services on the host first (fleet policy), then compile and run:
```cuda
// pcie-link-test.cu : nvcc -O2 -o /tmp/pcie-link-test /tmp/pcie-link-test.cu
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cuda_runtime.h>
#define CK(x) do{ cudaError_t e=(x); if(e!=cudaSuccess){fprintf(stderr,"CUDA %s (%s:%d)\n",cudaGetErrorString(e),__FILE__,__LINE__); return 1;} }while(0)
static double ms_between(cudaEvent_t a, cudaEvent_t b){float ms=0; cudaEventElapsedTime(&ms,a,b); return (double)ms;}
static int bench_size(size_t bytes){
const int iters = bytes>=(1UL<<30)?10:(bytes>=(256UL<<20)?20:100);
void *dev=NULL,*host=NULL,*pageable=NULL; cudaStream_t s=NULL;
CK(cudaMalloc(&dev,bytes)); CK(cudaHostAlloc(&host,bytes,cudaHostAllocDefault));
memset(host,0x5a,bytes); CK(cudaStreamCreate(&s));
cudaEvent_t a,b; CK(cudaEventCreate(&a)); CK(cudaEventCreate(&b));
for(int i=0;i<3;i++) CK(cudaMemcpy(dev,host,bytes,cudaMemcpyHostToDevice));
CK(cudaDeviceSynchronize());
CK(cudaEventRecord(a)); for(int i=0;i<iters;i++) CK(cudaMemcpy(dev,host,bytes,cudaMemcpyHostToDevice));
CK(cudaEventRecord(b)); CK(cudaEventSynchronize(b)); double h2d=(double)bytes*iters/(ms_between(a,b)/1000.0)/1e9;
CK(cudaEventRecord(a)); for(int i=0;i<iters;i++) CK(cudaMemcpyAsync(dev,host,bytes,cudaMemcpyHostToDevice,s));
CK(cudaEventRecord(b)); CK(cudaEventSynchronize(b)); double h2da=(double)bytes*iters/(ms_between(a,b)/1000.0)/1e9;
CK(cudaEventRecord(a)); for(int i=0;i<iters;i++) CK(cudaMemcpy(host,dev,bytes,cudaMemcpyDeviceToHost));
CK(cudaEventRecord(b)); CK(cudaEventSynchronize(b)); double d2h=(double)bytes*iters/(ms_between(a,b)/1000.0)/1e9;
CK(cudaMemcpy(dev,host,bytes,cudaMemcpyHostToDevice));
CK(cudaEventRecord(a)); CK(cudaMemcpy(dev,host,bytes,cudaMemcpyHostToDevice)); CK(cudaEventRecord(b)); CK(cudaEventSynchronize(b));
double one_us=ms_between(a,b)*1000.0;
pageable=malloc(bytes); memset(pageable,0x5a,bytes);
for(int i=0;i<2;i++) CK(cudaMemcpy(dev,pageable,bytes,cudaMemcpyHostToDevice));
CK(cudaEventRecord(a)); for(int i=0;i<iters;i++) CK(cudaMemcpy(dev,pageable,bytes,cudaMemcpyHostToDevice));
CK(cudaEventRecord(b)); CK(cudaEventSynchronize(b)); double pag=(double)bytes*iters/(ms_between(a,b)/1000.0)/1e9;
printf("%8.1f MiB | %6.2f | %6.2f | %6.2f | %8.1f us | %6.2f\n", bytes/1048576.0,h2d,h2da,d2h,one_us,pag);
free(pageable); cudaFreeHost(host); cudaFree(dev); cudaEventDestroy(a); cudaEventDestroy(b); cudaStreamDestroy(s); return 0;
}
int main(void){ cudaDeviceProp p; CK(cudaGetDeviceProperties(&p,0));
printf("device %s | CUDA %d.%d\n", p.name, p.major, p.minor);
printf(" MiB | H2D | async | D2H | 1x xfer | pageable\n");
size_t sz[]={3UL<<20,16UL<<20,64UL<<20,256UL<<20,1UL<<30,2UL<<30};
for(int i=0;i<6;i++) if(bench_size(sz[i])) return 1; return 0; }
```
Size choice matters: **3 MiB** is the granularity an expert-streaming engine moves, so
its single-transfer time is the number that matters; the big sizes give the ceiling.
## 3. Read it
- **Achieved GB/s is itself the proof of the trained link state** — 6.7 GB/s is only
possible at Gen4 x4 (Gen1 x4 ≈ 1, Gen2 ≈ 2, Gen3 ≈ 3.94), so no separate `LnkSta`
sample under load is needed.
- **async ≈ sync and pageable ≈ pinned ⇒ link-limited, not staging-limited.** If async is
much faster than sync, the engine's fill pipeline (batching copies on one stream) is
where the win is; if pageable is much slower, pinning is a real lever. Measure, don't
assume.
- Per-slab launch overhead = single-xfer time − (slab bytes ÷ rate). Small next to
bandwidth cost at 3 MiB.
## 4. Convert to a tokens/s ceiling
```
active expert bytes/token = active_params × bytes/param (A3B @ Q4_K_M ≈ 1.70 GiB)
link time/token = active expert bytes / measured H2D rate
ceiling (tok/s) = 1 / link time/token × resident-fraction saved (LRU hits)
```
Then compare against the alternatives on the same host — and against the
compute-in-RAM path computed at host RAM bandwidth (256 GB/s LPDDR5X on this fleet's
Strix Halo). If the link is >10× slower than the memory behind it (x4 is 31× here),
streaming loses by construction and no tuning rescues it; doubling the link (x4 → x8)
only doubles a losing number.
## Worked example (Dankclaw 2026-09-16)
Gen4 x4, measured H2D 6.72 / D2H 6.60 GB/s (85 % of spec), 3 MiB slab = 474 µs.
A3B MoE, Q4_K_M (75 GiB experts, ~1.70 GiB active/token): 272 ms/token ⇒ **3.7 tok/s**
(4.6 with 20 % resident). Same host, same model, measured: **20.46 tok/s** with the
experts computed on the CPU, 25 (halogen), 26.5 (mainline on the iGPU). Verdict: the
streaming design loses 3-5×, and the engine in question could not load the GGUF anyway.
## Pitfalls
- Stop production services before running this (fleet policy) — and remember the 3090's
VRAM may be almost fully occupied, so a 2 GiB test buffer needs the service stopped.
- Don't quote a derived line from inside the program without checking it: a printf
format/argument mismatch in this very probe printed a nonsense ceiling. Recompute by hand.
- GiB vs GB: convert before dividing (1.70 GiB = 1.825 GB).
- The idle `LnkSta` (2.5 GT/s, downgraded) is not the loaded link — quoting it makes a
working x4 look like a broken x1.
@@ -1,101 +0,0 @@
---
name: pcie-link-degradation-vs-idle-triage
description: "Decide whether a GPU/NVMe PCIe link is genuinely degraded (bad riser, bad slot, wrong connector) or just idle-downshifted, and whether moving the card to another slot could widen it — using kernel boot bandwidth lines, port LnkCap vs LnkSta, and NVML sampled under load."
---
# PCIe link: degraded or just idle?
Answer "is my GPU running at full speed?" without fooling yourself. The trap: an
idle NVIDIA GPU reports `LnkSta: Speed 2.5GT/s (downgraded)` because the driver
downshifts the link to Gen1 for power. That is not a fault, and it is not the
slot's fault either.
## Step 1 — the path ceiling, from the kernel (cheapest, most decisive)
```bash
sudo journalctl -k -b --no-pager | grep "available PCIe bandwidth"
```
Reads out per endpoint, e.g.:
```
pci 0000:c4:00.0: 63.012 Gb/s available PCIe bandwidth, limited by 16.0 GT/s
PCIe x4 link at 0000:00:03.1 (capable of 252.048 Gb/s with 16.0 GT/s PCIe x16 link)
```
- `63.012 Gb/s` = Gen4 **x4** (~7.88 GB/s/direction after 128b/130b encoding).
- "limited by ... link at <BDF>" names the **bottleneck device** (usually a root
port) and its width — this is the number that decides whether a swap can help.
- "capable of ... x16 link" is the **endpoint's own** capability, not the path's.
A big gap between the two is the whole story.
`dmesg` is often rotated; `journalctl -k -b` keeps this boot.
## Step 2 — the two sides separately
```bash
sudo lspci -vv -s <endpoint> | grep -E "LnkCap:|LnkSta:|LnkCtl2:|LnkCap2:"
sudo lspci -vv -s <root port> | grep -E "LnkCap:|LnkSta:|LnkCtl2:"
```
- `LnkSta` on a *bridge* is the trained link; on an endpoint it is that
endpoint's view. Widths/`(downgraded)` compare against the **own** `LnkCap`.
- `LnkCtl2: Target Link Speed: 16GT/s` on both ends = the platform *intends*
Gen4, so a Gen1 current speed is power management, not misconfiguration.
- A root port whose `LnkCap` width exceeds the trained width means the socket or
adapter wires fewer lanes than the controller can drive. **M.2 connectors are
4 lanes by pinout** — an M.2 riser can never deliver more, regardless of what
the bridge advertises.
## Step 3 — prove the link under load (the step people skip)
Never conclude from an idle reading on NVIDIA. Sample NVML while the GPU works:
```bash
nvidia-smi --query-gpu=pcie.link.gen.current,pcie.link.gen.max,\
pcie.link.gen.hostmax,pcie.link.width.current,pcie.link.width.max --format=csv
```
Fire a small, legitimate request at whatever already serves on that GPU (e.g. a
`max_tokens` completion against the local LLM endpoint) and poll the query while
utilization is high:
- `gen.current` climbing 1 → `hostmax` = idle downshift, link healthy.
- `gen.current` stuck below `hostmax` under load = real degradation.
- `width.current < width.max` under load = **physical lane limit** (wiring,
riser, or connector) — no software touches this.
- On a busy production GPU, this is a usage smoke test, not a benchmark: never
stop/bench a live service to read its link.
## Step 4 — do not plan hardware from `dmidecode -t slot`
On consumer/mini-PC firmware this table is routinely fiction — it has been seen
labelling an Ethernet port and an SD-reader controller as "M.2 Socket 3" slots
and advertising a free slot in a **PCI domain that does not exist** (e.g.
`0200:00:03.2`). Cross-check with the live bridge list:
```bash
lspci -nn | grep "PCI bridge" # every root port, whether populated or empty
lspci -t # topology; which endpoints hang off which port
```
Empty root ports still enumerate, so a genuinely absent port is genuinely
absent. Trust live PCI, not SMBIOS.
## Step 5 — verdict on "would moving the card to another slot help?"
- Compare the **bottleneck device named in step 1** with the target slot's port
`LnkCap` width. If the bottleneck is the *endpoint side* (riser/adapter) and
the target port is wider, a move can help.
- If both candidate connectors cap at the same width (two M.2 sockets = both
x4), moving is a **no-op** — say so plainly instead of endorsing the swap.
- Error counters are a useful tiebreaker: clean `DevSta`/`CESta` with a low
trained speed means "wired narrow", not "failing".
## Reporting
Give the ceiling in Gb/s, the card's own capability, what the idle reading
meant, and whether any physical change can move the number. If the answer is
"the link is already at the hardware maximum", state that and quantify whether
the workload actually cares (resident weights = PCIe is nearly idle per token;
CPU layer offload = it hurts).
@@ -1,46 +0,0 @@
---
name: pico8-asset-standards
description: "Standards for building cohesive, production-grade PICO-8 asset sheets, tilesets, and spritesheets with consistent palette, perspective, and detail flow."
---
# PICO-8 Asset Standards
## Purpose
Ensure every PICO-8 asset (tileset, spritesheet, character sheet, or asset sheet) is cohesive, production-grade, and ready to drop into a game without rework.
## Core Requirements
### Palette Discipline
- Use the PICO-8 16-color palette exclusively.
- Establish a dominant color family (2–3 related hues) for the main subject.
- Reserve accent colors for highlights, danger zones, and focal points only.
- Maintain consistent color roles across all assets in a sheet (e.g., if shadow is dark blue on one tile, it is dark blue everywhere).
### Perspective & Lighting
- Choose ONE perspective per sheet (top-down, side-view, or isometric) and never mix.
- Apply light from a single consistent direction across all tiles and sprites.
- Shadows follow the same shape logic on every asset (e.g., all cast shadows point bottom-right).
### Detail Flow & Hierarchy
- Start tiles/props from the palette's dominant colors, then add edge highlights and shading.
- Keep tile density uniform — avoid one hyper-detailed tile next to empty flat tiles.
- Props and decorations should read clearly at 8x8 or 16x16 without zooming.
### Sheet Organization
- Group related assets together (terrain together, props together, characters together).
- Leave consistent spacing between assets for clean cropping.
- Label sections visually through grouping, not text.
### Cohesion Checks
Before finishing an asset sheet, verify:
- All colors appear in the PICO-8 palette.
- Perspective does not flip mid-sheet.
- Lighting direction is consistent.
- Edge treatment (dither, outline, anti-aliasing style) matches across assets.
- No orphan tiles that don't connect to any neighbor.
## Common Pitfalls
- Mixing 8-bit and 16-bit detail levels.
- Using outline style on some tiles but not others.
- Forgetting palette constraint — colors outside the 16 PICO-8 colors.
- Inconsistent tile edge connections (a grass tile that only connects to one dirt variant).
@@ -1,29 +0,0 @@
---
name: podman-netavark-host-port-probe
description: "Diagnose \"connection refused\" when a rootless-podman container tries to reach a host service port on this netavark host; proves the published-port-only quirk and picks the working path (host.containers.internal)."
---
# Podman/netavark: container → host port connectivity probe
Use when a containerized service on a rootless-podman + netavark host cannot reach a HOST process by LAN IP (connection refused immediately, not a timeout) even though `ss -ltnp` proves the listener is up in the host netns.
## Why this happens
On netavark-backed networks, container→host-LAN-IP traffic is only delivered for ports that have a live port-forward/DNAT (published ports). Un-published host ports refuse instantly — there is no firewall rule and no stale DNAT to find; `nft list tables`/`iptables -t nat` will be empty. Do not hunt for REJECT rules; probe instead.
## Probe recipe (proves the quirk in one command)
```bash
ssh <host> "podman run --rm --network <container-network> --entrypoint sh docker.io/library/busybox:latest -c 'for p in <suspicious-port> <published-port>; do nc -w2 -z -v <host-lan-ip> \$p >/dev/null 2>&1 && echo port-\$p-OK || echo port-\$p-BAD; done'"
```
- Published port OK + target port BAD ⇒ published-port-only quirk confirmed.
- Also probe the alias path from inside the actual service container:
```bash
podman exec <svc> bash -c 'exec 3<>/dev/tcp/host.containers.internal/<port> && echo ALIAS-OK || echo ALIAS-FAIL'
```
Note: busybox `nc -z` reports any failure as failure — use it for bulk port sweeps; use the `/dev/tcp` probe (or `nc -v`) when the refusal-vs-timeout distinction matters.
## Fix decision
- If the alias path connects and the LAN-IP path refuses → set the service's host-facing URL to `http://host.containers.internal:<port>` in its compose/quadlet env, then redeploy via the normal git flow (`deploy.sh <target> --service <svc>` recreates the container with fresh env).
- Do NOT publish the daemon port just to make the LAN IP work — publishing is exactly what makes other ports refuse.
## Post-fix verification
`podman exec <svc> printenv <URL_VAR>` then tail logs for the connect confirmation (e.g. `Signal: connected`, `SSE: connected`) within ~40 s of recreation.
@@ -1,51 +0,0 @@
---
name: podman-netns-addressability-probe
description: "Runbook to answer \"can container X actually reach service Y\" on a rootless-podman/netavark host, instead of assuming localhost works everywhere; includes the probe commands and the false-positive traps."
---
# Podman netns addressability probe
Answer **"can this container actually dial that service?"** per *network namespace*,
not per host. On rootless podman + netavark, a bridge-net container cannot reach
host-netns loopback listeners at all, so `localhost:PORT` reasoning is wrong for it.
## Decision rule
- `NetworkMode=host` → the container **is** the host netns: dials everything on
`127.0.0.1`, binds any free port.
- bridge/named network → own netns. Only **published** ports are reachable, via the
network's **gateway IP**. Unpublished host listeners are **refused instantly (RST)**,
not routed — even when the listener is bound `0.0.0.0`.
## Probe sequence (all read-only, run on the host)
1. Namespace: `podman inspect <name> --format '{{.HostConfig.NetworkMode}}'`
2. Owner of a listener: `sudo ss -ltnp 'sport = :<port>'` (host netns only — this is
why a `0.0.0.0` listener can still be unreachable from a bridge net).
3. From inside the suspect container:
`podman exec <name> cat /proc/net/route` → default route = gateway IP to dial.
4. Probe the candidate address **inside that netns**:
`podman exec <name> sh -c 'timeout 2 bash -c "exec 3<>/dev/tcp/<gw-ip>/<port>"; echo rc=$?'`
## Interpretation (do not blur these)
- **instant refusal** = nothing listening in a reachable namespace (or rootlessport
rejected an unpublished port map).
- **timeout** = no route to that address at all. `host.containers.internal`
(`169.254.1.2`) is an alias on the **default `podman` net only**; containers on a
named network time out against it rather than being refused.
## Probe-tool gotchas verified on this host (podman 5.x, netavark)
- `podman network inspect <net> --format '{{json .containers}}'` **errors**
(`can't evaluate field containers in type interface {}`) — membership is not exposed
there. List from the container side instead:
`podman ps -a --format '{{.Names}} {{.Networks}}'`
- An **empty** `Networks` field in that output = host network or a shared namespace
(`--network=container:<other>`), not "no network".
- Quadlet `Network=<name>.network` yields runtime network name **`systemd-<name>`**;
probe with `podman network ls`, never by the `.network` filename.
## Fix options when a bridge-net container must reach a host-netns listener
(a) move it to `Network=host`, or (b) publish the port — (b) makes that port reachable
from every namespace **and the LAN**, so it is an exposure change needing explicit
operator approval, never a silent fix.
Repo source of truth for this fleet: `serverAppConfigs-docs/Architecture/Container
Network Namespaces.md` (commit `2e179b0`).
@@ -1,28 +0,0 @@
---
name: podman-recreate-anon-volume-wipe
description: "Diagnose \"container data wiped after restart\" on this rootless-podman fleet: quadlet restarts recreate containers via podman run --replace --rm, and anonymous volumes reset to empty on every recreation. Includes the live audit procedure and the known affected services (hermes trio /opt/data, romm /romm DB, searxng cache)."
---
# Podman recreation & anonymous-volume wipe audit
## Mechanism (verified live 2026-08-31 on Dankclaw)
A quadlet unit restart is NOT a start of the same container: the generated unit's ExecStart is
`podman run --name systemd-%N --replace --rm ...`. Every `systemctl --user restart <unit>.service` (and every deploy that does one, e.g. `deploy_hermes`) **destroys and recreates** the container. Consequences at recreation time:
- Writable-layer writes: gone by definition.
- Anonymous volumes (image-declared `VOLUME` with no explicit `-v`/`Volume=` for that path): NOT carried across recreation — the new container gets a FRESH EMPTY one seeded only from image content. Orphans stay on disk until pruned (`podman volume prune` only removes unmounted ones).
- Named volumes and bind mounts: persist (remounted by name/path).
## Audit procedure
1. `ssh <host> 'podman ps -a'` — map which host runs which stack (aliases `dankclaw`/`dankserver` match their hostnames).
2. Per container: `podman inspect <name> --format "{{json .Mounts}}"` → classify each mount `"Type":"bind"` vs `"Type":"volume"` (hex-name volume = anonymous).
3. Quirks: `podman ps --format` template field is `.Names`, not `.Name` (psReporter has no `Name` field — using `.Name` errors). Volume `_data` dirs under `~/.local/share/containers/storage/volumes/<id>/_data` are mode 0700 — unreadable by the plain ssh user; use `podman unshare`/sudo if content inspection is required.
## Fleet state at audit time
- **hermes trio** (`systemd-hermes`, `hermes-dashboard`, `hermes-webui`; gateway container auto-names `systemd-<unit>` because its unit lacks `ContainerName=`): `/data` = bind of `/home/dank/hermes-data` (durable state: config.yaml, sessions/, skills/, state.db) survives recreations; `/opt/data` (image default VOLUME + `HERMES_LAZY_INSTALL_TARGET`) is an anonymous volume → resets to image defaults on every deploy. So "Hermes lost data after restart" = anything written outside `/data`/`/webui` was reset, not the durable state.
- **romm** (dankserver): runs with anonymous volumes at `/romm` and `/redis-data` → its DB lives in an anon volume; recreating romm (`podman-compose up -d` after image bump) boots it with a fresh empty DB. Fix = named volume or bind before next recreation.
- **searxng**: `/var/cache/searxng` is an anonymous volume — cache only, benign reset on recreation.
- Everything else on both hosts was verified as bind mounts (or stateless by design: authentik-server has no mounts at all; anubis is a memory store with client-side JWTs).
## Fixes when data must survive recreation
Pin a NAMED volume (`Volume=hermes-data:/opt/data` in the quadlet unit) or point the env var at a bind-mounted path. Never rely on anonymous volumes surviving recreation.
@@ -1,26 +0,0 @@
---
name: porkbun-dns-ops
description: "Runbook for mutating Porkbun DNS records for franksdank.cloud via API from the target host, covering the relative-name edit quirk, rate-limit failure shape, and verify steps."
---
# Porkbun DNS ops (franksdank.cloud)
Operate the public DNS zone for `franksdank.cloud` via the Porkbun API v3, run **on Dankclaw** so the API key/secret sourced from `/home/dank/serverAppConfigs/.env` (`PORKBUN_API_KEY`, `PORKBUN_API_SECRET_KEY`) never leaves the host. Pipe scripts to the target with `ssh -o BatchMode=yes dankclaw 'bash -s' < /tmp/script.sh`.
## Endpoints (all POST, JSON body)
- List: `https://api.porkbun.com/api/json/v3/dns/retrieve/franksdank.cloud` — auth via headers `X-API-Key` + `X-Secret-API-Key` (Bearer is wrong here). Response shape is **top-level `.records[]`**, NOT `.content.records[]` as some docs claim — parse with `jq -r '.records[] | [.id,.name,.type,.content,(.ttl|tostring)] | @tsv'`.
- Delete: `/dns/delete/{domain}/{id}` (body carries apikey+secretapikey).
- Edit: `/dns/edit/{domain}/{id}` — pass the full record (`id`, `name`, `type`, `content`, `ttl`, `prio`).
## Quirks that bite (verified live 2026-08-31)
1. **`name` is a RELATIVE label on edit.** Passing `ai.franksdank.cloud` yields the record `ai.franksdank.cloud.franksdank.cloud`. Pass the bare label (`ai`) and the API appends the zone itself. Verify after every rename with a retrieve before declaring done.
2. **Rate limit answers as an HTML 404-style page, not JSON.** A batch of rapid mutations makes some calls return `<!DOCTYPE html>...` where jq then errors (`Cannot iterate over null` / `Invalid numeric literal`). Do not assume the call succeeded — sleep ~8 s and retry the failed ids; verify by re-listing the zone at the end.
3. Deletes/renames are per-record-id: always retrieve first, map name→id, then mutate by id.
## Rename procedure (vhost rename = DNS + Caddy together)
1. `retrieve` → collect ids for the renamed names.
2. `edit` each id with the new **relative** label, preserving type/content/ttl.
3. Re-retrieve and confirm `<new>.franksdank.cloud A <ip>`.
4. In-repo lockstep before deploy: rename the Caddyfile vhost block AND the Pi-hole `dankclaw/pihole/addn-hosts` line in the same commit — test 178 (`tests/pihole.bats`) fails the suite if any Caddyfile vhost lacks an addn-hosts A record on 192.168.0.143.
5. Deploy via `scripts/deploy.sh dankclaw` (plans pihole+caddy from changed paths). Capture the suite's true exit with `out=$(./tests/run.sh 2>&1); rc=$?` — piping to `tail` masks the exit code behind `tail`'s own status and has shipped untested commits here.
6. Verify live: `dig +short <name> @192.168.0.143` then `curl -o /dev/null -w %{http_code} https://<new-name>` (expect 200/302; first hit triggers dns-01 issuance, which works without inbound port 80).
@@ -1,88 +0,0 @@
---
name: published-port-cutover-gate
description: "When replacing or restarting a service that publishes a TCP port on this rootless-podman/pasta fleet, gate on the port actually being free first — otherwise pasta's bind fails at container create and the failure hides behind Restart=on-failure."
---
# Published-port cutover gate (rootless podman / pasta)
On hosts where **rootless podman publishes ports through pasta**, the published
port is bound in userspace at **container CREATE**, not when the app binds. A
predecessor process that has just been stopped can still hold that port —
listener, accepted connection, or TIME_WAIT residue from the connections it
served — so a replacement started immediately after `systemctl stop` dies with:
```
pasta: ... bind: Address already in use
conmon ... exit status 126
```
and because the unit is usually `Restart=on-failure`/`RestartSec=10`, it
**self-heals seconds-to-minutes later**. That is the trap: the deploy looks
like it failed, then looks fine, and the real defect (no port gate) stays
invisible because the retry masks it.
Observed on Dankclaw 2026-09-16 cutting `frank-large` from a root unit
(ik_llama.cpp) to a rootless Quadlet (halogen): `systemd` logged
"Deactivated successfully", and pasta tried to bind 7770 about a second later
and reported "Address already in use". Only the quadlet's own restart policy
recovered it.
## Procedure
Before starting the replacement:
1. **Retire the old unit and wait for it to be genuinely inactive.**
`systemctl stop <old>` (root) or `systemctl --user stop <old>`.
2. **Stop the NEW unit too, if this is a redeploy.**
`systemctl --user stop <new>.service || true`
Without this the gate deadlocks against itself: the currently-running
replacement is the thing holding the port. This mistake was made and caught
in the same session — a gate placed before the stop would have failed every
subsequent deploy of an already-running service.
3. **Gate on the port being empty, covering every socket state:**
```bash
wait_port_free() {
local port="$1" limit="${2:-90}" i
for ((i = 1; i <= limit; i++)); do
if [[ -z "$(ss -Htan "sport = :$port" 2>/dev/null)" ]]; then
return 0
fi
sleep 1
done
echo "FAIL: port $port is still held after ${limit}s:" >&2
ss -tan "sport = :$port" >&2 || true
return 1
}
```
`sport` matches both the listener and the server side of accepted
connections, so this also catches TIME_WAIT residue that a plain
`ss -ltn` check misses. Fail loudly with the socket table attached; a deploy
that starts a doomed container has not deployed anything.
4. **Then** install/pull and start, and wait on the service's health endpoint.
## Verifying the gate has teeth
- Re-run the same deploy: it must pass with the service already running (that
is the idempotency case the self-deadlock broke).
- Reproduce the failure once on purpose if the service is not user-facing:
stop the container and start the replacement with the gate bypassed, and
confirm `Address already in use` / exit 126.
- Assert it in the repo's seam tests: e.g. `grep -q 'wait_port_free <port>'`
and `grep -q '^wait_port_free() {'` in the deploy handler, so a later
refactor cannot quietly drop the gate.
## Related traps
- `systemctl disable` on some of these hosts **deletes the unit file** rather
than just the symlink, so a "move the unit to trash" step must tolerate a
file that is already gone — and should say so, or "no message" carries no
evidence.
- Never `rm`; move retired units into trash (`$HOME/.AGENTTRASH/<date>-<topic>/`).
- A rootless Quadlet's generated unit **cannot** be `systemctl --user enable`d;
boot persistence comes from `[Install] WantedBy=default.target` in the
`.container`. To keep a masked/failed quadlet from being regenerated, move
the `.container` out of `~/.config/containers/systemd/` and `daemon-reload` —
the generator's source is the lever, not `mask`.
@@ -1,29 +0,0 @@
---
name: quadlet-v1-generator-quirks
description: "When writing or deploying Quadlet .container units on podman 5.x (v1 generator, e.g. Dankclaw), avoid keys the v1 generator hard-fails on and verify generated ExecStart before starting; generated units cannot be enabled."
---
# Quadlet v1 generator quirks (podman 5.x)
The podman 5.x user generator (`/usr/lib/systemd/user-generators/podman-user-generator`, quadlet **v1**) behaves differently from the v4 docs. Verified live on Dankclaw 2026-08-28 while deploying the authentik trio.
## Hard-fail keys (unit silently fails to generate — `systemctl --user cat <unit>` shows "could not be found")
- Any dash-prefixed option line in `[Container]`: `--shm-size=512m` etc. → generator logs `unsupported key '--shm-size' in group 'Container'` and skips the whole unit. Omit them; accept podman defaults.
- `Command=` is unsupported — use `Exec=` (that is what this generator accepts).
## HealthCmd space-splitting
The v1 generator splits `HealthCmd` on spaces into the healthcheck argv array (`podman inspect --format {{.Config.Healthcheck.Test}} <ctr>` shows `[CMD-SHELL pg_isready -d ... ]`). A quoted probe like `/bin/sh -c "pg_isready ..."` lands as argv token `"pg_isready` → command-not-found → container reads **unhealthy while the DB is actually up**. Rule: HealthCmd must contain only space-free argv tokens, e.g. `HealthCmd=pg_isready -d authentik -U authentik` or `python /manage.py check --database default`.
## Generated units cannot be enabled
`systemctl --user enable <generated-unit>` fails with "unit is transient or generated". Boot persistence comes from `[Install] WantedBy=default.target` inside the `.container` file, honored by the generator at user-manager start; linger must stay on for the deploy user. Deploy handlers: install unit → `systemctl --user daemon-reload` → `systemctl --user start`; no `enable`.
## Probe a new key before shipping it
Drop a throwaway `probe.container` into `~/.config/containers/systemd/`, `systemctl --user daemon-reload`, then check the generator verdict:
```bash
journalctl --user --since "1 min ago" -o cat | grep -i "unsupported key"
systemctl --user cat probe.service # shows the generated ExecStart verbatim
rm ~/.config/containers/systemd/probe.container && systemctl --user daemon-reload
```
If a healthcheck ever reads unhealthy but logs look fine, suspect this quirk first: compare the baked `Healthcheck.Test` array against the intended command before believing the status.
@@ -0,0 +1,32 @@
---
name: radiance-frank-primary-benching
description: "Bench frank-primary (radiance) decode/prefill via the scratch-container harness: method, noise floor, and the MNBT/HOST_POOL/envelope laws to respect before proposing any flag change."
---
# Benching frank-primary (radiance)
## Method (works as of 2026-10-06)
- Entry point: `bash autoresearch.sh` (dev box) → scp's `scripts/frank-primary.env` + `scripts/autoresearch-frank-primary.sh` to dankserver → the bench script ON dankserver: stops frank-primary (relinked unit: `stop`, never `mask`), boots scratch container `arbench` on the candidate env, polls /health (binds after full load, ~110 s cold), runs ONE greedy completion (fixed ~140k-char repo-corpus prompt, seed 1234 → 43,627 prompt tokens, 256 decode tokens, temp 0), parses the engine `timings` object, tears down, and restores prod via a trap.
- Metrics from `timings`: `prompt_per_second` (cold prefill), `predicted_per_second` (decode at depth = (predicted_n-1)/predicted_ms), `prompt_ms` (TTFT), `draft_n`/`draft_n_accepted` (acceptance).
- Fresh prefix-cache tier per run (`/data/kvcache/arbench`) so cold prefill is cold; prod's tier untouched.
- Bench locally on dankserver (127.0.0.1) — no LAN jitter. Run-to-run ~4-5 min.
## Noise floor (measurement law)
- Decode: ~±3% on IDENTICAL config (33.06 / 32.03 / 30.90 observed). Single-run deltas <3% are not evidence; re-run before keeping.
- Prefill: ~±2% (453/448/457 identical config).
## Laws at the seqs=3 / maxlen=200k / fp8-KV / MTP-on shape (2×R9700, TP=2)
- **MNBT is a sharp resonance at 2048**: 2560 → decode 2.6-7.7 t/s (acceptance stays normal, verify stalls ~130 ms/step, generation truncates); 1536 → decode 24.7 + 22% prefill loss + truncation; 4096 → boot failure at seqs=3 (boots at seqs=2, -2.6% only). DO NOT move MNBT off 2048.
- **HOST_POOL_MIB 40960 is the load-bearing floor**: 36864 fails boot; 49152 boots but decode-flat.
- **EXPERT_VS_CACHE 0.82 is bracket-confirmed**: 0.78 fails boot; 0.84 costs 8.9% (toward the documented 0.87 collapse cliff).
- **TP_WIRE wht6 is mandatory** at seqs=3: bf16 (lossless) fails boot.
- **SPEC_TOKENS**: depths 1-3 statistically tied on decode; 0 = -6.8% (real), 4 = -22.5% (acceptance collapses to 0.50). Depth 2 nominal peak.
- MAX_MODEL_LEN 262144 (native) costs 2.5% — the larger KV pool steals resident-weights VRAM at expert_tiered.
- GPU_HEADROOM 48 vs 96: no effect.
## Hazard: abandoned bench contamination
- If a prior bench run is skipped/abandoned mid-flight, the next scratch boot may race the leftover engine and produce garbage numbers. Before trusting any collapse measurement: `podman ps -a` on dankserver — confirm the previous arbench is gone and frank-primary is active — then reproduce the collapse on a clean boot before writing it into the record.
## State
- HEAD on the autoresearch branch: spec=2, seqs=3, maxlen=200k (df6b5bb6e807). All spec 1-3 × seqs 2-3 configs decode within 30.9-33.1 — statistically tied. Frank's decision pending: keep spec=2/seqs=3 or revert to his spec=3/seqs=2 (83% acceptance, likely better for real multi-turn traffic).
- Production verified healthy (health 200) after every run via the harness trap.
@@ -0,0 +1,31 @@
---
name: radiance-precision-selection
description: "Choose tensor precisions for a radiance (StillDeadcode) rad-convert recipe on RDNA4: what is native, what is supported, what the hardware rewards."
---
# Radiance precision selection
Use when writing/choosing a `rad-convert` recipe for radiance on RDNA4 (gfx1201), or when arguing why a model was converted a particular way.
## Project map (which repo is authoritative)
- `codeberg.org/StillDeadcode/radiance` — standalone engine (C++/HIP). Recipes: `data/recipes/*.recipe`; docs: `docs/{GUIDE,MOE-W4,KERNELS,PLUGIN,TOOLS,OPS,ARCHITECTURES,DOCKER}.md`. Prefer the RAW form of Codeberg URLs when reading: `/raw/branch/main/<path>`.
- `codeberg.org/StillDeadcode/libr4d` — kernel library; entry points encode exact geometry and reject mismatch (`attn_decode_h256_gqa6_fp8kv`, `gdn_chunk_scan_k128_v128_c64_bf16`, `ar_oneshot_2rank_exact`). Kernel names are the ground truth for which dtypes have a fast path.
- `codeberg.org/StillDeadcode/vllm-radiance` — deprecated vLLM wrapper; README still carries the best prose on the FP8 dispatcher and kernel list.
## Precision ladder
| Precision | Role |
|---|---|
| block-FP8 (E4M3 + bf16 block scales) | native tuned GEMM/attention/KV — "preshuffled block-FP8 dispatcher" |
| MXFP4 (OCP group-32) / W4A8 | supported weight packing; activations dynamically quantized to FP8 E4M3, compute is FP8 WMMA |
| bf16 | KV (`auto`), skinny GEMMs, Gated-DeltaNet kernels |
| int8 / int4 | draft head and integer GEMM paths |
## Decision rules
- Prefer block-FP8 for anything the engine qualifies: generic upstream AITER linear in place of the FP8 dispatcher measured **9–11 % slower**; all upstream-qualified models are FP8 (Qwen3.8-27B-FP8, Qwen3.6-27B-FP8, Qwen3.6-35B-A3B-FP8, Gemma-4-31B-it-FP8).
- Weight packing is a *storage* choice, activation dtype is the *compute* choice: MXFP4 buys bytes, not an FP4 compute path.
- The matrix core is integer-biased: f16 WMMA is half the int8 rate and a quarter of the int4 rate ⇒ int4 > int8 > f16. FP8/INT8 kernels must fuse double-K WMMA to saturate the 128-bit memory interface (GPUOpen WMMA guide part 2) — a "slow" quantized kernel usually means a missing K-fused variant, not an inherent dtype cost.
- Keep low-bit tricks on the draft head (int4/int8) where verification tolerates them; keep lm_head and hard-selection tensors (QSA indexer top-k) at high precision — the shipped recipes quantify the cost (int8 lm_head beats E4M3 by ~0.6 top-1 points).
## Verification
- Read the recipe's own header comments: radiance recipes state measured KL / top-1 / prefill-decode deltas for their choice, and name the `--reuse` inputs.
- Confirm the kernel exists before promising a dtype: grep libr4d entry points for the dtype suffix (`fp8kv`, `bf16`, `exact`).

Some files were not shown because too many files have changed in this diff Show More