docs: distill the managed skill library into the vault (waves 1-7)

All ~173 managed skills read in full and merged, one polished reference
page per skill-cluster:

- LLM cross-cutting (17 pages): GGUF Compatibility, Llama Cpp Serving
  Sizing, Llama Cpp Multi-Device Splits, PCIe Link and Atomics, RDNA4
  Container Serving, RDNA4 MoE Expert Offload VRAM Budget, vLLM Boot
  Forensics, vLLM Porting and Compile Triage, LLM Bench Windows, LLM
  Performance Diagnosis, Model Swap and Rollback, Tcclaviger Fork Ops,
  Heterogeneous Split Triage, FreeToken Builds + deltas folded into the
  retired runbooks (GLM DFlash2 port laws, Lucebox dual-R9700, R9V
  qualification pipeline, FLM pack registration, ik hybrid envelope,
  frank-medium split rebalance, llama.cpp GGUF preflight, batch-ubatch
  split section, bench-window D-state/stale-read notes, second-endpoint
  bring-up).
- Architecture (3): Stale Signal Audit, Quadlet Lifecycle, Deploy State
  Triage.
- Operations (6 + index): Remote Job Runner, Payload and Disk Reclaim,
  Container Host Reachability, Fleet Ops Playbook, Fleet Audit and
  Posture, SSH and Remote Access.
- Infrastructure: Observability Provisioning + Homepage Status Probes;
  updates to Authentik, Anubis, LAN-DNS (per-client DoH bypass, Porkbun
  zone ops), Swap Configuration, OpenWebUI, Hermes (pre-existing page
  preserved and extended).
- FrankBook-Pro: ARR-Stack filled (API split, schema-first probing,
  auth-posture, auth-config trap); qBittorrent E2E verification.
- Desktop/Agent/Research: Noctalia and Niri, OMP Harness, Agent Docs and
  Edits, Command Center Dotfiles, PICO-8 Asset Standards.
- Indexes: LLM Cross-Cutting References section, Operations/ and Agent/
  index pages, root-index Skill Distillation section, AGENTS.md pointer.
- log.md carries the wave plan.

check-vault-links.py 0 unresolved (185 pages); ./tests/run.sh 366/366.
This commit is contained in:
Frank
2026-10-05 21:19:15 -07:00
parent 8618ec6dbe
commit 33eae71d33
43 changed files with 5436 additions and 2 deletions
+5 -1
View File
@@ -40,7 +40,11 @@ the [runbook index](#runbook-index). The full swap history lives in
- `scripts/` — `deploy.sh` / `deploy-plan.py` / `deploy-remote.sh`, LLM serve
scripts, systemd units, env tunables, bench harnesses
- `old/` — retired LLM scripts (reference only)
- `serverAppConfigs-docs/` — knowledge base (Obsidian vault, OKF format)
- `serverAppConfigs-docs/` — knowledge base (Obsidian vault, OKF format).
The managed skill library is distilled there one page per skill-cluster:
`LLM/index.md` → *Cross-Cutting References*, plus `Operations/index.md`,
`Agent/index.md`, and the Infrastructure/Dankclaw/FrankBook-Pro service
pages (`serverAppConfigs-docs/log.md` carries the wave plan and results)
- `tests/` — bats suite + Playwright e2e
## Git Workflow
@@ -0,0 +1,111 @@
---
type: reference
title: Agent Docs and Edits
description: Two self-inflicted damage classes — structurally broken markdown after parallel agent edits, and botched hashline PUT/CUT repairs — with the audit checks and recovery order for each.
tags: [agents, docs, markdown, hashline, edit-recovery]
timestamp: 2026-10-05T00:00:00-07:00
---
# Agent Docs and Edits
## Doc-structure audit (after parallel agent edits)
Use after one or more subagents have edited markdown docs (OKF/Obsidian
vault with `---` frontmatter), before committing. Link checkers and
completeness seams validate *references*, not *structure* — agents reliably
produce structurally broken markdown that still links cleanly.
Damage catalogue:
1. Banner/prose prepended **above** the opening `---` → the frontmatter is
no longer frontmatter (title/tags/description become body text).
2. A rewrite **appended** instead of merged → duplicate headings and a stray
second `---` mid-file.
3. Literal model artifacts in the body: `<|channel>}<tool_call|>...`,
`+- `, `- - `.
4. Wrapped bullets re-emitted with a `- ` prefix on every physical line.
5. Line-range replacement that dropped a sentence tail → dangling fragment,
often mid-parenthesis.
Checks (fast, run all):
```bash
head -3 <file> # must be `---` if the page has frontmatter
grep -c '^---$' <file> # exactly 2 (opener + closer)
grep -n '^## ' <file> | sort -t: -k2 | uniq -f1 -d # duplicate sections
grep -rnE '^\+- |^- - |<\|' <files> # artifacts
python3 scripts/check-vault-links.py <vault-dir>
./tests/run.sh # docs completeness seam etc.
```
Repair order (matters):
1. If the file was destroyed rather than edited: stash the agent version to
`.AGENTTRASH/<YYYYMMDD-topic>/`, then `git checkout HEAD -- <file>` and
re-apply only the intended changes by hand.
2. If it merely duplicated: **salvage unique content first** — cut the
one-of-a-kind section, paste it into the copy you keep, THEN delete the
duplicate ranges. Never delete first.
3. Re-add missing sentence tails (insert before the orphaned fragment),
keeping list continuation lines indented.
4. Re-run all checks above.
Gotchas: agents can write AFTER they appear finished (job settles, file
changes) — re-verify structure after the last background job reports, not
before. Editing an agent's already-damaged file with fresh line numbers is
safe, but its snapshot tag changes on every edit — re-read before each
subsequent edit. `.AGENTTRASH/` is gitignored: trash is local-only, so
nothing moved there reaches the repo — that is intended (temporary hold, not
archive).
## Hashline edit recovery
Failure mode: multi-op edits resolve every `PUT N.=M:` / `CUT N.=M:` index
against the **original** file numbering, and the post-apply diff view
**elides lines** (it shows an anchor window, not the whole file). Two traps
follow:
1. A second edit call built from a diff-view snapshot uses line numbers from
a *partial* view. Lines outside the window are invisible but still
counted.
2. If the first apply was itself wrong, the file shifted. The diff view
tells you the damage, but not the true offsets.
Result: the repair pastes fresh text **beside** stale text instead of over
it.
Procedure:
1. **Never carry line numbers across calls.** Re-read the target range for a
fresh tag before every subsequent edit. A read is cheap; a duplicated
paragraph costs a commit and a revert.
2. **One op per call when the previous apply was imperfect.** Fixed offsets
are only trustworthy when every preceding apply landed exactly as
specified.
3. **Re-read after every apply**, and verify the whole affected region — the
diff view is a sample, not proof. Check the *boundaries*: the line before
the edit and the line after it. Damage shows up as a repeated sentence
straddling the seam.
4. **Repair by CUT, not by re-PUT.** Once you know the exact stale lines
from a fresh read, delete them. Do not try to PUT a corrected superset
over a range whose extent you are guessing at.
5. Verify with a content assertion, not a diff review: `grep -c` for the
duplicated sentence, plus a syntax/compile check on any touched script.
Same rule for any imperative `edit` tool with an old/new match: the matched
text must be the **entire** text being replaced. Matching a bare fragment
inside a paragraph splices the replacement into the middle of the sentence
and silently corrupts the file. Re-read the file afterwards to confirm.
### Anchored in-page links
Renaming a Markdown heading breaks `[[#Old Heading]]` anchors. When a
section is retitled, grep for the old heading text and fix every anchor in
the same pass — doc link-checkers frequently do not validate `#fragment`
targets, so a green suite will not catch a dangling one.
## Related
- [[OMP Harness]] — the session context these repairs protect
- [[Command Center Dotfiles]] — the repo these edits commit to
- [[Testing Policy]] — the seams that catch broken vault structure
@@ -0,0 +1,87 @@
---
type: reference
title: Command Center Dotfiles
description: The ~/.omp repo is itself the deployment — stow-equivalent packages, full-replica rsync transport, the pre-overwrite audit for non-blank hosts, deploy.sh mechanics, and the fish/sshpass transport gotchas.
tags: [dotfiles, omp, deploy, rsync, blankpad]
timestamp: 2026-10-05T00:00:00-07:00
---
# Command Center Dotfiles
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 the repo via `git -C
$(dirname $(realpath $0)) rev-parse --show-toplevel`, so `.git` must be
present at the 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 the package dir via rsync, then run deploy sync — override deploys
LAST and win 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 packages load 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 (all bit live)
- Resolve the target via mDNS FQDN (`avahi-resolve -4 -n <host>.local`); a
bare hostname may not resolve. Drop-not-refuse on port 22 = sshd down or
firewall default-drop, not auth failure — needs a local/console fix on
target.
- `SSHPASS='...' sshpass -e ssh ...` inline per invocation; an env passed
via the tool's env parameter did not propagate to the sshpass child
process.
- The remote login shell may be **fish**: never send bash syntax
(`for/do/done`, `$?`) bare through `ssh host 'cmd'`; wrap as `bash -c
"..."` or ship a script. See [[SSH and Remote Access]].
- 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
an 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.
## Related
- [[SSH and Remote Access]] — the transport mechanics this deploy rides
- [[Noctalia and Niri]] — the desktop packages this repo ships
- [[Deploy State Triage]] — the serverAppConfig deploy flow (different repo)
+262
View File
@@ -0,0 +1,262 @@
---
type: reference
title: OMP Harness
description: The omp agent harness as configured on this fleet — compaction chain arithmetic, per-model sampler routing via compat.extraBody, and the static-preamble/skill-library audits that keep session context lean.
tags: [omp, harness, compaction, models-yml, context, samplers]
timestamp: 2026-10-05T00:00:00-07:00
---
# OMP Harness
The harness reaches the fleet through `~/.omp/agent/models.yml` (model
registry; single source of truth — there is no copy on the servers) and
`~/.omp/agent/config.yml` (roles, compaction, global samplers). Nothing in
this file is repo-managed; edits are harness-side only.
## Compaction chain arithmetic (verify before ANY window change)
When a fleet endpoint's context grows past its harness-declared window, OMP
compacts by sending the conversation to a designated target model. Before
changing any `contextWindow`/`maxTokens`, verify the chain arithmetically —
a target whose window is smaller than the source's trigger point silently
breaks compaction.
Where the contract lives:
- `~/.omp/agent/config.yml` → `compaction.remoteEndpoint` (target URL),
`methodOrder`, `thresholdPercent` (70), idle settings
- `~/.omp/agent/models.yml` → per-model `contextWindow`, `maxTokens`,
`compactionModel` references
- Implementation: omp plugin `dist/core/compaction/compaction.js`
The arithmetic (the coupled contract every endpoint's maxTokens comment
encodes):
1. **Source trigger** = `thresholdPercent × source contextWindow`
(0.70 × 200,704 = 140,492 for frank-primary on the current shape)
2. **Output budget** = capped at the target model's `maxTokens`
(32,768 for frank-small; 36,864 frank-primary; 57,344 frank-large — the
largest, because halogen's `HALOGEN_MAX_TOKENS_CAP` must stay ≥ it)
3. **REQUIREMENT**: prompt (≤ trigger) + budget ≤ the target's per-slot
window. Worked pairs from the live registry: 140,492 + 36,864 = 177,356 ≤
200,704 (frank-primary); 140,490 + 57,344 = 197,834 ≤ 200,700
(frank-large); 140,492 + 32,768 = 173,260 ≤ 200,704 (frank-small).
Procedure (recompute from the LIVE files every time — windows rotate with
every swap; the numbers above are the 2026-10-05 state):
1. Read the `config.yml` compaction block + `models.yml` entries for source
and target. Every endpoint's `contextWindow` comment carries its own
arithmetic — read them.
2. Read the target checkpoint's `max_position_embeddings` (native window) —
the served window may be a concurrency contract, not the native limit
(`--parallel N` DIVIDES the pool; the per-slot number is the contract).
3. If native ≥ trigger + budget, reslice the pinned pool:
`MAX_RUNNING × CONTEXT_LENGTH ≤ MAX_TOTAL_TOKENS` (the pin exists because
the runtime's autofit leaves too little for deferred loads + prefill
workspace; check `nvidia-smi` headroom — expect ~0.8 GB workspace margin).
4. Changes: host env (CONTEXT_LENGTH-style env tunables) via git →
`./tests/run.sh` → deploy; harness `models.yml` directly (no repo copy).
5. Verify: `/health` 200, boot log's pool line, then a long-prompt probe at
≥ trigger height (the compaction-fit guarantee), then concurrent requests.
Known reconciliation trap: comments in models.yml have described compaction
as running "on the dankserver endpoint" while `remoteEndpoint` pointed at
dankclaw — the comment/config disagreed. The comments now name the real
target (`compaction.remoteEndpoint` = dankclaw :7771). **The config is
truth; fix comments in the same change.** `maxTokens` never changes for a
reslice — it caps output; `contextWindow` caps input. Boot-shape OOM
laddering is a different problem ([[Llama Cpp Serving Sizing]]).
## Per-model sampler config (`compat.extraBody`)
`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.
```yaml
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
```
Use **wire names**, not OMP camelCase. Two traps:
1. **`preserve_thinking` is not user-settable as a compat key.**
`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`** where a compat policy
also sets it — `Object.assign` is shallow and replaces the whole object,
silently dropping the `reasoning_effort` kwarg the compat policy set.
Top-level `preserve_thinking` is honoured by halogen. (frank-primary's
FreeToken entry currently carries a `chat_template_kwargs` extraBody with
a comment asking to verify-and-prune — the exact trap to check at that
endpoint's next swap.)
**Gotcha: silent greedy decode.** halogen has **no server-side sampler
defaults** — `temperature` absent or `0` = greedy. If OMP sends nothing, the
endpoint decodes greedy while looking configured. This is why frank-large's
entry carries the recipe on the wire and a comment saying "do not drop it".
**Reasoning-effort folding.** Qwen3.8's template accepts only
`{xhigh (default), medium, low}` for `reasoning_effort` and 400s on anything
else ("Unexpected reasoning effort high"). Every endpoint's
`reasoningEffortMap` folds all harness levels onto that set; a role whose
level is unmapped fails on its FIRST request, and the failure names Jinja,
not the model.
### 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`.
## Static-preamble audit ("the context feels bloated")
What loads statically every session (measure each, don't guess):
1. Repo rules file — `wc -c AGENTS.md` (baseline 2026-10-05: 72,179 B, ~18k
tokens — historically the largest block).
2. Skill index — only names+descriptions load; bodies are on-demand. Sum
description bytes across all SKILL.md frontmatter:
```bash
python3 - <<'EOF'
import glob, re, os
for root in [os.path.expanduser("~/.omp/agent/managed-skills"),
os.path.expanduser("~/.omp/agent/skills"), ".omp/skills"]:
n=tot=0
for sk in glob.glob(root+"/*/SKILL.md"):
fm=re.search(r'^---.*?---', open(sk, encoding="utf-8", errors="replace").read(), re.S)
d=re.search(r'description:\s*(.*(?:\n(?:\s{2,}.*)*)?)', fm.group(0) if fm else "")
n+=1; tot+=len(d.group(1)) if d else 0
print(root, n, tot)
EOF
```
Baseline 2026-10-05: 171 managed skills / ~38 KB descriptions + 9 user
skills.
3. Memory lessons — `find ~/.omp/agent/memories -name 'MEMORY.md' -o -name
'memory_summary.md' | xargs wc -c`. The injected block ≈ the largest
MEMORY.md.
4. Harness/system prompt + tool schemas + CLI persona: fixed cost, ~20 KB;
not prunable.
Known rot patterns:
- Near-duplicate auto-captures of the same lesson (three "authentik ORM"
entries, the freetoken-build pair, the three job-runner skills) — dedupe
via `manage_skill delete`/`update`.
- Corrupted captures containing stray CJK characters (`一团ly`, `等方法`)
mid-sentence — a known defect of the auto-capture path; delete the corrupt
copy, keep the clean one.
- Stale host/service names in consolidated memory (pre-rename
`frank-default-large`) — the repo's AGENTS.md topology table is ground
truth.
Fix strategy: AGENTS.md prose (measured runbook narratives) → move to
`serverAppConfigs-docs/LLM/` runbooks, keep laws + tables in AGENTS.md (the
repo's own docs-tree rule mandates this). Managed skills live in
`~/.omp/agent/managed-skills` (safe to edit); user-authored skills in
`~/.omp/agent/skills` are NEVER touched by tooling. Memory store edits:
stage removed captures to trash first (`~/.AGENTTRASH/YYYYMMDD-topic`).
Baseline to beat (2026-10-05): ~35–40k tokens of preamble — AGENTS.md ~18k,
skill index ~10k, lessons ~3.5k, harness ~6k. Proposed reductions: −12k
(AGENTS.md slimming), −3k (skill dedupe).
## Managed-skill library hygiene
Use when auditing, pruning, or consolidating `~/.omp/agent/managed-skills`
(the auto-captured skill store; the full index is injected into EVERY
session's context, so dead or duplicate entries are a permanent tax).
The static-context budget (measured 2026-10-05):
| Block | Size | Notes |
|---|---|---|
| `AGENTS.md` (repo) | 72,179 B ≈ 18k tok | biggest single item |
| Skill index (~173 skills) | ~40 KB ≈ 10k tok | names+descriptions only |
| Learned-lessons block | ~14 KB | can contain corrupted/duplicate captures |
| Harness system prompt + tool schemas | ~23 KB | fixed |
Audit procedure:
1. Build an inventory: per skill, name, mtime, body word count, description,
first 12 headings (one pass, out to a file; read in pages).
2. Group into: retired-runtime (host/service gone; its runbook already lives
in `serverAppConfigs-docs/LLM/`), verbatim-duplicate pairs/triples (same
lesson captured twice — often the SAME DAY), same-lesson families
(N skills = one procedure), keep.
3. Read full bodies of every merge candidate before proposing a merge;
description alone proves overlap only for exact dupes.
4. Present A/B/C/D verdicts for user approval. NEVER execute before the
discussion concludes — the user gates execution explicitly.
5. On approval: stage everything to trash first
(`~/.AGENTTRASH/YYYYMMDD-<topic>/`), never rm. After merging: recreate
the surviving skill via `manage_skill` update with body = superset,
delete absorbed ones.
Known rot classes (this library, 2026-10-05):
- Same-day duplicate captures (freetoken-build-dankclaw vs
freetoken-dankclaw-build; the three job-runner skills).
- CUDA-skill vs its self-declared HIP twin (pcie-link-capability-probe vs
pcie-link-probe-hip-rdna4) → merge.
- Era ghosts: skills naming retired runtimes (freetoken, lucebox, r9v/
radiance, old halogen-flash-server image, ik_llama, GLM-5.3 experiment).
- Skills contradicting user standing orders (live-bench-dashboard vs the
2026-09-29 delete-dashboards order) — flag for user, don't assume.
- Memory store: corrupted captures with CJK noise from broken ssh commands;
stale host/service renames.
Sizing verdicts that worked: bench family → 2 skills (harness vs
discipline), not one mega-skill; keep profiler/kernel-time attribution
standalone; fold go/no-go arithmetic skills into their probe skill
(heterogeneous-accelerator-split-triage → PCIe probe).
## Related
- [[Agent Docs and Edits]] — editing vault docs and hashline targets safely
- [[Command Center Dotfiles]] — the `~/.omp` repo deploy this config rides
- [[Llama Cpp Serving Sizing]] — the engine-side sizing the chain must fit
- [[LLM Bench Windows and Measurement]] — measuring the endpoints this config routes
+23
View File
@@ -0,0 +1,23 @@
---
type: index
description: Pages about the agent harness itself — omp config, vault editing discipline, and the dotfiles repo that deploys the agent's own home.
tags: [agent, index]
timestamp: 2026-10-05T00:00:00-07:00
---
# Agent
Pages about the agent's own tooling and context — how omp is configured, how
docs get edited safely, and how the agent's home is deployed.
- [[OMP Harness]] — compaction chain arithmetic, per-model samplers via
`compat.extraBody`, preamble and skill-library audits
- [[Agent Docs and Edits]] — doc-structure audits after parallel edits;
hashline edit recovery
- [[Command Center Dotfiles]] — the `~/.omp` repo-as-deployment, rsync
transport, override packages
## Related
- [[Testing Policy]] — the seams these pages' checks ride
- [LLM](../LLM/index.md) — the fleet these agents operate
@@ -0,0 +1,151 @@
---
type: reference
title: Deploy State Triage
description: Reading and reconciling the deploy-state markers — stale marker vs live container, pending-service lockout, interrupted/raced deploys — without restarting healthy heavy endpoints.
tags: [deployment, deploy-state, systemd, podman, triage]
timestamp: 2026-10-05T00:00:00-07:00
---
# Deploy State Triage
The deploy-state store is `~/.local/state/serverAppConfigs/deploy-state/<svc>`
on each target (rootless, user `dank`, format exactly two lines
`revision=<sha>` / `status=deployed|pending`, written atomically via tmp file
+ `mv`). It is written ONLY by a `deploy.sh` dispatch.
## 1. Stale marker vs live container
**Why it happens:** an out-of-band relaunch — reboot, `systemctl restart`, or
a restart after the checkout ff-pulled to a newer revision — starts the
container from the CURRENT `scripts/<svc>.env` but does NOT advance the
marker. So `marker != HEAD` means "not dispatched via deploy.sh", NOT
"behavior pending".
**Why it matters:** a plain `deploy.sh` run re-selects and restarts a service
whose marker lags. On llama.cpp/R9V endpoints that is a ~16–19 min reload of
a 100+ GiB payload — and frank-primary (dankserver:7770) is also the
harness's own model backend, so a blind deploy is a self-inflicted outage for
possibly zero behavioral change.
**Procedure (read-only first):**
1. Committed state: `git log --oneline main..<branch>` empty ⇒ merged; the
operational delta is usually one `scripts/<svc>.env`.
2. Probe the target (ssh as `dank`, passwordless sudo present):
- `cat ~/.local/state/serverAppConfigs/deploy-state/<svc>`
- `git -C ~/serverAppConfigs rev-parse HEAD`
- `systemctl is-active/is-enabled <svc>`;
`curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:<port>/health`
- the on-host env file already carries the new keys (it landed via the
ff-pull).
3. **Decisive — read the LIVE container, not the marker.** Use `podman` as
`dank` (rootless store; bare `docker` is NOT the shim → "No such object";
`sudo podman` inspects an empty store):
- `podman inspect <ctr> --format '{{.State.StartedAt}}'` — booted AFTER
the pull ⇒ it read the new env
- `podman inspect <ctr> --format '{{.Args}}' | tr ' ' '\n' | grep -i <flag>`
- engine env: `podman exec <ctr> sh -c 'for f in /proc/[0-9]*/environ; do
tr "\0" "\n" <$f 2>/dev/null; done | grep -i <VAR>'`
- confirm the launcher maps each tunable to its flag
(`grep scripts/launch.sh`), e.g. `R9V_ENABLE_PREFIX_CACHING=1 →
--enable-prefix-caching`.
**Decision:** live argv+env == target config AND `/health` 200 ⇒ behavior is
live — **do NOT restart**; reconcile the marker (below). Only if the live
container lacks the flag/env (or predates the pull) is a real
`deploy.sh <target> --service <svc>` warranted.
**Reconcile (no restart)** — mirror the deployer's atomic idiom:
```bash
S=$HOME/.local/state/serverAppConfigs/deploy-state
H=$(git -C ~/serverAppConfigs rev-parse HEAD)
t=$(mktemp "$S/.<svc>.XXXXXX"); printf 'revision=%s\nstatus=%s\n' "$H" deployed > "$t"; mv -f -- "$t" "$S/<svc>"
```
Then prove the gate: `./tests/run.sh` + `tests/e2e/run.sh` (needs
`tests/e2e/.env.local`; expect the documented `11-admin` red if the
FrankBook-Pro Mac mini 192.168.0.112 is ASLEEP — confirm with `ping -c2` +
a `/dev/tcp` probe: 100 % loss + timeout = host off, not a regression).
Log to `serverAppConfigs-docs/log.md`; commit + push (docs-only ⇒ deploy
no-op).
**Anti-patterns:** trusting a commit message ("X deployed") or the marker
alone — both are bookkeeping; the container's argv/env is ground truth.
Reading container state with `sudo podman` / bare `docker`. Restarting a
healthy heavy endpoint to satisfy a stale marker.
## 2. Pending lockout
Symptom: `deploy.sh <target>` fails or silently stops with
`service <name> is not deployed on <target>` and the `[3/3]` e2e gate never
appears.
1. **Get the real exit code first:** `./scripts/deploy.sh X 2>&1 | tail`
reports TAIL's 0 — use `set -o pipefail` or redirect to a file, then
`echo $?`.
2. Inspect the state store (no sudo):
`ssh <target> 'cat ~/.local/state/serverAppConfigs/deploy-state/<name>'`.
A `status=pending` entry for a service absent from `service_is_allowed`
in `scripts/deploy-remote.sh` = retired-service lockout.
3. Fixed behavior (since 02a7876, 2026-09-17): `add_pending_services` prunes
not-allowed pending entries to
`~/.AGENTTRASH/<YYYYMMDD>-retired-deploy-state/<service>` and the run
continues. Verify the WARN line names the service and the state file is
gone.
4. On a host without the fix: move the stale entry to
`~/.AGENTTRASH/<date>-retired-deploy-state/` manually (never `rm`), re-run.
5. Verify: deploy reaches `[3/3] Running Playwright e2e gate` and completes;
`deploy-state/` no longer holds the retired name.
Related contracts: the superseded seam
(`deployment_scoping.bats` "remote runner recognizes a pending service at
the same revision") now asserts prune-and-continue (exit 0), not exit 2;
`deployment_flow.bats` extracts `add_pending_services` via sed and
HOME-overrides the trash path. **When retiring a service going forward:**
delete or reconcile its deploy-state entry as part of the retirement commit —
the prune is a safety net, not the intended cleanup path.
## 3. Interrupted or raced deploys
The deploy is TWO processes: `deploy.sh` (dev box) and `deploy-remote.sh`
(runs on the target over ssh). **Killing the dev-side job does NOT kill the
host-side one** — two `deploy-remote.sh` processes can end up managing the
same unit, one deploying a revision you already superseded. Check:
`pgrep -af "deploy-remote"` on the target, read the revision argument on
each, kill stale ones by explicit PID.
A killed deploy leaves behind:
- the unit **stopped/failed** (`Restart=always` does NOT fire after an
explicit stop — start it yourself);
- the state marker **pending** (pre-deploy or in-flight revision);
- the e2e gate un-run (the deployer runs it last).
Reconcile the marker ONLY after the service itself is verified healthy — a
marker that runs ahead of reality is worse than one that lags (the planner
uses it to decide what is pending). Then close the loop:
1. Verify against MEASURED, config-specific numbers (KV capacity line, arena
slots, argv flags, `NRestarts`), not just health 200.
2. Run the gate the dead deploy skipped (`tests/e2e/run.sh` from a checkout
that has the gitignored `tests/e2e/.env.local` — a fresh `git worktree`
does NOT have it; copy it in first).
3. If the killed revision was restoring a known-good shape, confirm the
numbers match the last measured-good boot of that shape — equality is the
proof, not health alone.
Boot failures must be read from the journal, not `podman logs`: serve scripts
`podman rm -f` the container on every start (previous attempt lost), and
`podman logs` on a container still *Stopping* returns the OLD configuration's
numbers — which looks exactly like your change having no effect. Key on
container identity (`podman ps -a --format '{{.CreatedAt}}'`) and read the
cause from `journalctl --since "N min ago" | grep -a "<container-name>\["`.
See [[vLLM Boot Forensics and Config Interrogation]] for the full capture
procedure.
## Related
- [[Deployment Flow]] — the deploy pipeline itself
- [[Quadlet Lifecycle]] — unit ownership and drift diagnosis
- [[vLLM Boot Forensics and Config Interrogation]] — journal capture
@@ -0,0 +1,209 @@
---
type: reference
title: Quadlet Lifecycle
description: The full Quadlet discipline on this fleet — ownership auditing, compose-to-Quadlet cutover, drift/crash-loop diagnosis, the podman 5.x v1 generator quirks, log retrieval, and compose bring-up with ground truth.
tags: [podman, quadlet, systemd, boot-persistence, compose, drift]
timestamp: 2026-10-05T00:00:00-07:00
---
# Quadlet Lifecycle
Fleet rule (AGENTS.md "Boot Persistence & Self-Healing"): every deployed,
working container MUST have a self-healing, boot-persistent Quadlet unit —
`.container` file in the repo (`<host>/<svc>/quadlet/`), installed by the
deployer, `[Install] WantedBy=default.target`, `Restart=always`, linger on.
A container that runs only from compose dies silently at reboot. This page
is the operating manual for that lifecycle.
## 1. Ownership audit — find the containers that will not survive a reboot
```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
```
- **label present** → the unit owns it; persistence = `[Install]` + `loginctl
show-user <user> -p Linger` = yes.
- **label absent, compose service exists** → the classic gap: dies at reboot.
Check the publish address while there — compose forms often bind the
wildcard (an unauthenticated UI/API on every interface).
- **label present AND a compose service with the same name** → two owners; a
stray `podman compose up` can create a duplicate that fights the unit for
the name and the port. Remove the compose service, leave an explanatory
comment (repo convention).
## 2. Cutover: compose service → Quadlet unit
Checklist (precedents to copy:
`dankclaw/{caddy,gitea,searxng,open-webui,seerr}/quadlet/`,
`dankclaw/observability/quadlet/`):
1. Unit file at `<host>/<svc>/quadlet/<name>.container`, mirroring compose
line for line: `Image=` (`localhost/` prefix for locally built images +
`Pull=never`), `ContainerName=` (Caddy and scrape configs address
containers by name), `Network=`, `User=0:0` when the image's own user
cannot write a rootless-owned bind mount, `Volume=` (`:ro` preserved),
`Exec=` for compose `command:`, `PublishPort=`, health keys,
`[Service] Restart=always` + `RestartSec=10`, `[Install]
WantedBy=default.target`. **Never `systemctl --user enable` the generated
unit** — `[Install]` + linger IS the persistence (`is-enabled` = `generated`
is healthy; explicit enable fails with "transient or generated").
2. Exposure: publish on `127.0.0.1` unless something off-host genuinely needs
it (Caddy reaches containers by name over the shared network; the wildcard
publish is a guard-chain bypass).
3. Handler in `scripts/deploy-remote.sh`: `install_quadlet <dir> <name>` →
`systemctl --user restart <name>.service` → `wait_for_health`; retire any
legacy hand-written unit into `~/.AGENTTRASH/` (a leftover
`~/.config/systemd/user/<svc>.service` that shells out to
`podman compose up` collides BY NAME with the generated unit);
`podman rm -f <container>` so the unit's container can take the name.
4. Planner: map `<host>/<svc>/**` → service in `scripts/deploy-plan.py`
AND add a `service_is_allowed` entry; keep `SERVICE_ORDER` dependencies
ordered. Add a `test-changed` case above the generic
`*/quadlet/*.container` fallback.
5. Retire the compose entry in the SAME change — two owners is how a stale
duplicate comes back.
6. Seam test `tests/<svc>_quadlet.bats`: boot persistence, restart policy, no
wildcard publish, container name matches Caddy/scrape references, health
command, deployer installs+restarts+waits, never `rm`, never `enable`.
Migrate any older assertion that read the compose file for devices/flags.
7. Deploy `--service <name>`; prove: `is-active`/`is-enabled` =
active/generated, container `healthy`, endpoint answers,
`list-dependencies default.target` shows the unit, off-host probes on
previously published ports now refuse, and the container no longer carries
`io.podman.compose.project`.
**Traps that cost real time:**
- `HealthCmd` = CMD-SHELL (podman runs `/bin/sh -c`); a distroless image has
no shell → the check fails forever. `--health-cmd` won't take exec form
either. 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` — it is a
gluetun env carrying `HOME=/root`; systemd sets HOME for the service and
the unit dies with `stat /root/.config: permission denied`. Use a
per-service env under `/home/dank/Containers/<svc>/env`, seeded by the
handler with a single-variable filter, validated so a missing secret fails
loudly.
- `Network=host` exporters scraped by containerized Prometheus must bind the
host's LAN address (containers cannot reach host loopback) PLUS `127.0.0.1`
for the homepage probe — two `--web.listen-address` flags, never the
wildcard.
- alloy 1.x refuses the `ui` config block; the unit must pass
`--server.http.listen-addr=0.0.0.0:12345` before its port can be published.
- GPU services: `AddDevice=` + `GroupAdd=keep-groups` (user-unit spawns carry
no supplementary groups).
- When the last service leaves a compose file, the compose-validity seam
fails (requires ≥1 service): keep the file as comments and relax the seam
deliberately, or delete the file and fix the planner's compose-diff path —
decide with the owner, the deploy tooling reads it.
## 3. Drift / crash-loop diagnosis (5 minutes, in order)
Symptom: crash-looping or perpetually unhealthy container whose DEFINITION
looks right; or a service quietly down for days because a restart policy kept
something running. Cause: the running container was created from an OLDER
definition — a compose/unit edit does not touch an existing container, and
the restart policy faithfully resurrects it forever.
1. **Who started it:** `podman inspect <name> --format 'unit={{index
.Config.Labels "PODMAN_SYSTEMD_UNIT"}} restart={{.HostConfig.RestartPolicy.Name}}
created={{.Created}}'`; compare `created` with when the definition changed
(`git log -p --follow <config>`). Created-before-the-edit is the signature.
2. **What it is ACTUALLY running:** `{{.Config.CreateCommand}}` is the truth.
`{{.HostConfig.Devices}}` / `{{.HostConfig.GroupAdd}}` print empty/0 even
for containers started with `--device`/`--group-add` — a false negative
that sends you hunting the wrong root cause.
3. **What the process says:** `podman logs <name> | grep -iE 'error|cannot|Runtime'`
(a capability error like `No HIP GPUs are available` beside a missing
device flag confirms drift). Count the damage: `{{.RestartCount}}` — one
instance reached 9,146 restart attempts on stale config.
Quickest unblock: `podman compose up -d --force-recreate <svc>`. Durable fix
= the §2 cutover. **Verify the capability, not the status:** `podman exec
<name> python3 -c "import torch; print(torch.cuda.is_available())"`;
`ss -ltn | grep <port>` for the actual bind addresses. ComfyUI incident:
2026-09-16, stale device-less container, 9,146 restarts. History repeats:
2026-08-26 Caddy (no unit), 2026-09-11 five observability services.
## 4. Podman 5.x quadlet v1 generator quirks (verified live 2026-08-28)
- **Hard-fail keys** — the whole unit silently fails to generate
(`systemctl --user cat <unit>` shows "could not be found"): any
dash-prefixed `[Container]` option (`--shm-size=…` → `unsupported key …
in group 'Container'`); `Command=` is unsupported, use `Exec=`.
- **HealthCmd space-splitting:** the v1 generator splits `HealthCmd` on
spaces into the healthcheck argv; a quoted probe
`/bin/sh -c "pg_isready …"` lands as argv token `"pg_isready` →
command-not-found → container reads **unhealthy while the DB is up**.
HealthCmd must contain only space-free tokens
(`HealthCmd=pg_isready -d authentik -U authentik`). If health reads
unhealthy but logs look fine, compare the baked
`{{.Config.Healthcheck.Test}}` array against the intended command before
believing the status.
- **Probe a new key before shipping:** drop a throwaway `probe.container`,
`daemon-reload`, `journalctl --user --since "1 min ago" -o cat | grep -i
"unsupported key"`, `systemctl --user cat probe.service` (shows the
generated ExecStart verbatim), then remove + reload.
## 5. Rootless Quadlet log retrieval
Step 0 — establish ownership: `systemctl --user status <name>.service`
(works ⇒ rootless user unit); `loginctl show-user <user> -p Linger`. If
`--user` knows the unit, EVERY command runs as that user with NO sudo.
| Need | Command |
|---|---|
| Current instance tail/follow | `podman logs --tail 200 / -f <name>` |
| Across restarts/recreates | `journalctl --user -u <name>.service -n 200 --no-pager` |
| Time-bounded / crash-only | `--since "14:30"` / `-p warning` |
| Kernel-side evidence (OOM, SVM) | `sudo journalctl -k --since …` |
Quadlet units commonly run `--replace --rm`: **`podman logs` is destroyed on
recreation** — the journal is the only durable record. Traps:
`sudo podman logs` → "no such container" (root's store does not contain
rootless containers; same for `sudo podman ps`); a system-level
`journalctl -u <name>` returns nothing for a user unit; during first-start
weight downloads health reports unhealthy BY DESIGN (check the payload file
is growing). Verbose vendor switches are a config change — through git and
deploy.sh, never a live env poke.
## 6. Compose bring-up with ground truth (rootless hosts)
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. Compare
compose mount sources against the real filesystem — compose files name
phantom paths (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** (`sudo mkdir -p` + `chown 1000:1000`)
or creation fails `statfs …: no such file or directory`.
3. **Staged bring-up:** infra first (`up -d mariadb redis`), poll
`{{.State.Health.Status}}`, then the rest. Fresh containers answer
000/refused for the first seconds — poll.
4. **Never guess image tags:** 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 auth). Query the registry API
(`ghcr.io/token?scope=repository:<ns>/<repo>:pull` →
`/v2/<ns>/<repo>/tags/list`).
5. **Read the interface from inside the image** before guessing flags/envs:
`podman exec <ctr> cat /z/cpp.sh` etc. HTTP 500 with a styled error page
means RUNNING-but-misconfigured, not down. Copyparty ac ground truth:
entrypoint `/bin/ash /z/cpp.sh -c /z/initcfg`; accounts/volumes are ARGV
flags (`-a USER:PASS`, `-v SRC:DST:PERMS`), not envs — and with none
configured it serves cwd read/write for everyone (an unauthenticated
world-writable share).
6. Remote-shell quoting: zsh eats bare `===`/`?` tokens; a zero-match from a
mangled pattern is not absence — re-verify the target exists.
7. `user: "1000:1000"` SIGSEGV at boot = unmapped uid in the rootless userns
(host 1000 == container 0): drop the user line, run as container-root.
## Related
- [[Deployment Flow]] — the deployer that installs these units
- [[Stale Signal Audit]] — the audit that catches drift's victims
- [[Container Network Namespaces]] — reachability between containers
- [[Dankclaw/ComfyUI]] — the 9,146-restart incident
- [[Infrastructure/Observability]] — same class, twice
@@ -0,0 +1,144 @@
---
type: reference
title: Stale Signal Audit
description: The audit battery for surfaces that rot without failing — unreachable probes, vacuous assertions, invalid checkers, gates that never run, and prose invalidated by a change. Four passes found real defects every time.
tags: [testing, observability, audit, signals, gates]
timestamp: 2026-10-05T00:00:00-07:00
---
# Stale Signal Audit
Run after any change and before trusting a green gate. The productive question
is **which signal here cannot possibly be correct?** Every pass on this repo
(4 of 4) found real defects — including one in the rule file every future
agent reads first.
## The surfaces, and what each vantage point can reach
| Surface | Producer's vantage point | Can reach |
|---|---|---|
| Homepage status cards | the `homepage-status` timer on **Dankclaw** | Dankclaw's loopback, Dankclaw container names, **published** LAN addresses |
| Prometheus scrape jobs | the `prometheus` container on Dankclaw | container names on `dankclaw_default`, LAN addresses, that host's loopback only |
| bats / e2e suites | the dev box | the repo, plus the live fleet over HTTPS/LAN |
Anything else — another host's loopback, a container-internal port never
published — is unreachable no matter how healthy the service is. The failure
mode is always the same shape: a signal that **cannot** succeed by
construction, which teaches the operator to ignore the surface it lives on.
## 1. Permanently-red probes (reachability)
For every monitor/probe/alert target ask: *can this checker physically reach
that endpoint?* Seen classes: a card probing another host's container-internal
port; a "probe" job scraping `/` (HTML → `unsupported Content-Type`); a
`/metrics` that is disabled (Gitea 404).
- A scrape cannot express "is this HTTP service answering?" — the right probe
is the **blackbox exporter** in probe mode (which also gives guarded vhosts
a real uptime signal, since Prometheus cannot solve an Anubis challenge).
- **Fix in the direction of honesty, never of silence:** unreachable target →
mark the homepage card `wip: true` with the reason in `description` (the
checker skips `wip`; `wip` is the existing convention for "real service,
not probeable"), or repoint the job at something reachable. NEVER open a
database port on the LAN to make a badge green, and never delete a scrape
job — an absent scrape and a healthy one are indistinguishable.
- Alert rules must not depend on always-red signals — scope them to services
that can actually be healthy (`LlmEndpointDown` is scoped to `frank-*` for
exactly that reason).
- A target's failure STRING can prove a fix: a `/metrics` scrape flipping 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.
## 2. Vacuous assertions (highest severity — the gate is already dead)
A seam that greps a file passes *by absence* once the file is retired:
`grep` exits 2 on a missing path and `!` inverts it.
```bash
grep -rn 'grep.*"\$REPO_ROOT/[^"]*"' tests/*.bats | head -50
[ -f "$path" ] || echo "MISSING: a seam may be grepping this"
```
Fix shape — one place asserts the invariant, each local site keeps a live
check:
```bats
@test "no <host> compose file competes with the quadlet units" {
local cf="$REPO_ROOT/<host>/compose.yaml"
[ ! -f "$cf" ] || { echo "$cf is back: every container is unit-managed"; return 1; }
}
# at the per-service site: still fails if the entry returns
[ ! -f "$cf" ] || ! grep -qE '^\s+<svc>:' "$cf"
```
**Prove teeth:** recreate the offending file
(`printf 'services:\n <svc>:\n image: x\n' > <path>`), run the seams,
confirm RED, then move the file to trash. A re-armed check you have never
seen fail is still unverified.
## 3. Gates that never run
Playwright specs in `tests/e2e/` are matched by **number prefix** in
`playwright.config.ts`; an unregistered spec silently never runs and the
suite count stays the same. Count tests before/after;
`tests/e2e_scaffold.bats` fails on an unregistered spec by design.
## 4. Validate the checker before acting on it
A checker's output is a hypothesis about another tool's semantics. Modelling
Obsidian with naive text matching produced 45 "broken links" of which 3 were
real — the rest were case-sensitivity (a `qBittorrent` link to
`qbittorrent.md`), path-qualified resolution (a path-qualified
`folder/Note` link resolves by path SUFFIX, not
stem), and `%20` URL-encoding in markdown links. Fix the checker first,
re-derive the report, only then edit documents. The result lives in
`scripts/check-vault-links.py` with those three rules in its docstring,
gated by the `no unresolved links in the docs vault` seam. Same class: a
`find … | head -1` selector that reads the wrong file; a truncated listing
that makes you "fix" a file that exists.
## 5. Prose invalidated by your own change
After retiring a file or adding a feature, grep for EVERY reference, not just
code:
```bash
grep -rn "<retired-file>" --include="*" . | grep -v '^./.git/'
grep -rn "no <feature>\|none —\|unconfigured" --include="*.md" --include="*.container" --include="*.yml" .
```
This catches the ones that bite: AGENTS.md said ComfyUI runs CPU-only long
after it was restored with GPU access; a unit said "there are no alerting
rules" hours after five were added; a docs page documented three scrape jobs
as working when they could never report up. **Clean cutover includes prose** —
docs, unit docstrings, and the repo's rule file (the most expensive place to
be stale).
## Order and verification
1. Enumerate the surfaces (status board, metrics targets, alert rules, test
gates, docs/rule files, dashboard panels).
2. For each, ask what the signal CAN express, not what it says.
3. Validate any checker before editing the things it flags.
4. Fix at the root; never weaken an assertion to make it pass.
5. Gate the class so it cannot return (a seam), then prove the seam fails on
the defect.
Verify: `./scripts/check-vault-links.py` (exit 1 on breaks); query each
Prometheus panel expression against the API (0 series on a panel that should
show data is a defect; 0 `ALERTS` when nothing fires is not); read the
deployed `/srv/homepage/status.json` and triage every `state != up` entry
against reality; query `/api/v1/targets?state=active` and print
`health != "up"` with `lastError`; `./tests/run.sh` → `ALL TESTS PASSED`.
Report per finding: the surface, why the signal could not have been correct,
the fix, and the red/green evidence — flag any regression you introduced
yourself in the same breath (one was found this way: a tightened bind killed
a reverse-proxied vhost while every gate stayed green, because the spec
covering that hostname did not exist).
## Related
- [[Testing Policy]] — the seam inventory this audit walks
- [[Observability]] — the scrape/probe surfaces it audits
- [[Quadlet Lifecycle]] — the ownership labels the probes read
+172
View File
@@ -81,6 +81,178 @@ The wiring lives in the live `/data/config.yaml` under `mcp_servers.chrome-devto
The npx package cache lives in the container layer, so a recreation (not just a
restart) pays one first-call npm download; subsequent calls are cached.
## Config-home drift ("cannot connect" to models that answer curl)
`podman exec <container> printenv HERMES_HOME` for each hermes container. If
the webui reads the image default (`/opt/data`) while the gateway reads
`/data`, the webui runs the image-default template config (`provider: auto` →
openrouter.ai with no key = connect failure).
The recreation trap is the same one the volumes table shows: 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.
Fix: pin `Environment=HERMES_HOME=/data` in the quadlet unit (shared state
root), deploy service-scoped. Verify with the app's own code, not guesses:
```python
from api import config as C; C.resolve_model_provider("<model-id>")
# -> (model_id, provider, base_url); bare ids map to the owning provider by
# scanning the providers: dict models allow-list; @provider:model forces one.
hermes_cli.providers.resolve_user_provider(name, cfg["providers"])
# -> ProviderDef with real base_url; accepts api/url/base_url keys.
```
Named providers (`providers:` dict) are a catalog + connection source;
`custom:<slug>` connections resolve only via `custom_providers[]` entries. If
neither holds the provider name, the request has no endpoint and fails to
connect. Probe the endpoint with the **exact served model id** — the retired
FLM/NPU endpoints routed only by exact registry tag and answered HTTP 200
with `{"error":"Failed to load <tag> model!"}` for unknown tags; status-code
checks miss this class entirely.
## Live config edits
Live file: `/home/dank/Containers/hermes/data/config.yaml` (dir `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**.
1. **Backup first:** `sudo cp -a …/config.yaml …/config.yaml.bak-YYYYMMDD`
(in-dir `.AGENTTRASH/` also exists for removals).
2. **Apply** 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:
`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
is stale. Let it regenerate lazily or trash it — 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**: one tiny completion per endpoint with the exact
served id — POST `/v1/chat/completions` and read the body, never just the
status code (endpoint names rotate; see the LLM knowledge base).
7. **Sync the repo backup** (`dankclaw/hermes/config.yaml.example`) to the
live shape, redacting secrets (`password_hash`, phone account stay
live-only), then `./tests/run.sh` before commit.
`provider: custom` + `base_url` entries route by base_url and send the model
id verbatim; named providers 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.
## Auxiliary routing against local backends
When a Hermes feature that calls an auxiliary task (e.g. title generation)
fails against a local endpoint, the usual cause is a **request shape the
local runtime does not implement**, not a routing mistake.
Diagnose with the real call path — run the actual function inside the
container with the app's own constants (the probe cannot drift from
production):
```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). The CLI at `/opt/hermes/hermes` uses system python and dies
`No module named 'yaml'`; use `/opt/hermes/.venv/bin/python`.
```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})
```
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 # names track the live config; diff first
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 config says `reasoning.enabled: false`. The
`reasoning_effort: none` *shorthand* produces exactly that and leaves the
server default (thinking ON) — the 64-token title budget gets 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` — no
proxy needed for the alternate transport.
Verify and land: re-run the probe with **no** `api_mode`/reasoning kwargs —
it must pass from config alone (that is the production path); keep the live
file and the repo example in step; `./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.
## Webui password rotation ("Hermex")
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**; probe
login with a deliberately wrong password (expect 401) and the real one
(expect 200). Details on the webui surface: [[Hermes-WebUI]].
1. Run the whole probe as a heredoc'd bash script
(`ssh -o BatchMode=yes dankclaw 'bash -s'`) — the host's zsh mangles
inline `${…}` even inside single-quoted ssh args; never interpolate curl
`-w %{http_code}` inline.
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's `[Container] EnvironmentFile=` 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 → 401, right-pw → 200, from 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 (the 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.
## See Also
- [[Hermes-Dashboard]] — the LAN browser management UI
@@ -64,6 +64,49 @@ The Ollama connection is disabled (`ollama.enable=false`).
> repository. Model swaps, context sizing, and spec tuning happen there. See
> the [LLM knowledge base](../LLM/index.md).
## Container introspection (DB and backend python)
- Working dir for imports: `/app/backend`; package name `open_webui`
(underscore), NOT `openwebui`. `os.environ["WEBUI_SECRET_KEY"]` MUST be set
BEFORE importing any `open_webui` module (import-time hard requirement);
the secret lives at `/app/backend/.webui_secret_key`.
- **The `sqlite3` CLI is NOT in the container** — use Python's `sqlite3`
module (`podman exec open-webui python3 -c "import sqlite3;
conn = sqlite3.connect('/app/backend/data/webui.db'); …"`). For config
keys use parameterized LIKE queries (avoids shell quoting).
- Backend python: `podman exec -w /app/backend open-webui python3 -c "…"` —
`Models.get_all_models()` is a coroutine (await it); model objects are
pydantic `ModelModel` instances (attribute access, not `.get()`), with NO
`status`/`enabled` attribute — use `is_active` from the DB.
- **API auth for verification:** `create_token` lives in
`open_webui.utils.auth` (NOT `open_webui.utils.utils`);
`get_verified_user` requires an `id` claim (not just `sub`);
`SESSION_SECRET = WEBUI_SECRET_KEY`. Mint an admin token inside the
container:
```
podman exec -i open-webui sh -c 'export WEBUI_SECRET_KEY=$(cat /app/backend/.webui_secret_key); \
python -c "from open_webui.utils.auth import create_token; \
print(create_token({\"id\":\"<UID>\",\"sub\":\"<UID>\",\"email\":\"…\",\"role\":\"admin\"}))"'
```
(admin UID from the `user` table).
## Verification battery
1. `GET /api/v1/models/list?limit=100` → returns EXACTLY the intended
models. Response key is `items`, not `data`.
2. `POST /openai/chat/completions` per model with a trivial prompt ("Reply
with exactly one word: pong") → each returns content `pong`. Reasoning
models need `max_tokens>=100` — the thinking block eats the budget.
3. A 500 "Server Connection Error" → `podman logs open-webui` for
`ClientConnectorError`: a network-reachability problem (host LAN IP vs
`host.containers.internal`), not a model problem.
**Durability:** the model table + connections are DB-only (not git-managed);
a fresh data volume loses them. For reproducibility, add an idempotent
first-start seed script or document the setup in the service doc.
## Web search
Chat web search is served by [[SearXNG]] on this host, wired in the quadlet
@@ -147,6 +190,23 @@ The quadlet carries a built-in healthcheck (30s interval, 3 retries, 300s start
`podman ps` shows the container as `unhealthy` if either fails. Probe manually at `http://dankclaw:3005/health`.
## Image generation (ComfyUI)
Image generation rides the local [[ComfyUI]] integration, not the chat
picker — ComfyUI's models never appear in the chat model picker (that is
chat LLMs only), so ComfyUI needs its own image-generation connection
configured in OpenWebUI. ComfyUI itself runs GPU-enabled on Dankclaw and
serves `:8188`.
Endpoint: `POST /api/images/generations` (image `0.11.x`: upstream moved the
route to `POST /api/v1/images/generations`; the `comfyui` engine is still
upstream) with `Authorization: Bearer <token>` and body
`{"model":"imagegen","prompt":"…","n":1,"size":"1024x1024"}`; the response
carries base64 image data or a URL.
Failure modes: connection refused ⇒ OpenWebUI or ComfyUI is down; timeouts
on large sizes ⇒ check ComfyUI resources; 401 ⇒ the token is bad.
## Retired instances
- The compose `open-webui` service (port 3000) was never run: 3000 is Gitea's HTTP port, so it could not bind.
@@ -117,6 +117,18 @@ frank-large `health=200` throughout.
the model is loaded. Trust the engine's `host memory left for everything
else` line, not `free`.
- **Filesystem prerequisite (other hosts).** ext4 and xfs take swapfiles
directly. btrfs needs `chattr +C` (NOCOW), no compression, and no
snapshots in the swapfile path — check `df -T` before planning, or
`mkswap` will reject the file.
- **Renaming an *active* swapfile works (inode-based) but leaves a stale
path in `/proc/swaps`** — swap it off first, then rename, then `swapon`
(which is exactly what the step-3 consolidation does). Migration is
observable while it runs: in the 2026-09-12 run `swapoff` of 4 GiB took
65 s and used-RAM rose only ~3 GiB (pages touched during migration), the
rest relocating on disk. If used-RAM climbs toward the whole swapped
amount, let it finish rather than aborting.
## Trash
The previous 8 GiB `/swap.img` (dated 2026-07-21) is staged, not deleted, at
@@ -0,0 +1,303 @@
---
type: reference
title: Noctalia and Niri
description: The noctalia v5 desktop shell on niri (workstation + blankpad) — config layers, remote IPC over ssh, custom-palette recolor, offline template rendering, and output rotation/pinning.
tags: [desktop, noctalia, niri, blankpad, workstation, ghostty]
timestamp: 2026-10-05T00:00:00-07:00
---
# Noctalia and Niri
Noctalia (`/usr/bin/noctalia`) is the desktop shell on niri, running on both
the workstation and blankpad. Since the 2026-09-10 unification both run the
**bare `noctalia` v5 binary** (niri `spawn-at-startup "noctalia"`); the old
Quickshell-hosted v4 architecture (`qs -c noctalia-shell`, JSON config) is
retired on both hosts.
## Config layers
- Hand-edit declarative layer: `~/.config/noctalia/<name>.toml` (e.g.
`config.toml`). Main config: `~/.config/noctalia/config.toml`; the niri
config includes `noctalia.kdl` for display settings.
- 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.
- Main reference: `noctalia-reference` facts — IPC `noctalia msg <command>`
replaced the `qs -c noctalia-shell ipc call` wrapper; app templates live at
`/usr/share/noctalia/assets/templates/` (gtk, ghostty, alacritty, hyprland,
sway, wezterm, helix, btop, starship, qt, kde, cava, scroll, mango,
umbriel, foot, emacs, kitty); palette generation `noctalia theme [image]`.
## Remote IPC over ssh (the D-Bus trap)
`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 the 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, rc 0, with 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`,
`color-scheme-get|set`, `theme-mode-get`, `templates-apply`,
`screenshot-fullscreen`.
**Wallpaper directory semantics:** empty/unset `[wallpaper] directory`
resolves to XDG 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 forces it immediately. Note:
the *current* wallpaper stays the built-in default until the next rotation
or an explicit `wallpaper-next`; `wallpaper-get` then reporting a path
inside the configured directory is the 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 the session bus, so
no WAYLAND_DISPLAY pinning is needed in unit context.
## Custom-palette recolor (without restarting the session)
Why custom, never the community cache:
`~/.local/state/noctalia/community-palettes/*.json` is a **sync cache** —
`.catalog/palettes.json` carries an `md5` per palette and noctalia re-fetches
from `https://api.noctalia.dev/palettes`, so 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
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):
```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) 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
`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. Symlinked-into-dotfiles paths show up as modified files
worth committing.
6. Reload surfaces: compositor — `niri msg action load-config-file`
(`reload-config` is NOT a valid niri subcommand); ghostty — see the trap
below.
Verification: `ghostty +show-config | grep -E '^(foreground|palette)'`
(proves the theme file resolves — note it prints only the delta from
defaults; cross-check with `ghostty +show-config --default` before
concluding a key is unset); `noctalia config validate` → `✓ Config is
valid`; visual via `noctalia msg screenshot-fullscreen` (saves to
`~/Pictures/screenshot_<ts>.png` and ALSO overwrites the clipboard with the
PNG — warn the user); `grep -c <old-hex> ~/.config/noctalia/palettes/*.json`
→ 0 for no leftovers.
Track it in the dotfiles repo (`~/.omp`, the command-center —
`scripts/deploy.sh` is a stow-equivalent over `dotfiles/<pkg>/` trees):
mirror the shape for the new palette so `git pull` + `deploy.sh` is the
whole propagation story — move the JSON into
`~/.omp/dotfiles/noctalia/.config/noctalia/palettes/` and symlink it back
into `~/.config/noctalia/palettes/`; re-set the custom palette (re-reads
through the symlink). Do the link by hand for one file instead of running
`deploy.sh` — sync mode quarantines and replaces every divergent live file.
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). 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.
- Saturating terminal text 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 — keep `mOnPrimary` dark).
- Revert = `noctalia msg color-scheme-set community "<original name>"`; the
community cache is untouched by the whole flow.
- Generated theme files 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.
## Offline template render (when templates-apply never ran)
Failure signature: niri `failed to read included config from
~/.config/niri/noctalia.kdl: No such file or directory` — the 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/` (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"
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"
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: `niri validate` → rc 0; `ghostty +validate-config` → rc 0; next
login journal shows `loaded config from config.kdl` with NO parse WARN,
custom output placement (`niri msg outputs` vs display.kdl), noctalia
autostarted (`pgrep noctalia`).
Traps: **`noctalia msg templates-apply` on 5.0.1 returns "ok" but applies
NOTHING** (this build's opt-in config schema does not act on it) — do not
trust its exit code, use the explicit `--render` calls above. Niri config
errors at boot are NOT fatal — the compositor keeps running on defaults,
which reads as "some settings silently stopped working", not a crash.
Regenerate after any palette change; the rendered files are static
snapshots.
## Niri output rotation and pinning
Verified on blankpad, niri 26.04 (two PM1601 externals + eDP-1 laptop panel).
**Ground truth first:** `niri msg outputs` (connector names, current mode,
scale, transform, logical pos/size) and `niri --version`. Output **names are
connector names** (`eDP-1`, `DP-2`, `HDMI-A-1`), not EDID model strings —
the EDID strings printed before them are decoration. On blankpad the live
config splits via `include "./cfg/*.kdl"` from `~/.config/niri/config.kdl`,
and `cfg/display.kdl` is a **symlink into the dotfiles repo** — edit the
repo path (that is what gets committed); check with `ls -ld`.
**Transform direction is counter-clockwise** (`niri msg output <NAME>
transform --help`): `90` = content rotated 90° CCW ⇒ the panel's **top
bezel sits on the LEFT**; `270` = top bezel 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, and hand the user the one-liner
to flip; don't stall on the question.
Two traps when editing 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`) 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`. Pin every
output's position to keep the left-to-right order. Positions are logical
pixels at each output's own scale; x-offsets chain (eDP-1 logical width
1536 → DP-2 at 1536 → +1200 portrait width → HDMI-A-1 at 2736):
```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 position x=0 y=0 } // MUST restate scale
```
Apply and verify:
```bash
niri validate -c ~/.config/niri/config.kdl # exit 0
niri msg action load-config-file # live reload; 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, positions matching
the intended chain. One-off rotation without touching config (temporary):
`niri msg output DP-2 transform 90`. Commit via the dotfiles repo
(`~/.omp`, `main`; push to the local Gitea permitted): `git pull --ff-only`
→ add the display.kdl → commit → push. Pitfalls: `reload-config` is not a
niri subcommand; 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.
## GPU RGB over I2C (EVGA iCX3)
Turn RGB on/off (or read it back) on an EVGA FTW3 / iCX3-controller GPU
(the 3090 on dankclaw) using the GPU's own nvidia i2c bus. No display
engine, no X, no daemon — the mode is EEPROM-saved and survives reboot.
- **Find the controller:** the nvidia i2c driver exposes the card's lighting
ports as `/dev/i2c-N`; on the 3090 FTW3 that is ports 1,5,6,7,8 →
i2c-1..i2c-5, and only i2c-1 carries a live slave (NVIDIA port 1). Probe
each bus. The iCX3 MCU answers at address **0x2D**. `/dev/i2c-*` are
`root:i2c 660` — run with `sudo` (dank has passwordless sudo).
- **Protocol (raw I2C_RDWR, NOT the SMBus ioctls)** — write the register
byte first, then the payload:
- enable `0xB2` write `04 C6 EB EA 15` (required once before mode writes)
- mode `0xC0` write `09 m0 m1 m2 m3 .. .. .. .. SYNC`; m0..m3 are the four
zone modes, `MODE_OFF = 0x00`; OFF = `09 00 00 00 00 FF FF FF FF 00`
- save `0x90` write `04 9E EB 00 90` (commit to EEPROM — the
reboot-persistence step)
- fw `0xB1` block read (firmware version)
- Read the mode back: write reg `0xC0`, then read 16 bytes; bytes 1..4 are
the zone modes (0x00 = OFF), bytes 6..9 the per-zone LED counts.
- **Verify persistence:** after a reboot, read the mode — all four zone
modes `0x00` means the EEPROM save held (confirmed 2026-09-19 post-reboot).
- **Tool:** a ~90-line I2C_RDWR reader/writer lived at `/tmp/evga_raw.c`
(`evga_raw <bus> mode|off|save|enable|fw`) — `/tmp` is wiped by reboot, so
recreate it if needed; OpenRGB's `EVGAGPUv3Controller.cpp` carries the
same protocol plus the full colour API.
- Gotchas: raw I2C_RDWR only (the iCX3 answers the raw form, not the SMBus
ioctls); the `enable` write must precede the first mode write in a
session; without the `save` write the mode is RAM-only and resets on power
cycle.
## Related
- [[Command Center Dotfiles]] — the stow-equivalent deploy this page links into
- [[Hermes]] — the agent that drove the CDP daemon alongside this desktop
- [[GGUF Compatibility]] — unrelated but shares the "read the file, not the name" lesson
@@ -5,3 +5,115 @@ description: "The *arr automation stack (Prowlarr, Sonarr, Radarr, Lidarr, Reada
tags: [arr-stack, prowlarr, sonarr, radarr, lidarr, readarr, qbittorrent, frankbook-pro, dankserver]
timestamp: 2026-09-04T00:00:00Z
---
# *arr Stack
The *arr automation stack runs on **FrankBook-Pro** (the Mac mini) as quadlets
inside its podman machine; all egress rides the gluetun WireGuard netns. The
per-service pages ([[Prowlarr]], [[Sonarr]], [[Radarr]], [[Lidarr]],
[[Readarr]], [[qBittorrent]]) carry deployment facts; this page holds the
cross-cutting API and auth-posture procedures.
## API version split (memorize before any *arr HTTP call)
- Sonarr/Radarr: `/api/v3/…`; Lidarr/Readarr/Prowlarr: `/api/v1/…`.
- Prowlarr is port **9696** — 8090 is Jackett.
- **Empty body = endpoint absent**, not a failure: probe endpoints by status
code first; v1 has no `/api/v1/profile` endpoint at all.
- `PUT /downloadclient` and `PUT /indexer/{id}` take a SINGLE resource object
(GET it, patch `settings`, PUT the 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`.
They are local-only secrets — they stop being secret the moment a vhost
serves the API unauthenticated.
## Schema-first probing (validators are free documentation)
Send the FULL schema template object with fields filled; v1 validators reject
partials, and each error names the next required field — peel validators one
live call at a time instead of guessing payload shapes:
- `'Fields' must not be empty` → send a full schema copy, not `fields:[]`.
- `'Base Url' must not be empty` when keys were camelCase → **PascalCase is
required** (`BaseUrl`, `ApiKey`) — schema keys, not the JSON wire keys.
- `DefaultMetadataProfileId must be > 0` → fetch real ids from
`GET /api/v1/metadataprofile` (rows like `(1,'Standard')`);
`GET /api/v1/qualitydefinition` returns the quality CATALOG — ids ≠
profiles.
- `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.
- `syncLevel` is enum `ApplicationSyncLevel` — omit it or use the int; a
string is rejected.
**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, a list = fail) — judge from bodies, not
status codes. Command POSTs (`MoviesSearch`, `ApplicationSync`) run
server-side and respond only when the work finishes: curl `-m` timeouts
(HTTP 000) during a search are NOT failures — poll queue/history afterward
instead of re-firing; warm small commands (`RefreshMonitoredDownloads`)
answer in ~20 ms, so a hang means live work in progress, not a dead API.
**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. The indexers table columns are
Id/Name/Implementation/Settings/ConfigContract/Enable/Priority/Added — the
enable flag column is `Enable` (not `EnableRss`). Prowlarr's real application
settings values live in `prowlarr.db` → `Applications.Settings` JSON (the
`/api/v1/applications` GET responses carry `fields: []`).
## Auth posture probe (ground truth is 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.
1. **Confirm which host runs the service before probing** (`podman ps` on
the target, not the box you ssh'd into first — the arrs live on
FrankBook-Pro).
2. Probe an API endpoint that bypasses the SPA shell, locally and through
any 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. Decision framing: 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.
## The `None` + `Enabled` auth-config trap (v1 .NET apps)
`<AuthenticationMethod>None</AuthenticationMethod>` +
`<AuthenticationRequired>Enabled</AuthenticationRequired>` is
self-contradictory: the UI Save is 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 the 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. (Prowlarr runs
`Forms` + `Enabled` healthily — the crash combo is `None`+`Enabled` only.)
General rules that saved the session these came from: 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 — read/edit via `podman exec`).
## See Also
- [[Prowlarr]] — indexer feeds and the v2 sync model
- [[Sonarr]] · [[Radarr]] · [[Lidarr]] · [[Readarr]] — the managers
- [[qBittorrent]] — download client, 4.6.7 pin, E2E verification
- [[FrankBook-Pro]] — the host and its podman machine
- [[Homepage Status Probes]] — how the e2e gate treats this sleeping host
@@ -111,6 +111,90 @@ Restart=always
probe one ssh-context out returns the residential IP — a false leak alarm.
- Ensure umask is set to `022` so downloaded files are readable by arr apps.
## E2E verification after any pipeline change
Run after ANY change to the 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 from a dev box via `ssh frankbook-pro`; inside the
Mac shell export `PATH="/usr/local/bin:/opt/homebrew/bin:$PATH"` for
`podman machine ssh`.
**Access note (2026-09-21):** the Mac's login user is **dank**@192.168.0.112
(NOT core — `core` is the podman-machine VM user, reached only via
`podman machine ssh`). blankpad has key auth for `dank@` (bootstrapped
2026-09-21) and a `frankbook-pro` alias in `~/.ssh/config`. macOS sshd has a
low MaxAuthTries: when password-bootstrapping use `-o
PreferredAuthentications=password -o PubkeyAuthentication=no
-o IdentitiesOnly=yes` or the key offers exhaust the attempts before the
password is tried.
**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 (bare `wget` on the
Mac host): it silently returns the residential Comcast IP and reads as a
hard-gate leak that does not exist. A residential reading ⇒ 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
```
WebUI from the LAN: `http://192.168.0.112:8080` (published on the gluetun
unit). Username `admin`; password = the machine-local `QBIT_WEBUI_PASSWORD`
(20 chars, generated once at provisioning by the deploy handler — never in
the repo). Parse-bug 403s never reach qB as failed logins — no ban accrues;
server-side truth is `/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/3600 s 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 — see the pin rationale
above). 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.
## See Also
- [[ARR-Stack]] — full *arr stack documentation
@@ -131,6 +131,55 @@ ungated), and gitea (git smart-HTTP clients cannot solve JS challenges —
distinguish challenge from deny at this release; grep the body for
`Access Denied` / `Making sure you` instead.
## Guard-chain layer diagnosis (a user reports a guarded page "broken")
Use when a guarded page (e.g. the `www.franksdank.cloud` homepage) reads as
"broken / links gone / not loading" and the fault must be localized to
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`.
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.
3. **Outpost errors**: `podman logs --tail 40 authentik-outpost 2>&1 | grep
-iE 'error|exception|traceback|500|provider'` — routine dramatiq task
logs are normal.
4. **Post-auth handlers from INSIDE the caddy container** (8016/8017 live in
its netns — `curl http://127.0.0.1:8017` from the HOST returns `000`
connection refused, which is EXPECTED, not a fault):
`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. (BusyBox wget in
this container has NO `-H` flag — use `--header "Host: …"`.)
5. **Files on disk**: `ls -la /srv/homepage/` and validate `status.json`
parses (`python3 -c "import json;json.load(open('/srv/homepage/status.json'))"`).
Tool/behavior notes: the anubis container is distroless — `podman exec anubis
sh` fails (`sh: executable not found`); inspect via its logs or the served
challenge page. Anubis logs `x-real-ip: 10.89.1.22` (the Caddy container's
podman IP) for ALL clients — rootless-podman published-port SNAT;
clients are indistinguishable by IP and clearance is cookie-based. Do NOT
mistake that 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 is healthy (challenge valid, outpost clean,
handlers 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? login loop? empty grid? "status unavailable"
text?) or the authentik login to drive the page in a real browser.
## Verify live
```
@@ -342,6 +342,168 @@ The silent re-auth path was proven end-to-end: after deleting
`qa-e2e-admin`'s rows, the e2e admin spec ran against the stale storage
state and passed with no login prompt (2 tests, 2.9 s).
## Guard-chain flip recipe (adding a guarded vhost)
Move a public vhost from `reverse_proxy <app>:<port>` onto the chain:
client → TLS edge → Anubis :8923 → outpost decision → 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.
Fleet convention for `external_host` is the exact browser-facing origin
(the inventory table carries both bare origins and trailing slashes;
routing matches on hostname, verified 2026-09-02).
`internal_host = http://caddy:8017`, all three flows set, and an
Application attached — the two provisioning rules above.
2. **Route flip in ONE commit/deploy**: the vhost becomes
`reverse_proxy anubis:8923` with `header_up X-Real-Ip {remote_host}`, and
inside the `http://:8017` listener add `@<svc> host <vhost>` +
`handle @<svc> { reverse_proxy <upstream> }`. Anubis guard set, Caddy TLS
edge, `:8017` routing table, and provider list change together.
3. Deploy via the repo deploy flow (commit first; deploy pushes main itself).
**Verification (all three):**
- Unauthenticated probe from a client: `curl -s https://<vhost>/` → HTTP 200
challenge page; grep the body for `challenge|not a bot|anubis` (~8
markers on a live challenge). 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; probe inside it or grep the config. Non-browser clients
cannot solve the JS challenge — before flipping, audit service-to-service
traffic: ingestion/push paths must target internal addresses (`loki:3100`),
never a public hostname of a guarded service. Exempt-by-construction
categories stay unguarded: the IdP itself (`auth.`), git smart-HTTP
endpoints, services with their own complete auth — document the exemption
in the vhost comment.
## Driving the ORM (`ak shell`)
All authentik object work happens through the server container's ORM shell —
no repo change, no deploy (these providers are NOT repo-managed; capture new
provider+application pairs as a blueprint or setup script to avoid drift).
```bash
ssh dankclaw 'podman exec -i authentik-server ak shell' <<'EOF' | sed -n '/229 objects/,$p'
from authentik.core.models import User, Group
...
EOF
```
- The heredoc IS the program: `ak shell` reads all of stdin and execs it
(heredoc-through-ssh quoting breaks; the raw-stdin pattern is the proven
one).
- Bootstrap prints JSON log lines, then
`### authentik shell (2026.8.3)` and `229 objects imported automatically…`
— pipe through `sed -n '/229 objects/,$p'` to drop the noise.
- 229 models are pre-imported. Import paths (each wrong guess costs a failed
exec): `User`, `Group`, `Application`, `ApplicationEntitlement`,
`PropertyMapping` → `authentik.core.models`; `ProxyProvider` →
`authentik.providers.proxy.models` (NOT `authentik.outposts.models`);
`LDAPProvider` → `authentik.providers.ldap.models`;
`authentik.core.models.application` does NOT exist as an import path; there
is no `authentik.policies.group` module in this build.
- For live DB truth prefer the postgres container
(`podman exec authentik-postgres psql -U authentik -d authentik -At -c
"select * from …"`) — **query with `select *` and stop guessing column
names**: 2026.x renamed columns (`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`).
## User provisioning playbook (user-level account)
There is no `User.create_user` classmethod, and `is_superuser` is NOT a
constructor kwarg (it is a property derived from group membership) — passing
it raises `TypeError: User() got unexpected keyword arguments`:
```python
from authentik.core.models import User, Group
u = User(username="ridley", email="ridley@franksdank.cloud", name="Ridley", is_active=True)
u.set_password("...") # password arrives on stdin only, never argv
u.save()
for g in ("Non-Admin Users", "jellyfin_users", "openwebui_users"):
u.groups.add(Group.objects.get(name=g))
```
`path` defaults to `users` (regular). The access map (verified live
2026-10-04): there are NO per-app policy bindings on the proxy providers
(all use the implicit-consent "Authorize Application" flow), so guarded-vhost
access is decided entirely by GROUP MEMBERSHIP consumed downstream —
- `homepage-admins` → Caddy RBAC for homepage `/admin` (`X-Authentik-Groups`
regex gate) and the homepage admin panel; regular users get only
`category: user` panels from `services.json`.
- `openwebui_users` / `openwebui_admins` → ApplicationEntitlements
"Open WebUI Users"/"Open WebUI Admins" (bound by group), consumed by the
"Open WebUI roles" property mapping → `roles` claim. Verify live with
`u.app_entitlements(app)`.
- `jellyfin_users` / `jellyfin_admins` → Jellyfin via the LDAP outpost
(filter `(memberOf=cn=jellyfin_users,ou=groups,dc=franksdank,dc=cloud)`).
- `Non-Admin Users` = the plain-user marker group (precedents: alex,
qa-e2e).
- EXCLUDE from a user-level account: `homepage-admins`, `jellyfin_admins`,
`openwebui_admins`, `authentik Admins`, `authentik Read-only`.
- Other user-category vhosts (SearXNG, Gitea, Calibre-Web, RomM, Copyparty,
Plex, Seerr, Marreta) need no per-user group: any authenticated user
passes the outpost.
Verify login behaviorally — `authenticate()` is the exact PasswordStage code
path:
```python
from django.contrib.auth import authenticate
assert authenticate(request=None, username="ridley", password="...") # -> User
assert authenticate(request=None, username="ridley", password="nope") is None
```
Look flows up by slug, not name:
`Flow.objects.get(slug="default-authentication-flow")` (its `.name` is
"Welcome to authentik!" — `.get(name=…)` fails). Stage order:
Identification(10) → Password(20) → AuthenticatorValidate(30; no-op with no
enrolled device) → UserLogin(100). With no MFA enrolled, nothing else blocks.
More field gotchas (each cost a failed exec): `ApplicationEntitlement.application`
can be null (guard with `getattr`); `LDAPProvider` has
`search_mode`/`bind_mode`/`base_dn`, lacks `search_base`/`of_groups`/
`organization`; `OAuth2Provider` has no `.scopes`; `Outpost.config` is an
`OutpostConfig` object, not a dict; `Policy` has no uniform `.policy_cls`
(use `type(p).__name__`); `User` has no `.status`.
Never rotate `AUTHENTIK_SECRET_KEY` to fix claim issues — it invalidates
every outpost session fleet-wide; purge the stale `proxysession` rows
instead (section above).
## Probing the outpost from inside the netns
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. Distroless images have no
shell (`podman exec authentik-server sh` fails), so probe by joining the
namespace:
```
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`).
- Boot transience: immediately after a restart the same probe can return 503
(configure task pending); poll until the answer changes before concluding.
- `podman inspect --format "{{.Config.NetMode}}"` errors on this podman
version — the field is `.HostConfig.NetworkMode`.
## Failure modes
| Symptom | Cause | Fix |
@@ -0,0 +1,158 @@
---
type: reference
title: Homepage Status Probes
description: The homepage TUI status page — how probe targets are chosen (host-netns vantage), the three false-down shapes, triage procedure, local render check, deploy, and the e2e gate interactions (asleep Mac mini, raw-IP matrix).
tags: [homepage, status, probes, e2e, homepage-status]
timestamp: 2026-10-05T00:00:00-07:00
---
# Homepage Status Probes
The homepage is a static site —
`shared/homepage/{index.html,admin.html,app.js,style.css,services.json,docs.html}`
— served from `/srv/homepage` on **dankclaw** only (`deploy_homepage` in
`scripts/deploy-remote.sh` refuses other targets). A checker
(`scripts/homepage-status.py`, run by the `homepage-status.timer` unit) probes
each card's target and writes `/srv/homepage/status.json` (mode **0644** so
Caddy can serve it — losing the mode 403s the page).
## The checker's vantage and its three false-down shapes
The checker probes TCP/HTTP **from the host netns**. It 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".
1. **TLS-only app probed with `method:"http"`.** Plex Media Server serves TLS
only on `32400`; plain-HTTP GET → TLS alert → curl exit 52, 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 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. Drop those cards.
A host-vantage probe can only reach a listener **published to the host**
(rootlessport/pasta); unpublished ports are invisible from the host netns.
See [[Container Host Reachability]] for the netns rules.
## Triage procedure
1. Read the card's probe target in `shared/homepage/services.json`; note
method (`tcp` vs `http`).
2. 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`),
which belongs to the default `podman` net (`10.88.0.0/16`). Probe with
bash `/dev/tcp` (no `python3`/`ip` in these images); escape loop vars so
they expand **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: re-point or re-method for HTTP/TLS mismatches; **drop** cards whose
only exposure is an internal netns; never invent a probe target.
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.
Removing a card's `probe` key without a code change crashes the checker
(`svc["probe"]` KeyError) — dropping the whole entry is the only zero-code
fix. The `description` field is consumed only by `app.js` `descHTML()`, so
adding a `details` object (role/spec/inputs/use) is schema-safe:
`tests/homepage_config.bats` checks only name/category/section/probe.
## Local render check (before every deploy)
The page fetches `services.json` + `status.json` at load; `file://` is
CORS-blocked, so serve over HTTP:
```bash
cd shared/homepage
python3 -c "import json; s=json.load(open('services.json')); \
json.dump({'services':[{'name':x['name'],'state':'up'} for x in s]}, open('status.json','w'), indent=2)"
python3 -m http.server 8899 --bind 127.0.0.1
```
Then in the browser: load `http://127.0.0.1:8899/index.html`, **wait ~1.5 s**
(panels render after the async fetch; checking immediately returns 0 panels),
extract via `tab.evaluate` — LLM cards render `.desc-role`/`.desc-spec`/
`.desc-inputs`/`.desc-use` from the card's `details` object, other cards a
plain `.desc`; `getComputedStyle(el).color` confirms tones. Screenshot for
visual proof. Expected local noise: `deployed.js` 404s (baked in at deploy
time) — harmless.
The scratch `status.json` is untracked and NOT git-ignored — move it to
`shared/homepage/.AGENTTRASH/` after the check (repo policy: never `rm`).
CSS palette lives in `style.css` `:root` (`--fg` bright green, `--dim` dim,
`--head` pale, `--link` cyan, `--wip` yellow, `--down` red). Add new tones as
`--*` vars and include any new `.desc-*` class in the `html[data-bare="1"]`
reset so `?bare=1` strips it to black.
## Deploy
```
./scripts/deploy.sh dankclaw --service homepage --skip-e2e
```
Copies the assets to `/srv/homepage`, regenerates `deployed.js` (git commit
datetime), restarts the `homepage-status` timer. Verify live:
`ssh dank@192.168.0.143 'grep … /srv/homepage/…'`.
**`--skip-e2e` is justified for homepage-only changes when the Mac mini is
asleep**: the e2e admin crawl (`tests/e2e/specs/11-admin.spec.ts:24`) walks
Prowlarr at `192.168.0.112:8989` on FrankBook-Pro; an asleep mini (ARP STALE,
no remote wake) times out that test — a pre-existing environmental failure,
not a regression. All non-Mac-mini e2e tests pass.
## e2e raw-IP *arr failures — probe from the browser's box, not a peer
When `11-admin` fails with `ERR_CONNECTION_TIMED_OUT`, the instinct is "the
Mac is asleep". Sometimes true; not always. Playwright runs on the dev box,
so 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:
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 (ICMP, not TCP):
ping -c 2 -W 3 192.168.0.112
```
| peer probe | dev-box 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 blocked. Waking cannot help |
Third row: confirm the Mac is innocent through the dankclaw jump
(`pgrep -fl socat`; `netstat -an | grep -c 8989`; loopback
`curl http://127.0.0.1:8989/` → 200; `/usr/libexec/ApplicationFirewall/socketfilterfw
--getglobalstate` → disabled). Forwarders up + loopback 200 ⇒ the block is
between the dev box and the Mac.
Don't hold a deploy hostage to a known-environmental red gate: verify the
deployed service **directly** (its own ledger/health/real completion), state
the gate was skipped or partially red and why (`--skip-e2e` prints the
warning, so the skip is recorded), and 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; deploy verified by other means"). Do not weaken or skip
the spec — it guards a real SSO-plus-RBAC contract.
## Related
- [[Container Host Reachability]] — the netns rules behind the vantage
- [[Observability Provisioning]] — the monitoring stack reading the same fleet
- [[Deploy State Triage]] — service-scoped deploys and the e2e gate
- [[Testing Policy]] — the seam/e2e contract this page's checks live in
@@ -158,6 +158,119 @@ The Porkbun zone `franksdank.cloud` carries an A record for every vhost plus the
| HTTPS works from the host but not the WAN | No port forward on the G36 for that service (manual router step) |
| Cert renewal fails in Caddy logs | Porkbun API credentials in the Dankclaw `.env`; test with the Porkbun API `/ping` endpoint |
## Per-client fix: resolver drop-in + browser DoH bypass
Symptom: a LAN machine gets `connection timeout` (curl rc=28) on
`https://*.franksdank.cloud` while the server is healthy. Root cause: the
public DNS hands out the WAN IP `71.227.227.172`, which has **no 443
port-forward** (filtered). Any resolver that is NOT Pi-hole produces the WAN
answer → SYN to a filtered port → timeout. There are TWO resolver layers to
check, in order:
**Layer 1 — systemd-resolved (fixes curl/shell).** A route-only drop-in
sends `*.franksdank.cloud` ONLY to Pi-hole and never to the default-route
servers:
```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)
getent hosts www.franksdank.cloud # what the OS actually resolves
curl -sk --resolve www.franksdank.cloud:443:192.168.0.143 \
https://www.franksdank.cloud/ -o /dev/null -w '%{http_code}\n' # 200 = server fine
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 is load-bearing: it routes only those domains to
Pi-hole. Verify: `getent hosts www.franksdank.cloud` → `192.168.0.143`;
`resolvectl status` shows `DNS Domain: ~franksdank.cloud ~franksdank.casa`.
**Layer 2 — browser DoH (the bypass: "curl works, browser times out").** If
Layer 1 is verified but the BROWSER still times out, the browser is doing
its own DNS over HTTPS and never asks systemd-resolved — it queries the TRR
provider (Cloudflare et al.) directly → WAN answer → timeout. Prove it:
`curl -sk -m 6 https://71.227.227.172/` → rc=28 (the WAN answer is a dead
end). Diagnosis details:
- Firefox profile may be at the XDG path `~/.config/mozilla/firefox/`, NOT
`~/.mozilla/firefox/` — find it via the running process if the default
path is empty.
- `grep network.trr. <profile>/prefs.js` — **no entries = shipped default,
and since Firefox 113 the shipped default is DoH ON.**
Fix (surgical, keeps DoH for everything else): Firefox → Settings → Privacy
& Security → **DNS over HTTPS** → "enabled for these domains" → **Add…** →
`franksdank.cloud` (and `franksdank.casa`). The exception list = domains
that do NOT use DoH; they fall back to the system resolver, which has the
route-only drop-in. Or set DoH to **Off** entirely (coarser). Chrome:
Settings → Privacy and security → Security → "Use secure DNS" → off, or
"Use the same settings as your system". Do NOT hand-edit `prefs.js` while
the browser is running — it holds prefs in memory and rewrites the file on
exit, clobbering the edit.
This is a per-client fix: every LAN client that must reach the homelab by
name needs Pi-hole as its DNS (or the drop-in) AND a browser that does not
bypass it with DoH. The server side needs no change. Verified on blankpad
2026-09-21: the drop-in was present since 09-12, curl returned 200, and
Firefox 155 with default (ON) DoH was the remaining hole.
## Porkbun zone ops (franksdank.cloud)
Operate the public DNS zone 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 — a test
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).
## See Also
- [[Pi-hole]] — the LAN DNS server (quadlet, records, web UI)
@@ -0,0 +1,95 @@
---
type: reference
title: Observability Provisioning
description: How Prometheus config/rules and Grafana datasources/dashboards are provisioned as code in serverAppConfigs — the config triad, deploy copiers, planner mappings, seam tests, entry points for each change type, and API verification.
tags: [observability, prometheus, grafana, provisioning, deployment]
timestamp: 2026-10-05T00:00:00-07:00
---
# Observability Provisioning
The monitoring stack is **provisioned as code**: dashboards, datasources,
scrape jobs and alert rules live in the repo and ride the normal deploy flow.
No hand-edits inside containers — they are wiped on recreate and never
committed.
## The config triad
| Repo path | What it holds |
|---|---|
| `shared/prometheus/prometheus.yml` | scrape jobs (global + per-host) |
| `shared/prometheus/rules/llm-fleet.yml` | alert rules + recording rules |
| `shared/grafana/provisioning/` | datasources + dashboards (provisioned tree) |
Targets and datasources reference **container names** (`loki:3100`,
`alloy:12345`), never loopback-published ports; the LLM endpoints are reached
by LAN IP (`192.168.0.143:7770`, `:7771`, `192.168.0.235:7770`) because they
are host-netns services, not containers. Prometheus 3 refuses HTML responses
as metrics — HTML-scraped "probes" are structurally inert and need a blackbox
exporter (see [[Stale Signal Audit]]).
## The deploy path
`scripts/deploy-remote.sh` carries two copier functions:
- `copy_prometheus_config` → `/opt/prometheus/config/` on the target
- `copy_grafana_provisioning` → `/opt/grafana/provisioning/`
`scripts/deploy-plan.py` maps changes under `shared/prometheus/**` and
`shared/grafana/**` to the `prometheus`/`grafana` services, so
`deploy.sh dankclaw --service grafana` (etc.) restarts only what the change
touched. Seam tests in `tests/observability_quadlet.bats` pin: unit files
exist, the copier functions exist, and the provisioned paths match the units'
mounts.
## Entry points
**Add a scrape target** — edit `shared/prometheus/prometheus.yml`. Decide the
vantage first: container name if the exporter is a container on the same
netns; host LAN IP if it is a host process (LLM endpoints); a blackbox module
if it is an HTTP probe, not a metrics endpoint.
**Add an alert/recording rule** — edit `shared/prometheus/rules/llm-fleet.yml`.
Current delivered set: 5 alert rules + 1 recording rule for the LLM fleet.
Rules reload with the prometheus service.
**Add a datasource** — drop a YAML file into
`shared/grafana/provisioning/datasources/`. Point it at the container name of
the backend (`http://prometheus:9090`), not loopback.
**Add a dashboard** — drop the JSON into
`shared/grafana/provisioning/dashboards/`. Provisioned dashboards live in
unified storage, not (only) the SQLite table — grep the API, not the DB, when
verifying (see the verify section of [[Fleet Audit and Posture]]).
## Verify through the APIs, not the filesystem
```bash
# prometheus: rules and targets actually loaded
curl -s http://127.0.0.1:9090/api/v1/rules | python3 -m json.tool | head
curl -s "http://127.0.0.1:9090/api/v1/targets?state=active" | \
python3 -c "import json,sys; [print(t['labels'].get('job'), t['health'], (t['lastError'] or '')[:60]) for t in json.load(sys.stdin)['data']['activeTargets']]"
# grafana: datasource + dashboard provisioned
curl -s -u admin:<pw> http://127.0.0.1:3002/api/datasources | python3 -m json.tool | grep -E '"name"|"url"'
```
Health answers from a stale process and provisioned resources can hide in
unified storage — a file existing on disk is not proof the running server
loaded it. See [[Stale Signal Audit]] for the never-green-target decision
table (refused = outage, 404/HTML = config defect).
## Current delivered state (2026-09-17)
Prometheus → Alertmanager (`dankclaw/observability/quadlet/alertmanager.container`,
loopback `:9093`) → host-side Signal relay (`scripts/signal-relay.py`, user
unit, `192.168.0.143:18093` — the LAN IP, the one bind a container can reach
via `host.containers.internal`) → signal-cli. Some targets read down for
structural reasons (HTML-scraped "probes", Gitea metrics off); the remedy is
the blackbox exporter. See [[Observability]].
## Related
- [[Observability]] — stack topology and the alert delivery chain
- [[Stale Signal Audit]] — classifying down targets before "fixing" them
- [[Quadlet Lifecycle]] — the units that host these services
- [[Deploy State Triage]] — how the planner selects these services
@@ -271,6 +271,83 @@ graph TD
6. Step up in size: 9B, then gpt-oss-20b.
7. Attempt `qwen3.6-moe:35b-a3b` only after the smaller runs succeed, and only with the production service stopped. Watch `free -h` during the model download and load.
## Registering packs absent from the catalog (NPU2)
Use when adding a non-catalog NPU2 pack (an extraction or fine-tuned model)
to the FLM registry. 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, and `flm list` with the
env var set lists custom tags (`⏬` = not downloaded).
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"`
— 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 (the deploy path, not ad-hoc restarts) so
the new process loads with all files present.
6. Verify with a REAL completion request (`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. Tag switching happens INSIDE one serve process; the 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 — use bracket-guarded patterns
(`[q]wen3.5:4b`) or exact PIDs.
## Tag load-failure diagnosis (200 with an error body)
`POST /v1/chat/completions` answers **HTTP 200** with a flat
`{"error":"Failed to load <tag> model!"}` and no `choices`; `/v1/completions`
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, distinguished via
`journalctl -u <flm-service> --full`:
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,
downloads missing weight files on the fly (first request hangs ~90–110 s),
then fails `Failed to load model: No such file
'/home/dank/.config/flm/xclbins/<PackName>/<kernel>.xclbin'`; subsequent
attempts fail fast (~2 s). Root cause: the serve script exports
`FLM_XCLBIN_PATH=/home/dank/.config/flm/xclbins` (user dir), so every
kernel lookup resolves under the user dir, which only contains symlinks
someone linked. **A tag listed in `/v1/models` is NOT proof it is
loadable on this host.** Fix: one symlink per missing pack
(`ln -s /opt/fastflowlm/share/flm/xclbins/<PackName>
/home/dank/.config/flm/xclbins/<PackName>`) — verified live: after the
symlink the first request loaded and answered in 7.4 s wall.
Switch-cost behavior: the `model` field routes per-request (no restart
needed); a request whose model differs from the resident one triggers a full
reload (7–11 s wall for an 8-token completion on ~4B-class packs);
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: `/v1/models` for registered tags; grep
the journal for `Failed to load model` (the quoted xclbin path names the
missing pack dir exactly); diff `ls ~/.config/flm/xclbins/` (symlinks =
installed) against `/opt/fastflowlm/share/flm/xclbins/` (available).
## Incident: qwen3.6-moe unreachable (2026-08-16)
Symptom: a client on dankware could not reach `qwen3.6-moe:35b-a3b` on port 52625. The journal shows three separate causes in the 14:19-15:03 window.
@@ -0,0 +1,97 @@
---
type: reference
title: FreeToken Builds
description: Building FreeToken (the official franksdank fork of FreeTok Tokenworks' freetoken vLLM-MTP runtime) on Dankclaw — the CUDA vLLM venv build and the ROCm build, with the dependency traps each one hit.
tags: [freetoken, build, dankclaw, vllm, mtp]
timestamp: 2026-10-05T00:00:00-07:00
---
# FreeToken Builds
FreeToken = the official franksdank fork of FreeTok Tokenworks' freetoken
vLLM-MTP runtime. Two build paths exist, both on **Dankclaw**, with
different dependency traps. Both were verified end-to-end 2026-09-11/12.
## Common verification (do not skip)
**Verify the build by real completion, not by version string.** A version
bump alone does not move data through the model — a build can print a new
version and still not have completed a real token. Before declaring a
FreeToken build deployable, run an actual completion (any tiny prompt) and
read the returned content:
```python
import json, urllib.request
req = urllib.request.Request("http://<host>:<port>/v1/chat/completions",
json.dumps({"model": "frank-small", "messages": [{"role": "user",
"content": "Hi"}]}).encode(), {"Content-Type": "application/json"})
print(json.load(urllib.request.urlopen(req, timeout=600)))
```
A 200 with a real `choices[0].message.content` is the only build green.
## CUDA build (the torch/vLLM dependency trap)
Both paths build on **Dankclaw** with the same preinstall state: Python
3.12.10 in the system venv and **Torch 2.10.0.dev** (nightly, repo
download.pytorch.org/whl/nightly/cu130, installed with
`uv pip install --index-strategy unsafe-best-match "torch==2.10.0.dev<date>+cu130"
--index-url https://download.pytorch.org/whl/nightly/cu130` — use
`--index-strategy unsafe-best-match`, `uv` otherwise refuses the dev
version). Verify the exact pair first; mismatched preinstalled deps are the
#1 build-failure cause.
Then install the **matching vllm dev wheel**:
```bash
uv pip install --index-strategy unsafe-best-match \
--index-url https://wheels.vllm.ai/<commit-sha>/vllm-0.6.6.dev0+g<shortsha>.cu130-cp312-cp312-linux_x86_64.whl \
--extra-index-url https://wheels.vllm.ai/nightly/cu130
```
The wheel URL pattern is fixed:
`https://wheels.vllm.ai/<full-commit>/vllm-0.6.6.dev0+g<8-char>.cu130-cp312-cp312-linux_x86_64.whl`
— read it back from vLLM's CI-run page (`run details →
build_and_install_package` job, "install the vllm wheel" step). **The trap:
vLLM refuses versions < its install_requires floor at runtime, so the wheel
version must be ≥ the runtime's floor.** A wheel built at a lower base
(0.5.x) downloads but `import vllm` fails with a version constraint error —
that failure looks like a build failure and is not one; upgrade the wheel,
not the code.
If `uv pip install -e . --no-deps` refuses (some repos pin the
`--no-deps` branch off), fall back to `python3 setup.py` inside the venv,
after `python -m venv --copies .venv && source .venv/bin/activate` and
`uv pip install --no-build-isolation "torch==…"` — the venv must NOT
re-resolve torch (a second torch from PyPI d1 breaks the CUDA extension ABI).
## ROCm build (gfx1151 via TheRock nightly)
Same venv discipline, ROCm wheels from TheRock's nightly index:
```bash
uv pip install --index-strategy unsafe-best-match \
--index-url https://rocm.nightlies.amd.com/v2/gfx1151-dcgpu-supports_fbsgens_split_0717-rc1/ \
"torch==2.10.0.dev<date>+rocm" "triton==3.6.0" "pytorch-triton==3.6.0"
```
Then `vllm` itself (`uv pip install -e . --no-build-isolation --no-deps`,
repo branch `specs-mtp-dev`, commit `2ee440d`), after copying the two
prebuilt extension wheels into the tree (the repo's `setup.py` skips
compiling them when found). The ROCm build also needs
`install-fastrak-gfx1151.sh` from the repo's scripts and
`FASTRACK_HOME=/home/dank/FastTrack-runtime` in the unit env. FreeToken's
x86_64-gfx1151 prebuilt extension (`freetoken-ops`) ships on HF hub
`franksdank/freetoken-ops-gfx1151` — fetch it once and point
`FREETOKEN_OPS_HOME` at it; never rebuild the extension from source unless
the extension API changed.
`--index-strategy unsafe-best-match` is load-bearing on both paths: the
nightly indexes carry version pairs that `uv`'s strict resolver otherwise
rejects, and the error (`No candidate found for pytorch-triton`) reads like
a missing package.
## Related
- [[Model Swap and Rollback]] — the swap/deploy story these builds serve
- [[Llama Cpp Serving Sizing]] — the harness measurement discipline
@@ -0,0 +1,159 @@
---
type: reference
title: Heterogeneous Split Triage
description: Triage before building when someone asks to split a model across unequal accelerators on one host (24 GB GDDR6X + LPDDR APU, PCIe x4 link) — link characterisation, the real bottleneck, what placement never buys, UMA pools, drafter splits, and the verdict ranking.
tags: [heterogeneous, multi-device, llama-cpp, triage, pcie]
timestamp: 2026-10-05T00:00:00-07:00
---
# Heterogeneous Split Triage
Produced the 2026-09-16 no-go verdict on splitting Qwen3.8-Flash-Next across
the RTX 3090 + Strix Halo pair. Use it to answer "could we split model X
across device A and device B" 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 idles at Gen1 x4 (~1 GB/s) and
upshifts under load. Measure while the device is busy, or you size the design
against the wrong number. Per-hop cost against the hidden state
(`n_embd × 2 B` for f16): a 5120-dim model moves 10 KiB/hop ≈ 1.3 µs at
Gen4 x4.
- **Bytes per token ≈ hops/token × hidden state** — typically ~1 MiB/token
for 2 hops/layer × 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* hits ~100 splits/token and
pins the GPU at ~5 % SM. Measure `graph splits` in the loader log; never
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/token = kv_bytes_per_position × context
```
Attention reads the whole cached context every token, so KV/token scales
with context — compare it against expert/weight traffic per token and total
token time. At long context on a hybrid arch (few attention layers, many
positions), **KV dominates**. Consequences:
- 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 7.88 GB/s =
hundreds of ms/token).
- Check the 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 — usable for decode projections, and it
assumes sequential stages (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
(compare GGUF `tokenizer.ggml.tokens`, never trust 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–50× faster than the target. 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 ~2× faster) break-even needs `p ≈ 0.7+` at K=3;
- compare against the **incumbent**, not the no-draft baseline: a target
already running its own MTP head is a high bar;
- check the runtime accepts an external drafter at all — closed engines may
enumerate a fixed drafter set and 400 anything else (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 (KV co-location)
```
- buffer types match 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) — `GPU_BACKEND_A=OFF` in the existing
build's CMakeCache tells you what needs adding;
- contiguous ranges are the WRONG split when KV bandwidth dominates: 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)
— check before assuming optimisations apply.
- A brand-new backend on the second device may not implement the arch's ops
at all: make the first measurement a **baseline of the runtime on that
device alone** — 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 discipline on production hosts: stop and mask first
([[Fleet Ops Playbook]]).
## 8. Verdict shape
Rank and report; 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 and 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 × bytes × tok/s` against link bandwidth) and overlapped; 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.
## Related
- [[PCIe Link and Atomics]] — the link and collective walls underneath
- [[Llama Cpp Multi-Device Splits]] — the deployed split shapes
- [[Llama Cpp Serving Sizing]] — KV and pool arithmetic
@@ -0,0 +1,271 @@
---
type: reference
title: LLM Bench Windows and Measurement
description: Stopping-and-restoring benchmark windows on the fleet LLM hosts (llama-bench sweeps, HTTP param benches, multi-arm vLLM offload sweeps), honest benchmark methodology, and the usage-counter measurement law under speculative decoding.
tags: [benchmarking, llama-bench, ab-testing, speculative, vllm, fleet]
timestamp: 2026-10-05T00:00:00-07:00
---
# LLM Bench Windows and Measurement
Fleet law first: **STOP the production service before any GPU experiment**
(AGENTS.md Benchmarking Policy) — a bench beside a live service starves the
GPU and corrupts results; an unisolated GPU experiment has precedent for
wedging a host until power cycle. Reference harnesses:
`scripts/bench-batch-ubatch.sh`, `scripts/autoresearch.sh`,
`scripts/autoresearch/frank-primary-arms.sh`.
## 1. The window structure (one config per arm, a driver owns the window)
1. **Stop + gate before every arm** — `wait_for_clear`: unit NOT active AND
no RUNNING container of that name (`podman ps`, not `-a`; `-a` counts
EXITED containers and can never clear — `rm -f` the exited one first) AND
port free (`ss -ltn`). The graceful-stop race cost a whole arm 2026-10-01:
`ExecStop=podman stop -t 120` drains up to 2 min; a driver that sleeps 3 s
then polls `/health` gets **200 from the still-running PRODUCTION engine**
— the arm "passes" with production's boot log, then `podman exec` dies
with `container state improper`.
2. **Boot the arm as the service, not a scratch port** (no scratch ports on
LLM hosts — one card, one server). The serve script takes its config via
env-file override (`FRANK_SMALL_ENV_FILE`, `FRANK_PRIMARY_AR_ENV_FILE`)
pointing at a staged copy; the checkout is never mutated.
3. **Fast-fail the health wait**: poll `podman ps | grep -qx <name>` inside
the loop — a refused boot dies in one poll instead of 20 min. Capture the
serve script's FULL stdout on failure (a `tail -8` loses engine-init root
causes; raise happens above the tail).
4. **Measure** (§4), capture boot-log engine facts (KV pool, arena slots,
SpecDecoding lines) — they survive measure crashes.
5. **Teardown + converge**: kill by explicit PID/pgid (never pattern kills),
`podman rm -f` scratch, wait `MemAvailable ≥ ~230 GiB` on dankserver
before the next boot; restore production from the EXIT trap and verify the
**conjunction oracle**: health 200 + `is-active` + `is-enabled` +
mask-symlink clean. A bare `systemctl start` is not a restore oracle.
(`systemctl mask` FAILS on a relinked unit — log it, explicit stop is
enough: systemd never auto-restarts an explicitly stopped unit.)
6. **Warm boots cycle ~2 min** (page cache), cold ~8 min — size timeouts for
mixed speeds.
**Destructive tails (2026-10-01):** a scratch script whose tail does
`systemctl stop` + `podman rm -f <PRODUCTION container>` fires later if a
probe loop survived the driver kill — it can destroy a just-deployed
production engine (signature: deploy exits green, then seconds-to-minutes
later `Exited (0)`, `Deactivated successfully`, clean exit = removed, not
crashed). Before ANY deploy: `pgrep -af` every scratch script of the session
and kill strays by explicit PID; killing the dev-side job does NOT kill the
host-side `deploy-remote.sh` (two of them once managed the same unit).
Two failure shapes inside the window that read as "the bench died":
- **Stale reads from a stopping container.** `podman stop` answers while the
engine inside is still flushing; a `podman logs` taken in that window
returns the boot-prefix only. Poll `podman ps` for `Exited (0)` (or
`podman wait`) before reading logs, or you will write "no results" into
the table for a run that has them.
- **A D-state worker wedges the stop.** A bench child that wedged in the
kernel ignores SIGTERM, so `podman stop -t N` burns its full timeout and
then the container is `Stopping` forever. Kill by container first
(`podman kill <ctr>` → SIGKILL path), verify the process is gone from
`podman ps` AND `nvidia-smi`, and only then re-serve. Never let the
window script proceed to a redeploy with a zombie bench worker holding
VRAM.
## 2. llama-bench sweeps (offline batch/ubatch/split/KV/depth)
**What llama-bench cannot express — decide BEFORE designing:** no
`--ctx-size` (`n_ctx = n_prompt + n_gen + n_depth`, so a bench can NEVER prove
activation buffers fit beside the DEPLOYED KV — only the real unit answers
that), no `--parallel` (no slot shape, no concurrency), no `--spec-*`
(`tg` numbers are the drafterless baseline, useful as a control only), and a
warmup run is itself a full-size run. `-d N` is a depth PREFIX (paid once per
cell via a `llama_state_seq_*` save/restore); without it a `tg` run is at
~zero depth and meaningless for long conversations.
**The separator trap:** `/` separates devices WITHIN one configuration, `,`
separates sweep values — for both `-dev` and `-ts`. `-ts 36,29` parses as the
single value 36 → everything on one card. `--tensor-split 36,29` (server) →
`-ts 36/29` (bench). Ranges exist: `-ub 512-2048+256`.
**Harness shape:** one invocation per cell (an infeasible cell calls
`exit(1)` and kills every later cell of a cross-product invocation — and
infeasibility is usually the question); TSV row per cell as it completes +
`bench.json` (`-o json`) + `bench.log` (`--progress`) + a 2 s `rocm-smi
--showmeminfo vram` VRAM trace per cell (a cell that allocated less than
intended looks fast); resume by skipping non-empty cell outputs; stop the
service in the harness, restore from `trap … EXIT`, verify afterwards. Match
the deployment's load flags exactly (`-ngl -sm -ts -fa -ctk/-ctv -t`).
**Run under 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
```
Deploy units set `LimitMEMLOCK=infinity` for ROCm staging; an SSH shell has
8 MiB and fails the load for an unrelated reason. This also survives ssh
disconnects. **Boot rungs under the production environment, not a bench
shell** — a rootless shell probe booted 3×262144 bf16 that production refused
(systemd `User=` + memlock change what the pinned-expert path holds):
`sudo systemd-run --uid=<user> --unit=<name> -p LimitMEMLOCK=infinity -p
Restart=no env VARFILE=… /path/serve.sh`.
**Verify before burning hours:** dry-run against a STUB binary with
`SERVICE=<nonexistent-name>` (the trap/stop never reached), tiny grid,
including one forced-`exit 3` cell — prove the TSV row, failure capture,
resume logic. Validate the real flag set in ~1 s by pointing `-m` at a
nonexistent file. **Reading:** bracket the sweep (reference cell first AND
last); check per-cell `stddev_ts` (a difference smaller than spread is not a
result); single-sequence decode is usually flat across batch/ubatch (prefill
knob); batch ≥ ubatch (llama.cpp clamps ubatch to batch, silently duplicating
smaller-batch cells); report what the grid cannot prove (drafter, slots, real
context).
## 3. HTTP benches of server-side parameters
`llama-bench` benches the bench binary, not the running server — server-side
params (`--threads-batch`, `--ubatch-size`, `--spec-type`, `--parallel`) need
HTTP:
- **Prefill**: large prompt, `max_tokens=1` (server crashes on 0), measure
`prompt_tokens / wall_time`.
- **Decode**: `max_tokens=16384` minimum, NEVER 1 (warmup, cache growth and
spec acceptance all stabilize over thousands of tokens); measure
`predicted_per_second` from `timings` or `completion_tokens / wall_time`;
temperature 0.
Known fleet harnesses: `scripts/bench-threads-batch-http.py`,
`scripts/bench-decode-threads-batch.py`, `scripts/bench-spec-decode.py`.
sglang-specific: `max_tokens=1` on `/v1/chat/completions` with the qwen3
reasoning parser streams zero deltas — use `/v1/completions`.
## 4. The measurement law (under speculative decoding this is absolute)
**Never count SSE chunks.** Rejected drafts break the 1:1 chunk↔token
mapping; chunk counting reported 8.3 tok/s for a config whose server counters
said 23–26 — a 3x false regression that nearly abandoned the change that
worked. Always:
```python
decode_tok_s = usage.completion_tokens / (wall - ttft)
# stream=True, stream_options: {include_usage: true}; ttft = first content-bearing chunk
```
vLLM's 10 s `Accepted throughput` log lines are a rolling window, not a
measurement (6.2 → 13.1 across adjacent windows on identical settings) — use
them for acceptance STATISTICS only (`Avg Draft acceptance rate`, `Mean
acceptance length`, per-position rates). Warm pass + ≥3 runs, median;
temperature 0; ≥256 tokens. **A decode "jump" with early-stopped completions
(ct < 256) and acceptance > 90 % is degenerate generation** (the fork's
repetition-halt fired at ~140 tokens on corrupted numerics) — inspect text
before believing a win. Run-to-run spread is 5–10 %: repeat arms; one sample
nearly shipped the wrong setting.
## 5. Honest methodology
- **Match the corpus to the workload.** Token soup under-reports speculative
decoding (2.9–3.6 tok/update vs 3.86–5.8 on real code) and mis-ranks draft
settings; acceptance, not step time, moves throughput. Build the corpus
from the repo's own sources: `{"prompt": "<instruction>\n\n<file body>"}`,
`--custom-output-len`.
- **Salt every run** with a unique per-arm token in the first bytes — it is
the prefix-cache key (and a proxy routing key). Without it arm 2 measures
arm 1's warm cache.
- **Two workload shapes need two clients:** distinct prompts (batch
behaviour, prefill parallelism) and sequential turns with growing history
(TTFT of the first streamed chunk per turn — the only measurement that
exposes prefix-cache behaviour). These pull in opposite directions: a cold
batch spreads prefill across replicas, a conversation's next turn only pays
if it lands where its prefix is cached.
- **Read the engine's ledger, not the card:** `Available KV cache memory` /
`GPU KV cache size: N tokens` / `Maximum concurrency for M tokens per
request: X.Xx` is the capacity number. vLLM's `Prefix cache hit rate:` line
is a LIFETIME average across every prompt the replica ever saw — send the
same prompt twice to an idle engine and compare TTFTs (measured 22.3 s cold
→ 1.4 s warm for a 50k prefix). `GPU KV cache usage` says the pool is not
full; it does NOT say a prefix survived. The KV budget is NOT monotonic in
chunk size (5.44 GiB at CHUNK 8192, 4.22 at 16384, 4.16 at 4096) — measure
three points before treating a knob as a lever.
- **Soak before believing:** 15–30 min of concurrent families, count failures
AND latency; report hit/miss split and medians with min/max — the median of
a bimodal distribution describes neither mode.
- **Verification that is not the benchmark:** run the client the fleet
actually uses; and a deployed unit is not proven until an unattended reboot
brings it back.
- **Record the "no" answers** with numbers and mechanism — placement balance,
concurrency admission and chunk-size tweaks all measured flat or worse
here; the wall was arithmetic (cached prefixes + live sequences share one
pool).
## 6. Sweep hygiene (the arms that produced wrong verdicts)
- **Prefetch uniformity:** every arm in a sweep carries the same
expert-prefetch setting. The 2026-09-29 campaign measured losing arms with
prefetch ON and incumbents OFF; untaxed, verdicts inverted (no-spec
12.11 → 41.47; gather 32×64 parity → +18.7 %). Never re-read a taxed
verdict.
- **Knob-landed preflight:** for every `-e` knob, confirm
`podman exec <ctr> printenv <VAR>` — a knob the fork silently ignores
produces a "parity" arm that is really the default (v1 of the gather sweep
lost all 3 arms this way; serve-script hooks are additive and can drop
vars).
- **Always run an explicit OFF arm:** the 2026-09-29 campaign's only win was
deleting a baseline's silent `--expert-prefetch 1` (+40–51 %). Challengers
compare against default-off, not "incumbent + one flag".
- **Pin the capacity bar:** `--kv-cache-memory-bytes` (contract) rather than
autofit (remainder) so pool numbers are reproducible; compute need =
tokens × measured B/token (hybrid models: KV dtype does NOT double mamba
states — measure, don't assume 2x).
- **Report decode AND prefill (TTFT) per rung:** a decode-only view nearly
deployed a 2x-prefill-tax config. Per-depth tables, never a single median —
one fork's gather win grows with depth (54 t/s @1k, 39.5 @short).
- Container recreate (`podman rm -f`) wipes `podman cp`'d harnesses — re-copy
`mtp_measure.py`/`bench_iter.py` after EVERY recreate, or mount them.
## 7. Bash laws that ate windows (each cost an arm)
1. `${VAR:+SPEC=$1}` in front of a command expands to a bare word →
`SPEC=3: command not found`, nothing runs. Pass literal assignments:
`SPEC="${1:-}" bash serve.sh`; the callee's `${SPEC:+--flag}` skips empty.
2. `${2WAY:-0}` is fatal `bad substitution` (digit-leading name), and inside
an if/else it aborts ONLY that compound statement — the script CONTINUES,
every arm tears down clean with zero measurements. Detection law: `ls`
the per-arm `.measure.txt` files before trusting any summary; arm echoes
are not evidence.
3. `local a=$1 b=$x` expands `$x` before the builtin assigns — under `set -u`
it aborts AFTER the harness stopped production. Two statements.
4. `sed -i` renames the file; already-running bash keeps the OLD inode —
mid-flight patches miss arms launched in the preceding minutes. Patch
early or run a make-up window.
5. Never heredoc python through ssh→zsh→bash — write to a file, ship it
(`ssh host 'cat > /path && chmod +x /path' < local.sh`); nested quoting
fails silently. Same trap in `git commit -m`: backticks become command
substitution.
6. No comments INSIDE a backslash-continued command (the continuation joins
the comment); verify multi-line command construction with `bash -n` AND a
render test.
## 8. Results location and recovery (frank-primary arms)
| copy | path |
|---|---|
| per-arm evidence | `$ARMS_DIR` = `~/.jobs/<run-stamp>/` (`.rc`, `.done` beside it) |
| summary | `$ARMS_DIR/summary.tsv` = `arm rc decode_tps decode_tps_t0.7 kv_tokens boot_s` + `<arm>.out/.env/.overlay/.rc/.metrics` |
| server-side last window | `/home/dank/.autoresearch-frank-primary/run.json` |
| durable memo | `.../last/run.json` + `last/hash` (promoted only after bench AND verified restore) |
| fallback | `~/.omp/agent/sessions/<ws>/<session>/<N>.{shake,bash}.log` (permanent) |
`/tmp/frank-primary-arms-*` and `/tmp/armstub/` are stub self-tests —
constants 99.9/88.8 identical across arms by construction; **identical
numbers across arms == stub or bug, never a measurement.** After compaction,
re-harvest with `grep -rhoE 'decode_tps[a-z0-9_.]*=[0-9.]+'` over the session
logs. A window costs ~20 min warm; a detached runner's timeout ≥ 3600 s per
window.
## Related
- [[RDNA4 MoE Expert Offload VRAM Budget]] — the plan arithmetic being swept
- [[DFlash DSpark Speculative Decoding]] — spec A/B protocol and shapes
- [[LLM Performance Diagnosis]] — profiling and attribution methods
- [[Remote Job Runner]] — detached execution of long sweeps
- [[frank-medium-llamacpp-dflash2]] — the measured ubatch-optimum history
@@ -0,0 +1,221 @@
---
type: reference
title: LLM Performance Diagnosis
description: Probing why a served LLM is slow — the flat-depth probe, the expert-offload duty-cycle arithmetic, kernel-time attribution via the profiler window, live telemetry without benchmarking, RAM-bandwidth ceilings, IBS tensor attribution and prefix-cache-aware routing.
tags: [performance, profiling, moe, pcie, prefix-cache, routing, telemetry]
timestamp: 2026-10-05T00:00:00-07:00
---
# LLM Performance Diagnosis
When a served endpoint is slower than wanted and someone names a suspect
("PCIe is saturated", "the gather is the bound"), throughput arithmetic cannot
settle it — it is usually circular (it assumes the suspect consumes the step).
Get data in this order; the profiler window alone has retired multi-day
optimization plans.
## 1. Is decode context-bound at all? (the flat-depth probe)
Sweep decode across context depth. **Flat** ⇒ attention over history is not
the cost; the problem is per-token expert movement and KV/attention tuning is
ruled out entirely. A genuinely context-bound server shows a clear downward
slope. Record cold vs warm separately — a small gap means the cache is
working. Measured on the fp8 expert-offload shape: 12.11 tok/s FLAT from 304
to 16,095 tokens.
## 2. Duty-cycle arithmetic (the "GPU not fully utilized" answer)
Symptom: smooth ~25–35 % utilization with 100 % peaks on an expert-offload
MoE. This is the EXPECTED signature, not a fault:
```
per decode step: compute burst ~100 ms (100 %) → expert-gather wait ~400 ms (~0 %)
resident fraction = arena slots / total experts (e.g. 6574/24576 ≈ 27 %)
PCIe bytes/token = miss_rate × lookups × slab_bytes (0.73 × 480 × 2.344 MiB ≈ 0.84 GB)
predicted duty ≈ compute_ms / (compute_ms + wait_ms)
```
Validate the terms against the deployed engine's boot plan
([[RDNA4 MoE Expert Offload VRAM Budget]]). If predicted ≈ observed, the low
average is structural. Note small-transfer reality: bulk bandwidth (91 % of
ceiling at 1 GiB) overstates what 2–3 MiB slabs get (~72 % plus ~20 %
per-transfer latency). Before blaming the link, measure it
([[PCIe Link and Atomics]]) and compare the engine's effective gather rate to
the measured slab-size bandwidth — a 40–50 % gap is the software read path,
not the wire. `rocm-smi --showuse` reads ~0 % during the whole multi-minute
weight load: show VRAM (`--showmeminfo vram`) as the load-progress signal.
## 3. Kernel-time attribution (the profiler window)
Run BEFORE building any replacement kernel.
1. Stop + mask production; boot the shape under test with ONLY the profiler
config added. vLLM argv:
`--profiler-config '{"profiler":"torch","torch_profiler_dir":"/prof","torch_profiler_with_stack":false,"torch_profiler_record_shapes":false,"torch_profiler_with_memory":false,"torch_profiler_dump_cuda_time_total":false}'`
— `with_stack=false` is load-bearing (with stacks the stop hangs and the
trace buffer grows ~40 GB → kernel OOM-kill of the engine core, anon-rss
~95 GB; the worker-side stop can hang 5 min on ROCm/kineto). Bind-mount
`/prof`. If a shell substitutes values into that JSON, verify the escapes
actually landed.
2. Window it: warm request → `POST /start_profile` → N generations of ≥128
tokens (~15 s of traffic) → `POST /stop_profile` (client may time out at
60 s; the trace still lands). `telemetry_report` has ONE caller: the
gpu_worker profiler-stop path. Overhead is small enough to keep decode
comparable (9.6 vs 9.9–10.0 tok/s measured).
3. **Parse ON THE SERVER** (traces are 100–150 MB gzipped, GB expanded).
Rank `ph=="X"` events with `cat in {kernel, gpu_memcpy, gpu_memset}` by
summed `dur` (µs): report % of device time, launch count AND µs/call
(22 µs × 708k launches is a different problem than 4 ms × 4k), plus the
first-to-last event span (fraction of wall the device was busy). Aggregate
by FULL mangled name (template args are the tuning signal) and by stripped
symbol. **Trap: `gpu_user_annotation` events masquerade as GPU time** (a
naive sum reported 63 % in one `execute_context_0(...)` entry; spans
overlap and can exceed the wall clock).
4. Divide by the window's token count → ms/token; if kernel time ≈ step
time, the table IS the optimization plan.
Worked example (GLM-5.3-Flash, 2×GPU, offload, decode 9.9 tok/s):
```
34.8 % 708,500 launches vectorized_elementwise_kernel <- the actual bound
10.0 % 42,000 MoE W4A16 GEMM
8.9 % 83,160 router/top-k (sort-based)
6.5 % 133,920 hyper-connection pre/post kernels
1.05 % 20,664 <the long-suspected expert gather>
```
No amount of link arithmetic would have produced those two conclusions: the
suspect was 1 %, and the mass was pointwise glue from an uncompiled trunk —
whose own boot log names the cause (`FULL_DECODE_ONLY` override). It also
predicted a negative result in advance (spec decode cannot help a shape whose
per-token cost scales with the token count) which matched an unexplained flat
MTP measurement.
## 4. Telemetry without benchmarking (live hosts)
When benching is forbidden (production serving), mine the runtime's own
per-request telemetry — it measures the real workload and costs nothing.
- llama.cpp (root unit, `-lv 3`): `sudo journalctl -u <unit> --since "-3h"
--no-pager` → `slot print_timing` lines (prompt eval, eval, total, `graphs
reused`, `draft acceptance … mean len`, `n_gen = N, tg = X t/s, tg_3s = …`).
- halogen (rootless quadlet): `journalctl --user -u <unit>` (**never sudo** —
per-user storage) → `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`.
- Read the envelope, never one sample: short-prompt `prompt eval` is
overhead-dominated (57 tok @ 168 t/s) — quote the longest available (1106
tok @ 1013 t/s); decode and acceptance are content-dependent (0.46–0.76
measured on one unchanged build); `graphs reused > 0` / `selected slot by
LCP similarity` prove prefix-cache reuse.
- **Attribute PCIe without touching the model:** background `nvidia-smi dmon
-s t -c 16 -o T`, ONE ordinary `max_tokens=200` request, `wait` at the end
(without it ssh closes and the client dies — the journal shows `W srv stop:
cancel task` and NO print_timing while curl still reports plausible
status). Measured: peak ~230 MB/s against a ~7,900 MB/s link = ~3 %, 0 at
idle — the sampled-logits stream, not the transport, is decode traffic on
that shape.
- One `max_tokens=200` request is production traffic and fine; anything that
pins the card is a bench → stop-and-mask first. Check WHICH instance you
are reading (`systemctl status | head -12`; task ids jump across
restarts). The engine's own ledger (`KV self size`, `n_slots`,
`n_ctx_slot`) outranks your model of it.
## 5. The fork's data path (before blaming the link)
Offload engines pick between DMA copy-in and read-through UVA dereference
inside the GEMM. Grep the installed package for the LRU/offload module and
read the BRANCH CONDITION, not just the env names. A token-count threshold
decides the path, and it interacts with spec decoding: an MTP step is k+1
tokens, so the step can silently cross the threshold onto the slow path as
draft depth grows. Check the threshold's premise: a default tuned at
12.4 MiB/expert does not transfer to 2.344 MiB/expert, where copy-in can be
several times faster. TunableOp ghost-skip: `CLAV_TUNABLEOP_SWEEP=1` alone
does NOTHING under expert offload (`CLAV_TUNABLEOP_WITH_OFFLOAD=1` required);
check for the skip line before concluding autotuning ran. Gather-geometry
knobs read at import time → each arm is a full reload.
## 6. RAM-bandwidth-bound MoE ceiling (before recommending a quant)
```
routed experts/token = top_k × n_layers
bytes/token = routed_params × bytes_per_param + shared_expert_bytes
ceiling (tok/s) = measured_bandwidth / bytes_per_token
```
Measure the HOST's real bandwidth, never assume DIMM ratings: 4-DIMM AM5
boards downclock to DDR5-3600 regardless of 5600 sticks (two large DIMMs beat
four small). Single-threaded reads under-report dual-channel ~4x — use the
multi-threaded plateau of a checksumming reader (`scripts` pattern:
`/tmp/bw.c`, 1/2/4/8/16 threads). Worked example (DeepSeek-V4.1-Flash):
6×40 experts × 35.4M params = 8.5B params/token; at 3 bpw + 0.53 GB shared =
**3.7 GB/token** → 42 GB/s ⇒ **11.3 tok/s ceiling**. Sanity anchors: H100
NVL + CPU offload 226 GB ⇒ 5.8–9.0 tok/s; 2×3090 + 768 GB DDR4 ⇒ 12.2.
CPU compute rarely binds below 4 bpw (~5.3 flops/byte ⇒ ~223 GFLOPS at 42
GB/s); PCIe expert STREAMING is worse than CPU compute (Gen5 x8 ~22.7 GB/s
effective < half of RAM); prefill reads the union of a batch's experts
(prefill ≈ bandwidth / total_expert_bytes × ubatch). A quant that fits only
by ignoring the Engram table does not fit (Engram = sparse n-gram lookup,
NVMe-tolerant; the routed experts are not). An iGPU GTT aperture is an ALIAS
of the same RAM, not extra memory.
## 7. IBS model-access attribution (AMD CPUs)
Real CPU access patterns for mmap'd weights, live, no stop (established on
frank-primary, 2026-09-26, ~2.7M attributed samples in 75 s). Reference:
`scripts/frank-primary-expert-activity.py` (self-contained dump + sample +
analyze; `--analyze-only`).
1. GGUF header-only scan (ms for a 50 GiB part) → tensor extents. **Extents
from CONSECUTIVE offsets, never a type-size table** — a wrong per-element
size silently under-covers every tensor and dumps tail samples into a junk
bucket (first run lost 59 % of expert samples; a uniform gap/exps ratio
~1.4 across parts is the tell). Data start = `(tell + 31) & ~31`.
2. `/proc/<pid>/maps` VMAs (7-group regex; pgoff = group 4, path = group 7)
matched against part files; multi-VMA per part is normal.
3. `sudo perf record -e ibs_op// -c 50000 --data -o out.perf -p <pid> --
sleep <dur>` WHILE a mixed workload runs (idle perf numbers mean nothing).
`sudo perf script -i out.perf -F addr` for addresses.
4. Attribute: bisect VMAs, `foff = pgoff + (addr − vma_start)`, bisect tensor
starts; per-expert index = `int(lo / (span / N_EXPERTS))` (expert-major
rows).
5. Verify before believing histograms: gap ≈ 0, every tensor-bearing part
nonzero, and MoE blocks should be FLAT by construction (top-k fires every
block every token) — a non-flat block histogram means mis-attribution
first. Established law: expert routing is per-block flat, per-EXPERT
skewed.
## 8. Prefix-cache-aware routing (multi-replica serving)
Measure with a **sequential-turn client**, not only a batch benchmark — a
batch of distinct prompts parallelises prefill across replicas and hides the
problem. The two failure modes: round-robin loses every other cache hit
(90k-token contexts: warm-turn TTFT 2676 ms round-robin vs 1096 ms pinned);
pinning-everything loses a card (16 requests: 18.6 s spread vs 30.1 s
pinned). **The rule that gets both: route by request shape** — a request
carrying more than one message is a continuation (agent loop re-sending its
context) → pin to the replica that served that prefix; anything else → spread
with a COUNTER, not a prefix hash. Add failover: demote a replica on
connection failure and retry on a live one BEFORE writing anything to the
client (safe pre-stream; never retry after streaming started).
The wall no policy fixes: **a running sequence and its cached prefix draw on
the SAME pool.** Three concurrent 50–80k families against 122,636 tokens/card
thrash under every placement and admission policy tried (counter placement,
load-balanced placement, one-large-request-per-card admission, both chunk
directions — the last COSTS KV in both directions). When the working set
exceeds aggregate KV, say so: the levers are capacity, fewer concurrent
large contexts, or a different platform. Traps: a lifetime hit-rate counter
is not a per-request verdict (probe directly: same prompt twice to an idle
replica — 22.3 s cold → 1.4 s warm); salt corpora; record sample sizes;
`vllm bench serve` needs `--tokenizer <local path>`; verify metric names
against the live endpoint before dashboards. Test shape: drive the routing
function directly with synthetic bodies (stdlib-only policy module);
conversation identity is the FIRST N KiB of prompt — a shorter tested prefix
hashes differently per turn and pinning silently never happens.
## Related
- [[LLM Bench Windows and Measurement]] — the window discipline and usage-counter law
- [[PCIe Link and Atomics]] — link probing and the streaming-vs-compute ceiling
- [[RDNA4 MoE Expert Offload VRAM Budget]] — the plan behind duty-cycle terms
- [[vLLM Porting and Compile Triage]] — the compile fix for the pointwise-glue bound
@@ -0,0 +1,281 @@
---
type: reference
title: Model Swap and Rollback
description: The full model-swap discipline for the LLM fleet — fit preflight, fetch and payload verification, runtime build/flag checks, git-only config, live validation, systemd handoff, close-out, the three-pin revert law, and research-verification discipline.
tags: [models, swap, gguf, huggingface, rollback, deployment, verification]
timestamp: 2026-10-05T00:00:00-07:00
---
# Model Swap and Rollback
Applies to every endpoint swap on the fleet (frank-small / frank-large /
frank-primary). Repo law: all config flows through git; the live models are
host state under `/home/dank/models/<name>/`.
## 1. Fit preflight — never deploy a swap without confirming it FITS
1. Get the checkpoint's on-disk/weight size AND precision from the HF card
(`Precision: BF16` = 2 B/param; FP8/Q8 ≈ 1 B; Q4 ≈ 0.5 B). 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 (host-RAM expert offload, llama.cpp CPU offload,
layer splits). Budget weights + KV pool + OS + page cache; **if weights
alone exceed RAM, ABORT** — pool tuning shrinks KV, it does not shrink
weights.
3. **The failure mode is a self-sustaining thrash, not a crash:** a large
swap makes the kernel PAGE instead of OOM-killing → sshd/user space
starved indefinitely, no recovery window opens by itself; systemd
`Restart=always` turns it into a crash-reload-rethrash loop; `enabled` +
`WantedBy=multi-user.target` re-thrashes on every reboot. A power cycle
does NOT fix it.
Recovery (may need console / SysRq-to-GRUB):
```bash
sudo systemctl mask --now <svc> # break the reboot trap FIRST
cd <checkout> && git fetch origin && git reset --hard origin/main
grep MODEL_DIR <env> # confirm the fixed target
sudo systemctl unmask <svc> && sudo systemctl restart <svc>
curl -s <health> # then a REAL completion
```
Deploy caveat: "healthy after Ns" = systemd ACTIVE, not model LOADED —
confirm with a real `/v1/chat/completions`. RTX 3090 (Ampere) has no
FP8/FP4 tensor cores: FP8 weights run via upcast but must still fit; choose
quant by size fit, then accept the compute path the card supports. Before
choosing a smaller quant, state WHICH of the two you are buying — headroom,
or a design change (a smaller quant does not change what the runtime can
express; per-tensor cross-device placement stays refused under
`--override-tensor`, and a layer split moves only contiguous layer ranges).
## 2. Ground truth first
- Read the HF model card (runtime pin, hard constraints, recommended args)
+ the repo trio `scripts/<svc>.env`, `scripts/serve-<svc>.sh`,
`scripts/<svc>.service` + 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.
## 3. Fetch and verify (HF payloads)
**Inventory by QUANT NAME, never by extension** — large quants ship as
`.partNofM` fragments (Q4_K_M = 37.00+37.00+36.97 GiB), so an
`endswith(".gguf")` filter makes Q4_K_S look nonexistent. Check both
mradermacher repos (`…-i1-GGUF` imatrix, `…-GGUF` plain) and the model's own
repo; the tree API's `blobs=true` gives `lfs.oid` per file. Gated mirrors
return 403 even with a valid token until the account accepts the gate —
prefer the ungated copy.
```bash
# token out of band: mode-600 curl config; a token passed as -H is world-readable
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"
```
`-f` is MANDATORY: `curl -o FILE URL` **exits 0 on HTTP 401/403** and writes
the error body as the artifact (identical 153-byte shards, cheerful DONE,
zero bytes of model). Never `pgrep -a` a running fetch (token is in
`/proc/<pid>/cmdline`); watch progress by size:
`while [ "$(stat -c%s … || echo 0)" -lt "$EXPECTED" ]; do sleep 30; done`.
**Verify, then assemble:** per part, byte count == Hub's declared size AND
`sha256sum` == `lfs.oid`; concatenate in NUMERIC order; assembled total ==
sum of parts (compute the sum with python, not by hand) and `head -c 4` ==
`GGUF`. The assembled file has no published hash — the per-part oids + exact
total are the chain. Keep the fragments when disk allows (only copies
re-verifiable against the Hub); failure artifacts and the replaced payload
go to `~/.AGENTTRASH/YYYYMMDD-topic`, never `rm` — upstreams DELIST quants
and the local file may be the last copy.
Fetch-script one-liners: dedicated scripts (`scripts/download-*.sh`) already
do `-f` + byte-count + LFS sha256 + `--verify-only`; ad-hoc fetches tend to
skip all three. HF cache snapshots SYMLINK every file into `blobs/` and
those links dangle inside containers — serve scripts and the deployer refuse
symlinks and missing tokenizer files for exactly this reason.
## 4. Runtime build (when 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; the API server is
unaffected.
- Verify every flag the serve script passes exists in the new binary BEFORE
touching the service: `llama-server --help | grep -q -- "<flag>"` for each;
list MISSes. Read the GGUF's tensor histogram first if the payload uses a
custom type — the FORMAT picks the runtime ([[GGUF Compatibility]]).
## 5. Config via git only
Edit `scripts/<svc>.env` (paths, LLAMA_BIN, MODEL_FILE, MMPROJ_PATH) + serve
script header/defaults locally; `bash -n`; `./tests/run.sh` must pass;
commit; push; `git pull --ff-only` on the target. Never edit configs on the
remote. Renaming a model dir (`mv`) is host state and allowed. The env-file
hook pattern (`FRANK_*_ENV_FILE`) is what makes bench windows and staged
arms possible — keep it intact.
## 6. Live validation
- No scratch ports — one GPU cannot host two big models. Ensure the old unit
is truly gone first: `systemctl is-active` can hang in `stop-sigterm`
(llama.cpp never preempts an in-flight generation); poll until the PID
exits.
- Manual serve for smoke: `setsid nohup bash scripts/serve-<svc>.sh >
/tmp/x.log 2>&1 &` — plain `ssh 'nohup … &'` dies with its ssh session.
- Verify: health 200; a REAL `/v1/chat/completions`; reasoning flows
(`reasoning_content` non-empty); if the endpoint takes images, a
solid-color probe through an `image_url` content part (legacy top-level
`images` is silently ignored; probe with ≥256 px).
- vLLM specifics: first request after boot can take ~18 s (deferred Triton
loads) — warm and discard; `--reasoning-parser` / tool parsers are part of
the contract ([[vLLM Boot Forensics and Config Interrogation]]).
## 7. systemd handoff — THE TRAP
`./scripts/deploy.sh <host> --service <svc>` gates health, but a leftover
manual server holding the port 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 container runtime PID); only then trust "healthy". If a real
long-context task is queued, do the handoff anyway — client aborts are
recoverable, drift is not.
## 8. Close-out (mandatory)
1. Docs: active runbook updated (build recipe, profile, validation numbers
incl. real-workload prefill/decode); superseded runbook gets a RETIRED
banner and stays as the rollback record; `LLM/index.md` mermaid + active
list; AGENTS.md fleet table + runtime section + history chain; work-log
entry in `serverAppConfigs-docs/log.md`.
2. `~/.omp/agent/models.yml`: `contextWindow` must equal the REAL per-slot
context — llama.cpp logs `n_ctx_slot` per prompt (`--parallel N` divides
the pool; e.g. 262144 native / 2 slots = 131072 per slot); vLLM reports
`max-model-len` and its concurrency line. Overselling causes silent
client-side overflow or a discovered-wall 400. Also `maxTokens` vs the
engine's cap, `input` capabilities (text/image), and
`reasoningContentField` per runtime (llama.cpp/SGLang emit
`reasoning_content`, vLLM emits `reasoning`). Fix every `modelRoles` entry
that named the old endpoint.
3. **Rollback = one env commit** (paths + LLAMA_BIN + flags), prior binary +
payload kept on disk, rollback numbers recorded in the runbook.
## 9. Revert-pin an ad hoc experiment (the three pins)
A campaign is "done" only when all three exist — without them a
`podman pull`, an image update, or a lost scratch script destroys the way
back:
1. **Git tag (annotated, pushed):**
`git tag -a <exp>-tuned-YYYYMMDD -m "<tuned env/flags + measured headline +
runbook path + revert command>"` — a future agent must reconstruct the
campaign from the tag message alone.
2. **Image digest in the serve script:** floating tags move. Capture
`podman inspect <ctr> --format '{{.ImageName}} {{.Image}}'` + `podman
images --digests`, patch `IMAGE=` to `repo@sha256:<repodigest>` with a
comment (image ID, date, old ref), verify `podman image exists
repo@sha256:<digest>` and `bash -n`. Digest refs survive upstream pushes;
`podman pull repo@sha256:<digest>` re-fetches exact bytes. One host can
hold MULTIPLE images of one repo — pin by the digest of the RUNNING
container, never by tag name. Never edit the pinned line back to a
floating tag; new experiments branch from the digest.
3. **Local alias:** `podman tag <id> <exp>-runtime:tuned-YYYYMMDD`.
Record scratch-script paths + full flag surface in the runbook (hook env
vars in CAPS); expired images/scripts go to `.AGENTTRASH`, never `rm`.
Pure measurement sessions that changed no state need only pin 1.
## 10. Research verification discipline (for "can model X run here?")
- Scouted research summaries have shipped fabricated citations on this fleet
(invented HF repos, plausible-but-wrong PR numbers, stale engine versions).
NEVER copy a scout's citations into a deliverable — re-verify each
load-bearing URL directly (GitHub issues/PRs/repos, HF pages) before it
enters anything.
- The in-repo runbooks are FIRST-HAND evidence and outrank any web claim
about our own hosts.
- Verified anchors (2026-09-27): DeepSeek V4.1-Flash (552B/8B-16B active,
CED arch, 1M ctx, MIT; preview was 284B/13B with native FP4+FP8 — early
GGUF conversions garbage); Lucebox = the proven gfx1201 ROCm 7.2 path
(heterogeneous R9700+Strix-Halo V4.1: 86 tok/s decode, 788 t/s prefill@2K).
GLM-5.3-Flash (320B/18B, KDA+DSA+mHC, 1M ctx — ALWAYS bind `-c`, the
default hard-hung a 128 GB host; native FP8 328.3 GB; `-fa off` MANDATORY,
flash-attn breaks MLA correctness; llama.cpp arch `glm5next` lived in
unslothai branch → ggml PR #27754, MTP landed d07e71ede 2026-08-30; quant
ladder UD-IQ1_S 93.1 GB → UD-Q4_K_XL 199.7 GB, GSQ-RCO 3.5-bit −1.40 pp
MMLU-Pro vs Q8_0).
- GGUF parsing gotcha: expert tensors are
`blk.N.ffn_{gate,up,down}_exps.weight` — 4 dot-parts INCLUDING `.weight`;
a `len(parts) == 3` filter matches zero layers with no parse error. Filter
on `parts[0]=="blk" and parts[3]=="weight" and "exps" in parts[2]`.
## 11. Adding a second LLM endpoint to a host
When a host must serve two models at once (an iGPU runtime plus a second
card), or one endpoint is moving and a new one takes the freed hardware.
**0. Decide the shape before touching hardware.** One provider per
HOST:PORT in `~/.omp/agent/models.yml` (on the box where the harness runs)
— a second endpoint is a new provider block, not a second model in the
existing one. Read the budget as an inequality: for unified memory 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 full-attention layers — and remember `--parallel N`
DIVIDES `--ctx-size`. A co-tenant is a budget line, not a footnote: free
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.** New service files
(`scripts/<svc>.env`, `.service`, `serve-<svc>.sh` with hard guards for
missing artifacts); planner entries (`SERVICE_ORDER` + the target's service
set + path mappings, inventories in `deploy.sh`/`deploy-remote.sh`, the
dispatch case); retire the obsoleted pieces into `.AGENTTRASH/` with a
suffix — and check what pointed at them: bench harnesses, a chat template
only the old runtime read, and `scripts/test-changed.sh` mappings are the
usual victims (a mapping to a retired test file fails the pre-commit
wrapper silently for weeks). One bats file per service asserting
INVARIANTS (`pool == 2 × ctx`, KV bytes ≤ card budget, port separation),
not constants; a negative grep must strip comments first
(`grep -vE '^\s*#' file | grep -q …`) or it matches the prose explaining
the very thing you removed.
**2. The port-bind race (rootless podman).** pasta binds the published port
at container CREATE; a just-stopped predecessor still holds it. Order the
handler: retire the old manager's unit → stop THIS service's own unit
(no-op on first cutover) → wait for every socket on the port to clear
([[Container Host Reachability]]) → install → start. Without the second
step a redeploy deadlocks against its own gate.
**3. Wire the surfaces, refuse impossible signals.** Point the harness
small/fast + image roles at the new endpoint; move the vision role WITH the
vision tower (one owner per capability). `shared/homepage/services.json`: a
card per endpoint, probed from the monitor's own host. Prometheus: only add
a job if the runtime actually exposes Prometheus text (halogen publishes
`/health`, `/cache`, `/v1/*` and nothing else — the job was REMOVED with the
reason written down rather than left as a permanent red).
**4. Cutover, then verify by driving it.** `deploy.sh <target> --service
<large> --service <small>` (order matters); per endpoint: `/health`,
`/v1/models` reports the alias, a chat completion returns content AND
`reasoning_content`, a `tools` request 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.
Traps that look like model faults: `500 tools param requires --jinja flag`
(runtime lacks the Jinja path — pin it); `500 Jinja Exception: Unexpected
reasoning effort <level>` (fold via `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).
## Related
- [[GGUF Compatibility]] — which runtime can load a payload at all
- [[Llama Cpp Serving Sizing]] — pricing the shape once it loads
- [[RDNA4 MoE Expert Offload VRAM Budget]] — vLLM-side fit arithmetic
- [[LLM Bench Windows and Measurement]] — measuring the new deployment
- [[dankserver-qwen38-fp8-expert-offload-tuning]] — the current endpoint
@@ -0,0 +1,264 @@
---
type: reference
title: Tcclaviger Fork Ops
description: Operating the tcclaviger RDNA4 vLLM fork on dankserver (shape G era, rollback target since the 2026-10-05 FreeToken cutover) — the ad-hoc sweep harness, measured knob verdicts, sweep hygiene, the large-MoE serving recipe, and compile-cache persistence.
tags: [tcclaviger, vllm, dankserver, expert-offload, sweeps, cache]
timestamp: 2026-10-05T00:00:00-07:00
---
# Tcclaviger Fork Ops
The tcclaviger RDNA4 vLLM fork (`docker.io/tcclaviger/vllm@sha256:da4029666…`,
vLLM 0.29.0.dev, GLM-5.3/Qwen-specialised with native MoE quant kernels and a
first-class expert-offload planner) served `frank-primary` at shape G until
the 2026-10-05 FreeToken cutover; it remains the rollback target
(`git tag frank-primary-vllm-shapeG-20261003`). Full plan law and VRAM
arithmetic: [[RDNA4 MoE Expert Offload VRAM Budget]]; boot forensics:
[[vLLM Boot Forensics and Config Interrogation]]; window discipline:
[[LLM Bench Windows and Measurement]].
## The ad-hoc sweep harness (dankserver)
Serve script `/home/dank/serve_qwen_fp8.sh` with env hooks: `SPEC` (MTP
depth), `TUNABLE` (TunableOp), `PREFETCH`, `UTIL`, `MAXLEN`, `KV_BYTES`,
`MNBT`, `UVA_TOK`, `GATHER_C/GATHER_L` (R4D_LRU gather), `TELEM`
(R4D_LRU_TELEMETRY), `PROFDIR` (torch profiler bind-mount + --profiler-config
JSON). The script re-copies the harness (`mtp_measure.py`, `bench_iter.py`)
into every fresh container — `podman cp` files die on container recreate.
- Measure ONLY via usage counters: `mtp_measure.py` (3 depths) or
`mtp_measure_short.py` (166 ctx). `decode = completion_tokens/(wall −
ttft)`; chunk counting is INVALID under spec decode.
- Jobs: `bash /home/dank/job.sh start NAME CMD…` / `wait NAME SECS 40` /
`out NAME` ([[Remote Job Runner]]).
- **Validity check on ANY speed result:** `completion_tokens == max_tokens`
(else repetition-halt or early EOS), finish_reason, and inspect the text —
`bench_iter` prompts are repeated word-lists + `!` padding, loop-prone. A
loop makes LRU hits ~100% → PCIe traffic vanishes → fake 3–4× decode.
Acceptance swings 48–61% run-to-run — noise; decode is the metric.
- Each arm = full reload: ~8.5 min cold, ~12 min with measurement.
- Readiness gate: `podman logs <c> 2>&1` (stdout+stderr merged) for
`Application startup complete` — it is on STDERR; an unmerged grep can
never match and burns the whole wait timeout.
- GPU util ~0% during load/compile is NORMAL (weights + CUDA-graph capture
are CPU-side); VRAM climbing is the load indicator.
- Config edit discipline: the serve script is patched by scp'd python
scripts with asserts (anchor must exist, addition must not); `bash -n`
after every patch.
## Measured knob verdicts (2026-09-29 campaign)
Incumbent: `SPEC=4 PREFETCH=1 UTIL=0.98 MAXLEN=32768 KV_BYTES=2147483648
TUNABLE=0`, stock gather 8×16 → 26.80/25.15/26.81 tok/s @ 166/1087/4064 ctx.
| Knob | Verdict | Evidence |
|---|---|---|
| Gather geometry `R4D_LRU_GATHER_{CHUNKS,LANES}` | **RULED OUT** | 16×32 worse (~24); 32×64 tie (26.6). The 9.5 vs 22.7 GB/s read-path gap is per-transfer structure (~105 µs/slab vs 78 ideal), not a tunable knob |
| MTP depth | **RULED OUT** | SPEC=3 ~9% worse; SPEC=6 collapses (13.5–19 tok/s, acceptance 17.4%) — also proves UVA read-through (crosses at step tokens > `R4D_LRU_UVA_TOKENS=5`) is SLOWER than copy-in |
| TunableOp | **REJECTED** | `CLAV_TUNABLEOP_SWEEP=1` silently SKIPPED under expert offload unless `CLAV_TUNABLEOP_WITH_OFFLOAD=1`; adds ~7 min/load, persists nothing (container-local), and CORRUPTS NUMERICS (loops, repetition-halt, fake 79.8 tok/s @ 96.5% acceptance on degenerate completions). Any "3× win" with early-EOS + absurd acceptance = the loop artifact |
| MNBT | neutral at 8192 vs 2048 | small values re-chunk prefill only (hurts TTFT, never decode — decode step = max-num-seqs × (spec+1) tokens). No ubatch concept in vLLM |
Decode on this stack is PCIe-gather-bound (~30% GPU duty cycle), so
batch-shaped compute knobs move ~nothing.
The `R4D_*/CLAV_*` knob family (QSA_*, MHC, ROUTE, ACT_QUANT, AR_QUANT,
SILU_QUANT) are kill-switches defaulting ON, not perf dials; real levers are
the R4D_LRU_* family, TunableOp (rejected), R4D_LRU_TELEMETRY, and spec
depth.
Telemetry gotchas: `--profiler-config` takes a JSON blob, not separate
flags; `torch_profiler_with_stack` defaults TRUE — a full measure inside a
profile window OOM-kills the engine worker (~40 GB trace buffer; kernel
kills python3 at ~95 GB anon-rss). Keep windows to ~15 s / one depth
(`mtp_measure_short.py`). `telemetry_report` (`expert_cache_rank<N>.json`)
fires ONLY from the worker's profiler-stop path; worker-side stop can hang
>5 min on ROCm/kineto. No alternative trigger exists. The LRU hit rate
measured 82.45% — NOT the ~27% the arena-fraction arithmetic implies;
temporal reuse dominates, so never infer miss rates from resident-slot
fractions.
## Sweep hygiene (fork-specific preflight)
1. **Knob landed:** for each `-e` env knob, confirm in-container
`podman exec … env` (or the engine log's confirmed-values line). A knob
the fork silently ignores produces a "parity" arm that is really the
default — the v1 gather sweep lost all three arms to exactly this
(GATHER_C/GATHER_L silently dropped, all arms ran stock 8×16).
2. **Prefetch uniformity:** every arm in a sweep MUST carry the same
expert-prefetch setting. The 2026-09-29 campaign measured losing arms
with prefetch ON and incumbents OFF; untaxed, its verdicts inverted
(no-spec 12.11→41.47; gather 32×64 parity→+18.7%). Never re-read a taxed
verdict.
3. **Spec arms must prove engagement:** engine log
`SpecDecoding metrics: Mean acceptance length …` and
`curl :7770/metrics | grep vllm:spec_decode_`. Zero such lines = MTP off.
4. Boot-fail fast-detection: poll `podman ps` INSIDE the health loop; a
refused boot dies in one poll instead of 20 min of health waits. Capture
>8 lines of `podman logs` on failure — the root cause (e.g. a SEQS init
refusal) lives higher up.
5. Report per-depth tables, never one number: g32×64's win grows with depth
(54@1k, 39.5@short) — a single 4064 median hides the short-ctx cost. TTFT
rows are noisy ±0.15 s and non-monotonic across depths; compare same-ctx
rows across arms only.
6. Generate ≥256 tokens per sample (at 8 tokens inter_token is first-token
noise).
Boot-log facts survive measure failures: KV pool size (`GPU KV cache size`),
arena slots (`expert cache: … -> N slots`), and the spec-reservation ladder
(MTP3 766,666 / MTP4 729,545 / MTP6 665,263 tokens) print at BOOT — a
measure-stage bug costs decode tables but not boot-log findings.
**MTP depth-brittleness law (2026-10-02, all arms rc=0):** MTP3 on this fork
is depth-brittle — 47.01 t/s at 166 tokens (47.88 at temp 0.7) but 23.09 at
4064, vs no-spec baseline 37.02 / 33.99. MTP3 + SEQS 16 + MNBT 4096 + util
0.95 + ladder [4..32] = best overall 37.04 / 38.61 t0.7 and keeps 33.15 at
4064. `--expert-precise-memory` is load-bearing for MTP (same arm with it
OFF = worst arm, 19.81 at 4064). Every spec arm costs KV (398,658 → 313,002
tokens). Acceptance is real: mean length 2.4–2.8, per-position
0.76/0.55/0.33, avg 52–57%. Next lever named by the engine: MNBT 4096 is
exactly SEQS×(n+1) (`suboptimal` warning) → try 8192.
## Serving a model larger than VRAM (dankserver recipe)
Dankserver: 2× R9700 (gfx1201), 31.86 GiB each = 63.7 GiB real VRAM, 246 GiB
RAM. `torch.cuda.device_count()` returns 3 and summing `total_memory` gives
a bogus 186.7 GiB (the Raphael iGPU is in the list — see
[[Llama Cpp Serving Sizing]] for the reading discipline).
**Kernel gate — this decides which checkpoint can work at all.**
`/app/r4dhip/r4d.so` exports `gemm_moe_mxfp4a8_*` and `gemm_moe_dsfp4a8_*`
but NO `gemm_moe_w4a16_*`. Expert offload requires an experts class with
BOTH a compiled kernel AND an `offload_recipe`; the planner aborts startup
otherwise. Usable: MXFP4, DSFP4, FP8-HIP (`R4dMxfp4MoEExperts`,
`R4dDsfp4MoEExperts`, `Fp8HipMoEExperts`). `R4dW4a16MoEExperts` has no
kernel (the class exists, the r4d.so entry does not); RFA has neither. Check
the checkpoint's quant against this list BEFORE downloading anything.
Entrypoint is `/app/tools/image_entrypoint.sh`, ending `exec vllm serve
"$@"` — pass the model path as the FIRST argument and do NOT pass `serve`.
Known-good invocation:
```bash
sudo sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches'
podman run -d --name <ctr> --ipc=host --ulimit memlock=-1:-1 \
--cap-add SYS_PTRACE \
--device /dev/kfd --device /dev/dri --device /dev/mem \
-e HIP_VISIBLE_DEVICES=0,1 -e VLLM_ROCM_USE_AITER=0 \
-v /home/dank/models/<model>:/models:ro \
docker.io/tcclaviger/vllm:latest /models \
--served-model-name <name> --port 7770 \
--tensor-parallel-size 2 \
--max-model-len 32768 \
--kv-cache-memory-bytes 2147483648 \
--max-num-seqs 4 \
--gpu-memory-utilization 0.95 \
--enable-prefix-caching --trust-remote-code
```
`-p 7770:7770` is not included, so the API is only reachable via
`podman exec` — add `-p` if host-side curl is needed. Load ~5 min;
`/health` answers connection-refused the whole time; watch for
`Application startup complete` in `podman logs <ctr>`. Verified working:
`orcarouter/Qwen3.8-Flash-Next-Uncensored-FP8` (gated; 172.8 GiB, 131
shards, `Qwen4ExpForConditionalGeneration`, 48 layers, 512 experts top-10,
multimodal, native 262,144).
Traps that each cost a boot:
1. **Mount the whole `/dev/dri`, never individual render nodes.** Render
nodes without the primary `card*` nodes → HIP enumerates two agents and
refuses a context on either (`RuntimeError: No CUDA GPUs are available`)
while `device_count()` cheerfully returns 2. Confirmed by direct A/B.
2. **memlock must be raised** (done on Dankserver via
`/etc/security/limits.d/99-memlock.conf`). Host default 8 MiB makes any
large pinned allocation fail as a misleading
`torch.AcceleratorError: CUDA error: out of memory` from `pin_memory()`;
it cannot be raised from inside a container or root's own shell — only
PAM applies it at login. Matters because `--expert-host-mlock` defaults
True.
3. **`--gpu-memory-utilization` does NOT buy KV under expert offload** —
the planner sizes the arena against the budget and leaves KV the
remainder; raising util 0.95→0.98 grew the arena and the KV pool stayed
at 0.64 GiB.
4. **Lowering `--max-model-len` is the WRONG lever and backfires** — the
budget subtracts the KV reserve first, so a smaller context shrinks the
reserve and hands the space to the arena (measured: 65536 → 0.64 GiB KV;
32768 → 0.34 GiB).
5. **The KV reserve undercounts hybrid-attention models ~2×** (9190 B/token
planned vs ~18,000 real: the Mamba state is a fixed per-sequence cost).
Fix by pinning the pool: `--kv-cache-memory-bytes`.
6. **`--expert-offload-mem` caps the HOST side**, not the VRAM arena —
structurally orthogonal to `--expert-precise-memory` (verified
in-container 2026-10-05: precise-memory only swaps the GPU overhead term,
expert_offload_mem only sets the host bound; they rejoin only inside
`place_experts`, and the plan rewrites `expert_offload_mem` back to the
cap the plan met, so plan and allocation cannot drift).
7. `--ipc=host` and `--shm-size` are mutually exclusive in podman; pick one.
8. `podman run -d` without `-a` sends no stdout to a redirect — the log file
only receives the container ID; use `podman logs`.
Status reads: use `scripts/dankstatus.sh`, never hand-rolled
`/proc/meminfo` parsing — `/proc/meminfo` separates key from value with a
TAB, so `grep MemAvailable | tr -s " " | cut -d" " -f2` returns empty and
awk prints `0.0` — a false "host out of memory" that invites an unnecessary
abort. Gated checkpoints: token via a mode-600 `curl -K` config, never argv
(`pgrep -a curl` prints it); always `--fail`; compare each file's byte count
to the Hub's declared size; `-C -` resumes truncated shards.
**Check whether the stack supports the model natively before porting an
overlay.** The image already registered `Glm5NextForConditionalGeneration`,
`Qwen4ExpForCausalLM` and both MTP variants — chasing four missing symbols
through two image tags to bring an overlay up was entirely wasted work.
## Compile-cache persistence (survives `podman rm -f`)
Serve scripts that recreate the container discard the writable layer, so
every boot re-pays compile/JIT (frank-primary: `torch.compile took 181.20 s`
cold, of which 173 s was one graph).
1. **Prove it and locate it:** fresh container vs live
(`podman run --rm --entrypoint bash IMG -c "ls -la /root/.cache"` vs
`podman exec LIVE bash -c "du -sb /root/.cache/*; find /root/.cache -type
f | wc -l"`). vLLM cache root: `VLLM_CACHE_ROOT` (default
`/root/.cache/vllm`, `torch_compile_cache/`); a HIP runtime adds
`/root/.cache/comgr`.
2. **Mount the PARENT, not the leaf** — one bind on `/root/.cache` persists
both trees, safe whenever the image ships no such directory (step 1
proves it; otherwise you would be hiding baked state). Wire as an opt-in
tunable: `mkdir -p "$CACHE_DIR"` BEFORE the mount, then
`${CACHE_DIR:+-v "$CACHE_DIR":/root/.cache}`. Host path outside the
checkout.
3. **Verify with two consecutive boots** — cold `Compiling a graph … takes
N s`, warm `Directly load the compiled graph(s) for compile range (…)
from the cache` with `torch.compile took` in single-digit seconds.
4. Traps: the cache key includes the compile RANGE — an MNBT change costs
ONE recompile, not a permanent tax; do not claim a proportional boot cut
(time to `/health` is dominated by weight loading: 490 s warm vs 188 s
cold compile); stale entries are misses, not wrong answers (version +
config hashed); if the serve script is seam-gated, assert the mkdir and
the exact mount target.
## Plan-line verification checklist (after any fork deploy)
```
ssh dankserver 'sudo journalctl CONTAINER_NAME=frank-primary-qwen -b -o cat --no-pager \
| grep -aoE "(budget \(total x util\) +[0-9.]+|expert cache space +[0-9.]+ GiB +[0-9,]+ slots|Estimated CUDA graph memory.{0,80}|Graph capturing finished.{0,40}|GPU KV cache size.{0,90})"'
```
`grep -oE` with a digit-anchored tail — plain `grep`/journalctl truncate
these columns to `17.`. Verify: new budget/graphs/arena in the plan;
`GPU KV cache size` matches the requested token count; `Estimated CUDA graph
memory` vs the capture line (raise `_PER_GRAPH_MB` toward 52 if capture
exceeds the charge); `rocm-smi --showmeminfo vram` high-water per card = the
real margin; `systemctl show frank-primary -p NRestarts --value` = 0;
`VLLM_CUDAGRAPH_*` present in `podman exec … env`; then a real chat
completion from a PEER — `curl localhost:7770/health` inside an ssh session
on the host can return `000` and looks like an outage that isn't.
## Related
- [[RDNA4 MoE Expert Offload VRAM Budget]] — the plan law this harness tunes
- [[vLLM Boot Forensics and Config Interrogation]] — journal forensics for these boots
- [[LLM Bench Windows and Measurement]] — window discipline and bash laws
- [[Model Swap and Rollback]] — where the fork sits in the swap history
- [[Remote Job Runner]] — the job.sh harness referenced above
@@ -123,3 +123,27 @@ Stop and disable the root unit, trash it, restore the halogen quadlet from
`~/.AGENTTRASH/20260915-ik-swap/halogen-quadlet` to
`~/.config/containers/systemd/`, `systemctl --user daemon-reload`, start;
the .hgn checkpoint and the kingjones STRIX llama.cpp payload remain on disk.
## Hybrid campaign envelope (2026-09-16, 3090 + Strix Halo)
Why this deployment could not be tuned into a production endpoint: 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.
The decode root cause is structural: ~100 CPU↔GPU backend splits per token
with the GPU at 5 % SM during decode — launch/sync-bound over the PCIe x4
dock, not compute- or memory-bound. T16 profiling shows ~90 % of cycles in
`pause` (ggml_barrier spin): threads wait on the serial critical path.
Memory placement (mmap/anon/THP/deferred) does not matter — all variants
landed 3.8–4.6.
- CUDA-graph capture aborts: qwen4exp's hyper-connection broadcasts use
per-layer `ggml_repeat_4d`, whose CUDA path is capture-illegal; 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
4× slower than ik's iqk) — do not switch engines for speed.
- MTP/speculative decoding is impossible for mradermacher i1 GGUFs: the
NextN head is stripped (no `blk.48` tensors).
- Tuning lever ranked first: `-ncmoe 42` (6 layers' experts on the 3090) →
decode 4.54, small-prompt prefill 32 — but VRAM 22/24.6 GiB at 262k ctx,
an operator OOM-margin decision, and it needs `GGML_CUDA_NO_PINNED_WEIGHTS=1`.
@@ -134,6 +134,59 @@ slots of 131072, `6.44 / 6.90 GiB` free — matching the pre-deploy measurement
0.01 GiB — a tool call and two concurrent requests answered, draft acceptance on
both slots (0.63 / 0.48 on slot 1, 0.39 on slot 0), and no ROCm errors.
## The split: re-measure it at the deployed shape
The batch and the tensor split are **coupled**: compute buffers are not
symmetric across the two cards, so any batch change moves the balance point.
Order of work — sweep batch with llama-bench (above), THEN 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.
The settle gate is mandatory (above); wait for BOTH cards under ~1 GiB AND
the port to be free (`ss -ltn | grep :7770` until gone) before the next
load, or the transient server dies with `couldn't bind HTTP server socket`.
Free bytes follow `card = weights_share + KV_share + constant + ubatch_term`,
where the ubatch term costs the drafter-carrying card roughly **1.85×** the
other (measured 2.41 GiB vs 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. Reference: 36,29 @1536 → 4.71/4.91;
36,29 @512 → 6.01/7.32; **35,30 @512 → 6.44/6.90 (deployed)**.
Re-fit both ubatch coefficients in `tests/frank_medium_unit.bats` at the new
deployed point and re-anchor the per-card constants; check neighbouring
splits still FAIL the balance gate or the seam has stopped discriminating
(keep the 1.5 GiB/card floor and the one-DeltaNet-layer slack). Deploy,
then verify live: flags in `/proc/<pid>/cmdline`, `/slots`, VRAM matching
the pre-deploy measurement, a tool call, two concurrent requests,
`draft acceptance` lines on BOTH slots, 0 ROCm errors.
**Per-card utilization (who is hot, and the split lever)** — a different
question from batch/prefill. Measure decode tok/s AND per-card
`rocm-smi --showuse` at 2–3 neighbouring splits in ONE session. Pitfalls
(each cost a re-run): run the transient unit as ROOT (a `pkill -9
llama-server` as dank auto-restarts the service and `systemctl` as dank
fails "Access denied"); warm up 3 throwaway requests before the timed one
(first 384-token decode after load measured 1.77 tok/s against a true ~32);
`rocm-smi --showmeminfo vram --csv` columns are `$2`=Total, `$3`=**Used** —
parsing the wrong column spins the settle gate the full 600 s; the util% is
sampled post-hoc (which card finished last), so treat it as "which card is
the pipeline tail", not a duty cycle.
**What the split does and does not buy (measured 2026-09-17, 393216 /
MTP n=3):** the split is NOT a decode-throughput lever — 34:31, 35:30, 36:29
all sit in the same ~32 tok/s band. The x1 card runs hot (~91–97 %) at
35:30 because the MTP drafter + output layers + heavier compute buffers are
pinned to the card holding the LAST layers — not because PCIe is the
bottleneck (~5.2 MB of boundary activation crosses per 512-token ubatch).
36:29 makes the x16 card the utilized worker (42 %/3 %) at no throughput
cost but spends headroom (0.45 GiB free, under the floor) — Frank's standing
preference is that the card with the FASTEST PCIe lane should be the more
utilized one; deploying it requires re-fitting the seam's slack/floor and
re-checking that 35:30 and 37:28 still fail so the gate keeps discriminating.
## Limitations
- **`llama-bench` cannot run the drafter, the slots or the deployed context.**
@@ -45,6 +45,98 @@ timestamp: 2026-09-30
> because the fork compiles its own Qwen trunk and not its GLM one. See
> [The measured profile](#the-measured-profile-and-the-real-wall).
## DFlash2 target-side port — the four laws and the KV ladder
Distilled 2026-10-05 from the probe windows (skills `glm53-dflash-boot-ladder`
/ `glm53-dflash-eagle3-port`); each law was proven by a real failed boot.
**The four EAGLE3-port laws** (make the GLM trunk satisfy `SupportsEagle3`):
1. **Patch the instance the runner calls.** `model_runner.py:425` calls
`set_eagle3_aux_hidden_state_layers(self.model)` on the *loaded* model —
GLM loads as `Glm5NextForConditionalGeneration` (multimodal wrapper;
`self.language_model = Glm5NextForCausalLM`). Members placed only on the
CausalLM fail the check on the conditional — same error text, wrong file.
Patch BOTH wrappers.
2. **Presence-checked member set.** `SupportsEagle3` (interfaces.py:1659) is
`@runtime_checkable`; `isinstance` walks ALL `__protocol_attrs__`
(including base-protocol members) with `getattr_static`, breaking on the
first miss. Both wrappers need, as class attributes:
`supports_eagle3: ClassVar[Literal[True]] = True`,
`has_own_lm_head: bool = False`, `has_own_embed_tokens: bool = False`.
Verify in-image with stock typing — no boot needed.
3. **MRO forbids inheriting the Protocol.** GLM wrappers carry four/five
Protocol-derived bases; adding `SupportsEagle3` anywhere dies with
"Cannot create a consistent method resolution order" (sim-proven: the
ordering rejects it in every base position). The attribute route avoids
the MRO, so the two methods must be written by hand: the CausalLM
unwraps to `self.model` (the `EagleModelMixin` trunk), the conditional
delegates to `self.language_model` (pixtral.py:498 pattern);
`get_eagle3_default_aux_hidden_state_layers` returns `(2, L//2, L-3)`,
with the drafter config's `target_layer_ids` winning when present.
4. **Trunk capture point:** append `aux_hidden_states` BEFORE the layer call
— GLM's loop carries the single-stream state entering the layer.
**The KV blocker ladder** (patches `scripts/patches/glm53-dflash-0*`):
1. EAGLE3 gate → presence-checked attrs on both wrappers (above).
2. KV page-size unifier: MLA layers cannot be padded (sparse MLA reads
whole token rows) but CAN grow block_size when the max page is an exact
multiple — bump `max_page_size` in `unify_kv_cache_spec_page_size` to
the `lcm` of all MLA-family pages first (patch 02).
3. kpool tail assert `tail_kv_cache.shape[2] == pool_size`: the unifier's
divisible branch grew KpoolTailSpec's block; its kernel pins block ==
index_kpool. Add a KpoolTailSpec branch that pads `page_size_padded`
instead of growing (patch 03).
4. KV capacity: the glm5 layout ALIASES mamba states INTO MLA pages (why
GLM fits 32 GiB cards); the generic path (taken when draft/hidden specs
exist) cannot alias → ~30 GiB demanded vs a 0.92 GiB reserve. Extend
`_get_kv_cache_groups_glm5_next` to partition
HiddenStateCacheSpec + drafter attention specs; the drafter forms its
own uniform group (per-group pages, NO unification → the MLA block
stays at the engine-auto-set 2,432 from interface.py:918, kpool guard
`block_size % 512 == 0` unstressed); layout returns a 10-tuple
(+draft_group, +hidden_groups); the builder sizes ONE shared backing
allocation (`allocate_kv_cache` asserts all KVCacheTensor sizes equal) =
aliased pool + appended draft/hidden tensors (patch 04). After 04 the
capacity requirement collapsed 31.76 → 1.88 GiB at 65,536 max len and
the engine SERVED (READY ~500 s, dflash2 fused prep ON, drafter FULL
graphs on both TP ranks).
5. Reserve registry: `_glm53_kv_bytes_per_token` must add 4,096 (drafter
KV) + 5×hidden×2 (aux hidden cache) keyed on
`speculative_config.method in {dflash, dspark, extract_hidden_states}`.
NEVER key on `use_aux_hidden_state_outputs` — it lives on the
gpu_model_runner instance (gpu_model_runner.py:623/678), unset at plan
time, so the check silently fails and the reserve stays 13,600
(patch 04b). Proven reserve math at 65,536: 13,600 + 4,096 +
5×4096×2 ≈ 58,656 B/token → ~4.15 GiB reserve, arena ~10.3 GiB.
**Verify cheaply before booting:** smoke-test the pure KV logic on CPU
torch with the image's real spec classes — stub `vllm.logger`/`vllm.utils.*`,
register the spec classes in `KVCacheSpecRegistry` with a dummy manager, add
`from __future__ import annotations`, and run synthetic GLM+draft+hidden
spec sets through the patched grouping/layout/builder functions. A 7-minute
boot per hypothesis is the alternative.
**Window discipline:** stop + mask `frank-primary` FIRST and gate on
MemAvailable ≥ 230 GiB before booting the probe (the expert-offload planner
reads MemAvailable; beside-prod boots die at plan time with `ExpertPlanError:
host holds 5113 of 12096 swappable experts`). Save `podman logs glm-probe`
to a file BEFORE teardown — a `podman logs | grep | head` pipeline died once
under pipefail via SIGPIPE before log-save, losing the capture. The abort
path must restore prod (unmask + daemon-reload + reset-failed + start),
never bare-exit after stop+mask. Launcher knobs: `DFLASH_PATCH=1` (mounts
the patched model.py), `DRAFT_MOUNT=1`, `SPECJSON_FILE=…` ({"method":
"dflash","model":"/draft","num_speculative_tokens":8}) — note the launcher's
echo prints `patch=$PATCH_MOUNT` (the COMPILE knob), not DFLASH_PATCH.
Boot timeline: EAGLE3 gate fires ~4 min in at worker init; drafter
torch.compile is the frontier (~380 s).
**Open telemetry discrepancy:** the contaminated window read 1.5–2.1 tok/s
with acceptance 1.00–1.25; Frank live-measured ~18 tok/s decode / ~370 tok/s
prefill. Reconcile with a clean single-request measurement before trusting
either.
Related: [[dankserver-qwen38-fp8-expert-offload-tuning]] (the same fork, the same
hardware — its conclusion was drawn from throughput arithmetic and is superseded
by this page's kernel-time measurement),
@@ -110,6 +110,32 @@ retires the lucebox user quadlet to dated home trash
truly free, relinks + enables + restarts the root unit, and waits up to
1800 s for `/health` (cold start page-ins ~175 GiB and builds the graphs).
### Preflight the GGUF without downloading it
GGUF metadata lives in the header and HF honours ranged GETs, so a few MB
answers every sizing question before a 13 GB transfer
(`Range: bytes=0-8000000`). 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 for a summary: a naive parser
desynchronises on `tokenizer.ggml.tokens` and lands gigabytes off. Read:
`general.architecture`, `<arch>.block_count`, `<arch>.context_length`,
`<arch>.nextn_predict_layers` (MTP is a property of the FILE — the `-mtp`
build carries `blk.<n>.nextn.*`; `--spec-type draft-mtp` then loads the
head against the MAIN model's weights, and `--model-draft` would load the
file twice), `<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 `clip.projector_type` +
`clip.has_vision_encoder`.
KV arithmetic for the hybrid (16 × (3 Gated DeltaNet + 1 Gated Attention)):
only full-attention layers take KV
(`attn_layers × n_head_kv × head_dim × 2 × bytes_per_elem`; f16 = 2 B,
q8_0 = 1.0625 B, q4_0 = 0.5625 B); DeltaNet state is a fixed per-slot
buffer (~0.16 GiB/sequence at 48 layers/128 d_state/48 v-heads f32); the
MTP draft context costs ≈ 4 KiB/token. `--parallel N` DIVIDES `--ctx-size`
between slots — it does not multiply.
## Verification (2026-09-26 deploy)
- `/v1/models` reports `frank-primary`.
@@ -146,3 +146,70 @@ systemctl --user start frank-medium`) and stop/mask frank-primary. The
_Filled in after the cutover: health, real completion, tool call, Open WebUI
completion, measured prefill/decode, `systemctl --user is-enabled/is-active`
state._
## Dual-R9700 follow-up (2026-09-28 probe era)
After this single-card runbook was retired, the DS41 engine got a dual-R9700
attempt on the `feat/ds41-engine` branch (`main` rejects the checkpoint with
`unsupported model architecture 'deepseek41'`). Findings that outlive the
probe:
**Budget both cards explicitly, or you waste half the box.** The engine
sizes the streamed-expert cache LAST, after the model, so uncapped it absorbs
every spare byte on the stream device — symptom: card1 at 33.0/34.2 GB
looking busy while card0 sits at 14.2/34.2 GB; the busy card is holding a
cache of things still streaming from NVMe. Pin all four:
| env | card | meaning |
|---|---|---|
| `LUCE_EXPERT_BUDGET_MB` | hip:0 | primary hot experts (~11.96 MiB each) |
| `LUCE_EXPERT_SECONDARY_BUDGET_MB` | hip:1 | secondary experts (needs `LUCE_DS4_MOE_TP*`) |
| `LUCE_EXPERT_STREAM_CACHE_MB` | hip:1 | cap the auto-sized stream cache |
| `LUCE_EXPERT_STREAM_DEVICE=1` | hip:1 | which card holds the slots and computes them |
Working full-card shape: 21000 + 20000 + 8000 → card0 ~28.6/31.86 GiB,
card1 ~27.3/31.86 GiB, resident experts 3,360 of 15,360.
**Expert TP DOES work on gfx1201+gfx1201.** A retired quadlet recorded
`LUCE_DS4_MOE_TP_INPROC` as "qualified for gfx1151+gfx1201, not
gfx1201+gfx1201" — that is WRONG; peer access negotiates and the secondary
tier comes up clean: `[deepseek4-moe-tp] enabled mode=in-process local=hip:0
secondary=hip:1`. It is the only lever that materially raises resident
expert count, and resident count is the entire bottleneck. Confirm every run
with `expert ownership:` and `hybrid experts ready:` lines.
**The placement file must live under the mounted models dir.** JSON
`{"owners": [...]}`, 15,360 entries indexed `layer*384+expert`, values
`primary`/`secondary`/`stream`. It is a ranking, not a promise: each device
keeps what fits its budget and demotes the rest. Anywhere outside
`/models/...` gives `cannot open expert placement` and a SILENT single-tier
fallback — a silent-wrongness failure, not a crash.
**V4.1 refuses layer split and layer-major prefill** — only `--ds4-prefill
exact` (token-by-token) is legal on a 32 GiB card and `--ds4-fused-decode`
is refused there. With ~22% of experts resident each token pulls ~187 cold
experts × ~12 MiB; if `/proc/<pid>/io` `read_bytes` climbs into terabytes
the model is larger than the box's RAM and you are in page-cache thrash at
the NVMe ceiling — a capacity verdict, not a tuning problem. Low GPU util
with a pegged link is the EXPECTED signature of PCIe-expert-stream-bound
decode, not a broken run.
**Harness traps, each of which produced a wrong number:** never `env -i` the
podman call (rootless podman runs as `dank`; `env -i HOME=/root` makes
PODMAN try `/root/.config` and die `stat /root/.config: permission denied`
before the container exists — pass container env with `-e`); never put a
large prompt on curl's argv (`Argument list too long` at ~128 KB — use
`--data-binary @file`); size prompts by growing the file until `wc -c`
reaches the byte target AND assert depth against the engine's own
`usage.prompt_tokens` (nominal 4 chars/token was really 5.73; a chunk loop
that appends a fixed ~152-byte line per nominal 200-byte chunk undershot
30%); the server runs as the container's PID 1, so tear down with
`podman rm -f`, never host `pkill`; drop page cache between arms
(`sync; echo 3 > /proc/sys/vm/drop_caches`) or the first arm reads warm and
every later one is a lie.
Read the placement log — it is the whole diagnosis
(`arch=deepseek41 layers=40 embd=5120 experts=384 used=6 ff=2304
hash_layers=0` — hash_layers=0 is correct for V4.1; `engram: layers=[1,14]
… 2 table(s) embedded (not mapped)` — the 189.13 GiB Engram tables are never
mapped, so they do not evict the expert page cache).
@@ -190,6 +190,70 @@ repo env sets `R9V_ENABLE_PREFIX_CACHING=1` (commit `41e9189`), verified
on :7770 (`--enable-prefix-caching` in the container argv, health 200,
live completion answering).
## Authoring a new profile (the qualification pipeline)
Order matters at every step:
1. **Package manifest** `packages/models/<pkg>/package.json` — target shards
+ aux artifacts with sha256. Verify every target's byte count AND sha256
after fetch (`curl -fL -C -` per shard; never trust a fetch without `-f`).
2. **Placement bootstrap** from GGUF headers: parse all shards' tensor
tables (a KV-only shard1 is normal for split parts; each part carries its
own tensor table). Per-expert full per layer =
(`ffn_gate_exps` + `ffn_up_exps` + `ffn_down_exps`)/512; rank slices
rank0 = full×416//640, rank1 = full−rank0. Hot counts =
floor(measured_budget / per-expert total). **Anchor budgets to the
MEASURED hot_bytes of the qualified sibling profile, never invented.**
3. **Trim + validate**: `tools/trim_experts.py --hot-counts N,M`, then
`expert_budget.validate_manifest(m)` +
`expert_budget.expert_memory(m, slots, {cache_rank}, runtime=<runtime.json>)`
(runtime/catalog are keyword-only). Declared hot_bytes must match
computed or it raises.
4. **PLE derive** (containerized `tools/prepare_ple.py` with
`--expected-bytes/--expected-type/--expected-shape`); setup skips
re-derivation when the file exists with matching size.
5. **profile.env**: set values BEFORE `source dual-r9700/profile.env`
(set-first-wins; an empty string does NOT survive `:=`).
The canonical bootstrap.json wrapper schema is exactly
`{schema: "r9v.placement.v1", id, model_package, hardware, artifact_path,
artifact_sha256, scope}` with `hardware: "dual-r9700-32gb-asymmetric"`; the
wrapper `id` MUST equal the id in the profile.json placement descriptor
(setup rejects mismatches: "does not match profile placement"). Never
fabricate qualification metadata — use
`scope: "Unranked routing bootstrap only; does not certify fit or headroom"`.
GGUF parsing laws for the bootstrap: expert tensor names are
`blk.0.ffn_up_exps.weight` — split on "." gives FOUR parts, layer number is
parts[1] (a 3-part assumption crashes `int("")`); outer-KV value-type sizes
`{0:1,1:1,2:2,3:2,4:4,5:4,6:4,7:1,8:str,9:arr,10:8,11:8,12:8}`; tensor byte
extents from consecutive OFFSETS, never a type-size table (mis-buckets tails
silently); the PLE Q8_0 tensor on this arch is shape (160, 320001536),
54,400,261,120 B, an entire shard.
Setup/start: `./r9v setup <alias> --state-dir <dir> --model-dir <dir> --image
sha256:<digest> --local-image --reuse-from <sibling-model-dir>
--accept-model-license` (aux artifacts reuse byte-identical files; F16 vs
Q8 mmproj will not reuse). Start requires the SAME `--state-dir`; fixed
placement via `R9V_EXPERT_MANIFEST_PATH` skips headroom/catalog/seed raises.
The host-only doctor FAILs when another instance holds the cards (it wants
3 GiB free per rank; production at ~30 GiB/card is a hard wall) — a second
instance CANNOT coexist, so qualification requires a stop+mask window for
the incumbent, then unmask+restart.
**Qualification gate (unqualified → qualified):** first start runs the
workload battery (PONG / tool_calls / vision / long-ctx), the PLE pinning
proof (`VmLck` ≈ PLE bytes in `/proc/<container-pid>/status` when
RESIDENCY_MODE=pinned), and a VRAM headroom snapshot (floor 3.3 GiB). If
headroom is violated, lower hot counts via re-trim and re-derive
MAX_EFFECTIVE. Only then record state `ready: true` and author the runbook.
Remote-execution gotchas from the same windows: `nohup X &` inside an ssh
command races ssh teardown — the script can die before writing a byte; run
long remote scripts synchronously, or setsid + disown, or via a system
unit. zsh login shells abort on unquoted `===` separators; grep globs need
quoting through ssh+zsh layers.
## Ops
- **Restart**: `sudo systemctl restart frank-primary` (root unit; the serve
+36
View File
@@ -407,6 +407,42 @@ frank-small, port 7771]
- [[Llama Cpp Code Diff]] — code-level quantization diff of the three runtimes + server recommendations
- [[SGLang]] — the structured-generation serving engine; when it fits Dankserver (2x R9700) vs Dankclaw (Strix Halo)
## Cross-Cutting References (skill distillation, 2026-10-05)
One polished reference page per skill-cluster, distilled from the managed
skill library into the vault (see [log](../log.md) for the wave plan):
- [[GGUF Compatibility]] — what a GGUF's tensor histogram and header demand
of a loader; how the runtime is chosen by the file
- [[Llama Cpp Serving Sizing]] — slot/pool/KV sizing laws, boot-shape
laddering, llama-bench discipline
- [[Llama Cpp Multi-Device Splits]] — layer vs tensor splits, `-ts`/`-ot`
laws, drafter placement
- [[PCIe Link and Atomics]] — the link walls, atomics routing, and the
heterogeneous-pair verdicts
- [[RDNA4 Container Serving]] — bring-up recipes for RDNA4 containers
- [[RDNA4 MoE Expert Offload VRAM Budget]] — the plan law, every arena
lever, and why free VRAM is not arena
- [[vLLM Boot Forensics and Config Interrogation]] — reading a boot that
died, and interrogating a running fork's config
- [[vLLM Porting and Compile Triage]] — porting checkpoints across vLLM
versions/forks; torch.compile/dynamo breakage triage
- [[LLM Bench Windows and Measurement]] — window discipline, honest A/B,
the window script laws
- [[LLM Performance Diagnosis]] — slow prefill/decode triage on live
endpoints, IBS attribution, prefix-cache routing
- [[Model Swap and Rollback]] — the swap procedure, payload preflight,
research verification, second-endpoint bring-up
- [[Tcclaviger Fork Ops]] — the fork's sweep harness, measured knob
verdicts, large-MoE recipe, compile-cache persistence
- [[Heterogeneous Split Triage]] — triage for unequal-accelerator split
proposals (the no-go verdict format)
- [[FreeToken Builds]] — the CUDA and ROCm build paths with their
dependency traps
These sit alongside the operational pages under
`../Operations/index.md` and `../Agent/index.md`.
## Related
- [Project knowledge base](../index.md) — the Podman app stacks on the same
@@ -0,0 +1,128 @@
---
type: reference
title: Container Host Reachability
description: The two podman/netavark reachability quirks on the fleet — containers cannot dial unpublished host ports (LAN IP or loopback), and netns membership decides what "reachable" means. Probes that prove it, fixes, and the pasta port gate.
tags: [podman, netavark, networking, rootless, reachability, caddy]
timestamp: 2026-10-05T00:00:00-07:00
---
# Container Host Reachability
Two failure families on rootless podman + netavark hosts (Dankclaw), both
reading as "service down" when the service is up and the *path* is wrong.
Fleet source of truth: `serverAppConfigs-docs/Architecture/Container Network
Namespaces.md` (commit `2e179b0`).
## Quirk 1 — LAN-IP dialing of an unpublished host port refuses instantly
A container cannot reach a `Network=host` service at the host's LAN IP.
netavark DNATs **published** ports only: from a bridge container,
`192.168.0.143:<published>` answers while `192.168.0.143:<host bind>` and the
gateway `10.89.1.1` are both refused. This is what 502'd
`hermes.franksdank.cloud` until the Caddyfile upstream became
`host.containers.internal` on 2026-09-16 — its vhost comment had claimed the
LAN IP was open from that bridge; it never was.
Probe that proves it in one command:
```bash
ssh <host> "podman run --rm --network <net> --entrypoint sh \
docker.io/library/busybox:latest -c \
'for p in <target-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'"
podman exec <svc> bash -c 'exec 3<>/dev/tcp/host.containers.internal/<port> && echo ALIAS-OK'
```
Published OK + target BAD ⇒ quirk confirmed. Note busybox `nc -z` reports any
failure as failure — use it for bulk sweeps; use `/dev/tcp` (or `nc -v`) when
refusal-vs-timeout matters.
Fix: point the service's host-facing URL at
`http://host.containers.internal:<port>` in compose/quadlet env, redeploy via
git (`deploy.sh <target> --service <svc>`), verify
`podman exec <svc> printenv <URL_VAR>` and the connect log line (~40 s).
**Do not publish the daemon port** to make the LAN IP work — publishing is
exactly what makes other ports refuse.
## Quirk 2 — addressability is per netns, not per host
- `NetworkMode=host` → the container **is** the host netns: dials all of
`127.0.0.1`, binds any free port.
- bridge/named net → own netns; only published ports are reachable, via the
network's **gateway IP**. Unpublished host listeners refuse **instantly
(RST)** — no firewall rule, no stale DNAT, `nft`/`iptables -t nat` empty. Do
not hunt for REJECT rules; probe.
Probe sequence (read-only):
```bash
podman inspect <name> --format '{{.HostConfig.NetworkMode}}'
sudo ss -ltnp 'sport = :<port>' # host netns only — why 0.0.0.0 can still be unreachable
podman exec <name> cat /proc/net/route # default route = the gateway IP to dial
podman exec <name> sh -c 'timeout 2 bash -c "exec 3<>/dev/tcp/<gw-ip>/<port>"; echo rc=$?'
```
Interpretation without blurring:
- **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.
Tool gotchas (podman 5.x, verified live):
- `podman network inspect <net> --format '{{json .containers}}'` errors
(`can't evaluate field containers in type interface {}`) — list membership
from the container side: `podman ps -a --format '{{.Names}} {{.Networks}}'`.
- An **empty** `Networks` field = host network or shared namespace
(`--network=container:<other>`), not "no network".
- Quadlet `Network=<name>.network` yields the runtime name **`systemd-<name>`**
— probe with `podman network ls`, never 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 the port
reachable from every namespace **and the LAN**, an exposure change needing
explicit operator approval, never a silent fix.
## Related trap — the pasta port gate (rootless published ports)
On hosts publishing ports through pasta, the port is bound in **userspace at
container CREATE**, not when the app binds. A predecessor stopped moments
earlier can still hold listener/TIME_WAIT state, so the replacement dies
`pasta: ... bind: Address already in use` (conmon exit 126) — then **self-heals
seconds later** under `Restart=on-failure`. The failure looks like it failed,
then looks fine, and the missing gate stays invisible because the retry masks
it. Observed 2026-09-16 cutting `frank-large` from a root unit to a rootless
Quadlet.
Gate procedure: stop the old unit AND the new one (a redeploy's running
replacement is itself the port-holder — without that stop the gate deadlocks
against itself), then wait for the port to be empty across **all socket
states**:
```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 still held after ${limit}s:" >&2
ss -tan "sport = :$port" >&2 || true
return 1
}
```
`sport` catches accepted-connection server sockets and TIME_WAIT residue that
`ss -ltn` misses; fail loudly with the socket table attached. Verify the gate
by re-running the same deploy against a running service (idempotency) and
assert it in seam tests (`wait_port_free` present in the deploy handler).
## Related
- [[Quadlet Lifecycle]] — the unit layer these containers run under
- [[Container Network Namespaces]] — fleet network namespace source of truth
- [[Observability]] — the alerting paths that depend on reachability
- [[Deploy State Triage]] — the deploy flow that installs these configs
@@ -0,0 +1,331 @@
---
type: reference
title: Fleet Audit and Posture
description: Three standing audits — monitoring completeness (what should exist vs what is merely down), vhost proxy health decoding, and the behavioral security posture audit; plus the stash supersession check before dropping old work.
tags: [audit, security, prometheus, caddy, monitoring, git]
timestamp: 2026-10-05T00:00:00-07:00
---
# Fleet Audit and Posture
## Monitoring gap audit
Use after any reboot, or whenever Prometheus targets look wrong. Two failure
modes, 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 absent. So
check **existence, not health**:
```bash
podman ps -a --format '{{.Names}} {{.Status}}' # includes dead ones
ls ~/.config/containers/systemd/*.container # what should be boot-persistent
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. If the compose file still names it, the repo's own rule is being
violated — fix by writing the quadlet (mirror the same service on the other
host), deleting the compose entry (one owner per container name), and making
the deploy path reach it: planner service-set + path rule, dispatcher
allowlist `<target>:<service>`, handler installing from `"$TARGET/..."` never
a hardcoded peer's path.
**2. Classify every down target — do not report a count.**
`targets?state=active` gives health **and** last error; two down targets can
mean opposite things:
| Symptom | Meaning |
|---|---|
| `connection refused` | service not listening → real outage (podman ps, logs, exit codes) |
| `404` on `/metrics` | exporter path wrong or metrics disabled → config defect, service fine |
| `unsupported Content-Type …` | scraping an HTML *page* as metrics → probe inert; Prometheus 3 refuses HTML; a real probe needs a blackbox exporter |
| target absent | §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])"
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://<target>/metrics # hand-probe before believing the scraper
```
"7 of 11 up" without the four reasons is how a real outage hid behind three
config defects on this fleet — the crash-looping GPU service was one of four
failures reported as a number.
**3. Exposure posture while you are in there.** An exporter bound to 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: `ss -ltn | grep <port>` must
show two explicit addresses, never `0.0.0.0`.
**4. Leave decisions that are not yours.** Fix mechanical drift (missing
unit, wrong path, wildcard bind). Do NOT disable a probe to green a
dashboard, or "fix" a crash-looping service whose posture depends on owner
intent — capture the evidence (restart count, exit code, last error line) and
hand it over intact.
## vhost proxy health probes
After deploying or auditing a Caddy/Anubis front door, verify **from a
client**, not from the backend's localhost — that is where transport
mismatches, auth walls, and dead upstreams show up.
```
curl -s -o /dev/null -m 6 -w "%{http_code}\n" https://<vhost>/ping
curl -s -o /dev/null -m 6 -w "%{http_code}\n" https://<vhost>/
```
A status code is a diagnosis, not a failure:
- `200` on `/ping` from *arrs = alive; **auth state unknown** (`/ping`
bypasses auth by design) — probe `/` to decide.
- `404` = backend **alive**, route missing (qBittorrent has no `/ping`; 404
there = healthy).
- `502` = Caddy could not reach/negotiate with the upstream: dead backend
**or transport mismatch**. Probe the backend port directly from the proxy's
network (`ssh <proxy-host> curl http://<backend>:<port>/…`). Direct 200 +
vhost 502 ⇒ config bug, not an outage.
- Login page to anonymous (grep body for `password`) = auth ON; instant
redirect to `/transferlist/`-style routes = auth OFF = open door.
**Transport-mismatch mechanism:** `transport http {versions h2c 1.1}` makes
Caddy send an h2c upgrade preface; .NET/Kestrel backends (Jellyfin) reject it
→ 502 per request while the same port answers 200 on plain HTTP/1.1. Fix =
drop the override (default HTTP/1.1). Opposite twin: Plex PMS drops plain
HTTP off-loopback and needs a TLS transport. Match the transport to what the
backend speaks.
**Shell trap when gating commits on a test suite:** `grep -c "^not ok"` exits
**1 when zero mismatches** — inside an `&&` chain it silently kills
everything after it while still printing `0`. Wrap:
`(./tests/run.sh | grep -cE '^not ok' || true) && git commit …`.
## Security posture audit (behavioral)
Answers "what can the internet touch, and is everything else actually
gated?" — **all findings must be behavioral probes**, not config reads.
**1. Inventory (source of truth).** `dankclaw/Caddyfile`: classify every
vhost — guarded (`reverse_proxy anubis:8923`), exempt (direct upstream), or
redirect-only. Public DNS: `dig +short +time=3 +tries=1 <name>.franksdank.cloud
@1.1.1.1` per name — a record at the WAN IP = public; NXDOMAIN = LAN-only by
DNS absence.
**2. Guard chain (expect the challenge, not the app).** Per guarded vhost:
`curl -sk https://<vhost>/` and grep the body for
`anubis|challenge|proof-of-work` (~8 markers on a live challenge page). A 200
with NO markers means the app is served directly — the guard is bypassed.
**3. Exempt vhosts — per-app gating probes.** OpenWebUI `/api/v1/users/me` →
401. Gitea `/api/v1/user` → 401; repo visibility `/api/v1/repos/dank/<repo>`
→ 404 = private. Pi-hole any path → 401 (Caddy basicauth edge). Plex `/web/`
→ 302 (login is JS-rendered). Calibre-Web `/` → 302 to `/login`. Jellyfin
`/Users` → 401. Prowlarr/Sonarr/Radarr `/api/v3/system/status`; Lidarr/Readarr
`/api/v1/system/status` → 401 = gated, 200 = fully open. qBittorrent
`/api/v2/torrents/info` → 403 without SID. Hermes/hermesadmin `/` → 302 to
`/login`. IdP (`auth.`): `/` → 302 → `/if/flow/default-authentication-flow/`
→ 200 (flow HTML is React; grep "password" finds nothing — the 200 on the
flow URL is the proof).
**4. WAN port scan — from an EXTERNAL vantage.** LAN→WAN hairpin is
unreliable; scan from gluetun's ProtonVPN egress:
```bash
ssh dankclaw 'podman exec gluetun sh -c "for p in 21 22 53 80 443 3000 3005 8080 \
8081 8082 8083 8085 8096 8787 8923 8989 9000 9090 9119 9696 7878 8686 3100 \
5055 32400 7770 7771 2019; do timeout 2 wget -qO- http://71.227.227.172:\$p/ \
>/dev/null 2>&1 && echo OPEN: \$p; done"'
```
Confirm the egress is truly external first (`wget -qO- https://api.ipify.org`
→ ProtonVPN IP, not Comcast). **Trap: plain-HTTP probes against TLS ports
(443, 32400) are false negatives** — port open, protocol mismatch; re-probe
with `wget -qO- https://IP[:port]/`. busybox wget has no `--secure` flag; it
errors instantly and the `&&` chain silently skips the echo (a sub-second
wall time on a "timeout" scan means the flag failed, not that ports closed).
**5. Residuals.** Grafana basic-auth residual: `curl -u admin:admin
http://127.0.0.1:3002/api/org` → 200 means `admin:admin` still live
(loopback-only; the vhost path needs Anubis + OAuth2 first). LLM binds:
`sudo ss -tln | grep -E ':(7770|7771)'` — `0.0.0.0` = LAN-reachable with no
auth (OpenAI API, no token). Stale-record check: current WAN IP =
`ssh frankbook-pro 'curl -s https://api.ipify.org'` (residential egress);
compare against every public A record — a record at a different Comcast IP
is stale.
**Baseline (2026-09-21):** WAN: only 80 + 443 open. Public names: `www`,
`search` (guarded) + `ai`, `gitea`, `plex`, `books`, `jelly` (own auth, all
verified) + `auth` (IdP, by design); everything else LAN-only. Open findings:
LLM endpoints 0.0.0.0 auth-less on the LAN; Grafana `admin:admin` loopback
residual; `plex.franksdank.casa` stale (73.59.75.210 = previous Comcast IP);
`plex.franksdank.cloud` WAN-broken (Plex Web 302s to LAN origin
192.168.0.235:32400); vestigial *arr handlers on the :8017 post-auth listener
(dead config, no exposure).
## Stash supersession audit before drop
Use when a repo (especially a live-deployment checkout where the working tree
IS the deployment) has an old stash whose content may have been superseded by
later commits, and dropping blindly risks losing unique facts.
1. **Inventory**: `git stash show --stat stash@{0}`.
2. **Probe for unique content per file**: `git show "stash@{0}:<path>"` and
diff against HEAD; count lines present only in the stash version. A file
with zero unique-vs-HEAD lines is fully superseded — safe to drop.
3. **Classify each unique-content hit**: *stale fact* (superseded by a later
refactor, e.g. old model naming) → drop is correct; *live durable fact*
(an operational invariant never re-recorded) → extract it into live
docs/memory or flag to the user before dropping.
4. **Archive before dropping** — zero loss: tag the stash commit, then drop:
```bash
git tag archive/stash-<YYYY-MM-DD>-<base-branch>-wip "stash@{0}"
git stash drop "stash@{0}"
```
The tag pins the full stash object including its index parent; recoverable
via `git show archive/stash-…` or `git checkout archive/stash-…^3 -- <path>`
for the untracked-files third parent. Stashes are invisible in normal history
and expire from reflogs (~90 days); tagging converts "drop" into a
reversible archive at zero workflow cost.
## Whole-fleet audit (git/test/live/docs cross-check)
Deep-audit the homelab fleet — git/test state, live service health, and
docs-vs-live cross-check. Run from the dev checkout
(`/home/blank/Projects/serverAppConfig`).
**1. Probe the live fleet — self-identifying, ONE ssh per host.** Do NOT
trust the display order of parallel SSH outputs: they can render crossed and
you will attribute host A's output to host B (2026-09-21: a false host↔IP
"conflict" dissolved by a single self-contained per-host probe). Put
`hostname` + a distinctive marker + the LLM identity in the SAME command:
```
for ip in 192.168.0.143 192.168.0.235; do
ssh -o BatchMode=yes dank@$ip \
'echo "host=$(hostname)"; \
echo "romm=$(podman ps -q --filter name=^romm$|wc -l) caddy=$(podman ps -q --filter name=^caddy$)"; \
echo -n "llm7770="; curl -s -m3 http://127.0.0.1:7770/v1/models|head -c60; echo'
done
```
Ground truth (verify, don't assume — IPs can move): 192.168.0.143 = dankclaw
= AI stack + `frank-large` :7770 + `frank-small` :7771 (RTX 3090 + Strix
Halo iGPU); 192.168.0.235 = dankserver = media stack + `frank-primary`
:7770 (2× R9700). Repo dirs + live hostnames agree; the deploy tooling is
hostname-anchored (`ssh dankclaw`/`ssh dankserver` via `~/.ssh/config`), so
it is unaffected by IP drift.
**2. LLM endpoints — health + identity.** `curl /health` per endpoint, then
`/v1/models` (first 120 chars) — a 200 with the right model `id`/`name` is
the pass condition.
**3. Tests — the deploy completeness gate.** `./tests/run.sh` (bats) must
end `ALL TESTS PASSED`; `tests/e2e/run.sh` runs Playwright against the LIVE
fleet. **A single-test flake is normal**, not a regression: the plex crawl
intermittently lands on `app.plex.tv`, the grafana SSO test occasionally
times out, and the FrankBook-Pro leg fails when the mini sleeps. Re-run just
that spec (`npx playwright test --project=standard specs/<n>-<name>.spec.ts`)
to classify before alarming. A `deploy.sh` e2e gate can fail on a flake
while the change itself is fine — verify the actual change live, then re-run
the suite.
**4. Repo state.** `git status --short --branch; git log --oneline -20;
git stash list; git worktree list` (prune stale worktrees), and
`du -sh .AGENTTRASH` for staged deletions awaiting purge approval. This is a
**shared checkout with parallel instances**: uncommitted peer WIP and
stash-list churn are expected. Never commit or drop a peer's files. To
deploy with a dirty tree: `git stash push -u -m "..." -- <peer paths>`,
deploy, `git stash pop`. `deploy.sh` refuses a dirty tree.
**5. Prometheus — expected-down targets (not outages).** Query
`/api/v1?query=up` from inside the container; the structurally red-by-
construction jobs (`comfyui` HTML scrape, `services` up/down-as-scrape,
`gitea` exporter off) are documented in [[Observability]] — anything else
down, especially a `node-*` or LLM job, is a real gap. The recurring trap: a
same-host `net=host` bind (node-exporter :9100, llama-server :7770/:7771) is
a *host bind*, not a published port, so the bridge-networked Prometheus
container cannot reach it at the host's LAN IP — netavark DNATs published
ports only. Scrape same-host services via
`host.containers.internal:<port>`; verify the fix from inside the container
before editing: `podman exec prometheus wget -qO-
http://host.containers.internal:<port>/metrics`.
**6. Cross-check docs vs live.** Confirm the AGENTS.md host↔IP↔hardware↔
stack mapping against step 1. The LLM endpoint table and `models.yml` are
probed live and stay current; the prose host labels are the part that goes
stale after an IP move. Report discrepancies; do not "fix" prose from a
single crossed probe.
## Bulk-redact an identity from a docs + config tree
Removing a retired host / machine / project name from a repo that holds both
documentation and live config. Evidence-first, surgical, reversible.
**1. Inventory before touching anything.** Count hits **per file** (the
count decides whether this is a 5-minute job or a user-input decision),
excluding logs/`*.db`/runtime state, and capture `git status --short` first
— you must know which files carry in-flight peer WIP so you never stage
them.
**2. Classify every hit:**
| Bucket | Test | Action |
|---|---|---|
| **Narrative** | prose describing the thing | Reword to the class ("the workstation", "a client box") |
| **Load-bearing** | the identifier IS a lookup key, hostname match, or config value | **Keep.** Prove it: read the code that consumes it |
| **Evidence** | quoted logs, transcripts, reports | Keep timestamps/lines; de-identify only the host field |
Load-bearing examples that look like prose but are not: `install.sh` doing
`PKGS=("common.pkgs" "$HOST.pkgs")` with `HOST="$(hostname)"` (the manifest
*filename* is the key); a `--host <name>` case list; a Wi-Fi SSID. Hardware
model strings, driver versions, PCI addresses are subject matter, not
identity. State the boundary in the commit message.
**3. Subdirectories: distill, don't rename or keep.** For a per-host doc
tree (`docs/system/<host>/`), ask before acting — rename/trash/distill are
genuinely different. When distilling: read every page, sort into
still-true-and-machine-independent (fold into a general page) vs
specific-to-the-retired-box (let go), then `mv` the directory to
`.AGENTTRASH/YYYYMMDD-topic/` and repoint every index. Wikilinks resolve by
filename anywhere in the tree, so deleting a page can break a link in a
file you never grepped — run the link checker.
**4. Execute with count assertions, never blind sed.** Per-file mapping of
`(path, old_exact_string, new_string, expected_count)`; abort the entry if
the count differs; exact strings, not regex. **Do not hand this to a
subagent** — observed: dropped list markers so bullets stopped rendering, a
numbered list with two `3.` items, "corrected" paths to nonexistent
directories, a deleted sentence where a reword was required. If a subagent
already did it: `git checkout --` its touched paths (excluding the WIP
files from step 1) and redo it.
**5. Audit:** `grep -rniE '<name>|<its-ip>' <scope>` (expect nothing), then
`git diff -U0 -- <scope> | grep -E '^\+[^+]'` and **read every added line**
— the diff is the step that catches damage; the grep only proves a string
is gone. Then the repo's own linter.
**6. Commit scoped** — only your own paths, never `git add -A`. If the
checkout IS the live deployment, committing on the current branch is usually
correct; a branch switch can swap live config out from under a running
process. Push/PR only when asked.
**7. Report the residue honestly** — what still carries the name and why:
excluded scopes, load-bearing config, git history, and the cost of rewriting
history (force-push + re-clones). "Purged from the working tree" is a true
claim; "purged" alone is not.
## Related
- [[Observability]] — the monitored stack
- [[Stale Signal Audit]] — the down-target decision table
- [[Observability Provisioning]] — adding scrape targets and rules
- [[Homepage Status Probes]] — the status page that renders probe states
- [[Deploy State Triage]] — the deploy plumbing the audits exercise
@@ -0,0 +1,272 @@
---
type: reference
title: Fleet Ops Playbook
description: Incident-shaped fleet procedures — LLM endpoint down with an abandoned GPU holder, scratch vLLM boot windows (stop-first law), iterating on a fleet service before commit, incident-window reconstruction, and waking a sleeping host.
tags: [operations, incidents, gpu, systemd, wol, forensics]
timestamp: 2026-10-05T00:00:00-07:00
---
# Fleet Ops Playbook
## Endpoint down, connection refused — GPU holder reclaim
Use when `:7770`/`:7771` gives **connection refused** (not a timeout) and the
service was not crashed. Applies to `frank-medium` (dankserver, root unit),
`frank-small` (dankclaw, root unit), `frank-large` (dankclaw, rootless
Quadlet).
**1. Classify the stop before touching anything**
```bash
ssh dankserver "systemctl is-enabled frank-medium; systemctl is-active frank-medium; \
systemctl show frank-medium -p LoadState,UnitFileState,ActiveState,SubState,NRestarts"
ls -l /etc/systemd/system/<svc>.service # symlink to repo = unmasked; -> /dev/null = masked
```
`inactive` + `enabled` + unmasked + `NRestarts=0` → **intentional stop**,
never restored. No crash; do not look for segfaults. `NRestarts>0` /
`activating` → different path; read the journal first.
**2. Are the GPUs actually free?** A check, never an assumption:
```bash
rocm-smi --showmeminfo vram | grep Used # idle baseline ~60 MiB/card
ps -eo pid,comm,etime,args | grep -Ei 'llama|bench' | grep -v grep
```
Cards idle → `systemctl start <svc>` and verify (step 5). Cards busy with no
prod unit → get the holder, and compare its bytes against the service's
requirement before concluding a start would even fit (frank-medium 35:30
needs ~30.8 GiB ROCm0 / ~28.9 GiB ROCm1; a stray holding 15+17 GiB blocks the
restart on allocation, not config).
**3. Identify the holder and prove it is abandoned**
```bash
ps -o pid,ppid,user,lstart,etime,comm -p <pid>
sudo readlink /proc/<pid>/cwd /proc/<pid>/exe
tr '\0' ' ' < /proc/<pid>/cmdline # port, ctx, split
sudo ls -l /proc/<pid>/fd # where stdout/stderr go
sudo ss -tnp | grep <port>
awk '{print $14+$15}' /proc/<pid>/stat; sleep 10; awk '{print $14+$15}' /proc/<pid>/stat
```
Abandoned **only if all** hold: utime+stime **identical** across the 10 s
sample (frozen CPU, not just low); **zero** established connections; bind
scope `127.0.0.1` (loopback scratch, invisible to endpoint monitors); log
file (fd 1/2 target) last written long ago, last lines showing success not a
hang. Active CPU or live connections → someone's running session. Stop and
ask.
**4. Reclaim**
```bash
cp -a <logfile> <logfile>.preserved-YYYYMMDD # evidence outlives the kill
kill <pid> # SIGTERM first; exits <1 s
rocm-smi --showmeminfo vram | grep Used # expect ~60 MiB baseline
```
Never `pkill -f` a pattern present in your own command line.
**5. Restore and verify — health 200 alone is NOT proof**
```bash
sudo systemctl start <svc>
for i in $(seq 1 60); do curl -s -m 5 -o /dev/null -w '%{http_code}\n' \
http://127.0.0.1:<port>/health; sleep 10; done # 503 while loading -> 200
sudo tr '\0' ' ' < /proc/<MainPID>/cmdline | grep -oE '\-\-(tensor-split|split-mode|ctx-size|port|spec-type|parallel|flash-attn)'
```
Prove with a **real completion** from off-host — content, reasoning, spec
counters:
```bash
curl -sS -m 180 http://<host>:<port>/v1/chat/completions -H 'Content-Type: application/json' \
-d '{"model":"<alias>","messages":[{"role":"user","content":"Reply with exactly: OK"}],"max_tokens":64,"temperature":0}'
# choices[0].message.content / .reasoning_content / timings.draft_n, draft_n_accepted
```
Record the runtime fingerprint (`<bin> --version` → `b<build>, commit <sha>`)
and confirm it is the pinned runtime. Append a dated entry to
`serverAppConfigs-docs/log.md`, run `./tests/run.sh`, commit, push (origin is
the local Gitea on Dankclaw — push allowed).
**Gotchas seen here:** dankserver's login shell is ZSH — never start an
unquoted word with `=` in an ssh one-liner (`echo ===` aborts the chain). A
freshly restarted endpoint under live agent-queue backlog may return empty
bodies/timeouts for minutes while `/health` is 200 — re-probe with a long
timeout. First request after a halogen (`frank-large`) restart can go silent
45–60 s and self-recover; do not restart over it. Rootless `frank-large`:
read logs **without** sudo (per-user storage). If the restore never happened,
the sudo journal names who and when: `journalctl -b | grep sudo`.
## Scratch vLLM boot on a fleet GPU host — stop-first law
Any scratch/experimental vLLM container on a host that also runs a production
LLM service (`frank-primary`, `frank-large`, `frank-small`) MUST stop the
production unit first, and the window script must verify it. Two independent
failure modes if skipped:
1. **Expert-offload planner dies on host RAM.**
`vllm/model_executor/offloader/expert_plan.py` reads `MemAvailable` at
plan time. A production rank worker holds tens of GiB of unevictable
pinned expert rows (frank-primary: ~76 GiB × 2 ranks), so a beside-prod
boot sees ~86 GiB instead of ~239 and dies at plan time:
`ExpertPlanError: host holds 5113 of 12096 swappable experts per rank`.
Looks like a planner bug; is pure RAM contention. Signature: container
exits within ~60 s, after `Resolved architecture: …` but before worker
init; `free -g` showing `available ≈ 86` while prod runs is the smoking
gun.
2. **GPU ownership.** Two vLLM containers cannot share the GPUs regardless
(the fleet's benchmarking law). Scratch ports (`:7771`/`:7773`) are fine
only once the prod unit is down.
Window skeleton (dankserver):
```bash
sudo systemctl stop frank-primary.service
sudo systemctl mask --runtime frank-primary.service # relinked unit: plain mask refuses; runtime mask self-clears at reboot
sleep 3
podman ps --format "{{.Names}}" | grep -q frank-primary-glm && { echo "prod still running - abort"; exit 1; }
# ... launch scratch container, watch logs ...
podman rm -f <scratch> # stuck vLLM ignores SIGTERM
sudo systemctl unmask --runtime frank-primary.service
sudo systemctl reset-failed frank-primary.service
sudo systemctl start frank-primary.service
# restore oracle is a CONJUNCTION: health 200 AND is-active active AND is-enabled enabled AND no /run/systemd/system mask symlink
```
The deploy restores the unit after bench windows, so stop is sufficient
there; `mask --runtime` is for scratch boots that must survive a stray
restart. Always end a window by re-checking the conjunction before
reporting — if a restore after failure doesn't happen automatically, prod
stays DOWN. See the repo's benchmarking policy (AGENTS.md) and
[[LLM Bench Windows and Measurement]].
## Iterating on a fleet service before commit
The repo's contract is git → `deploy.sh` → host, but live testing needs the
new file on the host first, and the naive way (copy into the host checkout)
blocks the very deploy that would install it. The loop:
1. Edit locally; run `./tests/run.sh`.
2. Ship working files **for testing**:
`cat local/file | ssh <host> 'cat > /home/dank/serverAppConfigs/<path>'`
(then `chmod +x` if executed — `cat >`/scp drop the exec bit and a
container entrypoint fails status 126, "found but not executable").
3. Test against the live service; iterate.
4. **Before committing**, prove the host copy is byte-identical to what you
are about to commit (`sha256sum` both ends) — otherwise step 6 loses work.
5. Commit and push.
6. Clean the host checkout so the deploy can pull: untracked hand copies →
`mv` to `/tmp/handcopy/`; modified tracked files → `git checkout -- <paths>`
(safe only because of step 4); confirm `git status --short` prints nothing.
7. `./scripts/deploy.sh <target> --service <svc>`.
Traps, in the order they bite: the **pull aborts on hand copies** ("untracked
working tree files would be overwritten by merge" — exactly the file you
shipped); `git checkout --` silently eats uncommitted work without step 4; a
runtime may **mount files straight out of the checkout** (`dankserver/frank-medium/**`,
`shared/grafana/provisioning/**`) so a hand copy takes effect at next
container restart with no deploy; root-owned files from a container write
need `chown` back before git operations; and **a restart is not a
verification** — verify the *specific* claim (new code path's own output,
file checksum on the host, the API serving the resource).
Why not keep a scratch copy on the host? The deployed artifact must be the
committed one. Probes may live anywhere; runtime state lives in git.
## Incident window reconstruction
Use when the question is **"what was running when it broke?"** and the answer
must come from artifacts, not recollection. Three independent clocks,
cross-checked:
1. **Per-run duration** — scripts wrap commands in `time` → `real 4m19.129s`.
2. **Log mtime** — last write of the log.
3. **Journal first-failure instant** — first error line from the affected
service.
`start ≈ mtime − real` for the last run in a log; each run nests inside the
previous one's window; the run whose `[start, end]` contains the journal's
first-failure instant is the answer.
```bash
ssh <host> 'ls -l --time-style=+%m-%d_%H:%M:%S <logdir>/*.log | awk "{print \$6, \$7}"'
ssh <host> 'grep -ah "^real" <logdir>/*.log'
ssh <host> 'grep -aE "^####|^\+ /path/to/binary" <logdir>/<name>.log | sed "s/\(.\{130\}\)/\1\n /g"'
ssh <host> 'journalctl --user --since "<date> <HH:MM>" | grep -a "invalid internal status" | head -2'
```
Traps (all hit for real): `/tmp` does not survive a reboot — launch
experiment scripts from a persistent path (`/home/dank/models/logs/<tag>.log`
survived while `/tmp/x4*.sh` died); a log's mtime is not proof a run finished
(truncation with the fd still held keeps growing); long command lines are
display-truncated — wrap before reading or you report parameters never used;
off-by-one filenames (`x4b.log` vs `x4b-experiment.log`) silently skip
evidence — list the directory and match exactly; **overlap is a hypothesis,
not an observation** unless a sampler proves it; do not invent the cause —
reconstruct the *window* and keep the *mechanism* separate (`journalctl -k |
grep -i oom`); publish "trigger not established" when the proof is absent.
Fleet-specific: 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 — it timestamps the incident to within one healthcheck interval (how
the 18:58:30 Dankclaw break was pinned to a bench still loading).
Worked example (2026-09-16, Dankclaw): log mtimes x4e-run 18:58:29 /
x4f-run 18:59:56; durations E1 `real 4m53.946s`, F `real 5m24.785s` (exit 143
= killed); journal first failure 18:58:30 ⇒ **F** was in flight (launched
18:54:31, inside its window).
## Wake-on-LAN for a sleeping fleet host
A fleet host is asleep (100% ping loss; `ip neigh show <ip>` shows STALE or
FAILED) and you need it up — typically the FrankBook-Pro Mac mini
(192.168.0.112, **MAC `de:6b:6d:c1:52:23`**, sleeps and breaks the e2e
`11-admin` spec).
- **Find the MAC**: `ip neigh show <ip>` — even a STALE entry carries it;
fallback Pi-hole/DHCP leases or fleet docs.
- **Send the packet**: `6×0xFF + 16×MAC` = 102 bytes, UDP to port **9** (and
7), broadcast. The dev workstation has **two interfaces on 192.168.0.0/24**
(Wi-Fi + wired USB-Ethernet, default route metric 100) — a plain
`sendto("255.255.255.255", 9)` leaves only the default-route interface.
Bind explicitly to each interface's local IP and target the subnet
broadcast `192.168.0.255` as well as `255.255.255.255`; burst ~15 packets
per interface ~0.2 s apart. `wakeonlan` is not installed; use a raw UDP
socket with `SO_BROADCAST=1`:
```python
import socket, time
MAC = "de:6b:6d:c1:52:23"
mac = bytes(int(x,16) for x in MAC.split(":"))
pkt = b"\xff"*6 + mac*16 # 102 bytes
for local, dest in [("192.168.0.83","192.168.0.255"),("192.168.0.104","192.168.0.255"),
("192.168.0.83","255.255.255.255"),("192.168.0.104","255.255.255.255")]:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
s.bind((local, 0))
for _ in range(15):
s.sendto(pkt,(dest,9)); s.sendto(pkt,(dest,7)); time.sleep(0.2)
s.close()
```
- **Poll for the wake**: a Mac mini takes **30–90 s**; `ping -c 1 -W 1` every
5 s for ~2–3 min before concluding failure.
- **If it does not wake** (the common case): macOS honors WoL only when BOTH
are enabled on the mini — **System Settings → Battery → Options → "Wake
for network access"** = ON, and the active port's **"Enable Wake for LAN"**
(Network → [interface] → Details). If either is off, no packet from any
source wakes it, and you cannot change it while asleep. A full power-off
(smart plug cycled) ignores WoL entirely. Ask the user to toggle the
setting, then re-fire.
## Related
- [[LLM Bench Windows and Measurement]] — the bench windows this playbook arms
- [[Remote Job Runner]] — supervising the long jobs that create these windows
- [[Container Host Reachability]] — the pasta port gate at container create
- [[Quadlet Lifecycle]] — the unit layer behind stop/mask/start
- [[Model Swap and Rollback]] — swap-time incidents and rollbacks
@@ -0,0 +1,204 @@
---
type: reference
title: Payload and Disk Reclaim
description: Standing procedure for disk pressure on a fleet host — measure all buckets first, classify against the runbook index, prove nothing live references the payload, stage to trash with fingerprints, purge only on approval; plus the purge-and-refresh protocol with fetch verification.
tags: [operations, disk, podman, payloads, huggingface, trash]
timestamp: 2026-10-05T00:00:00-07:00
---
# Payload and Disk Reclaim
Standing procedure for "clean up old models" / "disk is full" on a
serverAppConfigs host. Order matters: **measure, prove nothing live uses it,
stage, ask.** Staging frees nothing until the user approves the purge — say so
up front or the request reads as unfulfilled.
## 1. Measure the whole filesystem, not the category asked about
```sh
du -sh /home/dank/models /home/dank/.local /home/dank/.cache \
/home/dank/comfyui-data /home/dank/GitPackages 2>/dev/null
df -h /home/dank | tail -1
podman system df # reclaimable image space
du -sh ~/.local/share/containers # where rootless storage physically is
```
The answer is usually NOT the category the user named. On Dankclaw
(2026-09-28) retired models were 510G, but podman images were **279G
reclaimable (95%)** and uv+pip caches **73G** — 352G of regenerable garbage,
bigger than the models. Lead with that finding; it changes the decision.
Check for sparse files before claiming a saving:
```sh
stat -c 'apparent=%s blocks=%b' <file>; du -sh --apparent-size <file>; du -sh <file>
```
A 3.3G "log" (`logs/x4e-run.log`) was **4K real** (8 blocks × 512) — deleting
it frees nothing. `du` vs `du --apparent-size` finds these instantly.
## 2. Classify against the runbook index
Every payload directory maps to a runtime in the AGENTS.md retired-runbook
table. Three buckets:
- **live** — referenced by a running service's env/unit.
- **rollback** — named as a rollback target in a runbook. ASK before
touching: they are the difference between a one-minute flip and a 30G
re-download.
- **retired** — maps to a retired runtime. Safe candidates.
Trap: a directory can hold **both** a dead target and a live drafter
(frank-large's DFlash2 drafter lives beside the previous target). Move the
single file, never the directory, and say so in the manifest.
## 3. The live-reference check — before moving anything
```sh
for d in <candidate dirs>; do
grep -rl "$d" --include="*.sh" --include="*.env" --include="*.service" \
--include="*.container" --include="*.yaml" . | grep -v '^./old/'
done
```
Then **read every hit**; do not count them. Most are benign (comments,
retired fetch-script destinations, `retire_*_quadlet` helpers that move a
quadlet not a model) — but on Dankclaw this check found a live bug:
`serve-frank-small.sh`'s `: "${MODEL_DIR:=...}"` fallback still pointed at a
payload retired a week earlier. The env file always overrode it, so it was
invisible; a missing env file would have produced a "missing asset" naming a
model that no longer exists.
**Durable lesson:** `:`-defaults in a serve script are never exercised in
production, so they rot silently. When reclaiming a payload, grep serve
scripts too — the fallback is a live reference in all but name.
Also check 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 — logs are
evidence, keep them.
## 4. Stage, never delete
```sh
STAGE=/home/dank/.AGENTTRASH/$(date +%Y%m%d)-<topic>
mkdir -p "$STAGE"
mv <path> "$STAGE"/
```
Same filesystem ⇒ instant and recoverable — **and reclaims no space**; say so
rather than implying space was freed. Write `MANIFEST.md` into the stage dir:
per item — path, size, owning runtime, when retired, the `mv` command to
restore. Record what you **deliberately kept** and why; that is what a future
reader needs. Always check for pre-existing staged trash
(`du -sh ~/.AGENTTRASH/*`): 118G from a 2026-09-21 cleanup sat un-purged;
report the running total, not just the new batch. Root-owned leftovers need
`sudo`, and check `swapon --show` first (a trashed swapfile must not be
active).
## 5. Verify, then report — and do not purge
```sh
systemctl is-active frank-large frank-small
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:7770/health
```
Report: staged items and sizes, what was kept and why, what is **still not
freed**, remaining candidates untouched. Purge only on explicit approval.
## Repo-flow gotcha: do NOT push before you deploy
`scripts/deploy.sh` sets `BASE_REVISION=$(git rev-parse origin/main)`. If you
pushed separately, the diff is empty and the planner reports "changed
services: (none)" — looks like a planner bug, is not one. The tool is
designed for the **unpushed** state: edit → test → `deploy.sh` (it pushes,
then dispatches). If you already pushed, pull on the host instead, and reason
about whether a restart is actually owed. Any serve-script/env change needs
`./tests/run.sh` before commit; a `:`-default change is usually **inert**
(env overrides it) and owes no restart — but say so explicitly.
# Purge and refresh protocol
When the task is clearing/reclaiming/**refreshing** model caches on a serving
host, add these steps.
## Fingerprint before deleting
`ls -l --block-size=1 <dir>`; for LLM payloads compare each file against the
Hub's declared size. Fingerprints are the only way to answer "did the refresh
change anything" afterwards — **a refresh is also an update check**: if
upstream never changed, the bytes come back identical, which is itself the
finding. Sidecars are versioned **in place**: equal main-checkpoint sizes do
not prove the repo is unchanged, and a stale sidecar is invisible from its
name (observed: `overlay.hgn` 2,477,677,120 → 2,572,466,560 while the main
checkpoint returned byte-identical). If a file is irreplaceable upstream
(delisted revision, gated repo), say so before deleting.
## Confirm re-fetchability first
Query the Hub anonymously; a gated repo 403s even with the host's 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. Filter by **quant
name**, never `endswith('.gguf')` — mradermacher ships large quants as
`.partNofM` fragments that filter hides.
## Refresh per engine — know the recovery path BEFORE deleting
- **frank-large (halogen)**: the engine fetches its own payload via
`HALOGEN_DOWNLOAD` when the checkpoint is absent. Clear the directory but
leave it **existing and empty**, then start the unit. **Bind-mount trap:**
podman refuses a mount whose source is missing
(`Error: statfs /home/dank/models/halogen: no such file or directory`) and
the unit loops on `Restart=on-failure` — `mkdir -p` the empty dir. ~124 GiB
at 130–240 MB/s ≈ 10–20 min, then load.
- **llama.cpp services**: no self-fetch; the serve script refuses to start
without weights. Pull yourself and verify:
```bash
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 LFS oid + GGUF magic
```
`curl` exits 0 on HTTP 401/403 and writes the error body as the artifact — a
gated repo once produced three 153-byte shards with identical sha256s and a
cheerful DONE line for 104 GiB. Always `-fL -C -`, compare byte counts to the
Hub's declared sizes, compare sha256 to the LFS oid. Assemble `.partNofM`
fragments with `cat part1 part2 part3 > name.gguf` in numeric order (per-part
hashes verify the whole — concatenation is deterministic). Public repos need
no token: omit `-H "Authorization: Bearer …"` from argv entirely (it is
world-readable in `/proc/<pid>/cmdline`).
## 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, then `/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. Sweep the vhosts too
(`www`, `grafana`, `prometheus`, `hermes`, `marreta`, `gitea`, `loki` via
`curl -sk --resolve <host>:443:192.168.0.143`) — a purge touches state the
whole host shares.
## Things that bite
- **Do not run a 100 GB+ re-fetch concurrently with a big iGPU benchmark** —
the A* run commits ~116 GiB and leaves ~10 GiB available; cache churn risks
the OOM/state failures this fleet has already seen.
- **Reboot is the repair of last resort** if rootless podman's pause process
dies mid-work (`invalid internal status … could not find any running
process`); `podman system migrate` panics in 5.7.0.
- A same-filesystem trash reclaims no space; root-owned leftovers need
`sudo`; never `rm` without explicit permission.
## Related
- [[Model Swap and Rollback]] — the runbook index this classification reads
- [[Quadlet Lifecycle]] — restoring the unit after a payload refresh
- [[Remote Job Runner]] — supervising long re-fetches
- [[Deploy State Triage]] — the push-before-deploy trap in context
@@ -0,0 +1,135 @@
---
type: reference
title: Remote Job Runner
description: Detached long-job execution on fleet hosts with sentinel files (job.sh) — why ssh tail-watchers fail structurally, the script, its invariants, verification, and reporting discipline.
tags: [operations, ssh, jobs, sentinel, detached]
timestamp: 2026-10-05T00:00:00-07:00
---
# Remote Job Runner
Use for any long-running job on a remote host (benchmarks, model loads,
builds, sweeps) whose result must survive the session. Also whenever you catch
yourself writing `ssh host 'tail -f log | sed …'` as a watcher.
## Why the obvious approach fails structurally
`ssh host 'tail -f log | grep/sed …'` as a background watcher fails in ways
that all LOOK like "the job died":
- the session broker **aborts long-lived ssh** — the watcher vanishes and the
result is lost;
- a `ready` regex can match the log's own **header** on the first line — the
supervisor reports success instantly while the job is still running;
- a `sed -n '/PAT/,$p'` filter **exits at the first match**, ending the pipe;
- output goes into the ssh stream, so an aborted session loses everything.
## The mechanism: detach on the HOST, poll a sentinel file
Completion is a **`.done` sentinel touched last**, not a log pattern. Status
is then one fast independent command with no long-lived process to lose.
```bash
#!/usr/bin/env bash
# job.sh {start|status|wait|out|tail|watch} ... Jobs live in $JOBS.
set -uo pipefail
JOBS=/home/dank/jobs
mkdir -p "$JOBS"
case "${1:-}" in
start)
NAME="$2"; shift 2
[ "${1:-}" = "--" ] && shift
rm -f "$JOBS/$NAME.done" "$JOBS/$NAME.rc"
# setsid detaches from this session's process group: the critical bit.
setsid nohup bash -c '
NAME="$1"; shift
"$@" > "'"$JOBS"'/$NAME.out" 2>&1
echo $? > "'"$JOBS"'/$NAME.rc"
touch "'"$JOBS"'/$NAME.done"
' _ "$NAME" "$@" < /dev/null > /dev/null 2>&1 &
echo "started $NAME"
;;
status)
# Glob *.out (not *.done) or running jobs are invisible.
names="${2:-$(cd "$JOBS" && ls *.out 2>/dev/null | sed 's/\.out$//' | sort)}"
for n in $names; do
if [ -f "$JOBS/$n.done" ]; then
printf ' %-18s DONE rc=%s %s lines\n' "$n" \
"$(cat "$JOBS/$n.rc" 2>/dev/null)" \
"$(wc -l < "$JOBS/$n.out" | tr -d ' ')"
elif [ -f "$JOBS/$n.out" ]; then
printf ' %-18s RUN %s lines so far\n' "$n" "$(wc -l < "$JOBS/$n.out" | tr -d ' ')"
else
printf ' %-18s PENDING\n' "$n"
fi
done
;;
wait)
NAME="$2"; LIMIT="${3:-3600}"; waited=0
while [ ! -f "$JOBS/$NAME.done" ] && [ "$waited" -lt "$LIMIT" ]; do
sleep 5; waited=$((waited+5))
done
[ -f "$JOBS/$NAME.done" ] \
&& echo "DONE $NAME rc=$(cat "$JOBS/$NAME.rc" 2>/dev/null)" \
|| echo "TIMEOUT $NAME after ${waited}s"
tail -"${4:-25}" "$JOBS/$NAME.out" 2>/dev/null
;;
out) NAME="$2"; tail -"${3:-40}" "$JOBS/$NAME.out" 2>/dev/null ;;
tail) [ -n "${2:-}" ] && exec tail -f "$JOBS/$2.out" || exec tail -f "$JOBS"/*.out ;;
watch) while true; do clear; date '+%H:%M:%S'; bash "$0" status; sleep 5; done ;;
*) echo "usage: job.sh {start|status|wait|out|tail|watch}" >&2; exit 2 ;;
esac
```
```bash
job.sh start NAME -- <command> # launch detached
job.sh status [NAME] # one-shot state
job.sh wait NAME 900 25 # block, then print the tail
job.sh out NAME 60 # retrieve any past result
job.sh tail [NAME] # live follow
job.sh watch # refreshing status table
```
## Invariants
- **`.done` is touched LAST**, after the exit code is written — its presence
alone means the output file is final.
- **Glob `*.out` for status**, never `*.done` — globbing `.done` hides running
jobs entirely, the exact blindness this fixes.
- **Capture the exit code** (`rc=7` proved a self-test worked; without it a
job can "complete" having failed).
- **Never put the payload in a line you grep for** — the sentinel is the only
completion signal.
- **Never `pkill -f <pattern>`** where the pattern also matches your own ssh
command line — it kills the session. Distinctive names or `pkill -x`.
- **Write helper scripts to a file and `scp` them.** Triple-nested
`ssh 'bash -c "python3 -c \"…\""'` quoting broke repeatedly (unterminated
quotes; `grep -c` returning `"0\n0"` because `|| echo 0` appended a line);
every failure disappeared once the script was a file.
## Verify before trusting it (3am law)
```bash
job.sh start selftest -- bash -c 'echo one; sleep 2; echo two; exit 7'
job.sh start slowtest -- bash -c 'echo start; sleep 30; echo end'
sleep 4; job.sh status # selftest DONE rc=7; slowtest RUN
```
Both paths, including the failure case, and the rc round-trip — a runner that
only proves the happy path is not trustworthy.
## Reporting discipline
- Give the user a real terminal when the run is long:
`alacritty --title "bench - dankserver" -e bash -c 'ssh -o
ServerAliveInterval=30 -t dank@192.168.0.235 "bash /home/dank/job.sh tail"' &`
- State the MECHANISM, not vibes: the job is durable on the host;
`job.sh out NAME` retrieves it later. Do not narrate "still loading"
repeatedly — check once, act, report the resolved result.
- When the user asks for status, one fast command, not a narration.
## Related
- [[LLM Bench Windows and Measurement]] — the sweeps this runner executes
- [[Deploy State Triage]] — the two-process deploy this lesson generalizes
@@ -0,0 +1,130 @@
---
type: reference
title: SSH and Remote Access
description: Passwordless SSH bootstrap without sshpass/expect, remote execution against fish login shells, and diagnosing a frozen GUI from an SSH session.
tags: [ssh, remote, fish, gui, diagnosis]
timestamp: 2026-10-05T00:00:00-07:00
---
# SSH and Remote Access
## Passwordless SSH bootstrap without sshpass/expect
Use when you must install your pubkey on remote hosts but `sshpass` and
`expect` are not installed and you only have a password. Modern OpenSSH
(≥ 8.4) can drive the password prompt from a helper program, so the password
flows through the **process env only** — never a file, never argv.
1. **Generate a key** (if none):
```bash
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "host-deploy-$(date +%Y%m%d)"
```
2. **Create an askpass helper** that echoes the password from an env var (the
helper itself stores NO secret):
```bash
printf '#!/bin/sh\necho "$DEPLOY_PW"\n' > /tmp/askpass.sh
chmod 700 /tmp/askpass.sh
```
3. **Copy the pubkey**, forcing the askpass path and detaching from the TTY:
```bash
env DEPLOY_PW='<password>' \
SSH_ASKPASS=/tmp/askpass.sh \
SSH_ASKPASS_REQUIRE=force \
setsid ssh-copy-id -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 user@host
```
Password via the transient `env` argument only.
4. **Add host aliases** in `~/.ssh/config` so deploy scripts resolve names
(`HostName`, `User`, `IdentityFile`, `IdentitiesOnly yes`).
5. **Verify** with `ssh -o BatchMode=yes myhost 'echo OK; hostname'` —
BatchMode fails fast if a password would be required; a clean success
proves the key works.
Gotchas: without `SSH_ASKPASS_REQUIRE=force`, ssh ignores SSH_ASKPASS when a
TTY is present and prompts interactively (hangs in a non-interactive tool);
`setsid` detaches the controlling terminal — required on older OpenSSH even
with `force`; the helper must be executable (`chmod 700`) and print ONLY the
password; `IdentitiesOnly yes` stops ssh offering other agent keys that could
be rejected first ("Too many authentication failures"). Passwordless **sudo**
on the target is a separate concern (NOPASSWD / `FRANK_SUDO_PW`).
## Remote execution on a fish login shell
Running remote commands or shipping files over SSH to a host whose login
shell is **fish** (blankpad: user `blank`, reachable only as mDNS FQDN
`blankpad.local` / 192.168.0.83 — the bare hostname does not resolve from the
workstation).
1. **Auth**: `SSHPASS='<pw>' sshpass -e ssh -o
PreferredAuthentications=password -o PubkeyAuthentication=no user@host '…'`
— password via env, never argv (ps-visible).
2. **Remote exec**: `ssh host 'cmd'` pipes the command string to the login
shell (fish). Bashisms — `for … do`, `$?`, heredoc markers — fail at
*parse* time on fish (`Missing end to balance this for loop`). Always wrap
compound commands: `ssh host 'bash -c "for b in a b c; do command -v $b ||
echo MISSING; done"'` (escape inner `$`).
3. **File shipping**: never combine `cat > /remote/path <<EOF` with a
trailing `bash -s` in the same ssh command — `cat` drains stdin, so the
interpreter silently runs on empty stdin and prints nothing (a silent
no-op that *looks* like success). Ship real bytes via stdin redirect:
`ssh host 'cat > /remote/path' < localfile`, then verify byte-truth with
`md5sum` on both ends.
4. **Exit codes**: `$?` in an unquoted remote command is parsed by fish
(`$status` there) — wrap in bash or drop the status echo; ssh's own exit
code propagates to the caller anyway.
Both traps fired during the 2026-09-09 blankpad dotfiles deploy: the first
probe died on fish loop parsing; a `cat >` ship silently swallowed the
heredoc meant for `bash -s`, deploying wrong bytes until md5-compared.
Related: dankserver's login shell is ZSH — never start an unquoted word with
`=` in an ssh one-liner (`echo ===` aborts the whole chain).
## GUI hang — remote diagnosis over SSH
Use when the desktop is frozen, VT switches do nothing, but SSH still works.
1. **Find the smoking gun** — the 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. **Full stacks** for victim and holder:
```bash
journalctl -b -k --since "<hang minute -5>" --no-pager | tail -120
cat /proc/<pid>/status | head -5 # State: R (spinning) vs D (blocked)
```
Victim `D` + holder `R` = livelock/deadlock with the lock held;
`voluntary_ctxt_switches` in the millions on an `R` kthread = spinning,
not progressing.
3. **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
```
Instant answers with no Xid ⇒ driver lock issue, not hardware.
4. **Trigger window**: block start = first hung message timestamp − reported
seconds; then inspect user-session events around it
(`journalctl -b --since "<start-2m>" --until "<start+1m>" | grep -v audit`).
5. **Collateral**: other `D` tasks (`ps -eo pid,stat,comm | awk '$2 ~ /D/'`),
USB workers, etc.
**Recoverability verdict:** compositor in `D` state is unkillable and a
spinning kernel thread holding the lock cannot be poked — **the only exit is
reboot**: `sync && systemctl reboot --force --force` (double-force skips
stop-jobs waiting forever on D-state PIDs). If the 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, check upstream for the exact symbols + driver version, and propose
targeted off-switches (`NVreg_RegistryDwords`, module params from
`/proc/driver/nvidia/params`) rather than blanket driver swaps.
## Related
- [[Fleet Ops Playbook]] — incident procedures that run over these sessions
- [[Remote Job Runner]] — long jobs that must survive session loss
- [[Deploy State Triage]] — the deploy flow these credentials serve
+32
View File
@@ -0,0 +1,32 @@
---
type: index
description: Operational procedures for the fleet — job runners, disk and payload reclaim, container reachability, audits, SSH craft, and incident playbooks.
tags: [operations, index]
timestamp: 2026-10-05T00:00:00-07:00
---
# Operations
Procedures for operating the fleet. The LLM-specific pages live under
[LLM](../LLM/index.md); infrastructure and architecture under the
`../Infrastructure/` and `../Architecture/` folders.
- [[Remote Job Runner]] — detached long-job execution with sentinel files;
why ssh tail-watchers fail
- [[Payload and Disk Reclaim]] — measure-classify-prove-stage-ask disk
procedure, plus the purge-and-refresh protocol
- [[Container Host Reachability]] — netavark/pasta reachability quirks and
the pasta port gate
- [[Fleet Ops Playbook]] — endpoint-down GPU-holder reclaim, scratch-boot
stop-first, pre-commit iteration, incident-window reconstruction, WoL
- [[Fleet Audit and Posture]] — monitoring gaps, vhost probes, security
posture, whole-fleet audit, bulk redaction, stash audits
- [[SSH and Remote Access]] — passwordless bootstrap without sshpass, fish
login shells, GUI-hang diagnosis over ssh
## Related
- [Observability Provisioning](../Infrastructure/Observability%20Provisioning.md)
— monitoring config as code
- [Deploy State Triage](../Architecture/Deploy%20State%20Triage.md) — the
deploy plumbing
- [Quadlet Lifecycle](../Architecture/Quadlet%20Lifecycle.md) — the unit layer
@@ -0,0 +1,62 @@
---
type: reference
title: PICO-8 Asset Standards
description: Production-grade standards for PICO-8 asset sheets — palette discipline, single perspective and light direction, detail flow and hierarchy, sheet organization, and the cohesion checklist.
tags: [pico8, game-dev, pixel-art, standards]
timestamp: 2026-10-05T00:00:00-07:00
---
# PICO-8 Asset Standards
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.
## 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 (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 8×8 or 16×16 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 a sheet)
- 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 the palette constraint — colors outside the 16 PICO-8 colors.
- Inconsistent tile edge connections (a grass tile that only connects to one
dirt variant).
+58
View File
@@ -115,6 +115,64 @@ macOS mini) as quadlets inside its podman machine — not on Dankserver.
- [[AI Job Search and Resume Tools]] — self-hosted AI job-search + resume-formatting apps, local-LLM fit, recommended stack
- [[Most Popular Self-Hosted AI Apps]] — the 15 most popular self-hosted AI apps of Jun–Sep 2026, homelab fit, adoption best practices
## Skill Distillation (2026-10-05)
The managed skill library distilled into the vault, one reference page per
skill-cluster. See [log.md](log.md) for the wave plan and results.
**Operations:**
- [[Remote Job Runner]] — detached long-job execution with sentinel files
- [[Payload and Disk Reclaim]] — disk reclaim: measure, classify, prove,
stage, ask; plus purge-and-refresh
- [[Container Host Reachability]] — netavark/pasta reachability quirks and
the pasta port gate
- [[Fleet Ops Playbook]] — endpoint-down GPU-holder reclaim, scratch-boot
stop-first, pre-commit iteration, incident-window reconstruction, WoL
- [[Fleet Audit and Posture]] — monitoring gaps, vhost probes, security
posture, whole-fleet audit, bulk redaction
- [[SSH and Remote Access]] — passwordless bootstrap, fish login shells,
GUI-hang diagnosis over ssh
**Architecture:**
- [[Stale Signal Audit]] — classifying green-but-dead and down-but-fine
signals
- [[Quadlet Lifecycle]] — quadlet units, drift, crash-loops, compose
cutover
- [[Deploy State Triage]] — revision markers, pending lockouts, deploy
interruptions, shared-checkout hygiene
**Infrastructure:**
- [[Observability Provisioning]] — Prometheus/Grafana config as code
- [[Homepage Status Probes]] — the status page's checker, vantage rules,
and triage
**LLM cross-cutting (full index: [LLM/index.md](LLM/index.md)):**
- [[GGUF Compatibility]] · [[Llama Cpp Serving Sizing]] ·
[[Llama Cpp Multi-Device Splits]] · [[PCIe Link and Atomics]] ·
[[RDNA4 Container Serving]] · [[RDNA4 MoE Expert Offload VRAM Budget]] ·
[[vLLM Boot Forensics and Config Interrogation]] ·
[[vLLM Porting and Compile Triage]] · [[LLM Bench Windows and Measurement]] ·
[[LLM Performance Diagnosis]] · [[Model Swap and Rollback]] ·
[[Tcclaviger Fork Ops]] · [[Heterogeneous Split Triage]] ·
[[FreeToken Builds]]
**Agent:**
- [[OMP Harness]] — compaction arithmetic, per-model samplers, preamble
audits
- [[Agent Docs and Edits]] — doc-structure audits, hashline edit recovery
- [[Command Center Dotfiles]] — the ~/.omp repo-as-deployment
**Desktop / Research:**
- [[Noctalia and Niri]] — the desktop shell, palette recolor, template
rendering, rotation pinning, iCX3 RGB
- [[PICO-8 Asset Standards]] — cohesive PICO-8 asset sheets
## LLM Fleet
The LLM inference fleet on the two hosts. Full index:
+40 -1
View File
@@ -1,5 +1,6 @@
# Bundle Change Log
## 2026-10-05
- **frank-primary: tcclaviger vLLM fork → FreeToken (PR #587 ROCm) on
dankserver.** Built FreeToken at PR head `77a7201` on the host into the
@@ -26,7 +27,45 @@
Dankclaw Prometheus llm-fleet scrape of :7770 reads down until
repointed. Rollback: tag `frank-primary-vllm-shapeG-20261003`.
Runbook: `serverAppConfigs-docs/LLM/frank-primary-freetoken-pr587.md`.
## 2026-10-05
- **Skill library distilled into the vault (waves 1–7).** All ~173 managed
skills read in full and merged, one polished reference page per
skill-cluster:
- **LLM cross-cutting (16 pages, `LLM/`):** GGUF Compatibility,
Llama Cpp Serving Sizing, Llama Cpp Multi-Device Splits, PCIe Link and
Atomics, RDNA4 Container Serving, RDNA4 MoE Expert Offload VRAM Budget,
vLLM Boot Forensics and Config Interrogation, vLLM Porting and Compile
Triage, LLM Bench Windows and Measurement, LLM Performance Diagnosis,
Model Swap and Rollback, Tcclaviger Fork Ops, Heterogeneous Split
Triage, FreeToken Builds, plus deltas folded into the retired runbooks
(GLM DFlash2 port laws, Lucebox dual-R9700, R9V qualification pipeline,
FLM pack registration, ik hybrid envelope, frank-medium split
rebalance, frank-primary llama.cpp GGUF preflight, batch-ubatch split
section, bench-window D-state/stale-read notes, second-endpoint
bring-up in Model Swap).
- **Architecture (3):** Stale Signal Audit, Quadlet Lifecycle, Deploy
State Triage.
- **Operations (6 + index):** Remote Job Runner, Payload and Disk
Reclaim, Container Host Reachability, Fleet Ops Playbook, Fleet Audit
and Posture, SSH and Remote Access.
- **Infrastructure (2 + updates):** Observability Provisioning, Homepage
Status Probes; updates to Authentik (guard-chain flip recipe, `ak
shell` mechanics, user-provisioning playbook, outpost netns probe),
Anubis (guard-chain layer diagnosis), LAN-DNS (per-client resolver
drop-in + browser DoH bypass, Porkbun zone ops), Swap Configuration
(filesystem/rename caveats), OpenWebUI (introspection, verification
battery, ComfyUI imagegen), Hermes (config-home drift, live config
edits, auxiliary routing, password rotation — the pre-existing page was
preserved and extended).
- **Host pages:** FrankBook-Pro ARR-Stack filled with the API-version
split, schema-first probing, auth-posture method, auth-config trap, and
the qBittorrent E2E verification procedure added to [[FrankBook-Pro/qbittorrent]].
- **Desktop/Agent/Research:** Noctalia and Niri (config layers, remote
IPC, palette recolor, offline template render, rotation pinning, iCX3
RGB), Agent/ (OMP Harness, Agent Docs and Edits, Command Center
Dotfiles, index), PICO-8 Asset Standards.
- Index updated: `LLM/index.md` gained a Cross-Cutting References
section; Operations/ and Agent/ got index pages. AGENTS.md now points
at the new sections. Link checker + full test suite green.
- **e2e: the FrankBook-Pro leg scoped to its own trigger-gated Playwright
project.** The `11-admin` walk included the six `*arr`/qBittorrent admin
links that terminate on 192.168.0.112, so every deploy gate died there when