Compare commits

...
Author SHA1 Message Date
Frank bc718f04f4 Fix scoped deployment state and config ownership 2026-08-25 20:24:47 -07:00
Frank 3a56a07883 Guard target-owned deployment handlers 2026-08-25 20:13:33 -07:00
Frank a07b8c9306 Deploy services independently by changed paths 2026-08-25 20:13:33 -07:00
Frank 1ba47e4d22 Deploy services independently by changed paths 2026-08-25 20:13:33 -07:00
Frank 2277a2e9ac docs(kv): add bit-density KV cache quant table across 3 runtimes
- Place ik's Trellis (IQ*_KT), IQK (IQ*_K), MXFP4, and dedicated fast KV type
  Q8_KV in a single high->low bits-per-value table with ROCmFPX TurboQuant KV.
- Mark mainline-provided types (*), KV-native vs weight-family-reused-for-KV.
- Hadamard annotated as a transform on top of a base quant, not a row.
- bpw read from exact ggml block structs (QK_K=256, fp16 scale): type_size x 8 / QK,
  verified against the struct comment "6.5625 bits per weight" and the trait table.
  Notes where legacy nominal figures (4.0/4.25) diverge.
- AGENTS.md: add benchmark directive (use llama-bench / vllm bench; ask before
  rolling your own).
2026-08-25 20:03:13 -07:00
Frank 4308f62004 Normalize Hermes local provider names 2026-08-25 19:42:46 -07:00
Frank 8fb5bd7a5a Remove unrelated Hermes reasoning overrides 2026-08-25 19:33:56 -07:00
Frank 5766ff7dab Set ubatch sweep to requested 32k matrix 2026-08-25 19:12:42 -07:00
Frank c02d67513d Update benchmark documentation after harness replacement 2026-08-25 19:08:42 -07:00
Frank 863897f92a Clarify llama-bench harness scope 2026-08-25 19:07:46 -07:00
Frank dc69e2b4b4 Support fixed ubatch batch sweep at 16K context 2026-08-25 18:54:09 -07:00
Frank dfdd4bb6a8 Set Hermes local model reasoning levels 2026-08-25 18:48:04 -07:00
Frank 4e8ea24b2f Record complete 32K ubatch benchmark findings 2026-08-25 18:40:32 -07:00
Frank 929796352a Research llama.cpp runtimes + SGLang; fix AGENTS.md OKF spec link
Fan out subagents to compare mainline llama.cpp, charlie12345/ROCmFPX,
and ik_llama.cpp, then add their findings to the project knowledge base:
- docs/Research/Llama Cpp Runtimes.md  (positioning + quantization + server fit)
- docs/Research/Llama Cpp Code Diff.md (code-level quant diff, table, recommendations)
- docs/Research/SGLang.md              (what SGLang is, comparison, our-boxes fit)
- docs/Research/index.md               (Research progressive-disclosure index)
Cross-linked from docs/index.md (root) and docs/LLM/index.md.

AGENTS.md:
- Add Documentation Authoring section: docs/ is an OKF bundle; point doc authors
  at the canonical spec in the system-config knowledge base
  (~/.pi/agent/docs/references/okf-spec.md), not the orphaned
  docs/references/okf-spec.md; consolidate read-index/one-concept-per-file/
  progressive-disclosure/citation rules.
- Map AGENTS.md to docs_completeness.bats in the focused pre-commit hook (it was
  last committed before the hook existed and had no mapping).
2026-08-25 18:23:19 -07:00
Frank 2693259227 configure anonymous SearXNG provider defaults 2026-08-25 17:58:22 -07:00
Frank e6670f914f docs: record live SearXNG provider baseline 2026-08-25 17:58:22 -07:00
Frank a05584b155 tune SearXNG VPN request timeouts and retries 2026-08-25 17:58:22 -07:00
Frank e1f05bd3c2 Configure Hermes access to small LLM 2026-08-25 17:55:52 -07:00
Frank 96f5ae6fe1 Raise ubatch benchmark context to 32K 2026-08-25 17:44:45 -07:00
Frank 4cb67d7b33 Set ubatch sweep context and batch to 16K 2026-08-25 17:32:12 -07:00
Frank babc823f11 Avoid shell variable collision in bench parser 2026-08-25 17:26:32 -07:00
Frank 56ee7cca45 Pass run number into ubatch benchmark parser 2026-08-25 17:12:51 -07:00
Frank c072c75fa9 Make ubatch benchmark cleanup failure safe 2026-08-25 17:11:42 -07:00
Frank 119ca2493f feat: add tmux session snapshot and restore utility 2026-08-25 17:04:15 -07:00
Frank a8e0b88d99 Parse current llama timing output in ubatch bench 2026-08-25 16:57:02 -07:00
Frank 835996a67b Keep deployment moving when Caddy restart is blocked 2026-08-25 16:55:10 -07:00
Frank eae5bc2565 Continue LLM deployment when compose restart fails 2026-08-25 16:54:18 -07:00
Frank c7a72b465e Fix privileged shared config deployment 2026-08-25 16:53:13 -07:00
Frank d6ea71b9a4 Configure DeepSeek reasoning and large-model ubatch sweep 2026-08-25 16:52:45 -07:00
Frank 8c8c3f7cd0 Enable focused tests as repository pre-commit hook 2026-08-25 16:18:01 -07:00
Frank 63ff1b6b43 Add focused changed-path test wrapper 2026-08-25 16:05:59 -07:00
Frank 7a0feb39aa Add CLI helper for entropy volume 2026-08-25 15:38:57 -07:00
Frank 6ee195dd8f Restore historical Q6 large model runtime 2026-08-25 14:23:47 -07:00
Frank ddfc64013f Fix Q6 AGENT projector path 2026-08-25 13:58:50 -07:00
Frank 86d5f38142 Restore Q6 AGENT large model 2026-08-25 13:36:07 -07:00
Frank 205b11aae4 Clarify historical ROCmFPX payload sizes 2026-08-25 13:28:38 -07:00
Frank 6c9e71efd2 Document Q8 AGENT set_rows serving blocker 2026-08-25 12:16:47 -07:00
48 changed files with 3230 additions and 1635 deletions
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Repository pre-commit hook: run only tests selected by staged paths.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
exec "$REPO_ROOT/scripts/test-changed.sh"
+71 -29
View File
@@ -27,7 +27,7 @@ host.
| Target | Runtime | Service | Model | Port |
|--------|---------|---------|-------|------|
| Dankserver | llama.cpp (upstream b10121) | frank-default-small | HauhauCS Qwen3.6-35B-A3B-Uncensored-Aggressive Q6_K_P + ngram-mod, 36 MoE experts on CPU | 7770 |
| Dankclaw | charlie12345/ROCmFPX llama.cpp | frank-default-large | kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT Q8_0_ROCMFPX (standalone MTP draft head, Turbo4 V cache, 262K context / 1 slot), reasoning on | 7770 |
| Dankclaw | charlie12345/ROCmFPX llama.cpp | frank-default-large | kingjones Qwen3.8 Uncensored Q6_0_ROCmFPX_AGENT (standalone MTP draft head, Turbo4 V cache, 262K context / 1 slot), reasoning on | 7770 |
History: the 2026-08-17 redo retired the pipeline-parallel split and the
FastFlowLM NPU endpoint. The 2026-08-18 swap retired the vLLM service on
@@ -35,8 +35,9 @@ Dankclaw. On 2026-08-21 a DFlash2 Vulkan deployment briefly replaced CIRU,
then was reverted the same day for speed. On 2026-08-22 the service briefly
swapped to the Kairic Edge IU4 runtime, then was reverted to v3 the same day
(operator decision). On 2026-08-25 the service switched to the kingjones
Qwen3.8-27B-ROCmFPX-Q8_0-AGENT model (standalone MTP draft, 262144 context,
reasoning on); the previous Kairic Edge model was retired the same day. Runbooks:
Qwen3.8-27B-Uncensored-Q6_0-ROCmFPX_AGENT model (standalone MTP draft,
262144 context, reasoning on); the previous Q8_0 AGENT model was retired after
its `set_rows` crash. Runbooks:
[docs/LLM/index.md](docs/LLM/index.md).
Retired scripts live in `old/` (reference only) and `.AGENTTRASH/`.
@@ -45,12 +46,39 @@ Retired scripts live in `old/` (reference only) and `.AGENTTRASH/`.
- `dankserver/` — compose.yaml + Caddyfile + .env.example for the media stack
- `dankclaw/` — compose.yaml + Caddyfile + .env.example for the AI stack
- `shared/` — homepage, Prometheus, Loki, Alloy, SearXNG configs
- `scripts/` — `deploy.sh` (unified deployer), LLM serve scripts, systemd
units, env tunables, bench harnesses
- `scripts/` — `deploy.sh`, `deploy-plan.py`, and `deploy-remote.sh`
(service-scoped deployer), LLM serve scripts, systemd units, env tunables,
and bench harnesses
- `old/` — retired PP/NPU-era LLM scripts (reference only)
- `docs/` — knowledge base (Obsidian vault). `docs/LLM/` is the LLM fleet
- `tests/` — bats test suite
## Documentation Authoring
The `docs/` vault is the project knowledge base (Obsidian, wikilinks) and is an
OKF (Open Knowledge Format) knowledge bundle. Follow the OKF spec for every doc
you create or edit.
- **Canonical spec:** `~/.pi/agent/docs/references/okf-spec.md` — the OKF v0.1
spec in the **system-config knowledge base** at `~/.pi/agent/docs`. The repo's
own `docs/references/okf-spec.md` is deprecated/removed; use the system one. The
spec index and maintenance guide are
`~/.pi/agent/docs/references/index.md` and
`~/.pi/agent/docs/references/okf-maintenance.md`.
- **Read before you touch:** open the relevant `index.md` first
(`docs/index.md` → `docs/LLM/index.md`). Read the whole target file before
editing it.
- **One concept per file**, with YAML frontmatter that has a non-empty `type:`
field (`reference` / `guide` / `configuration` / `research`) plus `description`,
`tags`, and `timestamp`. Split broad topics; keep pages ~1–500 lines.
- **Progressive disclosure:** an `index.md` at each level links its children.
Every `docs/**/*.md` (except `index.md`) must be linked from
`docs/index.md` with `[[Title]]` — the docs-completeness test checks this.
- **Cross-link** siblings as `[[Title]]` wikilinks; cite external claims under a
`# Citations` section.
- **Always update docs** when a task changes behavior, then run
`./tests/run.sh` (the docs-completeness seam is in there) before commit.
## Git Workflow
All edits flow through git. Do not edit files on the remote machine directly.
@@ -65,20 +93,28 @@ All edits flow through git. Do not edit files on the remote machine directly.
4. **Run the tests** — `./tests/run.sh` must pass
5. **Commit and push** — push to origin (Gitea on Dankclaw)
6. **Pull on the target** — `git pull` on the remote machine
7. **Restart** — `scripts/deploy.sh` pulls, restarts the stack and the LLM
service, and checks health
7. **Deploy** — `scripts/deploy.sh` pulls, plans, and dispatches only the
changed service(s), then checks the selected service health
## Deployment
Use `scripts/deploy.sh` to deploy committed changes to a target. One script
covers both workloads per host: the podman stack and the LLM service.
Use `scripts/deploy.sh` to deploy committed changes to a target. The planner
maps changed paths to individual services; the remote runner restarts only the
selected service and records its successful revision outside the checkout.
```
./scripts/deploy.sh dankclaw # AI stack + frank-default-large (:7770)
./scripts/deploy.sh dankserver # media stack + frank-default-small (:7770)
./scripts/deploy.sh dankclaw --dry-run # preview without changes
./scripts/deploy.sh dankclaw
./scripts/deploy.sh dankserver
./scripts/deploy.sh dankclaw --service searxng
./scripts/deploy.sh dankserver --service frank-default-small
./scripts/deploy.sh dankclaw --dry-run
```
A documentation-only change is a no-op. The LLM services are selected only by
their own unit/env/serve files or the shared chat template, so unrelated
container changes cannot interrupt inference. `frank-default-npu` is relink-only
when explicitly selected.
Set `FRANK_SUDO_PW` for headless runs on a host without passwordless sudo
(remote sudo reads it from stdin via `sudo -S`). The value is never stored
in this repo.
@@ -88,10 +124,10 @@ graph LR
A[Edit locally] --> B[Tests pass]
B --> C[Commit]
C --> D[Push to origin]
D --> E[deploy.sh: pull on target]
E --> F[restart podman stack]
F --> G[relink + restart LLM unit]
G --> H[health: homepage + LLM]
D --> E[deploy.sh computes service plan]
E --> F[Target pulls with ff-only]
F --> G[deploy-remote.sh dispatches selected service]
G --> H[Per-service health check]
```
## Testing
@@ -106,8 +142,8 @@ development machine.
Test seams: compose validity, env consistency (no leaked secrets), Caddyfile
validity, quadlet units, docs completeness, script syntax (all of `scripts/`,
including the LLM serve scripts), LLM unit integrity (units point at files in
this repo), LLM secret hygiene, deployment flow. See
[docs/Architecture/Testing Policy.md](docs/Architecture/Testing%20Policy.md).
this repo), LLM secret hygiene, deployment flow, and service-scoped deployment
planning/dispatch. See [docs/Architecture/Testing Policy.md](docs/Architecture/Testing%20Policy.md).
## Secret Rules
@@ -147,6 +183,9 @@ These servers serve real users (about 3 people). Running a bench while the
service is up starves the GPU and corrupts results. Restart the service when
the bench finishes.
ALWAYS bench using llama-bench for llama.cpp or vllm bench for vllm runtimes.
DO NOT ROLL YOUR OWN BENCH UNLESS YOU CANNOT TEST THE DESIRED PARAMETER WITH THE OFFICIAL BENCHES. ASK THE USER BEFORE DEVIATING FROM THIS DIRECTIVE.
1. Stop the service before any benchmark:
`sudo systemctl stop frank-default-small` (Dankserver) or
`sudo systemctl stop frank-default-large` (Dankclaw).
@@ -182,19 +221,22 @@ When you rename a service or move a model to a new host, update
## Runtime & Troubleshooting
### kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT llama.cpp (Dankclaw, ACTIVE)
### kingjones Qwen3.8 Uncensored Q6_0 ROCmFPX AGENT llama.cpp (Dankclaw, ACTIVE)
The large service serves `kingjones/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT`
(public) from `/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT/`
with a charlie12345/ROCmFPX `llama-server` (build `build-kairic`). It runs a
The large service serves `kingjones/Qwen3.8-Uncensored-ROCmFP4-STRIX-MTP`
(public) from `/home/dank/models/kingjones-Qwen3.8-27B-STRIX-MTP/` with a
charlie12345/ROCmFPX `llama-server` (build `build-promptforge`). It runs a
standalone MTP draft head on ROCm0, f16 K + TurboQuant V KV cache, a full
262144 context / 1 slot, and reasoning on (Sharp template, budget -1,
`--reasoning-format none`; chat kwargs keep `preserve_thinking` +
`preserve_reasoning`). The payload (~28 GB main + 1.7 GB draft + 0.9 GB
mmproj, ~30 GB total) plus the 262k KV cache fit in the lifted 114 GiB TTM/GTT
budget (raised 2026-08-25). Env `scripts/frank-default-large.env`, serve
`scripts/serve-frank-large.sh`, runbook
[docs/LLM/kingjones-q8agent-rocmfpx.md](docs/LLM/kingjones-q8agent-rocmfpx.md).
`preserve_reasoning`). The Q6_0 main model is 24.86 GB. Env
`scripts/frank-default-large.env`, serve `scripts/serve-frank-large.sh`, and
runbook `docs/LLM/kingjones-q8agent-rocmfpx.md` define the deployment.
**Serving status (2026-09-01):** restored the previously deployed Q6_0 AGENT
model. The Q8_0 AGENT experiment is documented in
`docs/LLM/kingjones-q8agent-rocmfpx.md`; it was retired because it crashed in
`ggml_compute_forward_set_rows` and did not provide a working service.
### charlie12345/ROCmFPX ROCmI4 W4A4 llama.cpp (Dankclaw, prior v3, 2026-08-18 to 2026-08-25)
@@ -354,7 +396,7 @@ Direct edits on the remote drift from version control. Future agents will not
know what changed. Git is the single source of truth. The two workloads share
two hosts, one Caddy, and one Gitea. One repo keeps them in one history.
> User request → Fetch from remote → Commit here → Edit → Test → Commit → Push → Pull on remote → Deploy + restart
> User request → Fetch from remote → Commit here → Edit → Test → Commit → Push → Pull on remote → Dispatch changed service(s) → Health
## Summary
@@ -363,7 +405,7 @@ two hosts, one Caddy, and one Gitea. One repo keeps them in one history.
| Editing a compose/Caddyfile/serve script | **Work through git** — fetch, commit, edit, test, push, pull, deploy. Never edit on the remote directly. |
| Config exists only on remote | **Pull it into the repo first** — commit the fetched version (redacted) before making changes. |
| Unstaged local changes | **Commit or discard** — check `git status` before starting work. Ask the user if unsure. |
| Deploying a committed change | **Run `scripts/deploy.sh <target>`** — it pushes, pulls on the target, restarts the stack and the LLM service, and checks both health endpoints. See [docs/LLM/deployment.md](docs/LLM/deployment.md). |
| Deploying a committed change | **Run `scripts/deploy.sh <target>`** — it pushes, pulls with fast-forward-only, plans changed paths, dispatches selected services, and checks their health. See [docs/LLM/deployment.md](docs/LLM/deployment.md). |
| Benchmarking | **Stop the service first** — scratch port, restart, verify health. See the Benchmarking Policy above. |
| Deleting a file | **Move it to trash** — `.AGENTTRASH/` (in-repo) or `~/.AGENTTRASH/YYYYMMDD-topic`. Never `rm`. |
| Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. |
+5 -4
View File
@@ -37,7 +37,7 @@ one deploy script.
- `dankserver/` — compose.yaml + Caddyfile + .env.example for the Dankserver stack
- `dankclaw/` — compose.yaml + Caddyfile + .env.example for the Dankclaw stack
- `shared/` — homepage, prometheus config, deploy helpers
- `scripts/` — `deploy.sh` (unified: stack + LLM), LLM serve scripts, systemd units, env tunables, bench harnesses
- `scripts/` — `deploy.sh` and `deploy-remote.sh` (service-scoped deployment), the planner, LLM serve scripts, systemd units, env tunables, and bench harnesses
- `old/` — retired PP/NPU-era LLM scripts (reference only)
- `docs/` — project knowledge base (Obsidian vault, wikilinks). `docs/LLM/` is the LLM fleet
- `tests/` — bats test suite (see [[Testing Policy]])
@@ -46,9 +46,10 @@ one deploy script.
See [docs/Architecture/Deployment Flow.md](docs/Architecture/Deployment%20Flow.md)
and [docs/LLM/deployment.md](docs/LLM/deployment.md). Run
`./scripts/deploy.sh <dankclaw|dankserver>` after pushing committed changes.
It pushes, pulls on the target, restarts the podman stack and the LLM
service, and checks both health endpoints.
`./scripts/deploy.sh <dankclaw|dankserver>` after committing changes. It
plans and deploys only the changed services. Use `--service NAME` for an
explicit single-service retry; inference is restarted only when its own files
(or the shared chat template) changed.
## Knowledge Base
+58
View File
@@ -0,0 +1,58 @@
# Hermes configuration backup / non-secret baseline.
#
# Source on Dankclaw: /home/dank/hermes-data/config.yaml
# This file is a sanitized Git backup, not a complete replacement for the
# live Hermes state. Do not add dashboard password hashes, phone numbers,
# OAuth credentials, API keys, sessions, memories, or generated state here.
# The live directory is bind-mounted into the containers as /data.
model:
provider: dankclaw-large
base_url: http://192.168.0.143:7770/v1
api_key: not-needed
default: frank-default-large
providers:
dankclaw-large:
api: http://192.168.0.143:7770/v1
discover_models: false
models:
frank-default-large:
context_length: 262144
dankserver-small:
api: http://192.168.0.235:7770/v1
discover_models: false
models:
frank-default-small:
context_length: 163840
# Auxiliary routing from the live configuration. These use the existing
# custom endpoint shape and are retained here as a settings backup.
auxiliary:
vision: {provider: custom, model: frank-default-small, base_url: http://192.168.0.235:7770/v1}
web_extract: {provider: custom, model: frank-default-small, base_url: http://192.168.0.235:7770/v1}
compression: {provider: custom, model: frank-default-small, base_url: http://192.168.0.235:7770/v1}
skills_hub: {provider: custom, model: frank-default-small, base_url: http://192.168.0.235:7770/v1}
approval: {provider: custom, model: frank-default-small, base_url: http://192.168.0.235:7770/v1}
mcp: {provider: custom, model: frank-default-small, base_url: http://192.168.0.235:7770/v1}
title_generation: {provider: custom, model: frank-default-small, base_url: http://192.168.0.235:7770/v1}
memory_query_rewrite: {provider: custom, model: frank-default-small, base_url: http://192.168.0.235:7770/v1}
tts_audio_tags: {provider: custom, model: frank-default-small, base_url: http://192.168.0.235:7770/v1}
triage_specifier: {provider: custom, model: frank-default-small, base_url: http://192.168.0.235:7770/v1}
kanban_decomposer: {provider: custom, model: frank-default-small, base_url: http://192.168.0.235:7770/v1}
profile_describer: {provider: custom, model: frank-default-small, base_url: http://192.168.0.235:7770/v1}
curator: {provider: custom, model: frank-default-small, base_url: http://192.168.0.235:7770/v1}
dashboard:
# basic_auth intentionally omitted; its password_hash remains live-only.
font: atkinson-hyperlegible
skills:
disabled: []
onboarding:
seen:
busy_input_prompt: true
_config_version: 34
platforms:
signal:
enabled: true
# extra.http_url and account intentionally omitted from the backup.
+1 -1
View File
@@ -5,4 +5,4 @@
"attachmentFolderPath": "_attachments",
"useMarkdownLinks": false,
"showUnsupportedFiles": true
}
}
+99 -30
View File
@@ -1,60 +1,129 @@
---
type: guide
title: Deployment Flow
description: Service-scoped Git deployment for the two homelab hosts.
tags:
- deployment
- operations
- git
timestamp: 2026-09-03
---
# Deployment Flow
Git is the single source of truth. A `deploy.sh` script moves committed
changes to the target hosts over SSH. One run per target covers both
workloads on that host: the podman stack and the LLM service.
Git is the single source of truth. `scripts/deploy.sh` pushes the committed
`main` branch, pulls it on one target, and applies only the runtime services
whose tracked inputs changed. A SearXNG change no longer recreates the
inference service; an LLM change no longer restarts unrelated containers.
## Workflow
```mermaid
graph LR
A[Edit in this repo] --> B[Commit]
B --> C[Push to origin]
C --> D[deploy.sh: pull on target]
D --> E[restart stack]
E --> F[health check]
A[Edit in this repo] --> B[Run tests]
B --> C[Commit main]
C --> D[deploy.sh computes plan]
D --> E[Push main]
E --> F[Target pulls with ff-only]
F --> G[deploy-remote.sh dispatches selected services]
G --> H[Per-service health check]
```
1. Edit files locally in this repository.
2. Commit and push.
3. Run `./scripts/deploy.sh <dankclaw|dankserver>`.
4. The script pulls the repo on the target and restarts the podman stack
and the LLM service.
5. It verifies service health after restart (homepage and LLM endpoint).
2. Run `./tests/run.sh`, then commit the complete change on `main`.
3. Run `./scripts/deploy.sh <target>`.
4. The planner maps changed paths to services. Compose YAML is compared by
service definition so an edit to one service does not restart its peers.
5. The target pulls with `git pull --ff-only origin main` and runs the remote
dispatcher with the explicit plan.
6. The dispatcher updates only selected Quadlet, compose, homepage, Caddy, or
systemd services and records each successful service revision outside Git.
## Deploy Script
## Commands
```
./scripts/deploy.sh dankclaw # pull + restart on Dankclaw
./scripts/deploy.sh dankserver # pull + restart on Dankserver
```bash
./scripts/deploy.sh dankclaw
./scripts/deploy.sh dankserver
./scripts/deploy.sh dankclaw --service searxng
./scripts/deploy.sh dankserver --service frank-default-small
./scripts/deploy.sh dankclaw --dry-run
```
SSH uses the existing key pair (`dank@dankclaw`, `dank@dankserver`). No passwords are stored or documented in this repo. If sudo is needed, use the interactive sudo on the target — never commit a password.
`--service` is repeatable and is an explicit retry/override. Without it, the
planner compares the local `HEAD` to `origin/main`. A documentation-only
commit produces an empty plan. The remote state directory can also replay a
service whose earlier deployment failed, but it never expands a plan to a
service on the other host.
## Service managers
| Service family | Runtime manager | Scope behavior |
|---|---|---|
| Dankclaw compose services | `podman-compose -f dankclaw/compose.yaml up -d --no-deps NAME` | Recreates only `NAME`; Caddy is handled separately because it fronts both hosts. |
| SearXNG, Gluetun, Valkey, OpenWebUI, Pi-hole, Gitea | user Quadlet + `systemctl --user` | Installs the selected unit and restarts only the required dependency-coupled units. |
| `frank-default-large` | systemd + `systemctl` | Relinks and restarts only the large inference service; waits up to 20 minutes for `/health`. |
| `frank-default-small` | systemd + `systemctl` | Relinks and restarts only the small inference service; waits up to 20 minutes for `/health`. |
| Dankserver media inventory | host-managed bare containers | Compose is inventory only; the scoped deploy does not create duplicate containers. |
The shared chat template maps to the inference service on the selected target.
The inactive `frank-default-npu` unit is relink-only when explicitly selected;
it is not restarted by an unrelated large-model deployment.
## Caddy safety
Caddy runs on Dankclaw and fronts both hosts. The dispatcher never stops
`gitea-caddy` implicitly. If the legacy `gitea-caddy` container owns ports
80/443 while the compose `caddy` container is absent or stopped, a Caddy
selection fails closed rather than replacing the live proxy. Resolve port
ownership explicitly before retrying. A Caddy reload failure falls back to a
restart of **only** the selected `caddy` container.
Homepage files, generated `deployed.js`, the status checker, and the rendered
Caddyfile are updated only when their service is selected. A media-only
Dankserver deployment does not touch Dankclaw's proxy or homepage.
## Per-service state
The remote runner writes one revision file per successfully completed service
to:
```text
${XDG_STATE_HOME:-~/.local/state}/serverAppConfigs/deploy-state/
```
The directory is outside the checkout and is never committed. A failed handler
is not marked successful. On the next ordinary deployment, the runner compares
the recorded revision with the new checkout and retries only a still-changed
service. This prevents a failed Caddy or LLM deployment from being silently
forgotten while avoiding a host-wide restart.
## Targets
| Target | Host | Stack |
|--------|------|-------|
| dankclaw | dank@dankclaw (192.168.0.143) | `dankclaw/` |
| dankserver | dank@dankserver (192.168.0.235) | `dankserver/` |
| Target | Host | Repository checkout |
|---|---|---|
| dankclaw | `dank@dankclaw` (`192.168.0.143`) | `/home/dank/serverAppConfigs` |
| dankserver | `dank@dankserver` (`192.168.0.235`) | `/home/dank/serverAppConfigs` |
## Health Verification
The deploy script polls the health endpoint of each service after restart. Service healthchecks are defined in each compose file. A service that fails its healthcheck is reported, not hidden.
SSH uses the existing key pair. No password is stored or documented in this
repository. If sudo is needed on a headless target, `FRANK_SUDO_PW` is read
from the environment and is never written to the repository.
## Tests
Run the test suite before pushing:
Run the full suite before pushing:
```
```bash
./tests/run.sh
```
The suite validates compose YAML, env consistency, Caddyfiles, docs completeness, and script syntax. See [[Testing Policy]].
The deployment seams are in `tests/deployment_scoping.bats` and
`tests/deployment_flow.bats`. They cover path isolation, compose narrowing,
Caddy safety, inference isolation, per-service state, and legacy wrapper
routing. See [[Testing Policy]].
## See Also
- [[Storage Policy]]
- [[Testing Policy]]
- [LLM deployment docs](../LLM/deployment.md) — the LLM half of the same flow
- [LLM deployment](../LLM/deployment.md)
- [[Caddy]]
- [[SearXNG]]
+23 -1
View File
@@ -11,7 +11,29 @@ This repository tests its configuration at defined seams. The tests run with `ba
| Caddyfile validity | `tests/caddyfile.bats` | per-host Caddyfile, `.local` convention, balanced braces, basic auth present |
| Docs completeness | `tests/docs_completeness.bats` | every compose service has a docs page; index links every page |
| Script syntax | `tests/scripts_syntax.bats` | `bash -n` clean, executable bits, shellcheck when available |
| Deployment flow | `tests/deployment_flow.bats` | deploy.sh exists, targets known, SSH reachable (skips offline) |
| Deployment flow | `tests/deployment_flow.bats`, `tests/deployment_scoping.bats` | scoped dispatcher exists, targets are known, service plans isolate inference, Caddy ownership is guarded, and SSH reachability is checked (skips offline) |
## Focused pre-commit testing
For normal commits, use the focused wrapper:
```bash
./scripts/test-changed.sh
```
It reads staged paths with `git diff --cached` and runs only the mapped Bats
files. The tracked `.githooks/pre-commit` hook invokes this wrapper. Enable the
repository hook path once per checkout:
```bash
git config core.hooksPath .githooks
```
Use `--list` to inspect the selection without running tests, or `--paths FILE
...` to preview a mapping. Shared test infrastructure and unmapped paths fail
closed rather than silently skipping coverage. Run the full suite with
`./tests/run.sh` when changing shared test helpers, the test runner, or when
focused coverage is not sufficient.
## Red-Green
+1 -1
View File
@@ -22,7 +22,7 @@ The gateway container runs `hermes gateway run` (the Signal platform). That entr
The dashboard refuses to bind to a non-loopback address without an auth provider. There is no unauthenticated public bind. This repo uses basic auth:
- `dashboard.basic_auth.username` and `dashboard.basic_auth.password_hash` live in the git-ignored `/home/dank/hermes-data/config.yaml`.
- `dashboard.basic_auth.username` and `dashboard.basic_auth.password_hash` live in the live `/home/dank/hermes-data/config.yaml`; the tracked backup at `dankclaw/hermes/config.yaml.example` intentionally omits the password hash.
- The stored value is a one-way scrypt hash. The plaintext password is never written to a file or the repo.
- The hash is generated with `plugins.dashboard_auth.basic.hash_password`.
+12 -2
View File
@@ -4,6 +4,10 @@ Hermes is the self-improving AI agent from Nous Research. It runs in a terminal,
**Host:** Dankclaw — **Hostname:** `hermes.dankclaw.local` — **Docs:** https://hermes-agent.nousresearch.com/docs
**Persistent state:** `/home/dank/hermes-data` on Dankclaw, mounted as `/data` in
both containers. The tracked, sanitized configuration baseline is
[`dankclaw/hermes/config.yaml.example`](../../dankclaw/hermes/config.yaml.example).
## Facts (from research, primary source)
| Item | Value |
@@ -17,15 +21,21 @@ Hermes is the self-improving AI agent from Nous Research. It runs in a terminal,
## Configuration
- Point Hermes at the local model endpoint (`http://localhost:7770/v1`) or an external provider through its provider config.
- Point Hermes at the local model endpoint or an external provider through its provider config.
- The gateway process exposes one port for chat integration; the exact port is set in the Hermes config (`8090` convention in this repo).
- The data volume persists skills, memory, and session history.
- Hermes has two named local providers: `dankclaw-large` reaches
`http://192.168.0.143:7770/v1` and exposes `frank-default-large`;
`dankserver-small` reaches `http://192.168.0.235:7770/v1` and exposes
`frank-default-small`.
- The live `config.yaml` contains secrets and user state and is not committed;
the tracked example is the sanitized backup of its non-secret settings.
## Volumes
| Container path | Host path (convention) |
|----------------|------------------------|
| `/data` | `/opt/hermes/data` |
| `/data` | `/home/dank/hermes-data` |
## Gotchas
+91 -21
View File
@@ -15,6 +15,26 @@ engine for an hour to 15 days and silently drops it from results. That is the
This page is the fixbook. Read [[SearXNG]] for the stack; the sub-pages below
are the detail if you need it.
## Provider policy
The default aggregation should maximize **independent, anonymous coverage**
without turning one user query into a request storm. Disabled engines remain
installed and selectable on demand; `inactive` is reserved for engines that
should disappear completely.
| Category | Default baseline | On-demand / deferred |
|----------|------------------|----------------------|
| General/web | Bing, DuckDuckGo, Wikipedia | Google CSE, Brave, Startpage, Qwant, Mojeek, Wiby, MWMBL after retesting |
| Technical | GitHub, MDN, Stack Overflow, Ask Ubuntu, Superuser, Docker Hub, PyPI | Other technical indexes as needed |
| Science | arXiv, PubMed, Semantic Scholar, OpenAlex-compatible sources | Google Scholar until independently tested |
| News | Reuters, Bing News, DuckDuckGo News, Wikinews | Google News, Brave News, Startpage News |
| Images | Openverse, Wikimedia Commons, Bing Images, Unsplash | Google/Brave/Startpage image engines |
| Videos | YouTube, Vimeo, Dailymotion, Bing Videos | Brave video engines |
This policy uses no API credentials and does not identify the operator to a
search provider. It is deliberately conservative: a bundled engine is not
necessarily healthy, anonymous, or suitable for every default query.
## Root cause (one line)
All SearXNG engine traffic shares **one ProtonVPN exit IP** with no rotation, so
@@ -51,6 +71,12 @@ Most "Google is broken on a VPN host" cases are a **mix of both**.
## Fix A — Engine config (settings.yml `engines:`)
The current tracked policy enables Bing and DuckDuckGo for default general
search, gives each weight 2, disables pagination, and allows 5 seconds for the
VPN path. CAPTCHA/429-prone Google, Brave, and Startpage families are retained
but disabled from default aggregation. This limits fan-out while preserving
explicit/on-demand selection.
Important: SearXNG's engines are configured in the **`engines:` list inside
`settings.yml`**, not a separate `engines.yml` file. With
`use_default_settings: true` (our setting) your `engines:` list is *merged* over
@@ -137,12 +163,12 @@ Tune the `outgoing` block and the suspend model to survive load:
```yaml
outgoing:
request_timeout: 6.0 # raise from 2.0 so slow engines aren't dropped as unresponsive
max_request_timeout: 15.0
pool_connections: 200 # bigger pool = more concurrent connections per engine host
pool_maxsize: 40 # keep-alive connections per host
enable_http2: true # multiplexing = fewer distinct "new connection" events per engine
retries: 3 # on 429/5xx, retry against a different proxy + source IP
request_timeout: 4.0 # current Dankclaw value; modest VPN-latency allowance
max_request_timeout: 12.0
pool_connections: 100
pool_maxsize: 20
enable_http2: true
retries: 1 # avoid amplifying 429/CAPTCHA responses from one exit IP
useragent_suffix: "ops@yourhomelab.example" # so a per-engine blocker can reach you
search:
@@ -159,8 +185,12 @@ Notes:
that is a historical-searx concept. The functional equivalent is the suspend
schedule above (widen to back off before blocking, narrow to recover faster).
- `request_timeout` interacts with the suspend model: too tight → engines look
"unresponsive"; too loose → slow pages. Aim per-engine `timeout` at
*proxy-add-on time + engine latency*, capped by `max_request_timeout`.
"unresponsive"; too loose → slow pages. Dankclaw uses 4 seconds globally and
12 seconds as the cap; raise it only when measurements show real upstream
latency rather than 429/CAPTCHA responses.
- Keep retries low: retrying a 429/CAPTCHA through the same VPN exit can make the
provider's rate limit worse. A retry count of 1 is a bounded compromise for a
transient network failure.
- `safe_search: 0` when diagnosing — a too-strict global safe search can make an
engine return zero rows and *look* blocked.
@@ -226,6 +256,39 @@ gateway.
> containers/systemd/<unit>.container` `Environment` to inject names into the
> tunnel's `/etc/hosts`.
## Observed Dankclaw baseline (2026-08-25)
The read-only audit found:
- SearXNG image: `ghcr.io/searxng/searxng:latest`; embedded version `1.0.0`.
- Gluetun uses `VPN_SERVICE_PROVIDER=custom` with a static WireGuard profile;
no `SERVER_COUNTRIES`, `SERVER_CITIES`, `SERVER_REGIONS`, or
`SERVER_HOSTNAMES` filters are configured.
- The observed exit was `149.22.88.19` (Seattle, Washington, US).
- Bing and DuckDuckGo returned results. Mojeek returned no results without an
upstream error.
- `google cse` and Brave were suspended for too many requests; Startpage was
suspended for CAPTCHA; Qwant returned CAPTCHA. SearXNG logs also recorded
Google's "unusual traffic from your network" response.
- `/healthz` returned `200 OK`; the SearXNG service and Gluetun service were
healthy. This isolates the primary failures to upstream provider handling,
not application availability.
This deployment returns the legacy array form for `unresponsive_engines`:
```json
"unresponsive_engines": [
["brave", "Suspended: too many requests"],
["startpage", "Suspended: CAPTCHA"]
]
```
Do not assume the newer object form (`engine`, `error_type`, `suspended`)
until the live response has been checked. The direct published port is the
useful diagnostic path because it exercises the ProtonVPN exit. The Caddy
hostname resolved to the WAN address during the audit and timed out from the
audit host, so it was not used as the baseline path.
## Fix D — Verify an engine is unblocked
Three layers, cheapest first. All queries should hit the path production uses
@@ -242,11 +305,11 @@ curl -s "$BASE/search?q=test&engines=google&format=json" \
Inspect:
- `content[].engine` — which engine produced each result.
- `unresponsive_engines[]` — `{engine, error_type, suspended}`. `error_type`
tells you *why* (`SearxEngineTooManyRequests`, `SearxEngineCaptcha`,
`timeout`, `SearxEngineAccessDenied`); `suspended: true` = auto-banned right
now.
- `timings[].engine` — per-engine latency.
- `unresponsive_engines[]` — inspect the live shape first. On Dankclaw it is
`[engine, message]`, for example `["google cse", "Suspended: too many requests"]`.
Newer builds may return objects containing `engine`, `error_type`, and
`suspended`.
- `timings[].engine` — per-engine latency when the running build emits timings.
Full status check (bash + jq):
@@ -285,16 +348,23 @@ misleading).
## Recommended path for Dankclaw (ranked)
1. **Verify first** — `/json` + `?engines=google` and the searx-checker to see
1. **Measure individual engines** — use `/json` + `?engines=<name>` and several
query classes before enabling a bundled engine in default aggregation.
2. **Keep default fan-out small** — prefer independent anonymous providers and
disable pagination for rate-sensitive engines.
3. **Promote by evidence** — raise `weight` only for engines that return useful
results consistently; weight is not a health check.
4. **Avoid credentials** — do not add API-backed engines unless the provider is
genuinely free, anonymous, and compatible with our privacy requirements.
5. **Verify first** — `/json` + `?engines=google` and the searx-checker to see
exactly which engines are down and why. Confirm the running SearXNG version
(check whether `parsers`/`rate_limit` exist in the pinned build).
2. **Tighten Fix B** — raise `outgoing.request_timeout`, add `retry_on_http_error:
[403, 429]` on the Google engines, widen `suspended_times`. Cheapest, no new
infra.
3. **Duplicate the Google engines** (Fix A) so a CAPTCHA ban on one leaves the
other returning results.
4. **If Google still re-blocks** — Fix C Option 1: point the Google engines at a
dedicated-IP proxy (Option 2 dedicated second Gluetun if reputation
6. **Use conservative request tuning** — Dankclaw now allows 5 seconds per
upstream request, caps it at 15 seconds, and retries once. This addresses
modest VPN latency without multiplying 429/CAPTCHA traffic. Adjust only
after measuring the resulting error and latency profile.
7. **If Google still re-blocks** — Fix C Option 1: point the Google engines at
a dedicated-IP proxy (Option 2 dedicated second Gluetun if reputation
isolation is needed). Avoid the same-netns-proxy trap.
---
+33 -7
View File
@@ -61,9 +61,29 @@ valkey:
url: "valkey://searxng-valkey:6379/0"
outgoing:
request_timeout: 3.0
max_request_timeout: 10.0
retries: 3
request_timeout: 5.0
max_request_timeout: 15.0
retries: 1 # avoid amplifying upstream 429/CAPTCHA responses
# Anonymous, bounded default aggregation. Disabled engines remain configured
# for explicit/on-demand selection; see SearXNG Providers for the policy.
engines:
- name: bing
disabled: false
weight: 2
paging: false
timeout: 5.0
- name: duckduckgo
disabled: false
weight: 2
paging: false
timeout: 5.0
- name: google cse
disabled: true
- name: brave
disabled: true
- name: startpage
disabled: true
```
## Volumes
@@ -96,10 +116,16 @@ Reach SearXNG through Caddy (`search.dankclaw.local`) for browser use. A local s
## Unblocking Google & Search Providers
SearXNG queries all ~50 upstream engines from one Gluetun VPN exit IP. The
rate-sensitive engines (Google, DuckDuckGo) then answer with reCAPTCHA / 429,
and SearXNG **suspends** the offending engine for hours to days, which is what
shows up as "Google went unavailable". This is upstream, not local.
The current request policy allows a modest amount of VPN latency (`4.0s` per
request, `12.0s` maximum) while limiting retries to one. This can recover slow
providers, but it cannot fix an upstream CAPTCHA or 429 caused by the shared
VPN exit IP. SearXNG queries all ~50 upstream engines from one Gluetun VPN exit
IP. The rate-sensitive engines (Google, DuckDuckGo) then answer with reCAPTCHA / 429.
The default provider policy therefore favors anonymous Bing and DuckDuckGo,
disables observed CAPTCHA/429 providers from normal aggregation, and disables
pagination to reduce upstream request volume. SearXNG **suspends** offending
engines for hours to days, which is what shows up as "Google went unavailable".
This is upstream, not local.
Fixes (cheapest first): tune the `outgoing` block (`request_timeout`,
`retry_on_http_error`, `suspended_times`), duplicate the `google` engine under a
+43
View File
@@ -0,0 +1,43 @@
# Entropy Volume
The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at
`/mnt/entropy`. The command-line helper reuses the host's desktop-integrated
`udisks2`, `crypttab`, and `fstab` configuration; it does not store or handle
the passphrase itself.
## Command-line mount
```bash
/home/dank/serverAppConfigs/scripts/mount-entropy.sh
```
Run it as the normal desktop user (`dank`), not from a root shell. `udisksctl`
will securely prompt for the LUKS passphrase. `sudo mount /mnt/entropy` then
uses the existing `/etc/fstab` entry, preserving its options:
```text
nosuid,nodev
```
The existing mountpoint is owned by `dank:dankserver` with mode `0755`; the
helper deliberately does not run `chmod` or `chown`, so it cannot silently
change those permissions.
## Device path
The current storage chain is:
```text
/dev/md0 -> LUKS UUID cd86c728-459e-4f3a-bcc2-41ecf84685ec
-> ext4 label entropy
-> /mnt/entropy
```
The helper checks that `/dev/md0` exists and exits successfully if the volume
is already mounted. It is intentionally specific to this host and volume:
that avoids accidentally unlocking or mounting a different disk.
## Related
- [[Storage Policy]]
- [[Dankserver Stack]]
+1 -1
View File
@@ -253,7 +253,7 @@ ngram draft costs its own forward pass / occupancy without adding enough
accepted tokens on top of MTP. Baseline (no ngram) remains the production
config (`--spec-type draft-mtp` only). Keep ngram-mod off.
The harness lives at [scripts/bench-spec-stack.sh](../../scripts/bench-spec-stack.sh);
The historical harness was moved to `.AGENTTRASH/bench-scripts-20260901/` and is retained for audit only.
it boots the v3 stack on a scratch port and reads authoritative
`print_timing` lines. It requires the production service to be stopped.
+96 -199
View File
@@ -1,227 +1,124 @@
---
type: guide
title: LLM Deployment
description: Scoped deployment and update process for the two inference services.
tags:
- llm
- deployment
- systemd
timestamp: 2026-09-03
---
# Deployment and Update Process
This page describes how to deploy and update the LLM servers on Dankserver
and Dankclaw. The process uses git as the single source of truth. All script
edits flow through this repository.
This page describes how to deploy and update the LLM servers on Dankserver and
Dankclaw. The process uses Git as the single source of truth. A change to
SearXNG, Caddy, or another container service does **not** restart inference.
> 2026-08-22: the former `vllmScripts` repository is merged into this repo.
> Both LLM services now run from the `/home/dank/serverAppConfigs` checkout
> on each host. The retired checkouts (`/home/dank/vLLM` on Dankserver,
> `/home/dank/vllmScripts` on Dankclaw) are archived on the hosts.
## Fleet
## Overview
| Target | Host | Service | Port |
|---|---|---|---:|
| Dankserver | `dank@dankserver` | `frank-default-small` | 7770 |
| Dankclaw | `dank@dankclaw` | `frank-default-large` | 7770 |
Two servers run LLM inference services. Each server pulls its scripts from
this repository. The deployment process moves a committed change from the
repository to a running service.
| Target | Host | Runtime | Service | Port |
|--------|------|---------|---------|------|
| Dankserver | dank@dankserver | upstream llama.cpp (Ornith-1.5-35B-A3B-CRACK Q6_K, native MTP draft, selected MoE experts offloaded via --override-tensor) | frank-default-small | 7770 |
| Dankclaw | dank@dankclaw | charlie12345/ROCmFPX llama.cpp (caf-eemin Qwen3.8-27B ROCmI4 Q4_0, embedded MTP, ROCmI4 W4A4, 262K ctx / 1 slot) | frank-default-large | 7770 |
## Workflow
The deployment workflow has seven steps.
## Scoped workflow
```mermaid
graph LR
A[Edit scripts locally] --> B[Commit]
B --> C[Push to origin<br>Gitea on Dankclaw]
C --> D[Pull on target<br>git pull]
D --> E[Restart podman stack]
E --> F[Relink + restart LLM unit]
F --> G[Verify health endpoints]
A[Edit LLM files] --> B[Run tests]
B --> C[Commit main]
C --> D[deploy.sh selects matching service]
D --> E[Target pulls ff-only]
E --> F[Relink one unit]
F --> G[Restart one inference service]
G --> H[Wait for that service health]
```
1. Fetch the current script from the target machine.
2. Commit the fetched version in this repository.
3. Edit the script locally in the working tree.
4. Run the tests (`./tests/run.sh` must pass).
5. Commit and push the change to origin.
6. Pull the change on the target machine.
7. Restart the services and verify that they are healthy.
Run the unified deployer from the repository root:
## Deploy Script
The `scripts/deploy.sh` script is the unified deployer. One run per target
covers both workloads on that host: the podman stack and the LLM service.
Run it from the repository root.
Run this command to deploy to Dankserver:
```
./scripts/deploy.sh dankserver
```bash
./scripts/deploy.sh dankserver --service frank-default-small
./scripts/deploy.sh dankclaw --service frank-default-large
./scripts/deploy.sh dankclaw --dry-run
```
Run this command to deploy to Dankclaw:
Without `--service`, `scripts/deploy-plan.py` maps changed files to the service
on the selected host. The following files select the corresponding service:
```
./scripts/deploy.sh dankclaw
- `scripts/frank-default-small.env`, `frank-default-small.service`, and
`serve-frank-small.sh` select `frank-default-small` on Dankserver.
- `scripts/frank-default-large.env`, `frank-default-large.service`, and
`serve-frank-large.sh` select `frank-default-large` on Dankclaw.
- `scripts/chat-template.jinja` selects the LLM on the selected host only.
- Documentation, tests, deployment tooling, and unrelated container changes
do not select either inference service.
The target-side `scripts/deploy-remote.sh` relinks the selected unit into
`/etc/systemd/system/`, reloads systemd, enables it, restarts it, and waits for
`http://127.0.0.1:7770/health`. The wait budget is 20 minutes because cold model
loads can be slow. A failed handler does not advance its per-service state.
## Unit and artifact paths
Both units point into the stable target checkout:
```text
/home/dank/serverAppConfigs/scripts/frank-default-small.service
/home/dank/serverAppConfigs/scripts/frank-default-large.service
```
Add the `--dry-run` flag to preview without making changes:
The unit files and serve scripts validate their model/runtime artifacts before
starting. Keep `REMOTE_DIR` stable; moving the checkout requires updating both
the unit absolute paths and the deployment tests in the same change.
```
./scripts/deploy.sh dankserver --dry-run
## NPU unit
`frank-default-npu` is an inactive, historical Dankclaw endpoint. It is
relinked only when explicitly selected with `--service frank-default-npu`; the
large-model handler never restarts it. This is intentional: the NPU is not part
of the active inference path and should not be disturbed by a large-model
change.
## Isolation guarantee
The remote runner has one explicit service dispatch. It does not run a
host-wide LLM loop, `podman-compose up` for unrelated services, or a blanket
systemd restart. Each successful service is recorded under the target user's
state directory outside Git:
```text
~/.local/state/serverAppConfigs/deploy-state/
```
The script does these actions:
On a later ordinary deployment, a service remains pending only while its
recorded revision differs from the checkout and the planner still finds a
change in that service's inputs.
1. Verifies that the local working tree has no uncommitted changes.
2. Pushes the `main` branch to origin.
3. Pulls the repository on the target machine.
4. Restarts the podman stack (Dankclaw only. The Dankserver media stack
runs as bare containers).
5. Relinks the LLM systemd units, reloads systemd, enables, and restarts
the active LLM service.
6. Waits for the Caddy homepage and the LLM health endpoint (over ssh).
## Manual recovery
Set `FRANK_SUDO_PW` when running headless on a host without passwordless
sudo, so that remote sudo reads the password from stdin (`sudo -S`). The
password is never stored in this repo. Both fleet hosts currently allow
passwordless sudo for the deploy user (verified 2026-08-22). The
passwordless restart helpers from the retired topology are obsolete and are
not used.
If the LLM handler fails after the target pull:
Health endpoints:
1. Inspect `journalctl -u frank-default-small -n 200` or
`journalctl -u frank-default-large -n 200`.
2. Confirm the model files, custom runtime binary, and chat template are
readable by `dank`.
3. Retry only the affected service:
- Dankserver: `http://127.0.0.1:7770/health`
- Dankclaw: `http://127.0.0.1:7770/health`
```bash
./scripts/deploy.sh dankserver --service frank-default-small
# or
./scripts/deploy.sh dankclaw --service frank-default-large
```
## Manual Update
4. Verify the matching health endpoint before declaring recovery.
### Update Dankserver (frank-default-small)
Run these commands on Dankserver:
```
cd /home/dank/serverAppConfigs
git pull origin main
sudo ln -sf /home/dank/serverAppConfigs/scripts/frank-default-small.service /etc/systemd/system/frank-default-small.service
sudo systemctl daemon-reload
sudo systemctl restart frank-default-small
curl -sf http://127.0.0.1:7770/health
```
### Update Dankclaw (frank-default-large)
Run these commands on Dankclaw:
```
cd /home/dank/serverAppConfigs
git pull origin main
sudo ln -sf /home/dank/serverAppConfigs/scripts/frank-default-large.service /etc/systemd/system/frank-default-large.service
sudo systemctl daemon-reload
sudo systemctl restart frank-default-large
curl -sf http://127.0.0.1:7770/health
```
## Service Units
### Dankserver — frank-default-small
The active service on Dankserver is upstream llama.cpp (b10121) serving
`Ornith-1.5-35B-A3B-CRACK-Q6_K.gguf` under the alias `frank-default-small`
on port 7770. It runs the stock `/home/dank/llama.cpp/build/bin/llama-server`
binary with expert tensors selected by an explicit `--override-tensor`
regex. Expert tensors from every full-attention block and blocks 35 through 40
stay on CUDA; other expert tensors are CPU-resident. The service uses 7 CPU
threads, ngram-mod speculative decoding only, f16 KV, context 491520 (3 slots
of 163840 each), batch
4096, ubatch 1024, and `--no-mmap`. The unit is
`scripts/frank-default-small.service`. It launches through
`scripts/serve-frank-small.sh`, which reads tunables from
`scripts/frank-default-small.env`. It uses `ubatch=1024` and the chat template
from `peculiar-ragdoll/Qwen-Sharp-Chat-Templates`. See [[dealign-ornith-crack]].
### Dankclaw — frank-default-large
The active service on Dankclaw uses the ROCmFPX llama.cpp runtime to serve
[kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF](https://huggingface.co/kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF)
under the alias `frank-default-large` on port 7770. The main model is
`Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf`; the standalone MTP draft is
`mtp-Qwen3.8-27B-Q4_0.gguf`, and the BF16 vision projector is loaded alongside
it. The K-side KV cache remains `f16`; the V-side cache uses `turbo4`. The
service keeps the existing 65536 context / 1-slot profile. The unit file is
`scripts/frank-default-large.service`; it launches through
`scripts/serve-frank-large.sh`, which reads tunables from
`scripts/frank-default-large.env`, applying the Qwen-Sharp chat template.
> The kingjones ROCmFP4 STRIX MTP model served the slot from 2026-08-24; it
> was replaced by the caf-eemin ROCmI4 embedded-MTP model on 2026-08-25. The
> v3 PromptForge payload and the CIRU ROCmFPX runtime are retired on this slot.
> The intermediate DFlash2 Vulkan deployment was retired the same day (2026-08-21).
> See [[dflash2-large-vulkan]] for the archived runbook.
## Validation
The deploy script checks the health endpoint after a restart. For deeper
checks, run a completion request against the endpoint:
```
curl -s http://127.0.0.1:7770/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"frank-default-small","messages":[{"role":"user","content":"Say OK"}]}'
```
## Troubleshooting
If the health check fails after a deploy:
1. Read the service logs with `journalctl -u <service> -n 200`.
2. Confirm that the model files exist in the cache.
3. Confirm that the GPU has free memory (`nvidia-smi` on Dankserver, `rocm-smi` on Dankclaw).
4. Check the service status with `systemctl status <service>`.
## Incident: HIP Runtime Crash From the `TEMP` Env Variable (2026-08-13)
On 2026-08-13 the old `frank-default-large` service crash-looped for hours
on Dankclaw. Root cause: the env files exported `TEMP=1.0` (a sampling
default). The ROCm HIP runtime reads `TEMP` as its JIT kernel-cache
directory, used the path `1.0`, failed, and segfaulted on a NULL object.
Fix: rename the sampling variable to `TEMPERATURE` (commit `227ce01`).
If a service segfaults at load with no obvious cause, compare the exported
env against the HIP runtime's env reads. Do not name any tunable `TEMP`,
`HOST`, or `GPU_MAX_HEAP_SIZE`.
## Incident: Jobs "Released Prematurely" Under Parallel Load (2026-08-13)
Historical: on the retired topology, parallel jobs on the Dankserver 35B
service died with a cuBLAS crash under researcher load. The affected
service is retired. A client abort logs `W srv stop: cancel task,
id_task = N`. A natural stop logs only `slot release` with
`truncated = 0`. The server never preempts a running generation.
## Incident: frank-default-small Crash Loop After Distro Upgrade (2026-08-21)
On 2026-08-21 a release upgrade moved Dankserver from Ubuntu 24.04 to
Ubuntu 26.04 (Resolute Raccoon). The upgrade removed the CUDA 12 runtime
packages: `libcudart12`, `libcublas12`, `libcublaslt12`, and
`nvidia-cuda-toolkit`. The `llama-server` binary links
`libcudart.so.12` and `libcublas.so.12`. On every start the binary exited
with code 127. systemd restart-looped the service continuously. The health
endpoint on port 7770 stayed down.
Fix: reinstall the runtime packages. The same versions remain in the 26.04
repository.
sudo apt-get install -y libcudart12 libcublas12 libcublaslt12
Verify that the binary resolves all libraries:
ldd /home/dank/llama.cpp/build/bin/llama-server | grep "not found"
Start the service and check the health endpoint on port 7770.
After any release upgrade on Dankserver, run the `ldd` check above. A
missing CUDA 12 library means the packages need a reinstall.
Do not stop the other host's inference service during recovery. For benchmark
work, follow the separate stop/benchmark/restart procedure in [[Testing Policy]]
and the repository rules.
## See Also
- [AGENTS.md](../../AGENTS.md) — the git workflow rules
- [README.md](../../README.md) — repository overview
- [index.md](index.md) — LLM fleet knowledge base index
- [frank-redo-2026-08-17.md](frank-redo-2026-08-17.md) — the full redo runbook
- [docs/index.md](../index.md) — the project knowledge base (podman stacks)
- [Deployment Flow](../Architecture/Deployment%20Flow.md)
- [[index]]
- [Dankclaw deployment architecture](../Architecture/Network%20Architecture.md)
+3 -1
View File
@@ -142,7 +142,9 @@ Decode throughput depends on the ubatch (per-eval token batch). Run the sweep
on 8k contexts, 512 → 4096 in steps of 512, with a fixed batch:
```
STOPSVC=1 ./scripts/bench-ubatch-rocmi4.sh
sudo systemctl stop frank-default-large
./scripts/bench-ubatch.sh
sudo systemctl start frank-default-large
```
The script stops `frank-default-large` for a clean GPU, boots one server
+17 -9
View File
@@ -3,7 +3,7 @@
Documentation for the LLM inference fleet on two local machines
(2026-09-01): upstream llama.cpp on Dankserver (frank-default-small,
Ornith-1.5-35B-A3B CRACK) and ROCmFPX llama.cpp on Dankclaw
(frank-default-large, kingjones Qwen3.8-27B ROCmFPX Q8 AGENT). Both
(frank-default-large, kingjones Qwen3.8 Uncensored Q6 AGENT). Both
services run the Qwen-Sharp chat template.
> 2026-08-22: the former `vllmScripts` repository is merged into the
@@ -26,9 +26,9 @@ ngram-mod, selected experts offloaded via override, 491520 ctx]
subgraph Dankclaw[AMD Strix Halo - Ubuntu 26.04]
D[gfx1151 iGPU 64 GiB unified memory]
E[CIRU ROCmFPX llama.cpp PromptForge v3
E[charlie12345/ROCmFPX build-kairic (v81)
frank-default-large, port 7770]
F[Qwen3.8-27B ROCmFPX Q8_0 AGENT
F[Qwen3.8 Uncensored Q6 ROCmFPX AGENT
MTP draft + Turbo4 V cache, 262K ctx / 1 slot]
D --> E --> F
end
@@ -48,14 +48,16 @@ MTP draft + Turbo4 V cache, 262K ctx / 1 slot]
- [[deployment]] — deploy and update process for both servers
- [[frank-redo-2026-08-17]] — the 2026-08-17 topology change
- [kingjones Qwen3.8-27B ROCmFPX Q8 AGENT](https://huggingface.co/kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF) — active frank-default-large model on Dankclaw; standalone MTP and Turbo4 V cache
- [[kingjones-q8agent-rocmfpx]] — the Q8_0-AGENT runbook: serving profile, download, 262144 context, reasoning + preserve_thinking, 114 GiB TTM/GTT
- [kingjones Qwen3.8 Uncensored ROCmFP4 STRIX MTP](https://huggingface.co/kingjones777/Qwen3.8-27B-Uncensored-ROCmFP4-STRIX-MTP-GGUF) — active frank-default-large model on Dankclaw; Q6 AGENT main model with standalone MTP
- [[kingjones-strix-mtp]] — the active Q6 AGENT runbook: serving profile, standalone MTP, reasoning + preserve_thinking, 114 GiB TTM/GTT
- [[ubatch-benchmark-large]] — active-model llama-bench ubatch sweep plan and results
- [[kingjones-q8agent-rocmfpx]] — the retired Q8_0-AGENT investigation and `set_rows` failure
- [[eemin-rocmi4-mtp]] — the former caf-eemin Qwen3.8-27B embedded-MTP model
on Dankclaw (retired)
- [[dealign-ornith-crack]] — the dealign Ornith-1.5-35B-A3B CRACK model on
Dankserver (frank-default-small, active 2026-08-24)
- [[kingjones-strix-mtp]] — the retired kingjones Qwen3.8-27B ROCmFP4 STRIX MTP
model on Dankclaw (superseded 2026-08-25 by the ROCmI4 embedded-MTP model)
- [[kingjones-strix-mtp]] — the active kingjones Qwen3.8-27B ROCmFP4 STRIX MTP
model on Dankclaw (restored 2026-09-01 after the Q8_0 AGENT experiment)
- [[dealign-ornith-crack]] — the dealign Ornith-1.5-35B-A3B CRACK model on
Dankserver (frank-default-small, active 2026-08-24)
- [[ciru-activefpx-promptforge]] — the RETIRED v3 CIRU PromptForge runtime
@@ -107,10 +109,16 @@ MTP draft + Turbo4 V cache, 262K ctx / 1 slot]
- [[Pipeline Parallelism/index]] — index of multi-node inference research. The PP
pair is retired. These pages are research history.
## Runtime Research (llama.cpp family)
- [[Llama Cpp Runtimes]] — mainline vs charlie12345/ROCmFPX vs ik_llama.cpp: positioning, quantization, server fit
- [[Llama Cpp Code Diff]] — code-level quantization diff of the three runtimes + server recommendations
- [[SGLang]] — the structured-generation serving engine; when it fits Dankserver (3090) vs Dankclaw (Strix Halo)
## Related
- [Project knowledge base](../index.md) — the Podman app stacks on the same
two hosts. OpenWebUI consumes the `:7770` endpoint on Dankserver. The
Gitea instance on Dankclaw hosts this repository.
- [Deployment Flow](../Architecture/Deployment%20Flow.md) — the unified
git-based deploy for both workloads
- [Deployment Flow](../Architecture/Deployment%20Flow.md) — the service-scoped
git-based deploy with inference isolation
+65 -5
View File
@@ -1,6 +1,9 @@
# frank-default-large: kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT
**Status:** ACTIVE on Dankclaw (since 2026-08-25).
**Status:** Deployed on Dankclaw (since 2026-08-25). Serving is **BLOCKED by
the `set_rows` build bug** — the model loads but crashes in
`ggml_compute_forward_set_rows` before it can serve. All config goals are met;
a build fix is required for the model to run.
**Alias:** `frank-default-large`, port **7770**.
@@ -26,6 +29,53 @@ fit in the lifted **114 GiB TTM/GTT** budget (see the 114 GiB change in
| Tunables | `scripts/frank-default-large.env` |
| Serve script | `scripts/serve-frank-large.sh` |
## Serving status: the `set_rows` build bug (blocks serving)
As of 2026-08-25 the Q8_0 AGENT model will not serve on Dankclaw. It loads,
reaches `initializing slots, n_slots = 1`, but never prints `start serving`.
Health stays `503` until the process exits. The crash is in
`ggml_compute_forward_set_rows` in `libggml-cpu.so.0`, a NULL function-pointer
jump (address `0x00000000`).
The backtrace (gdb). The main thread and one OpenMP worker crash in the same
path:
```mermaid
graph TD
A[llama_context::graph_compute] --> B[ggml_backend_cpu_graph_compute]
B --> C[ggml_graph_compute]
C --> D[GOMP_parallel]
D --> E[ggml_compute_forward_set_rows]
E --> F[NULL dispatch -> address 0x0]
D --> G[Thread 23 worker: same path]
```
Test matrix. Five ROCmFPX builds and the all-CPU path were tested on a scratch
port (`/tmp/g80bench/run.sh`), `gl=45`, `ctx=131072`. All crash in `set_rows`.
| Build | Binary | Version | Result |
| ----- | ------ | ------- | ------ |
| build-kairic | `ciru-qwen38/ROCmFPX/build-kairic/bin/llama-server` | v81 (205a3e5f4) | segfault in set_rows |
| build-promptforge | `ciru-qwen38/ROCmFPX/build-promptforge/bin/llama-server` | PromptForge/ActiveFPX lineage | segfault in set_rows |
| build-rocmi4-w4a4 | `rocmi4-qwen38/ROCmFPX/build-rocmi4-w4a4/bin/llama-server` | v257 (c49ebdb) | segfault in set_rows |
| build-strix-rocmfp4 | `GitPackages/ROCmFPX/build-strix-rocmfp4/bin/llama-server` | v209 (00d5452) | segfault in set_rows |
| all-CPU | build-kairic, `-ngl 0` | v81 | segfault in set_rows |
The crash is not GPU-specific (the all-CPU path fails too), not MTP-specific
(`--spec-type none` fails), and not fixed by `LLAMA_SET_ROWS=0`. So it is a
build bug: the Q8_0 AGENT GGUF graph needs a `set_rows` dispatch the ROCmFPX
backends do not register. The older caf-eemin ROCmI4 and Kairic Edge models
served fine on related ROCmFPX builds. The caf-eemin payload was about 14.4
GiB; the separate
PromptForge/ActiveFPX deployment used a much larger multi-file payload of about
36.7 GiB. These are different deployments. The Q8_0 AGENT graph is the trigger
for the `set_rows` failure.
**Fix path:** rebuild charlie12345/ROCmFPX from source with a working
`set_rows` for the ROCmFPX backend, or find a build where it is registered.
This unblocks the model. See the context budget reality below once `set_rows`
is fixed.
## Model files
Three files, from the HF repo above:
@@ -93,9 +143,14 @@ Key choices:
- **f16 K + TurboQuant V** cache. f16 K is the safe long-ctx default; TurboQuant
halves the V cache at 262k while keeping quality (see the ubatch/threads-batch
notes in [[qwen3.8-strix-optimizations]]).
- **Full 262144 context, 1 slot.** The whole context KV cache plus the ~30 GB
payload (main + draft + mmproj) fit in the 114 GiB budget. Concurrent
requests queue; the server never preempts a running generation.
- **262144 context, 1 slot — the target.** The whole context KV cache plus the
~30 GB payload (main + draft + mmproj) are meant to fit in the 114 GiB
budget. Measured reality: `gl=999` (all layers on ROCm0) at 262144 **OOMs**
on device-local (`cudaMalloc failed`, the KV cache needs ~10-18 GiB that does
not fit once the payload is on the card). `gl=45` (partial offload) fits a
131072 context. The env ships `CTX_SIZE=262144`; the proven serving context
today is **131072 at `gl=45`** (before the `set_rows` fix). `deploy.sh` waits
up to 20 minutes (see `LLM_WAIT`).
- **Reasoning on.** The Sharp template drives thinking (medium effort);
`--reasoning-format none` lets the template project the reasoning content into
`reasoning_content`. `--reasoning-budget -1` = no budget cap.
@@ -160,6 +215,11 @@ reads `--host/--port/--temp` as CLI flags, never as env vars.
- **2026-08-25 (today):** switched the large service to the kingjones
Q8_0-AGENT model with 262144 context and reasoning on. Downloaded the three
payload files from the HF repo, raised the TTM/GTT to 114 GiB for the full
context, and persisted the env/serve script in this repo.
context, and persisted the env/serve script in this repo. Measured that the
model loads but crashes in `ggml_compute_forward_set_rows` on all five
available ROCmFPX builds; serving is BLOCKED pending a rebuild. The config
goals (GTT 114 GiB, reasoning on, preserve_thinking/preserve_reasoning on,
262144 env context) are in place. Proven serving context today is 131072 at
`gl=45`. See the `set_rows` build bug section above.
- Preceding model: Kairic Edge Qwen3.8-27B IU4 (deployed 2026-08-25 ~05:42,
retired the same day for the AGENT model). See [[kairic-edge]].
+6 -7
View File
@@ -15,13 +15,12 @@ timestamp: 2026-08-24
# kingjones Qwen3.8-27B ROCmFP4 STRIX MTP on Dankclaw
> **Retired 2026-08-25.** The kingjones ROCmFP4 STRIX MTP payload has been
> replaced by the caf-eemin Qwen3.8-27B ROCmI4 embedded-MTP model on the same
> port and iGPU, under a new runtime (charlie12345/ROCmFPX ROCmI4 W4A4). See
> [[eemin-rocmi4-mtp]]. The old kingjones GGUF + standalone MTP Q4_0 draft are
> retired on this slot. The CIRU ROCmFPX runtime/binary is also retired here.
> **Active again 2026-09-01.** The kingjones ROCmFP4 STRIX MTP payload is
> restored as `frank-default-large` after the Q8_0 AGENT experiment. The Q8_0
> model was retired because it crashed before serving. The Q6 AGENT model,
> standalone MTP draft, Q8 projector, and PromptForge runtime are now active.
> _This page documents the retired model and its runbook for reference._
> This page documents the active model and its runbook.
`frank-default-large` (port 7770) serves
`Qwen3.8-27B-Uncensored-ROCmFP4-STRIX-MTP` from `kingjones777` on Hugging
@@ -34,7 +33,7 @@ here; the head is loaded with `--model-draft`.
The card ships six text files plus the vision projector. The service uses
the Q6 AGENT file (requested for the fleet), the separate MTP draft head
and the Q8 vision projector.
and the Q8 vision projector (`mmproj-Qwen3.8-27B-Q8_0.gguf`).
| File | Size |
|------|------|
+56
View File
@@ -0,0 +1,56 @@
---
type: benchmark plan
title: frank-default-large ubatch sweep
description: Standardized ubatch sweep for the active kingjones Qwen3.8 ROCmFPX deployment.
tags:
- llama.cpp
- rocm
- benchmark
- ubatch
timestamp: 2026-09-01
---
# frank-default-large ubatch sweep
This benchmark uses the active `frank-default-large` model on Dankclaw with
`llama-bench`. It does not use `llama-server`, HTTP requests, or server-log
scraping. Stop the production service before running it and start the service
again afterward.
The old custom harnesses are retained in `.AGENTTRASH/bench-scripts-20260901/`
for auditability only. Their results are invalid and must not be used.
## Sweep
The requested points are `256, 512, 768, 1024, 1280, 1536, 1792, 2048,
3072, 4096` at 32K context. Each point runs three 128-token repetitions by
default.
Run on Dankclaw from the repository checkout:
```bash
sudo systemctl stop frank-default-large
./scripts/bench-ubatch.sh
sudo systemctl start frank-default-large
```
The script uses the active Q6 AGENT model, a fixed 32K prompt workload, and
`llama-bench` JSONL output. The ubatch-only sweep is:
```text
256, 512, 768, 1024, 1280, 1536, 1792, 2048, 3072, 4096
```
The GPU is shared with live workloads; do not run with the production service
up.
## Results
No valid results are recorded here. All results from the retired custom
harnesses are explicitly discarded. Record the raw `llama-bench` JSONL output
from the new harness here after a clean run.
## See also
- [LLM fleet index](index.md)
- [Deployment](deployment.md)
- [Active kingjones runbook](kingjones-strix-mtp.md)
+222
View File
@@ -0,0 +1,222 @@
---
type: research
title: Llama.cpp Code Diff
description: Code-level diff of mainline llama.cpp, charlie12345/ROCmFPX, and ik_llama.cpp with a focus on quantization code, ggml type numbers, and the differences that matter for Dankserver (RTX 3090) and Dankclaw (Strix Halo).
tags:
- llama.cpp
- quantization
- ggml
- ROCmFPX
- ik_llama
- code-diff
timestamp: 2026-09-01
---
# Llama.cpp Code Diff
Code-level diff of the three runtimes, focused on **quantization code** and the
differences that affect our two servers. Positioning overview is in
[[Llama Cpp Runtimes]].
## 1. Quantization file map
Where quant lives in each runtime (paths relative to the repo root):
| File / path | Mainline | ROCmFPX | ik_llama.cpp |
|-------------|----------|---------|--------------|
| `ggml/include/ggml.h` (`enum ggml_type`) | FP16/FP32/BF16, legacy `Q*_0`, K-quant types, `IQ*_` | **adds** `Q4_0_ROCMFP4` … `Q2_0_ROCMFPX`, `Q4_0_ROCMI4`, `Turbo3_0`, `Turbo4_0` (enums **100–108**) | IQK non-linear (`IQ4_NL` etc.), Trellis `IQ*_KT`, MXFP4, Bitnet, `Q8_KV` |
| `ggml/src/ggml-quants.*` | K/I/legacy dequant, row-interleaved 3-bit | `block_rocmfp4` unpack + E4M3 scaling, IU4 WMMA dispatch | `iqk/*` fused quant-gemms, IQK row-interleave, Trellis decode |
| `tools/quantize/quantize.c` | all mainline quants + `--imatrix` | ROCmFPX quantizer (`STRIX`/`FAST`/`_AGENT` presets) | IQK/Trellis/MXFP4 packers, `--cache-type-k/v` + Hadamard |
| `gguf.cpp` / `gguf-io.c` | standard ftype table | **custom ftypes** 100–106 (FP4), 110–113 (FPX), 118 (I4) | IQK/Trellis ftype tags |
| KV cache | `f32/f16/bf16/q8_0/q4_0/q4_1/iq4_nl/q5_0/q5_1` (needs FlashAttention) | **TurboQuant** `Turbo3_0`/`Turbo4_0`/`V` | `Q8_KV`, IQK/Trellis/MXFP4 reused in `-ctk`, `--k-cache-hadamard`, `--v-cache-hadamard` |
Two number spaces in ROCmFPX (authoritative `ggml/include/ggml.h`, sha
`c49ebdbd`):
- **`ggml_type` enum** (tensor storage): `Q4_0_ROCMFP4`=100, `Q4_0_ROCMFP4_FAST`=101,
`Q6_0_ROCMFPX`=102, `Q8_0_ROCMFPX`=103, `Q3_0_ROCMFPX`=104, `Turbo3_0`=105,
`Turbo4_0`=106, `Q2_0_ROCMFPX`=107, `Q4_0_ROCMI4`=108, count 109.
- **`ggml_ftype`** (GGUF model-file): ROCmFP4 family `100–106` (`STRIX`=105,
`STRIX_LEAN`=106), ROCmFPX family `110–113` (FP6=110, FP8=111, FP3=112,
FP2=113), `ROCmI4`=118.
## 2. Comparison table
| Runtime | Quant formats | Granularity | Custom ggml types | MoE | Attention accel | Hybrid GPU/CPU offload | Build backends |
|---------|---------------|-------------|-------------------|-----|-----------------|------------------------|----------------|
| Mainline | K/I/legacy/TQ, imatrix, row-RI 3-bit | grouped/per-block + double-quant | none | native `--cpu-moe` | FlashAttention (multi-backend) | `--n-gpu-layers`, partial offload | CPU/CUDA/Vulkan/Metal/SYCL/HIP-ROCm/CANN |
| charlie12345/ROCmFPX | ROCmFP2/3/4/6/8, ROCmI4, TurboQuant KV | block + E4M3 codebook | **yes (100–108)** | MTP draft head (bundled) | **MTEA** + TurboQuant FA (custom) | Strix-tuned MTP profile | HIP/ROCm + Vulkan (**CUDA off**), CPU ref |
| ik_llama.cpp | IQ* non-linear, improved K, IQK row-RI, Trellis, MXFP4, Q8_KV, Bitnet | grouped + non-linear + row-interleaved | IQK/Trellis/MXFP4/Bitnet tags | **fused MoE**, `-ooae`, `--fit`, `-ot`, GDN | **FlashMLA** (DeepSeek MLA) | **finest-grained** override-tensor / cpu-moe / fit | CPU (AVX-512/VNNI), CUDA (Turing+), Vulkan, ROCm(weak) |
## 3. Code-difference narrative
**What each fork ADDED that mainline lacks:**
- **ROCmFPX** — ~10 new `ggml_type` entries, the `block_rocmfp4` packed-nibble
format, `*_ROCMFPX`/`*_ROCMI4` ftypes, tensor-aware presets (`*_AGENT`,
`*_COHERENT`, `*_STRIX`), `block_rocmfp4` dequant + dispatch kernels,
TurboQuant KV types, MTEA attention kernel. Mainline does not know these
tensor types. It is a *fork* that adds a new backend: custom tensor
registration + HIP/ROCm + Vulkan kernels + CPU reference. **CUDA is disabled.**
- **ik_llama.cpp** — new code under `ggml/src/iqk/` (IQK GEMM + row-interleave),
CUDA IQK flash-attn kernels (`GGML_IQK_FA_ALL_QUANTS=ON`), fused MoE
(`fnn_up`+`fnn_gate`), fused delta-net (GDN) SSM ops, `flash_fwd_mla` for
DeepSeek MLA/DSA, and hybrid-offload controls (`-ot/--override-tensor`,
`--cpu-moe`, `-ooae/--offload-only-active-experts`, `-ser/--smart-expert-reduction`,
`--fit`, `-op/--offload-policy`, split-mode graph). Run-time quant repack
(`-rtr`). **Additive** — GGUF remains compatible.
**What mainline has that the forks lag on:**
- **ik_llama.cpp**: architecture baseline is **Aug-2024** — newest
architectures arrive late (PRs move fast, but the core is ~1.5y behind).
- **ROCmFPX**: **AMD-first, experimental**; narrower, must rebuild/re-quant per
fork line, CUDA ecosystem (the fleet's 3090) is unreachable.
**The one caveat that changes a build (ik_llama.cpp + `-rtr`):**
K-quants have **no CUDA row-interleaved implementation**. With `-rtr`, every
CPU-resident K-quant tensor is repacked to row-interleaved and then **run on CPU**
even where GPU would be faster → lower prompt-processing speed. For a MoE that
keeps experts on CPU (`--cpu-moe`), this is a real hit. Prefer IQK/R4/Trellis/
MXFP4 variants, or skip `-rtr` and place tensors with `--fit`/`-ot`.
## 4. Server-specific benefits
### Dankserver — NVIDIA RTX 3090 (24 GB), 62 GB RAM — 35B MoE (Ornith-1.5-35B-A3B CRACK)
- The winning lever for a **low-VRAM CUDA MoE** is **expert-aware offload + fused
MoE**, which is ik_llama.cpp's core strength:
- `-ooae/--offload-only-active-experts` — with ~8 active experts this cuts
RAM→VRAM traffic dramatically vs shipping all experts.
- `-fmoe/--fused-moe`, GDN fusion (the CRACK model is a hybrid GatedDeltaNet +
MoE), `--fit`, `-ot/--override-tensor`, `--cpu-moe`.
- **Quant pick for the 3090:** `Q4_K_M`/`Q5_K_M` on mainline, or an **IQK/R4 /
Trellis / MXFP4** variant on ik_llama.cpp that has a **CUDA** path. Avoid raw
K-quant + `-rtr`. Enable FlashAttention; `GGML_CUDA_FORCE_MMQ=1`;
`ngram-mod` drafts (MoEs want long drafts).
- ⚠️ **Expert-count discrepancy:** the CRACK GGUF README says **256 experts / 8
active**; the fleet brief cites ~36. Verify `n_expert` / `n_expert_used` from
the GGUF before finalizing the offload recipe — both point at expert-aware
offload being the right tool.
- **Mainline only?** Fine: `--cpu-moe` + `Q4_K_M` + FlashAttention + `ngram-mod`.
- **ROCmFPX?** No — ROCm-only formats, CUDA disabled on the builds.
### Dankclaw — AMD gfx1151 Strix Halo (64 GiB unified), ROCm 7.2.4 — Qwen3.8-27B ROCmFP4 STRIX MTP
- **ROCmFPX is the natural fit** and stays the choice: native IU4 (ROCmI4),
ROCmFP4 weight formats, MTP draft head, TurboQuant KV, 262K / 1 slot. The
production `build-promptforge` `llama-server` already runs there.
- **ik_llama.cpp on Strix?** ROCm is its **weakest** backend (gfx1151 not
validated, k-quant-no-hip-row-interleave), and we'd lose the bespoke MTP
draft-head speedup. Higher-risk change; only with a small soak-test first.
- **Mainline on Strix** works for correctness with standard quants but loses FPX,
IU4, TurboQuant, and MTEA.
## 6. KV cache quant table
Bit-density-ordered comparison of everything usable in the **KV cache** (`-ctk`
key / `-ctv` value, and draft `-ctkd`/`-ctvd`). Order is high → low bits-per-
value. Columns mark **origin** (`*` = mainline-provided) and whether the type is
**KV-native** (designed for the cache) or **weight-family reused for KV** (a
model-weight quant the runtime also accepts in `-ctk`/`-ctv`).
**How bpw is read here.** `bpw = ggml_type_size × 8 / QK`, computed from the
exact block structs in `ggml.h` / `ggml-common.h` (`QK_K = 256`, fp16 scale).
This is the true in-RAM cost per value, so it is what sets KV-cache size and
quantize/dequant throughput. A footnote marks the few types whose **nominal**
figure (older 1-byte-scale layout / data-bits-only) diverges from this exact
number; the table uses the exact number throughout.
### 6a. All KV-usable types, by bit density (interleaved)
| bpw | Type | Origin | KV-native | Notes |
|-----|------|--------|-----------|-------|
| 32.0 | f32 | mainline * | yes | full precision |
| 16.0 | f16 | mainline * | yes | default |
| 16.0 | bf16 | mainline * | yes | |
| 9.25 | q8_K | mainline * | weight | rare in KV; per-16-block sums; ik Q8_KV is the fast KV version |
| 8.13 | **q8_0** | mainline * | yes | |
| 8.06 | **q8_KV** | ik * | yes | the dedicated **fast KV cache** type (`-ctk q8_KV`); Q8_0-shaped, 1 shared scale |
| 6.63 | iq6_K | ik | weight→KV | IQK family |
| 6.56 | q6_K | mainline * | weight→KV | |
| 6.00 | **q5_1** | mainline * | yes | (nominal 5.06) |
| 5.50 | **q5_0** | mainline * | yes | (nominal 5.06) |
| 5.50 | q5_K | mainline * | weight→KV | |
| 5.50 | iq5_K | ik | weight→KV | IQK family |
| 4.50 | **q4_0** | mainline * | yes | (nominal 4.25) |
| 4.50 | **q4_1** | mainline * | yes | (nominal 4.25) |
| 4.50 | **iq4_nl** | mainline * | yes | (nominal 4.0) |
| 4.50 | iq4_K | ik | weight→KV | IQK family |
| 4.5 | **Turbo4_0** | ROCmFPX * | yes | TurboQuant; ~4.5 |
| 4.25 | iq4_ks | ik | weight→KV | IQK subsample |
| 4.25 | iq4_xs | mainline * | weight→KV | popular *weight* format, also usable in KV |
| 4.25 | mxfp4 | ik * | weight→KV | |
| 3.5 | **Turbo3_0** | ROCmFPX * | yes | TurboQuant; ~3.5 |
| 3.44 | iq3_K | ik | weight→KV | IQK family |
| 3.44 | iq3_s | mainline * | weight→KV | |
| 2.69 | iq2_kl | ik | weight→KV | IQK subsample |
| 2.38 | iq2_K | ik | weight→KV | IQK family |
| 2.19 | iq2_ks | ik | weight→KV | IQK subsample |
| 2.13 | iq2_kt | ik * | weight→KV | **Trellis** (lattice) |
| 1.75 | iq1_kt | ik * | weight→KV | **Trellis** (lattice) |
### 6b. Origin legend
- **Mainline KV-native** (`-ctk`/`-ctv` = exactly these 9, from `common/arg.cpp`
`kv_cache_types`): `f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1`. Marked
with `*` and `yes` in the KV-native column.
- **ROCmFPX KV-native**: `turbo3_0, turbo4_0` — TurboQuant's dedicated KV formats
(add a third `V` type for values only). Marked `*` here.
- **ik additions**: `q8_KV` (fast KV, dedicated), plus the weight-family quants
ik lets you reuse in `-ctk`/`-ctv` — the **IQK** family (`iq*_K`, plus the
`IKS`/`IKL` subsamples) and the **Trellis** lattice family (`iq*_KT`), and
`mxfp4`. ik's `-ctk` accepts a broader set than mainline's.
### 6c. Trellis, IQK, MXFP4 — where they sit
- **Trellis (`IQ*_KT`)** — ik's lattice-code quants, mirror the `IQ1..IQ4` bit
ladder: `iq1_kt` 1.75 → `iq2_kt` 2.13 → `iq3_kt` 3.13 → `iq4_kt` 4.00. Highest
quality per bit of the family; lowest-density entries in the whole table.
- **IQK (`IQ*_K`)** — ik's improved K-quants: `iq2_k` 2.38, `iq3_k` 3.44,
`iq4_k` 4.50, `iq5_k` 5.50, `iq6_k` 6.63, plus subsamples `iq2_kl` 2.69 /
`iq2_ks` 2.19 / `iq4_ks` 4.25. They sit alongside the mainline K-family
(iq2_k ≈ iq2_s, iq4_k ≈ q4_K) but with ik's row-interleave + fused GEMM.
- **MXFP4** (`mxfp4`, 4.25) — the mixed-precision FP4 cache quant; ik's entry.
### 6d. Hadamard is a transform, not a row
`--k-cache-hadamard` / `--v-cache-hadamard` (ik) is a **structured transform
applied on top of a base KV quant**, not its own bit-density row. It spreads
quantization error, improving accuracy for the underlying type; ik's guidance is
**best below Q6_0**. Row-interleaving (the `R4`/`R8` variants) is the same idea —
a packing transform on the K-family — and is likewise a modifier, not a quant.
### 6e. Server notes
- **Dankclaw (production, ROCmFP4 STRIX MTP):** KV stays on `turbo4_0` /
`turbo3_0` (TurboQuant) — the runtime's own formats. ik's IQK/Trellis path on
Strix is higher-risk (see §4).
- **Dankserver (3090 MoE):** `-ctk q8_KV` or `q4_K`/`q5_K` (mainline) with
FlashAttention + KV-quant; on ik, `iq4_k`/`iq4_kt`/`iq2_k` if a CUDA path is
confirmed for that type. Keep FlashAttention and the KV quant version in lockstep
— they are coupled (a quant change that drops FA off the path changes KV math).
## 5. Recommendations for our fleet
- **Dankclaw:** keep **charlie12345/ROCmFPX** (already production-tuned for
gfx1151). If you want more quant variety on Strix, explore the ROCmFP2 (`Q2_0_ROCMFPX`,
2.5 bpw, S40 codebook) preview — but do not port to ik_llama.cpp as primary.
- **Dankserver:** **try ik_llama.cpp** (CUDA-90 + AVX-512 build) for fused-MoE +
`offload-only-active-experts`, staying off raw K-quant `-rtr`. If that is too
much surface, stay on **mainline** with `--cpu-moe` + `Q4_K_M` + FlashAttention
+ `ngram-mod`. Neither box needs ROCmFP4.
- **Common:** pin a commit per fleet line; re-test after any quant/KV/context
change (FlashAttention + KV-quant are coupled).
## See Also
- [[Llama Cpp Runtimes]] — positioning + quantization overview
- [[dealign-ornith-crack]] — the CRACK MoE on Dankserver
- [[kingjones-strix-mtp]] — the ROCmFP4 model on Dankclaw
- [[Quantization by Hardware Architecture]] — quant vs hardware
## Citations
- Mainline quant + backends — https://github.com/ggml-org/llama.cpp ·
tools/quantize/README.md · docs/speculative.md
- ROCmFPX enum (ggml_type 100–108) — https://github.com/charlie12345/ROCmFPX/blob/main/ggml/include/ggml.h
- ik_llama.cpp quant/MoE/FlashMLA/backends — https://github.com/ikawrakow/ik_llama.cpp (README, docs/parameters.md, docs/build.md) · DeepWiki · discussions#562
- ik_llama.cpp `-rtr` K-quant trap & backend-bandwidth caveat — https://github.com/ikawrakow/ik_llama.cpp/blob/main/README.md
+141
View File
@@ -0,0 +1,141 @@
---
type: research
title: Llama.cpp Runtimes
description: Compared mainline llama.cpp, charlie12345/ROCmFPX, and ik_llama.cpp — positioning, quantization formats, code-level differences, and fit for Dankserver (RTX 3090) and Dankclaw (Strix Halo).
tags:
- llama.cpp
- quantization
- ROCmFPX
- ik_llama
- inference
- amd
- nvidia
timestamp: 2026-09-01
---
# Llama.cpp Runtimes
Comparison of three `llama.cpp`-derived inference runtimes used (or usable) on
the two homelab boxes: **mainline** `ggml-org/llama.cpp`,
**charlie12345/ROCmFPX** (what Dankclaw runs), and **ik_llama.cpp**
(ikawrakow). The goal is to know, for each server, which quant formats are on
the table and which runtime best fits the hardware.
Full code-level quantification is in [[Llama Cpp Code Diff]]. This page is the
positioning + quantization summary.
## Which runtime is which
| Runtime | Repo | What it is | Primary backends | For our boxes |
|---------|------|-----------|------------------|---------------|
| Mainline | [`ggml-org/llama.cpp`](https://github.com/ggml-org/llama.cpp) | Canonical, general-purpose, one codebase for every backend | CPU, CUDA, Vulkan, Metal, SYCL, HIP/ROCm, CANN, OpenVINO | Baseline both boxes; the portable default |
| charlie12345/ROCmFPX | [`charlie12345/ROCmFPX`](https://github.com/charlie12345/ROCmFPX) (+ reference fork [`rocmfp4-llama`](https://github.com/charlie12345/rocmfp4-llama), clean pkg [`rocmfp4`](https://github.com/charlie12345/rocmfp4)) | Experimental AMD-focused GGUF *weight* quant family layered on llama.cpp | HIP/ROCm + Vulkan (CUDA **disabled**), CPU reference | **Dankclaw** (native Strix Halo) |
| ik_llama.cpp | [`ikawrakow/ik_llama.cpp`](https://github.com/ikawrakow/ik_llama.cpp) | Fork focused on SOTA quants + CPU / hybrid GPU-CPU performance | CPU (AVX-512/VNNI), CUDA (Turing+), Vulkan, ROCm (weak) | **Dankserver** candidate (3090 + MoE) |
Key lineage notes:
- Mainline moved from `ggerganov/llama.cpp` to the `ggml-org` org. Rolling
commit hashes (the fleet pins `b10121`) plus newer `vX.Y.Z` tags. Pin a
commit for reproducibility.
- ROCmFPX is an *experimental feature family* on the canonical `main` branch;
three GitHub surfaces exist (reference fork, clean patch, pre-patched tree).
It is **AMD-only** — CUDA is compiled out.
- ik_llama.cpp's core is an **August-2024 llama.cpp snapshot** maintained with
a rolling set of PRs (MLA, fused MoE, row-interleaved quants, MTP, DFlash all
appeared here first, later partially merged upstream). The feature surface is
current; the architecture baseline is ~1.5 years behind mainline.
## Quantization summary
### Mainline (what the fleet already ships)
- **Base:** FP32 / FP16 / BF16.
- **K-quants** (modern default, two-level block + double-quant): `Q2_K` …
`Q6_K`, with `_S`/`_M`/`_L` precision mixes. `Q4_K_M` is the popular default;
`Q6_K` is "almost lossless."
- **i-quants** (importance-matrix, non-linear, best quality/size at low bits but
slower decode): `IQ4_NL`, `IQ4_XS`, `IQ3_*`, `IQ2_*`. Row-interleaved 3-bit
family (IQ3_XXS/S/XS) reduces the quality penalty of whole-row quant.
- **Legacy:** `Q*_0`/`Q*_1` (fast, simple), ternary `TQ1_0`.
- **KV cache quant:** `f32/f16/bf16/q8_0/q4_0/iq4_nl/q5_0/q5_1`; quantizing KV
requires FlashAttention (`-fa on`).
- Speculative decoding native (MTP, EAGLE3, DFlash, DSpark, `ngram-mod`).
### ROCmFPX (Dankclaw's AMD weight formats)
- Integer GGUF **weight** formats only this fork reads: **ROCmFP2/3/4/6/8** and
**ROCmI4 (W4A4 IU4 WMMA)**. These are custom `ggml` tensor types
(**`ggml_type` 100–108**) with matching GGUF **`ftype`** (ROCmFP4 family
`100–106`; ROCmFPX family `110–113`; `ROCmI4` = `118`). Stock llama.cpp,
Ollama, and LM Studio **cannot load them**.
- Flagship **ROCmFP4** (`block_rocmfp4`): 32 weights/block, two E4M3 scale
bytes, ~18 bytes/block, ~4.5 bpw, E2M1 nibbles + E4M3 codebook up to 10
units. Variants: `STRIX` / `STRIX_LEAN`, `FAST`, `COHERENT`, plus `*_AGENT`
/ `*_STRIX_MTP` draft-head presets.
- **TurboQuant KV:** `Turbo3_0` (3.5 bpw) / `Turbo4_0` (4.5 bpw) + `TurboQuant V`
(f16 K) on the production model.
- **ROCmI4 (W4A4):** the gfx1151 native IU4 4-bit WMMA path (`-DGGML_HIP_ROCMI4_W4A4=ON`);
lossy activation quant, exact int8 MMQ.
### ik_llama.cpp (SOTA quants + CPU/hybrid)
- **IQ\*** non-linear family; `IQ4_NL` gives lower perplexity than mainline
`Q4_K_M` at the same size. Improved **K-quants** and the signature
**row-interleaved IQK / R4 / R8** packing (activates AVX-512/VNNI/NEON
matmul paths that vanilla llama.cpp leaves cold).
- **Trellis** quants (`IQ*_KT`), **MXFP4**, **Q8_KV** fast KV type, first-class
**Bitnet**.
- ⚠️ **K-quant / `-rtr` trap (critical for the 3090):** K-quants have **no CUDA
row-interleaved path**; `-rtr` (run-time repack) forces CPU-resident K-quant
tensors to CPU matmul even when GPU would be faster. Prefer IQK/R4/Trellis/
MXFP4 variants, or skip `-rtr` and let `--fit`/`-ot` place experts.
## Fit for our servers
### Dankserver — NVIDIA RTX 3090 (24 GB VRAM), 62 GB RAM, CUDA
Serves **Ornith-1.5-35B-A3B CRACK**, a 35B **MoE** (~36 experts; the CRACK
GGUF README lists 256/8-active — verify `n_expert` from the GGUF).
- **Mainline** is the natural baseline: `Q4_K_M`/`Q5_K_M`, FlashAttention to
shrink KV, `GGML_CUDA_FORCE_MMQ=1` (default on RTX 30xx, lower VRAM),
`--cpu-moe` to spill experts to the 62 GB RAM, and `ngram-mod` drafts (MoEs
want long drafts).
- **ik_llama.cpp** is the more compelling *upgrade* here: fused MoE (`-fmoe`),
**offload-only-active-experts** (`-ooae`) which — with only ~8 active experts —
sharply cuts RAM→VRAM traffic, `--fit`, smart-expert-reduction (`-ser`), GDN
fusion for the SSM path, and MTP. Requires the CUDA-90 + AVX-512 build and care
with the `-rtr` K-quant trap.
- **ROCmFPX** does **not** help a CUDA box (ROCm-only formats, CUDA disabled).
The 3090's lever is CUDA quant + FastMTP, not ROCmFPX.
### Dankclaw — AMD gfx1151 Strix Halo (64 GiB unified VRAM), ROCm 7.2.4
Serves **kingjones Qwen3.8-27B ROCmFP4 STRIX MTP** (the production build).
- **ROCmFPX is the native fit**: IU4/MTP/FPX, HIP+Vulkan kernels, TurboQuant KV,
262K ctx / 1 slot. Measured 8K MTP ~30.3 tok/s (2.83× vs `Q4_K_M`).
- **ik_llama.cpp** — ROCm is its **weakest** backend (gfx1151 not validated) and
the k-quant-no-hip-row-interleave issue bites there too. Keep ROCmFPX.
- **Mainline** ROCm build works for correctness (standard quants) but loses FPX,
IU4, TurboQuant, MTEA, and agent presets.
## When to reach for which
- **Stay mainline** if you want one portable upstream for both boxes, the widest
GGUF ecosystem, and standard FlashAttention/MTP. Best fit for **Dankserver**
if you don't want the ik_llama.cpp build surface.
- **Use ik_llama.cpp** for Dankserver when the MoE offload and fused-MoE wins
justify an extra build (watch `-rtr` + K-quants).
- **Use ROCmFPX** for Dankclaw — it is purpose-built for gfx1151.
- **ROCmFPX** is for the AMD fleet; **CUDA/FastMTP** for the NVIDIA box. Do not
port ROCmFP4 to the 3090 via Vulkan as a primary path.
## See Also
- [[Llama Cpp Code Diff]] — the code-level quantization diff + comparison table
- [[dealign-ornith-crack]] — the CRACK model on Dankserver
- [[kingjones-strix-mtp]] — the ROCmFP4 model on Dankclaw
- [[Quantization by Hardware Architecture]] — which quant fits which GPU/CPU
- [[SGLang]] — the structured-generation serving engine (the other lane)
## Citations
- Mainline — https://github.com/ggml-org/llama.cpp
- charlie12345/ROCmFPX — https://github.com/charlie12345/ROCmFPX ·
rocmfp4-llama — https://github.com/charlie12345/rocmfp4-llama · rocmfp4 —
https://github.com/charlie12345/rocmfp4
- ik_llama.cpp — https://github.com/ikawrakow/ik_llama.cpp · docs ·
https://ikawrakow-ik_llama-cpp.mintlify.app/
- ROCmFPX quant/enum/serve detail —
https://github.com/charlie12345/ROCmFPX/blob/main/ggml/include/ggml.h
- SGLang — https://github.com/sgl-project/sglang · https://docs.sglang.io/
+154
View File
@@ -0,0 +1,154 @@
---
type: research
title: SGLang
description: What SGLang is, how it works (RadixAttention, paged KV, overlap scheduler, structured output), how it compares to llama.cpp and vLLM, and whether it fits Dankserver (RTX 3090) and Dankclaw (Strix Halo).
tags:
- sglang
- serving
- inference
- structured-output
- radix-attention
- llama.cpp
timestamp: 2026-09-01
---
# SGLang
SGLang (for **S**tructured **G**eneration **Lang**uage) is a high-throughput
serving engine for large (and vision-language) models, from the LMSYS / UC
Berkeley team, published at NeurIPS 2024. Today it is one of the two dominant
open-source serving engines, alongside vLLM. People talk about it because it
makes "call the model, branch, force the output into JSON" programs *fast*.
## What it is — in one paragraph
You serve an LLM by writing a small program: call the model, then based on its
output call it again, branch into parallel attempts, and force the answer into a
JSON schema. SGLang co-designs a Python-embedded language with a runtime that can
*see* the program's structure, then exploits it: it caches and reuses the model's
"working memory" (the KV cache) across requests that share a prompt prefix,
batches new requests into running generations without waiting for a batch to
drain, and forces tightly-structured output (JSON / regex / tool tags) by masking
illegal tokens one step at a time — or several at once. Reported payoff:
**up to 6.4× higher throughput** on structured / agentic / RAG workloads vs vLLM,
TGI, and similar.
## Core architecture
Two halves that can also run independently:
- **Frontend** — a Python DSL (`@sgl.function`, `gen`/`select`/`fork`/`join`/
`extend`) for multi-call LLM programs with branching, parallel sampling, tool
calls, and structured output.
- **Backend runtime** — a high-throughput server (`python -m sglang.launch_server`)
or an in-process `Engine`. OpenAI-compatible `/v1`, so RadixAttention and
structured decoding work with a vanilla OpenAI client.
Building blocks:
- **z-normalization** — pre-compile per-request sampling metadata into contiguous
tensors so the GPU sampler does one bulk copy per batch per step (kills the
CPU scheduler bottleneck).
- **PagedTensor** — the KV cache is paged (fixed-size pages) and laid out
non-contiguously in a global pool; mirrors vLLM's PagedAttention.
- **RadixAttention** — a radix tree of shared token prefixes; on a new request it
walks to the longest cached prefix, reuses those KV blocks, and only prefills
the divergent suffix. Payoff is compute + throughput for long shared prefixes
(RAG, agents), not raw memory.
- **Overlap scheduler** — runs the CPU prep for step t+1 *under* the GPU forward
of step t, so kernels launch back-to-back with no idle bubble (plus CUDA
graphs + torch.compile).
- **Structured / output-guided generation** — compiles the constraint (JSON
schema / regex / EBNF) into a finite-state machine over the vocabulary and masks
disallowed logits; **jump-forward** decoding emits forced token runs in one step.
Backends: XGrammar (default), Outlines.
## SGLang vs llama.cpp vs vLLM
| Dimension | SGLang | llama.cpp |
|-----------|--------|-----------|
| Runtime | Python + CUDA/C++ kernels | C/C++, no Python required |
| GPU requirement | GPU required, **no CPU path** | CPU-only / GPU-only / hybrid offload |
| Model format | HF safetensors; AWQ/GPTQ/FP8 on the fly; **GGUF via llama.cpp backend** | **GGUF only** (convert first) |
| KV reuse | RadixAttention (always-on radix tree) | basic prompt caching, no radix sharing |
| Structured output | first-class (XGrammar, jump-forward) | narrower |
| Concurrency | continuous batching + overlap scheduler | single-node, lower concurrency |
| Multi-GPU | native tensor parallelism | layer-split, more manual |
| Setup | `pip install sglang[all]`, venv, CUDA match | static binary / Docker, minutes |
| License | Apache 2.0 | MIT |
**Pick SGLang** when you need many concurrent users, structured / JSON / tool-call
output, or repeated long prefixes (RAG, agents) where RadixAttention pays off, and
you own a GPU + Python/CUDA dependency chain. **Pick llama.cpp** for the fastest
download-to-endpoint path, CPU/edge, GGUF libraries, and quantization breadth.
**Pick vLLM** for the broadest model-hardware coverage and mature multi-GPU.
The paper's headline 6.4× / 5× numbers are vs **vLLM v0.1** (no prefix caching);
the gap narrows vs modern vLLM — benchmark on your own traffic.
## Deployment model & gotchas
- **Pluggable backends:** default vLLM engine, SGLang's own `sglang` engine, or a
recent beta **`llama.cpp` backend** that serves GGUF and reuses `llama-server`.
- **VRAM headroom runs higher** than llama.cpp (pre-allocated KV pool + radix
tree). Size with `--mem-fraction-static`, `--context-length`,
`--chunked-prefill-size`. OOM → lower context / fraction or a smaller quant.
- **Model-format lock-in:** wants safetensors + AWQ/GPTQ/FP8 (or GGUF via the
llama.cpp backend). The current CPU-Q6_K_P and ROCmFP4-MTP models are **GGUF**.
- **AMD / ROCm: yes, officially.** Prebuilt `lmsysorg/sglang` ROCm Docker image or
`pip`. Quant on AMD: FP8 (Aiter/Triton), AWQ (Triton), MXFP4 (CDNA3/4),
`petit_nvfp4`. Marlin-based methods are **out** on AMD. Kernel work targets
**datacenter Instinct (CDNA2/3/4)** — consumer/client parts like **Strix Halo
(gfx1151)** are supported at the ROCm level but are not the kernel-tuning target.
- **Prefix diversity kills RadixAttention** (per-user/personalized prompts → tree
degenerates). Single-replica trees need prefix-aware routing across a fleet
(`sglang-router --policy cache_aware`). Fast-moving API — pin versions.
## Relevance to our servers
### Dankserver — NVIDIA RTX 3090 (24 GB) + 62 GB RAM — 35B MoE
A **genuine fit candidate (with a migration step)**. The 3090 is CUDA-native.
A 35B MoE (~3B active) is ideal for continuous batching + structured generation,
and 24 GB can hold a quantized 35B MoE (FP8/AWQ safetensors ~18–20 GB) with room
for KV. MoE expert parallelism is supported.
- **What changes:** the current model is a **CPU GGUF (Q6_K_P)**; SGLang wants
safetensors (FP8/AWQ) for CUDA, OR use SGLang's **`llama.cpp` backend** to serve
the existing GGUF under SGLang's frontend. Check for an AWQ/FP8 variant of the
HauhauCS checkpoint on Hugging Face.
- **Where it wins:** repeated structured/JSON/tool calls or a long shared system
prompt (RAG) → RadixAttention + jump-forward give big gains vs the current
sequential CPU pipeline. Simple low-concurrency chat → the operational weight
may not pay off.
### Dankclaw — AMD gfx1151 Strix Halo (64 GiB unified) — Qwen3.8-27B ROCmFP4 MTP
A **niche / experimental fit, not a drop-in replacement**. 64 GiB is generous and
SGLang-on-ROCm is supported (Docker/pip), but:
- Our model is a **custom ROCmFP4/MTP GGUF**. SGLang's primary path wants
safetensors + an AMD quant (FP8/AWQ); MXFP4 needs CDNA3/4 and Strix Halo (CDNA
3.5 *client*) is **not** an SGLang/AMD kernel target. Quant availability for this
exact MTP model is unverified, and we'd lose the bespoke MTP draft-head speedup.
- **Best-fit path:** SGLang's **`llama.cpp` backend** on top of our existing ROCm
llama.cpp / GGUF — gain the structured-output DSL, RadixAttention, and scheduler
without rebuilding the kernel stack. Or test the native `lmsysorg/sglang` ROCm
image as a proof-of-concept. Treat it as an enhancement/experiment, not a
production swap, until a quant for the MTP model exists and perf is measured on
gfx1151.
## Tradeoffs at a glance
- Higher baseline VRAM than llama.cpp (KV pool + radix tree).
- GPU/accelerator required — no pure-CPU path (llama.cpp is the only option of the
trio for a CPU-only box).
- Model-format lock-in (safetensors/AWQ/GPTQ/FP8, or GGUF via llama.cpp backend).
- Marlin-based quant out on AMD; Strix Halo is not an MXFP4 target.
- Prefix diversity can nullify RadixAttention; pin versions; watch cache-report.
## See Also
- [[Llama Cpp Runtimes]] / [[Llama Cpp Code Diff]] — the llama.cpp side of the trio
- [[dealign-ornith-crack]] — the CRACK MoE on Dankserver
- [[kingjones-strix-mtp]] — the ROCmFP4 MTP model on Dankclaw
- [[Quantization by Hardware Architecture]]
## Citations
- SGLang paper (arXiv:2312.07104) — https://arxiv.org/abs/2312.07104 · NeurIPS 2024
- Repo — https://github.com/sgl-project/sglang · docs — https://docs.sglang.io/
- AMD / ROCm — https://docs.sglang.io/docs/hardware-platforms/amd_gpu ·
Structured Outputs — https://docs.sglang.io/docs/advanced_features/structured_outputs
- lmsysorg/sglang ROCm images — https://hub.docker.com/r/lmsysorg/sglang
- SGLang vs llama.cpp — https://markaicode.com/vs/sglang-vs-llamacpp/
+24
View File
@@ -0,0 +1,24 @@
# Research
Subagent research briefs for the two hosts. Each page is a focused research
findings doc; the project root index ([docs/index.md](../index.md)) links back.
## LLM Inference
- [[Llama Cpp Runtimes]] — mainline vs charlie12345/ROCmFPX vs ik_llama.cpp: positioning, quantization, server fit
- [[Llama Cpp Code Diff]] — code-level quantization diff + comparison table + server recommendations
- [[SGLang]] — the structured-generation serving engine (llama.cpp/vLLM comparison, our- servers fit)
## Image / Media
- [[ComfyUI ROCm and Vulkan]] — live-verified GPU path for ComfyUI on Strix Halo
- [[Lemonade and sd.cpp]] — the ggml-family image-generation alternative
- [[Jellyfin]] — media server research brief
## Backups
- [[Backblaze Integration]] — off-site backup plan (future)
## Legacy
- [[Service Research Brief]] — consolidated subagent research behind these pages
+13 -4
View File
@@ -27,9 +27,13 @@ covers both workloads).
- [[Network Architecture]] — Caddy reverse proxy, hostname routing, Gluetun VPN path
- [[Storage Policy]] — bind mounts, data locations, ownership
- [[Deployment Flow]] — git-based deploy, target map, health checks
- [[Deployment Flow]] — service-scoped git deploy, target map, and health checks
- [[Testing Policy]] — the test seams and how to run them
## Dankserver Storage
- [[Entropy Volume]] — command-line unlock and mount helper
## Dankserver Services
- [[RomM]] — retro game manager
@@ -84,6 +88,9 @@ covers both workloads).
- [[Service Research Brief]] — consolidated subagent research behind these pages
- [[ComfyUI ROCm and Vulkan]] — live-verified GPU path for ComfyUI on Strix Halo
- [[Lemonade and sd.cpp]] — the ggml-family image-generation alternative
- [[Llama Cpp Runtimes]] — mainline vs charlie12345/ROCmFPX vs ik_llama.cpp: quantization + server fit
- [[Llama Cpp Code Diff]] — code-level quantization diff of the three llama.cpp runtimes
- [[SGLang]] — the structured-generation serving engine (llama.cpp/vLLM comparison + our fit)
- [[Backblaze Integration]] — off-site backup plan (future)
- [[Jellyfin]] — media server research brief
- [[ARR-Stack]] — automated download stack research brief
@@ -97,9 +104,9 @@ The LLM inference fleet on the two hosts. Full index:
- [[deployment]] — unified deploy and update process for both servers
- [[frank-redo-2026-08-17]] — the 2026-08-17 topology change
- [[eemin-rocmi4-mtp]] — the caf-eemin Qwen3.8-27B ROCmI4 embedded-MTP model on Dankclaw (frank-default-large, active)
- [[kingjones-strix-mtp]] — the RETIRED kingjones Qwen3.8-27B ROCmFP4 STRIX MTP model on Dankclaw (superseded 2026-08-25)
- [[kingjones-q8agent-rocmfpx]] — the Q8_0-AGENT runbook for frank-default-large (active): serving, download, 262144 context, reasoning + preserve_thinking, 114 GiB TTM/GTT
- [[kingjones-strix-mtp]] — the active kingjones Qwen3.8-27B ROCmFP4 STRIX MTP model on Dankclaw (restored 2026-09-01)
- [[kingjones-q8agent-rocmfpx]] — the retired Q8_0-AGENT investigation and `set_rows` failure
- [[eemin-rocmi4-mtp]] — the former caf-eemin Qwen3.8-27B ROCmI4 embedded-MTP model on Dankclaw
- [[ciru-activefpx-promptforge]] — the RETIRED v3 CIRU PromptForge runtime on Dankclaw (superseded 2026-08-24)
- [[kingjones-strix-mtp]] — the kingjones Qwen3.8-27B ROCmFP4 STRIX MTP model on Dankclaw (active)
- [[dealign-ornith-crack]] — the dealign Ornith-1.5-35B-A3B CRACK model on Dankserver (active)
@@ -112,6 +119,7 @@ The LLM inference fleet on the two hosts. Full index:
### Benchmarks and Sizing
- [[frank-default-large-benchmark]] — backend/spec benchmark behind the old head
- [[ubatch-benchmark-large]] — active-model llama-bench ubatch sweep plan and results
- [[frank-default-small-benchmark]] — the small-model benchmark suite
- [[Running Qwen3.6 35B A3B FP8 on Dankserver]] — Qwen3.6-35B-A3B hardware notes
- [[Running Qwen3.6 27B FP8 on Dankclaw]] — Qwen3.6-27B sizing on the iGPU
@@ -154,3 +162,4 @@ The LLM inference fleet on the two hosts. Full index:
- [AGENTS.md](../AGENTS.md) — repository rules
- [docs/LLM/index.md](LLM/index.md) — the LLM fleet knowledge base
- [dankware Configuration Knowledgebase](~/.pi/agent/docs/index.md) — the "root" level system knowlegebase
-77
View File
@@ -1,77 +0,0 @@
#!/usr/bin/env bash
# Bench the Kairic Edge "fast greedy (default)" profile.
# Usage: ./bench-fast-greedy.sh
#
# Config: reasoning off, argmax fastpath=1, deterministic sampler.
# Sends a coding prompt, captures full stream, computes decode tokens and time.
set -euo pipefail
HOST="127.0.0.1"
PORT=7770
ALIAS="frank-default-large"
# A coding prompt representative of the card's 164-task suite.
PROMPT='### Instruction
Write a Python class that implements a priority queue using a binary heap. Include methods for enqueue, dequeue, peek, and is_empty. Add type hints, docstrings, and a usage example. The heap should support arbitrary comparable elements.
### Response'
DECODE_TOKENS=0
START_SEC=0
END_SEC=0
echo "=== Fast-greedy benchmark (FASTGREEDY=1) ==="
echo "Model: $ALIAS on $HOST:$PORT"
echo "Prompt tokens: $(echo "$PROMPT" | wc -c) chars"
echo ""
# Use /v1/completions with streaming, capture tokens+time from usage.
RESPONSE=$(curl -sf http://$HOST:$PORT/v1/completions \
-H 'Content-Type: application/json' \
-d "{
\"model\": \"$ALIAS\",
\"prompt\": $(python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" <<< "$PROMPT"),
\"max_tokens\": 512,
\"temperature\": 0,
\"top_p\": 1.0,
\"top_k\": 0,
\"min_p\": 0.0,
\"n\": 1,
\"stream\": true
}" 2>/dev/null)
# Extract final usage from the last JSON line (finish_reason=stop or length).
# Also count output text tokens.
LAST_LINE=$(echo "$RESPONSE" | grep 'finish_reason' | tail -1)
# Parse timing from the last chunk (has full usage)
if echo "$LAST_LINE" | grep -q 'timings'; then
COMP_TOKENS=$(echo "$LAST_LINE" | python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
print(data.get('usage', {}).get('completion_tokens', 0))
print(data.get('timings', {}).get('predicted_per_second', 0))
print(data.get('timings', {}).get('prompt_per_second', 0))
" 2>/dev/null)
echo "$COMP_TOKENS" | {
read CT
read TPS
read PPS
echo "Completion tokens: $CT"
echo "Predicted (decode) tokens/sec: $TPS"
echo "Prompt tokens/sec: $PPS"
}
else
echo "WARNING: No timing data found in response"
echo "$LAST_LINE" | head -5
fi
# Count the actual text output
OUTPUT_TEXT=$(echo "$RESPONSE" | grep '"text"' | head -1 | sed 's/.*"text":"//;s/","index.*//')
OUTPUT_TOKENS=$(echo -n "$OUTPUT_TEXT" | wc -w)
echo "Output text (rough token count): ~$OUTPUT_TOKENS words"
echo ""
# Check journal for draft acceptance and final timing
echo "=== Draft acceptance & final timing ==="
sleep 2
ssh -p 2222 dank@dankclaw "journalctl -u frank-default-large --since '1 minute ago' --no-pager 2>/dev/null | grep -E 'draft acceptance|print_timing.*n_decoded|statistics.*draft-mtp' | tail -4" 2>/dev/null || echo "(could not read journal)"
-153
View File
@@ -1,153 +0,0 @@
#!/usr/bin/env bash
# bench-kairic-ab.sh — A/B perf isolation for the Kairic Edge deployment on
# Dankclaw. Run this ON DANKCLAW (paths are absolute target paths).
#
# Configs:
# runner pinned runner profile (b2048/ub512, reasoning off, target
# greedy argmax fast path ON)
# nommap runner + --no-mmap
# nofast runner + fast path OFF
# prod production profile (b4096/ub4096, --no-mmap, reasoning on/deepseek,
# fast path OFF, preserve_thinking)
# prod2048 prod with the pinned runner's batch sizes (b2048/ub512)
# prodnommap (obsolete: identical to prod since ubatch 4096 + --no-mmap
# moved into the production profile)
#
# Usage: ./scripts/bench-kairic-ab.sh <cfg> [ctx] [prompt_chars]
# Example: ./scripts/bench-kairic-ab.sh prod 16384 53000
#
# The server runs on scratch port 7893. STOP frank-default-large before
# benchmarking (see AGENTS.md). The server is killed on exit.
set -uo pipefail
CFG="${1:?runner|nofast|prod}"
CTX="${2:-32768}"
PCHARS="${3:-11000}"
PORT=7893
BIN=/home/dank/ciru-qwen38/ROCmFPX-kairic/build-kairic/bin/llama-server
MDIR=/home/dank/models/Kairic-Qwen3.8-27B-IU4-Edge
SDK=/home/dank/ling3-ciru/Ling-3.0-Flash-CIRU-int4-Strix-native/.venv/lib/python3.12/site-packages
LOG=/tmp/kairic-ab-$CFG.log
: > "$LOG"
export ROCM_PATH="$SDK/_rocm_sdk_devel"
export LD_LIBRARY_PATH="$(dirname "$BIN"):$SDK/_rocm_sdk_libraries/lib:$SDK/_rocm_sdk_core/lib:$SDK/_rocm_sdk_devel/lib"
export HIP_VISIBLE_DEVICES=0
export HSA_OVERRIDE_GFX_VERSION=11.5.1
export GGML_CUDA_GRAPH_OPT=0
export LLAMA_MTP_CPU_ARGMAX_FASTPATH=1
export PROMPTFORGE_TARGET_ONLY=0
export PROMPTFORGE_MODE=iu4_ffn
export PROMPTFORGE_IU4_SIDECAR="$MDIR/Qwen3.8-27B-Kairic-IU4-FFN.pfs"
export PROMPTFORGE_SIDECAR="$MDIR/Qwen3.8-27B-Kairic-IU4-FFN.pfs"
export PROMPTFORGE_IU4_HADAMARD=1
export PROMPTFORGE_IU4_SEGMENTED=0
export PROMPTFORGE_ENABLE_FFN_KEEPERS=0
export PROMPTFORGE_FFN_KEEPERS=late6
export PROMPTFORGE_ENABLE_GDN=1
export PROMPTFORGE_ENABLE_GDN_W8=0
export PROMPTFORGE_GDN_SIDECAR_OVERRIDE="$MDIR/Qwen3.8-27B-Kairic-IU4-GDN.pfs"
export PROMPTFORGE_GDN_SIDECAR="$MDIR/Qwen3.8-27B-Kairic-IU4-GDN.pfs"
export PROMPTFORGE_GDN_IU4_HADAMARD=1
export PROMPTFORGE_ENABLE_GDN_KEEPERS=0
export PROMPTFORGE_ENABLE_GDN_OUTPUT=1
export PROMPTFORGE_GDN_OUTPUT_SIDECAR="$MDIR/Qwen3.8-27B-Kairic-IU4-GDN-Output.pfs"
export PROMPTFORGE_GDN_OUTPUT_KEEPERS=v3_lateq6
export PROMPTFORGE_ENABLE_SMALLM_IU4=1
export PROMPTFORGE_ENABLE_SMALLM_GDN_IU4=1
export PROMPTFORGE_ENABLE_SMALLM_GDN_OUTPUT_IU4=0
export PROMPTFORGE_ENABLE_IU4_DECODE_M2_M5=0
export PROMPTFORGE_OUTPUT_K8_STRICT_GREEDY=0
MMAP=""
case "$CFG" in
runner) export LLAMA_TARGET_GREEDY_ARGMAX_FASTPATH=1; B=2048; UB=512; REA=off; RFMT=none; EXTRA="" ;;
nommap) export LLAMA_TARGET_GREEDY_ARGMAX_FASTPATH=1; B=2048; UB=512; REA=off; RFMT=none; EXTRA=""; MMAP="--no-mmap" ;;
nofast) export LLAMA_TARGET_GREEDY_ARGMAX_FASTPATH=0; B=2048; UB=512; REA=off; RFMT=none; EXTRA="" ;;
prod) export LLAMA_TARGET_GREEDY_ARGMAX_FASTPATH=0; B=4096; UB=4096; REA=on; RFMT=deepseek; EXTRA="--chat-template-kwargs {\"preserve_thinking\":\"true\"}"; MMAP="--no-mmap" ;;
prod2048) export LLAMA_TARGET_GREEDY_ARGMAX_FASTPATH=0; B=2048; UB=512; REA=on; RFMT=deepseek; EXTRA="--chat-template-kwargs {\"preserve_thinking\":\"true\"}"; MMAP="--no-mmap" ;;
prodnommap) export LLAMA_TARGET_GREEDY_ARGMAX_FASTPATH=0; B=4096; UB=2048; REA=on; RFMT=deepseek; EXTRA="--chat-template-kwargs {\"preserve_thinking\":\"true\"}"; MMAP="--no-mmap" ;;
esac
"$BIN" -m "$MDIR/Qwen3.8-27B-IU4-Kairic-Edge.gguf" \
--alias bench --host 127.0.0.1 --port "$PORT" --jinja \
-dev ROCm0 -ngl 999 -c "$CTX" -b "$B" -ub "$UB" -fa on -ctk f16 -ctv f16 \
-t 16 -tb 32 -np 1 -ctxcp 32 --cache-ram 8192 --cache-prompt --cache-idle-slots $MMAP \
--metrics --kairic-edge \
--spec-type draft-mtp --spec-draft-device ROCm0 --spec-draft-ngl 999 \
--spec-draft-type-k f16 --spec-draft-type-v f16 \
--spec-draft-n-max 4 --spec-draft-n-min 0 --spec-draft-p-min 0.0 --spec-draft-p-split 0.10 \
--spec-draft-backend-sampling \
--temp 0 --top-p 1.0 --top-k 0 --min-p 0.0 \
--reasoning "$REA" --reasoning-format "$RFMT" --reasoning-budget -1 \
$EXTRA >> "$LOG" 2>&1 &
SPID=$!
trap 'kill $SPID 2>/dev/null; wait $SPID 2>/dev/null' EXIT
for i in $(seq 1 300); do
kill -0 $SPID 2>/dev/null || { echo "SERVER DIED"; tail -20 "$LOG"; exit 2; }
curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1 && break
sleep 2
done
echo "=== CFG=$CFG ready ==="
python3 - "$PORT" "$CFG" "$PCHARS" <<'PY'
import sys, json, time, urllib.request
port, cfg, pchars = sys.argv[1], sys.argv[2], int(sys.argv[3])
VAR = [
"narrative nonfiction blends observation with reflection, ",
"and a careful writer lets each clause earn its place ",
"in the sentence's rising line, so meaning accumulates slowly. ",
"The reader is not hurried; the reader is led. Loose and periodic, ",
"the style turns on concrete nouns and verbs that carry weight. ",
"A paragraph is not a list but a single turn of thought that ",
"opens, develops, and lands, and clarity is a courtesy owed ",
"to the willing reader who gives the writer their attention. ",
]
parts = []
t = 0
while len("".join(parts)) < pchars:
for k in range(3):
parts.append(VAR[(t + k) % len(VAR)])
parts.append(f" Note {t}. ")
t += 1
longtext = "".join(parts)
prompt = longtext + "\n\nNow write a long, detailed continuation of this essay (at least five paragraphs) about the philosophy of language.\n"
body = {
"model": "bench",
"prompt": prompt,
"max_tokens": 512,
"temperature": 0,
"top_p": 1.0,
"top_k": 0,
"min_p": 0.0,
"stream": False,
"cache_prompt": True,
}
# ignore_eos is rejected by the exact-Q8 greedy argmax fast path (runner cfg).
if cfg not in ("runner",):
body["ignore_eos"] = True
def run(tag):
req = urllib.request.Request(f"http://127.0.0.1:{port}/v1/completions",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=3600) as resp:
out = json.load(resp)
except Exception as e:
body_txt = getattr(e, "read", lambda: b"")()
print(f"[{tag}] REQUEST FAILED: {e}")
print(f"[{tag}] body: {body_txt.decode('utf-8', 'ignore')[:500]}")
raise SystemExit(3)
wall = time.time() - t0
u = out.get("usage", {})
tm = out.get("timings", {})
pt = u.get("prompt_tokens", 0); ct = u.get("completion_tokens", 0)
pps = tm.get("prompt_per_second", 0); tps = tm.get("predicted_per_second", 0)
print(f"[{tag}] cfg={cfg} prompt={pt} completion={ct} prefill={pps:.1f} t/s decode={tps:.2f} t/s wall={wall:.1f}s")
run("cold")
run("warm")
PY
grep -E "print_timing|statistics" "$LOG" | tail -6
exit 0
-191
View File
@@ -1,191 +0,0 @@
#!/usr/bin/env bash
# bench-spec-stack.sh — compare MTP-only vs MTP+ngram-mod spec on the v3
# CIRU prompt-forge stack (Dankclaw, gfx1151), at 8k / 16k / 64k context.
#
# Reads authoritative prefill/generation tok/s from llama-server's own
# `print_timing` lines (prompt eval time / eval time). Runs one server
# instance per (spec, ctx) on a scratch port.
#
# Usage:
# ./scripts/bench-spec-stack.sh <draft-mtp|draft-mtp,ngram-mod> <ctx> [out_tokens]
# Example:
# ./scripts/bench-spec-stack.sh draft-mtp,ngram-mod 16384 256
set -euo pipefail
SPEC="${1:?usage: bench-spec-stack.sh <spec> <ctx> [out_tokens]}"
CTX="${2:?usage: bench-spec-stack.sh <spec> <ctx> [out_tokens]}"
OUT_TOKENS="${3:-256}"
ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-default-large.env
source "$ENV_FILE"
# This harness benchmarks the retired v3 stack. Force the v3 values so the
# harness works regardless of the live service env (now Kairic).
MODEL_DIR=/home/dank/models/CIRU-Qwen3.8-27B-ActiveFPX-v3
MODEL_FILE=Qwen3.8-27B-CIRU-ActiveFPX-PromptForge-v3-Q8-FFNEQ-LateQ6.gguf
FFN_PFS=Qwen3.8-27B-CIRU-ActiveFPX-PromptForge-FFN.pfs
GDN_PFS=Qwen3.8-27B-CIRU-ActiveFPX-PromptForge-GDN.pfs
OUTPUT_K8_PFS=Qwen3.8-27B-CIRU-ActiveFPX-PromptForge-Output-K8.pfs
MMPROJ_FILE=Qwen3.8-27B-mmproj-BF16.mmproj
# Derive the PromptForge sidecar paths from the env base values.
export PROMPTFORGE_SIDECAR="$MODEL_DIR/$FFN_PFS"
export PROMPTFORGE_GDN_SIDECAR="$MODEL_DIR/$GDN_PFS"
export PROMPTFORGE_MTP_OUTPUT_K8_PROXY="$MODEL_DIR/$OUTPUT_K8_PFS"
MODEL="$MODEL_DIR/$MODEL_FILE"
LLAMA_BIN=/home/dank/ciru-qwen38/ROCmFPX/build-promptforge/bin/llama-server
PORT=7893
PARALLEL=1
BATCH=4096
UBATCH=4096
LOG="/tmp/specstack-$(echo "$SPEC" | tr ',' '-')-ctx$CTX.log"
: > "$LOG"
for f in "$MODEL" "$PROMPTFORGE_SIDECAR" "$PROMPTFORGE_GDN_SIDECAR" "$PROMPTFORGE_MTP_OUTPUT_K8_PROXY" "$MODEL_DIR/$MMPROJ_FILE"; do
[[ -f "$f" ]] || { echo "missing: $f" >&2; exit 1; }
done
[[ -x "$LLAMA_BIN" ]] || { echo "missing: $LLAMA_BIN" >&2; exit 1; }
export PROMPTFORGE_MODE="m2048_fused_tail1476"
export GGML_CUDA_GRAPH_OPT=0
export PROMPTFORGE_ENABLE_SMALLM_W8=1
export PROMPTFORGE_MTP_OUTPUT_K8=1
export PROMPTFORGE_MTP_OUTPUT_K8_VALIDATE=0
export PROMPTFORGE_MTP_OUTPUT_K8_PROXY
unset LLAMA_MTP_CPU_ARGMAX_FASTPATH LLAMA_MTP_BACKEND_GREEDY || true
unset PROMPTFORGE_IU4_SIDECAR PROMPTFORGE_GDN_IU4_SIDECAR || true
unset PROMPTFORGE_ENABLE_NGRAM_MOD PROMPTFORGE_ENABLE_NGRAM_M65_IU4 || true
export ROCM_PATH="$ROCM_DEVEL"
export LD_LIBRARY_PATH="${ROCM_LIB}/lib:${ROCM_CORE}/lib:${ROCM_DEVEL}/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
echo "=== spec-stack bench: spec=$SPEC ctx=$CTX model=$MODEL_FILE ==="
echo "=== env: OUTPUT_K8=$PROMPTFORGE_MTP_OUTPUT_K8 smallm=$PROMPTFORGE_ENABLE_SMALLM_W8 ==="
# ngram-mod tuning matches the retired fallback line: n_min 48 / n_max 64 / n_match 24
"$LLAMA_BIN" \
--model "$MODEL" \
--mmproj "$MODEL_DIR/$MMPROJ_FILE" \
--alias bench \
--host 127.0.0.1 \
--port "$PORT" \
-dev ROCm0 \
-ngl 999 \
-c "$CTX" \
-b "$BATCH" \
-ub "$UBATCH" \
-fa on \
-ctk "$CACHE_TYPE_K" \
-ctv "$CACHE_TYPE_V" \
-np "$PARALLEL" \
-ctxcp "$CTX_CHECKPOINTS" \
--cache-ram "$CACHE_RAM" \
--cache-prompt \
--no-mmap \
--spec-type "$SPEC" \
--spec-draft-device ROCm0 \
--spec-draft-ngl 999 \
--spec-draft-type-k f16 \
--spec-draft-type-v f16 \
--spec-draft-n-max 4 \
--spec-draft-n-min 0 \
--spec-draft-p-min 0.0 \
--spec-draft-p-split 0.10 \
--spec-draft-backend-sampling \
--spec-ngram-mod-n-min 48 \
--spec-ngram-mod-n-max 64 \
--spec-ngram-mod-n-match 24 \
--temp 0.7 \
--top-p 0.95 \
-lv 4 \
>> "$LOG" 2>&1 &
SERVER_PID=$!
trap 'kill $SERVER_PID 2>/dev/null; wait $SERVER_PID 2>/dev/null' EXIT
echo "server pid $SERVER_PID on 127.0.0.1:$PORT"
echo -n "waiting for server"
ready=0
crashed=0
for i in $(seq 1 900); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
crashed=1
break
fi
if curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
echo " [ready]"
ready=1
break
fi
[ $((i % 10)) = 0 ] && echo -n .
sleep 2
done
if [ "$crashed" = "1" ]; then
echo " BACKEND_CRASHED (server died before ready)"
tail -30 "$LOG"
exit 2
fi
[ "$ready" = "1" ] || { echo " FAIL (timeout)"; tail -25 "$LOG"; exit 1; }
# Print the implementations that initialized (feasibility evidence)
grep -E "adding speculative implementation|ngram_mod|draft-mtp" "$LOG" | head -6
python3 - "$SPEC" "$CTX" "$PORT" "$OUT_TOKENS" "$LOG" <<'PY'
import sys, time, json, urllib.request
spec, ctx, port, out_tokens, log = sys.argv[1], int(sys.argv[2]), sys.argv[3], int(sys.argv[4]), sys.argv[5]
# Flowing prose (varied) at ~75% of ctx so MTP acceptance is realistic.
VAR = (
"narrative nonfiction blends observation with reflection, ",
"and a careful writer lets each clause earn its place ",
"in the sentence's rising line, so meaning accumulates slowly. ",
"The reader is not hurried; the reader is led. Loose and periodic, ",
"the style turns on concrete nouns and verbs that carry weight, ",
"and it resists the abstract noun that hides a vague claim. ",
"A paragraph is not a list. It is a single turn of thought that ",
"opens, develops, and lands. This is the craft of prose. Choose, ",
"arrange, and above all omit. Omission is the discipline that ",
"keeps the whole clear, and clarity is the courtesy we owe the ",
"willing reader."
)
n = len(VAR)
parts = []
t = 0
while t * 76 < (ctx * 3) // 4:
for k in range(5):
parts.append(VAR[(t + k) % n])
parts.append(f" This is cued by note {t}. ")
t += 1
prompt = "".join(parts)
prompt += ("\n\nNow, in a detailed and flowing paragraph, continue this essay about "
"the philosophy of language and its influence on modern computation. ")
body = json.dumps({
"prompt": prompt,
"max_tokens": out_tokens,
"temperature": 0.0,
"stream": False,
}).encode()
req = urllib.request.Request(f"http://127.0.0.1:{port}/v1/completions",
data=body, headers={"Content-Type": "application/json"})
t0 = time.time()
with urllib.request.urlopen(req, timeout=7200) as resp:
out = json.load(resp)
dt = time.time() - t0
pt = out.get("usage", {}).get("prompt_tokens", 0)
ct = out.get("usage", {}).get("completion_tokens", 0)
print(f"request done: prompt_tokens={pt} completion_tokens={ct} wall={dt:.1f}s", flush=True)
lines = open(log, encoding="utf-8", errors="ignore").read().splitlines()
prompt_evals = [l for l in lines if "prompt eval time" in l]
evals = [l for l in lines if "eval time" in l and "prompt eval" not in l]
def grab(lines, label):
if lines:
print(label + ": " + lines[-1].strip())
print(f"== RESULT spec={spec} ctx={ctx} ==")
grab(prompt_evals, "PREFILL")
grab(evals, "GENERATION")
print(f"prompt_tokens={pt} completion_tokens={ct} wall={dt:.1f}s")
PY
-218
View File
@@ -1,218 +0,0 @@
#!/usr/bin/env bash
# bench-ubatch-rocmi4.sh — sweep ubatch size at 8k context on the
# frank-default-large ROCmI4 embedded-MTP stack (dankclaw, gfx1151).
#
# Boots one llama-server instance per ubatch on a scratch port with a fixed
# -b and -c 8192, runs a standardized ~75%-of-ctx prose workload, and reads
# authoritative prefill/generation tok/s from the server's own print_timing
# lines. Steps from UBATCH_MIN to UBATCH_MAX inclusive by UBATCH_STEP.
#
# Usage:
# ./scripts/bench-ubatch-rocmi4.sh
# UBATCH_MIN=512 UBATCH_MAX=4096 UBATCH_STEP=512 CTX=8192 RUNS=3 \
# ./scripts/bench-ubatch-rocmi4.sh
#
# Benchmarking policy: stop the production service first so the GPU is not
# starved. With STOPSVC=1 the script restarts frank-default-large on exit.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_LARGE_ENV_FILE:-$SCRIPT_DIR/frank-default-large.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
# --- sweep parameters -------------------------------------------------------
: "${UBATCH_MIN:=512}"
: "${UBATCH_MAX:=4096}"
: "${UBATCH_STEP:=512}"
: "${CTX:=8192}" # 8k contexts
: "${BENCH_BATCH:=$CTX}" # fixed batch = ctx, so the full 8k prompt fits
: "${OUT_TOKENS:=256}"
: "${RUNS:=3}" # decode runs per ubatch
: "${PORT_BASE:=7894}" # scratch ports 7894..
: "${STOPSVC:=0}" # stop/restart the live unit for a clean GPU
# --- resolved paths (fall back to serve-script defaults) --------------------
: "${LLAMA_BIN:=/home/dank/rocmi4-qwen38/ROCmFPX/build-rocmi4-w4a4/bin/llama-server}"
: "${MODEL_DIR:=/home/dank/models/eemin-Qwen3.8-27B-ROCMI4-MTP}"
: "${MODEL_FILE:=Qwen3.8-27B-Q4_0_ROCMI4.gguf}"
: "${MMPROJ_FILE:=mmproj-BF16.gguf}"
: "${CHAT_TEMPLATE_FILE:=$SCRIPT_DIR/chat-template.jinja}"
: "${ALIAS:=frank-default-large}"
: "${HSA_OVERRIDE_GFX_VERSION:=11.5.1}"
: "${GGML_HIP_ENABLE_UNIFIED_MEMORY:=1}"
: "${MTP_N_MAX:=16}"
: "${MTP_P_MIN:=0.60}"
MODEL="$MODEL_DIR/$MODEL_FILE"
MMPROJ="$MODEL_DIR/$MMPROJ_FILE"
for f in "$MODEL" "$CHAT_TEMPLATE_FILE"; do
if [[ ! -f "$f" ]]; then
echo "missing required file: $f" >&2
exit 1
fi
done
if [[ ! -x "$LLAMA_BIN" ]]; then
echo "missing llama-server binary: $LLAMA_BIN" >&2
exit 1
fi
MMPROJ_ARGS=()
if [[ -f "$MMPROJ" ]]; then
MMPROJ_ARGS=(--mmproj "$MMPROJ")
fi
STopped=0
cleanup() {
if [ "$STopped" = 1 ]; then
echo "=== restarting frank-default-large ==="
sudo systemctl restart frank-default-large || true
STopped=0
fi
}
trap cleanup EXIT
stop_live_service() {
if [ "$STOPSVC" = 1 ]; then
echo "=== stopping frank-default-large for a clean GPU ==="
sudo systemctl stop frank-default-large || true
STopped=1
fi
}
# wait_health <pid> <port> — poll /health until ready or the server dies.
wait_health() {
local pid="$1" port="$2" i ready=0 crashed=0
for i in $(seq 1 900); do
if ! kill -0 "$pid" 2>/dev/null; then crashed=1; break; fi
if curl -sf "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then
ready=1; break
fi
[ $((i % 10)) = 0 ] && echo -n .
sleep 2
done
[ "$crashed" = 1 ] && { echo " BACKEND_CRASHED (before ready)"; return 2; }
[ "$ready" = 1 ] || { echo " FAIL (timeout)"; return 1; }
echo " [ready]"
return 0
}
# run_one <ubatch> <port> <out_tokens> <runs> — boot one server instance, run
# the workload, parse print_timing, print a RESULT line.
run_one() {
local ubatch="$1" port="$2" out_tokens="$3" runs="$4"
local log="/tmp/ubatch-${ubatch}-p${port}.log"
: > "$log"
echo "=== ubatch=${ubatch} ctx=${CTX} port=${port} runs=${runs} ==="
"$LLAMA_BIN" \
--model "$MODEL" \
"${MMPROJ_ARGS[@]}" \
--chat-template-file "$CHAT_TEMPLATE_FILE" \
--chat-template-kwargs '{"enable_thinking":true,"preserve_thinking":true}' \
--alias bench-ubatch \
--host 127.0.0.1 --port "$port" \
-dev ROCm0 -ngl 999 -np 1 \
-t 16 -tb 32 \
-c "$CTX" -b "$BENCH_BATCH" -ub "$ubatch" \
-fa on -ctk f16 -ctv f16 -ctxcp 16 \
--cache-ram 8192 --cache-prompt --metrics --no-mmap \
--spec-type draft-mtp --spec-mtp-strict-qwen \
--spec-draft-device ROCm0 --spec-draft-ngl all \
--spec-draft-type-k f16 --spec-draft-type-v f16 \
--spec-draft-n-max "$MTP_N_MAX" --spec-draft-n-min 0 \
--spec-draft-p-min "$MTP_P_MIN" \
--spec-draft-backend-sampling \
--temp 0.7 --top-p 0.95 \
--reasoning on --reasoning-format deepseek --reasoning-budget -1 \
-lv 4 >>"$log" 2>&1 &
local pid=$!
wait_health "$pid" "$port" || { tail -30 "$log"; return 2; }
# one python pass per run; reuse the same boot across runs
local r
for r in $(seq 1 "$runs"); do
echo "--- run ${r}/${runs} ---"
python3 - "$ubatch" "$port" "$out_tokens" "$CTX" "$log" <<'PY'
import sys, time, json, urllib.request
ubatch, port, out_tokens, ctx, log = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]), sys.argv[5]
# Flowing prose at ~75% of ctx so MTP acceptance is realistic.
VAR = (
"narrative nonfiction blends observation with reflection, ",
"and a careful writer lets each clause earn its place ",
"in the sentence's rising line, so meaning accumulates slowly. ",
"The reader is not hurried; the reader is led. Loose and periodic, ",
"the style turns on concrete nouns and verbs that carry weight, ",
"and it resists the abstract noun that hides a vague claim. ",
"A paragraph is not a list. It is a single turn of thought that ",
"opens, develops, and lands. This is the craft of prose. Choose, ",
"arrange, and above all omit. Omission is the discipline that ",
"keeps the whole clear, and clarity is the courtesy we owe the ",
"willing reader."
)
n = len(VAR)
parts = []
t = 0
while t * 76 < (ctx * 3) // 4:
for k in range(5):
parts.append(VAR[(t + k) % n])
parts.append(f" This is cued by note {t}. ")
t += 1
prompt = "".join(parts)
prompt += ("\n\nNow, in a detailed and flowing paragraph, continue this essay about "
"the philosophy of language and its influence on modern computation. ")
body = json.dumps({
"prompt": prompt, "max_tokens": out_tokens,
"temperature": 0.0, "stream": False,
}).encode()
req = urllib.request.Request(f"http://127.0.0.1:{port}/v1/completions",
data=body, headers={"Content-Type": "application/json"})
t0 = time.time()
with urllib.request.urlopen(req, timeout=7200) as resp:
out = json.load(resp)
dt = time.time() - t0
pt = out.get("usage", {}).get("prompt_tokens", 0)
ct = out.get("usage", {}).get("completion_tokens", 0)
lines = open(log, encoding="utf-8", errors="ignore").read().splitlines()
prefill = [l for l in lines if "prompt eval time" in l]
evals = [l for l in lines if "eval time" in l and "prompt eval" not in l]
def grab(arr):
return arr[-1].strip() if arr else "n/a"
g_tps = ""
if evals and ct:
# eval time line: "eval time = .. ms for N tokens"
ev = evals[-1].split("= ")[-1]
ms = float(ev.split(" for")[0].strip())
g_tps = f"{ct / (ms / 1000):.2f}"
print(f"== RESULT ubatch={ubatch} run={r} "
f"PREFILL={grab(prefill)} GEN_TPS={g_tps} "
f"pt={pt} ct={ct} wall={dt:.1f}s")
PY
done
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
}
main() {
stop_live_service
local idx=0 ubatch port
printf "%-10s %-14s %-10s\n" "UBATCH" "PREFILL" "GEN_TPS"
for ubatch in $(seq "$UBATCH_MIN" "$UBATCH_STEP" "$UBATCH_MAX"); do
port=$((PORT_BASE + idx))
run_one "$ubatch" "$port" "$OUT_TOKENS" "$RUNS"
idx=$((idx + 1))
done
echo "=== sweep complete ==="
}
main "$@"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Benchmark ubatch with llama-bench for frank-default-large.
#
# This is intentionally a ubatch-only benchmark. It uses one llama-bench
# process, a fixed 32K prompt workload, and a fixed scheduler size large
# enough to hold that workload. No server process, HTTP requests, or log
# scraping are used.
#
# Run on Dankclaw after stopping frank-default-large:
# sudo systemctl stop frank-default-large
# ./scripts/bench-ubatch.sh
# sudo systemctl start frank-default-large
set -euo pipefail
: "${LLAMA_BENCH:=/home/dank/GitPackages/ROCmFPX/build-strix-rocmfp4/bin/llama-bench}"
: "${MODEL:=/home/dank/models/kingjones-Qwen3.8-27B-STRIX-MTP/Qwen3.8-27B-Uncensored-Q6_0_ROCMFPX_AGENT.gguf}"
: "${UBATCH_VALUES:=256,512,768,1024,1280,1536,1792,2048,3072,4096}"
: "${CONTEXT:=32768}"
: "${GEN_TOKENS:=128}"
: "${REPETITIONS:=3}"
: "${DEVICE:=ROCm0}"
: "${OUTPUT:=jsonl}"
if [[ ! -x "$LLAMA_BENCH" ]]; then
printf 'missing executable llama-bench: %s\n' "$LLAMA_BENCH" >&2
exit 1
fi
if [[ ! -r "$MODEL" ]]; then
printf 'missing or unreadable model: %s\n' "$MODEL" >&2
exit 1
fi
exec "$LLAMA_BENCH" \
--model "$MODEL" \
--n-prompt "$CONTEXT" \
--n-gen "$GEN_TOKENS" \
--batch-size "$CONTEXT" \
--ubatch-size "$UBATCH_VALUES" \
--device "$DEVICE" \
--n-gpu-layers 999 \
--cache-type-k f16 \
--cache-type-v f16 \
--flash-attn on \
--mmap 0 \
--repetitions "$REPETITIONS" \
--output "$OUTPUT"
+10 -100
View File
@@ -1,104 +1,14 @@
#!/usr/bin/env bash
# deploy-open-webui-quadlet.sh — Install and (re)start open-webui on dankclaw.
#
# open-webui is boot-persistent via Quadlet (see AGENTS.md). This script:
# 1. Pushes main to origin
# 2. Pulls the repo on the target
# 3. Creates the data dir and copies the unit + network into ~/.config/containers/systemd/
# 4. daemon-reloads and starts open-webui (first run pulls the image)
#
# It is idempotent: it stops any running unit and removes any container with the
# name (a prior run or a bare-container leftover) before reinstalling and
# starting. The data dir is preserved across reruns, so existing chats survive.
#
# Usage:
# ./scripts/deploy-open-webui-quadlet.sh <target> [--dry-run]
# ./scripts/deploy-open-webui-quadlet.sh dankclaw
#!/usr/bin/env bash
# Compatibility wrapper for the service-scoped deployment dispatcher.
set -euo pipefail
REMOTE_USER="dank"
REMOTE_HOST="dankclaw"
REMOTE_DIR="/home/dank/serverAppConfigs"
QDIR="dankclaw/open-webui/quadlet"
UNIT="open-webui.service"
DATA_DIR="/home/dank/Containers/open-webui/data"
TARGET="${1:-}"
DRY_RUN=0
if [ "${2:-}" = "--dry-run" ]; then
DRY_RUN=1
fi
if [ -z "${TARGET:-}" ]; then
echo "Usage: $0 <target> [--dry-run]"
exit 1
fi
if [ "${TARGET}" != "dankclaw" ]; then
echo "This stack runs only on dankclaw (got: ${TARGET})"
exit 1
fi
# ---------------------------------------------------------------------------
# Local pre-flight
# ---------------------------------------------------------------------------
cd "$(dirname "$0")/.."
if ! git diff --quiet HEAD; then
echo "FAIL: Local working tree has uncommitted changes. Commit first."
exit 1
fi
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Would push main, pull on ${REMOTE_HOST}, install ${UNIT}, and start it (data at ${DATA_DIR})."
exit 0
fi
echo "=== Deploying open-webui quadlet to ${REMOTE_HOST} ==="
echo "[1/3] Pushing local main to origin..."
git push origin main
REMOTE="${REMOTE_USER}@${REMOTE_HOST}"
echo "[2/3] Installing and starting ${UNIT} on ${REMOTE_HOST}..."
ssh "$REMOTE" bash -s <<REMOTE_EOF
set -euo pipefail
cd '${REMOTE_DIR}'
echo ' Pulling repo...'
git pull origin main
echo ' Stopping any running unit (idempotent)...'
systemctl --user stop '${UNIT}' 2>/dev/null || true
echo ' Removing any container with the name (prior run or bare-container leftover)...'
podman rm -f open-webui 2>/dev/null || true
echo ' Creating the data dir (preserved across reruns)...'
mkdir -p '${DATA_DIR}'
echo ' Copying the quadlet unit + network into ~/.config/containers/systemd/ ...'
mkdir -p ~/.config/containers/systemd
cp ${QDIR}/open-webui.container ~/.config/containers/systemd/
cp ${QDIR}/open-webui.network ~/.config/containers/systemd/
echo ' daemon-reload (generates the unit)...'
# Quadlet units are generator-managed: systemctl enable fails on them. The
# [Install] WantedBy=default.target in the .container file is itself the
# enablement (is-enabled reports "generated"); with user linger on it starts at
# boot. So: daemon-reload, then start.
systemctl --user daemon-reload
echo ' Starting (first run pulls the image; this can take a while)...'
systemctl --user start '${UNIT}'
echo ' Unit state:'
systemctl --user --no-pager status '${UNIT}' 2>&1 | grep -E "●|Active:" || true
REMOTE_EOF
echo "[3/3] Verifying open-webui is up..."
ssh "$REMOTE" '
echo " is-enabled: $(systemctl --user is-enabled open-webui.service 2>&1) (boot-persistent)"
echo " port 3005:"; (ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null) | grep -E ":3005\b" || echo " (not listening yet)"
echo " HTTP probe:"; curl -s -o /dev/null -w " http://127.0.0.1:3005 -> %{http_code}\n" -m 10 http://127.0.0.1:3005/ || echo " (no HTTP yet)"
' || true
echo "=== open-webui quadlet deployed to ${REMOTE_HOST} ==="
shift || true
case "$TARGET" in
dankclaw) exec "$(dirname "$0")/deploy.sh" dankclaw --service open-webui "$@" ;;
*)
echo "This stack runs only on dankclaw (got: ${TARGET:-<missing>})" >&2
exit 2
;;
esac
+9 -114
View File
@@ -1,118 +1,13 @@
#!/usr/bin/env bash
# deploy-pihole-quadlet.sh — Install and (re)start the Pi-hole LAN DNS on
# dankclaw. Pi-hole retired the dnsmasq quadlet (2026-08-24).
#
# Pi-hole is boot-persistent via Quadlet (see AGENTS.md). This script:
# 1. Pushes main to origin
# 2. Pulls the repo on the target
# 3. Creates the data dirs and copies the quadlet unit + env + addn-hosts
# 4. daemon-reloads and starts pihole (first run pulls the image)
#
# It is idempotent: it stops any running unit and removes any container with
# the name (a prior run or a bare-container leftover) before reinstalling and
# starting. The data dirs (etc-pihole, etc-dockers) are preserved across
# reruns, so the blocklist and web settings survive. The env file is created
# from env.example only if absent; fill the real WEBPASSWORD before the web
# UI is usable.
#
# Usage:
# ./scripts/deploy-pihole-quadlet.sh <target> [--dry-run]
# ./scripts/deploy-pihole-quadlet.sh dankclaw
# Compatibility wrapper for the service-scoped deployment dispatcher.
set -euo pipefail
REMOTE_USER="dank"
REMOTE_HOST="dankclaw"
REMOTE_DIR="/home/dank/serverAppConfigs"
QDIR="dankclaw/pihole"
UNIT="pihole.service"
DATA_DIR="/home/dank/Containers/pihole"
TARGET="${1:-}"
DRY_RUN=0
if [ "${2:-}" = "--dry-run" ]; then
DRY_RUN=1
fi
if [ -z "${TARGET:-}" ]; then
echo "Usage: $0 <target> [--dry-run]"
exit 1
fi
if [ "${TARGET}" != "dankclaw" ]; then
echo "This stack runs only on dankclaw (got: ${TARGET})"
exit 1
fi
# ---------------------------------------------------------------------------
# Local pre-flight
# ---------------------------------------------------------------------------
cd "$(dirname "$0")/.."
if ! git diff --quiet HEAD; then
echo "FAIL: Local working tree has uncommitted changes. Commit first."
exit 1
fi
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Would push main, pull on ${REMOTE_HOST}, install ${UNIT}, and start it (data at ${DATA_DIR})."
exit 0
fi
echo "=== Deploying pihole quadlet to ${REMOTE_HOST} ==="
echo "[1/3] Pushing local main to origin..."
git push origin main
REMOTE="${REMOTE_USER}@${REMOTE_HOST}"
echo "[2/3] Installing and starting ${UNIT} on ${REMOTE_HOST}..."
ssh "$REMOTE" bash -s <<REMOTE_EOF
set -euo pipefail
cd '${REMOTE_DIR}'
echo ' Pulling repo...'
git pull origin main
echo ' Stopping any running unit (idempotent)...'
systemctl --user stop '${UNIT}' 2>/dev/null || true
echo ' Removing any container with the name (prior run or bare-container leftover)...'
podman rm -f pihole 2>/dev/null || true
echo ' Creating the data dirs (preserved across reruns)...'
mkdir -p '${DATA_DIR}/etc-pihole' '${DATA_DIR}/etc-dockers'
echo ' Creating the env file from env.example if absent...'
if [ ! -f '${DATA_DIR}/env' ]; then
cp ${QDIR}/env.example '${DATA_DIR}/env'
echo ' env created: set the real WEBPASSWORD before the web UI is usable'
else
echo ' env present, keeping it'
fi
echo ' Copying the record file (addn-hosts)...'
cp ${QDIR}/addn-hosts '${DATA_DIR}/addn-hosts'
echo ' Copying the quadlet unit into ~/.config/containers/systemd/ ...'
mkdir -p ~/.config/containers/systemd
cp ${QDIR}/quadlet/pihole.container ~/.config/containers/systemd/
echo ' daemon-reload (generates the unit)...'
# Quadlet units are generator-managed: systemctl enable fails on them. The
# [Install] WantedBy=default.target in the .container file is itself the
# enablement (is-enabled reports "generated"); with user linger on it starts
# at boot. So: daemon-reload, then start.
systemctl --user daemon-reload
echo ' Starting (first run pulls the image; this can take a while)...'
systemctl --user start '${UNIT}'
echo ' Unit state:'
systemctl --user --no-pager status '${UNIT}' 2>&1 | grep -E "●|Active:" || true
REMOTE_EOF
echo "[3/3] Verifying pihole is up..."
ssh "$REMOTE" '
echo " is-enabled: $(systemctl --user is-enabled pihole.service 2>&1) (boot-persistent)"
echo " port 53:"; (ss -ulpn 2>/dev/null) | grep -E ":53\b" || echo " (not listening yet)"
echo " resolution:"; dig @127.0.0.1 romm.franksdank.cloud +short 2>/dev/null || echo " (no answer yet)"
' || true
echo "=== pihole quadlet deployed to ${REMOTE_HOST} ==="
shift || true
case "$TARGET" in
dankclaw) exec "$(dirname "$0")/deploy.sh" dankclaw --service pihole "$@" ;;
*)
echo "This stack runs only on dankclaw (got: ${TARGET:-<missing>})" >&2
exit 2
;;
esac
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env python3
"""Plan runtime services affected by repository changes.
The planner is intentionally side-effect free. It accepts either an explicit
path list or two Git revisions. The latter lets compose-file changes resolve
to only the service definitions that actually changed instead of restarting an
entire compose project.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import PurePosixPath
from typing import Any
import yaml
# Dependency-sensitive services come before their consumers. Independent
# compose services follow. LLM services are deliberately last and are never
# implied by a generic host, stack, documentation, or config change.
SERVICE_ORDER = (
"gluetun",
"searxng-valkey",
"searxng",
"pihole",
"open-webui",
"gitea-postgres",
"gitea-server",
"gitea-caddy",
"comfyui",
"hermes",
"hermes-dashboard",
"prometheus",
"grafana",
"loki",
"alloy",
"node-exporter",
"authentik-postgres",
"authentik-server",
"authentik-worker",
"caddy",
"homepage",
"mariadb",
"redis",
"romm",
"copyparty",
"jellyfin",
"calibre-web",
"prowlarr",
"qbittorrent",
"radarr",
"sonarr",
"lidarr",
"readarr",
"frank-default-large",
"frank-default-npu",
"frank-default-small",
)
DANKCLAW_SERVICES = {
"gluetun",
"searxng-valkey",
"searxng",
"pihole",
"open-webui",
"gitea-postgres",
"gitea-server",
"gitea-caddy",
"comfyui",
"hermes",
"hermes-dashboard",
"prometheus",
"grafana",
"loki",
"alloy",
"node-exporter",
"authentik-postgres",
"authentik-server",
"authentik-worker",
"caddy",
"homepage",
"frank-default-large",
"frank-default-npu",
}
DANKSERVER_SERVICES = {
"mariadb",
"redis",
"romm",
"copyparty",
"jellyfin",
"calibre-web",
"prowlarr",
"qbittorrent",
"radarr",
"sonarr",
"lidarr",
"readarr",
"frank-default-small",
}
DANKCLAW_COMPOSE_SERVICES = {
"comfyui",
"hermes",
"hermes-dashboard",
"prometheus",
"grafana",
"loki",
"alloy",
"node-exporter",
"authentik-postgres",
"authentik-server",
"authentik-worker",
"caddy",
}
DANKSERVER_COMPOSE_SERVICES = {
"mariadb",
"redis",
"romm",
"copyparty",
"jellyfin",
"calibre-web",
"prowlarr",
"qbittorrent",
"radarr",
"sonarr",
"lidarr",
"readarr",
}
def _add(services: set[str], *names: str) -> None:
services.update(names)
def target_services(target: str) -> set[str]:
if target == "dankclaw":
return DANKCLAW_SERVICES
if target == "dankserver":
return DANKSERVER_SERVICES
raise ValueError(f"unknown target: {target}")
def _compose_services(target: str) -> set[str]:
return DANKCLAW_COMPOSE_SERVICES if target == "dankclaw" else DANKSERVER_COMPOSE_SERVICES
def _git_show(repo: str, revision: str, path: str) -> str | None:
result = subprocess.run(
["git", "-C", repo, "show", f"{revision}:{path}"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
check=False,
)
return result.stdout if result.returncode == 0 else None
def _yaml_document(contents: str | None) -> dict[str, Any]:
if contents is None:
return {}
try:
data = yaml.safe_load(contents) or {}
except yaml.YAMLError:
return {}
return data if isinstance(data, dict) else {}
def _yaml_services(contents: str | None) -> dict[str, Any]:
services = _yaml_document(contents).get("services", {})
return services if isinstance(services, dict) else {}
def compose_changes(repo: str, target: str, base: str, head: str) -> set[str]:
"""Return only compose services whose YAML definitions or dependencies changed."""
path = f"{target}/compose.yaml"
old_doc = _yaml_document(_git_show(repo, base, path))
new_doc = _yaml_document(_git_show(repo, head, path))
old = old_doc.get("services", {}) if isinstance(old_doc.get("services", {}), dict) else {}
new = new_doc.get("services", {}) if isinstance(new_doc.get("services", {}), dict) else {}
names = set(old) | set(new)
allowed = _compose_services(target)
# An unavailable or malformed revision is safer as a broad compose plan
# than as a silent no-op. It still cannot include LLM units.
if (old_doc and not new_doc) or (new_doc and not old_doc and names):
return allowed
# Top-level networks/configuration can affect every compose service even
# when no individual service mapping changed. Ignore the services key while
# comparing the rest of the Compose document.
old_globals = {key: value for key, value in old_doc.items() if key != "services"}
new_globals = {key: value for key, value in new_doc.items() if key != "services"}
if old_globals != new_globals:
return allowed
return {name for name in names if name in allowed and old.get(name) != new.get(name)}
def services_for_path(path: str, target: str) -> set[str]:
"""Return services affected by one repository-relative path."""
path = path.replace("\\", "/")
p = PurePosixPath(path)
parts = p.parts
services: set[str] = set()
if not parts:
return services
# Non-runtime material is deliberately a no-op.
if parts[0] in {"docs", "tests", "old", ".githooks"}:
return services
if path in {"README.md", "AGENTS.md"} or parts[0] == ".obsidian":
return services
if parts[0] == "shared":
if len(parts) < 2:
return services
group = parts[1]
if group == "searxng":
_add(services, "searxng")
elif group == "prometheus":
_add(services, "prometheus")
elif group == "loki":
_add(services, "loki")
elif group == "alloy":
_add(services, "alloy")
elif group == "homepage":
# Homepage is a Dankclaw-owned deployment. Shared homepage files
# are intentionally not copied during a Dankserver-only run.
if target == "dankclaw":
_add(services, "homepage")
return services
if parts[0] == "dankclaw":
if len(parts) >= 2 and parts[1] == "compose.yaml":
# Revision-aware callers replace this broad fallback with the
# service-level YAML comparison in compose_changes().
_add(services, *DANKCLAW_COMPOSE_SERVICES)
return services
if len(parts) >= 2 and parts[1] == "Caddyfile":
_add(services, "caddy")
return services
if len(parts) >= 2 and parts[1] == "Containerfile.comfyui":
_add(services, "comfyui")
return services
if len(parts) >= 2 and parts[1] in {"caddy", "comfyui", "loki", "alloy", "hermes"}:
_add(services, parts[1])
return services
if len(parts) >= 2 and parts[1] == "searxng":
if len(parts) >= 4 and parts[2] == "quadlet":
name = parts[3]
if name == "gluetun.container":
# SearXNG shares Gluetun's network namespace. A Gluetun
# recreation requires both units to be restarted.
_add(services, "gluetun", "searxng")
elif name == "searxng.container":
_add(services, "searxng")
elif name == "searxng-valkey.container":
_add(services, "searxng-valkey", "searxng")
elif name == "searxng.network":
_add(services, "gluetun", "searxng-valkey", "searxng")
return services
if len(parts) >= 2 and parts[1] == "pihole":
_add(services, "pihole")
return services
if len(parts) >= 2 and parts[1] == "open-webui":
_add(services, "open-webui")
return services
if len(parts) >= 2 and parts[1] == "gitea":
if len(parts) >= 4 and parts[2] == "quadlet":
name = parts[3]
if name == "gitea-postgres.container":
_add(services, "gitea-postgres")
elif name == "gitea-server.container":
_add(services, "gitea-server")
elif name == "gitea-caddy.container":
_add(services, "gitea-caddy")
elif name == "gitea.network":
_add(services, "gitea-postgres", "gitea-server", "gitea-caddy")
return services
return services
if parts[0] == "dankserver":
if len(parts) >= 2 and parts[1] == "compose.yaml":
_add(services, *DANKSERVER_COMPOSE_SERVICES)
return services
if parts[0] == "scripts":
name = p.name
if name in {"frank-default-large.env", "frank-default-large.service", "serve-frank-large.sh"}:
_add(services, "frank-default-large")
elif name in {"frank-default-npu.env", "frank-default-npu.service", "serve-frank-default-npu.sh"}:
_add(services, "frank-default-npu")
elif name in {"frank-default-small.env", "frank-default-small.service", "serve-frank-small.sh"}:
_add(services, "frank-default-small")
elif name == "chat-template.jinja":
# Only the selected target's LLM consumes this shared template.
_add(services, "frank-default-large" if target == "dankclaw" else "frank-default-small")
elif name in {"homepage-status.py", "homepage-status.service", "homepage-status.timer"}:
_add(services, "homepage")
# Deployment/test/benchmark scripts and other repo tooling do not
# restart a runtime service.
return services
# Examples and env templates are not deployed runtime state.
if p.name in {".env.example", "env.example"}:
return services
return services
def changed_paths(repo: str, base: str, head: str) -> list[str]:
result = subprocess.run(
["git", "-C", repo, "diff", "--name-only", "--diff-filter=ACDMRTUXB", base, head],
text=True,
stdout=subprocess.PIPE,
check=True,
)
return [line for line in result.stdout.splitlines() if line]
def plan(paths: list[str], target: str, repo: str | None = None,
base: str | None = None, head: str | None = None) -> list[str]:
services: set[str] = set()
for path in paths:
if path == f"{target}/compose.yaml" and repo and base and head:
services.update(compose_changes(repo, target, base, head))
else:
services.update(services_for_path(path, target))
allowed = target_services(target)
return [name for name in SERVICE_ORDER if name in services and name in allowed]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", choices=("dankclaw", "dankserver"), required=True)
parser.add_argument("--paths", nargs="+")
parser.add_argument("--repo", help="repository path for revision-aware planning")
parser.add_argument("--base", help="base Git revision")
parser.add_argument("--head", help="head Git revision")
args = parser.parse_args()
if not args.paths and not (args.repo and args.base and args.head):
parser.error("provide --paths or all of --repo, --base, and --head")
paths = args.paths or changed_paths(args.repo, args.base, args.head)
print("\n".join(plan(paths, args.target, args.repo, args.base, args.head)))
return 0
if __name__ == "__main__":
sys.exit(main())
+529
View File
@@ -0,0 +1,529 @@
#!/usr/bin/env bash
# deploy-remote.sh — apply an explicit service plan on one target host.
#
# This script runs on the target checkout. It deliberately does not infer a
# plan, pull Git, or restart an unrelated service. deploy.sh computes the plan
# from the pushed revision and passes the selected service names here.
# Per-service revisions live in /var/lib/serverAppConfigs/deploy-state (or
# DEPLOY_STATE_DIR), outside the checkout, so an unselected change remains
# pending until its service is explicitly deployed.
set -euo pipefail
TARGET="${1:-}"
REVISION="${2:-}"
shift 2 || true
FROM_REVISION=""
PENDING=0
SERVICES=()
usage() {
cat <<'EOF'
Usage: deploy-remote.sh <dankclaw|dankserver> <git-revision> [--pending] [service]...
The service list is explicit. An empty list is a successful no-op.
EOF
}
while [[ "$#" -gt 0 ]]; do
case "$1" in
--from-revision)
[[ "$#" -ge 2 ]] || { echo "--from-revision requires a revision" >&2; exit 2; }
FROM_REVISION="$2"
shift 2
;;
--pending)
PENDING=1
shift
;;
--help|-h)
usage
exit 0
;;
*)
SERVICES+=("$1")
shift
;;
esac
done
REMOTE_DIR="${REMOTE_DIR:-/home/dank/serverAppConfigs}"
STATE_DIR="${DEPLOY_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/serverAppConfigs/deploy-state}"
run_privileged() {
if [[ -n "${FRANK_SUDO_PW:-}" ]]; then
printf '%s\n' "$FRANK_SUDO_PW" | sudo -S -- "$@"
else
sudo -- "$@"
fi
}
if [[ "$TARGET" != dankclaw && "$TARGET" != dankserver ]]; then
usage >&2
exit 2
fi
if [[ -z "$REVISION" ]]; then
usage >&2
exit 2
fi
if [[ "$REVISION" == *[!0-9a-fA-F]* ]]; then
echo "invalid Git revision: $REVISION" >&2
exit 2
fi
if ! git -C "$REMOTE_DIR" cat-file -e "$REVISION^{commit}" 2>/dev/null; then
echo "revision is not a commit in the target checkout: $REVISION" >&2
exit 2
fi
cd "$REMOTE_DIR"
# Keep the state directory outside Git. The file is written only after a
# handler completes successfully; an interrupted handler remains pending.
mkdir -p "$STATE_DIR"
service_is_allowed() {
local service="$1"
case "$TARGET:$service" in
dankclaw:gluetun|dankclaw:searxng-valkey|dankclaw:searxng|dankclaw:pihole|\
dankclaw:open-webui|dankclaw:gitea-postgres|dankclaw:gitea-server|\
dankclaw:gitea-caddy|dankclaw:comfyui|dankclaw:hermes|\
dankclaw:hermes-dashboard|dankclaw:prometheus|dankclaw:grafana|\
dankclaw:loki|dankclaw:alloy|dankclaw:node-exporter|\
dankclaw:authentik-postgres|dankclaw:authentik-server|\
dankclaw:authentik-worker|dankclaw:caddy|dankclaw:homepage|\
dankclaw:frank-default-large|dankclaw:frank-default-npu|\
dankserver:mariadb|dankserver:redis|dankserver:romm|\
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|\
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\
dankserver:frank-default-small)
return 0
;;
*)
return 1
;;
esac
}
mark_status() {
local service="$1" status="$2" tmp
tmp="$(mktemp "$STATE_DIR/.${service}.XXXXXX")"
printf 'revision=%s\nstatus=%s\n' "$REVISION" "$status" > "$tmp"
mv -f -- "$tmp" "$STATE_DIR/$service"
}
mark_deployed() {
mark_status "$1" deployed
}
state_revision() {
local service="$1" line value
if [[ -r "$STATE_DIR/$service" ]]; then
while IFS= read -r line; do
case "$line" in
revision=*) value="${line#revision=}" ;;
esac
done < "$STATE_DIR/$service"
printf '%s' "${value:-}"
fi
}
state_status() {
local service="$1" line value=pending
if [[ -r "$STATE_DIR/$service" ]]; then
while IFS= read -r line; do
case "$line" in
status=*) value="${line#status=}" ;;
esac
done < "$STATE_DIR/$service"
fi
printf '%s' "$value"
}
register_changed_services() {
local base="$1" service recorded status
[[ -n "$base" ]] || return 0
while IFS= read -r service; do
[[ -n "$service" ]] || continue
recorded="$(state_revision "$service")"
status="$(state_status "$service")"
if [[ "$status" != deployed || "$recorded" != "$REVISION" ]]; then
mark_status "$service" pending
fi
done < <(python3 scripts/deploy-plan.py --target "$TARGET" --repo "$REMOTE_DIR" \
--base "$base" --head "$REVISION")
}
add_changed_services() {
local base="$1"
local service
[[ -n "$base" ]] || return 0
while IFS= read -r service; do
[[ -n "$service" ]] || continue
SERVICES+=("$service")
done < <(python3 scripts/deploy-plan.py --target "$TARGET" --repo "$REMOTE_DIR" \
--base "$base" --head "$REVISION")
}
add_pending_services() {
local service recorded status changed
for state_file in "$STATE_DIR"/*; do
[[ -f "$state_file" ]] || continue
service="${state_file##*/}"
recorded="$(state_revision "$service")"
status="$(state_status "$service")"
if [[ "$status" != deployed ]]; then
# A pending service was intentionally skipped or its handler
# failed. Retry it even when the checkout has not advanced.
SERVICES+=("$service")
continue
fi
[[ -n "$recorded" && "$recorded" != "$REVISION" ]] || continue
if [[ "$TARGET" == dankclaw && "$service" == frank-default-small ]]; then continue; fi
if [[ "$TARGET" == dankserver && "$service" == frank-default-large ]]; then continue; fi
if ! changed="$(python3 scripts/deploy-plan.py --target "$TARGET" \
--repo "$REMOTE_DIR" --base "$recorded" --head "$REVISION")"; then
echo "WARN: unable to compare state for $service; retaining it as pending" >&2
SERVICES+=("$service")
continue
fi
if grep -Fxq "$service" <<< "$changed"; then
SERVICES+=("$service")
fi
done
}
copy_searxng_config() {
run_privileged mkdir -p /opt/searxng/config
run_privileged cp shared/searxng/settings.yml /opt/searxng/config/settings.yml
run_privileged cp shared/searxng/limiter.toml /opt/searxng/config/limiter.toml
}
copy_prometheus_config() {
run_privileged mkdir -p /opt/prometheus/config
run_privileged cp shared/prometheus/prometheus.yml /opt/prometheus/config/prometheus.yml
}
copy_loki_config() {
run_privileged mkdir -p /opt/loki/config
run_privileged cp shared/loki/loki-config.yaml /opt/loki/config/loki-config.yaml
}
copy_alloy_config() {
run_privileged mkdir -p /opt/alloy/config
run_privileged cp shared/alloy/config.alloy /opt/alloy/config/config.alloy
}
render_caddyfile() {
local hash
hash="$(grep -E '^CADDY_BASICAUTH_HASH=' .env 2>/dev/null | tail -1 | cut -d= -f2- || true)"
if [[ -z "$hash" ]]; then
echo "WARN: CADDY_BASICAUTH_HASH is not set; refusing to overwrite rendered Caddyfile" >&2
return 1
fi
run_privileged mkdir -p /home/dank/Containers/caddy
sed -e "s|REPLACE_WITH_HASH|$hash|g" dankclaw/Caddyfile > /tmp/serverAppConfigs.Caddyfile
run_privileged cp /tmp/serverAppConfigs.Caddyfile /home/dank/Containers/caddy/Caddyfile
}
deploy_homepage() {
if [[ "$TARGET" != dankclaw ]]; then
echo "homepage is owned by dankclaw; refusing to deploy it on $TARGET" >&2
return 2
fi
run_privileged mkdir -p /srv/homepage
run_privileged cp shared/homepage/index.html shared/homepage/admin.html shared/homepage/app.js \
shared/homepage/style.css shared/homepage/services.json /srv/homepage/
if [[ -d shared/homepage/fonts ]]; then
run_privileged cp -r shared/homepage/fonts /srv/homepage/
fi
local deployed_at
deployed_at="$(git log -1 --format=%cI)"
printf 'window.DEPLOYED_AT = "%s";\n' "$deployed_at" > /tmp/deployed.js
run_privileged cp /tmp/deployed.js /srv/homepage/deployed.js
run_privileged chmod -R a+rX /srv/homepage
run_privileged cp scripts/homepage-status.py /usr/local/bin/homepage-status.py
run_privileged cp scripts/homepage-status.service scripts/homepage-status.timer /etc/systemd/system/
run_privileged systemctl daemon-reload
run_privileged systemctl enable homepage-status.timer
run_privileged systemctl restart homepage-status.timer
run_privileged python3 /usr/local/bin/homepage-status.py /srv/homepage/services.json /srv/homepage/status.json
}
deploy_caddy() {
if [[ "$TARGET" != dankclaw ]]; then
echo "caddy is owned by dankclaw; refusing to deploy it on $TARGET" >&2
return 2
fi
render_caddyfile
deploy_homepage
local cfhash oldhash image
cfhash="$(sha256sum dankclaw/caddy/Containerfile | cut -d' ' -f1)"
image=franksdank/caddy-porkbun
oldhash="$(podman image inspect "$image:latest" --format '{{index .Config.Labels "cfhash"}}' 2>/dev/null || echo none)"
if [[ "$oldhash" != "$cfhash" ]]; then
podman build --no-cache --label "cfhash=$cfhash" -t "$image:latest" \
-f dankclaw/caddy/Containerfile .
fi
# The canonical reverse proxy may be gitea-caddy on a host with the old
# split deployment. Never stop or replace it implicitly. Refuse the Caddy
# deployment until the operator has explicitly migrated port ownership.
if podman container exists gitea-caddy && ! podman container exists caddy; then
echo "FAIL: gitea-caddy owns the reverse-proxy deployment; refusing to replace it with caddy." >&2
return 1
fi
if podman container exists gitea-caddy && podman container exists caddy; then
local gitea_state caddy_state
gitea_state="$(podman inspect gitea-caddy --format '{{.State.Status}}' 2>/dev/null || true)"
caddy_state="$(podman inspect caddy --format '{{.State.Status}}' 2>/dev/null || true)"
if [[ "$gitea_state" == running && "$caddy_state" != running ]]; then
echo "FAIL: gitea-caddy currently owns ports 80/443; refusing to start caddy." >&2
return 1
fi
fi
if podman container exists caddy; then
podman-compose -f dankclaw/compose.yaml up -d --no-deps caddy
local reloaded=0
for _ in 1 2 3 4 5; do
if podman exec caddy caddy reload --config /etc/caddy/Caddyfile \
--adapter caddyfile >/dev/null 2>&1; then
reloaded=1
echo "Caddy reloaded."
break
fi
sleep 3
done
if [[ "$reloaded" -ne 1 ]]; then
echo "Caddy reload failed; restarting only caddy."
podman restart caddy
fi
else
echo "WARN: caddy container is absent; no proxy was stopped or replaced."
fi
}
deploy_compose_service() {
local service="$1"
if [[ "$TARGET" == dankserver ]]; then
# Dankserver's compose file is the source-of-truth inventory, but its
# production containers are individually systemd-managed/bare. Do not
# use compose up here: it can create a second container with a wrong
# name and can interfere with a running workload.
echo "WARN: $service is recorded in dankserver/compose.yaml; production container management is host-specific and no restart was attempted."
return 0
fi
case "$service" in
comfyui|hermes|hermes-dashboard|prometheus|grafana|loki|alloy|node-exporter|authentik-postgres|authentik-server|authentik-worker)
case "$service" in
comfyui|loki|alloy)
build_compose_image "$service"
;;
prometheus)
copy_prometheus_config
;;
loki)
copy_loki_config
;;
alloy)
copy_alloy_config
;;
esac
podman-compose -f dankclaw/compose.yaml up -d --no-deps "$service"
;;
*)
echo "unknown compose service: $service" >&2
return 2
;;
esac
}
build_compose_image() {
local service="$1" image cfhash oldhash containerfile
case "$service" in
comfyui)
image=comfyui-rocm:gfx1151
containerfile=dankclaw/Containerfile.comfyui
;;
loki|alloy)
image="franksdank/$service:latest"
containerfile="dankclaw/$service/Containerfile"
;;
*)
return 0
;;
esac
cfhash="$(sha256sum "$containerfile" | cut -d' ' -f1)"
oldhash="$(podman image inspect "$image" --format '{{index .Config.Labels "cfhash"}}' 2>/dev/null || echo none)"
if [[ "$oldhash" != "$cfhash" ]]; then
podman build --no-cache --label "cfhash=$cfhash" -t "$image" -f "$containerfile" .
fi
}
quadlet_unit_dir="$HOME/.config/containers/systemd"
install_quadlet() {
local service="$1"
local source_dir="$2"
local unit="$3"
mkdir -p "$quadlet_unit_dir"
cp "$source_dir/$unit.container" "$quadlet_unit_dir/"
if [[ -f "$source_dir/$unit.network" ]]; then
cp "$source_dir/$unit.network" "$quadlet_unit_dir/"
fi
systemctl --user daemon-reload
}
deploy_searxng() {
copy_searxng_config
install_quadlet searxng dankclaw/searxng/quadlet searxng
install_quadlet searxng-valkey dankclaw/searxng/quadlet searxng-valkey
install_quadlet gluetun dankclaw/searxng/quadlet gluetun
# The network is shared by all three units; install it even when only the
# SearXNG service was selected. These copies are user-owned Quadlet state.
cp dankclaw/searxng/quadlet/searxng.network "$quadlet_unit_dir/"
systemctl --user daemon-reload
systemctl --user start searxng-network.service
systemctl --user restart gluetun.service searxng-valkey.service searxng.service
}
deploy_gluetun() {
install_quadlet gluetun dankclaw/searxng/quadlet gluetun
cp dankclaw/searxng/quadlet/searxng.network "$quadlet_unit_dir/"
systemctl --user daemon-reload
systemctl --user restart gluetun.service searxng.service
}
deploy_searxng_valkey() {
install_quadlet searxng-valkey dankclaw/searxng/quadlet searxng-valkey
cp dankclaw/searxng/quadlet/searxng.network "$quadlet_unit_dir/"
systemctl --user daemon-reload
systemctl --user restart searxng-valkey.service searxng.service
}
deploy_open_webui() {
install_quadlet open-webui dankclaw/open-webui/quadlet open-webui
mkdir -p /home/dank/Containers/open-webui/data
systemctl --user daemon-reload
systemctl --user restart open-webui.service
}
deploy_pihole() {
mkdir -p /home/dank/Containers/pihole/etc-pihole /home/dank/Containers/pihole/etc-dockers
if [[ ! -f /home/dank/Containers/pihole/env ]]; then
cp dankclaw/pihole/env.example /home/dank/Containers/pihole/env
echo "WARN: created Pi-hole env from the example; set WEBPASSWORD before using its UI" >&2
fi
cp dankclaw/pihole/addn-hosts /home/dank/Containers/pihole/addn-hosts
mkdir -p "$quadlet_unit_dir"
cp dankclaw/pihole/quadlet/pihole.container "$quadlet_unit_dir/"
systemctl --user daemon-reload
systemctl --user restart pihole.service
}
deploy_gitea_unit() {
local service="$1"
local unit="$2"
mkdir -p "$quadlet_unit_dir"
cp "dankclaw/gitea/quadlet/$unit.container" "$quadlet_unit_dir/"
[[ -f dankclaw/gitea/quadlet/gitea.network ]] && cp dankclaw/gitea/quadlet/gitea.network "$quadlet_unit_dir/"
systemctl --user daemon-reload
systemctl --user restart "$service.service"
}
relink_unit() {
local service="$1"
run_privileged ln -sfn "$REMOTE_DIR/scripts/$service.service" "/etc/systemd/system/$service.service"
}
deploy_llm_large() {
relink_unit frank-default-large
# NPU is intentionally not restarted here. It is a separate, retired
# endpoint and its unit is only relinked when explicitly selected.
run_privileged systemctl daemon-reload
run_privileged systemctl enable frank-default-large
run_privileged systemctl restart frank-default-large
wait_for_health frank-default-large http://127.0.0.1:7770/health 1200
}
deploy_llm_small() {
relink_unit frank-default-small
run_privileged systemctl daemon-reload
run_privileged systemctl enable frank-default-small
run_privileged systemctl restart frank-default-small
wait_for_health frank-default-small http://127.0.0.1:7770/health 1200
}
deploy_llm_npu() {
relink_unit frank-default-npu
run_privileged systemctl daemon-reload
echo "Relinked frank-default-npu; it was not restarted."
}
wait_for_health() {
local service="$1" url="$2" limit="$3"
local i
for ((i=1; i<=limit; i++)); do
if curl -fsS "$url" >/dev/null 2>&1; then
echo "$service healthy after approximately $((i * 2))s"
return 0
fi
sleep 2
done
echo "FAIL: $service did not become healthy in $((limit * 2))s" >&2
return 1
}
deploy_service() {
local service="$1"
case "$service" in
caddy) deploy_caddy ;;
homepage) deploy_homepage ;;
searxng) deploy_searxng ;;
gluetun) deploy_gluetun ;;
searxng-valkey) deploy_searxng_valkey ;;
open-webui) deploy_open_webui ;;
pihole) deploy_pihole ;;
gitea-postgres) deploy_gitea_unit gitea-postgres gitea-postgres ;;
gitea-server) deploy_gitea_unit gitea-server gitea-server ;;
gitea-caddy) deploy_gitea_unit gitea-caddy gitea-caddy ;;
frank-default-large) deploy_llm_large ;;
frank-default-small) deploy_llm_small ;;
frank-default-npu) deploy_llm_npu ;;
*) deploy_compose_service "$service" ;;
esac
}
if [[ -n "$FROM_REVISION" ]]; then
# Register every changed service before applying the requested scope. This
# preserves an explicit --service selection as pending work for the next
# ordinary deployment.
register_changed_services "$FROM_REVISION"
fi
if [[ "$PENDING" -eq 1 ]]; then
add_changed_services "$FROM_REVISION"
add_pending_services
fi
# Remove duplicate service names while preserving caller/planner order.
UNIQUE_SERVICES=()
declare -A SEEN_SERVICES=()
for service in "${SERVICES[@]}"; do
[[ -n "$service" ]] || continue
if [[ -z "${SEEN_SERVICES[$service]:-}" ]]; then
UNIQUE_SERVICES+=("$service")
SEEN_SERVICES[$service]=1
fi
done
SERVICES=("${UNIQUE_SERVICES[@]}")
if [[ "${#SERVICES[@]}" -eq 0 ]]; then
echo "No services selected; nothing to deploy."
exit 0
fi
for service in "${SERVICES[@]}"; do
[[ "$service" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "invalid service: $service" >&2; exit 2; }
service_is_allowed "$service" || { echo "service $service is not deployed on $TARGET" >&2; exit 2; }
echo "=== Deploying $service at $REVISION ==="
mark_status "$service" pending
deploy_service "$service"
mark_deployed "$service"
echo " STATE[$service] = $REVISION (status=deployed)"
done
+12 -93
View File
@@ -1,97 +1,16 @@
#!/usr/bin/env bash
# deploy-searxng-quadlet.sh — Install and (re)start the SearXNG quadlet stack.
#
# The gluetun + searxng + valkey trio is boot-persistent via Quadlet, not
# podman-compose (see AGENTS.md). This script:
# 1. Pushes main to origin
# 2. Pulls the repo on the target
# 3. Copies the quadlet units into ~/.config/containers/systemd/
# 4. daemon-reloads, enables the units for boot, and starts them in order
#
# It is idempotent: it stops any running units and removes any containers with
# the trio's names (compose leftovers or the previous quadlet run) before
# reinstalling and starting, so it is safe to run on first migration and on
# every later config change.
#
# Usage:
# ./scripts/deploy-searxng-quadlet.sh <target> [--dry-run]
# ./scripts/deploy-searxng-quadlet.sh dankclaw
#!/usr/bin/env bash
# Compatibility wrapper for the service-scoped deployment dispatcher.
# Use deploy.sh for Git push/pull and planning; this name remains for operators
# and runbooks that historically deployed the Quadlet trio directly.
set -euo pipefail
REMOTE_USER="dank"
REMOTE_HOST="dankclaw"
REMOTE_DIR="/home/dank/serverAppConfigs"
QDIR="dankclaw/searxng/quadlet"
UNITS="searxng-network.service gluetun.service searxng-valkey.service searxng.service"
TARGET="${1:-}"
DRY_RUN=0
if [ "${2:-}" = "--dry-run" ]; then
DRY_RUN=1
fi
if [ -z "${TARGET:-}" ]; then
echo "Usage: $0 <target> [--dry-run]"
exit 1
fi
if [ "${TARGET}" != "dankclaw" ]; then
echo "This stack runs only on dankclaw (got: ${TARGET})"
exit 1
fi
# ---------------------------------------------------------------------------
# Local pre-flight
# ---------------------------------------------------------------------------
cd "$(dirname "$0")/.."
if ! git diff --quiet HEAD; then
echo "FAIL: Local working tree has uncommitted changes. Commit first."
exit 1
fi
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Would push main, pull on ${REMOTE_HOST}, install quadlet units, and start: ${UNITS}"
exit 0
fi
echo "=== Deploying SearXNG quadlet stack to ${REMOTE_HOST} ==="
echo "[1/3] Pushing local main to origin..."
git push origin main
REMOTE="${REMOTE_USER}@${REMOTE_HOST}"
echo "[2/3] Installing and starting the quadlet units on ${REMOTE_HOST}..."
ssh "$REMOTE" bash -s <<REMOTE_EOF
set -euo pipefail
cd '${REMOTE_DIR}'
echo ' Pulling repo...'
git pull origin main
echo ' Stopping any running units (idempotent)...'
systemctl --user stop searxng.service gluetun.service searxng-valkey.service searxng-network.service 2>/dev/null || true
echo ' Removing any containers with the trio names (compose leftovers or prior run)...'
podman rm -f searxng searxng-valkey gluetun 2>/dev/null || true
echo ' Copying quadlet units into ~/.config/containers/systemd/ ...'
mkdir -p ~/.config/containers/systemd
cp ${QDIR}/*.container ${QDIR}/*.network ~/.config/containers/systemd/
echo ' daemon-reload (generates the units)...'
# Quadlet units are generator-managed: systemctl enable fails on them ("transient
# or generated"). The [Install] WantedBy=default.target in each .container file is
# itself the enablement (is-enabled reports "generated"), and with user linger on
# they start at boot. The .network unit is pulled in by the containers that set
# Wants=/After=searxng-network.service. So: daemon-reload, then start.
systemctl --user daemon-reload
echo ' Starting in dependency order...'
systemctl --user start searxng-network.service
systemctl --user start gluetun.service searxng-valkey.service
systemctl --user start searxng.service
REMOTE_EOF
echo "[3/3] Verifying unit state..."
ssh "$REMOTE" 'systemctl --user --no-pager status searxng-network.service gluetun.service searxng-valkey.service searxng.service 2>&1 | grep -E "●|Active:"' || true
echo "=== SearXNG quadlet stack deployed to ${REMOTE_HOST} ==="
shift || true
case "$TARGET" in
dankclaw) exec "$(dirname "$0")/deploy.sh" dankclaw --service searxng "$@" ;;
*)
echo "This stack runs only on dankclaw (got: ${TARGET:-<missing>})" >&2
exit 2
;;
esac
+200 -287
View File
@@ -1,302 +1,215 @@
#!/usr/bin/env bash
# deploy.sh — Deploy the serverAppConfigs repo to a target and restart its
# podman stack and LLM fleet services.
# deploy.sh — push main, pull one target, and deploy only selected services.
#
# Implements the git deployment workflow from AGENTS.md. Assumes local
# changes are committed and pushed, then:
# 1. Pushes main to origin
# 2. Pulls the repo on the target
# 3. Copies shared configs into place and restarts the podman stack
# (dankclaw only; the dankserver media stack runs as bare containers)
# 4. Relinks the LLM systemd units and restarts the active LLM service
# 5. Waits for the Caddy homepage and the LLM health endpoint
#
# Sudo: the fleet hosts allow passwordless sudo for the deploy user. For
# headless runs on a host without that, set FRANK_SUDO_PW so remote sudo
# reads the password from stdin (`sudo -S`). The password is never stored
# in this repo.
# Planning is path-aware and happens before the push. The target-side runner
# then applies only those services, with per-service deployment revisions kept
# outside the Git checkout. A docs-only commit is a no-op. An LLM service is
# restarted only when its own files (or the shared chat template) are selected.
#
# Usage:
# ./scripts/deploy.sh <target> [--dry-run]
# ./scripts/deploy.sh <dankclaw|dankserver> [--service NAME]... [--dry-run]
#
# Targets:
# dankclaw - AI/search/dev/observability stack + frank-default-large
# (CIRU ROCmFPX llama.cpp, Qwen3.8-27B, port 7770)
# dankserver - media/library stack (bare containers) + frank-default-small
# (upstream llama.cpp, Qwen3.6-35B-A3B, port 7770)
#
# Example:
# ./scripts/deploy.sh dankclaw
# ./scripts/deploy.sh dankserver --dry-run
# Without --service, the planner selects services changed since origin/main and
# the remote runner also applies pending service changes from its state store.
# --service intentionally restricts the run to the named services; it is useful
# for a safe, explicit retry after another service failed.
set -euo pipefail
# ---------------------------------------------------------------------------
# Target configuration
# ---------------------------------------------------------------------------
declare -A REMOTE_USER REMOTE_HOST REMOTE_DIR COMPOSE HEALTH_URL COMPOSE_UP COPY_SHARED
declare -A LLM_SERVICES LLM_UNITS LLM_HEALTH LLM_WAIT
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
PLANNER="$SCRIPT_DIR/deploy-plan.py"
REMOTE_USER="dank"
REMOTE_DIR="/home/dank/serverAppConfigs"
# Caddy is consolidated on dankclaw: it fronts both hosts' services. The
# dankserver media stack runs as bare containers (not this repo's compose),
# so its stack deploy is pull-only; health is checked against the shared Caddy.
REMOTE_USER[dankclaw]=dank
REMOTE_HOST[dankclaw]=dankclaw
REMOTE_DIR[dankclaw]="/home/dank/serverAppConfigs"
COMPOSE[dankclaw]="dankclaw/compose.yaml"
HEALTH_URL[dankclaw]="http://127.0.0.1/"
COMPOSE_UP[dankclaw]=1
COPY_SHARED[dankclaw]=1
# LLM: frank-default-large is the active service (restart + health wait).
# frank-default-npu is relinked only (not enabled on the host); its unit
# picks up the new paths at its next manual start.
LLM_SERVICES[dankclaw]="frank-default-large"
LLM_UNITS[dankclaw]="frank-default-large frank-default-npu"
LLM_HEALTH[dankclaw]="http://127.0.0.1:7770/health"
# CIRU llama.cpp (ROCm 7.15) loads the v3 five-file payload (GGUF + FFN +
# GDN + Output-K8 .pfs + mmproj, ~36.7 GiB) + MTP draft/Output-K8 proxy and
# initializes PromptForge views; allow 20 minutes.
LLM_WAIT[dankclaw]=600
usage() {
cat <<'EOF'
Usage: deploy.sh <dankclaw|dankserver> [options]
REMOTE_USER[dankserver]=dank
REMOTE_HOST[dankserver]=dankserver
REMOTE_DIR[dankserver]="/home/dank/serverAppConfigs"
COMPOSE[dankserver]="dankserver/compose.yaml"
HEALTH_URL[dankserver]="http://192.168.0.143/"
COMPOSE_UP[dankserver]=0
COPY_SHARED[dankserver]=0
# LLM: frank-default-small is the active service (restart + health wait).
LLM_SERVICES[dankserver]="frank-default-small"
LLM_UNITS[dankserver]="frank-default-small"
LLM_HEALTH[dankserver]="http://127.0.0.1:7770/health"
# Cold load: --no-mmap reads the 30.6 GB Q6_K_P through RAM (MoE experts on
# CPU); allow 20 minutes.
LLM_WAIT[dankserver]=600
Options:
--service NAME deploy only NAME (repeatable; bypasses automatic planning)
--dry-run show the plan without pushing or connecting to a target
-h, --help show this help
Examples:
./scripts/deploy.sh dankclaw
./scripts/deploy.sh dankclaw --service searxng
./scripts/deploy.sh dankserver --service frank-default-small
./scripts/deploy.sh dankclaw --dry-run
EOF
}
TARGET="${1:-}"
if [[ "$TARGET" == "-h" || "$TARGET" == "--help" ]]; then
usage
exit 0
fi
if [[ -z "$TARGET" ]]; then
usage >&2
exit 2
fi
shift
case "$TARGET" in
dankclaw) REMOTE_HOST="dankclaw" ;;
dankserver) REMOTE_HOST="dankserver" ;;
*)
echo "Unknown target: $TARGET" >&2
usage >&2
exit 2
;;
esac
DRY_RUN=0
if [ "${2:-}" = "--dry-run" ]; then
DRY_RUN=1
fi
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
if [ -z "${TARGET:-}" ]; then
echo "Usage: $0 <dankclaw|dankserver> [--dry-run]"
exit 1
fi
if [ -z "${REMOTE_HOST[$TARGET]:-}" ]; then
echo "Unknown target: $TARGET"
exit 1
fi
# ---------------------------------------------------------------------------
# Sudo wrapper (headless-friendly)
# ---------------------------------------------------------------------------
SUDO="sudo"
if [ -n "${FRANK_SUDO_PW:-}" ]; then
SUDO="printf '%s\n' '$FRANK_SUDO_PW' | sudo -S"
fi
# ---------------------------------------------------------------------------
# Local checks (pre-flight)
# ---------------------------------------------------------------------------
cd "$(dirname "$0")/.."
if ! git diff --quiet HEAD; then
echo "FAIL: Local working tree has uncommitted changes."
echo "Commit or stash them before deploying."
exit 1
fi
if [ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]; then
echo "WARN: Not on 'main'; deploying a non-main branch."
fi
echo "=== Deploying to ${TARGET} ==="
echo " host: ${REMOTE_HOST[$TARGET]}"
echo " dir: ${REMOTE_DIR[$TARGET]}"
echo " compose: ${COMPOSE[$TARGET]}"
echo " llm svc: ${LLM_SERVICES[$TARGET]}"
echo " llm health: ${LLM_HEALTH[$TARGET]}"
echo " homepage: ${HEALTH_URL[$TARGET]}"
echo ""
# ---------------------------------------------------------------------------
# Push (unless dry-run)
# ---------------------------------------------------------------------------
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Would push local main to origin"
echo "[dry-run] Would SSH to ${REMOTE_HOST[$TARGET]} and git pull"
else
echo "[1/5] Pushing local main to origin..."
git push origin main
fi
REMOTE="$(
printf '%s@%s' "${REMOTE_USER[$TARGET]}" "${REMOTE_HOST[$TARGET]}"
)"
# ---------------------------------------------------------------------------
# Remote: pull + copy shared configs + restart stack + restart LLM service
# ---------------------------------------------------------------------------
REMOTE_SCRIPT="
set -euo pipefail
cd '${REMOTE_DIR[$TARGET]}'
echo '[2/5] Pulling repo on ${TARGET}...'
git pull origin main
if [ -f .env ]; then
echo ' .env present, using it'
else
echo ' WARN: no .env on target; copy the .env.example and fill it in'
fi
if [ '${COPY_SHARED[$TARGET]}' = '1' ]; then
echo '[3/5] Copying shared configs into place...'
mkdir -p /opt/searxng/config /opt/prometheus/config /opt/loki/config /opt/alloy/config
cp shared/searxng/settings.yml /opt/searxng/config/settings.yml
cp shared/searxng/limiter.toml /opt/searxng/config/limiter.toml
cp shared/prometheus/prometheus.yml /opt/prometheus/config/prometheus.yml
cp shared/loki/loki-config.yaml /opt/loki/config/loki-config.yaml
cp shared/alloy/config.alloy /opt/alloy/config/config.alloy
echo ' Rendering Caddyfile (basicauth hash from .env)...'
# Extract the hash with grep/cut rather than sourcing .env: sourcing a
# bcrypt value in a set -u shell dies on its dollar-sign expansion.
# '|| true' so a missing hash warns below instead of aborting under set -e.
# Only the shared family hash remains: the homepage's per-user hashes were
# retired in favor of Authentik forward_auth (the /admin/ page and the user
# page are now behind the same SSO session).
CADDY_BASICAUTH_HASH=\"\$(grep -E '^CADDY_BASICAUTH_HASH=' .env 2>/dev/null | tail -1 | cut -d= -f2- || true)\"
if [ -n \"\${CADDY_BASICAUTH_HASH}\" ]; then
# '|' delimiter: the bcrypt hash contains '/' characters.
mkdir -p /home/dank/Containers/caddy
sed -e \"s|REPLACE_WITH_HASH|\${CADDY_BASICAUTH_HASH}|g\" \\
dankclaw/Caddyfile > /home/dank/Containers/caddy/Caddyfile
else
echo ' WARN: CADDY_BASICAUTH_HASH is not set in .env; keeping the placeholder(s)'
fi
echo ' Deploying the homepage...'
${SUDO} mkdir -p /srv/homepage
${SUDO} cp shared/homepage/index.html shared/homepage/admin.html shared/homepage/app.js \\
shared/homepage/style.css shared/homepage/services.json /srv/homepage/
if [ -d shared/homepage/fonts ]; then ${SUDO} cp -r shared/homepage/fonts /srv/homepage/; fi
# The last master deployment datetime (Q43): the deployed commit's datetime.
DEPLOYED_AT=\"\$(git log -1 --format=%cI)\"
printf 'window.DEPLOYED_AT = \"%s\";\\n' \"\$DEPLOYED_AT\" > /tmp/deployed.js
${SUDO} cp /tmp/deployed.js /srv/homepage/deployed.js
${SUDO} chmod -R a+rX /srv/homepage
# The status checker: install the timer + service, run it once, enable it.
${SUDO} cp scripts/homepage-status.py /usr/local/bin/homepage-status.py
${SUDO} cp scripts/homepage-status.service scripts/homepage-status.timer /etc/systemd/system/
${SUDO} systemctl daemon-reload
${SUDO} systemctl enable homepage-status.timer
${SUDO} systemctl restart homepage-status.timer
${SUDO} python3 /usr/local/bin/homepage-status.py /srv/homepage/services.json /srv/homepage/status.json
fi
if [ '${COMPOSE_UP[$TARGET]}' = '1' ]; then
echo '[4/5] Restarting stack...'
# Locally built images: caddy + porkbun module, and loki/alloy + a static
# busybox (their upstream images lack the wget the healthchecks need).
for dir in caddy loki alloy; do
img=franksdank/\$dir
[ \"\$dir\" = caddy ] && img=franksdank/caddy-porkbun
# Rebuild when the Containerfile changed. buildah's layer cache can
# serve a stale COPY --from layer (keyed on stage name), so a rebuild
# always runs with --no-cache.
cfhash=\$(sha256sum dankclaw/\$dir/Containerfile | cut -d' ' -f1)
oldhash=\$(podman image inspect \$img:latest --format '{{index .Config.Labels \"cfhash\"}}' 2>/dev/null || echo none)
if [ \"\$oldhash\" != \"\$cfhash\" ]; then
echo \" \$img image missing or stale; building it...\"
podman build --no-cache --label cfhash=\$cfhash -t \$img:latest -f dankclaw/\$dir/Containerfile .
fi
done
podman-compose -f '${COMPOSE[$TARGET]}' up -d
# A Caddyfile render is a bind-mount content change, not a container
# config change: the caddy container often survives 'up -d' and keeps
# serving its in-memory config. Reload explicitly; the file watcher
# (admin on) is the backstop if the API is not up yet. If the reload
# cannot be applied at all (e.g. the running config has the admin API
# disabled -- the fix cannot ride the API it is meant to enable),
# restart the container so it reads the file on start.
caddy_reloaded=0
for i in 1 2 3 4 5; do
if podman exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1; then
caddy_reloaded=1
echo \" Caddy reloaded.\"
break
fi
sleep 3
done
if [ \"\$caddy_reloaded\" -ne 1 ]; then
echo \" Caddy reload failed; restarting the container to load the file.\"
podman restart caddy
fi
else
echo '[4/5] Skipping compose restart (stack runs as bare containers on this host)'
fi
echo '[4/5] Relinking LLM units and restarting LLM services...'
for unit in ${LLM_UNITS[$TARGET]}; do
# The loop variable must expand on the remote, so it is escaped here and
# the path is unquoted (it has no spaces). Quoting the source path would
# ship a literal loop-variable token and create a dangling symlink
# (the 2026-08-22 relink bug).
${SUDO} ln -sf ${REMOTE_DIR[$TARGET]}/scripts/\${unit}.service /etc/systemd/system/\${unit}.service
done
${SUDO} systemctl daemon-reload
for svc in ${LLM_SERVICES[$TARGET]}; do
${SUDO} systemctl enable \"\${svc}\"
${SUDO} systemctl restart \"\${svc}\"
echo \" restarted \${svc}\"
done
"
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Remote commands would run:"
echo "$REMOTE_SCRIPT"
else
# shellcheck disable=SC2087
ssh "$REMOTE" bash -s <<REMOTE_EOF
${REMOTE_SCRIPT}
REMOTE_EOF
fi
# ---------------------------------------------------------------------------
# Health checks: homepage, then the LLM endpoint
# ---------------------------------------------------------------------------
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] Would wait for health at ${HEALTH_URL[$TARGET]} and ${LLM_HEALTH[$TARGET]}"
else
echo "[5/5] Waiting for homepage ${HEALTH_URL[$TARGET]} (via ssh)..."
HEALTH="${HEALTH_URL[$TARGET]}"
for i in $(seq 1 60); do
if ssh "$REMOTE" "curl -sf '$HEALTH'" > /dev/null 2>&1; then
echo " Homepage healthy after approx $((i * 2))s"
break
fi
sleep 2
done
if ! ssh "$REMOTE" "curl -sf '$HEALTH'" > /dev/null 2>&1; then
echo "FAIL: Homepage did not become healthy in 120s"
exit 1
fi
echo "[5/5] Waiting for LLM health ${LLM_HEALTH[$TARGET]} (via ssh)..."
LLM_URL="${LLM_HEALTH[$TARGET]}"
LLM_ITERS="${LLM_WAIT[$TARGET]}"
for i in $(seq 1 "$LLM_ITERS"); do
if ssh "$REMOTE" "curl -sf '$LLM_URL'" > /dev/null 2>&1; then
echo " LLM healthy after approx $((i * 2))s"
echo ""
echo "=== Deploy complete: ${TARGET} ==="
EXPLICIT_SERVICES=()
while [[ "$#" -gt 0 ]]; do
case "$1" in
--dry-run)
DRY_RUN=1
shift
;;
--service)
[[ "$#" -ge 2 ]] || { echo "--service requires a name" >&2; exit 2; }
EXPLICIT_SERVICES+=("$2")
shift 2
;;
-h|--help)
usage
exit 0
fi
sleep 2
done
echo "FAIL: LLM service did not become healthy in $((LLM_ITERS * 2))s"
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
cd "$REPO_ROOT"
if [[ ! -x "$PLANNER" ]]; then
echo "FAIL: missing executable planner: $PLANNER" >&2
exit 1
fi
# A deployment is a release operation, not a way to publish an arbitrary
# worktree. Include untracked files in this guard; the old diff-only check let
# an untracked secret or half-written config pass the pre-flight.
if [[ -n "$(git status --porcelain)" ]]; then
echo "FAIL: Local working tree has uncommitted changes." >&2
echo "Commit or remove them before deploying." >&2
exit 1
fi
if [[ "$(git branch --show-current)" != "main" ]]; then
echo "FAIL: deploy.sh must run from the main branch." >&2
exit 1
fi
HEAD_REVISION="$(git rev-parse HEAD)"
if BASE_REVISION="$(git rev-parse origin/main 2>/dev/null)"; then
:
else
BASE_REVISION="$(git rev-parse HEAD^ 2>/dev/null || git rev-parse HEAD)"
fi
valid_service() {
local service="$1"
case "$TARGET:$service" in
dankclaw:gluetun|dankclaw:searxng-network|dankclaw:searxng-valkey|dankclaw:searxng|\
dankclaw:pihole|dankclaw:open-webui|dankclaw:gitea-postgres|dankclaw:gitea-server|\
dankclaw:gitea-caddy|dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\
dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
dankclaw:authentik-worker|dankclaw:caddy|dankclaw:homepage|\
dankclaw:frank-default-large|dankclaw:frank-default-npu|\
dankserver:mariadb|dankserver:redis|dankserver:romm|dankserver:copyparty|\
dankserver:jellyfin|dankserver:calibre-web|dankserver:prowlarr|\
dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\
dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|\
dankserver:frank-default-small)
return 0
;;
*)
return 1
;;
esac
}
for service in "${EXPLICIT_SERVICES[@]}"; do
if ! valid_service "$service"; then
echo "Service '$service' is not deployed on $TARGET." >&2
exit 2
fi
done
PLAN=()
if [[ "${#EXPLICIT_SERVICES[@]}" -gt 0 ]]; then
PLAN=("${EXPLICIT_SERVICES[@]}")
else
mapfile -t PLAN < <(
python3 "$PLANNER" --target "$TARGET" --repo "$REPO_ROOT" \
--base "$BASE_REVISION" --head "$HEAD_REVISION"
)
fi
# Preserve planner order while removing duplicate explicit service names.
UNIQUE_PLAN=()
declare -A SEEN=()
for service in "${PLAN[@]}"; do
[[ -n "$service" ]] || continue
if [[ -z "${SEEN[$service]:-}" ]]; then
UNIQUE_PLAN+=("$service")
SEEN[$service]=1
fi
done
PLAN=("${UNIQUE_PLAN[@]}")
printf '=== Deployment plan: %s ===\n' "$TARGET"
printf ' checkout: %s\n' "$HEAD_REVISION"
if [[ "${#PLAN[@]}" -eq 0 ]]; then
echo " changed services: (none; the remote will check pending service state)"
else
printf ' changed services:\n'
printf ' - %s\n' "${PLAN[@]}"
fi
REMOTE="${REMOTE_USER}@${REMOTE_HOST}"
REMOTE_ARGS=("$TARGET" "$HEAD_REVISION")
if [[ "${#EXPLICIT_SERVICES[@]}" -eq 0 ]]; then
REMOTE_ARGS+=(--pending)
fi
REMOTE_ARGS+=("${PLAN[@]}")
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "[dry-run] Would push main to origin."
echo "[dry-run] Would SSH to $REMOTE, pull with --ff-only, and run the service-scoped runner."
printf '[dry-run] Runner arguments:'
printf ' %q' "${REMOTE_ARGS[@]}"
printf '\n'
exit 0
fi
echo "[1/2] Pushing main to origin..."
git push origin main
echo "[2/2] Pulling and deploying on $REMOTE_HOST..."
# The target runner computes any pending revisions from its own state after
# the pull. This keeps a target that missed an earlier service deployment
# recoverable without restarting services that are already current.
ssh "$REMOTE" bash -s -- "${REMOTE_ARGS[@]}" <<'REMOTE_EOF'
set -euo pipefail
TARGET="$1"
EXPECTED_REVISION="$2"
shift 2
cd "/home/dank/serverAppConfigs"
OLD_REVISION="$(git rev-parse HEAD)"
echo " pulling origin/main..."
git pull --ff-only origin main
NEW_REVISION="$(git rev-parse HEAD)"
if [[ "$NEW_REVISION" != "$EXPECTED_REVISION" ]]; then
echo "FAIL: target checkout is $NEW_REVISION, expected $EXPECTED_REVISION" >&2
exit 1
fi
exec ./scripts/deploy-remote.sh "$TARGET" "$NEW_REVISION" "$@" \
--from-revision "$OLD_REVISION"
REMOTE_EOF
echo "=== Deploy complete: $TARGET ==="
+20 -16
View File
@@ -1,4 +1,4 @@
# Tunables for frank-default-large (kingjones Qwen3.8-27B ROCmFPX Q8 AGENT,
# Tunables for frank-default-large (kingjones Qwen3.8 Uncensored Q6 AGENT,
# dankclaw, Strix Halo gfx1151).
#
# Runtime: ROCmFPX llama.cpp with TurboQuant support. Serves the kingjones
@@ -9,18 +9,22 @@
# HIP runtime reads those bare env vars (see the 2026-08-13 TEMP incident).
# llama.cpp reads --host/--port/--temp as CLI flags, never as env vars.
# ROCmFPX build with TurboQuant KV-cache support.
LLAMA_BIN=/home/dank/ciru-qwen38/ROCmFPX/build-kairic/bin/llama-server
# PromptForge ROCmFPX build and its historical ROCm 7.15 SDK environment.
LLAMA_BIN=/home/dank/ciru-qwen38/ROCmFPX/build-promptforge/bin/llama-server
ROCM_DEVEL=/home/dank/ling3-ciru/Ling-3.0-Flash-CIRU-int4-Strix-native/.venv/lib/python3.12/site-packages/_rocm_sdk_devel
ROCM_LIB=/home/dank/ling3-ciru/Ling-3.0-Flash-CIRU-int4-Strix-native/.venv/lib/python3.12/site-packages/_rocm_sdk_libraries
ROCM_CORE=/home/dank/ling3-ciru/Ling-3.0-Flash-CIRU-int4-Strix-native/.venv/lib/python3.12/site-packages/_rocm_sdk_core
# Model files from https://huggingface.co/kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF.
MODEL_DIR=/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT
MODEL_FILE=Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf
# Model files from https://huggingface.co/kingjones777/Qwen3.8-27B-Uncensored-ROCmFP4-STRIX-MTP-GGUF.
MODEL_DIR=/home/dank/models/kingjones-Qwen3.8-27B-STRIX-MTP
MODEL_FILE=Qwen3.8-27B-Uncensored-Q6_0_ROCMFPX_AGENT.gguf
MTP_DRAFT_FILE=$MODEL_DIR/mtp-Qwen3.8-27B-Q4_0.gguf
MMPROJ_FILE=mmproj-Qwen3.8-27B-BF16.gguf
MMPROJ_FILE=mmproj-Qwen3.8-27B-Q8_0.gguf
# Qwen-Sharp chat template (tracked in the repo, staged on the host). The Sharp
# template drives thinking on by default (medium effort) and produces the
# peg-native + deepseek chat/reasoning shape the Pi agent expects.
# template drives thinking on by default (medium effort); the server emits
# reasoning in DeepSeek format so clients receive reasoning_content separately
# instead of literal <think> tags in content.
CHAT_TEMPLATE_FILE=/home/dank/serverAppConfigs/scripts/chat-template.jinja
CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true,"preserve_reasoning":true}'
@@ -37,13 +41,13 @@ LISTEN_PORT=7770
CTX_SIZE=262144
PARALLEL=1
# Batch/ubatch follow the existing qualified Dankclaw profile.
BATCH_SIZE=512
UBATCH_SIZE=512
BATCH_SIZE=4096
UBATCH_SIZE=4096
NUM_THREADS=16
THREAD_BATCH=32
CACHE_TYPE_K=f16
CACHE_TYPE_V=turbo4
CTX_CHECKPOINTS=32
CACHE_TYPE_V=f16
CTX_CHECKPOINTS=16
CACHE_RAM=8192
# Standalone MTP draft head, loaded from MTP_DRAFT_FILE by the serve script.
@@ -60,10 +64,10 @@ MIN_P=0.0
PRESENCE_PENALTY=1.5
# Reasoning on (Sharp template drives it; preserve_thinking + preserve_reasoning
# keep the reasoning trail in the conversation). reasoning-format none lets the
# template project the reasoning content into reasoning_content.
# keep the reasoning trail in the conversation). DeepSeek format projects the
# reasoning into the API's reasoning_content field instead of content tags.
REASONING=on
REASONING_FORMAT=none
REASONING_FORMAT=deepseek
# Strix Halo unified memory + native gfx1151 detection for this ROCmFPX build.
HSA_OVERRIDE_GFX_VERSION=11.5.1
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Unlock and mount Dankserver's encrypted entropy volume.
#
# The desktop flow uses the system's crypttab and fstab entries. Reuse those
# entries here instead of embedding a passphrase, mount options, or ownership
# changes in this helper. udisksctl reads the passphrase through its secure
# desktop/terminal authentication path.
set -euo pipefail
MOUNTPOINT='/mnt/entropy'
MAPPER='/dev/mapper/luks-cd86c728-459e-4f3a-bcc2-41ecf84685ec'
if mountpoint -q "$MOUNTPOINT"; then
printf '%s is already mounted.\n' "$MOUNTPOINT"
exit 0
fi
if [ ! -e /dev/md0 ]; then
printf 'ERROR: RAID device /dev/md0 is not available.\n' >&2
exit 1
fi
if [ ! -e "$MAPPER" ]; then
udisksctl unlock --block-device /dev/md0
fi
# udisks2 may mount automatically as part of unlocking. Do not attempt a
# second mount in that case.
if ! mountpoint -q "$MOUNTPOINT"; then
sudo mount "$MOUNTPOINT"
fi
printf 'Mounted %s.\n' "$MOUNTPOINT"
+17 -13
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Serve the kingjones Qwen3.8-27B ROCmFPX Q8 AGENT model as frank-default-large.
# The deployment uses a standalone MTP draft head and Turbo4 V-side KV cache.
# Serve the kingjones Qwen3.8 Uncensored Q6 AGENT model as frank-default-large.
# The deployment uses a standalone MTP draft head and f16 KV cache.
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
@@ -12,23 +12,23 @@ if [[ -f "$ENV_FILE" ]]; then
set +a
fi
: "${LLAMA_BIN:=/home/dank/ciru-qwen38/ROCmFPX/build-kairic/bin/llama-server}"
: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT}"
: "${MODEL_FILE:=Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf}"
: "${LLAMA_BIN:=/home/dank/ciru-qwen38/ROCmFPX/build-promptforge/bin/llama-server}"
: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-27B-STRIX-MTP}"
: "${MODEL_FILE:=Qwen3.8-27B-Uncensored-Q6_0_ROCMFPX_AGENT.gguf}"
: "${MTP_DRAFT_FILE:=$MODEL_DIR/mtp-Qwen3.8-27B-Q4_0.gguf}"
: "${MMPROJ_FILE:=mmproj-Qwen3.8-27B-BF16.gguf}"
: "${MMPROJ_FILE:=mmproj-Qwen3.8-27B-Q8_0.gguf}"
: "${ALIAS:=frank-default-large}"
: "${BIND_ADDR:=0.0.0.0}"
: "${LISTEN_PORT:=7770}"
: "${CTX_SIZE:=262144}"
: "${PARALLEL:=1}"
: "${BATCH_SIZE:=2048}"
: "${UBATCH_SIZE:=512}"
: "${BATCH_SIZE:=4096}"
: "${UBATCH_SIZE:=4096}"
: "${NUM_THREADS:=16}"
: "${THREAD_BATCH:=32}"
: "${CACHE_TYPE_K:=f16}"
: "${CACHE_TYPE_V:=turbo4}"
: "${CTX_CHECKPOINTS:=32}"
: "${CACHE_TYPE_V:=f16}"
: "${CTX_CHECKPOINTS:=16}"
: "${CACHE_RAM:=8192}"
: "${MTP_N_MAX:=4}"
: "${MTP_N_MIN:=0}"
@@ -55,14 +55,18 @@ if [[ ! -x "$LLAMA_BIN" ]]; then
exit 1
fi
SERVER_DIR="$(cd -- "$(dirname -- "$LLAMA_BIN")" && pwd)"
ROCM_PATH="${ROCM_PATH:-/opt/rocm}"
LIBRARY_PATH="$SERVER_DIR:$ROCM_PATH/lib:$ROCM_PATH/lib/rocm_sysdeps/lib:$ROCM_PATH/lib/llvm/lib:$ROCM_PATH/llvm/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
ROCM_PATH="${ROCM_DEVEL:-${ROCM_PATH:-/opt/rocm}}"
if [[ -n "${ROCM_LIB:-}" && -n "${ROCM_CORE:-}" ]]; then
LIBRARY_PATH="$ROCM_LIB/lib:$ROCM_CORE/lib:$ROCM_DEVEL/lib/llvm/lib:$SERVER_DIR:${LD_LIBRARY_PATH:-}"
else
LIBRARY_PATH="$SERVER_DIR:$ROCM_PATH/lib:$ROCM_PATH/lib/rocm_sysdeps/lib:$ROCM_PATH/lib/llvm/lib:$ROCM_PATH/llvm/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
fi
exec /usr/bin/env \
ROCM_PATH="$ROCM_PATH" \
HIP_VISIBLE_DEVICES=0 \
HSA_OVERRIDE_GFX_VERSION="$HSA_OVERRIDE_GFX_VERSION" \
LD_LIBRARY_PATH="$LIBRARY_PATH" \
HSA_OVERRIDE_GFX_VERSION="$HSA_OVERRIDE_GFX_VERSION" \
GGML_CUDA_GRAPH_OPT=0 \
LLAMA_TARGET_GREEDY_ARGMAX_FASTPATH=0 \
LLAMA_MTP_CPU_ARGMAX_FASTPATH=1 \
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# Run only the Bats tests affected by the staged paths.
#
# Usage:
# scripts/test-changed.sh # inspect the staged commit
# scripts/test-changed.sh --list # print selected test files
# scripts/test-changed.sh --paths FILE ...
#
# --paths is intended for tests and local experimentation. The normal mode is
# deliberately staged-only so a pre-commit hook cannot accidentally validate
# an unrelated dirty working-tree file.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LIST_ONLY=0
USE_PATHS=0
declare -a CHANGED=()
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 2
}
while [ "$#" -gt 0 ]; do
case "$1" in
--list)
LIST_ONLY=1
shift
;;
--paths)
USE_PATHS=1
shift
while [ "$#" -gt 0 ] && [[ "$1" != --* ]]; do
CHANGED+=("$1")
shift
done
;;
--)
shift
CHANGED+=("$@")
break
;;
*)
die "unknown option '$1' (use --list or --paths FILE ...)"
;;
esac
done
cd "$REPO_ROOT"
if [ "$USE_PATHS" -eq 0 ]; then
while IFS= read -r -d '' path; do
CHANGED+=("$path")
done < <(git diff --cached --name-only -z --diff-filter=ACMRTUXB)
fi
[ "${#CHANGED[@]}" -gt 0 ] || {
printf 'No staged paths; no focused tests to run.\n'
exit 0
}
declare -a TESTS=()
add_test() {
local test="$1"
local existing
for existing in "${TESTS[@]:-}"; do
[ "$existing" != "$test" ] || return 0
done
TESTS+=("$test")
}
for path in "${CHANGED[@]}"; do
case "$path" in
tests/*.bats)
add_test "$path"
;;
tests/helpers.bash|tests/run.sh)
die "$path is shared test infrastructure; run ./tests/run.sh explicitly"
;;
.githooks/*)
add_test tests/changed_tests.bats
;;
dankserver/compose.yaml|dankclaw/compose.yaml)
add_test tests/compose_validity.bats
add_test tests/env_consistency.bats
add_test tests/deployment_scoping.bats
;;
dankserver/Caddyfile|dankclaw/Caddyfile)
add_test tests/caddyfile.bats
add_test tests/deployment_scoping.bats
;;
dankserver/.env.example|dankclaw/.env.example)
add_test tests/env_consistency.bats
;;
shared/homepage/*)
add_test tests/homepage_caddy.bats
add_test tests/homepage_config.bats
add_test tests/homepage_deploy.bats
add_test tests/homepage_pages.bats
add_test tests/homepage_checker.bats
add_test tests/deployment_scoping.bats
;;
shared/searxng/*)
add_test tests/searxng_limiter.bats
add_test tests/searxng_quadlet.bats
add_test tests/deployment_scoping.bats
;;
dankclaw/*.container|dankclaw/*.network)
add_test tests/open_webui_quadlet.bats
add_test tests/pihole.bats
add_test tests/searxng_quadlet.bats
add_test tests/deployment_scoping.bats
;;
scripts/deploy.sh|scripts/deploy-remote.sh|scripts/deploy-plan.py|scripts/deploy-*-quadlet.sh)
add_test tests/deployment_flow.bats
add_test tests/deployment_scoping.bats
add_test tests/scripts_syntax.bats
;;
scripts/mount-entropy.sh)
add_test tests/mount_entropy.bats
;;
scripts/frank-default-large.env|scripts/serve-frank-large.sh)
add_test tests/frank_large_config.bats
add_test tests/llm_secrets.bats
add_test tests/scripts_syntax.bats
;;
scripts/frank-default-small.env|scripts/serve-frank-small.sh)
add_test tests/frank_small_config.bats
add_test tests/llm_secrets.bats
add_test tests/scripts_syntax.bats
;;
scripts/*.service)
add_test tests/llm_units.bats
add_test tests/scripts_syntax.bats
add_test tests/deployment_scoping.bats
;;
scripts/*)
add_test tests/scripts_syntax.bats
;;
docs/*)
add_test tests/docs_completeness.bats
add_test tests/deployment_scoping.bats
;;
dankclaw/hermes/*)
add_test tests/docs_completeness.bats
;;
README.md|AGENTS.md)
# Project guidance changes can affect the documented deployment
# contract; validate the docs vault and scoped deployment seams.
add_test tests/docs_completeness.bats
add_test tests/deployment_scoping.bats
;;
*)
die "no focused-test mapping for '$path'; add one before committing"
;;
esac
done
if [ "$LIST_ONLY" -eq 1 ]; then
printf '%s\n' "${TESTS[@]}"
exit 0
fi
command -v bats >/dev/null 2>&1 || die 'bats is not installed'
printf 'Running focused tests for %d changed path(s):\n' "${#CHANGED[@]}"
printf ' %s\n' "${TESTS[@]}"
bats --print-output-on-failure "${TESTS[@]}"
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env bash
#
# tmux-resurrect.sh — snapshot and restore the exact layout of a tmux session
# on Dankclaw, including each pane's start directory and foreground command.
#
# Use it after a reboot (or when the display session is gone) to reopen the
# same windows/panes on the Dankclaw console:
#
# scripts/tmux-resurrect.sh snapshot # record the live session to JSON
# scripts/tmux-resurrect.sh restore # rebuild it, then attach
#
# Data file: ~/.tmux-resurrect/<session>.json (set TMUX_RES_DIR to relocate).
# Re-run snapshot as the session changes so the restore stays faithful.
#
# How each pane's command is captured: a tmux pane runs one main shell
# (#{pane_pid}). The foreground command is that shell's direct child — chosen
# as the child that launched its own child (ssh, sudo ...) or the first child
# when none did (btop sitting at the top of a pane). An idle shell yields no
# command and is left at its prompt on restore.
set -euo pipefail
TMUX_RES_DIR="${TMUX_RES_DIR:-$HOME/.tmux-resurrect}"
DEFAULT_SESSION="0"
die() { printf 'tmux-resurrect: %s\n' "$*" >&2; exit 1; }
b64() { base64 -w0; }
b64d() { base64 -d; }
# fg_command <shell_pid>: print the foreground command of a tmux pane, or the
# empty string when the shell is idle.
fg_command() {
local pid="$1" kids=() kid cmd_pid=""
mapfile -t kids < <(pgrep -P "$pid" 2>/dev/null || true)
if [ "${#kids[@]}" -eq 0 ]; then return 0; fi
# Prefer a child that launched its own child (a launcher such as ssh or
# sudo); fall back to the first direct child (e.g. btop at the top).
for kid in "${kids[@]}"; do
if pgrep -P "$kid" >/dev/null 2>&1; then
cmd_pid="$kid"; break
fi
done
if [ -z "$cmd_pid" ]; then cmd_pid="${kids[0]}"; fi
tr '\0' ' ' < "/proc/$cmd_pid/cmdline" 2>/dev/null | sed -E 's/[[:space:]]+$//'
}
# snapshot <session> [dest.json]
snapshot() {
local session="${1:-$DEFAULT_SESSION}" dest="${2:-}"
local exists
exists=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -Fxq "$session" && echo 1 || echo 0)
[ "$exists" = 1 ] || die "no tmux session named '$session'"
mkdir -p "$TMUX_RES_DIR"
[ -z "$dest" ] && dest="$TMUX_RES_DIR/$session.json"
python3 - "$dest" "$session" 3< <(
{
while IFS=$'\t' read -r widx wname wlayout; do
printf 'W\t%s\t%s\t%s\n' "$widx" "$(printf '%s' "$wname" | b64)" "$(printf '%s' "$wlayout" | b64)"
while IFS=$'\t' read -r pidx ppid pdir; do
pcmd=$(fg_command "$ppid")
printf 'P\t%s\t%s\t%s\t%s\n' "$widx" "$pidx" \
"$(printf '%s' "$pdir" | b64)" "$(printf '%s' "$pcmd" | b64)"
done < <(tmux list-panes -t "$session:$widx" \
-F $'#{pane_index}\t#{pane_pid}\t#{pane_current_path}' | sort -n -k1,1)
done < <(tmux list-windows -t "$session" \
-F $'#{window_index}\t#{window_name}\t#{window_layout}' | sort -n -k1,1)
}
) > /dev/null <<'PY'
import sys, json, base64, os
dest, session = sys.argv[1], sys.argv[2]
wmap, panes = {}, {}
for line in os.fdopen(3).read().splitlines():
if not line:
continue
p = line.split('\t')
if p[0] == 'W':
wmap[int(p[1])] = {"name": base64.b64decode(p[2]).decode(),
"layout": base64.b64decode(p[3]).decode()}
elif p[0] == 'P':
widx = int(p[1])
panes.setdefault(widx, []).append({
"index": int(p[2]),
"dir": base64.b64decode(p[3]).decode(),
"cmd": base64.b64decode(p[4]).decode(),
})
windows = []
for widx in sorted(wmap):
windows.append({
"index": widx,
"name": wmap[widx]["name"],
"layout": wmap[widx]["layout"],
"panes": sorted(panes.get(widx, []), key=lambda x: x["index"]),
})
with open(dest, "w") as f:
json.dump({"session": session, "windows": windows}, f, indent=2, ensure_ascii=False)
f.write("\n")
PY
printf 'snapshot: wrote %s (session %s)\n' "$dest" "$session"
}
# emit_plan <src.json> [session]: python reads the JSON and prints a base64
# TSV plan (W/P records) for the bash orchestrator to consume.
emit_plan() {
python3 - "$1" "$2" <<'PY'
import sys, json, base64
src, session = sys.argv[1], sys.argv[2]
with open(src) as f:
data = json.load(f)
wins = data.get("windows", [])
if session:
wins = [w for w in wins if str(w.get("index")) == "0"]
def b64(s): return base64.b64encode(s.encode()).decode()
for w in wins:
print('W\t%s\t%s\t%s' % (w["index"], b64(w["name"]), b64(w["layout"])))
for pan in w["panes"]:
print('P\t%s\t%s\t%s\t%s' % (w["index"], pan["index"],
b64(pan["dir"]), b64(pan["cmd"])))
PY
}
# restore [session] [--no-attach] [--dry-run]
restore() {
local session="${1:-$DEFAULT_SESSION}"
shift || true
local keep=0 noattach=0 dryrun=0 a
for a in "$@"; do
case "$a" in
--keep) keep=1 ;;
--no-attach) noattach=1 ;;
--dry-run) dryrun=1 ;;
*) die "unknown restore option: $a" ;;
esac
done
local dest="$TMUX_RES_DIR/$session.json"
[ -f "$dest" ] || die "no snapshot at $dest (run snapshot first)"
# Restore wrapper: run tmux, or print the command under --dry-run.
tmuxr() {
if [ "$dryrun" = 1 ]; then printf ' tmux %s\n' "$*"; else tmux "$@"; fi
}
local target_exists
target_exists=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -Fxq "$session" && echo 1 || echo 0)
if [ "$target_exists" = 1 ]; then
if [ "$keep" = 1 ]; then
die "session '$session' already exists; rebuild without --keep or save a copy"
fi
if [ "$dryrun" = 1 ]; then printf ' # kill existing session %s\n' "$session"
else tmux kill-session --force "$session"; fi
fi
# Parse the plan into flat, window-major lists.
local -a WNAME=() WLAY=() WSTART=() PD=() PC=()
local tag idx nameb layoutb dirb cmdb
while IFS=$'\t' read -r tag idx nameb layoutb dirb cmdb; do
case "$tag" in
W) WNAME+=("$(printf '%s' "$nameb" | b64d)"); WLAY+=("$(printf '%s' "$layoutb" | b64d)"); WSTART+=("${#PD[@]}") ;;
P) PD+=("$(printf '%s' "$dirb" | b64d)"); PC+=("$(printf '%s' "$cmdb" | b64d)") ;;
esac
done < <(emit_plan "$dest" "")
local n=${#WNAME[@]} w first last npanes i curwin wname wlayout dir cmd
for ((w = 0; w < n; w++)); do
first=${WSTART[w]}
if (( w + 1 < n )); then last=${WSTART[w + 1]}; else last=${#PD[@]}; fi
npanes=$(( last - first ))
wname="${WNAME[w]}"; wlayout="${WLAY[w]}"
if [ "$dryrun" = 1 ]; then
if (( w == 0 )); then
tmuxr new-session -d -s "$session" -n "$wname" -c "${PD[first]}"
else
tmuxr new-window -d -t "$session" -n "$wname" -c "${PD[first]}"
fi
curwin="$session:$wname"
elif (( w == 0 )); then
curwin=$(tmux new-session -d -P -F '#{window_id}' \
-s "$session" -n "$wname" -c "${PD[first]}")
else
curwin=$(tmux new-window -d -P -F '#{window_id}' \
-t "$session" -n "$wname" -c "${PD[first]}")
fi
# Create the remaining panes in recorded order (index == creation order).
for ((i = first + 1; i < last; i++)); do
tmuxr split-window -t "$curwin" -c "${PD[i]}"
done
# Restore the exact geometry for this window.
tmuxr select-layout -t "$curwin" "$wlayout"
# Launch each pane's recorded command, matched to panes by ascending
# pane index (so this is correct regardless of base-index).
local -a ordered=()
mapfile -t ordered < <(tmux list-panes -t "$curwin" \
-F $'#{pane_id}\t#{pane_target}' | sort -n -k1,1)
local k=0 tgt
for tgt in "${ordered[@]}"; do
cmd="${PC[first + k]}"
if [ -n "$cmd" ]; then tmuxr send-keys -t "$tgt" "$cmd" Enter; fi
k=$((k + 1))
done
done
if [ "$noattach" = 1 ]; then
return 0
elif [ -t 0 ]; then
tmuxr attach -t "$session"
elif [ "$dryrun" = 0 ]; then
echo "session '$session' rebuilt; attach with: tmux attach -t $session"
fi
}
usage() {
cat <<'USAGE'
tmux-resurrect.sh — snapshot & restore a tmux session on Dankclaw.
Usage:
tmux-resurrect.sh snapshot [session] [dest.json] record the live session
tmux-resurrect.sh restore [session] [options] rebuild the session
options:
--keep skip if the session already exists (default: rebuild)
--no-attach rebuild without attaching to this terminal
--dry-run print the commands instead of running them
USAGE
}
main() {
local cmd="${1:-snapshot}"
shift || true
case "$cmd" in
snapshot) snapshot "$@" ;;
restore) restore "$@" ;;
-h|--help|help) usage ;;
*) die "unknown command: $cmd (use snapshot|restore)" ;;
esac
}
main "$@"
+83 -3
View File
@@ -21,13 +21,93 @@ valkey:
url: "valkey://searxng-valkey:6379/0"
outgoing:
request_timeout: 3.0
max_request_timeout: 10.0
request_timeout: 5.0
max_request_timeout: 15.0
pool_connections: 100
pool_maxsize: 20
enable_http2: true
keepalive_expiry: 5.0
retries: 3
retries: 1
# Keep default aggregation anonymous and bounded. Disabled engines remain
# available for explicit selection/on-demand use; inactive engines are removed.
# Provider-specific overrides below also reduce pagination fan-out.
engines:
- name: bing
disabled: false
weight: 2
paging: false
timeout: 5.0
- name: bing images
paging: false
timeout: 5.0
- name: bing news
paging: false
timeout: 5.0
- name: bing videos
paging: false
timeout: 5.0
- name: duckduckgo
disabled: false
weight: 2
paging: false
timeout: 5.0
- name: duckduckgo images
paging: false
timeout: 5.0
- name: duckduckgo news
paging: false
timeout: 5.0
- name: duckduckgo videos
paging: false
timeout: 5.0
- name: wikipedia
weight: 1
timeout: 5.0
- name: google cse
disabled: true
paging: false
timeout: 5.0
- name: google cse images
disabled: true
paging: false
timeout: 5.0
- name: google news
disabled: true
paging: false
timeout: 5.0
- name: google scholar
disabled: true
paging: false
timeout: 5.0
- name: brave
disabled: true
paging: false
timeout: 5.0
- name: brave.images
disabled: true
paging: false
timeout: 5.0
- name: brave.news
disabled: true
paging: false
timeout: 5.0
- name: brave.videos
disabled: true
paging: false
timeout: 5.0
- name: startpage
disabled: true
paging: false
timeout: 5.0
- name: startpage images
disabled: true
paging: false
timeout: 5.0
- name: startpage news
disabled: true
paging: false
timeout: 5.0
plugins:
searx.plugins.unit_converter.SXNGPlugin:
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bats
# Seam: the changed-test wrapper selects only the tests associated with
# staged paths and refuses to silently run the entire suite.
load helpers
@test "changed-test wrapper is executable and uses staged diff by default" {
[ -x "$REPO_ROOT/scripts/test-changed.sh" ]
[ -x "$REPO_ROOT/.githooks/pre-commit" ]
run grep -F 'scripts/test-changed.sh' "$REPO_ROOT/.githooks/pre-commit"
[ "$status" -eq 0 ]
run grep -F 'git diff --cached' "$REPO_ROOT/scripts/test-changed.sh"
[ "$status" -eq 0 ]
}
@test "changed-test wrapper maps a compose change to compose and environment tests" {
run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths dankserver/compose.yaml
[ "$status" -eq 0 ]
[[ "$output" == *"tests/compose_validity.bats"* ]]
[[ "$output" == *"tests/env_consistency.bats"* ]]
[[ "$output" != *"tests/homepage_pages.bats"* ]]
}
@test "changed-test wrapper maps a service script to its focused config tests" {
run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths scripts/serve-frank-small.sh
[ "$status" -eq 0 ]
[[ "$output" == *"tests/scripts_syntax.bats"* ]]
[[ "$output" == *"tests/frank_small_config.bats"* ]]
[[ "$output" != *"tests/frank_large_config.bats"* ]]
}
+20 -33
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bats
# Seam: target reachability and deployment flow integrity.
# These tests assert the deployment contract of the unified repo:
# - a deploy.sh exists and accepts <dankclaw|dankserver> and --dry-run
# - deploy.sh covers both the podman stack and the LLM fleet services
# These tests assert the service-scoped deployment contract:
# - deploy.sh accepts a target, optional service scope, and --dry-run
# - deploy-remote.sh owns per-service handlers and deployment state
# - no host-wide inference restart is hidden in the dispatcher
# - every host referenced in the repo is reachable over SSH (dank@host)
# - the local git remote is configured before deploy is attempted
# Reachability tests are SKIPPED when the target is unreachable so the suite
@@ -38,44 +39,30 @@ load helpers
# Regression: the Caddyfile render step in deploy.sh must survive a bcrypt
# hash (which contains '/' characters). Reproduce the sed expression with a
# slash-bearing hash and confirm the placeholder is replaced.
@test "deploy.sh reloads Caddy after rendering (hot reload, not just container start)" {
# The caddy container often survives 'up -d' (a Caddyfile change is a
# bind-mount content change, not a container config change). Without an
# explicit reload, a rendered fix can sit on disk while Caddy serves the
# stale in-memory config.
grep -q 'caddy reload' "$REPO_ROOT/scripts/deploy.sh"
@test "scoped remote runner owns Caddy reload and safe fallback" {
# Caddy is handled only when selected by the planner. A failed reload may
# restart Caddy, but must not restart the whole compose project.
grep -q 'caddy reload' "$REPO_ROOT/scripts/deploy-remote.sh"
grep -q 'podman restart caddy' "$REPO_ROOT/scripts/deploy-remote.sh"
! grep -qE 'podman-compose.*(up|restart).*\$\(' "$REPO_ROOT/scripts/deploy-remote.sh"
}
@test "deploy.sh restarts caddy when the reload cannot be applied" {
# If the running config has the admin API disabled (or the API is down),
# 'caddy reload' can never apply the new file -- the fix cannot be
# delivered over the API it is meant to enable. The fallback is a
# container restart, which always reads the file.
grep -q 'podman restart caddy' "$REPO_ROOT/scripts/deploy.sh"
@test "remote runner has separate handlers for both inference services" {
grep -q 'deploy_llm_large' "$REPO_ROOT/scripts/deploy-remote.sh"
grep -q 'deploy_llm_small' "$REPO_ROOT/scripts/deploy-remote.sh"
}
# The unified deploy.sh must manage the LLM fleet services on both hosts:
# frank-default-small (dankserver) and frank-default-large (dankclaw). A
# deploy that only restarts the podman stack leaves the LLM units pointing
# at the retired vllmScripts checkout.
@test "deploy.sh restarts the LLM services on both targets" {
grep -q 'frank-default-small' "$REPO_ROOT/scripts/deploy.sh"
grep -q 'frank-default-large' "$REPO_ROOT/scripts/deploy.sh"
@test "inference restarts are absent from the top-level dispatcher" {
! grep -qE 'systemctl .*restart (frank-default-small|frank-default-large)' "$REPO_ROOT/scripts/deploy.sh"
}
@test "deploy.sh health-checks both LLM endpoints (both on :7770, by host)" {
# 2026-08-22: frank-default-large moved from :52625 to :7770 on
# dankclaw. Both LLM services now listen on :7770, distinguished by
# host (dankserver vs dankclaw).
grep -qE '^LLM_HEALTH\[dankserver\]="http://127\.0\.0\.1:7770/health"' "$REPO_ROOT/scripts/deploy.sh"
grep -qE '^LLM_HEALTH\[dankclaw\]="http://127\.0\.0\.1:7770/health"' "$REPO_ROOT/scripts/deploy.sh"
@test "remote runner health-checks an inference endpoint" {
grep -q '127.0.0.1:7770/health' "$REPO_ROOT/scripts/deploy-remote.sh"
}
@test "deploy.sh pulls on the merged checkout on both targets" {
# Both targets run the whole stack (podman + LLM) from one checkout.
local count
count=$(grep -c '/home/dank/serverAppConfigs' "$REPO_ROOT/scripts/deploy.sh")
[ "$count" -ge 2 ]
@test "deploy.sh pulls the merged checkout on the target" {
grep -q 'git pull --ff-only origin main' "$REPO_ROOT/scripts/deploy.sh"
grep -q '/home/dank/serverAppConfigs' "$REPO_ROOT/scripts/deploy.sh"
}
@test "deploy.sh Caddyfile render survives a slash-bearing bcrypt hash" {
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bats
# Seam: service-scoped deployment planning.
# A repository commit can contain changes for several workloads while only a
# subset is safe to restart. The planner must keep inference isolated from
# unrelated service changes and treat docs-only changes as a no-op.
load helpers
PLANNER="$REPO_ROOT/scripts/deploy-plan.py"
REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
@test "deployment planner maps SearXNG changes to SearXNG only" {
run python3 "$PLANNER" --target dankclaw --paths \
shared/searxng/settings.yml docs/Dankclaw/SearXNG.md
[ "$status" -eq 0 ]
[ "$output" = "searxng" ]
}
@test "deployment planner maps large-model changes to the large model only" {
run python3 "$PLANNER" --target dankclaw --paths \
scripts/frank-default-large.env scripts/frank-default-large.service
[ "$status" -eq 0 ]
[ "$output" = "frank-default-large" ]
}
@test "shared chat-template changes map to the selected host's inference service" {
run python3 "$PLANNER" --target dankclaw --paths scripts/chat-template.jinja
[ "$status" -eq 0 ]
[ "$output" = "frank-default-large" ]
run python3 "$PLANNER" --target dankserver --paths scripts/chat-template.jinja
[ "$status" -eq 0 ]
[ "$output" = "frank-default-small" ]
}
@test "deployment planner maps small-model changes to the small model only" {
run python3 "$PLANNER" --target dankserver --paths \
scripts/frank-default-small.service
[ "$status" -eq 0 ]
[ "$output" = "frank-default-small" ]
}
@test "documentation-only changes plan no runtime deployment" {
run python3 "$PLANNER" --target dankclaw --paths \
docs/Architecture/Deployment\ Flow.md docs/Dankclaw/SearXNG.md
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "deployment planner maps shared config to one service" {
run python3 "$PLANNER" --target dankclaw --paths \
shared/prometheus/prometheus.yml
[ "$status" -eq 0 ]
[ "$output" = "prometheus" ]
}
@test "deployment planner maps service-owned non-compose files" {
run python3 "$PLANNER" --target dankclaw --paths dankclaw/hermes/config.yaml.example
[ "$status" -eq 0 ]
[ "$output" = "hermes" ]
run python3 "$PLANNER" --target dankclaw --paths scripts/serve-frank-default-npu.sh scripts/frank-default-npu.env
[ "$status" -eq 0 ]
[ "$output" = "frank-default-npu" ]
}
@test "homepage is owned by Dankclaw's consolidated proxy" {
run python3 "$PLANNER" --target dankserver --paths shared/homepage/index.html
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "compose planner narrows a compose edit to the changed service" {
local base head
base=$(git -C "$REPO_ROOT" rev-parse HEAD)
head=$(git -C "$REPO_ROOT" rev-parse HEAD)
run python3 "$PLANNER" --target dankclaw --repo "$REPO_ROOT" \
--base "$base" --head "$head" --paths dankclaw/compose.yaml
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "network-coupled SearXNG config changes include the required peers" {
run python3 "$PLANNER" --target dankclaw --paths \
dankclaw/searxng/quadlet/gluetun.container
[ "$status" -eq 0 ]
[ "$output" = $'gluetun\nsearxng' ]
}
@test "remote deployer carries per-service deployment state" {
[ -x "$REMOTE_DEPLOY" ]
grep -q 'deploy-state' "$REMOTE_DEPLOY"
grep -q 'STATE\[' "$REMOTE_DEPLOY"
grep -q 'mark_status.*pending\|status=pending' "$REMOTE_DEPLOY"
grep -q 'mark_status.*deployed\|status=deployed' "$REMOTE_DEPLOY"
}
@test "remote deployer routes inference restarts through selected handlers" {
# The runner may restart inference only from the service-dispatch path. It
# must not contain a host-wide restart loop.
grep -q 'deploy_llm_large' "$REMOTE_DEPLOY"
grep -q 'deploy_llm_small' "$REMOTE_DEPLOY"
grep -q 'deploy_service' "$REMOTE_DEPLOY"
! grep -qE 'for .*frank-default-(small|large)' "$REMOTE_DEPLOY"
}
@test "remote runner rejects services belonging to the other host" {
run env DEPLOY_STATE_DIR="$(mktemp -d)" REMOTE_DIR="$REPO_ROOT" \
bash "$REMOTE_DEPLOY" dankserver "$(git rev-parse HEAD)" frank-default-large
[ "$status" -eq 2 ]
}
@test "remote runner recognizes a pending service at the same revision" {
local state_dir="$(mktemp -d)"
printf 'revision=%s\nstatus=pending\n' "$(git rev-parse HEAD)" > "$state_dir/unknown-service"
run env DEPLOY_STATE_DIR="$state_dir" REMOTE_DIR="$REPO_ROOT" \
bash "$REMOTE_DEPLOY" dankclaw "$(git rev-parse HEAD)" --pending
[ "$status" -eq 2 ]
[[ "$output" == *"unknown-service"* || "$output" == *"not deployed"* ]]
}
@test "top-level deployer delegates to the service-scoped remote runner" {
grep -q 'deploy-remote.sh' "$REPO_ROOT/scripts/deploy.sh"
grep -qE 'SERVICE|service' "$REPO_ROOT/scripts/deploy.sh"
grep -q 'OLD_REVISION' "$REPO_ROOT/scripts/deploy.sh"
grep -q 'OLD_REVISION\|old checkout revision' "$REPO_ROOT/scripts/deploy.sh"
}
@test "legacy service deploy wrappers delegate to the scoped deployer" {
for script in deploy-searxng-quadlet.sh deploy-pihole-quadlet.sh deploy-open-webui-quadlet.sh; do
grep -q 'deploy.sh' "$REPO_ROOT/scripts/$script" || {
echo "$script does not delegate to deploy.sh"
return 1
}
done
}
+24 -6
View File
@@ -11,14 +11,15 @@ UNIT_FILE="$REPO_ROOT/scripts/frank-default-large.service"
TEMPLATE_FILE="$REPO_ROOT/scripts/chat-template.jinja"
@test "frank-default-large serves the kingjones AGENT model" {
grep -q '^MODEL_FILE=Qwen3\.8-27B-Q8_0_ROCMFPX_AGENT\.gguf$' "$ENV_FILE"
grep -q '^MODEL_FILE=Qwen3\.8-27B-Uncensored-Q6_0_ROCMFPX_AGENT\.gguf$' "$ENV_FILE"
grep -q '^MTP_DRAFT_FILE=' "$ENV_FILE"
grep -q '^MMPROJ_FILE=mmproj-Qwen3\.8-27B-Q8_0\.gguf$' "$ENV_FILE"
! grep -q -- '--kairic-edge' "$SERVE_FILE"
}
@test "frank-default-large uses the ROCmFPX runtime" {
grep -q 'ROCmFPX/.*/bin/llama-server' "$ENV_FILE"
grep -q 'build-kairic/bin/llama-server' "$ENV_FILE"
grep -q 'build-promptforge/bin/llama-server' "$ENV_FILE"
}
@test "frank-default-large runs standalone MTP" {
@@ -38,6 +39,11 @@ TEMPLATE_FILE="$REPO_ROOT/scripts/chat-template.jinja"
grep -q ': "${CTX_SIZE:=262144}"' "$SERVE_FILE"
}
@test "frank-default-large uses DeepSeek reasoning output" {
grep -q '^REASONING_FORMAT=deepseek$' "$ENV_FILE"
grep -q -- '--reasoning-format "$REASONING_FORMAT"' "$SERVE_FILE"
}
@test "frank-default-large has reasoning and preserved reasoning/thinking enabled" {
grep -q '^REASONING=on$' "$ENV_FILE"
grep -q 'enable_thinking":true' "$ENV_FILE"
@@ -45,18 +51,30 @@ TEMPLATE_FILE="$REPO_ROOT/scripts/chat-template.jinja"
grep -q 'preserve_reasoning":true' "$ENV_FILE"
}
@test "frank-default-large uses Turbo4 only for the V cache" {
@test "frank-default-large uses f16 for both KV cache sides" {
grep -q '^CACHE_TYPE_K=f16$' "$ENV_FILE"
grep -q '^CACHE_TYPE_V=turbo4$' "$ENV_FILE"
grep -q '^CACHE_TYPE_V=f16$' "$ENV_FILE"
grep -qF -- '-ctk "$CACHE_TYPE_K" -ctv "$CACHE_TYPE_V"' "$SERVE_FILE"
}
@test "frank-default-large exposes a swappable ubatch" {
grep -q '^UBATCH_SIZE=512$' "$ENV_FILE"
grep -q ': "${UBATCH_SIZE:=512}"' "$SERVE_FILE"
grep -q '^UBATCH_SIZE=4096$' "$ENV_FILE"
grep -q ': "${UBATCH_SIZE:=4096}"' "$SERVE_FILE"
grep -q -- '-ub "$UBATCH_SIZE"' "$SERVE_FILE"
}
@test "llama-bench ubatch harness uses the active model" {
local bench="$REPO_ROOT/scripts/bench-ubatch.sh"
[ -x "$bench" ]
grep -q 'llama-bench' "$bench"
grep -q 'Qwen3\.8-27B-Uncensored-Q6_0_ROCMFPX_AGENT\.gguf' "$bench"
grep -q -- '--n-prompt "$CONTEXT"' "$bench"
grep -q -- '--ubatch-size "$UBATCH_VALUES"' "$bench"
grep -q -- '--batch-size "$CONTEXT"' "$bench"
grep -q -- '--mmap 0' "$bench"
! grep -qE '(^|[[:space:]])llama-server|(^|[[:space:]])curl|print_timing' "$bench"
}
@test "frank-default-large uses the tracked Qwen-Sharp template on :7770" {
grep -q '^CHAT_TEMPLATE_FILE=' "$ENV_FILE"
grep -q -- '--jinja' "$SERVE_FILE"
+7 -5
View File
@@ -10,10 +10,12 @@ load helpers
@test "deploy.sh ships the homepage (assets, timer, basicauth hash, deployed.js)" {
local d="$REPO_ROOT/scripts/deploy.sh"
grep -q "/srv/homepage" "$d"
grep -q "homepage-status" "$d"
grep -q "CADDY_BASICAUTH_HASH" "$d"
grep -q "deployed.js" "$d"
local r="$REPO_ROOT/scripts/deploy-remote.sh"
grep -q "deploy-remote.sh" "$d"
grep -q "/srv/homepage" "$r"
grep -q "homepage-status" "$r"
grep -q "CADDY_BASICAUTH_HASH" "$r"
grep -q "deployed.js" "$r"
}
@test "the checker runs as a systemd timer pointing at the checker" {
@@ -27,5 +29,5 @@ load helpers
}
@test "deploy.sh is git-driven: it pulls main on the target" {
grep -q "git pull origin main" "$REPO_ROOT/scripts/deploy.sh"
grep -q "git pull --ff-only origin main" "$REPO_ROOT/scripts/deploy.sh"
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bats
# Seam: the entropy helper unlocks the known LUKS-on-md device and mounts via
# the host's fstab entry, preserving the existing mount options and directory
# ownership rather than inventing a second mount configuration.
load helpers
@test "entropy helper is executable and has the expected safe shell shape" {
run bash -c "test -x '$REPO_ROOT/scripts/mount-entropy.sh'"
[ "$status" -eq 0 ]
run grep -E '^set -euo pipefail$' "$REPO_ROOT/scripts/mount-entropy.sh"
[ "$status" -eq 0 ]
run grep -F 'udisksctl unlock --block-device /dev/md0' "$REPO_ROOT/scripts/mount-entropy.sh"
[ "$status" -eq 0 ]
run grep -F 'sudo mount "$MOUNTPOINT"' "$REPO_ROOT/scripts/mount-entropy.sh"
[ "$status" -eq 0 ]
}
@test "entropy helper does not embed a secret or alter mount ownership" {
run grep -Eiq 'password|secret|chmod|chown' "$REPO_ROOT/scripts/mount-entropy.sh"
[ "$status" -eq 1 ]
}
+37 -4
View File
@@ -57,9 +57,42 @@ assert not missing, f"pass_ip missing {missing}; got {pass_ip}"
PYEOF
}
@test "SearXNG outgoing policy allows modest VPN latency without retry amplification" {
python3 - "$REPO_ROOT/shared/searxng/settings.yml" <<'PYEOF'
import sys, yaml
with open(sys.argv[1]) as fh:
data = yaml.safe_load(fh)
outgoing = data.get("outgoing", {})
assert outgoing.get("request_timeout") == 5.0, outgoing
assert outgoing.get("max_request_timeout") == 15.0, outgoing
assert outgoing.get("retries") == 1, outgoing
assert outgoing["request_timeout"] < outgoing["max_request_timeout"], outgoing
PYEOF
}
@test "SearXNG default provider policy favors anonymous healthy engines" {
python3 - "$REPO_ROOT/shared/searxng/settings.yml" <<'PYEOF'
import sys, yaml
with open(sys.argv[1]) as fh:
data = yaml.safe_load(fh)
engines = {entry["name"]: entry for entry in data.get("engines", [])}
assert engines["bing"]["disabled"] is False, engines.get("bing")
assert engines["bing"]["weight"] > 1, engines["bing"]
assert engines["bing"]["paging"] is False, engines["bing"]
assert engines["duckduckgo"]["disabled"] is False, engines["duckduckgo"]
assert engines["duckduckgo"]["paging"] is False, engines["duckduckgo"]
for name in ("google cse", "startpage", "brave"):
assert engines[name]["disabled"] is True, engines.get(name)
assert engines[name].get("inactive", False) is False, engines[name]
for name in ("bing", "duckduckgo", "wikipedia"):
assert engines[name]["timeout"] == 5.0, engines[name]
PYEOF
}
@test "deploy.sh copies limiter.toml into /opt/searxng/config" {
grep -q "shared/searxng/limiter.toml" "$REPO_ROOT/scripts/deploy.sh" \
|| { echo "deploy.sh does not copy shared/searxng/limiter.toml"; return 1; }
grep -q "/opt/searxng/config/limiter.toml" "$REPO_ROOT/scripts/deploy.sh" \
|| { echo "deploy.sh does not place limiter.toml at /opt/searxng/config"; return 1; }
local d="$REPO_ROOT/scripts/deploy-remote.sh"
grep -q "shared/searxng/limiter.toml" "$d" \
|| { echo "deploy-remote.sh does not copy shared/searxng/limiter.toml"; return 1; }
grep -q "/opt/searxng/config/limiter.toml" "$d" \
|| { echo "deploy-remote.sh does not place limiter.toml at /opt/searxng/config"; return 1; }
}