Compare commits

..
Author SHA1 Message Date
Frank 9c386d4f89 feat(qwen38): score the inherited corpus at the window the container really has
The reference rig declared a 32,768-token window while the boot ledger hands it
7,436, so 197 of the 198 inherited documents were too long to score. The engine
warns about the real pool; the flag was the only cited source.

Add scripts/rechunk-kld-corpus.py, which moves document boundaries without
moving tokens: each record is re-cut into windows of at most --max-doc-tokens
(7,168 = 7x1024, the largest whole kilopage under 7,436), and each window gets
its own score_from = len - --score-tail. The reference scores the same token
series at more positions and at a shorter context; the calibration corpus is
untouched, so no quant moves. Writes go through a temp file and the token
counts must match before the output is published.

The rig header now says what the pool actually is, and scripts/test-changed.sh
routes the new script to its own seam instead of the generic syntax fallback.
2026-10-28 00:26:28 -07:00
Frank 1c56dea67a fix(qwen38): record at MNBT 1,024 — the step size the cache can actually pay for
The rerun after 96c5a06 refused every corpus document again ("document 196 is
8507 tokens and the KV pool holds 7436"), and the boot table shows why the
host-pool change could not have helped: vram_budget.cpp put the container in its
`no lender` branch — elastic 25.56 GiB, experts 25.29 GiB (their plan-fixed
share), kv cache 271.25 MiB — identical at 12,288 and at 49,152 MiB of host
pool. The only supplier left there is the activation arena, and the engine says
so at vram_budget.cpp:117: "lower --max-num-batched-tokens (the arena scales
with it)". The log prices both rungs directly (a 2,048-token step costs 1.08
GiB, a 1,024-token step 504.39 MiB), so the default moves to 1,024: the cache
grows to 866 MiB / 24,300 tokens and scores every one of the 197 documents
(longest 11,970). Calibration keeps its wide 65,536; it was never tied to MNBT.

This also retracts the mechanism 96c5a06 recorded in the header ("the pool buys
KV as well as residency"). Inheriting the env's 49,152 / 96 stays right as
single-source-of-truth, but on this payload the pool is not the cache's supplier.
2026-10-27 22:19:51 -07:00
Frank 96c5a0625c fix(qwen38): the rig's card split is inherited, not invented
run-qwen38-bf16.sh carried its own --host-pool-mib 12288 / --gpu-headroom-mib 768,
copied from the w4-era swift15 rig. The engine derives the whole per-card split from
those two numbers, and the KV cache's funding is `elastic - expert_floor` where the
expert floor is a function of the pinned host pool — so the pool buys cache as well
as residency. The first kldref boot got a 270 MiB / 7,436-position pool and the
recorder refused all 197 corpus documents ("document 0 is 10554 tokens and the KV
pool holds 7436"), exiting 1.

The rig now sources FRANK_ARCHON_HOST_POOL_MIB / FRANK_ARCHON_GPU_HEADROOM_MIB from
scripts/frank-archon.env, the file that owns them (49152 / 96), with `:?` guards, and
the header records the law instead of the claim that the pool never touches logits.
tests/qwen38_reference.bats pins the shape verbatim, so its SHAPE contract moves with
it; the suite is green (558 tests).
2026-10-27 21:54:19 -07:00
Frank a7329d190f feat(qwen38): the BF16 reference instrument - build it, drain once, record logits
Two tools the KLD gate depends on.

build-qwen38-bf16-rad.sh converts the checkpoint to a BF16 .rad with NO recipe
line, so every tensor stays bf16 - the reference must not carry the quantization
it is there to measure. It gates before writing 335 GiB: the checkpoint present
and fully verified by the fetcher's own verifier, the peak-disk bill (350,000
MiB: the checkpoint's 343,323 plus margin, because the .rad is built beside it),
an existing output retired to ~/.AGENTTRASH by --force instead of deleted, and
--plan-only before the real convert. --max-ctx 32768 is a declare-time check, not
a weight change: dropped, the resolver is handed the checkpoint's 262,144 window,
which at fp8 cache is 258.8 GiB on a 192 GiB machine.

run-qwen38-bf16.sh serves that container in three modes - kldref (record the
reference logits), score (record an arm's logits against that reference), calib
(open the Gram tap for the imatrix). It drains frank-archon and restores it on
every exit path, and the two logits modes ride ONE step size
(FRANK_QWEN38_MNBT, default 2048) and ONE window (FRANK_QWEN38_MAX_MODEL_LEN,
default 32768) so a reference and an arm cannot be measured at different shapes:
that is the whole validity of the KL number. Four slots over 2,048 matches
frank-archon.env's operating point (MAX_SEQS=4, MAX_BATCHED_TOKENS=2048); the
smaller host pool (12,288 vs 49,152) buys residency, which moves speed, not
logits. calib keeps 65,536 - decode-bound serving, never the gate.

kldref refuses before the drain when the corpus's longest record cannot fit the
declared window (limit = window x 3 chars, the pessimistic tokenizer bound; the
measured corpus is 38,263 chars, 2.6x inside 32,768). It runs on --dry-run too,
so the check is visible before a 20-minute drain is spent. 12 seam tests, each
red-proofed by neutralising the gate it pins.
2026-10-27 19:40:48 -07:00
Frank 5fe04e569f test(qwen38): campaign seam for fetch, protected plane, arms and gate
32 black-box tests over the four tools the campaign runs, driven through their
own argv with fixture corpora - no GPU, no container, no rad-convert. Each test
is an output an operator reads: the verifier's MISSING line and exit, the byte
bill the derivation lands, the refusal text, the plan and the convert the driver
prints per arm, the interval verdict the gate returns.

Includes the planner seam the swift15 campaign lacked: the campaign's paths plan
NO deployable service, so merging this toolchain can never drain frank-archon.
The driver fixture carries four grams so --min-grams has a thin-pool control,
and the arm-driver invocation passes env assignments positionally after the
driver's own flags, so the seam exercises the real call shape.
2026-10-27 19:36:58 -07:00
Frank bb027265db feat(qwen38): the FP8-anchored KLD gate - the served payload is the floor, not BF16
The swift15 gate compared an arm against the BF16 reference and passed on
KL <= 0.03. That bar is now the wrong instrument: the fleet already ships the
publisher's FP8-native iq4r-moe payload, so an arm that beats BF16 but loses to
what is running is a downgrade. The gate takes three recordings - fp8 anchor,
iq4r anchor, candidate - and requires the candidate's KL strictly inside the
interval the two anchors define, at every slice, plus top-1 agreement no worse
than the served payload.

The anchors must be ordered (fp8 better than iq4r) or the run stops: an
unordered pair means the instrument or the recording is wrong, not that the arm
lost, and one number out of the report is named rather than guessed at.
--recover prints the tightened numbers the same way it prints the failing ones,
so the operator sees the bar they would have had to clear.
2026-10-27 19:36:53 -07:00
Frank 4e551fafd7 feat(qwen38): the w5nl64 recipe template and the three-arm convert driver
One template (every routed expert at five bits, E4M3 scales in 64-groups, the
top level rotated through the 128-wide Hadamard chosen by GPTQ against CALIB)
plus a driver that generates the per-arm expert row, so the other 60 lines can
never drift between arms: control (cd=3), cd=6, and act_order=1 with NO cd= -
libquant refuses the pair (lq_registry.cpp:105), so the AoA arm is its own
sweep, not a fourth column.

The header carries the campaign's order (fetch -> build reference -> one drained
window for the calibration tap and the KLD reference -> trash the bf16 container
-> imatrix -> arms -> fetch the FP8 anchor last -> gate -> trio edit), because
that sequence is point-of-use for whoever runs it next, and the disk sequencing
is forced: 754 GiB free does not hold checkpoint + reference + three arms.

Gates that bite before 5h13m of convert time is spent: the BF16 reference still
occupying the volume, a recording too thin to be a calibration (converting
uncalibrated is silently worse and only whispers on stderr), an imatrix outside
the mounted campaign dir, an arm that already exists, a protected derivation
that cannot land its target, and a destination that cannot hold the output. Each
arm runs --plan-only first so the engine's own fit check bills at plan time; the
served container's argv shape is what the arms declare.
2026-10-27 19:36:53 -07:00
Frank 3d8d18a45c feat(qwen38): fold the calibration tap's Grams into libquant's imatrix GGUF
rad-convert's GPTQ path reads importance from an imatrix GGUF; the reference
container's --kld-record pass emits per-expert and per-hidden Grams as raw
recordings. This folds them into one v3 file: a single expert-major f32 entry
per MoE layer plus the hidden-width entries, every data offset a multiple of
general.alignment as the loader requires.

Refusals, each one a silent-worse outcome the swift15 campaign hit: an expert
with no gram of its own carries the layer's pooled diagonal and the count is
reported (a silent zero is a down-quantized expert nobody priced); expert grams
with no pooled gram, a gram short by one float (names the file), the same global
expert recorded twice with different numbers, an empty recording directory, and
an existing output. --min-rows sets the noise floor below which a recording is
dropped. rows is u64 so a corpus that outgrows u32 survives the header instead
of wrapping.
2026-10-27 19:36:38 -07:00
Frank c13a2568d4 feat(qwen38): size the protected bf16 expert plane by byte bill, not by ratio
The swift15 rule (top 7% by imatrix energy, bar-only) left the plane size to the
distribution. The w5nl64 arm trades a *pinned host pool* against KL, so the
operator needs to say how many GiB of bf16 experts the pool can afford and have
the set land there: --target-count walks the ranked candidates until the byte
bill matches, keeps the bar-only pass for experts that are disproportionate for
their size, and reports the pool's ceiling instead of padding when the count
cannot be reached.

TP=2 cuts the expert plane into two 256-expert rank halves, and an odd count in
either half unbalances the placement, so the parity audit is a refusal (exit 2,
'PARITY LAW BROKEN'), not a silent fix-up: the driver stops rather than
converting a payload whose expert placement no one priced.
2026-10-27 19:36:32 -07:00
Frank e7a835e41f feat(qwen38): fetch and verify the vendor Qwen3.8-Flash-Next BF16 checkpoint
The w5nl64 campaign measures against the vendor checkpoint, not the Swift-1.5
finetune: the served payload (qwen3.8-next-flash-fp8-iq4r-moe) is quantized from
this base, so fidelity must be scored against the same reference. 335.3 GiB
across 10 shards.

Verifier gates on the index's own shard list, each shard's declared byte count,
and the Hub's declared total - a curl without -f writes the 401 body as the
'shard' and reports success. --fetch refuses when the destination cannot hold
the checkpoint, because a 335 GiB download that fills the disk mid-shard costs
the whole campaign window.
2026-10-27 19:36:26 -07:00
dank 0ecfe830f7 Merge pull request 'fix(deploy): relay firewall DROP must be appended, not -I-inserted above its ACCEPTs' (#75) from fix/signal-relay-fw-order into main 2026-10-21 16:59:16 -07:00
Frank ce370a5b28 fix(deploy): relay firewall DROP must be appended, not -I-inserted above its ACCEPTs
iptables -I prepends, so the install block's final -I of the blanket DROP
landed it at position 1, above all three ACCEPTs: every packet to
18093/tcp died there (live 2026-10-21: 159 packets on the DROP, 0 on each
ACCEPT). The host's own health probe timed out beside a LISTENing socket
and the whole dankclaw drain aborted at the alertmanager gate twice.

Install now rebuilds the tail: delete any blanket DROP wherever it sits,
keep the ACCEPTs at the head, append the DROP last - converges on hosts
poisoned by the old order. New bats case runs install_signal_relay for
real against an iptables sandbox seeded with the poisoned order (red on
the old code, green on this one).
2026-10-21 16:52:40 -07:00
Frank bfe4d185bd docs(vault): OpenWebUI picker opened to all three models for every account (2026-10-21)
Per Frank, frank-archon is public alongside dragoon/zealot: three-leg DB
trio (conn enable, model row ctx 200000, access_grant principal='*'),
verified as role=user. Token-mint traps recorded (cwd, PID-1 secret).
2026-10-21 16:48:32 -07:00
dank 94a9f3aa31 Merge pull request 'feat(security): digest-pin every registry image; drop batch-killing HostAddr' (#74) from security/quadlet-digest-pins into main 2026-10-21 16:32:44 -07:00
Frank d2810ab610 feat(security): digest-pin every registry image on all three hosts (fleet law)
Closing the command-center security queue #70/#71/#72/#73/#74: rolling tags
let a rebuild move under a running service with nobody approving it, and
make a CVE audit ungradeable. 36 quadlet units across dankclaw, dankserver
and frankbook-pro now carry tag@sha256; localhost/* builds stay exempt
(their build is the pin). The pihole and copyparty pins pull the fixed
builds; valkey 9.1.2 (already running) and redis 7.4.11 are pinned in
place, vendor fixes TBD.

New gate tests/quadlet_image_pins.bats: no unpinned registry image, no
generator-rejected key anywhere. Three seams that pinned the old exact-tag
law (frankbook readarr, open_webui, seerr) now require tag AND digest -
strictly stronger. Quadlet Lifecycle docs: batch-abort law, entrypoint
flags-only law, pin law.
2026-10-21 16:32:36 -07:00
Frank 7a8cc83385 fix(quadlet): drop HostAddr from prometheus.unit - podman 5.7.0 rejects the key and the quadlet generator then aborts the ENTIRE user batch, silently orphaning every other unit (prometheus.service vanished while the container ran; deploy died 'Unit prometheus.service not found', live 2026-10-21). host.containers.internal resolves natively; the key was never needed. 2026-10-21 16:32:36 -07:00
dank c4623f1629 Merge pull request 'fix(blackbox): flags-only Exec; bind loopback+LAN; exec-form HealthCmd' (#73) from fix/blackbox-entrypoint-bind into main 2026-10-21 16:25:32 -07:00
Frank 065a6911f9 fix(blackbox): Exec carries flags only (image has ENTRYPOINT) and the exporter binds loopback+LAN
Three stacked lessons, each proved live 2026-10-21:
- the image's ENTRYPOINT is /bin/blackbox_exporter, so Exec= repeating the
  binary made it a positional arg: 'unexpected /bin/blackbox_exporter'.
- the podman gateway 169.254.1.2 is NOT bindable from the host netns - it
  exists only inside each rootless netns: 'bind: cannot assign requested
  address'. The scrape path does not need it bound: a bridge container
  dialing the gateway lands on the host LOOPBACK (proved 200 from the
  prometheus container), so the exporter binds 127.0.0.1 + LAN.
- HealthCmd shell form ('/bin/sh -c "..."') is passed as one quoted token
  and quadlet folds the following --health-interval/--health-retries (and
  the image) into the command string; the plain exec form the sibling units
  use is correct.

Prometheus.md bind law corrected (gateway is a forward-to-loopback, not a
bind address); deploy-remote gate stays loopback, which is now honest.
2026-10-21 16:04:53 -07:00
dank 745d66d60d Merge pull request 'fix(blackbox): --web.listen-address (repeatable), not --listen-address' (#72) from fix/blackbox-web-listen-flag into main 2026-10-21 15:58:36 -07:00
Frank 4009f118c1 fix(blackbox): the flag is --web.listen-address, repeatable, not --listen-address
Third boot failure: the exporter exited 100 ms in with 'unknown long
flag --listen-address' (proved against the pinned image, plus --help).
The bind semantics were right; the flag name was invented. --web.listen-address
is repeatable - one per address - so the dual gateway+LAN bind stands.
Proven again pre-deploy with the real argv: the run starts and dies
only on the deliberate /dev/null placeholder config.
2026-10-21 15:55:21 -07:00
dank 7de343c171 Merge pull request 'fix(blackbox): complete the unit - Image, Network=host, honest bind topology' (#71) from fix/blackbox-unit-generation into main 2026-10-21 15:52:30 -07:00
Frank 139a3cd4fa fix(blackbox): unit had no Image and no Network=host - quadlet refused to generate it
The deploy died at 'Unit blackbox-exporter.service not found': a
.container without Image= is not generated at all, and the header
argued for Network=host it never declared. Completes the unit (digest
pinned prom/blackbox-exporter, Network=host) and fixes the bind
topology the first cut got wrong:

- Prometheus is bridge-networked and the netavark hairpin gap bars it
  from its own host's LAN IP, so a LAN-only prober bind is unscrapable
  and a loopback-only bind was claimed to ride the gateway on faith.
  The prober now binds 9115 on the podman gateway (scrape path) AND
  the LAN (healthcheck/diagnostics); the listen_address line leaves
  blackbox.yml so config and Exec cannot disagree.
- comfyui probe target http://comfyui:8188 -> http://127.0.0.1:8188:
  bridge container names do not resolve in the host netns; the
  published 0.0.0.0:8188 answers on loopback there.
- New seam: every observability unit must declare Image= (red on the
  shipped file by name); copier test pinned module presence instead
  of the removed listen line. Docs updated to the real topology.
2026-10-21 15:41:37 -07:00
dank 5ffbf19fb2 Merge pull request 'fix(deploy): blackbox-exporter was gated out of its own deploy path' (#70) from fix/blackbox-service-gates into main 2026-10-21 15:34:49 -07:00
Frank 1fd98cab3f fix(deploy): blackbox-exporter was gated out of its own deploy path
PR #69 wired the copier, planner entry and health gate but missed the
three dispatch gates: the client valid_service, the remote
service_is_allowed, and deploy_service's case list. The client gate
rejected --service blackbox-exporter outright; even past it the
dispatcher's catch-all hard-fails on dankclaw (no compose since
2026-09-16). New seam pins the class: every dankclaw service the
remote gate allows must carry a deploy_service case - proven red by
planting a gated, undispatched name.
2026-10-21 14:59:47 -07:00
dank d61c19d41f Merge pull request 'Observability: kill every structural error (blackbox, labels, gates)' (#69) from fix/observability-errors-gone into main 2026-10-21 14:53:16 -07:00
Frank e6fcf217b4 docs(comfyui): the GTT aperture is not free RAM beside frank-dragoon
Measured 2026-10-21 with frank-dragoon live: MemAvailable 15.3 GiB,
iGPU busy 1.75 GiB. The Qwen-Image weight sets (13.3-14.2 GB) do not
fit that budget, and the page's 122 GB /system_stats figure is the
aperture, not headroom. Adds the fit table and the
stop-mask-render-verify procedure (the 3090 bench law, applied to the
APU); ComfyUI.md gotcha links it.
2026-10-21 13:35:50 -07:00
Frank cb86ad8fd7 docs(observability): post-blackbox state, copier law, label-promotion why
Prometheus/Observability/Provisioning pages: every job up after the
blackbox swap, the copier list learns copy_blackbox_config and the
alloy validate gate, the config table gains alloy + blackbox rows, and
the logs bullet states the stream-label law instead of the retired
'awaiting deploy' note.
2026-10-21 13:33:03 -07:00
Frank 078b550bd8 test(observability): copier sandboxes, probe-target seam exception, cop gates
- caddyfile seam learns the documented exception: URL-shaped targets
  are legal only inside a prober job (state is per-job; the first cut
  leaked it and passed a planted URL).
- observability seams: blackbox unit + planner entries, both copiers
  executed for real in sandboxes, alloy engine-validation refusal
  (image-gated), dashboard panel invariants.
- AGENTS.md: structural target downs retired.
2026-10-21 13:33:03 -07:00
Frank 765a8cb7de feat(deploy): blackbox copier, alloy engine-validation gate, planner wiring
copy_alloy_config now validates the rendered config with the very
alloy image that will run it, before installing: a misplaced attribute
crash-loops the unit behind a green deploy, and no textual seam can
see it. copy_blackbox_config ships the prober config; the planner maps
the new unit and shared/blackbox/** to the blackbox-exporter service,
ordered before prometheus.
2026-10-21 13:33:03 -07:00
Frank 999c4fe440 fix(alloy): promote CONTAINER_NAME to an indexed Loki stream label
Loki metrics queries group only by indexed stream labels, so the
journal-as-JSON stream made every per-container view structurally
empty (measured 6.5k lines/5min flowing while the board read zero).
A loki.process relabel stage now extracts CONTAINER_NAME into a
'container' label. alloy 1.21's json stage has no on_error attribute
(validated against the pinned image) — format_as_json guarantees the
line shape, so no error branch exists.
2026-10-21 13:33:03 -07:00
Frank 2af2e6ac56 fix(grafana): observability and logs-overview boards read real data
observability.json: stale 'structural downs' panel description fixed,
target census matches the post-blackbox job set. logs-overview.json:
panels grouped by the promoted container label instead of the absent
container_name, so the board stops reading empty beside live traffic.
2026-10-21 13:32:58 -07:00
Frank fbe54ca1ad feat(gitea): enable /metrics for Prometheus scraping
Gitea shipped metrics disabled, so its scrape target was a permanent
structural down beside a healthy service.
2026-10-21 13:32:58 -07:00
Frank f073b91033 feat(observability): blackbox exporter replaces the HTML-probe jobs
Prometheus 3 refuses HTML scrape bodies, so the comfyui/services probe
jobs were structurally dead targets reading as outages. HTTP liveness
now rides a blackbox exporter (shared/blackbox/blackbox.yml, loopback
bind, http_2xx module); prometheus.yml probes SearXNG, OpenWebUI and
ComfyUI via __param_target relabeling. The unit is a boot-persistent,
self-healing root quadlet.
2026-10-21 13:32:58 -07:00
dank 425994515a Merge pull request 'fix(deploy): grafana copier provisions EVERY dashboard json' (#68) from fix/grafana-copier-glob into main 2026-10-21 09:49:15 -07:00
Frank c41384e03d fix(deploy): grafana copier provisions EVERY dashboard json, not a hand-listed subset
copy_grafana_provisioning named llm-fleet.json alone, so a new board in
shared/grafana/provisioning/dashboards/ silently never reached
/opt/grafana/provisioning. Measured 2026-10-21: the containers board was
merged, seam-tested, and documented as working since 2026-10-20, yet was
NEVER provisioned — its panel fixes were undeployable; the other four
boards existed on disk only because someone hand-copied them once (and
host-system.json there had gone stale against the repo). The copier now
globs the directory, and GRAFANA_PROV_ROOT lets the new bats case run
the copier for real into a sandbox (red against the old hand-listed
body, green against the glob). Vault: the copier law + the containers
incident on Observability Provisioning, the corrected dashboards claim
on Observability.

Note: a mktemp sandbox + trap RETURN races bats' trap machinery (the
dir vanishes mid-function); BATS_TEST_TMPDIR is the safe idiom.
2026-10-21 09:40:57 -07:00
dank 127621bdd7 Merge pull request 'docs(vault)+fix(grafana): audit tranche 1 and dashboard data-flow fixes' (#67) from fix/dragoon-context-200k into main 2026-10-21 09:28:43 -07:00
Frank c96d0228a4 fix(grafana): dashboard census fixes — every panel now receives data
Census: all 5 provisioned dashboards, 56 targets, every expression
re-evaluated against the live Prometheus/Loki with template vars expanded
(queries must run from the host: both APIs bind 127.0.0.1; from the dev box
that is connection-refused, which is NOT a dashboard defect).

- host-system 'Load 5m / core' was STRUCTURALLY EMPTY: bare '/' drops
  non-matching pairs and the right side collapses to {job} while the left
  carries {job,instance} -> 0 series forever. Fixed with / on(job); proven
  live (2 series).
- containers 'Memory vs limit (%)' was STRUCTURALLY EMPTY: every
  container_spec_memory_limit_bytes on this fleet is 0 (no cgroup limits),
  so the '> 0' filter can never match. Rewritten to share-of-host-total via
  label_replace join of the node exporter's MemTotal by host (the parens
  form is a PromQL parse error: nothing may follow group_left); 62 series
  live. Panel unit percentunit kept -> expression returns a fraction.
- llm-fleet 'Firing fleet alerts' is legitimately empty when healthy
  (ALERTS carries only firing/pending series); description now says so,
  per the Stale Signal rule that 0-series-on-ALERTS is not a defect.

logs-overview and observability: all targets already returning data.
2026-10-21 02:45:57 -07:00
Frank a61308d72e docs(vault): audit tranche 1 — one-owner dedupe of the observability cluster, dead-shape numbers destroyed
Purpose-test pass over the audit findings (every verdict re-verified
against the file before touching it; three scout findings were hallucinated
— a nonexistent filename, my own changelog line, and sanctioned Eras rows):

- Observability Provisioning: destroyed the 63-line 'Current delivered
  state' block — chain topology/relay/delivery restates [[Observability]]
  and [[Alertmanager]] (those are the owners). Its three sole-custody laws
  MOVED to Stale Signal Audit as 'Three rules that outlive the incident':
  down-beside-healthy -> read lastError; Prometheus alert state in process
  vs Alertmanager's --storage.path persistence; candidate-arm metrics are
  never fleet state.
- RDNA4 MoE VRAM budget: kept the laws (registry over-prices the pool;
  never reuse a B/token ratio across geometries; pin pool bytes
  floor-sized), era-labelled the rates, destroyed the measured ladder
  table for the superseded shape-G/bf16 rungs.
- Stale Signal Audit: new rules section + Provisioning Related link.

check-okf 109/0, vault links 0 unresolved, docs seams green.
2026-10-21 02:35:44 -07:00
dank ccb805cf0b Merge pull request 'fix(frank-dragoon): mount the distro XRT at the third prefix the NPU child searches' (#66) from fix/dragoon-context-200k into main 2026-10-21 02:25:42 -07:00
Frank c6e2887f94 fix(frank-dragoon): mount the distro XRT at the third prefix the NPU child searches
The lane's child dlopens libxrt_core.so.2 from /opt/xilinx/lib/<multiarch>/
as well; our mounts filled only /opt/xilinx/xrt/lib and the install path.
Since the host's libxrt2 was reconfigured 2026-10-20 the child dies naming
the missing prefix ('No such library
/opt/xilinx/lib/x86_64-linux-gnu/libxrt_core.so.2') and the engine
supervisor takes the WHOLE container down on a dead lane — measured
2026-10-21: frank-dragoon boot-looped 88 times with chat dead while the
GPU lines looked healthy.

Each XRT library now mounts THREE times from the same real file (never a
symlink); the multiarch name derives from FRANK_DRAGOON_XRT_DIR's basename
so the bats fixture proves the derivation. Proven end-to-end before commit:
production-shaped scratch boot + the three extra mounts answers the full
boot ledger, npu_api buckets u0..u9, and /health ok at the 262144 ceiling.

A dead lane is not survivable on this engine — arm it only with all three
prefixes (page law updated).
2026-10-21 02:21:53 -07:00
dank 9eb53ef937 Merge pull request 'feat(frank-dragoon): 200,000-token harness window via the native 262,144 ceiling' (#65) from fix/dragoon-context-200k into main 2026-10-21 01:40:40 -07:00
Frank 688928d489 docs(dragoon): the 262144 ceiling, the 200,000 window, and the 95 % trigger law
Shape + harness contract on the endpoint page, AGENTS.md, and the OMP
Harness worked example recomputed to the live pair; the 400-zone law now
names the 85 %-comments vs 95 %-config disagreement as the recurring
cause and points at the seam that enforces the arithmetic.
2026-10-21 01:34:28 -07:00
Frank 56503dbad1 feat(frank-dragoon): raise HALOGEN_CTX to the native 262144 ceiling
The 200,704 ceiling squeezed the harness contextWindow down to 155,648
(the 400-zone fit law needs ~45k of gap under the ceiling), which is why
long dragoon sessions kept hitting the 400 zone: the harness clamps
max_tokens to window - est - 4096, its chars/4 est under-counts ~3 %,
and config.yml compacts at 95 % while the pair was designed for 85 %.

Ceiling -> checkpoint's native 262,144 (no RAM move: the KV pool stays
401,408 and is what the ledger bills; the band ctx <= pool <= slots*ctx
still holds). The harness window becomes 200,000 with a 62,144 gap.

The fit-law seam now reads the declared window from the vault page (the
one owner) and checks the arithmetic at the live 95 % trigger, so a
ceiling/window pair that drifts back together fails tests/run.sh
instead of a live session.
2026-10-21 01:34:28 -07:00
dank fbeb9701e4 Merge pull request 'fix(observability): serve every exporter bind; scrape same-host cadvisor via gateway' (#64) from fix/gpu-serve-and-cadvisor-target into main 2026-10-21 00:39:54 -07:00
Frank 650b6e0281 fix(observability): serve every exporter bind; scrape same-host cadvisor via gateway
Two shipped-2026-10-20 bugs that left gpu-dankclaw, gpu-dankserver and
cadvisor-dankclaw DOWN beside a green deploy:

- gpu-exporter.py built one ThreadingHTTPServer per bind but ran
  serve_forever() on servers[0] only: the LAN-class socket sat listen()ing
  but never accept()ed, loopback deploy gate passed, every scrape that
  reached the other bind timed out on the SYN backlog. Now one thread per
  extra bind. Regression test spins --serve on two loopback binds and
  requires BOTH to answer /metrics.

- cadvisor-dankclaw scraped its own host's LAN IP. Measured from inside the
  Prometheus container: a bridge container cannot dial its own host's LAN
  address at all (hairpin refuses published ports too); the podman gateway
  answers. Target moved to host.containers.internal:8080; the seam that
  pinned the LAN address (asserting the wrong doctrine) is re-pinned to the
  gateway plus a negative pin against the own-host LAN dial. Observability
  page records the measured reachability law replacing the inferred one.
2026-10-21 00:34:17 -07:00
dank b62a05837f Merge pull request 'fix(deploy): client allowlist missing cadvisor/gpu-exporter/prowlarr' (#63) from fix/deploy-sh-allowlist-drift into main 2026-10-21 00:15:45 -07:00
Frank 0367244d2a fix(deploy): client allowlist was missing cadvisor, gpu-exporter, prowlarr
deploy.sh's valid_service and deploy-remote.sh's service_is_allowed are
two gates for the same door, and they drifted: the remote gate gained
cadvisor (2026-10-20) and gpu-exporter (2026-10-21) while the client gate
never did, so `--service cadvisor|gpu-exporter` died with "is not deployed
on <target>" at the client — the first-ever deploy of a new service is
exactly the case that needs the client gate to know it. dankserver:prowlarr
(planner can emit it for dankserver) was remote-only too.

Adds the four missing pairs to the client gate and a deployment_flow
contract test that diffs the two extracted lists per target — the test the
cadvisor PR should have carried. searxng-network stays the one sanctioned
client-only exception (network unit, no remote handler), documented inline.
2026-10-21 00:15:21 -07:00
dank 656d9567d5 Merge pull request 'chore(plex): bump pin to 1.43.5.11029-6bf8af3f0-ls329 (latest)' (#62) from chore/plex-1435 into main 2026-10-21 00:07:28 -07:00
Frank 0b4bc60a38 chore(plex): bump pin to 1.43.5.11029-6bf8af3f0-ls329 (latest)
ls329 is the newest build on the linuxserver channel and Plex 1.43.5.11029
is their newest release; the live pin (ls325 / 1.43.4.10903) is two Plex
releases behind. Digest-verified at pin time: the tag's manifest digest
equals the registry's current 'latest' (sha256:8f655bad...e9e2334), the
same law the page records. Quadlet restart runs PMS's usual one-minor
schema migration on first boot; the deployer's /identity HTTPS gate
tolerates the first-boot migration up to 600 s.

Rollback: revert this commit (one line).
2026-10-21 00:06:53 -07:00
dank a2011f4eb6 Merge pull request 'feat(observability): per-GPU metrics for the mixed AMD/NVIDIA fleet' (#61) from feat/gpu-stats into main 2026-10-20 23:50:11 -07:00
Frank b02647b992 feat(observability): per-GPU metrics for the mixed AMD/NVIDIA fleet
Add a host-process GPU exporter, scrape it on both servers, and graph it
on the host-system dashboard. The fleet has four GPUs across three vendors
of interest (2x R9700 on Dankserver, the 8060S iGPU + RTX 3090 on Dankclaw)
and nothing exposed them until now.

scripts/gpu-exporter.py reads each AMD card's amdgpu sysfs
(gpu_busy_percent / mem_info_vram_* / hwmon temp1_input / power1_average)
plus one nvidia-smi --query-gpu CSV, and emits unified gpu_* Prometheus
families (utilization_percent, memory_used/total_bytes, temperature_celsius,
power_watts) labelled gpu/vendor/model. Absent hardware yields ABSENT series,
never a fabricated 0: the R9700s expose no sysfs power, so they simply carry
no gpu_power_watts line. Both sources are injectable, so the seam runs on a
GPU-less dev box; live-verified on Dankserver's real sysfs.

It runs as a plain user unit (not a quadlet): it must read the host's own DRM
sysfs and shell to nvidia-smi, which a container cannot do without GPU
passthrough. Same posture as signal-relay. The base unit binds loopback; the
deploy drop-in appends the host LAN address, never a wildcard (matching
node-exporter/cadvisor). deploy_gpu_exporter installs/enables/health-gates it
on both hosts and is wired into the planner (both targets), the test-changed
router, and two Prometheus scrape jobs using the node/cadvisor reach rules
(gateway on Dankclaw, LAN on Dankserver).
2026-10-20 23:42:26 -07:00
dank b35269bfa5 Merge pull request 'docs(archon): four-slot ledger, resolver law from source, probe self-calibration' (#60) from wt/20261020-213526 into main 2026-10-20 23:30:27 -07:00
Frank 1aeb7af6df docs(probe): price the corpus chars/token ratio at run time
The stored 3.28 chars/token (measured 2026-10-11) had drifted to 3.20 as the
repo's docs grew, so leg B sent ~201,900-token prompts and took a legitimate
400 from the engine's own window check — which reads as a capacity-contract
failure on a healthy endpoint. One ~4k-token request now prices the ratio, and
the leg sizes to 97 % of the deployed window.
2026-10-20 23:26:53 -07:00
Frank cd0be97b39 docs(vault): the four-slot ledger, and the resolver's funding law from the source
The shape block and boot ledger now quote the live 1.8.1 boot: experts 20.72 GiB
(78 %) / kv cache 5.64 GiB (21 %) = 747,492 tokens, 3.7 full-length sessions,
`--checkpoint-slots 40`, `queue_cap 32`, sampler 12 rows, `experts-from-host`
8.99 GiB a rank.

The KV Capacity law is rewritten from the pinned source instead of inference:
`core/mem/vram_budget.cpp:146-156` funds the cache as `elastic - expert_floor`,
`core/mem/kv.cpp:266` sets its ceiling from `max_seqs x max_ctx` (demand, never
funding), and `core/place/planner.cpp:100-104` makes the expert floor fall
one-for-one with `--host-pool-mib` — which is why +4,224 MiB of pinned host pool
bought the pool and cost 3.01 GiB of residency a card. The old "funds ~90 %,
never 100 %" phrasing was inference; the byte-identical 45,568 row is the ceiling
case, not a mystery. Slot-cost law carries the four-slot price (state +
checkpoint store + rows, still under the 25-row exact-wire bound). The two-slot
era is one row in Eras with its rollback commit. Date stamps in the trio
comments corrected to 2026-10-20.
2026-10-20 23:26:53 -07:00
dank 7993a336bf Merge pull request 'feat(frank-archon): four decode slots over a >=700k-token KV pool' (#59) from wt/20261020-213526 into main 2026-10-20 23:14:35 -07:00
Frank 1bb6399524 feat(frank-archon): four decode slots over a >=700k-token KV pool
Frank's order: 4 slots, a 700k pool, round UP if the resolver cannot land it
exactly. The cache target is MAX_SEQS x MAX_MODEL_LEN, so the demand is
800,000 fp8 tokens; the resolver funds ~91-93 % of demand (three boots of
evidence on the endpoint page), which clears 700k, and the VRAM has to come
from the expert plane -- which has to go somewhere, so HOST_POOL_MIB moves with
it. expert_vram_floor (core/place/planner.cpp:100-104) is exactly
plane + 2 units - host_room, and the live boot ledger pins that plane at 67.60
GiB a rank (23.73 GiB floor at 44,928 of room), so 49,152 drops the floor to
19.61 GiB and leaves the cache ~6.9 GiB: enough for the whole target, with the
leftover returned to the experts.

Costs, both real: pinned host RAM 87.75 -> 96.0 GiB (the pool is pinned once per
rank) and ~3.2 GiB a card of expert residency, read over the x8 Gen5 link when
the router picks a host-resident expert. The boot ledger's vram_kv line is the
referee; if it comes back under 700,000 tokens HOST_POOL_MIB goes up 1024 and
this redeploys.

TTM_MIN_MIB 150,000 -> 162,000 for the new pin ledger (2 x pool + PLE 48,824 +
prefix 8,192 + MNBT 2,048 + the auto checkpoint store, now 40 slots of 112 MiB =
161,848 MiB), and the bats ledger guard now counts the checkpoint store because
it grows with the slot count. The two env-value assertions in that seam are
bumped with the values they guard.
2026-10-20 23:10:05 -07:00
dank 31b71a16d6 Merge pull request 'feat(observability): per-container metrics via cadvisor + Containers dashboard' (#58) from feat/containers-observability into main 2026-10-20 23:08:23 -07:00
dank a4edaa7d9d Merge pull request 'feat(frank-archon): arm --prefill-interleave at 1' (#57) from wt/20261020-213526 into main 2026-10-20 22:55:58 -07:00
Frank aa75b45543 feat(frank-archon): arm --prefill-interleave at 1
A prefill chunk that shares a step with decoding sequences holds the device for
the chunk's whole time: measured on this shape, decode-only steps 15.5 ms
against chunk steps 166 ms, and 4.7 % of the steps carry 34 % of the engine's
step time. Harness-visible decode falls 166 -> 13 tok/s (per stream 6.7 = one
token per shared step) once concurrent prefill passes 5k tok/s, and TTFT p90 for
an arriving request goes 2.4 -> 18.0 s.

BUSY_PREFILL_CHUNK stays `auto`: it is a prefill-speed dial, and the source
refuses to halve the chunk for decode rows alone -- which is why
engine_steps_held_total read zero for the whole 34.7 h boot. PREFILL_INTERLEAVE
is the knob that acts on the measured mechanism: N decode-only steps follow any
chunk step taken beside decoders. N=1 is the conservative first arm -- a stream
takes its token from the chunk step and one from the held step (~2 tokens per
182 ms instead of one per 166 ms); the cost lands on the prefilling prompt's
TTFT and on requests still queued, not on anything decoding.

Rollback: PREFILL_INTERLEAVE=0. Verification: the flag must reach argv (the
serve script only passes it when > 0), engine_steps_held_total must leave zero,
then the §9 bucket pair must move before N rises.
2026-10-20 22:52:31 -07:00
Frank 31908c623f feat(observability): per-container metrics via cadvisor + Containers dashboard
Per-container CPU/memory was invisible for the quadlet app stack — Grafana could
only see the host (node_exporter) and the LLM engines. This adds a rootless
cadvisor quadlet on each host and a `containers` dashboard.

cadvisor works on this rootless-Podman / cgroup-v2 host on one condition:
--cgroupns=host. Podman starts every container in a private cgroup namespace, so
without it cadvisor sees only its own cgroup root and exports a single series
(measured 2026-10-20: 1 series off, 996 on). The libpod socket does not name the
rootless app containers (it resolved 2 of 34), so naming comes from elsewhere:
the quadlet unit name is carried in the cgroup path
(app.slice/<name>.service/libpod-payload-<hex>), and Prometheus relabels it into
a `container` label. No podman socket needed.

cadvisor cannot bind per-interface like node_exporter, so each unit is a bridge
container publishing exactly 127.0.0.1:8080 and the host LAN address — never a
wildcard. Prometheus (a bridge container) reaches a host-published port at the
LAN address, so cadvisor-dankclaw/dankserver scrape 192.168.0.143:8080 /
192.168.0.235:8080. Only the families cadvisor actually exports for these
containers are graphed — CPU usage, memory working-set/limit, OOM — not network
or filesystem, which are absent under the host-PID layout.

Wired through the deployer (allowlist, deploy_observability case + /healthz gate,
dispatcher), the planner (both targets), and observability_quadlet.bats.
2026-10-20 22:51:42 -07:00
Frank b1fc1113c6 docs(vault): replace an invented --checkpoint-policy name with the three that exist
The replay law named `--checkpoint-policy floor`; config.cpp admits exactly
geometric-backoff (default), tip-only, keep-all and refuses anything else at
boot, so the recorded arm could never be deployed. Replaced with the real
semantics, priced from core/mem/prefix.{h,cpp}: the default keeps a geometric
ladder (9 snapshots for a 200k session at interval 2048 -- exactly the live
ledger line -- so a turn diverging d behind the tip can resume at tip-3d:
diverge at 120k, replay 21.7k); retention is not the write path, so keep-all
buys a bounded replay for pinned host RAM alone (196 slots x 111.98 MiB = 21.4
GiB against the store's eighth-of-host budget of 31.5 GiB, hard bound = room);
and shortening --checkpoint-interval cannot fix the deep case -- the coarsest
ladder scale is interval x 2^k under the depth, so the deepest gap stays about
half the prompt at every interval. §9 keeps only the three-name locator.
2026-10-20 22:34:13 -07:00
Frank 9f7457e39e docs(vault): answer "what are we doing wrong" on decode-vs-prefill
frank-archon Laws: add the measured interference census (decode-only step
15.5 ms vs 166 ms chunk steps; 4.7% of steps hold 34% of step time; median
decode 166 -> 13 t/s above 5k prefill tok/s, per-stream 6.7 t/s = one token
per shared step; TTFT p90 2.4 -> 18.0 s for arriving requests), the lag test
that kills the residency theory, the busy_cap arithmetic, and the correction
that --prefill-interleave is the decoder dial and auto is not.

Facts that change how an operator reads this box: /diagnostics exists and
reports link-below-max at 100% busy; latency metrics live under vllm:, cadence
under radiance:; preemption is vllm:num_preemptions_total (264 this boot, all
bursts) while the radiance gauge is instant depth; both root ports are x8 Gen5
max by board (31.5 GB/s ceiling, not 64); DDR is back at 4800 MT/s while the
4600 corruption fix is recorded -- verify before blaming garbage tokens.

§9 (LLM Performance Diagnosis): method corrected to the phase-ratio counters,
dead 1.0.13 numbers destroyed, four PromQL traps added (metric prefix, 1 s EMA
gauges vs 15 s scrape, counter resets across builds, 2-minute mean diluting the
chunk), instruments that cannot be reached from Prometheus, and the knob list
priced from the pinned 1.8.1 source instead of the stale guide.
2026-10-20 22:08:00 -07:00
Frank 687dbfbabe docs(scripts): correct frank-archon's step-sharing and slot-count comments
The env comment claimed BUSY_PREFILL_CHUNK=auto protects decode rows; the 1.8.1
source (core/sched/scheduler.cpp busy_cap) reserves 1+spec rows per decoding
sequence and 1/8 of the budget per waiting request, and refuses to shrink the
chunk for decoders alone -- auto is a prefill-speed dial. The interleave comment
carried a 512 that is not this build's auto value (0 until the held-step
counter asks; 64 in help mode). The serve header still described three decode
slots; MAX_SEQS has been 2 since the 2026-10-19 reslice. No behavior change.
2026-10-20 22:07:55 -07:00
dank 961713e92d Merge pull request 'test(e2e): cover notebook + prometheus guard-surface gaps (full-suite audit)' (#56) from feat/e2e-audit-coverage into main 2026-10-20 21:21:44 -07:00
dank 01db29e754 Merge pull request 'feat(grafana): starter dashboards (host/system, Loki logs, observability self-monitoring)' (#55) from feat/grafana-starter-dashboards into main 2026-10-20 21:20:53 -07:00
Frank ab06e4c221 test(e2e): cover the two guard-surface gaps found in the full-suite audit
Audit of all 12 e2e specs for the 'green-but-vacuous' class that broke
Grafana's SSO. The suite is otherwise sound (incident-driven, leakage-based);
two real coverage gaps surfaced:

- notebook.franksdank.cloud (open-notebook, deployed 2026-10-19) was a guarded
  vhost in NEITHER the 13-/14- GUARDED lists NOR any dedicated spec. A broken
  upstream would stay invisible - the exact 17-hermes class. Added to the 14-
  guard audit (unauth -> authentik gate, 'Open Notebook' leak marker) plus a
  new 21- that proves authenticated reachability to open-notebook's own login
  through the guard chain, with a positive app marker.
- prometheus.franksdank.cloud was only ever asserted '<500 and not a login
  wall' (13-/11-), which a 404 also passes. New 22- adds the app-is-really-
  there proof 20- gives Grafana: SSO handoff + its own /api/v1/status/buildinfo
  answers {status:success}.

Both specs wired into the standard project (playwright testMatch), which the
e2e_scaffold bats gate enforces. Verified live against the fleet; full bats
suite green.
2026-10-20 21:16:23 -07:00
Frank 1006e96312 feat(grafana): starter dashboards for host/system, Loki logs, and observability self-monitoring
llm-fleet.json already owns LLM capacity+spec-decode. These three cover the
domains that had NO dashboard:
- host-system: node_exporter CPU/Load/Mem/Disk/Net/Uptime/FS/FDs for both
  Dankclaw and Dankserver (templated by job node-dankclaw|node-dankserver)
- logs-overview: Loki volume + per-container error rate + a sample log panel;
  container is parsed from the JSON line at query time (journal streams have
  no container_name stream label)
- observability: the stack's own health - Prometheus target up/down + scrape
  interval drift + samples/s + TSDB, Loki ingest/discard, Grafana CPU/mem.

Panel queries verified live against Prometheus/Loki; provisioned dashboards
confirmed via /api/search. Alertmanager is running but NOT scraped by
Prometheus - noted in the dashboard, no empty panels built on it.
2026-10-20 21:10:06 -07:00
dank 7d85fed1c9 Merge pull request 'Fix Grafana Authentik SSO (500 + missing email claim) + RP landing test' (#54) from fix/grafana-authentik-sso into main 2026-10-20 21:04:02 -07:00
Frank d8196ce499 docs: Grafana reads claims from userinfo, not USE_ID_TOKEN
The login lands once authentik emits the email claim (the IdP fix); Grafana needs
no USE_ID_TOKEN toggle, and dropping it also avoids a *_TOKEN false-positive in
the literal-secret seam. Correct the two SSO notes accordingly.
2026-10-20 20:41:57 -07:00
Frank 6f442a14a3 fix(observability): repair Grafana Authentik OAuth2 relying-party login
The Authentik SSO button returned "Server Error" (500): the grafana provider had
a NULL authorization_flow and the authorize view crashed building a None flow
planner. After that, the login failed with "InternalError" — no email claim
reached Grafana, so it fell back to GET <api_url>/emails, which authentik 404s.

Unit env: request authentik real scopes (openid email profile, not the GitLab
user:email that crashes the authorize view); standard userinfo API_URL with no
?api=1 (Grafana v13 turns ?api=1 into a /emails 403); email/sub/preferred_username
attribute paths; ALLOW_SIGN_UP=true since the Anubis->Authentik guard fronts the
callback. scripts/authentik-grafana-sso-fix.sh idempotently re-applies the two
IdP-side causes (NULL authorization_flow; grafana-email-scope returning a bare
string instead of {"email":..}). Verified end to end: RP login lands on / and
/api/user answers 200 with the real email, user provisioned as Viewer.
2026-10-20 20:41:36 -07:00
Frank afe72781bc docs: correct Grafana Authentik SSO facts
The vault claimed the fork property-mapping evaluator was broken and userinfo
returned only sub, so email could never reach Grafana (outcome unverified). That
was a misdiagnosis: the real causes were the NULL authorization_flow (the
NoneType.slug crash) and the email scope mapping returning a bare string rather
than a dict. Record the four verified causes and fixes, point to the repair
script and the new landing spec, and drop the unverified caveat.
2026-10-20 20:40:15 -07:00
Frank 200039d589 test(e2e): cover Grafana relying-party SSO landing (spec 20)
13-sso proves an authenticated session reaches Grafana login page but never
follows the OAuth button, so the relying-party login could be dead (500, or an
email-claim gap bouncing to /login) while the suite stayed green. Spec 20 drives
/login/generic_oauth through the real Anubis+authentik round-trip and asserts a
live session (/api/user 200 with a non-empty email). Wire 20- into the standard
project (qa-e2e session).
2026-10-20 20:40:15 -07:00
Frank 91d9864bb0 fix(observability): repair Grafana Authentik OAuth2 relying-party login
The Authentik SSO button returned "Server Error" (500) because the grafana
provider authorization_flow was NULL and the authorize view crashed building a
None flow planner. After that, the login failed with "InternalError": no email
claim reached Grafana, so it fell back to GET <api_url>/emails, which authentik
404s.

Unit env: request authentik real scopes (openid email profile, not the GitLab
user:email that crashes the authorize view); read claims from the id_token
(USE_ID_TOKEN=true) and drop ?api=1 from the API URL (Grafana v13 turns it into
a /emails 404); pin email/sub/preferred_username attribute paths; allow
first-login provisioning since the Anubis->Authentik guard fronts the callback.

scripts/authentik-grafana-sso-fix.sh idempotently re-applies the two IdP-side
causes (NULL authorization_flow; the grafana-email-scope mapping that returned
a bare string instead of {"email":..}). Verified end to end: RP login lands on /
and /api/user answers 200 with the real email, user provisioned as Viewer.
2026-10-20 20:40:15 -07:00
dank 980491668c Merge pull request 'Alloy ships logs: root host-network service so it can read the journal' (#53) from feat/alloy-root-journal into main 2026-10-20 20:05:00 -07:00
Frank e494617599 docs(alloy): document the root journal-read cutover
Alloy page rewritten to the shipped root design (why root, the two
mounts that make it work, deploy-pending status); Observability current
state marks the log pipeline fixed-in-code, awaiting deploy.
2026-10-20 19:50:15 -07:00
Frank 9809ed9c74 test(observability): pin the root alloy shape
The seams encoded alloy-as-rootless (default.target, dankclaw_default,
loki:3100 by name, 0.0.0.0+PublishPort UI, After=loki.service). Update
them to the root/system/host shape and add a test pinning the
load-bearing properties: Network=host, the /etc/machine-id mount, the
journal mounts, loopback UI, and the system-install + root-mirror
deploy path.
2026-10-20 19:50:15 -07:00
Frank 634fef242b feat(alloy): run it as a root host-network service so it reads the journal
A rootless Alloy shipped zero lines: the journal files are
root:systemd-journal 0640 (other::---) and a rootless container carries no
supplementary groups, so sd_journal_open sees an empty view silently; the
rootless quadlet container logs also live in user-1000.journal, not the
ctr.log paths the old config tailed.

As root, one loki.source.journal reads system.journal + user-*.journal
and carries both. Network=host because Loki publishes 127.0.0.1:3100 and
no bridge container can reach the host loopback; the UI binds loopback
directly (no PublishPort). The /etc/machine-id mount is load-bearing —
sd_journal finds the journal dir by the local machine id; without it the
source reads 0 lines, health green.

deploy_alloy routes around the rootless loop: build rootless, mirror the
image into the root store (a root build fails on DNS in the build
container), retire the old rootless alloy unit, install to
/etc/containers/systemd. Validated with a throwaway root probe: 31,825
lines read, 26,966 pushed to Loki in 20s, zero drops.
2026-10-20 19:50:15 -07:00
dank 2fe68e69af Merge pull request 'Observability: vllm rule group for frank-archon + Loki datasource; correct stale log-pipeline facts' (#52) from fix/observability-vllm-loki into main 2026-10-20 19:39:38 -07:00
Frank 70dd7fbe6d docs(observability): correct current state and log-pipeline root cause
AGENTS.md: the rule set is now two groups (llm-fleet + vllm-fleet) and a
Loki datasource is provisioned beside Prometheus.
Observability.md: current-state census refreshed (three LLM targets up,
two rule groups, Loki datasource); logs still carry no data.
Alloy.md: replace the 'wrong glob' theory with the proven cause - a
rootless container cannot read the 0640 systemd-journal files (a rootless
busybox read returns DENIED), and rootless quadlet logs land in the user
journal the system-only source never reads. Closing it is a
permission/architecture decision, not a config glob.
2026-10-20 19:28:20 -07:00
Frank 2626332828 feat(grafana): provision the Loki datasource
Adds shared/grafana/provisioning/datasources/loki.yml at http://loki:3100
so logs are readable through Grafana Explore. Loki ships no web UI of its
own and the guarded vhost serves only the API; this is the human surface.
2026-10-20 19:28:20 -07:00
Frank eb4d9910cb feat(observability): add vllm-fleet rule group for frank-archon
radiance exports vllm:* metrics (verified on the live endpoint), not
llamacpp:*: nothing in llm-fleet could ever see frank-archon. Add a
vllm-fleet group - 1 recording + 4 alert (queueing, prefix-cache
collapse, preemption, p95 time-to-first-token) keyed to vllm:*.

Split the observability seam so llm-fleet rules bind llamacpp:* and
vllm-fleet rules bind vllm:*, and neither may evaluate the other's
namespace. Drops the retired no-metrics-for-archon doctrine the seam
still asserted and the vacuous namespace grep.
2026-10-20 19:28:20 -07:00
dank 5cc2b3c25a Merge pull request 'docs(openwebui): TTS reflects live frank-tts wiring (wilwheaton, LAN IP)' (#51) from docs/openwebui-tts-voice into main 2026-10-20 18:20:04 -07:00
Frank 18a29c40d2 docs(openwebui): TTS config reflects the live frank-tts wiring
The Text-to-speech section recorded audio.tts base_url host.containers.internal
and voice narrator1. Re-keyed the live config DB 2026-10-20: voice wilwheaton
(narrator1 retired), api_base_url the canonical host LAN IP 192.168.0.143:7772.
7772 is published so host.containers.internal also reaches it, but the LAN IP
matches this page's own on-host law. Browser-kokoro example voice updated to the
current pack; the dated 2026-10-18 verification is left as history.
2026-10-20 18:18:01 -07:00
dank 73a7ec3fc1 Merge pull request 'chore(frank-tts): repoint narrator slots to live voice packs' (#50) from chore/narrator-voice-repoint into main 2026-10-20 18:12:52 -07:00
Frank 130e9d739f chore(frank-tts): repoint narrator slots to the live voice packs
Frank replaced the narrator1/2/3 packs with cloned character voices
(wilwheaton, vaati, dr.kern, garrickhagon, jeffhays). The three narrator
slots are now carried by real packs: narrator1=wilwheaton, narrator2=vaati,
narrator3=dr.kern.

- frank-tts.env DEFAULT_VOICE narrator1 -> wilwheaton (a voice-less request
  400'd on the dead name; wilwheaton is narrator1's successor).
- Docs: correct the /health census and the podcast-speaker references in
  Frank TTS.md and Open Notebook.md to the live packs. Dated 2026-10-18/19
  measurement logs are left as history.

Open Notebook's live dank-duo speaker profile was repointed over REST
(Frank->wilwheaton, Comrade->vaati) - app-side, not in this commit.
Bats fixtures keep narrator1 as a self-contained sample pack; untouched.
2026-10-20 18:11:40 -07:00
dank 854890a155 Merge pull request 'chore(open-notebook): admin-side positioning + single-tenant note' (#49) from chore/open-notebook-admin-tenant into main 2026-10-20 17:44:45 -07:00
Frank d9f3d1be1e chore(open-notebook): position as admin-side; document single-tenant
open-notebook has no user accounts upstream (PasswordAuthMiddleware = one
shared password; SurrealDB has no user/account/owner table — verified in the
running image). One shared password = one shared workspace; the guard chain
gates access but cannot partition data, and Authentik identity is never
consumed. Multi-user is approved-but-unbuilt upstream (#712, post-v2); OIDC
declined (#607). So move the homepage card from the user grid to the admin
tier and add the single-tenant boundary to the vault page. Fix the card's
stale 'frank-zealot' -> frank-archon. Update the seam's incidental category
pin to the new intentional value. Password rotated on the host (not repo).
2026-10-20 17:38:22 -07:00
dank afa3a4521c Merge pull request 'docs(authentik): record 7-day login-session TTL + setter script' (#48) from chore/authentik-session-ttl into main 2026-10-20 16:57:29 -07:00
Frank 9cc8ca1c88 docs(authentik): record the 7-day login-session TTL and add a setter script
The login-session lifetime (raised to days=7 on 2026-10-20) is owned by the
default-authentication-login stage's session_duration, not a global setting and
not SESSION_COOKIE_AGE. Authentik exposes no SESSION_SAVE_EVERY_REQUEST, so the
window is absolute. session_duration is a STRING idiom; a timedelta stores a form
timedelta_from_string rejects, crashing login. Add scripts/authentik-set-login-session-duration.sh
that validates the form before writing (DB state, not repo-managed, re-run after
a blueprint re-import). Trim the page back under the OKF 500-line cap. tests/run.sh green.
2026-10-20 16:55:44 -07:00
dank 9c05c40aa0 Merge pull request 'docs(agent): scaffold agent-skills config in the vault' (#47) from chore/agent-skills-vault-setup into main 2026-10-20 16:11:47 -07:00
Frank 63219787af docs(agent): scaffold the agent-skills config as three vault pages
Point the mattpocock-lineage skills (triage, to-tickets, to-spec,
wayfinder) at repo facts instead of a parallel docs/ tree: Gitea is the
issue tracker (credential-safe curl shape, token-only writes), the five
canonical triage roles keep their default label strings (created on the
tracker today), and GLOSSARY.md/docs/adr/ stay lazy with this vault
owning operational facts. AGENTS.md gains the ## Agent skills pointer
block; indices regenerated.
2026-10-20 16:09:30 -07:00
Frank e914ecbca6 docs(pi-hole): the record-load trap rides a container restart, not a boot
The 2026-10-19 write-up inferred a host restart from FTL's 16:25 start line. It was wrong: uptime -s reads 2026-10-18 01:24 (two days up) and the user journal shows 'Stopping pihole.service' at 16:25:23 with StartedAt 16:25:27 — a plain container restart inside the open-notebook deploy window. That moves where the verification belongs: after every pihole restart, not just after a reboot.
2026-10-20 10:56:39 -07:00
Frank 1eebf3b129 docs(netns): the by-name path to frank-dragoon is verified from the consumer
open-notebook resolves frank-dragoon-halogen to 10.89.1.39, GET :8731/v1/models answers, and the engine logs the app's POST /v1/embeddings 200. That makes the 2026-10-19 dial census a pre-move record: pasta's forwarding rule is unchanged, the endpoint was moved out of pasta, so the 'menu is frank-tts + frank-archon' line is now past tense. Frontmatter date was still 2026-09-02 against October content; bumped.
2026-10-19 22:48:44 -07:00
Frank af5a1f69a2 docs(pi-hole): FTL can boot with the record file mounted and serve none of it
The 2026-10-19 reboot left every franksdank.cloud name NXDOMAIN for LAN clients while external names kept answering from cache; the file was mounted and readable and dns.hosts was intact, and 'pihole restartdns reload' restored every name in seconds. Mechanism not established. Instrument and remedy now on the owner page, plus a failure-mode row for the FTL rate-limit case seen beside it (10.0.2.x = a pasta container's forwarder).
2026-10-19 22:46:57 -07:00
Frank 0930510f9b docs(vault): the embedding lane has a consumer, and two shapes that bite
open-notebook's embedding default now points at qwen3-embedding-0.6b on frank-dragoon's XDNA2 lane, proven through the app's own paths (model test 1024 dims, worker 'Successfully embedded', vector search ranking two unrelated notes 0.659/0.292 and 0.906/0.318). Records the two app-side traps: defaults store the model ID and a name is accepted then fails in the worker, and POST /api/embed 500s on an upstream RecordID typing bug after the vector is already written. frank-dragoon's lane section moves from 'off until the mount fix' to armed-and-serving with the boot-ledger contract and the measured idle RSS; the page's stale 'not on dankclaw_default' reachability claims are retired.
2026-10-19 22:46:57 -07:00
dank af327bcc5e Merge pull request 'feat(frank-dragoon): open the NPU node the way rootless can actually reach it' (#46) from feat/frank-dragoon-npu-lane into main 2026-10-19 22:35:49 -07:00
Frank fcf94562d1 feat(frank-dragoon): open the NPU node the way rootless can actually reach it
The armed boot reached the engine and died in its own preflight:

  halogen npu: /dev/accel/accel0 is here, but this process cannot open it
    (crw-rw---- nobody:nogroup).

The node is root:render 0660 and a rootless container maps exactly one uid/gid,
so `render` is unmapped inside it and `--group-add keep-groups` — the vendor's
advice, and the ComfyUI pattern for /dev/kfd — cannot grant a group the
namespace does not map. Measured 2026-10-19 from a `dank` shell already in
`render`: without the flag denied, with the flag denied, with the udev mode
0666 the open succeeds (OPEN_OK). Same wall 99-comfyui-gpu-access.rules already
solved for the GPU compute nodes, so the accel node gets its counterpart:

  dankclaw/udev/98-halogen-npu-access.rules — KERNEL=="accel[0-9]*",
  SUBSYSTEM=="accel", MODE="0666"

The deployer installs the rule, reloads rules, re-triggers the node and only
then checks the mode; the serve script refuses any node that is not
other-writable, naming the rule, so this cannot return as a crash loop (189
restarts while chat was down is the failure mode being closed).

Both checks read the permission bit directly: `[ -o FILE ]` is not
"other-writable" — it means "exists" and `-o` doubles as the OR operator inside
test, which is why the first cut of the gate refused everything.

Dragoon seam: 33 -> 35 tests. The fixture node is 0666 like the installed one,
a 0660 node is refused with the rule named and nothing rendered, and the rule
file's shape plus the deployer's install/trigger are asserted.
2026-10-19 22:35:06 -07:00
dank 861920797d Merge pull request 'fix(deploy): the dragoon gate reads the deployer's own payload-dir twin' (#45) from feat/frank-dragoon-npu-lane into main 2026-10-19 21:49:16 -07:00
Frank 3ef711f146 fix(deploy): the dragoon gate reads the deployer's own payload-dir twin
The containment gate added in #44 parsed scripts/frank-dragoon.env through a
$CHECKOUT variable that does not exist in deploy-remote.sh, so the very next
deploy died at `set -u` before it touched the unit:

  ./scripts/deploy-remote.sh: line 1445: CHECKOUT: unbound variable

deploy-remote.sh runs from the checkout root and every other path in that block
is a parity-twin default (LLM_DRAGOON_CKPT/_VISION/_TOK_DIR), so the payload dir
gets the same treatment: LLM_DRAGOON_MODEL_DIR, defaulted to the deployed path,
compared directly. The seam now asserts that twin against the env file next to
the NPU twins it already checks, and the containment comparison itself is
verified both ways (inside accepted; the old outside path and a shared-prefix
sibling both refused).
2026-10-19 21:48:47 -07:00
dank 823fd52d0e Merge pull request 'fix(frank-dragoon): stage the NPU lane inside the payload mount' (#44) from feat/frank-dragoon-npu-lane into main 2026-10-19 21:46:25 -07:00
Frank bda011d26f docs(vault): the NPU lane's one door into the container, and the pin that landed
frank-dragoon's lane section: the staging dir must live inside the payload dir
(the image ships no /models, so a nested :ro bind has no mountpoint to land on),
the entrypoint's rejection of HALOGEN_NPU_DIR, and the fabric-clock paragraph
corrected — the vendor's root host unit is installed and reports `held (manual)`
as of today, so "nothing is held" is retired along with the hand-write it
replaced.

dankclaw-iommu-translation-off: the deferred `pp_dpm_fclk` pin is no longer
deferred; the fact is owned by [[frank-dragoon]] and this page links it.
2026-10-19 21:43:36 -07:00
Frank b19f1c49d6 fix(frank-dragoon): stage the NPU lane inside the payload mount
The lane's first boot died at container init, not at the engine:

  runc create failed: ... create mountpoint for /models/npu mount:
  mkdirat .../merged/models/npu: read-only file system

The image ships no /models, and the serve script binds the payload dir
there read-only, so a nested `-v $NPU_DIR:/models/npu:ro` had to create
its own mountpoint underneath a read-only parent — impossible. The
engine's own entrypoint confirms the container path is fixed: it rejects
HALOGEN_NPU_DIR outright ("our NPU models live under /models/npu/<id>/").

Fix is the mount shape, not the engine: FRANK_DRAGOON_NPU_DIR now lives
inside FRANK_DRAGOON_MODEL_DIR, so the payload's single read-only mount
puts the files at /models/npu/<id>/ by itself and the nested bind is
gone. Verified on the running container: after the move, `podman exec
frank-dragoon-halogen ls /models/npu/qwen3-embedding-0.6b` answers with
the staged tree while the lane is still armed off.

Both gates refuse the old shape by name (serve + deployer parity) so a
future payload swap that moves the model dir cannot reintroduce a crash
that lands after the deployer has already stopped the running unit.
2026-10-19 21:42:31 -07:00
dank b1ca4e02fa Merge pull request 'feat(frank-dragoon): run the XDNA2 NPU lane beside the flash model' (#43) from feat/frank-dragoon-npu-lane into main 2026-10-19 21:28:41 -07:00
Frankandomp 7f65cf0252 docs(vault): the pasta endpoint has no address an app container can use
The 2026-10-19 legs table blamed the frank-dragoon refusal on a root/rootless
netavark rule-set split. It is not that: the container is rootless (the root
unit's User=dank runs podman run), it is on pasta, and pasta installs no DNAT
for rootless-bridge traffic while refusing a second network outright. The
gateway alias does reach it (200) and is link-local, which is the same dead end
the Open Notebook URL guard hit. Measured with the move onto the bridge now in
the deployer, including the /proc/net/tcp line that shows 8730 is loopback-bound
and so stays private after the move.

Co-Authored-By: omp <omp@dankclaw>
2026-10-19 21:27:07 -07:00
Frankandomp 346e1d22f4 feat(frank-dragoon): put the endpoint on the app bridge so app containers can name it
An endpoint on podman's rootless default (pasta) has no address another
container can use, measured on 2026-10-19 from inside open-notebook:

  - 192.168.0.143:7770            connection refused (pasta forwards the
                                  published port in the host netns; no netavark
                                  DNAT exists for traffic arriving from a
                                  rootless bridge)
  - host.containers.internal:7770 200, but the alias resolves to 169.254.1.2 —
                                  link-local, which open-notebook's URL guard
                                  refuses to store
  - frank-dragoon-halogen:8731    name unknown: different netns
  - --network pasta --network dankclaw_default
                                  "cannot set multiple networks without bridge
                                  network mode" — pasta cannot be added to

So reaching the NPU endpoints from an app container means moving the container
onto dankclaw_default (FRANK_DRAGOON_NETWORK; empty keeps today's shape). This
is not an exposure change: the engine's unauthenticated token port binds the
container's own loopback (/proc/net/tcp: 127.0.0.1:8730 against 0.0.0.0:8731),
so only the API port becomes name-resolvable — the same trust the published
7770 already carries on the LAN. The deployer refuses a network that does not
exist rather than starting a unit that restart-loops on it.

Co-Authored-By: omp <omp@dankclaw>
2026-10-19 21:27:07 -07:00
Frankandomp 53ef121180 feat(frank-dragoon): run the XDNA2 NPU lane beside the flash model
halogen-flash-server already ships an NPU path on the pin we run: for each
model named in HALOGEN_NPU_MODELS it loads the vendor's precompiled AIE
programs (XDNA2 executes only AIE-compiled graphs) and answers them on
/v1/embeddings inside the SAME container behind port 7770. That gives the
fleet an in-house embedding endpoint without touching the iGPU trunk.

The lane is opt-in and one line wide: HALOGEN_NPU_MODELS blank is the
pre-lane endpoint byte for byte (no device, no mounts, no engine env).

The three host preconditions the vendor names are gated in the serve script
and mirrored in the deployer (the 2026-10-01 twin law), each refused by name:
  - the staged model files. /models is read-only and HALOGEN_DOWNLOAD stays
    unset so the container opens no outbound connection at boot, so
    scripts/download-frank-dragoon-npu.sh stages them from the pinned
    revision with the catalog's own byte counts and sha256s (the engine
    re-checks both at boot);
  - the distro XRT's libxrt_coreutil/libxrt_core/libxrt_driver_xdna, each
    mounted twice — where the image looks for XRT and at its own path —
    because those libraries find each other where they were installed and
    an /opt/xilinx/xrt of symlinks dangles inside the container;
  - the GPU fabric clock HELD. On Strix Halo the iGPU and the NPU co-running
    hang the machine (and corrupt NPU results) while FCLK changes speed, and
    halogen refuses to start in that state; it cannot read host sysfs from
    inside, so the hold is proven host-side. dankclaw/host/ carries the
    vendor's halogen-fabric-clock{,.service} verbatim at tag v0.16.2 (sha256
    a3a4f7f4e0142e71… / 5f9f1cc4c85a1c02…); the deployer installs, enables
    and gates it only while the lane is named, and releases the clock plus
    drops the boot hook when it is not.

After health, the deployer's lane gate is a real embedding through
/v1/embeddings — a 200 on /health proves nothing about the NPU programs.

Planner: dankclaw/host/halogen-fabric-clock* re-dispatches frank-dragoon
(the deployer installs it); dankclaw/host/grub and the fetch script plan
nothing.

Seam: 11 new tests — the off arm really renders nothing lane-specific, the
on arm renders the device/mounts/env, every missing precondition refuses by
name, the deployer twins and the host unit shape are pinned.

Co-Authored-By: omp <omp@dankclaw>
2026-10-19 21:22:50 -07:00
dank fa093e617d Merge pull request 'docs(vault): halogen NPU lane on the current pin, and the greedy-schema law' (#42) from feat/open-notebook-portal into main 2026-10-19 21:14:52 -07:00
Frank 6215f1a6c8 docs(vault): halogen has an NPU lane on the pin we already run, and it is greedy-only
- frank-dragoon: measured the response_format contract. json_schema and json_object answer
  schema-valid JSON at temperature 0 and draw a 400 at any sampled temperature, and because
  the sampler default is server-side an absent temperature is sampled. The page claimed
  "response_format is unsupported", which sends the next agent past a working feature.
- frank-dragoon: new "The NPU lane" section. The pinned 0.16.2 image already ships the
  five-program AIE catalog including qwen3-embedding-0.6b, so /v1/embeddings is one env var
  plus the accel device, the distro-XRT mount and the fabric-clock hold; embeddinggemma-2
  needs 0.18.3+, the stock-kernel-driver memory-budget fix 0.18.8+. Host census recorded:
  no /opt/xilinx/xrt, distro XRT 1:2.21.75 with libxrt_driver_xdna, clocks unheld.
- Open Notebook: scriptwriter probe verdict — greedy-only, and open-notebook's model record
  is {credential, name, provider, type} with no per-row config, so it cannot ask for greedy;
  frank-archon stays the writer. The embedding gap now has a named door instead of "build a
  new llama.cpp slot".
- Frank TTS: "the NPU is not a door at all" was too broad. It is not a door for the TTS
  family; halogen's precompiled catalog is a door on the same device.
- System Specs: NPU runtime row corrected to the distro XRT actually installed, and the
  stale "frank-zealot serves 27B" line retired in favour of [[frank-zealot]] as sole owner.

Tests: ./tests/run.sh, 455 passed.
2026-10-19 16:40:48 -07:00
dank 8bac97ec87 Merge pull request 'feat(open-notebook): guarded vhost, single origin, live homepage card' (#41) from feat/open-notebook-portal into main 2026-10-19 16:24:11 -07:00
Frank 69739237c8 docs(open-notebook): record the deployed shape and what the first render proved
The page described an undeployed service whose scriptwriter was going to be
frank-zealot. Both halves are now false, and the reason is worth more than the
correction.

- Status, ports, coordinates: deployed 2026-10-19, guarded vhost, API published
  on loopback only. The first episode is the proof: 747 s of speech in 301 s of
  wall clock (2.5x real time, knobs at 1), download route 200 with the bearer
  and 401 without.
- New section for the guard chain: provider-before-route, one upstream because
  the image rewrites /api/* itself, and the API_URL law — autodetect appends
  :5055, which is seerr's port on this host, so an unset API_URL yields a UI
  that loads and an app that cannot call itself.
- Provisioned records table (credentials, model rows, dank-duo, dank-daily) and
  the two contract traps measured on the live API: generate takes profile
  NAMES, and the fleet has no /v1/embeddings, so every note save fails its
  embed_note command while renders from content work fine.
- Why frank-archon writes the scripts: frank-zealot is reachable from this
  netns only at 169.254.1.2, and open-notebook's URL guard refuses to store a
  link-local base URL with no override. Census lands on [[Container Network
  Namespaces]], which also gains the root-vs-rootless publish rule — a
  root-published port is invisible to a rootless container, so "publish the
  port" was the wrong remedy as written there.
- Frank TTS: the request's model field is ignored; only voice selects a pack.

Tests: ./tests/run.sh, 455 passed.
2026-10-19 16:22:38 -07:00
Frank e5580abb6f feat(open-notebook): put the podcast generator behind the guard chain
The service came up internal-only: UI on the LAN at 8502, API at 5056, both
published on every interface, with only its own shared password in front. That
put the episode library outside the fleet's auth chain and one port scan away
from anything on the network.

- Caddy: guarded vhost notebook.franksdank.cloud (Anubis -> Authentik; provider
  notebook-proxy, internal_host caddy:8017) plus the post-auth route to the
  published UI port. One upstream is enough, because the image's own Next.js
  layer rewrites /api/* to the FastAPI beside it — so the API needs no outward
  face at all, and 5056 moves to loopback, where only the deployer's health gate
  dials it.
- scripts/open-notebook.env: API_URL and CORS_ORIGINS become the single guarded
  origin. Left to itself the app auto-detects <host>:5055, the port seerr holds
  on this host; a LAN-origin API_URL would also take the browser around the
  guard chain, where its authenticated session does not ride.
- addn-hosts: the LAN A record. The homepage card goes live and links the vhost.
- Seam: the API origin is derived from whatever the Caddyfile routes to 8502, so
  renaming the vhost moves the test instead of leaving it blessing a stale name,
  and a new test proves the vhost hands the WHOLE request to Anubis.
2026-10-19 16:13:53 -07:00
dank ee6d8610b3 Merge pull request 'fix(open-notebook): the database probe has to be exec-form JSON' (#40) from feat/open-notebook-portal into main 2026-10-19 16:07:53 -07:00
Frank 933420fd29 fix(open-notebook): the database probe has to be exec-form JSON
podman wraps a bare HealthCmd as CMD-SHELL (measured on Dankclaw's podman 5.7:
--health-cmd "/surreal is-ready" stores {"Test":["CMD-SHELL","/surreal is-ready"]}),
and the pinned SurrealDB v2 image is distroless — no /bin/sh, not even `ls`.
So the probe could never run: every 30 s interval exited 1 in ~30 ms with empty
output while the database was listening and answering, and open-notebook's first
deploy died at the gate ("did not become healthy in 90s"). The hand-check
`podman exec … /surreal is-ready` printed OK, which is why the old comment
believed the probe worked.

The JSON array survives the Quadlet generator (--health-cmd "[\"/surreal\",\"is-ready\"]")
and stores ["CMD","/surreal","is-ready"], which execs directly: a throwaway
instance of the pinned digest went healthy on that form. The seam is re-pinned
to the shell-free contract; the old assertion forbade quotes in any HealthCmd,
which is what pinned the broken shape.
2026-10-19 16:07:28 -07:00
dank 9ffa99902d Merge pull request 'fix(frank-archon): the state-dir guard names what the directory is now' (#39) from fix/frank-archon-state-dir-comment into main 2026-10-19 15:02:01 -07:00
Frank b8f564f3bd fix(frank-archon): the state-dir guard names what the directory is now
The refusal message still named "the /data state dir"; /data left with the
prefix disk tier. The serve script mounts /models ro, the rad-tune cache and the
chat template only, and tests/frank_archon_unit.bats asserts no :/data mount
exists, so the line an operator reads at the moment a boot refuses pointed at a
mount that is gone.

Text only. The guard still refuses: the block was run standalone with the
variable unset and every neighbour set - it prints the new message and exits 1.
bash -n clean, ./tests/run.sh green (454 tests).
2026-10-19 15:02:01 -07:00
dank d42d053abe Merge pull request 'docs(frank-archon): reconcile the runbook with the live 1.8.1 two-slot shape' (#38) from docs/frank-archon-181-two-slot into main 2026-10-19 14:56:59 -07:00
Frank 7eed636bd1 docs(perf): destroy §9's vendor absolutes, keep the ratios
The t/s ladder and the TTFT delta triple measure a configuration neither
deployed nor compared; per the purpose test they are dead weight. What survives
is the knob pricing (halving the shared chunk costs roughly a fifth of prefill
throughput), the long-prompt-only shape of the span win, and the method. Run-on
now names the era the census was taken in.
2026-10-19 14:55:43 -07:00
Frank d9b82b372e docs(frank-archon): reconcile the runbook with the live 1.8.1 two-slot shape
The endpoint moved to radiance 1.8.1 with MAX_SEQS=2 and the
--vram-weights-mib/--vram-kv-mib pair removed (PR #30); env, serve script and
bats carried the new shape while the page and the AGENTS.md block still
described the 1.0.13 3-slot era, so every point-of-use number was wrong.

Re-derived from the live checkout and the 2026-10-19 11:10 boot ledger, not
from the stale pre-#31 draft: runtime block (digest, version oracle, the two
CLI breaks that are boot refusals), shape table (demand-signal rows, derived
VRAM split, restored spec/batch row, host-only prefix tier, step sharing),
container contract (no /data mount), the measured 2-slot ledger, a new Eras row
for the retired 1.0.13 era whose rollback is a digest plus a trio rewrite, and
the laws: pool sizing and the per-card budget re-scoped to the pair era, a new
resolver law (funds ~90% of the worst case, host RAM below a slab step is inert,
MAX_SEQS is a demand signal), the sludge law marked measured-at-3-slots with the
fix ordered-not-verified, the knob-availability law flipped, and the slots law
now pricing the third slot in KV (4.20 -> 2.76 GiB).
2026-10-19 14:55:43 -07:00
dank 3d10a4ca4f Merge pull request 'docs+fix(frank-zealot): the adapter's measured effect, and a unit description that stopped lying' (#37) from feat/zealot-uncensored-lora into main 2026-10-19 14:47:59 -07:00
Frank e5b731216c fix(frank-zealot): the unit description still described the 27B era
`systemctl status` said `2x262144 slots over one 524288 pool, unquantized f16
K+V, MTP off` — three payload eras and two reslices out of date, and still
wrong about the thing that decides whether the endpoint can be trusted (the KV
dtype and whether the drafter is armed). It now quotes the env: 3x200704 over
one 602112 pool, f16 K + q8_0 V, MTP armed, adapter, Q8_0 vision.

Touching the unit selects the service in the planner, so this deliberately does
NOT come with a deploy: the hub is serving live conversations. The revision
marker stays behind until the next real frank-zealot change.
2026-10-19 14:47:30 -07:00
Frank ce8d82d4a7 docs(frank-zealot): record what the adapter did, and where provenance is read
Before/after on the two probe prompts (both opened `I cannot provide…`; neither
now carries a marker — one ran to its 2048-token cap, the other finished at
1930), the residual `Important Disclaimer` heading named for what it is (style,
not the circuit), and the same-boot proof that chat/tools/vision still answer.
Provenance now points at the boot ledger: the fork prints every
`prism.abliterate.*` key at each start, so the PTQ1_0-vs-PQ2_0 mismatch is
visible to anyone who boots the service — the page links that instrument
instead of restating it.
2026-10-19 14:47:30 -07:00
dank 02d04bc1f6 Merge pull request 'feat(frank-zealot): apply the refusal-direction adapter (rank-1 LoRA) at load' (#36) from feat/zealot-uncensored-lora into main 2026-10-19 14:43:26 -07:00
Frank 428ac22736 Merge remote-tracking branch 'origin/main' into feat/zealot-uncensored-lora 2026-10-19 14:43:06 -07:00
Frank 5d5cac4da3 docs(frank-zealot): the adapter is a payload row, a shape line, and two laws
- Shape table: an Adapter row (bytes, sha, what it edits, and the PTQ1_0
  provenance asymmetry), the fetch line now names both fetchers, the flag list
  carries --lora-scaled.
- One paragraph: the adapter changes behaviour, not the budget equation, and the
  probe that proves it must budget >= 2048 tokens because the thinking channel
  eats a short budget whole and returns empty content with finish_reason=length.
- Eras: the new era's rollback is one env line on a file already loaded.
- Laws: provenance is metadata, not a gate (tensor names, not hashes; an
  embedding rank would crash, which is why the publisher left token_embd alone);
  the fork pin now has a third reason, since older builds refuse the adapter with
  `unsupported prism.hadamard.version: 2`. Base-alignment law updated: the 9B
  refusal escape hatch was a fetch, and it has now been taken.
- AGENTS.md: topology payload cell and the zealot operator block.
2026-10-19 14:42:56 -07:00
Frank 3eaba57925 feat(frank-zealot): apply the refusal-direction adapter at load
Frank ordered the adapter; this adds it to the shape as one knob and one flag.

- `LORA_FILE` / `LORA_SCALE` in the env, `--lora-scaled <file>:<scale>` as ONE
  argv token so a scale can never reach the engine without its adapter. The
  rollback follows the mmproj convention exactly: `LORA_FILE=` (explicitly
  empty) drops the flag entirely, no rebuild, and `LORA_SCALE=0` is the card's
  own "inert but present" value.
- New fetcher `scripts/download-frank-zealot-uncensored-lora.sh`: `-f`, byte
  count, sha256, `--verify-only`, idempotent. The file is NOT LFS (a plain git
  object), so the pinned sha is the content hash, not an LFS oid.
- The deployer gates on the adapter beside the pack and the projector, and its
  error names the ADAPTER's fetcher — sending an operator to the bonsai9 script
  for a 3 MB file is how a payload ships without it.
- Seam: path+scale pinned as one token, the scale knob including 0, absence on
  the empty opt-out, a refusal to boot on a missing adapter, and the env ↔
  deployer anti-drift pair extended to LORA_FILE/LLM_ZEALOT_LORA. 454/454.

Provenance, stated where it will be read: the adapter's own metadata names
`Ternary-Bonsai-2-9B-PTQ1_0.gguf` (3561df7a…) as the pack the direction was
fitted against. This endpoint serves the PQ2_0+MTP+Q8_0 packing (c79fcdfa…),
which is not that file and is not on this host. The loader matches tensor names,
not hashes, so it applies — and build 10777 is the build the card verified on —
but the strength is calibrated on a sibling quantization. The proof is therefore
the refusal probe against the live endpoint, captured before and after, and not
the card's table.
2026-10-19 14:42:56 -07:00
dank 9b9c32005f Merge pull request 'docs(frank-tts): the engine computes on the gfx1151 now; the NPU question is closed' (#35) from docs/frank-tts-igpu-followup into main 2026-10-19 14:38:33 -07:00
Frank ea2fc6440b docs(frank-tts): record the iGPU cutover, close the NPU question
The cutover PR merged the runtime; this carries the surface that has to be
true afterwards.

- Engine page: the shape is now `--device Vulkan0` on the gfx1151 as a
  co-tenant beside frank-dragoon; the registry/thread/streaming laws gain a
  fourth — the device law — whose numbers stay in the verdict report, and the
  deploy record gets the cutover steps including the `glslc` configure gate.
  The CPU-era arm/rollback prose keeps the qualitative argument and drops the
  dead numbers per the purpose test.
- Endpoint page: "Where the engine computes" now says iGPU + co-tenancy, and
  states the NPU verdict in one paragraph instead of a research tour.
- Quadlet comment: it claimed supplementary groups are what grant render
  access. On this host the udev rule world-writes the render nodes and the
  container's `id` shows no groups at all, so keep-groups is belt-and-braces;
  the RSS note now matches the device shape instead of the CPU trial.
- Verdict report: gate 2 (contention) recorded as measured after the cutover,
  and the build-stage prediction corrected — the package is `glslc`, not
  `libvulkan-dev glslang-dev`.
- index.md regenerated (one description changed).
2026-10-19 14:37:29 -07:00
dank 6248f02ca2 Merge pull request 'feat(frank-tts-engine): synthesize on the gfx1151 iGPU (Vulkan), CPU stays as the rollback' (#34) from feat/frank-tts-vulkan into main 2026-10-19 14:27:11 -07:00
Frank 25df9d05a2 feat(frank-tts-engine): synthesize on the gfx1151 iGPU via Vulkan
The NPU path is a dead end for this payload (verdict:
report/tts-npu-spike-verdict-2026-10-19.md); the iGPU is the accelerator that
exists on this box today, and it is already shared with frank-dragoon.

- Containerfile.engine: -DGGML_VULKAN=ON, plus the three packages the door
  needs to configure — libvulkan-dev, spirv-headers, and glslc, which Ubuntu's
  glslang-tools does NOT ship (find_package(Vulkan COMPONENTS glslc REQUIRED)
  fails without it) — and libvulkan1 + mesa-vulkan-drivers (the RADV ICD) at
  runtime. No NVIDIA ICD on purpose: one Vulkan device in the enumeration is
  what makes the NAME Vulkan0 safe to write down.
- tts-engine-start.sh: QWENTTS_DEVICE -> --device, default Vulkan0. A value
  that is set but EMPTY passes no flag at all, which is the CPU rollback; that
  is why the default is written as an -v test and not with :=, because := also
  fires on an empty value and would silently undo the rollback.
- quadlet: AddDevice=/dev/dri + GroupAdd=keep-groups (a rootless container that
  drops its supplementary groups enumerates no render device). The node is not
  named: this host is hybrid, renderD128 is the NVIDIA card and renderD129 the
  amdgpu iGPU.
- env: QWENTTS_DEVICE=Vulkan0 plus the device law in the header, including the
  rollback line.
- tests: the two CPU-only assertions now assert the Vulkan shape, and a new one
  pins the rollback (empty device => no --device in argv). 451/451 green.

Measured before writing any of it: the configure in the digest-pinned base
answers "Found Vulkan: ... found components: glslc" and "Including Vulkan
backend"; gate 1 (RADV GFX1151 visible to a rootless container off this base)
is recorded in the verdict report.
2026-10-19 14:26:58 -07:00
dank 7601edfe28 Merge pull request 'docs(LLM): the decode stall a prefill causes, and the knobs that turn it' (#27) from docs/radiance-prefill-decode-census into main 2026-10-19 14:21:45 -07:00
Frank e133d2412d Merge origin/main into docs/radiance-prefill-decode-census
Two conflicts, both from the 2026-10-19 vault compaction on main:

* frontmatter description — main shortened it to satisfy OKF DESC
  (max 120 chars); this branch's longer wording would have failed the
  seam. Kept main's shape and folded the new §9 in by naming
  step-shape attribution instead of profiler attribution.
* the Related bullet for [[vLLM Porting and Compile Triage]] — that
  page was destroyed on main, so the link is dropped rather than
  resurrected; this branch's [[frank-archon]] row is kept.

Index rows regenerated with scripts/gen-vault-index.py.
2026-10-19 14:20:05 -07:00
Frank e3a02e25c7 docs(LLM): census the decode stall a prefill causes, and the knobs that turn it
Live step-shape census on frank-archon (radiance 1.0.13, boot of 2026-10-15):
decode-only steps run 18.7 ms, prefill+mixed steps 317 ms — 60 % of engine
wall-clock inside 8 % of the steps — and a day of Prometheus shows decode at
13.3 t/s median while prefill runs >=1000 t/s against 106-240 t/s when it does
not. The lag test says the penalty is confined to the prefill window: no
lingering expert-residency tail, and preemptions are zero.

Adds the method as LLM Performance Diagnosis S9 (counter arithmetic, the lagged
bucket correlation that separates step contention from residency churn, and the
four radiance knobs with their published costs), and the endpoint's own numbers
plus the verdict as two frank-archon laws: --busy-prefill-chunk and
--prefill-interleave are absent from this pin (proven via the running
container's --help), so MNBT is the only crank here and an image bump is gated
by the 1.2.0 numerics dead end.

No behavior change; docs only.
2026-10-19 04:11:20 -07:00
149 changed files with 9362 additions and 936 deletions

No files matched your search

+51 -26
View File
@@ -27,7 +27,7 @@ merge absorbed the former `vllmScripts` repo, history preserved).
|--------|---------|---------|---------|------| |--------|---------|---------|---------|------|
| Dankserver | `frank-archon` | radiance, digest-pinned, root unit, TP=2 | publisher `qwen3.8-next-flash-fp8-iq4r-moe.rad` | 7770 | | Dankserver | `frank-archon` | radiance, digest-pinned, root unit, TP=2 | publisher `qwen3.8-next-flash-fp8-iq4r-moe.rad` | 7770 |
| Dankclaw | `frank-dragoon` | halogen-flash-server 0.16.2, digest-pinned, root unit | `qwen38-flash-next-v2-abliterated.hgn` + n-gram + vision tower | 7770 | | Dankclaw | `frank-dragoon` | halogen-flash-server 0.16.2, digest-pinned, root unit | `qwen38-flash-next-v2-abliterated.hgn` + n-gram + vision tower | 7770 |
| Dankclaw | `frank-zealot` | llama.cpp PrismML fork build 10777, root unit — the only loader for this payload | Ternary-Bonsai-2-9B PQ2_0 + Q8_0 mmproj | 7771 | | Dankclaw | `frank-zealot` | llama.cpp PrismML fork build 10777, root unit — the only loader for this payload | Ternary-Bonsai-2-9B PQ2_0 + Q8_0 mmproj + rank-1 refusal adapter | 7771 |
Payload identity, shape, eras, laws and rollback live in ONE page each: Payload identity, shape, eras, laws and rollback live in ONE page each:
[[frank-archon]], [[frank-dragoon]], [[frank-zealot]]. `scripts/<service>.env` [[frank-archon]], [[frank-dragoon]], [[frank-zealot]]. `scripts/<service>.env`
@@ -166,19 +166,22 @@ bare compose container never returned; 2026-09-11: five observability
services had no units). The observability services now have units under services had no units). The observability services now have units under
`dankclaw/observability/quadlet/` and their compose definitions were removed — `dankclaw/observability/quadlet/` and their compose definitions were removed —
one owner per container name. The stack is **configured and delivering as of one owner per container name. The stack is **configured and delivering as of
2026-09-17**: a Grafana datasource and the `llm-fleet` dashboard are 2026-09-17**: Grafana datasources (Prometheus and Loki) and the `llm-fleet`
provisioned from `shared/grafana/provisioning/`, `shared/prometheus/rules/ dashboard are provisioned from `shared/grafana/provisioning/`, and
llm-fleet.yml` loads 5 alert rules + 1 recording rule, and firing rules are `shared/prometheus/rules/llm-fleet.yml` loads two rule groups — `llm-fleet`
(llama.cpp/halogen, `llamacpp:*`) and `vllm-fleet` (radiance, `vllm:*`) — alert
+ recording rules, and firing rules are
delivered: Prometheus → Alertmanager (`dankclaw/observability/quadlet/ delivered: Prometheus → Alertmanager (`dankclaw/observability/quadlet/
alertmanager.container`, loopback :9093) → host-side Signal relay alertmanager.container`, loopback :9093) → host-side Signal relay
(`scripts/signal-relay.py`, user unit, 192.168.0.143:18093 — the host LAN (`scripts/signal-relay.py`, user unit, 192.168.0.143:18093 — the host LAN
IP, the one bind a container can reach as `host.containers.internal`; IP, the one bind a container can reach as `host.containers.internal`;
iptables admits loopback, the host and the podman bridge subnet only) → signal-cli account 318311. iptables admits loopback, the host and the podman bridge subnet only) → signal-cli account 318311.
Prometheus targets read down for structural reasons (HTML-scraped "probes", The former structural target downs (HTML-scraped "probes", Gitea metrics off)
Gitea metrics off); the remedy is a blackbox exporter. `frank-zealot` was down for are retired as of 2026-10-21: HTTP liveness rides a blackbox exporter
a different reason until 2026-10-18: llama.cpp will not serve `/metrics` at all (`shared/blackbox/blackbox.yml`), Gitea serves `/metrics` from its quadlet, and
without `--metrics`. See every Prometheus job reports up. `frank-zealot` was down for a different reason
`serverAppConfigs-docs/Infrastructure/Observability.md`. until 2026-10-18: llama.cpp will not serve `/metrics` at all without
`--metrics`. See `serverAppConfigs-docs/Infrastructure/Observability.md`.
## Testing ## Testing
@@ -417,16 +420,17 @@ disagrees with it is the bug.
digest-pinned `ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84…`. digest-pinned `ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84…`.
Engine stdout (the boot ledger: weights, KV pool, clamp) goes to Engine stdout (the boot ledger: weights, KV pool, clamp) goes to
`/home/dank/logs/frank_dragoon.log`, NOT the journal. `/home/dank/logs/frank_dragoon.log`, NOT the journal.
- Shape: `HALOGEN_CTX=200704` is the **per-request ceiling** (a longer prompt - Shape: `HALOGEN_CTX=262144` (the checkpoint's native window) is the
is a 400, never a truncation); `HALOGEN_KV_SLOTS=2` over ONE **per-request ceiling** (a longer prompt is a 400, never a truncation);
`HALOGEN_KV_POOL_POSITIONS=401408` pool — pool positions buy concurrency, `HALOGEN_KV_SLOTS=2` over ONE `HALOGEN_KV_POOL_POSITIONS=401408` pool —
the per-request window never moves; `HALOGEN_SPEC_ADAPT=0` keeps the MTP pool positions buy concurrency, the per-request window never moves;
drafter always armed (the adaptive default disarms it and halves decode); `HALOGEN_SPEC_ADAPT=0` keeps the MTP drafter always armed (the adaptive
server sampler 1.0/0.95/20. default disarms it and halves decode); server sampler 1.0/0.95/20.
- Harness contract: `models.yml` `contextWindow: 155648` = ceiling − 45,056. - Harness contract: `models.yml` `contextWindow: 200000` — ~45k+ under the
The clamp is `min(45056, window − est − 4096)` and the chars/4 estimate ceiling. The clamp is `min(45056, window − est − 4096)` and the chars/4
under-counts, so a harness window equal to the ceiling makes every long estimate under-counts (trigger is 95 %), so a harness window at the ceiling
request a guaranteed 400. Reasoning arrives in `reasoning_content`; the makes every long request a guaranteed 400.
Reasoning arrives in `reasoning_content`; the
template rejects `reasoning_effort` outside `xhigh|medium|low`; images are template rejects `reasoning_effort` outside `xhigh|medium|low`; images are
OpenAI `image_url` parts (probe ≥256 px); `response_format` unsupported; OpenAI `image_url` parts (probe ≥256 px); `response_format` unsupported;
`/completion`, `/ping`, `/props`, `/tokenize` are 404. `/completion`, `/ping`, `/props`, `/tokenize` are 404.
@@ -440,7 +444,11 @@ disagrees with it is the bug.
`prism.hadamard.*` dequant metadata — stock llama.cpp refuses it, and a `prism.hadamard.*` dequant metadata — stock llama.cpp refuses it, and a
loader ignoring that metadata dequantizes wrong); payload loader ignoring that metadata dequantizes wrong); payload
`Bonsai2-9B-MTP/…PQ2_0.gguf` + the publisher's **Q8_0** mmproj (the BF16 `Bonsai2-9B-MTP/…PQ2_0.gguf` + the publisher's **Q8_0** mmproj (the BF16
tower can crash the server with concurrent image requests). tower can crash the server with concurrent image requests) + the rank-1
refusal-direction adapter applied at load by `--lora-scaled …:1`. The
adapter's rollback is `LORA_FILE=` (empty) in the env — one line, no rebuild
— and build 10777 is also the gate that lets it load: older forks refuse it
with `unsupported prism.hadamard.version: 2`.
- Shape: `--ctx-size 602112 --parallel 3` = **three conversations at - Shape: `--ctx-size 602112 --parallel 3` = **three conversations at
200,704 each**, `CACHE_TYPE_K=f16` + `CACHE_TYPE_V=q8_0` (asymmetric: 25,088 200,704 each**, `CACHE_TYPE_K=f16` + `CACHE_TYPE_V=q8_0` (asymmetric: 25,088
B/token, 14,406 MiB of pool), batch 6144 / ubatch 1024, B/token, 14,406 MiB of pool), batch 6144 / ubatch 1024,
@@ -458,12 +466,15 @@ disagrees with it is the bug.
- Owns: `scripts/frank-archon.env`, `scripts/serve-frank-archon.sh`, - Owns: `scripts/frank-archon.env`, `scripts/serve-frank-archon.sh`,
`scripts/frank-archon.service`, `scripts/ttm-preflight.sh`, template `scripts/frank-archon.service`, `scripts/ttm-preflight.sh`, template
`scripts/qwen38-froggeric-v22.5.jinja`; image digest-pinned `scripts/qwen38-froggeric-v22.5.jinja`; image digest-pinned
`stilldeadcode/radiance@sha256:4c3f4845…`; container name = unit name. `stilldeadcode/radiance@sha256:d590e300…` (radiance 1.8.1); container name = unit name.
- Shape: TP=2 over `wht6`, `--max-num-seqs 3`, `--max-model-len 200000`, - Shape: TP=2 over `wht6`, `--max-num-seqs 2`, `--max-model-len 200000`,
`expert_tiered` placement, 44,928 MiB pinned host pool per rank, VRAM pair `expert_tiered` placement, 44,928 MiB pinned host pool per rank, `fp8` KV,
`--vram-weights-mib 25600 --vram-kv-mib 5120` (ONE budget: weights + kv ≤ MTP depth 2, MNBT 2048, `--busy-prefill-chunk auto`, prefix cache 8 GiB host
30,969 MiB/card), `fp8` KV, MTP depth 2, MNBT 2048, prefix cache 8 GiB host tier only, n-gram table pinned to RAM. The per-card weights/KV split is derived
+ 64 GiB disk, n-gram table pinned to RAM. by the engine's budget resolver from `--gpu-headroom-mib`, the cache bound and
the host pool; the `--vram-weights-mib`/`--vram-kv-mib` pair and the prefix disk
tier are gone (removed at 1.3.0 / 1.8.1) and naming either is a boot refusal,
not a no-op.
- Harness/gate contract: `models.yml` `contextWindow` MUST quote 200,000 - Harness/gate contract: `models.yml` `contextWindow` MUST quote 200,000
(over-length is refused, never truncated); the ~147 GiB pinned-RAM ledger (over-length is refused, never truncated); the ~147 GiB pinned-RAM ledger
is why this is a root unit, and `ttm-preflight.sh` raises is why this is a root unit, and `ttm-preflight.sh` raises
@@ -796,6 +807,20 @@ dies, its page is destroyed. Rollback is a commit, not a document.
`serverAppConfigs-docs/index.md` is the locator for every page that survives; `serverAppConfigs-docs/index.md` is the locator for every page that survives;
retired scripts live in `old/` and `.AGENTTRASH/`. retired scripts live in `old/` and `.AGENTTRASH/`.
## Agent skills
### Issue tracker
Issues live in Gitea (`dank/serverAppConfigs` on Dankclaw), via its REST API with the stored credential. See `serverAppConfigs-docs/Agent/Issue Tracker.md`.
### Triage labels
The five canonical roles keep their default strings (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `serverAppConfigs-docs/Agent/Triage Labels.md`.
### Domain docs
Single-context: root `GLOSSARY.md` + `docs/adr/`, created lazily; this vault stays the owner of operational facts. See `serverAppConfigs-docs/Agent/Domain Docs.md`.
## Why ## Why
Direct edits on the remote drift from version control, and future agents Direct edits on the remote drift from version control, and future agents
+29
View File
@@ -162,6 +162,25 @@ marreta.franksdank.cloud {
} }
} }
# --- Open Notebook (guarded: Anubis → Authentik; notebook-proxy provider, internal_host caddy:8017) ---
# The podcast generator. ONE upstream carries the whole app: the image's own
# Next.js layer rewrites /api/* to the FastAPI beside it (INTERNAL_API_URL,
# default http://localhost:5055 inside the container), so a browser on this
# vhost never needs the API port. That is why scripts/open-notebook.env sets
# API_URL to exactly this origin — left unset, the app auto-detects
# `<host-you-opened>:5055`, and 5055 on this host belongs to seerr — and why
# the published API port sits on loopback. App-level login
# (OPEN_NOTEBOOK_PASSWORD) stays on behind the chain: two owners, edge identity
# and the app's own session.
# Dialled by published LAN port, not container name: open-notebook is a
# rootless quadlet and this Caddy lives in root's storage, so root's DNS does
# not see the container (the same reason marreta and SearXNG are dialled by IP).
notebook.franksdank.cloud {
reverse_proxy anubis:8923 {
header_up X-Real-Ip {remote_host}
}
}
# --- Pi-hole (quadlet on slirp4netns; published 8083) --- # --- Pi-hole (quadlet on slirp4netns; published 8083) ---
pihole.franksdank.cloud { pihole.franksdank.cloud {
basicauth { basicauth {
@@ -420,6 +439,16 @@ http://:8017 {
handle @marreta { handle @marreta {
reverse_proxy 192.168.0.143:8082 reverse_proxy 192.168.0.143:8082
} }
@notebook host notebook.franksdank.cloud
handle @notebook {
# Bare proxy, like ai.franksdank.cloud: the app's SSE answers
# (/api/search/ask, chat sessions) stream for minutes through this
# same listener with no flush or timeout tuning, so nothing here
# needs it — a read/write timeout on the route would be the thing
# that cuts them.
reverse_proxy 192.168.0.143:8502
}
} }
# --- Optional: Jackett --- # --- Optional: Jackett ---
@@ -20,7 +20,7 @@ Wants=authentik-server.service
[Container] [Container]
ContainerName=authentik-outpost ContainerName=authentik-outpost
Image=ghcr.io/goauthentik/server:2026.8.3 Image=ghcr.io/goauthentik/server:2026.8.3@sha256:ab9b4e8cc4ab3f8d1198d2db6aeea66bafea1963b3f2843589e0d163f97d9849
Exec=worker Exec=worker
Network=dankclaw_default Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
@@ -14,7 +14,7 @@ After=network.target
[Container] [Container]
ContainerName=authentik-postgres ContainerName=authentik-postgres
Image=docker.io/library/postgres:16-alpine Image=docker.io/library/postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
Network=dankclaw_default Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Environment=POSTGRES_DB=authentik Environment=POSTGRES_DB=authentik
@@ -19,7 +19,7 @@ Wants=authentik-postgres.service
[Container] [Container]
ContainerName=authentik-server ContainerName=authentik-server
Image=ghcr.io/goauthentik/server:2026.8.3 Image=ghcr.io/goauthentik/server:2026.8.3@sha256:ab9b4e8cc4ab3f8d1198d2db6aeea66bafea1963b3f2843589e0d163f97d9849
Exec=server Exec=server
Network=dankclaw_default Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
@@ -16,7 +16,7 @@ Wants=authentik-server.service
[Container] [Container]
ContainerName=authentik-worker ContainerName=authentik-worker
Image=ghcr.io/goauthentik/server:2026.8.3 Image=ghcr.io/goauthentik/server:2026.8.3@sha256:ab9b4e8cc4ab3f8d1198d2db6aeea66bafea1963b3f2843589e0d163f97d9849
Exec=worker Exec=worker
Network=dankclaw_default Network=dankclaw_default
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
@@ -15,7 +15,7 @@ After=network.target
[Container] [Container]
ContainerName=gitea-postgres ContainerName=gitea-postgres
Network=gitea.network Network=gitea.network
Image=docker.io/library/postgres:16-alpine Image=docker.io/library/postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
EnvironmentFile=/home/dank/Containers/gitea/env EnvironmentFile=/home/dank/Containers/gitea/env
Environment=POSTGRES_USER=gitea Environment=POSTGRES_USER=gitea
Environment=POSTGRES_PASSWORD=${GITEA_DB_PASSWORD} Environment=POSTGRES_PASSWORD=${GITEA_DB_PASSWORD}
@@ -19,7 +19,7 @@ After=gitea-postgres.service network.target
[Container] [Container]
ContainerName=gitea-server ContainerName=gitea-server
Network=gitea.network Network=gitea.network
Image=docker.io/gitea/gitea:latest Image=docker.io/gitea/gitea:latest@sha256:30777c91280d973fa02be48b1ade735bd6859e8f6f7af3b4444d519437852504
EnvironmentFile=/home/dank/Containers/gitea/env EnvironmentFile=/home/dank/Containers/gitea/env
Environment=DATABASE_TYPE=postgres Environment=DATABASE_TYPE=postgres
Environment=DATABASE_HOST=gitea-postgres Environment=DATABASE_HOST=gitea-postgres
@@ -29,6 +29,13 @@ Environment=DATABASE_PASSWD=${GITEA_DB_PASSWORD}
Environment=DATABASE_PORT=5432 Environment=DATABASE_PORT=5432
Environment=USER_UID=1000 Environment=USER_UID=1000
Environment=USER_GID=1000 Environment=USER_GID=1000
# Prometheus scrapes /metrics over the published LAN port (job "gitea" in
# shared/prometheus/prometheus.yml). Off, /metrics is a 404 and the target is
# permanently red (the audit's "disabled metrics" class). 0.0.0.0 binds inside
# the container; the only route in is this host's published 3000, and the
# exposed surface is counters only — same posture as the LLM /metrics.
Environment=GITEA__metrics__ENABLED=true
Environment=GITEA__metrics__ENABLED_HOST=0.0.0.0
PublishPort=0.0.0.0:3000:3000 PublishPort=0.0.0.0:3000:3000
PublishPort=2222:22 PublishPort=2222:22
Volume=/home/dank/Containers/gitea/data:/data Volume=/home/dank/Containers/gitea/data:/data
+65
View File
@@ -0,0 +1,65 @@
#!/bin/sh
# halogen-fabric-clock hold | release | status: the AMD GPU's fabric clock (FCLK), held at its top level.
#
# On Strix Halo the GPU and the NPU working at the same time can hang the machine, and corrupt the NPU's results,
# while the GPU's fabric clock changes speed; held at its top level, the two run together cleanly. halogen-flash-server
# with HALOGEN_NPU_MODELS refuses to start until the clock is held. This holds it: the performance level `manual` and
# only the top pp_dpm_fclk level, on every amdgpu device that has the control (the GPU and SoC clocks stay automatic).
# Cost: about 1 W at idle. Run as root; halogen-fabric-clock.service runs `hold` at every boot and after a resume.
#
# hold hold it (waits up to 60 s for the GPU driver to come up)
# release give it back to the driver (the performance level `auto`)
# status print each device's state; exit 0 when every device is held
set -u
SYS="${HALOGEN_SYSFS:-/sys}"
devices() {
for d in "$SYS"/class/drm/card*/device; do
[ -w "$d/pp_dpm_fclk" ] || [ -r "$d/pp_dpm_fclk" ] || continue
[ -e "$d/power_dpm_force_performance_level" ] || continue
echo "$d"
done | sort -u
}
held() { # held DEVICE_DIR: the performance level high, or manual with only the top fclk level selected
perf=$(cat "$1/power_dpm_force_performance_level" 2>/dev/null)
[ "$perf" = high ] && return 0
[ "$perf" = manual ] || return 1
top=$(grep . "$1/pp_dpm_fclk" | tail -n 1)
[ "$(grep -c '\*' "$1/pp_dpm_fclk")" = 1 ] && [ "${top%\*}" != "$top" ]
}
cmd="${1:-status}"
case "$cmd" in
hold)
n=0
while [ -z "$(devices)" ] && [ "$n" -lt 60 ]; do sleep 1; n=$((n + 1)); done
[ -n "$(devices)" ] || { echo "halogen-fabric-clock: no amdgpu device with a fabric clock control (pp_dpm_fclk) under $SYS/class/drm" >&2; exit 1; }
rc=0
for d in $(devices); do
lvl=$(grep . "$d/pp_dpm_fclk" | tail -n 1 | cut -d: -f1)
# the driver applies the level several seconds after the write, so the read-back waits up to 30 s
ok=0
if echo manual > "$d/power_dpm_force_performance_level" && echo "$lvl" > "$d/pp_dpm_fclk"; then
t=0; until held "$d" || [ "$t" -ge 300 ]; do sleep 0.1; t=$((t + 1)); done
held "$d" && ok=1
fi
if [ "$ok" = 1 ]; then
echo "halogen-fabric-clock: $d: held at level $lvl ($(grep . "$d/pp_dpm_fclk" | tail -n 1 | awk '{print $2}'))"
else
echo "halogen-fabric-clock: $d: could not hold it (level $lvl)" >&2; rc=1
fi
done
exit "$rc" ;;
release)
for d in $(devices); do echo auto > "$d/power_dpm_force_performance_level" && echo "halogen-fabric-clock: $d: released (auto)"; done ;;
status)
[ -n "$(devices)" ] || { echo "halogen-fabric-clock: no amdgpu device with a fabric clock control"; exit 1; }
rc=0
for d in $(devices); do
if held "$d"; then echo "$d: held ($(cat "$d/power_dpm_force_performance_level"))"
else echo "$d: NOT held ($(cat "$d/power_dpm_force_performance_level"))"; rc=1; fi
done
exit "$rc" ;;
*) echo "usage: halogen-fabric-clock hold | release | status" >&2; exit 2 ;;
esac
@@ -0,0 +1,19 @@
# Holds the AMD GPU's fabric clock at its top level at boot and after every resume, for halogen-flash-server with
# the NPU (HALOGEN_NPU_MODELS): on Strix Halo the GPU and the NPU at once are safe only with that clock held.
# Install (as root), from this directory:
# install -m 755 halogen-fabric-clock /usr/local/sbin/
# install -m 644 halogen-fabric-clock.service /etc/systemd/system/
# systemctl daemon-reload && systemctl enable --now halogen-fabric-clock.service
# Check: halogen-fabric-clock status. Undo: systemctl disable halogen-fabric-clock.service, then
# halogen-fabric-clock release (or reboot). The unit runs once at boot and once after each resume (a resume can hand
# the clock back to the driver); it holds nothing open, so it shows inactive between runs.
[Unit]
Description=Hold the AMD GPU fabric clock at its top level (GPU and NPU together)
After=systemd-modules-load.service suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/halogen-fabric-clock hold
[Install]
WantedBy=multi-user.target suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target
@@ -23,7 +23,7 @@ After=gluetun.service network.target
[Container] [Container]
ContainerName=marreta_browser ContainerName=marreta_browser
Network=gluetun.container Network=gluetun.container
Image=docker.io/lightpanda/browser:nightly Image=docker.io/lightpanda/browser:nightly@sha256:979c36ed869f1016b8dcf22939a0633c262f2c30e892a03a9f720eba2ff1931e
[Service] [Service]
TimeoutStartSec=300 TimeoutStartSec=300
+1 -1
View File
@@ -32,7 +32,7 @@ After=gluetun.service marreta-browser.service network.target
[Container] [Container]
ContainerName=marreta ContainerName=marreta
Network=gluetun.container Network=gluetun.container
Image=ghcr.io/manualdousuario/marreta:latest Image=ghcr.io/manualdousuario/marreta:latest@sha256:30f2a34567b79b66da794b4b9423751bbbcd07ceff600d9f6a2d219e7e439362
# Relocate off SearXNG's 8080 inside the shared netns. # Relocate off SearXNG's 8080 inside the shared netns.
Environment=NGINX_HTTP_PORT=8085 Environment=NGINX_HTTP_PORT=8085
Environment=APP_NAME=Marreta Environment=APP_NAME=Marreta
@@ -33,7 +33,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=alertmanager ContainerName=alertmanager
Image=docker.io/prom/alertmanager:latest Image=docker.io/prom/alertmanager:latest@sha256:e9733bafb1bdef9b00e25a21f8f99dc26a22224bf16641ad754d1649f4c3357a
Network=dankclaw_default Network=dankclaw_default
User=0:0 User=0:0
Exec=--config.file=/etc/alertmanager/alertmanager.yml --storage.path=/alertmanager Exec=--config.file=/etc/alertmanager/alertmanager.yml --storage.path=/alertmanager
+35 -39
View File
@@ -1,55 +1,51 @@
# Quadlet unit: alloy (rootless, boot-persistent). # Quadlet unit: alloy (ROOT system service, boot-persistent).
# #
# Install path on dankclaw: ~/.config/containers/systemd/alloy.container # Install path on dankclaw: /etc/containers/systemd/alloy.container
# Unit name: alloy.service (container name: alloy) # Unit name: alloy.service (container name: alloy) — SYSTEM manager, root.
# #
# Log shipper. This replaces the compose service of the same name in # WHY ROOT (not rootless, unlike its observability siblings). Alloy reads the
# dankclaw/compose.yaml ("Observability" block). # host journal. The journal files are root:systemd-journal 0640 (other::---);
# a rootless container carries no supplementary groups and cannot open them —
# a rootless read of system.journal returns DENIED (see the Alloy page in the
# vault). Running as root, one loki.source.journal reads system.journal AND
# user-1000.journal, so it carries both host logs and the rootless Quadlet
# containers' logs. Validated 2026-10-20: a root probe read 31,825 journal
# lines and pushed 26,966 into Loki in 20 s, zero drops.
# #
# IMAGE. franksdank/alloy is built locally by deploy-remote.sh # NETWORK=host. Loki publishes 127.0.0.1:3100 (loopback only) on the host, and
# (build_compose_image) from dankclaw/alloy/Containerfile: upstream # no bridge container — rootless or root — can reach the host's loopback, so
# grafana/alloy plus a static busybox for the healthcheck. Pull=never keeps a # this unit runs on the host network and pushes to 127.0.0.1:3100. The side
# boot from reaching for that local tag at a registry. # effect: the UI must bind loopback too, so Exec sets the listen-addr to
# 127.0.0.1 (the old rootless unit used 0.0.0.0 only because container loopback
# was not host loopback; under Network=host they are the same address, so the
# unauthenticated UI stays loopback-only — nothing off-host reaches it).
# #
# NETWORK. It joins dankclaw_default so its config's loki.write endpoint # THE /etc/machine-id MOUNT IS LOAD-BEARING. sd_journal locates the journal
# resolves the container name loki:3100. # directory by the local machine id; without it the container opens nothing and
# the journal source reads exactly 0 lines — silently, no error, health green.
# This mount is the entire reason the pipeline carries data. Never remove it.
# #
# THE UI PORT EXISTS ON PURPOSE. Alloy's UI listens on the container's own # IMAGE. Built rootless by build_compose_image alloy, then mirrored into the
# 127.0.0.1 by default and cannot be moved from the config file (alloy 1.x # root image store by the deployer (a root build runs in the build container's
# refuses the ui block), so the compose service had no reachable health # own resolver and fails on DNS here). Pull=never: boot must not reach for the
# endpoint at all: the homepage status card for alloy reported "down" # local tag at a registry.
# continuously and there was nothing for a deploy to wait on. The command
# below therefore moves the UI to the container's wildcard address and the
# port is published on host loopback only, which puts alloy in the same class
# as the other three: reachable from the host, by Caddy over the private
# network, and by nothing off-host. Alloy has no authentication, which is
# exactly why it is not published wider.
#
# SOURCES. The host journald socket and the host log/container-storage trees
# are bind-mounted read-only; Alloy tails them and pushes to Loki. The config
# lives at /opt/alloy/config/config.alloy, installed from
# shared/alloy/config.alloy on every deploy.
#
# ORDERING. Wants/After loki.service: without a reachable Loki, Alloy's write
# client fails and its log pipeline backs up.
[Unit] [Unit]
Description=Alloy log shipper (Dankclaw) Description=Alloy log shipper (Dankclaw)
After=network-online.target loki.service After=network-online.target
Wants=network-online.target loki.service
[Container] [Container]
ContainerName=alloy ContainerName=alloy
Image=localhost/franksdank/alloy:latest Image=localhost/franksdank/alloy:latest
Pull=never Pull=never
Network=dankclaw_default Network=host
Exec=run --server.http.listen-addr=0.0.0.0:12345 /etc/alloy/config.alloy Exec=run --server.http.listen-addr=127.0.0.1:12345 /etc/alloy/config.alloy
PublishPort=127.0.0.1:12345:12345 Volume=/opt/alloy/config:/etc/alloy:ro
Volume=/opt/alloy/config:/etc/alloy Volume=/etc/machine-id:/etc/machine-id:ro
Volume=/run/systemd/journal/socket:/run/systemd/journal/socket:ro Volume=/run/systemd/journal/socket:/run/systemd/journal/socket:ro
Volume=/var/log:/var/log:ro Volume=/var/log/journal:/var/log/journal:ro
Volume=/var/lib/containers:/var/lib/containers:ro Volume=/run/log/journal:/run/log/journal:ro
HealthCmd=/usr/local/bin/busybox wget --spider -q http://localhost:12345/-/healthy HealthCmd=/usr/local/bin/busybox wget --spider -q http://127.0.0.1:12345/-/healthy
HealthInterval=30s HealthInterval=30s
HealthTimeout=10s HealthTimeout=10s
HealthRetries=3 HealthRetries=3
@@ -60,4 +56,4 @@ Restart=always
RestartSec=10 RestartSec=10
[Install] [Install]
WantedBy=default.target WantedBy=multi-user.target
@@ -0,0 +1,56 @@
# Quadlet unit: blackbox exporter (rootless, host network, boot-persistent).
#
# Install path on dankclaw:
# ~/.config/containers/systemd/blackbox-exporter.container
#
# WHY Network=host. The probes it runs are (a) the host's own LAN IP
# (192.168.0.143:8081 SearXNG, :3005 OpenWebUI — host-process/portmap binds a
# bridge container cannot reach; netavark DNATs published ports only, see
# Container Host Reachability in the vault) and (b) ComfyUI's published port
# via 127.0.0.1 (rootless --network=host shares the real host netns, so the
# 0.0.0.0:8188 publish answers on loopback). A bridge-networked prober could
# reach neither class reliably.
#
# WHY the listener binds 127.0.0.1 + LAN. A bridge container CANNOT dial
# its own host's LAN IP (netavark hairpin gap, measured 2026-10-21), but a
# bridge container dialing the podman gateway 169.254.1.2 lands on the host
# LOOPBACK — that is the scrape path, and it needs this exporter to be
# LISTENING ON LOOPBACK (proved: 200 from the prometheus container to
# http://169.254.1.2:9115/-/healthy with a loopback bind). The gateway
# address itself is NOT bindable from the host netns — it exists only
# inside each rootless netns; binding it dies "bind: cannot assign
# requested address" (live 2026-10-21). The LAN bind stays for diagnostics.
# No public DNS name, no guard chain: internal net only, per the exposure
# policy. The listen flag is --web.listen-address and it is REPEATABLE, one
# flag per bind; plain --listen-address does not exist and the exporter
# exits in 100 ms with "unknown long flag" (live 2026-10-21).
[Unit]
Description=Blackbox exporter (HTTP probe prober)
After=network-online.target
[Container]
Image=docker.io/prom/blackbox-exporter@sha256:ea1c7aa3ef3f307cd03e85f34abb604478ccb90b567fc690da0cd9776da76b13
ContainerName=blackbox-exporter
Network=host
# The image's ENTRYPOINT is /bin/blackbox_exporter itself (proved live
# 2026-10-21: `podman run IMAGE sh` dies "unexpected sh"), so Exec= carries
# FLAGS ONLY — repeating the binary makes it a positional arg and kingpin
# exits "unexpected /bin/blackbox_exporter".
Exec=--config.file=/etc/blackbox_exporter/config.yml --web.listen-address=127.0.0.1:9115 --web.listen-address=192.168.0.143:9115
Volume=/opt/blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
# Plain exec form like every sibling unit. Shell form (`/bin/sh -c "…"`)
# is passed as one quoted token and quadlet then folds the following
# --health-interval/--health-retries (and the image name) into the command
# string — unit died twice that way, live 2026-10-21. wget only; this
# busybox variant has no curl, and host-network wget answers on loopback.
HealthCmd=wget -q -O /dev/null http://127.0.0.1:9115/-/healthy
HealthInterval=30s
HealthRetries=3
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,56 @@
# Quadlet unit: cadvisor (rootless, boot-persistent) — DANKCLAW.
#
# Install path on dankclaw: ~/.config/containers/systemd/cadvisor.container
# Unit name: cadvisor.service (container name: cadvisor)
#
# WHY THIS EXISTS. The app stack is ~34 rootless Podman containers and until
# now nothing reported per-container CPU/memory — Grafana could only see the
# host (node_exporter) and the LLM engines. cadvisor reads the host cgroup
# tree and exports container_cpu_* / container_memory_* per container.
#
# THE LOAD-BEARING FLAG IS --cgroupns=host. Podman starts every container in a
# PRIVATE cgroup namespace, so an exporter container sees only its OWN cgroup
# subtree: cadvisor exported a single root series until this flag was added,
# then it walked the whole tree (996 per-container series measured 2026-10-20).
# The quadlet unit name is carried in the cgroup path — app.slice/<name>.service/
# libpod-payload-<hex> — so Prometheus relabels that path into a `container`
# label (shared/prometheus/prometheus.yml, cadvisor-dankclaw job) and no
# libpod/podman socket is needed to name containers.
#
# BINDING. cadvisor has no per-interface listen flag (unlike node_exporter's
# --web.listen-address), so it binds 0.0.0.0 INSIDE its own network namespace.
# The bridge PublishPort is what keeps the unauthenticated endpoint off the
# host's other interfaces: it publishes exactly loopback (host-local probes and
# the deployer's health gate) and this host's LAN address (Prometheus, a bridge
# container, reaches a host-published port at its LAN address — the same netavark
# rule node-exporter relies on, only here the port really is published).
#
# It is rootless on purpose: dank owns the app containers' cgroups, so a
# rootless cadvisor reads every one of them; root would buy nothing here.
[Unit]
Description=cAdvisor container metrics (Dankclaw)
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=cadvisor
Image=gcr.io/cadvisor/cadvisor:v0.49.1@sha256:3cde6faf0791ebf7b41d6f8ae7145466fed712ea6f252c935294d2608b1af388
PodmanArgs=--pid=host --cgroupns=host
PublishPort=127.0.0.1:8080:8080
PublishPort=192.168.0.143:8080:8080
Volume=/sys:/sys:ro
Volume=/:/rootfs:ro
Exec=--housekeeping_interval=30s
HealthCmd=wget --spider -q http://localhost:8080/healthz
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=40s
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
@@ -34,7 +34,7 @@ Wants=network-online.target prometheus.service
[Container] [Container]
ContainerName=grafana ContainerName=grafana
Image=docker.io/grafana/grafana:latest Image=docker.io/grafana/grafana:latest@sha256:dbbc38ea21103cefb50396340cd88afec779c7e070b0a7bf5a75b4ce46651d3f
Network=dankclaw_default Network=dankclaw_default
User=0:0 User=0:0
PublishPort=127.0.0.1:3002:3000 PublishPort=127.0.0.1:3002:3000
@@ -47,9 +47,24 @@ Environment=GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=${GRAFANA_OAUTH_CLIENT_SECRET}
# client_id query parameter — /application/o/<slug>/authorize/ does not exist. # client_id query parameter — /application/o/<slug>/authorize/ does not exist.
Environment=GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://auth.franksdank.cloud/application/o/authorize/ Environment=GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://auth.franksdank.cloud/application/o/authorize/
Environment=GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://auth.franksdank.cloud/application/o/token/ Environment=GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://auth.franksdank.cloud/application/o/token/
# ?api=1 = introspection-style userinfo: returns custom claims (groups) too. # Standard OIDC userinfo. Grafana v13 fetches <api_url>/emails for the address
Environment=GF_AUTH_GENERIC_OAUTH_API_URL=https://auth.franksdank.cloud/application/o/userinfo/?api=1 # ONLY when the token carries no `email` claim (authentik has no /emails path and
Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=false # 404s it, failing the login) — so the claim must actually be there: the fix is
# the grafana-email-scope mapping (see scripts/authentik-grafana-sso-fix.sh),
# not a Grafana flag. Do NOT append ?api=1 to this URL: Grafana then GETs
# <...>/userinfo/?api=1/emails, a 403.
Environment=GF_AUTH_GENERIC_OAUTH_API_URL=https://auth.franksdank.cloud/application/o/userinfo/
# authentik's scopes are openid/email/profile. `user:email` (a GitLab-ism) is
# not a known scope here; requesting it makes the authorize view crash.
# Quoted: quadlet word-splits Environment=, so an unquoted spaced list
# truncates SCOPES to its first token.
Environment="GF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile"
Environment=GF_AUTH_GENERIC_OAUTH_EMAIL_ATTRIBUTE_PATH=email
Environment=GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_PATH=sub
Environment=GF_AUTH_GENERIC_OAUTH_NAME_ATTRIBUTE_PATH=preferred_username
# First SSO login auto-provisions the user. Safe: unauthenticated traffic never
# reaches this callback — the Anubis -> Authentik guard fronts every request.
Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true
Environment=GF_AUTH_GENERIC_OAUTH_AUTO_UPSERT=true Environment=GF_AUTH_GENERIC_OAUTH_AUTO_UPSERT=true
Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_ORG=true Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_ORG=true
Environment=GF_AUTH_GENERIC_OAUTH_ORG_ID=1 Environment=GF_AUTH_GENERIC_OAUTH_ORG_ID=1
@@ -33,7 +33,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=node-exporter ContainerName=node-exporter
Image=docker.io/prom/node-exporter:latest Image=docker.io/prom/node-exporter:latest@sha256:1b4e4438faca4dd7e001dd445d161a4a2091b0fededa84093b3a8dfeae1f1be0
Network=host Network=host
PodmanArgs=--pid=host PodmanArgs=--pid=host
Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:9100 --web.listen-address=192.168.0.143:9100 Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:9100 --web.listen-address=192.168.0.143:9100
@@ -43,8 +43,16 @@ Wants=network-online.target
[Container] [Container]
ContainerName=prometheus ContainerName=prometheus
Image=docker.io/prom/prometheus:latest Image=docker.io/prom/prometheus:latest@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
Network=dankclaw_default Network=dankclaw_default
# Same-host scrape targets are dialed at host.containers.internal (the podman
# gateway): a bridge container cannot dial its own host's LAN IP (netavark
# hairpin gap, measured 2026-10-21), and traffic to the gateway lands on host
# loopback / published ports (cadvisor/gpu jobs and the blackbox scrape all
# ride it). The name resolves natively — NO HostAddr key: quadlet 5.7.0 calls
# it unsupported and the WHOLE user quadlet batch then fails to generate,
# silently orphaning every other unit (live 2026-10-21: prometheus.service
# vanished while its container kept running as an orphan).
User=0:0 User=0:0
Exec=--config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/prometheus --storage.tsdb.retention.time=30d --web.enable-lifecycle Exec=--config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/prometheus --storage.tsdb.retention.time=30d --web.enable-lifecycle
PublishPort=127.0.0.1:9090:9090 PublishPort=127.0.0.1:9090:9090
@@ -48,13 +48,22 @@
# without `podman unshare` — accepted for a datastore nobody edits by hand; # without `podman unshare` — accepted for a datastore nobody edits by hand;
# episode AUDIO is a host directory for the opposite reason (see the app unit). # episode AUDIO is a host directory for the opposite reason (see the app unit).
# #
# HEALTH. The base image is distroless: no shell, no curl, no wget, so no # HEALTH. The base image is distroless — measured against this digest, not even
# CMD-SHELL probe and no wget/curl probe is possible here (same class of unit # `ls` exists (`exec: "ls": executable file not found in $PATH`), so there is no
# as loki, whose probe had to move into the image). SurrealDB ships its own # `/bin/sh`, no curl and no wget. SurrealDB ships its own probe instead:
# probe instead — `surreal is-ready` connects to ws://localhost:8000 by default # `surreal is-ready` connects to ws://localhost:8000 by default, needs no
# and needs no credentials — verified against this digest: it prints `OK` and # credentials, prints `OK` and exits 0 while the server is up.
# exits 0 while the server is up. Argument-only HealthCmd, like every unit on #
# this host, because the quadlet generator splits it on spaces. # It must be handed over as a QUADLET-SIDE JSON ARRAY, not a bare command.
# podman wraps a bare `HealthCmd` string as `CMD-SHELL` (measured on this host's
# podman 5.7: `--health-cmd "/surreal is-ready"` stores
# `{"Test":["CMD-SHELL","/surreal is-ready"]}`), and with no `/bin/sh` in the
# image that probe fails in ~30 ms with empty output on every interval while the
# database answers perfectly — which is exactly how this service's first deploy
# died on 2026-10-19 (`did not become healthy in 90s`). The quoted array survives
# the generator (`--health-cmd "[\"/surreal\",\"is-ready\"]"`) and stores
# `["CMD","/surreal","is-ready"]`, which execs directly and goes healthy. Do not
# "simplify" it back to a bare command.
[Unit] [Unit]
Description=SurrealDB (open-notebook datastore) Description=SurrealDB (open-notebook datastore)
@@ -70,7 +79,7 @@ EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env
EnvironmentFile=/home/dank/Containers/open-notebook/env EnvironmentFile=/home/dank/Containers/open-notebook/env
Exec=start --log info rocksdb:/mydata/mydatabase.db Exec=start --log info rocksdb:/mydata/mydatabase.db
Volume=open-notebook-db:/mydata Volume=open-notebook-db:/mydata
HealthCmd=/surreal is-ready HealthCmd=["/surreal","is-ready"]
HealthInterval=30s HealthInterval=30s
HealthTimeout=10s HealthTimeout=10s
HealthRetries=3 HealthRetries=3
@@ -33,17 +33,21 @@
# and 5055 is exactly the port seerr already holds on this host # and 5055 is exactly the port seerr already holds on this host
# (`PublishPort=127.0.0.1:5055:5055`; `ss -ltn` on Dankclaw 2026-10-18 shows # (`PublishPort=127.0.0.1:5055:5055`; `ss -ltn` on Dankclaw 2026-10-18 shows
# 127.0.0.1:5055 taken). So the API leaves the container on host port 5056. # 127.0.0.1:5055 taken). So the API leaves the container on host port 5056.
# That forces one setting: the frontend auto-detects the API as # Only 8502 is published on all interfaces, because it is the single upstream
# `<host-you-opened>:5055`, so API_URL in scripts/open-notebook.env must name # the guard chain dials: notebook.franksdank.cloud → anubis → authentik →
# the remapped port explicitly, or the UI loads and every call fails. Both # http://:8017 → 192.168.0.143:8502 (dankclaw/Caddyfile), and this Caddy lives
# ports are published on all interfaces because the consumer is a browser on # in root's storage, so a published port is the only way it can reach a rootless
# the LAN — the fleet's browser-facing services (open-webui, comfyui, gitea) # quadlet. The API needs no outward face at all — the image's own Next.js layer
# bind the same way, while loopback-only is reserved for services whose user # rewrites /api/* to the FastAPI beside it, so one origin serves UI and API —
# path is the guard chain. This service has NO vhost and no Caddy route: it is # and the fleet's rule is that loopback-only is for services whose user path is
# internal-only until it is guarded, and its own password # the guard chain, which this became on 2026-10-19; 5056 stays published on
# (OPEN_NOTEBOOK_PASSWORD) is what stands in front of it. That password is not # loopback because the deployer's health gate dials it there. That is also why
# decoration: with it unset the API answers everyone on the LAN, including the # API_URL in scripts/open-notebook.env names the vhost rather than a port: left
# unauthenticated episode-audio download. # to itself the frontend auto-detects `<host-you-opened>:5055`, and the UI loads
# while every call fails. OPEN_NOTEBOOK_PASSWORD is not made redundant by the
# chain: it is what keeps the API honest for everything else on this host, and
# with it unset the API answers anyone, including the unauthenticated
# episode-audio download.
# #
# VOLUMES. `/app/data` is a named volume: uploads, the LangGraph SQLite # VOLUMES. `/app/data` is a named volume: uploads, the LangGraph SQLite
# checkpoints and the extraction caches the image keeps under it # checkpoints and the extraction caches the image keeps under it
@@ -81,7 +85,7 @@ Pull=never
Network=dankclaw_default Network=dankclaw_default
Network=open-notebook.network Network=open-notebook.network
PublishPort=0.0.0.0:8502:8502 PublishPort=0.0.0.0:8502:8502
PublishPort=0.0.0.0:5056:5055 PublishPort=127.0.0.1:5056:5055
EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env
EnvironmentFile=/home/dank/Containers/open-notebook/env EnvironmentFile=/home/dank/Containers/open-notebook/env
Volume=open-notebook-data:/app/data Volume=open-notebook-data:/app/data
@@ -27,7 +27,7 @@ After=network.target
[Container] [Container]
ContainerName=open-webui ContainerName=open-webui
Network=open-webui.network Network=open-webui.network
Image=ghcr.io/open-webui/open-webui:main Image=ghcr.io/open-webui/open-webui:main@sha256:4860ba005ae8f19f08d1e12b0868855b7b339ea0dad9927f196820fc0de04575
PublishPort=3005:8080 PublishPort=3005:8080
Environment=OPENAI_API_BASE_URL=http://192.168.0.143:7770 Environment=OPENAI_API_BASE_URL=http://192.168.0.143:7770
Environment=ANONYMIZED_TELEMETRY=False Environment=ANONYMIZED_TELEMETRY=False
+1
View File
@@ -45,3 +45,4 @@
192.168.0.143 pihole.franksdank.cloud 192.168.0.143 pihole.franksdank.cloud
192.168.0.143 seerr.franksdank.cloud 192.168.0.143 seerr.franksdank.cloud
192.168.0.143 marreta.franksdank.cloud 192.168.0.143 marreta.franksdank.cloud
192.168.0.143 notebook.franksdank.cloud
+1 -1
View File
@@ -24,7 +24,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=pihole ContainerName=pihole
Image=docker.io/pihole/pihole:latest Image=docker.io/pihole/pihole:latest@sha256:d34134716fc32038b052a8674c7c6879e0ff21f0e6d8af345166fffa2c27c113
Network=slirp4netns Network=slirp4netns
PublishPort=0.0.0.0:53:53/udp PublishPort=0.0.0.0:53:53/udp
PublishPort=0.0.0.0:53:53/tcp PublishPort=0.0.0.0:53:53/tcp
+1 -1
View File
@@ -16,7 +16,7 @@ After=searxng-network.service network.target
[Container] [Container]
ContainerName=gluetun ContainerName=gluetun
Network=searxng.network Network=searxng.network
Image=docker.io/qmcgaw/gluetun:latest Image=docker.io/qmcgaw/gluetun:latest@sha256:c2ee4191762fca6b6a3c3359a731198a95d9c9af4db32d466e223bf3a84e779b
AddCapability=NET_ADMIN AddCapability=NET_ADMIN
AddDevice=/dev/net/tun:/dev/net/tun AddDevice=/dev/net/tun:/dev/net/tun
Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER} Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
@@ -16,7 +16,7 @@ After=searxng-network.service network.target
ContainerName=searxng-valkey ContainerName=searxng-valkey
Network=searxng.network Network=searxng.network
NetworkAlias=valkey NetworkAlias=valkey
Image=docker.io/valkey/valkey:9-alpine Image=docker.io/valkey/valkey:9-alpine@sha256:48332870af354a799964c0012ae1194a0bf2bf894eb508f945810596dc2d8d11
Volume=/opt/searxng/valkey:/data Volume=/opt/searxng/valkey:/data
[Service] [Service]
+1 -1
View File
@@ -16,7 +16,7 @@ After=gluetun.service searxng-valkey.service network.target
[Container] [Container]
ContainerName=searxng ContainerName=searxng
Network=gluetun.container Network=gluetun.container
Image=ghcr.io/searxng/searxng:latest Image=ghcr.io/searxng/searxng:latest@sha256:5ed0323b5f5f8701264ef65e239a2bd83cb57098f2b971fa30bf79c06eeae271
Environment=SEARXNG_SECRET=${SEARXNG_SECRET} Environment=SEARXNG_SECRET=${SEARXNG_SECRET}
Environment=SEARXNG_BASE_URL=${SEARXNG_BASE_URL} Environment=SEARXNG_BASE_URL=${SEARXNG_BASE_URL}
Volume=/opt/searxng/config:/etc/searxng Volume=/opt/searxng/config:/etc/searxng
+1 -1
View File
@@ -29,7 +29,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=seerr ContainerName=seerr
Image=ghcr.io/seerr-team/seerr:v3.4.1 Image=ghcr.io/seerr-team/seerr:v3.4.1@sha256:f4768de5f616248d723e05891f3345a1402123775d03bf0890dbfedc0831bda1
Network=dankclaw_default Network=dankclaw_default
PublishPort=127.0.0.1:5055:5055 PublishPort=127.0.0.1:5055:5055
Volume=/home/dank/Containers/seerr/data:/app/config Volume=/home/dank/Containers/seerr/data:/app/config
+26
View File
@@ -0,0 +1,26 @@
# /etc/udev/rules.d/98-halogen-npu-access.rules
#
# Why the NPU node is world-writable: `frank-dragoon` runs rootless podman under a
# unit with `User=dank`, and the container's user namespace maps exactly one uid and
# one gid (dank). `/dev/accel/accel0` is `root:render 0660`, so inside the container
# the node reads `nobody:nogroup` and the engine dies with its own diagnosis:
#
# halogen npu: /dev/accel/accel0 is here, but this process cannot open it
# (crw-rw---- nobody:nogroup).
#
# `--group-add keep-groups` is the vendor's advice and it does NOT work in this
# shape: measured 2026-10-19 from the host as a `dank` shell that was already in
# `render`, with and without the flag, the node still read `nobody:nogroup` and the
# open was refused — an unmapped gid cannot be granted to a process inside a
# namespace that does not map it. Same conclusion `99-comfyui-gpu-access.rules`
# reached for `/dev/kfd` from the systemd-user-spawn direction: the mode is the only
# spawn-context-independent door short of running the stack rootful.
#
# Blast radius: any process in this host's single uid domain (every rootless
# container here shares it) can submit to the XDNA2 NPU. The NPU holds no user data
# and no privileged context — the same trade already accepted for the GPU compute
# nodes beside it. Installed by `deploy-remote.sh` when the NPU lane is armed and left
# in place afterwards — the mode costs nothing while the lane is idle. `serve-frank-dragoon.sh`
# refuses to arm the lane on a node that is not other-accessible, so a host that lost this
# file fails loudly at boot instead of crash-looping.
KERNEL=="accel[0-9]*", SUBSYSTEM=="accel", MODE="0666"
+44 -6
View File
@@ -19,11 +19,14 @@
# is recorded in the parent tree, so cloning the parent commit recursively is # is recorded in the parent tree, so cloning the parent commit recursively is
# reproducible without a second pin. # reproducible without a second pin.
# #
# THE BUILD LINE IS THE ONE PROVEN ON DANKCLAW, verbatim: # THE BUILD LINE IS THE ONE PROVEN ON DANKCLAW, verbatim — with the Vulkan door
# open since 2026-10-19. That configure was run in this exact base image and
# answers `Found Vulkan: /usr/lib/x86_64-linux-gnu/libvulkan.so (found version
# "1.3.275") found components: glslc` then `Including Vulkan backend`:
# #
# cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CCACHE=OFF \ # cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CCACHE=OFF \
# -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF -DGGML_OPENMP=ON \ # -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF -DGGML_OPENMP=ON \
# -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF -DGGML_VULKAN=OFF \ # -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF -DGGML_VULKAN=ON \
# -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF \ # -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF \
# && cmake --build build -j 8 # && cmake --build build -j 8
# #
@@ -42,8 +45,12 @@
# defaults it ON outside wasm/cross-compiles, i.e. -march=native for THIS # defaults it ON outside wasm/cross-compiles, i.e. -march=native for THIS
# host — correct while build host == run host, wrong if anyone ever builds # host — correct while build host == run host, wrong if anyone ever builds
# this image elsewhere, which is why the fleet builds it on Dankclaw. # this image elsewhere, which is why the fleet builds it on Dankclaw.
# CUDA/HIP/VULKAN/METAL/SYCL/RPC all OFF: this is a CPU endpoint (the trial # CUDA/HIP/METAL/SYCL/RPC stay OFF, VULKAN is ON: the endpoint computes on the
# ran on CPU; the box's GPUs belong to the text endpoints). # gfx1151 iGPU (decision and co-tenancy gates:
# report/tts-npu-spike-verdict-2026-10-19.md, shape in [[Frank TTS Engine]]).
# Nothing else is available to this image: no ROCm userland, no NVIDIA
# userland, no macOS, no oneAPI, and RPC would add a second process to
# supervise on a box that already has one engine per card.
# #
# BUILD SHARED LIBS, RUNTIME SMALL. BUILD_SHARED_LIBS defaults ON on Linux, so # BUILD SHARED LIBS, RUNTIME SMALL. BUILD_SHARED_LIBS defaults ON on Linux, so
# tts-server links libggml*.so rather than swallowing them; the runtime stage # tts-server links libggml*.so rather than swallowing them; the runtime stage
@@ -67,9 +74,28 @@ ARG BASE_IMAGE=docker.io/library/ubuntu:24.04@sha256:68ee22004a9109b44973313b02f
# ------------------------------------------------------------------ build # ------------------------------------------------------------------ build
FROM ${BASE_IMAGE} AS build FROM ${BASE_IMAGE} AS build
# The three Vulkan build deps are not optional and not interchangeable:
# libvulkan-dev vulkan/vulkan.h + the VulkanHeaders/VulkanLoader CMake
# configs, i.e. what find_package(Vulkan) resolves to.
# glslc ggml compiles its shaders with glslc, not glslangValidator.
# Ubuntu's glslang-tools ships glslangValidator and NOT glslc,
# so without this package `find_package(Vulkan COMPONENTS
# glslc REQUIRED)` fails and there is no Vulkan backend.
# The archive's glslc is old (2023.8): it compiles
# GL_KHR_cooperative_matrix — the path ggml's quantized
# matmul needs — but reports cooperative_matrix2,
# integer_dot_product, bfloat16 and the micro-float
# extensions as unsupported, so those shader families are
# compiled out. If a Vulkan prefill ever reads as
# inexplicably slow, suspect this compiler before the device;
# the fix is a pinned glslang build passed as
# -DVulkan_GLSLC_EXECUTABLE=<path>, not a different runtime.
# spirv-headers find_package(SPIRV-Headers CONFIG REQUIRED); the distro
# package ships /usr/share/cmake/SPIRV-Headers/.
RUN apt-get update -qq \ RUN apt-get update -qq \
&& apt-get install -y -qq --no-install-recommends \ && apt-get install -y -qq --no-install-recommends \
ca-certificates cmake g++ git make \ ca-certificates cmake g++ git make \
libvulkan-dev glslc spirv-headers \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Checked out detached at a full SHA, then the recorded submodule. No branch # Checked out detached at a full SHA, then the recorded submodule. No branch
@@ -90,7 +116,7 @@ RUN cmake -B build -DCMAKE_BUILD_TYPE=Release \
-DGGML_BLAS=OFF \ -DGGML_BLAS=OFF \
-DGGML_CUDA=OFF \ -DGGML_CUDA=OFF \
-DGGML_HIP=OFF \ -DGGML_HIP=OFF \
-DGGML_VULKAN=OFF \ -DGGML_VULKAN=ON \
-DGGML_METAL=OFF \ -DGGML_METAL=OFF \
-DGGML_SYCL=OFF \ -DGGML_SYCL=OFF \
-DGGML_RPC=OFF \ -DGGML_RPC=OFF \
@@ -102,9 +128,21 @@ FROM ${BASE_IMAGE}
# libgomp1: the OpenMP runtime the build above links against. curl: the unit's # libgomp1: the OpenMP runtime the build above links against. curl: the unit's
# HealthCmd. ca-certificates: so that curl can speak https if anyone ever # HealthCmd. ca-certificates: so that curl can speak https if anyone ever
# points a probe at a URL with a scheme this host terminates elsewhere. # points a probe at a URL with a scheme this host terminates elsewhere.
#
# libvulkan1 + mesa-vulkan-drivers are what make `--device Vulkan0` mean
# anything at runtime: libvulkan1 is the loader libggml-vulkan.so dlopens, and
# mesa-vulkan-drivers is the RADV ICD that claims the gfx1151. Loader without an
# ICD enumerates ZERO devices, and tts-server then dies in device lookup AFTER
# both GGUFs have been read — the journal reads as a model fault, not a missing
# driver. Enumerated from this base on 2026-10-19 with `--device /dev/dri
# --group-add keep-groups` plus the host's ComfyUI udev rule: "Radeon 8060S
# Graphics (RADV GFX1151)", API 1.4.318 (the heap figure is owned by
# [[Frank TTS Engine]]; it is the GTT-backed aperture, not the ~2 GiB DRM
# carve-out). No NVIDIA ICD is installed on purpose: one Vulkan device in the
# enumeration is what makes the NAME Vulkan0 safe to write down.
RUN apt-get update -qq \ RUN apt-get update -qq \
&& apt-get install -y -qq --no-install-recommends \ && apt-get install -y -qq --no-install-recommends \
ca-certificates curl libgomp1 \ ca-certificates curl libgomp1 libvulkan1 mesa-vulkan-drivers \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Same unprivileged uid as the frank-tts façade (10000): the voice packs and the # Same unprivileged uid as the frank-tts façade (10000): the voice packs and the
@@ -40,12 +40,28 @@
# speaks to the server from inside its own namespace, which is where the address # speaks to the server from inside its own namespace, which is where the address
# is valid. # is valid.
# #
# MEMORY / NO GPU. Peak RSS measured 5.7 GiB with both GGUFs resident # MEMORY / THE RENDER NODE. Host RSS is ~1.7 GiB, not the size of the GGUFs:
# (2026-10-18 trial); no memory limit is set because the box has the room and a # the Vulkan backend copies both tensors into device memory at load, so they
# limit would convert a busy moment into an OOM kill mid-utterance. No # stop counting in the process's RSS (measured after the 2026-10-19 cutover —
# /dev/kfd, no /dev/dri, no keep-groups: every GPU backend is compiled out of # `podman stats` / `ps -o rss`). No memory limit is set: the box has the room
# the image, the endpoint is CPU-only by decision, and the GPUs on this host # and a limit would convert a busy moment into an OOM kill mid-utterance.
# belong to the text endpoints. #
# `AddDevice=/dev/dri` is what makes the iGPU visible; without it the Vulkan
# ICD enumerates nothing and the server dies on device lookup after it has
# already read both GGUFs. `GroupAdd=keep-groups` is belt-and-braces, not the
# thing that works here: the units podman spawns carry no supplementary groups
# at all, and this host grants render access a different way — the udev rule
# that makes the render nodes world-writable (same rule ComfyUI rides, see
# dankclaw/comfyui). Measured: `id` inside this container lists only the
# container's own group, and `tts-server` still holds renderD129.
# The node is NOT named: this host is hybrid, renderD128 is the NVIDIA card and
# renderD129 the amdgpu iGPU, so a node number in the unit is a guess that a
# re-probe can invalidate, while the directory hands the container every render
# node and the driver picks by name.
#
# /dev/kfd stays absent on purpose: that is the ROCm compute interface, owned by
# the engines that use HIP. A Vulkan endpoint has no use for it, and handing a
# container a second way onto the GPU is surface for nothing.
# #
# THE VOICE MOUNT IS READ-ONLY AND IS FOR THE WARMER, NOT THE ENGINE. A voice # THE VOICE MOUNT IS READ-ONLY AND IS FOR THE WARMER, NOT THE ENGINE. A voice
# becomes known to the engine only through POST /v1/audio/voices, and the # becomes known to the engine only through POST /v1/audio/voices, and the
@@ -98,6 +114,9 @@ Network=frank-tts-engine.network
EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts-engine.env EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts-engine.env
Volume=/home/dank/models/Qwen3-TTS-GGUF:/models:ro Volume=/home/dank/models/Qwen3-TTS-GGUF:/models:ro
Volume=/home/dank/voices:/voices:ro Volume=/home/dank/voices:/voices:ro
# Render access for the iGPU (see MEMORY / THE RENDER NODE above).
AddDevice=/dev/dri
GroupAdd=keep-groups
HealthCmd=curl -fsS http://127.0.0.1:8880/health HealthCmd=curl -fsS http://127.0.0.1:8880/health
HealthInterval=30s HealthInterval=30s
HealthTimeout=10s HealthTimeout=10s
+54 -19
View File
@@ -7,7 +7,7 @@
# supervision, no retries, no logging of its own. PID 1 stays the server, so # supervision, no retries, no logging of its own. PID 1 stays the server, so
# `podman stop` and the unit's Restart=always drive it directly. # `podman stop` and the unit's Restart=always drive it directly.
# #
# THE FLAGS THAT ARE NOT HERE, and must stay absent: # THE FLAG THAT MUST STAY ABSENT:
# #
# --threads / -t does not exist in this build. src/backend.h computes the # --threads / -t does not exist in this build. src/backend.h computes the
# CPU thread count as std::thread::hardware_concurrency()/2 # CPU thread count as std::thread::hardware_concurrency()/2
@@ -18,13 +18,19 @@
# placement — pinning is deliberately not done anywhere in # placement — pinning is deliberately not done anywhere in
# this stack. See the env file header for the same warning # this stack. See the env file header for the same warning
# aimed at the operator. # aimed at the operator.
# --device left at its default. Every GPU backend is compiled out of
# the image, so the default device pick can only land on
# CPU; naming a device would add a way to be wrong.
# #
# Everything defaults to the shape the 2026-10-18 trial measured, so an empty # --device IS here, but conditionally: it reaches the binary only when
# environment still boots the decided configuration: 1.7 B talker Q4_K_M with # QWENTTS_DEVICE is non-empty, and empty is the CPU rollback — the binary then
# the Q8_0 tokenizer, one request at a time, on the private bridge. # takes its own default device, which is CPU because the CPU backend is always
# built. One line in scripts/frank-tts-engine.env moves the endpoint between the
# two shapes, with no rebuild. What must never reach argv is a device NAME the
# loader does not enumerate: that is not refused by the argument parser, it dies
# during device lookup with the loader's own error, so the journal reads as a
# load that failed rather than a configuration refusal.
#
# Everything defaults to the shape this endpoint runs: 1.7 B talker Q4_K_M with
# the Q8_0 tokenizer, one request at a time, on the private bridge, on the
# gfx1151 iGPU. An empty environment still boots that shape.
set -euo pipefail set -euo pipefail
# The binary path. Overridable for one reason: the bats seam runs a stub # The binary path. Overridable for one reason: the bats seam runs a stub
@@ -60,13 +66,33 @@ set -euo pipefail
: "${QWENTTS_LANG:=auto}" : "${QWENTTS_LANG:=auto}"
# One request at a time. The server-side queue is a serialised FIFO at this # One request at a time. The server-side queue is a serialised FIFO at this
# setting, which is what keeps the measured per-voice timings true; batching # setting, which is what keeps the per-voice timings predictable. A second
# on a CPU build splits the same cores between two syntheses and the warm RTF # concurrent synthesis shares the device with the first — and on this box that
# margin (0.55-0.67 against real time 1.0) disappears. The engine's own # device is also carrying frank-dragoon — so it buys no throughput and it
# default is also 1; it is written here because it is a decision, not an # doubles the wait for the caller behind it. The engine's own default is also 1;
# accident. # written here because it is a decision, not an accident.
: "${QWENTTS_MAX_BATCH:=1}" : "${QWENTTS_MAX_BATCH:=1}"
# The compute device, NAMED and not numbered: `Vulkan0` is how ggml enumerates
# the gfx1151 iGPU on this host. An index would follow whatever order the
# loader's device list returns; a name says which device this endpoint means.
# The container sees exactly one Vulkan device because the image carries the
# Mesa ICD and no NVIDIA one, so the 3090 is not in the enumeration at all.
#
# EMPTY IS THE CPU ROLLBACK: a value that is set but EMPTY passes no `--device`,
# the binary picks its own default, and the CPU backend is always built — which
# is why moving this endpoint between the iGPU and the CPU is one line in
# scripts/frank-tts-engine.env and not a rebuild.
#
# That is also why the default below is NOT `${QWENTTS_DEVICE:=Vulkan0}`: `:=`
# fires on a set-but-empty variable as well as an unset one, so the rollback line
# `QWENTTS_DEVICE=` would be rewritten back to Vulkan0 and the endpoint would sit
# on the shared GPU with no way out short of a rebuild. Only UNSET takes the
# default.
if [[ ! -v QWENTTS_DEVICE ]]; then
QWENTTS_DEVICE=Vulkan0
fi
# Fail loudly BEFORE binding, so the journal says which artifact is missing # Fail loudly BEFORE binding, so the journal says which artifact is missing
# instead of the unit reporting a container that starts and dies. This is the # instead of the unit reporting a container that starts and dies. This is the
# self-healing path: Restart=always re-runs it every RestartSec, and the same # self-healing path: Restart=always re-runs it every RestartSec, and the same
@@ -79,11 +105,20 @@ for artifact in "$QWENTTS_MODEL" "$QWENTTS_CODEC"; do
fi fi
done done
exec "$QWENTTS_BIN" \ ARGS=(
--model "$QWENTTS_MODEL" \ --model "$QWENTTS_MODEL"
--codec "$QWENTTS_CODEC" \ --codec "$QWENTTS_CODEC"
--alias "$QWENTTS_ALIAS" \ --alias "$QWENTTS_ALIAS"
--host "$QWENTTS_HOST" \ --host "$QWENTTS_HOST"
--port "$QWENTTS_PORT" \ --port "$QWENTTS_PORT"
--lang "$QWENTTS_LANG" \ --lang "$QWENTTS_LANG"
--max-batch "$QWENTTS_MAX_BATCH" --max-batch "$QWENTTS_MAX_BATCH"
)
# Only when a device is named. An empty QWENTTS_DEVICE has to produce NO flag,
# because "the rollback shape" is argv identical to the pre-iGPU endpoint.
if [[ -n "$QWENTTS_DEVICE" ]]; then
ARGS+=(--device "$QWENTTS_DEVICE")
fi
exec "$QWENTTS_BIN" "${ARGS[@]}"
@@ -43,7 +43,7 @@ After=network.target
[Container] [Container]
ContainerName=calibre-server ContainerName=calibre-server
Image=lscr.io/linuxserver/calibre:9.15.0 Image=lscr.io/linuxserver/calibre:9.15.0@sha256:59e4b0e036e62f228f8bcfae1ade42f47047719885fe454ddbad38e4597b987b
Network=pasta Network=pasta
EnvironmentFile=/home/dank/Containers/calibre-server/env EnvironmentFile=/home/dank/Containers/calibre-server/env
Environment=PUID=1000 Environment=PUID=1000
@@ -16,7 +16,7 @@ After=network.target
[Container] [Container]
ContainerName=calibre-web ContainerName=calibre-web
Image=lscr.io/linuxserver/calibre-web:0.6.27-ls402 Image=lscr.io/linuxserver/calibre-web:0.6.27-ls402@sha256:802729966faed5e32d671cd4f92ba6196e9e754a6c0f740ce1ee9acd86b10e55
Network=pasta Network=pasta
Environment=DOCKER_MODS=linuxserver/mods:universal-calibre Environment=DOCKER_MODS=linuxserver/mods:universal-calibre
Environment=LANG=en_US.UTF-8 Environment=LANG=en_US.UTF-8
@@ -21,7 +21,13 @@ After=network.target
[Container] [Container]
ContainerName=copyparty ContainerName=copyparty
Image=ghcr.io/9001/copyparty-ac:latest # Digest-pinned: upstream stopped publishing version tags after 1.19.15, so
# the pin rides :latest@digest (the tag in the reference is cosmetic; the
# digest is the pull). Pinned 2026-10-21 at the build whose label is
# 1.20.26 - closes GHSA-3c88-p299-7hvr (1.20.25) and GHSA-hg99-jqfp-rf76
# (1.20.26); the previously running build was the rolling :latest at
# 1.20.24 (issue #73 on command-center).
Image=ghcr.io/9001/copyparty-ac:latest@sha256:7f77459160a4bf34fdca79f2965dff4f0c1d7069a3d9cdfb3632acb041dd2f6a
Network=dankserver_appnet Network=dankserver_appnet
EnvironmentFile=/home/dank/Containers/copyparty/env EnvironmentFile=/home/dank/Containers/copyparty/env
Exec=-a ${COPAY_ACCOUNT}:${COPAY_PASSWORD} -v /w::r:ra,dank Exec=-a ${COPAY_ACCOUNT}:${COPAY_PASSWORD} -v /w::r:ra,dank
@@ -16,7 +16,7 @@ After=network.target
[Container] [Container]
ContainerName=jellyfin ContainerName=jellyfin
Image=ghcr.io/jellyfin/jellyfin:latest Image=ghcr.io/jellyfin/jellyfin:latest@sha256:c3efa69a22f2691fc88d6bb035e87a8560e395a6047f062fb381c61c3185e8f5
Network=dankserver_appnet Network=dankserver_appnet
Environment=DEBIAN_FRONTEND=noninteractive Environment=DEBIAN_FRONTEND=noninteractive
Environment=DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true Environment=DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true
@@ -0,0 +1,39 @@
# Quadlet unit: cadvisor (rootless, boot-persistent) — DANKSERVER.
#
# Install path on dankserver: ~/.config/containers/systemd/cadvisor.container
# Unit name: cadvisor.service (container name: cadvisor)
#
# The Dankserver twin of dankclaw/observability/quadlet/cadvisor.container.
# Same rootless shape, same --cgroupns=host requirement (see that file for the
# full story), only the LAN address differs: this host publishes on
# 192.168.0.235:8080 so Prometheus on Dankclaw can scrape it over the real
# network (cross-host, so the host.containers.internal trick does not apply).
# It reports the media/library stack (romm, plex, jellyfin, copyparty, calibre,
# tautulli) by their quadlet unit names via the cgroup path.
[Unit]
Description=cAdvisor container metrics (Dankserver)
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=cadvisor
Image=gcr.io/cadvisor/cadvisor:v0.49.1@sha256:3cde6faf0791ebf7b41d6f8ae7145466fed712ea6f252c935294d2608b1af388
PodmanArgs=--pid=host --cgroupns=host
PublishPort=127.0.0.1:8080:8080
PublishPort=192.168.0.235:8080:8080
Volume=/sys:/sys:ro
Volume=/:/rootfs:ro
Exec=--housekeeping_interval=30s
HealthCmd=wget --spider -q http://localhost:8080/healthz
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=40s
[Service]
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
@@ -37,7 +37,7 @@ Wants=network-online.target
[Container] [Container]
ContainerName=node-exporter ContainerName=node-exporter
Image=docker.io/prom/node-exporter:latest Image=docker.io/prom/node-exporter:latest@sha256:1b4e4438faca4dd7e001dd445d161a4a2091b0fededa84093b3a8dfeae1f1be0
Network=host Network=host
PodmanArgs=--pid=host PodmanArgs=--pid=host
Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:9100 --web.listen-address=192.168.0.235:9100 Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:9100 --web.listen-address=192.168.0.235:9100
+1 -1
View File
@@ -4,7 +4,7 @@ After=network-online.target
Wants=network-online.target Wants=network-online.target
[Container] [Container]
Image=lscr.io/linuxserver/plex:1.43.4.10903-e5521bd8c-ls325 Image=lscr.io/linuxserver/plex:1.43.5.11029-6bf8af3f0-ls329@sha256:8f655bad7dcbc5398a78f70a5af12600b4ed3424f901566ed1eaa6710e9e2334
ContainerName=plex ContainerName=plex
Network=dankserver_appnet:alias=plex Network=dankserver_appnet:alias=plex
Environment=PUID=0 Environment=PUID=0
@@ -14,7 +14,7 @@ After=network.target
[Container] [Container]
ContainerName=romm-mariadb ContainerName=romm-mariadb
Image=docker.io/library/mariadb:11 Image=docker.io/library/mariadb:11@sha256:f16d7a344413e519dff518f27bdb221388f84b0d274c97c4fb5f2cb0dc1b34fa
Network=dankserver_appnet:alias=mariadb Network=dankserver_appnet:alias=mariadb
EnvironmentFile=/home/dank/Containers/romm/env EnvironmentFile=/home/dank/Containers/romm/env
Environment=MARIADB_DATABASE=romm Environment=MARIADB_DATABASE=romm
+1 -1
View File
@@ -12,7 +12,7 @@ After=network.target
[Container] [Container]
ContainerName=romm-redis ContainerName=romm-redis
Image=docker.io/library/redis:7-alpine Image=docker.io/library/redis:7-alpine@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499
Network=dankserver_appnet:alias=redis Network=dankserver_appnet:alias=redis
Volume=/home/dank/Containers/romm/redis:/data Volume=/home/dank/Containers/romm/redis:/data
HealthCmd=redis-cli ping HealthCmd=redis-cli ping
+1 -1
View File
@@ -18,7 +18,7 @@ After=romm-mariadb.service romm-redis.service network.target
[Container] [Container]
ContainerName=romm ContainerName=romm
Image=ghcr.io/rommapp/romm:latest Image=ghcr.io/rommapp/romm:latest@sha256:03295f95a6290e752641f9553ac4198b0e5b71b879d57f7b2371b98f608feb54
Network=dankserver_appnet Network=dankserver_appnet
EnvironmentFile=/home/dank/Containers/romm/env EnvironmentFile=/home/dank/Containers/romm/env
Environment=DB_HOST=mariadb Environment=DB_HOST=mariadb
@@ -4,7 +4,7 @@ After=network-online.target
Wants=network-online.target Wants=network-online.target
[Container] [Container]
Image=lscr.io/linuxserver/tautulli:v2.18.1-ls245 Image=lscr.io/linuxserver/tautulli:v2.18.1-ls245@sha256:c66d3afa8f7a701a48dd1b1f57b22eca0483d895d670fe11ecff783f6a9bbd73
ContainerName=tautulli ContainerName=tautulli
Network=dankserver_appnet:alias=tautulli Network=dankserver_appnet:alias=tautulli
Environment=PUID=0 Environment=PUID=0
@@ -19,7 +19,7 @@ Description=Gluetun VPN tunnel (FrankBook-Pro podman machine)
[Container] [Container]
ContainerName=gluetun ContainerName=gluetun
Image=docker.io/qmcgaw/gluetun:latest Image=docker.io/qmcgaw/gluetun:latest@sha256:c2ee4191762fca6b6a3c3359a731198a95d9c9af4db32d466e223bf3a84e779b
AddCapability=NET_ADMIN AddCapability=NET_ADMIN
AddDevice=/dev/net/tun:/dev/net/tun AddDevice=/dev/net/tun:/dev/net/tun
Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER} Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
@@ -15,7 +15,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=lidarr ContainerName=lidarr
Image=lscr.io/linuxserver/lidarr:latest Image=lscr.io/linuxserver/lidarr:latest@sha256:d8549ff9ea29b4015c32257360652c1000c31ea471a2b3f0835ae77292faf35b
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -24,7 +24,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=prowlarr ContainerName=prowlarr
Image=lscr.io/linuxserver/prowlarr:latest Image=lscr.io/linuxserver/prowlarr:latest@sha256:b3b00047ed20f6ce2b16780145cb6a0acb51c9e28ea6aa6f5dcd33c7034f1797
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -29,7 +29,7 @@ Network=gluetun.container
# Pinned to the last 4.x line: Readarr's retired qB client requires the # Pinned to the last 4.x line: Readarr's retired qB client requires the
# qB 4.x login response (HTTP 200, body "Ok."); qB 5.x answers 204 with an # qB 4.x login response (HTTP 200, body "Ok."); qB 5.x answers 204 with an
# empty body, which fails Readarr's auth check. All other clients accept 4.x. # empty body, which fails Readarr's auth check. All other clients accept 4.x.
Image=lscr.io/linuxserver/qbittorrent:4.6.7 Image=lscr.io/linuxserver/qbittorrent:4.6.7@sha256:55f15d44396315551f87294a176efae733b16e283f38980308e46073950257c6
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -15,7 +15,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=radarr ContainerName=radarr
Image=lscr.io/linuxserver/radarr:latest Image=lscr.io/linuxserver/radarr:latest@sha256:fc0ae3c50f8128b97e84381903ec8d6845c3a569641cb7117c124376cccb57c1
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -19,7 +19,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=readarr ContainerName=readarr
Image=lscr.io/linuxserver/readarr:amd64-1.0.980 Image=lscr.io/linuxserver/readarr:amd64-1.0.980@sha256:a95e82dbb0eb2bb43de4c150f1dab9dd821306c67ea0b418983a957e731f2b61
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
@@ -16,7 +16,7 @@ After=gluetun.service
[Container] [Container]
ContainerName=sonarr ContainerName=sonarr
Image=lscr.io/linuxserver/sonarr:latest Image=lscr.io/linuxserver/sonarr:latest@sha256:889dcc58c6aea859033985894b519b2e6b1e55d92527ed123f9f52e42ae7c97b
Environment=PUID=0 Environment=PUID=0
Environment=PGID=0 Environment=PGID=0
Environment=TZ=America/Los_Angeles Environment=TZ=America/Los_Angeles
+28 -17
View File
@@ -136,25 +136,36 @@ TTS engine today|3.84 GiB RSS, CPU|its device-resident replacement lands in that
ICD**, so Vulkan device 0 is the iGPU here. Do not trust that ordering — select by ICD**, so Vulkan device 0 is the iGPU here. Do not trust that ordering — select by
device *name*, or an image that grows an `nvidia_icd.json` silently points TTS at the device *name*, or an image that grows an `nvidia_icd.json` silently points TTS at the
3090 slot. 3090 slot.
2. **Contention gate.** `frank-dragoon` is the endpoint the operator session runs on, so the 2. **Contention gate — PASSED, measured 2026-10-19 after the cutover.** With a 16.9 s
honest measurement with both loaded is dragoon's own TTFT and decode with a synthesis in synthesis occupying the device, `frank-dragoon` answered a streamed 124-token request at
flight — one request each, no ladder. Expect compute-unit sharing rather than memory **17 ms to first token and ~35 tok/s decode**, against 17 ms and ~33 tok/s for the same
pressure: the aperture has room, the shaders do not. request with the card idle. Compute-unit sharing showed up as nothing. One request per
arm, no ladder: these are the gate's evidence, not a throughput record, and the engine's
own shape lives in [[Frank TTS Engine]].
**Change surface** — the whole edit, so nobody re-derives it: **Change surface — as landed** (PR #34, `6248f02`; the CPU shape stays reachable as the
rollback):
1. `Containerfile.engine` build stage: add `libvulkan-dev glslang-dev`. It installs only 1. `Containerfile.engine` build stage: add **`libvulkan-dev glslc spirv-headers`**. The
`ca-certificates cmake g++ git make` today, and ggml's Vulkan backend compiles its prediction above (`libvulkan-dev glslang-dev`) is wrong on two counts and the configure
shaders at build time — the flag alone fails at configure. says so: ggml looks for `glslc`, which Ubuntu's `glslang-tools` does **not** ship (it
2. `-DGGML_VULKAN=OFF` → `ON` in the same cmake block; the flag list is written out ships `glslangValidator`), and `find_package(SPIRV-Headers CONFIG REQUIRED)` needs the
explicitly, so this is a one-word diff. distro's `spirv-headers`, which does carry the CMake config. `glslc` is its own source
3. Runtime stage: add `mesa-vulkan-drivers`. Without a Vulkan **ICD inside the image** the package on noble, at 2023.8 — old enough that only `GL_KHR_cooperative_matrix` compiles,
device node buys nothing — there is no `radeon_icd.json` in the container today. so the coopmat2 / integer-dot / bf16 / micro-float shader families are built out. Suspect
4. `frank-tts-engine.container`: `AddDevice=/dev/dri` + `GroupAdd=keep-groups` — **not** that compiler before the device if a Vulkan prefill ever reads as slow.
`renderD128`. On this hybrid box that node is the NVIDIA card; the iGPU is 2. `-DGGML_VULKAN=OFF` → `ON`: one word in the written-out flag list.
`renderD129` (`readlink -f /sys/class/drm/renderD129/device/driver` → `amdgpu`). 3. Runtime stage: `mesa-vulkan-drivers` **and** `libvulkan1` — the ICD and its loader. An
5. Engine argv: `--device Vulkan0` — qwentts.cpp's tools take `--device <name>` at the image with a loader and no ICD enumerates zero devices.
pinned commit. 4. `frank-tts-engine.container`: `AddDevice=/dev/dri` + `GroupAdd=keep-groups` — the
DIRECTORY, never the node: on this hybrid box `renderD128` is the NVIDIA card and
`renderD129` the amdgpu iGPU (`readlink -f /sys/class/drm/renderD129/device/driver` →
`amdgpu`). On this host the udev rule already makes the nodes world-writable, so the
group is belt-and-braces, not the gate.
5. Engine argv: `--device Vulkan0`, reached through `QWENTTS_DEVICE` in
`scripts/frank-tts-engine.env`. **An empty value passes no flag at all** — that is the
CPU rollback, and it is why the entrypoint's default is an `-v` test rather than `:=`,
which also fires on a set-but-empty value and would silently undo the rollback.
Gate 1 recipe, already run and **PASSED**: `mesa-vulkan-drivers` + `vulkan-tools` in a Gate 1 recipe, already run and **PASSED**: `mesa-vulkan-drivers` + `vulkan-tools` in a
throwaway container off the same base, `--device /dev/dri`, then `vulkaninfo --text` and read throwaway container off the same base, `--device /dev/dri`, then `vulkaninfo --text` and read
+32 -14
View File
@@ -1,27 +1,45 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# apply-protected-cast.sh — RUNS ON DANKSERVER. Rewrite the recipe's # apply-protected-cast.sh — RUNS ON DANKSERVER. Rewrite a recipe's
# PROTECTED-BEGIN/END section from Swift's OWN calibration grams: run # PROTECTED-BEGIN/END section from the campaign's OWN calibration grams: run
# derive-protected-experts.py over the calib dir, splice its cast lines into # derive-protected-experts.py over the calib dir, splice its cast lines into
# the marked section of the recipe in place, keep the pre-edit recipe in the # the marked section of the recipe in place, keep the pre-edit recipe in the
# campaign trash. Refuses to run twice (marker text says derived). # campaign trash. Refuses to run twice (marker text says derived), and refuses
# when a requested target count was not reached. Run it on a COPY of the
# template so the committed recipe stays the clean, reviewable baseline.
set -euo pipefail set -euo pipefail
CALIB=${1:-/home/dank/corpora/calib} SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RECIPE=${2:-/home/dank/serverAppConfigs-swift15/scripts/recipes/qwen4exp-swift15-w4dn8-i8-hc8m.recipe} CALIB=${1:-/home/dank/campaign/qwen38-w5nl64/calib}
PY=/home/dank/venvs/radcorpus/bin/python RECIPE=${2:-$SELF_DIR/recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe}
DERIVE=/home/dank/serverAppConfigs-swift15/scripts/derive-protected-experts.py TARGET_COUNT=${3:-0}
PY=${PY:-/home/dank/venvs/radcorpus/bin/python}
DERIVE=${DERIVE:-$SELF_DIR/derive-protected-experts.py}
TS=$(date +%Y%m%d-%H%M%S) TS=$(date +%Y%m%d-%H%M%S)
# OUT and TRASH_DIR are overridable so the seam can drive the whole script in a
# scratch directory; on the box they are the campaign's usual places.
OUT=${OUT:-/tmp/protected-cast-$TS.txt}
TRASH_DIR=${TRASH_DIR:-/home/dank/.AGENTTRASH}
grep -q "derive-protected-experts.py rewrote" "$RECIPE" && { grep -q "derive-protected-experts.py rewrote" "$RECIPE" && {
echo "recipe already carries derived cast lines; refusing to re-derive" >&2; exit 1; } echo "recipe already carries derived cast lines; refusing to re-derive" >&2; exit 1; }
grep -q "PROTECTED-BEGIN" "$RECIPE" || { echo "no PROTECTED markers in $RECIPE" >&2; exit 1; } grep -q "PROTECTED-BEGIN" "$RECIPE" || { echo "no PROTECTED markers in $RECIPE" >&2; exit 1; }
"$PY" "$DERIVE" "$CALIB" > "/tmp/protected-cast-$TS.txt" ARGS=("$DERIVE" "$CALIB")
N=$(grep -c 'cast dtype=bf16' "/tmp/protected-cast-$TS.txt") if [ "$TARGET_COUNT" -gt 0 ]; then
[ "$N" -ge 2 ] || { echo "derive produced $N cast lines; refusing" >&2; exit 1; } ARGS+=(--target-count "$TARGET_COUNT")
echo "derived $N protected experts -> /tmp/protected-cast-$TS.txt" fi
"$PY" "${ARGS[@]}" > "$OUT"
N=$(grep -c 'cast dtype=bf16' "$OUT")
[ "$N" -ge 2 ] || { echo "derive produced $N cast lines; refusing (see $OUT)" >&2; exit 1; }
if [ "$TARGET_COUNT" -gt 0 ] && [ "$N" -lt "$TARGET_COUNT" ]; then
echo "derive reached $N protected experts, target was $TARGET_COUNT; refusing to convert" >&2
echo " (the bar sweep bottomed out: the recording is too thin or the target too high)" >&2
exit 1
fi
echo "derived $N protected experts -> $OUT"
cp -f "$RECIPE" "/home/dank/.AGENTTRASH/$TS-qwen4exp-swift15-w4dn8-i8-hc8m.recipe" mkdir -p "$TRASH_DIR"
"$PY" - "$RECIPE" "/tmp/protected-cast-$TS.txt" <<'PYEOF' cp -f "$RECIPE" "$TRASH_DIR/$TS-$(basename "$RECIPE")"
"$PY" - "$RECIPE" "$OUT" <<'PYEOF'
import sys import sys
recipe, castfile = sys.argv[1], sys.argv[2] recipe, castfile = sys.argv[1], sys.argv[2]
lines = open(recipe).read().splitlines() lines = open(recipe).read().splitlines()
@@ -35,7 +53,7 @@ for l in lines:
elif 'PROTECTED-END' in l: elif 'PROTECTED-END' in l:
insec = False insec = False
out.append(l) out.append(l)
out.append('# (derive-protected-experts.py rewrote this section from Swift\'s own grams)') out.append('# (derive-protected-experts.py rewrote this section from the campaign grams)')
elif not insec: elif not insec:
out.append(l) out.append(l)
open(recipe, 'w').write('\n'.join(out) + '\n') open(recipe, 'w').write('\n'.join(out) + '\n')
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Repair the Grafana OAuth2 relying-party SSO on Dankclaw's authentik.
#
# grafana.franksdank.cloud logs in through authentik as an OAuth2 relying party.
# Two DB-side facts (the provider was created by hand via `ak shell`, never
# repo-managed) silently broke that login, and BOTH had previously been
# mis-recorded here as a single "the property-mapping evaluator is broken, so
# userinfo returns only `sub`" myth. They are neither, and the evaluator is fine:
#
# 1. provider.authorization_flow was NULL. The authorize view builds a flow
# planner from it and crashes with
# `AttributeError: 'NoneType' object has no attribute 'slug'` — an HTTP 500
# "Server Error — Powered by authentik", before any claim is touched.
# Fix: point it at default-provider-authorization-implicit-consent, the
# flow every other provider in this instance already uses.
# 2. The `grafana-email-scope` ScopeMapping's expression was the bare string
# `user.email`. authentik merges a scope mapping's result into the token as
# a MAPPING: a returned dict adds its keys as claims; a returned string is
# keyed under the mapping's NAME ("grafana-email-scope"), so no `email`
# claim is ever emitted. Grafana, finding no email, falls back to
# GET <api_url>/emails, authentik 404s that path, and the login dies with
# "Login failed: InternalError". Fix: return
# {"email": user.email, "email_verified": True} — the shape the stock
# OpenID 'email' mapping uses. The dict-valued `grafana-profile-scope`
# mapping has always worked, which is the proof the evaluator is fine.
#
# This is authentik DB state. It is NOT in any deployable file: a blueprint
# re-import, or an ak-shell re-creation of the grafana provider, reverts BOTH
# facts and the symptom returns verbatim. Re-run this after any such rebuild.
# Every step is idempotent.
#
# Grafana's own half — SCOPES="openid email profile", a standard userinfo API_URL
# with no `?api=1`, and ALLOW_SIGN_UP=true — lives in
# dankclaw/observability/quadlet/grafana.container and deploys normally.
#
# Usage: authentik-grafana-sso-fix.sh (override host: AUTHENTIK_HOST=…)
set -euo pipefail
HOST="${AUTHENTIK_HOST:-dankclaw}"
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<'PYEOF'
from authentik.flows.models import Flow
from authentik.providers.oauth2.models import OAuth2Provider
p = OAuth2Provider.objects.filter(name="grafana").first()
if not p:
raise SystemExit("no OAuth2Provider named 'grafana'")
# 1. The authorization flow every other provider uses.
want = Flow.objects.filter(slug="default-provider-authorization-implicit-consent").first()
if not want:
raise SystemExit("default-provider-authorization-implicit-consent flow is missing")
if p.authorization_flow_id != want.pk:
p.authorization_flow = want
p.save()
print(f"set grafana.authorization_flow -> {want.slug}")
else:
print(f"grafana.authorization_flow already {want.slug}")
# 2. The email scope mapping must RETURN A DICT, not a bare string.
m = p.property_mappings.filter(name="grafana-email-scope").first()
if not m:
raise SystemExit("grafana-email-scope mapping is missing")
fixed = 'return {"email": user.email, "email_verified": True}'
if m.expression.strip() != fixed:
m.expression = fixed
m.save()
print("repaired grafana-email-scope -> returns {email, email_verified}")
else:
print("grafana-email-scope expression already correct")
# 3. The id_token must carry the scope claims; Grafana reads identity from it.
if not p.include_claims_in_id_token:
p.include_claims_in_id_token = True
p.save()
print("enabled include_claims_in_id_token")
else:
print("include_claims_in_id_token already on")
PYEOF
echo "Done. Verify: sign in at https://grafana.franksdank.cloud via Authentik;"
echo "the round trip must land on / and /api/user must answer 200 with an email."
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Set the authentik login-session TTL on Dankclaw.
#
# The login-session lifetime is governed by the `default-authentication-login`
# stage's `session_duration`, NOT a global setting and NOT SESSION_COOKIE_AGE
# (that is the pre-login session, sessions.unauthenticated_age). Authentik does
# not expose SESSION_SAVE_EVERY_REQUEST at all, so the session is an absolute
# window from login, not a sliding one.
#
# The field is a STRING in authentik's own idiom ("days=7", "seconds=30",
# "hours=12") — not a Python timedelta. Writing str(timedelta(days=7)) stores
# "7 days, 0:00:00", which authentik's timedelta_from_string parser REJECTS
# (ValueError), and login then crashes computing the session expiry. Always pass
# the "key=value" form; this script validates it before writing.
#
# Existing sessions keep their old expiry; the new TTL applies from the next
# login. This is DB state — it is NOT repo-managed and a blueprint re-import or
# rebuild-from-scratch reverts it. Run this again after such a rebuild.
#
# Usage: authentik-set-login-session-duration.sh ["days=7"] (default days=7)
set -euo pipefail
DUR="${1:-days=7}"
STAGE="default-authentication-login"
HOST="${AUTHENTIK_HOST:-dankclaw}"
# Refuse anything that is not the safe key=value duration idiom.
if ! printf '%s' "$DUR" | grep -Eq '^[A-Za-z]+=[0-9]+([ ]+[A-Za-z]+=[0-9]+)*$'; then
echo "invalid duration '$DUR' — use authentik form, e.g. 'days=7' or 'seconds=30'" >&2
exit 2
fi
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
from django.apps import apps
from authentik.lib.utils.time import timedelta_from_string
dur = "$DUR"
timedelta_from_string(dur) # validate the form before touching the row
ULS = apps.get_model("authentik_stages_user_login", "UserLoginStage")
s = ULS.objects.get(name="$STAGE")
s.session_duration = dur
s.save(update_fields=["session_duration"])
back = ULS.objects.get(name="$STAGE").session_duration
assert back == dur, "persisted value mismatch: %r" % back
print("session_duration set to", repr(back), "->", timedelta_from_string(back))
PYEOF
echo "Login-session TTL for '$STAGE' is now $DUR (applies on next login)."
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env bash
# build-qwen38-bf16-rad.sh — RUNS ON DANKSERVER. rad-convert the vendor BF16
# checkpoint /home/dank/models/Qwen3.8-Flash-Next into
# /home/dank/models/rad/qwen38-bf16.rad with NO recipe and NO --reuse: a weight
# no rule matches keeps the checkpoint's encoding, so every tensor stays the
# bf16 it is in the safetensors and the container is the checkpoint's own
# numbers behind a .rad header.
#
# This file is the campaign's measuring stick, twice over: scripts/
# run-qwen38-bf16.sh kldref records the KLD reference logits from it (every
# arm's --kld-ref is scored against that record) and the same container serves
# with the calibration tap to write the Grams that derive the protected-expert
# list and feed GPTQ. A reference that is not the vendor checkpoint, byte for
# byte in bf16, silently moves every gate in the campaign — hence no recipe, no
# --reuse, and the four refusal gates below.
#
# Why a container at all: a checkpoint dir has no mapped/file tiers, and a
# weight only a host kernel reads (the 47.7 GiB PLE n-gram table) cannot be
# served from one; rad-convert also bakes the tokenizer. The convert runs on
# the HOST backend — no --device /dev/kfd, no /dev/dri — so declare resolves
# kernels on CPU and production serving is untouched for the whole run (the
# precedent that proved it: scripts/convert-swift15-w4dn8.sh).
#
# Gates, every one a refusal with exit 2 and a named reason, BEFORE anything is
# written, cheapest first — the verifier runs last of them because its Hub gate
# hashes 343 GiB and must not be paid for by a run that was going to refuse on
# the output file anyway.
# checkpoint the tree must be on disk; an absent dir names the command that
# makes one (scripts/download-qwen38-bf16.sh --fetch) and stops.
# free space the convert writes ~343,323 MiB next to the ~343,323 MiB
# checkpoint; QWEN38_MIN_FREE_MIB (default 350000 = that figure
# plus 6.5 GiB of slack) is the floor. The volume that cannot
# hold both is discovered at minute 2, not hour 5.
# output the campaign converts the reference ONCE. An existing
# qwen38-bf16.rad is refused; --force retires it to
# ~/.AGENTTRASH/<utc>-qwen38-bf16.rad (trash law: nothing is
# deleted, and the move is a rename on the same volume).
# verified scripts/download-qwen38-bf16.sh's verify mode must pass. A
# failed verify exits 1, not 2: the tree being the wrong payload
# is a result, a missing environment is a refusal.
# plan rad-convert --plan-only -v first — the oracle for "does this
# shape boot". A refused plan costs seconds and saves a convert.
#
# usage: scripts/build-qwen38-bf16-rad.sh [--dry-run] [--plan-only] [--force]
# --dry-run print the argv it would exec (podman wrapping rad-convert),
# one shell-quoted argument a line, and exit 0. No gate runs, no
# container starts, nothing is written — the offline test seam.
# It does resolve the checkpoint and output paths through the
# /models mount, because an argv that carries host paths would
# not run either.
# --plan-only run the plan and every gate except the disk and output ones —
# those two protect bytes the plan never writes, and this is the
# probe to run when the disk gate itself is what refuses
# --force retire an existing output container to the trash dir first
#
# knobs: FRANK_QWEN38_MODELS the host dir mounted at /models (default
# /home/dank/models); FRANK_QWEN38_CKPT (default $MODELS/
# Qwen3.8-Flash-Next, the same knob the verifier reads);
# FRANK_QWEN38_BF16_OUT (default $MODELS/rad/qwen38-bf16.rad);
# QWEN38_MIN_FREE_MIB; QWEN38_TRASH (default $HOME/.AGENTTRASH). The
# verifier's own census knobs (FRANK_QWEN38_SHARDS / _BYTES / _REPO)
# reach it through the environment untouched.
# exit: 0 converted (or planned), 1 the checkpoint failed verification,
# 2 refused before anything was written
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ONE owner of the engine pin: the digest frank-archon serves on. The reference
# must be built by the SAME engine build that scores it, or a KLD delta is
# partly a version delta.
# shellcheck disable=SC1091
source "$SCRIPT_DIR/frank-archon.env"
IMAGE="${FRANK_ARCHON_IMAGE:?FRANK_ARCHON_IMAGE must name the radiance image}"
MODELS="${FRANK_QWEN38_MODELS:-/home/dank/models}"
CKPT="${FRANK_QWEN38_CKPT:-$MODELS/Qwen3.8-Flash-Next}"
OUT="${FRANK_QWEN38_BF16_OUT:-$MODELS/rad/qwen38-bf16.rad}"
TRASH="${QWEN38_TRASH:-$HOME/.AGENTTRASH}"
MIN_FREE_MIB="${QWEN38_MIN_FREE_MIB:-350000}"
LOG="/home/dank/logs/qwen38_bf16_convert.log"
DRY=0
PLAN_ONLY=0
FORCE=0
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY=1 ;;
--plan-only) PLAN_ONLY=1 ;;
--force) FORCE=1 ;;
*) echo "usage: ${0##*/} [--dry-run] [--plan-only] [--force]" >&2
exit 2 ;;
esac
shift
done
# The convert sees the tree only through the /models mount, so both paths must
# be under it; the container-side spelling is what goes into the argv.
case "$CKPT" in
"$MODELS"/*) CKPT_IN="/models/${CKPT#"$MODELS"/}" ;;
*) echo "REFUSED: checkpoint $CKPT is not under the mounted models dir $MODELS — the convert cannot see it" >&2
exit 2 ;;
esac
case "$OUT" in
"$MODELS"/*) OUT_IN="/models/${OUT#"$MODELS"/}" ;;
*) echo "REFUSED: output $OUT is not under the mounted models dir $MODELS — rad-convert could not write it" >&2
exit 2 ;;
esac
# Declare with NO recipe line — that is what keeps every tensor bf16 — and at the
# bound this container will actually be served at. Both flags come off the pinned
# image's own help: --kv-cache-dtype at convert "only picks the kernels it
# checks" and the container is identical either way, so naming fp8 is free
# insurance — if the fp8 KV kernels the fleet serves would not resolve, the
# convert refuses here instead of the boot failing on a drained window.
# --max-ctx is the bound declare is run at, whose default is the checkpoint's
# training context (262,144 here, whose fp8 cache is 258.8 GiB against a 192 GiB
# machine, so the default is not an option). 32,768 is set by the corpus, not by
# taste: kld.jsonl's longest record is 38,263 characters — ~9,600 tokens at the
# pessimistic chars/4, ~7,700 at the chars/4.97 this repo measured on the K3
# corpus — so the window the rig boots at clears every record 2.6x over and the
# reference boot cannot die on a sequence that will not fit. The rig's window is
# one knob (FRANK_QWEN38_MAX_MODEL_LEN in scripts/run-qwen38-bf16.sh, default
# 32,768) and that script refuses a corpus its window cannot hold, so raising one
# without the other is caught before the drained window, not inside it. The
# payload containers the gate scores declare at the training context because
# production must hold the 200k shape; a declared bound is a declare-time check,
# not a weight change, so the reference and the payloads carry the same numbers
# whatever each declared at — which is why one can be measured against the other.
CONVERT_ARGS=("$CKPT_IN" -o "$OUT_IN" --max-ctx 32768 --kv-cache-dtype fp8)
PODMAN=(podman run --rm --entrypoint rad-convert -v "$MODELS:/models")
PLAN_CMD=("${PODMAN[@]}" --name qwen38-bf16-plan "$IMAGE" "${CONVERT_ARGS[@]}" --plan-only -v)
CONV_CMD=("${PODMAN[@]}" --name qwen38-bf16-convert "$IMAGE" "${CONVERT_ARGS[@]}")
if [ "$DRY" = "1" ]; then
echo "dry run: no gate checked, no container started, nothing written"
echo "# $CKPT -> $OUT (the convert sees them as $CKPT_IN and $OUT_IN)"
echo "# plan argv:"
printf '%q\n' "${PLAN_CMD[@]}"
echo "# convert argv:"
printf '%q\n' "${CONV_CMD[@]}"
exit 0
fi
# ---- gate: the checkpoint is on disk ---------------------------------------
if [ ! -d "$CKPT" ]; then
echo "REFUSED: no checkpoint at $CKPT — fetch and verify it first: scripts/download-qwen38-bf16.sh --fetch" >&2
exit 2
fi
# ---- gate: peak disk --------------------------------------------------------
# The deepest existing ancestor is the right thing to ask: a fresh
# /home/dank/models/rad has no free space of its own to report.
OUT_DIR="$(dirname "$OUT")"
PROBE="$OUT_DIR"
while [ ! -d "$PROBE" ] && [ "$PROBE" != "/" ]; do PROBE="$(dirname "$PROBE")"; done
FREE_MIB="$(df --output=avail -m "$PROBE" | tail -1 | tr -d ' ')"
if ! [[ "$FREE_MIB" =~ ^[0-9]+$ ]]; then
echo "REFUSED: cannot read the free space under $PROBE (df said: $FREE_MIB)" >&2
exit 2
fi
if [ "$PLAN_ONLY" != "1" ] && [ "$FREE_MIB" -lt "$MIN_FREE_MIB" ]; then
echo "REFUSED peak-disk: $FREE_MIB MiB free under $PROBE, $MIN_FREE_MIB MiB wanted (QWEN38_MIN_FREE_MIB) — the bf16 container rewrites the checkpoint's own 343,323 MiB of tensors beside it" >&2
exit 2
fi
# ---- gate: one conversion ---------------------------------------------------
if [ -e "$OUT" ]; then
if [ "$PLAN_ONLY" = "1" ]; then
echo "note: $OUT already exists; --plan-only writes nothing, so it stays"
elif [ "$FORCE" = "1" ]; then
mkdir -p "$TRASH"
DEST="$TRASH/$(date -u +%Y%m%dT%H%M%SZ)-$(basename "$OUT")"
mv "$OUT" "$DEST"
echo "TRASHED: retired the existing reference container $OUT -> $DEST"
else
echo "REFUSED: $OUT already exists — the campaign converts the reference once; re-run with --force to retire it to $TRASH first" >&2
exit 2
fi
fi
# ---- gate: the tree really is the checkpoint (the full verify) -------------
echo "== verifying $CKPT (scripts/download-qwen38-bf16.sh, full gates)"
rc=0
bash "$SCRIPT_DIR/download-qwen38-bf16.sh" --dir "$CKPT" || rc=$?
if [ "$rc" -ne 0 ]; then
if [ "$rc" -eq 2 ]; then
echo "REFUSED: the checkpoint verifier refused the environment (rc=2); nothing was converted" >&2
exit 2
fi
echo "REFUSED: $CKPT failed verification (rc=$rc) — a half tree converts to a half reference" >&2
exit 1
fi
# ---- plan, then convert ------------------------------------------------------
mkdir -p "$(dirname "$LOG")"
# rad-convert opens -o during declare as well, so the output directory exists
# before the plan; that empty directory is all this creates ahead of it.
mkdir -p "$OUT_DIR"
echo "== rad-convert --plan-only -v (declare + plan; writes nothing)"
"${PLAN_CMD[@]}" 2>&1 | tee -a "$LOG"
rc=${PIPESTATUS[0]}
if [ "$rc" -ne 0 ]; then
echo "REFUSED plan: rad-convert --plan-only exited rc=$rc — the engine refuses this encoding/shape, no container was written (log: $LOG)" >&2
exit 2
fi
if [ "$PLAN_ONLY" = "1" ]; then
echo "PLAN_OK: $CKPT_IN declares for $OUT_IN; no container written (log: $LOG)"
exit 0
fi
echo "== convert: $CKPT -> $OUT ($(date -Is))"
"${CONV_CMD[@]}" 2>&1 | tee -a "$LOG"
rc=${PIPESTATUS[0]}
if [ "$rc" -ne 0 ]; then
echo "rad-convert rc=$rc; log: $LOG" >&2
exit "$rc"
fi
MIB=$(( $(stat -c %s "$OUT") / 1048576 ))
echo "BUILD_DONE: $OUT is ${MIB} MiB (log: $LOG)"
echo "NEXT: scripts/run-qwen38-bf16.sh kldref records the reference logits from it"
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env bash
# convert-qwen38-w5nl64.sh — RUNS ON DANKSERVER. The CPU half of the Qwen3.8-Flash-Next five-bit
# campaign: convert the vendor BF16 checkpoint into the campaign's arms, each one the whole
# `w5nl64a8h` rung on BOTH expert projections with the protected experts kept bf16.
#
# Nothing here touches a GPU: the container is started without /dev/kfd or /dev/dri, so the
# converter runs on the host backend and the fleet can keep serving through the whole script.
# The engine pin is the digest that serves frank-archon, so a winning arm crosses no version
# boundary between measurement and serving.
#
# Three arms, because the campaign's question is which search knob pays for its hours
# (recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe):
#
# cd3 the recipe as written — coordinate descent three sweeps. The control.
# cd6 six sweeps: the same search, twice the passes.
# ao1 act_order=1 instead of cd: GPTQ sweeps columns in activation-importance order and
# stores them permuted. This is a SEPARATE arm, not a fourth sweep — libquant refuses
# cd= together with act_order=1 ("cd= sweeps the columns in their stored order, and
# act_order=1 stores them permuted; one or the other", libquant/lq_registry.cpp), so
# the two knobs cannot be combined into a best-of-both container.
#
# The arms are generated from ONE template with `--quant` overrides, not three near-duplicate
# recipes: --quant rules are inserted BEFORE the recipe file and the first rule whose glob matches
# a weight decides it (core/format/recipe.cpp: Recipe::match), so an override cleanly replaces the
# expert line and the other 60 lines cannot drift between arms.
#
# Protected experts: apply-protected-cast.sh derives the bf16 set from THIS campaign's grams
# (--target-count 48, two per layer so each rank's 256-expert half stays even) and splices it into
# a per-run COPY of the template; the committed recipe keeps its markers and stays reviewable.
#
# Gates, in the order they bite — each one is a lesson the swift15 campaign paid for:
# inputs checkpoint (dir + index), recipe, grams, imatrix, tokenizer. A missing grams
# directory converts UNCALIBRATED with one stderr note, which is silently worse
# than failing here.
# reference the 335.3 GiB BF16 reference container must be GONE before the converts start.
# It and the checkpoint together eat the volume; its job (the KLD recording and
# the calibration grams) is finished by then, and both survive on disk without the
# container beside it. The FP8-native anchor is FETCHED after the converts (disk
# sequencing), so nothing here waits on it.
# plan --plan-only runs FIRST, per arm, and prints "about X would be written". The
# number is the bill: free space must cover it plus the 128k-prompt overshoot
# margin, and the finished artifact is checked against it.
# outputs no arm overwrites anything; move the old one to trash.
#
# Usage: convert-qwen38-w5nl64.sh [--arm cd3|cd6|ao1]... [--dry-run] [--plan-only]
# [--target-count N] [--keep-recipe]
set -u -o pipefail
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
IMAGE=${IMAGE:-stilldeadcode/radiance@sha256:d590e300234bf6d2f942a0b8d456d183153fd808f28820e6c2fa69c9389178ed}
CKPT=${CKPT:-/home/dank/models/Qwen3.8-Flash-Next}
CAMPAIGN=${CAMPAIGN:-/home/dank/campaign/qwen38-w5nl64}
RECIPE=${RECIPE:-$SELF/recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe}
CALIB=${CALIB:-$CAMPAIGN/calib}
IMATRIX=${IMATRIX:-$CAMPAIGN/imatrix.gguf}
OUTDIR=${OUTDIR:-/home/dank/models/rad}
BF16_RAD=${BF16_RAD:-$OUTDIR/qwen38-bf16.rad} # the reference container, not an arm output
STATE=${STATE:-$CAMPAIGN} # the dir the container mounts read-only: the
# spliced recipe and the imatrix live here
TARGET_COUNT=${TARGET_COUNT:-48}
MIN_GRAMS=${MIN_GRAMS:-24576} # per-expert DOWN grams: 48 recorded layers x 512
# experts. The checkpoint has 49 MoE layers, but the
# MTP layer is never recorded, so its 512 experts are
# absent from the tap's output by design
MARGIN_MIB=${MARGIN_MIB:-40960} # the long-prompt overshoot, off the plan number
TS=$(date +%Y%m%d-%H%M%S)
ARMS=()
DRY=0
PLAN_ONLY=0
KEEP_RECIPE=0
while [ "$#" -gt 0 ]; do
case "$1" in
--arm) ARMS+=("$2"); shift 2 ;;
--dry-run) DRY=1; shift ;;
--plan-only) PLAN_ONLY=1; shift ;;
--target-count) TARGET_COUNT="$2"; shift 2 ;;
--keep-recipe) KEEP_RECIPE=1; shift ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ "${#ARMS[@]}" -gt 0 ] || ARMS=(cd3 cd6 ao1)
for a in "${ARMS[@]}"; do
case "$a" in cd3|cd6|ao1) ;; *) echo "unknown arm: $a (cd3|cd6|ao1)" >&2; exit 2 ;; esac
done
# The expert rule's option set, spelled once. --quant replaces the template's line wholesale, so
# every option has to be repeated here; `calib=$CALIB` is expanded by the recipe reader from the
# container environment (core/format/recipe.cpp: expand refuses an unset name rather than taking
# it for empty).
expert_opts='table=w5nl,group=64,scale=fp8_e4m3,scale2=f32,block2=*x*,scale2_value=0.0001220703125,transform=fwht128,rule=search,calib=$CALIB'
arm_quant() {
case "$1" in
# The control is the template itself: no override, so the recipe's own line decides.
cd3) return 0 ;;
cd6) echo "blk.*.ffn_*_exps.*.weight=gptq:${expert_opts},cd=6" ;;
# act_order=1 WITHOUT cd: libquant refuses the pair (lq_registry.cpp:105, "cd= sweeps the
# columns in their stored order, and act_order=1 stores them permuted; one or the other"), so
# this arm is the interleave knob alone, not cd=3 plus it.
ao1) echo "blk.*.ffn_*_exps.*.weight=gptq:${expert_opts},act_order=1" ;;
esac
}
# --- gates ------------------------------------------------------------------------------------
[ -d "$CKPT" ] || { echo "no checkpoint at $CKPT; run scripts/download-qwen38-bf16.sh --fetch" >&2; exit 2; }
[ -f "$CKPT/model.safetensors.index.json" ] || { echo "checkpoint at $CKPT has no safetensors index — a torn fetch" >&2; exit 2; }
[ -f "$CKPT/tokenizer.json" ] || { echo "no tokenizer.json in $CKPT; the .rad cannot carry one without it" >&2; exit 2; }
[ -f "$RECIPE" ] || { echo "no recipe at $RECIPE" >&2; exit 2; }
GRAMS=$(find "$CALIB" -maxdepth 1 -name 'gram.r*.bin' 2>/dev/null | wc -l)
[ "$GRAMS" -ge "$MIN_GRAMS" ] || {
echo "calib dir $CALIB holds $GRAMS gram files (<$MIN_GRAMS); run $0 after run-qwen38-bf16.sh calib" >&2; exit 2; }
[ -f "$IMATRIX" ] || { echo "no imatrix at $IMATRIX; run scripts/grams-to-imatrix.py over $CALIB" >&2; exit 2; }
# The container sees the campaign dir at one fixed mount point, so an imatrix kept elsewhere has
# no path inside it: copy it in (or point IMATRIX at the campaign dir) rather than let the
# converter fail halfway through a planned arm.
[ "$(dirname "$IMATRIX")" = "$STATE" ] || {
echo "imatrix $IMATRIX is not in the campaign dir $STATE the container mounts; move it there" >&2; exit 2; }
if [ -e "$BF16_RAD" ]; then
echo "the 335.3 GiB BF16 reference container $BF16_RAD is still on disk; its recording and" \
"the calibration grams are already taken, so move it to trash — the converts need its" \
"space (peak-disk law)" >&2
exit 2
fi
for a in "${ARMS[@]}"; do
[ -e "$OUTDIR/qwen38-w5nl64-$a.rad" ] && {
echo "$OUTDIR/qwen38-w5nl64-$a.rad already exists; move it to trash first" >&2; exit 2; }
done
# --- the protected plane, spliced into a per-run copy of the template --------------------------
mkdir -p "$STATE"
WORK="$STATE/recipe-$TS.recipe"
cp "$RECIPE" "$WORK"
if [ "$DRY" = 1 ]; then
echo "would run: $SELF/apply-protected-cast.sh $CALIB $WORK $TARGET_COUNT"
else
# Positional, because the tool reads its recipe and calib dir by position: passing them as
# environment variables would be silently ignored and it would rewrite the COMMITTED
# template in place instead of this run's copy.
"$SELF/apply-protected-cast.sh" "$CALIB" "$WORK" "$TARGET_COUNT" \
|| { echo "the protected-expert derivation refused; the arms would convert unprotected" >&2; exit 1; }
fi
run_convert() { # run_convert ARM [extra flags...]
local arm="$1"; shift
local out="$OUTDIR/qwen38-w5nl64-$arm.rad" log="$STATE/convert-$arm-$TS.log"
local argv=(--rm --name "qwen38-convert-$arm"
-e "CALIB=/data/calib" -e "OMP_NUM_THREADS=$(nproc)"
-v "$(dirname "$CKPT"):/models:ro"
-v "$CALIB:/data/calib:ro"
-v "$STATE:/campaign:ro"
-v "$OUTDIR:/out")
local conv=(/models/"${CKPT##*/}" --recipe "/campaign/$(basename "$WORK")"
--tokenizer "/models/${CKPT##*/}/tokenizer.json"
--imatrix /campaign/"${IMATRIX##*/}"
# No --max-ctx: the context declare then runs at the checkpoint's training context
# (262144), which is what the shipped container did — rad-info --meta on it carries
# no override. A container declared at a smaller bound cannot be expected to serve
# the 200k window D2 holds, and the convert is not where to discover that.
# --kv-cache-dtype changes nothing in the artifact; it picks the kernels the declare
# checks, and the fleet serves fp8 KV, so an unresolvable fp8 kernel must be said no
# to by the convert, not by a boot on a drained window.
--kv-cache-dtype fp8 -o "/out/$(basename "$out")")
local q; q=$(arm_quant "$arm")
[ -n "$q" ] && conv+=(--quant "$q")
if [ "$DRY" = 1 ]; then
echo "podman run ${argv[*]} \$IMAGE --entrypoint /opt/radiance/bin/rad-convert ${conv[*]} $*"
return 0
fi
podman run "${argv[@]}" --entrypoint /opt/radiance/bin/rad-convert "$IMAGE" "${conv[@]}" "$@" \
2>&1 | tee "$log"
return "${PIPESTATUS[0]}"
}
# --- plan, then bill, then convert -------------------------------------------------------------
overall=0
for arm in "${ARMS[@]}"; do
echo "== arm $arm =="
if [ "$DRY" = 1 ]; then
# Both lines, in order: the plan runs before the convert, and the convert is what the
# operator is about to pay an afternoon for.
run_convert "$arm" --plan-only
run_convert "$arm"
continue
fi
rc=0; plan_log=$(run_convert "$arm" --plan-only) || rc=$?
[ "$rc" -eq 0 ] || { echo "arm $arm: --plan-only failed (rc=$rc); nothing was written" >&2; overall=1; continue; }
echo "$plan_log"
# "about 141.2 GiB would be written" is the converter's own bill; parse it rather than
# hardcoding a size, so the gate still works after any recipe edit.
want=$(printf '%s' "$plan_log" | sed -n 's/.*about \([0-9.]*\) \([KMGT]iB\) would be written.*/\1 \2/p' | tail -1)
[ -n "$want" ] || { echo "arm $arm: the plan printed no size; refusing to convert blind" >&2; overall=1; continue; }
need_mib=$(awk -v n="${want% *}" -v u="${want#* }" \
'BEGIN{ f = (u=="KiB") ? 2^-10 : (u=="MiB") ? 1 : (u=="GiB") ? 1024 : (u=="TiB") ? 1048576 : 0
if (f == 0) { exit 1 }; printf "%d", n*f + 0.5 }') || {
echo "arm $arm: the plan printed an unknown unit in '$want'" >&2; overall=1; continue; }
free_mib=$(df --output=avail -m "$OUTDIR" | tail -1 | tr -d ' ')
echo "plan: $want ($need_mib MiB); free ${free_mib} MiB on $OUTDIR (margin $MARGIN_MIB MiB)"
if [ "$free_mib" -lt $((need_mib + MARGIN_MIB)) ]; then
echo "arm $arm: needs ${need_mib} MiB + ${MARGIN_MIB} margin, ${free_mib} free; free space and re-run" >&2
overall=1; continue
fi
if [ "$PLAN_ONLY" = 1 ]; then echo "planned, not converted (--plan-only)"; continue; fi
rc=0; run_convert "$arm" || rc=$?
[ "$rc" -eq 0 ] || { echo "arm $arm: rad-convert rc=$rc; log: $STATE/convert-$arm-$TS.log" >&2; overall=1; continue; }
out="$OUTDIR/qwen38-w5nl64-$arm.rad"
got_mib=$(( $(stat -c %s "$out") / 1048576 ))
# The receipt: the artifact is what the plan predicted, within the metadata the plan does not
# count. An arm that came out materially smaller lost a plane somewhere.
lo=$(( need_mib * 98 / 100 )); hi=$(( need_mib * 103 / 100 ))
if [ "$got_mib" -lt "$lo" ] || [ "$got_mib" -gt "$hi" ]; then
echo "arm $arm: $out is ${got_mib} MiB, the plan said ${need_mib} MiB (allowed ${lo}-${hi})" >&2
overall=1; continue
fi
echo "CONVERT_DONE $arm ${got_mib} MiB -> $out"
done
if [ "$KEEP_RECIPE" = 0 ] && [ "$DRY" = 0 ]; then
mkdir -p "$CAMPAIGN/.AGENTTRASH"
mv "$WORK" "$CAMPAIGN/.AGENTTRASH/$(basename "$WORK")" 2>/dev/null || true
fi
exit "$overall"
+32 -4
View File
@@ -34,12 +34,16 @@ SERVICE_ORDER = (
"hermes", "hermes",
"hermes-dashboard", "hermes-dashboard",
"hermes-webui", "hermes-webui",
"prometheus",
"grafana", "grafana",
"loki", "loki",
"alloy", "alloy",
"node-exporter", "node-exporter",
"cadvisor",
"gpu-exporter",
"alertmanager", "alertmanager",
# The prober must answer before prometheus re-runs the services job.
"blackbox-exporter",
"prometheus",
"authentik-postgres", "authentik-postgres",
"authentik-server", "authentik-server",
"authentik-worker", "authentik-worker",
@@ -92,7 +96,10 @@ DANKCLAW_SERVICES = {
"loki", "loki",
"alloy", "alloy",
"node-exporter", "node-exporter",
"cadvisor",
"gpu-exporter",
"alertmanager", "alertmanager",
"blackbox-exporter",
"authentik-postgres", "authentik-postgres",
"authentik-server", "authentik-server",
"authentik-worker", "authentik-worker",
@@ -124,6 +131,8 @@ DANKSERVER_SERVICES = {
"readarr", "readarr",
"frank-archon", "frank-archon",
"node-exporter", "node-exporter",
"cadvisor",
"gpu-exporter",
} }
# FrankBook-Pro runs the *arr stack + qBittorrent inside a podman machine on # FrankBook-Pro runs the *arr stack + qBittorrent inside a podman machine on
@@ -271,6 +280,8 @@ def services_for_path(path: str, target: str) -> set[str]:
# Provisioned datasources and dashboards: git is the source of # Provisioned datasources and dashboards: git is the source of
# truth, so a change here must restart the service that reads them. # truth, so a change here must restart the service that reads them.
_add(services, "grafana") _add(services, "grafana")
elif group == "blackbox":
_add(services, "blackbox-exporter")
elif group == "homepage": elif group == "homepage":
# Homepage is a Dankclaw-owned deployment. Shared homepage files # Homepage is a Dankclaw-owned deployment. Shared homepage files
# are intentionally not copied during a Dankserver-only run. # are intentionally not copied during a Dankserver-only run.
@@ -293,6 +304,17 @@ def services_for_path(path: str, target: str) -> set[str]:
if len(parts) >= 2 and parts[1] in {"caddy", "comfyui", "loki", "alloy", "hermes", "anubis", "seerr"}: if len(parts) >= 2 and parts[1] in {"caddy", "comfyui", "loki", "alloy", "hermes", "anubis", "seerr"}:
_add(services, parts[1]) _add(services, parts[1])
return services return services
if len(parts) >= 3 and parts[1] == "host":
# Host state a service needs at boot lives under dankclaw/host/.
# The fabric-clock hold is frank-dragoon's NPU precondition (the
# engine refuses to start with HALOGEN_NPU_MODELS set while the
# clock moves) and the deployer installs and gates it, so changing
# it re-dispatches that endpoint. `grub` is the tracked boot line,
# which moves only through scripts/set-dankclaw-bootline.sh and is
# a reboot decision — it plans no service.
if parts[2].startswith("halogen-fabric-clock"):
_add(services, "frank-dragoon")
return services
if len(parts) >= 4 and parts[1] == "authentik" and parts[2] == "quadlet": if len(parts) >= 4 and parts[1] == "authentik" and parts[2] == "quadlet":
name = parts[3].removesuffix(".container") name = parts[3].removesuffix(".container")
if name in {"authentik-postgres", "authentik-server", "authentik-worker", "authentik-outpost", "authentik-outpost-ldap"}: if name in {"authentik-postgres", "authentik-server", "authentik-worker", "authentik-outpost", "authentik-outpost-ldap"}:
@@ -363,7 +385,7 @@ def services_for_path(path: str, target: str) -> set[str]:
# consumer (prometheus before grafana, loki before alloy). # consumer (prometheus before grafana, loki before alloy).
if len(parts) >= 4 and parts[2] == "quadlet": if len(parts) >= 4 and parts[2] == "quadlet":
name = parts[3].removesuffix(".container") name = parts[3].removesuffix(".container")
if name in {"prometheus", "grafana", "loki", "alloy", "node-exporter", "alertmanager"}: if name in {"prometheus", "grafana", "loki", "alloy", "node-exporter", "cadvisor", "alertmanager", "blackbox-exporter"}:
_add(services, name) _add(services, name)
return services return services
if len(parts) >= 2 and parts[1] == "gitea": if len(parts) >= 2 and parts[1] == "gitea":
@@ -406,8 +428,8 @@ def services_for_path(path: str, target: str) -> set[str]:
# node-dankserver job had nothing to scrape. # node-dankserver job had nothing to scrape.
if len(parts) >= 4 and parts[2] == "quadlet": if len(parts) >= 4 and parts[2] == "quadlet":
name = parts[3].removesuffix(".container") name = parts[3].removesuffix(".container")
if name == "node-exporter": if name in {"node-exporter", "cadvisor"}:
_add(services, "node-exporter") _add(services, name)
return services return services
# frank-archon's quadlet-era directory left the tree on 2026-09-26 # frank-archon's quadlet-era directory left the tree on 2026-09-26
# (the Lucebox DS4.1 unit was retired for the llama.cpp root unit — # (the Lucebox DS4.1 unit was retired for the llama.cpp root unit —
@@ -438,6 +460,12 @@ def services_for_path(path: str, target: str) -> set[str]:
# change to it redeploying alertmanager is the only path that # change to it redeploying alertmanager is the only path that
# reaches it on the host. # reaches it on the host.
_add(services, "alertmanager") _add(services, "alertmanager")
if name in {"gpu-exporter.py", "gpu-exporter.service"}:
# The host-process GPU exporter is installed directly by the
# gpu-exporter deploy on BOTH hosts, so its script and unit select
# that service (target_services then keeps it only on the host being
# planned). Editing the collector restarts the running exporter.
_add(services, "gpu-exporter")
if name in {"frank-zealot.env", "frank-zealot.service", "serve-frank-zealot.sh"}: if name in {"frank-zealot.env", "frank-zealot.service", "serve-frank-zealot.sh"}:
# llama.cpp frank-zealot (2026-09-16) keeps its unit, env and serve # llama.cpp frank-zealot (2026-09-16) keeps its unit, env and serve
# files here; each one selects frank-zealot only. The ik_llama.cpp # files here; each one selects frank-zealot only. The ik_llama.cpp
+312 -30
View File
@@ -86,7 +86,7 @@ service_is_allowed() {
dankclaw:open-webui|dankclaw:gitea-postgres|dankclaw:gitea-server|\ dankclaw:open-webui|dankclaw:gitea-postgres|dankclaw:gitea-server|\
dankclaw:comfyui|dankclaw:hermes|\ dankclaw:comfyui|dankclaw:hermes|\
dankclaw:hermes-dashboard|dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|\ dankclaw:hermes-dashboard|dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|\
dankclaw:loki|dankclaw:alloy|dankclaw:node-exporter|dankclaw:alertmanager|\ dankclaw:loki|dankclaw:alloy|dankclaw:node-exporter|dankclaw:cadvisor|dankclaw:gpu-exporter|dankclaw:alertmanager|dankclaw:blackbox-exporter|\
dankclaw:authentik-postgres|dankclaw:authentik-server|\ dankclaw:authentik-postgres|dankclaw:authentik-server|\
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|\ dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|\
dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\ dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\
@@ -97,7 +97,7 @@ service_is_allowed() {
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\ dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\ dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\ dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\
dankserver:frank-archon|dankserver:node-exporter|\ dankserver:frank-archon|dankserver:node-exporter|dankserver:cadvisor|dankserver:gpu-exporter|\
frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\ frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\
frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\ frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\
frankbook-pro:readarr) frankbook-pro:readarr)
@@ -252,16 +252,24 @@ install_signal_relay() {
# The relay binds the host's LAN IP (the one address a bridge container # The relay binds the host's LAN IP (the one address a bridge container
# can reach as host.containers.internal), so keep the unauthenticated # can reach as host.containers.internal), so keep the unauthenticated
# endpoint off the rest of the LAN: admit loopback, the host itself # endpoint off the rest of the LAN: admit loopback, the host itself
# (its own health probe) and the podman bridge subnet only. Idempotent: # (its own health probe) and the podman bridge subnet only.
# -C before -I. # ORDER IS THE GATE: `iptables -I` inserts at position 1, so the old
# shape (-C/-I x3 ACCEPT, then -C/-I the DROP) landed the blanket DROP
# ABOVE the ACCEPTs and every probe timed out beside a LISTENing socket
# (live 2026-10-21: 159 packets on the DROP, 0 on each ACCEPT; the whole
# dankclaw drain aborted at the alertmanager gate twice before the cause
# was read off `nft list ruleset`). Rebuild the tail instead of inserting
# it: delete any blanket DROP wherever it sits, keep the three ACCEPTs
# at the head (-C before -I, unchanged), then APPEND the DROP so it is
# last and the ACCEPTs decide first. Converges on poisoned hosts.
run_privileged iptables -D INPUT -p tcp --dport 18093 -j DROP 2>/dev/null || true
run_privileged iptables -C INPUT -p tcp --dport 18093 -s 127.0.0.1 -j ACCEPT 2>/dev/null \ run_privileged iptables -C INPUT -p tcp --dport 18093 -s 127.0.0.1 -j ACCEPT 2>/dev/null \
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -s 127.0.0.1 -j ACCEPT || run_privileged iptables -I INPUT -p tcp --dport 18093 -s 127.0.0.1 -j ACCEPT
run_privileged iptables -C INPUT -p tcp --dport 18093 -s 192.168.0.143 -j ACCEPT 2>/dev/null \ run_privileged iptables -C INPUT -p tcp --dport 18093 -s 192.168.0.143 -j ACCEPT 2>/dev/null \
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -s 192.168.0.143 -j ACCEPT || run_privileged iptables -I INPUT -p tcp --dport 18093 -s 192.168.0.143 -j ACCEPT
run_privileged iptables -C INPUT -p tcp --dport 18093 -s 10.89.0.0/16 -j ACCEPT 2>/dev/null \ run_privileged iptables -C INPUT -p tcp --dport 18093 -s 10.89.0.0/16 -j ACCEPT 2>/dev/null \
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -s 10.89.0.0/16 -j ACCEPT || run_privileged iptables -I INPUT -p tcp --dport 18093 -s 10.89.0.0/16 -j ACCEPT
run_privileged iptables -C INPUT -p tcp --dport 18093 -j DROP 2>/dev/null \ run_privileged iptables -A INPUT -p tcp --dport 18093 -j DROP
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -j DROP
# The interpreter needs a moment to bind after restart; a one-shot curl # The interpreter needs a moment to bind after restart; a one-shot curl
# raced the socket on 2026-09-17 and failed a healthy deploy. # raced the socket on 2026-09-17 and failed a healthy deploy.
local i local i
@@ -275,14 +283,81 @@ install_signal_relay() {
return 1 return 1
} }
# Host-process GPU metrics exporter (amdgpu sysfs + nvidia-smi). Not a quadlet:
# it runs python3 from the checkout on the host so it can read the DRM/cgroup
# sysfs and shell out to nvidia-smi. Same two-address bind posture as
# node-exporter/cadvisor (loopback + this host's LAN), so it is never a wildcard.
deploy_gpu_exporter() {
local unit_dir="$HOME/.config/systemd/user" lan dropin i
case "$TARGET" in
dankclaw) lan=192.168.0.143 ;;
dankserver) lan=192.168.0.235 ;;
*) echo "gpu-exporter: unknown target '$TARGET'" >&2; return 2 ;;
esac
mkdir -p "$unit_dir"
cp scripts/gpu-exporter.service "$unit_dir/gpu-exporter.service"
dropin="$unit_dir/gpu-exporter.service.d"
mkdir -p "$dropin"
printf '[Service]\nEnvironment=GPU_EXPORTER_BINDS=127.0.0.1,%s\n' "$lan" \
> "$dropin/bind.conf"
systemctl --user daemon-reload
systemctl --user enable gpu-exporter.service
systemctl --user restart gpu-exporter.service
# The interpreter needs a moment to bind after restart (the same race that
# once failed a healthy signal-relay deploy).
for i in $(seq 1 15); do
curl -fsS -m 5 http://127.0.0.1:9401/metrics >/dev/null && return 0
sleep 1
done
echo "gpu-exporter is not answering on 127.0.0.1:9401/metrics" >&2
return 1
}
copy_loki_config() { copy_loki_config() {
run_privileged mkdir -p /opt/loki/config run_privileged mkdir -p /opt/loki/config
run_privileged cp shared/loki/loki-config.yaml /opt/loki/config/loki-config.yaml run_privileged cp shared/loki/loki-config.yaml /opt/loki/config/loki-config.yaml
} }
copy_alloy_config() { copy_alloy_config() {
run_privileged mkdir -p /opt/alloy/config # The repo config carries the token GRAFANA_HOST_LABEL; the installed copy
run_privileged cp shared/alloy/config.alloy /opt/alloy/config/config.alloy # carries the host's name, so Loki streams say WHICH machine's journal a
# line came from without a second config file per host.
# *_CONF_ROOT are test seams: the bats cases execute these copiers for
# real against a sandbox with a stubbed run_privileged.
local root="${ALLOY_CONF_ROOT:-/opt/alloy}" tmp
tmp="$(mktemp)"
sed "s/GRAFANA_HOST_LABEL/$TARGET/g" shared/alloy/config.alloy > "$tmp"
if ! grep -q "host = \"$TARGET\"," "$tmp" || grep -q "GRAFANA_HOST_LABEL" "$tmp"; then
echo "alloy config: host-label substitution failed" >&2
rm -f "$tmp"
return 1
fi
# Syntax gate: alloy's config language has no schema — a misplaced
# attribute (e.g. `on_error` outside the json stage) parses only at RUN
# time, and the unit then crash-loops with a healthy-looking deploy.
# Validate against the very image that will run it, before installing.
# Skipped only in the bats sandbox (no image there).
if [ "$root" = "/opt/alloy" ] || podman image exists localhost/franksdank/alloy:latest 2>/dev/null; then
if ! podman run --rm -v "$tmp:/c.alloy:ro" \
--entrypoint /usr/bin/alloy localhost/franksdank/alloy:latest \
validate /c.alloy; then
echo "alloy config: validate FAILED against the pinned image" >&2
rm -f "$tmp"
return 1
fi
fi
run_privileged mkdir -p "$root/config"
run_privileged cp "$tmp" "$root/config/config.alloy"
rm -f "$tmp"
}
copy_blackbox_config() {
# Single-file mount target: the unit binds this exact path into
# /etc/blackbox_exporter/config.yml. cp (not cp -r) keeps it one file.
# BLACKBOX_CONF_ROOT is the same test seam.
local root="${BLACKBOX_CONF_ROOT:-/opt/blackbox}"
run_privileged mkdir -p "$root"
run_privileged cp shared/blackbox/blackbox.yml "$root/blackbox.yml"
} }
copy_grafana_provisioning() { copy_grafana_provisioning() {
@@ -291,14 +366,25 @@ copy_grafana_provisioning() {
# datasource would be invisible to review, lost with the data volume, and # datasource would be invisible to review, lost with the data volume, and
# unknown to anyone reading the repository. The unit already mounts # unknown to anyone reading the repository. The unit already mounts
# /opt/grafana/provisioning read-only, so this only has to populate it. # /opt/grafana/provisioning read-only, so this only has to populate it.
run_privileged mkdir -p /opt/grafana/provisioning/datasources \ # GRAFANA_PROV_ROOT is the test seam: point it at a sandbox and the whole
/opt/grafana/provisioning/dashboards # copy runs unprivileged (the bats case executes this function for real).
local root="${GRAFANA_PROV_ROOT:-/opt/grafana}"
run_privileged mkdir -p "$root/provisioning/datasources" \
"$root/provisioning/dashboards"
run_privileged cp shared/grafana/provisioning/datasources/prometheus.yml \ run_privileged cp shared/grafana/provisioning/datasources/prometheus.yml \
/opt/grafana/provisioning/datasources/prometheus.yml "$root/provisioning/datasources/prometheus.yml"
run_privileged cp shared/grafana/provisioning/dashboards/provider.yml \ run_privileged cp shared/grafana/provisioning/dashboards/provider.yml \
/opt/grafana/provisioning/dashboards/provider.yml "$root/provisioning/dashboards/provider.yml"
run_privileged cp shared/grafana/provisioning/dashboards/llm-fleet.json \ # EVERY dashboard JSON rides, not a hand-listed subset: the fleet provider
/opt/grafana/provisioning/dashboards/llm-fleet.json # turns each file in the directory into a dashboard, so a copier that names
# files one by one silently drops new boards (measured 2026-10-21: the
# containers board existed in the repo since its commit and was NEVER
# provisioned — the panel-census browser check could not find it while the
# hand-placed older boards happened to be on disk). Same rule as the quadlet
# tier: the directory is the contract, the copy is generated state.
for dash in shared/grafana/provisioning/dashboards/*.json; do
run_privileged cp "$dash" "$root/provisioning/dashboards/$(basename "$dash")"
done
} }
render_caddyfile() { render_caddyfile() {
@@ -487,13 +573,15 @@ seed_grafana_env() {
|| { echo "grafana env missing GRAFANA_OAUTH_CLIENT_SECRET; set it in dankclaw/.env" >&2; return 1; } || { echo "grafana env missing GRAFANA_OAUTH_CLIENT_SECRET; set it in dankclaw/.env" >&2; return 1; }
} }
# The five observability containers left compose on 2026-09-11 (they were # The observability containers left compose on 2026-09-11 (they were started by
# started by hand with podman-compose and never came back after a reboot). # hand with podman-compose and never came back after a reboot). Five are ordinary
# They are ordinary rootless Quadlet units now: same images, same mounts, same # rootless Quadlet units: same images, same mounts, same arguments, same
# arguments, same container names, so nothing that addresses them by name — # container names, so nothing that addresses them by name — Caddy, Prometheus'
# Caddy, Prometheus' own scrape config, Alloy's loki.write endpoint — changed. # own scrape config — changed. Only the published ports did: the three HTTP UIs
# Only the published ports did: the three HTTP UIs bind 127.0.0.1, because the # bind 127.0.0.1, because the compose services published on 0.0.0.0 and none of
# compose services published on 0.0.0.0 and none of them authenticates. # them authenticates. Alloy is the exception: a ROOT system service on
# Network=host, because reading the host journal needs privileges a rootless
# container cannot get. It is handled by deploy_alloy, not the loop below.
deploy_observability() { deploy_observability() {
local service="$1" local service="$1"
# Observability units live under <target>/observability/quadlet: Dankclaw # Observability units live under <target>/observability/quadlet: Dankclaw
@@ -512,16 +600,17 @@ deploy_observability() {
copy_loki_config copy_loki_config
build_compose_image loki build_compose_image loki
;; ;;
alloy)
copy_alloy_config
build_compose_image alloy
;;
grafana) grafana)
copy_grafana_provisioning copy_grafana_provisioning
seed_grafana_env || return 1 seed_grafana_env || return 1
;; ;;
node-exporter) node-exporter)
;; ;;
cadvisor)
;;
blackbox-exporter)
copy_blackbox_config
;;
*) *)
echo "unknown observability service: $service" >&2 echo "unknown observability service: $service" >&2
return 2 return 2
@@ -534,11 +623,75 @@ deploy_observability() {
alertmanager) wait_for_health alertmanager http://127.0.0.1:9093/-/healthy 60 ;; alertmanager) wait_for_health alertmanager http://127.0.0.1:9093/-/healthy 60 ;;
grafana) wait_for_health grafana http://127.0.0.1:3002/api/health 90 ;; grafana) wait_for_health grafana http://127.0.0.1:3002/api/health 90 ;;
loki) wait_for_health loki http://127.0.0.1:3100/ready 60 ;; loki) wait_for_health loki http://127.0.0.1:3100/ready 60 ;;
alloy) wait_for_health alloy http://127.0.0.1:12345/-/healthy 60 ;;
node-exporter) wait_for_health node-exporter http://127.0.0.1:9100/metrics 60 ;; node-exporter) wait_for_health node-exporter http://127.0.0.1:9100/metrics 60 ;;
cadvisor) wait_for_health cadvisor http://127.0.0.1:8080/healthz 90 ;;
# Loopback: the exporter binds 127.0.0.1 + LAN (the gateway address
# is not bindable from the host netns — see the unit header).
blackbox-exporter) wait_for_health blackbox-exporter http://127.0.0.1:9115/-/healthy 60 ;;
esac esac
} }
# Mirror an image the deployer built rootless into the ROOT image store. A root
# quadlet reads root storage; a root build (build_compose_image is rootless) is
# not possible on this host — the build container's resolver fails DNS — so the
# only way to get a root-readable copy is save-and-load. Compares image ids so a
# rebuilt alloy actually re-lands in root storage instead of leaving a stale tag.
mirror_image_to_root() {
local ref="$1" rid
rid="$(podman image inspect "$ref" --format '{{.Id}}' 2>/dev/null)" || {
echo "ERROR: rootless image $ref is not built; cannot mirror to root" >&2
return 1
}
if [[ "$(run_privileged podman image inspect "$ref" --format '{{.Id}}' 2>/dev/null)" == "$rid" ]]; then
return 0
fi
local tar; tar="$(mktemp /var/tmp/alloy-mirror.XXXXXX.tar)"
podman save -o "$tar" "$ref"
run_privileged podman load -i "$tar"
rm -f "$tar"
}
# Install a quadlet into the SYSTEM generator dir (root manager) instead of the
# user dir. Boot persistence is the unit's own [Install] section (multi-user
# target); the v1 system generator creates the .wants on daemon-reload, so —
# like the user units — no `systemctl enable` (generated units cannot be enabled
# here).
install_system_quadlet() {
local source_dir="$1" unit="$2"
run_privileged mkdir -p /etc/containers/systemd
run_privileged cp "$source_dir/$unit.container" /etc/containers/systemd/
run_privileged systemctl daemon-reload
}
# Alloy as a ROOT system service on Network=host. See the unit file: a rootless
# container cannot read the 0640 journal files, and it cannot reach Loki's
# 127.0.0.1:3100 publish from a bridge netns. Root + host network fixes both;
# the price is that this one container runs as root and on the host network.
deploy_alloy() {
copy_alloy_config
build_compose_image alloy # rootless build (resolver works)
mirror_image_to_root localhost/franksdank/alloy:latest
# Retire the rootless unit BEFORE the system one starts: both managers
# generate alloy.service, and two containers named "alloy" both binding
# 127.0.0.1:12345 cannot coexist. Move the user quadlet out of the generator
# dir (it regenerates from there) and stop the user unit.
if [[ -f "$quadlet_unit_dir/alloy.container" ]] ||
systemctl --user is-active --quiet alloy.service 2>/dev/null; then
systemctl --user stop alloy.service 2>/dev/null || true
if [[ -f "$quadlet_unit_dir/alloy.container" ]]; then
local trash="$HOME/.AGENTTRASH/$(date +%Y%m%d)-alloy-root-cutover"
mkdir -p "$trash"
mv -- "$quadlet_unit_dir/alloy.container" "$trash/alloy.container"
echo "retired rootless alloy.container -> $trash"
fi
systemctl --user daemon-reload
fi
install_system_quadlet dankclaw/observability/quadlet alloy
run_privileged systemctl restart alloy.service
wait_for_health alloy http://127.0.0.1:12345/-/healthy 60
}
build_compose_image() { build_compose_image() {
local service="$1" image cfhash oldhash containerfile local service="$1" image cfhash oldhash containerfile
# Every file the image COPIES IN, beside the Containerfile. The skip below # Every file the image COPIES IN, beside the Containerfile. The skip below
@@ -1310,9 +1463,10 @@ deploy_anubis() {
# conversion (2026-10-11) of the abliterated Qwen3.8-Flash-Next — # conversion (2026-10-11) of the abliterated Qwen3.8-Flash-Next —
# qwen38-flash-next-v2-abliterated.hgn (66,687,678,432 B, ~62 GiB resident) # qwen38-flash-next-v2-abliterated.hgn (66,687,678,432 B, ~62 GiB resident)
# + the REQUIRED n-gram lookup table + the vision tower — MTP spec decode # + the REQUIRED n-gram lookup table + the vision tower — MTP spec decode
# (HALOGEN_SPEC_ADAPT=0), 2 slots over ONE 401,408-position KV pool (2 × ctx, # (HALOGEN_SPEC_ADAPT=0), 2 slots over ONE 401,408-position KV pool at the
# raised 2026-10-18 so BOTH slots can hold a maximum-length request) at the # native ctx 262144 ceiling (raised 2026-10-21 from 200704 so the harness can
# ctx 200704 ceiling, image input — on the Strix Halo iGPU. It stays a # run a 200,000 window with its ~45k fit gap; the pool still carries two
# 200k conversations at once), image input — on the Strix Halo iGPU. It stays a
# ROOT unit because the serve script drives `podman run` directly (the frank-archon pattern): # ROOT unit because the serve script drives `podman run` directly (the frank-archon pattern):
# relink_unit points /etc/systemd/system/frank-dragoon.service at the repo # relink_unit points /etc/systemd/system/frank-dragoon.service at the repo
# file, boot persistence is that unit's [Install], and LimitMEMLOCK=infinity # file, boot persistence is that unit's [Install], and LimitMEMLOCK=infinity
@@ -1329,6 +1483,9 @@ deploy_anubis() {
# port gate is the same as every cutover since 2026-09-16: nothing # port gate is the same as every cutover since 2026-09-16: nothing
# starts until 7770 is released. # starts until 7770 is released.
LLM_DRAGOON_IMAGE="${LLM_DRAGOON_IMAGE:-ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84ac22308a53f5d1ca6b86806702d7039e5ebc51cae4c66621b92fe04a}" LLM_DRAGOON_IMAGE="${LLM_DRAGOON_IMAGE:-ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84ac22308a53f5d1ca6b86806702d7039e5ebc51cae4c66621b92fe04a}"
# The payload dir is its own twin because the NPU lane's containment gate below
# reads it; every artifact path under it stays a full literal as usual.
LLM_DRAGOON_MODEL_DIR="${LLM_DRAGOON_MODEL_DIR:-/home/dank/models/halogen-v2-abliterated}"
LLM_DRAGOON_CKPT="${LLM_DRAGOON_CKPT:-/home/dank/models/halogen-v2-abliterated/qwen38-flash-next-v2-abliterated.hgn}" LLM_DRAGOON_CKPT="${LLM_DRAGOON_CKPT:-/home/dank/models/halogen-v2-abliterated/qwen38-flash-next-v2-abliterated.hgn}"
LLM_DRAGOON_CKPT_BYTES="${LLM_DRAGOON_CKPT_BYTES:-66687678432}" LLM_DRAGOON_CKPT_BYTES="${LLM_DRAGOON_CKPT_BYTES:-66687678432}"
LLM_DRAGOON_NGRAM="${LLM_DRAGOON_NGRAM:-/home/dank/models/halogen-v2-abliterated/qwen38-flash-next-ngram.hgn}" LLM_DRAGOON_NGRAM="${LLM_DRAGOON_NGRAM:-/home/dank/models/halogen-v2-abliterated/qwen38-flash-next-ngram.hgn}"
@@ -1337,6 +1494,18 @@ LLM_DRAGOON_VISION="${LLM_DRAGOON_VISION:-/home/dank/models/halogen-v2-abliterat
LLM_DRAGOON_VISION_BYTES="${LLM_DRAGOON_VISION_BYTES:-897916416}" LLM_DRAGOON_VISION_BYTES="${LLM_DRAGOON_VISION_BYTES:-897916416}"
LLM_DRAGOON_TOK_DIR="${LLM_DRAGOON_TOK_DIR:-/home/dank/models/halogen-v2-abliterated/tokenizer}" LLM_DRAGOON_TOK_DIR="${LLM_DRAGOON_TOK_DIR:-/home/dank/models/halogen-v2-abliterated/tokenizer}"
LLM_DRAGOON_TOK_FILES="${LLM_DRAGOON_TOK_FILES:-tokenizer.json chat_template.jinja tokenizer_config.json merges.txt vocab.json generation_config.json}" LLM_DRAGOON_TOK_FILES="${LLM_DRAGOON_TOK_FILES:-tokenizer.json chat_template.jinja tokenizer_config.json merges.txt vocab.json generation_config.json}"
# NPU lane (2026-10-19). HALOGEN_NPU_MODELS is the whole switch and the parity
# twin of the env file's line: empty here AND in the env = the pre-lane
# endpoint. The gates below mirror serve-frank-dragoon.sh, and the vendor's
# fabric-clock host unit (dankclaw/host/, verbatim at tag v0.16.2) is installed
# ONLY while the lane is named — on Strix Halo the iGPU and the NPU co-running
# hang the machine while the fabric clock moves, so halogen refuses to boot with
# the lane armed unless that clock is held.
LLM_DRAGOON_NPU_MODELS="${LLM_DRAGOON_NPU_MODELS:-qwen3-embedding-0.6b}"
LLM_DRAGOON_NPU_DIR="${LLM_DRAGOON_NPU_DIR:-/home/dank/models/halogen-v2-abliterated/npu}"
LLM_DRAGOON_DEV_ACCEL="${LLM_DRAGOON_DEV_ACCEL:-/dev/accel/accel0}"
LLM_DRAGOON_XRT_DIR="${LLM_DRAGOON_XRT_DIR:-/usr/lib/x86_64-linux-gnu}"
LLM_DRAGOON_NETWORK="${LLM_DRAGOON_NETWORK:-dankclaw_default}"
retire_halogen_dragoon_quadlet() { retire_halogen_dragoon_quadlet() {
local trash="$HOME/.AGENTTRASH/20260919-orca-cutover/quadlet" local trash="$HOME/.AGENTTRASH/20260919-orca-cutover/quadlet"
@@ -1415,6 +1584,77 @@ deploy_llm_dragoon() {
fi fi
done done
# The container network has to exist before the run line names it (a typo
# there is a restart-looping unit, not a config error anyone reads).
if [[ -n "$LLM_DRAGOON_NETWORK" ]] && ! podman network exists "$LLM_DRAGOON_NETWORK"; then
echo "ERROR: podman network $LLM_DRAGOON_NETWORK does not exist" >&2
echo "frank-dragoon: create it or blank FRANK_DRAGOON_NETWORK (pasta default)" >&2
return 1
fi
# --- NPU lane: the same predicates the serve script checks --------------
if [[ -n "$LLM_DRAGOON_NPU_MODELS" ]]; then
# One door into the container: the read-only /models mount of the
# payload dir. A staging dir outside it needs a nested bind whose
# mountpoint runc cannot create through a read-only parent, so the
# parity twin of the serve gate refuses the shape before the restart.
if [[ "$LLM_DRAGOON_NPU_DIR/" != "$LLM_DRAGOON_MODEL_DIR"/* ]]; then
echo "ERROR: NPU staging dir $LLM_DRAGOON_NPU_DIR is outside the payload dir $LLM_DRAGOON_MODEL_DIR — the lane's files reach the container only through the read-only /models mount" >&2
return 1
fi
if [[ ! -e "$LLM_DRAGOON_DEV_ACCEL" ]]; then
echo "ERROR: NPU node $LLM_DRAGOON_DEV_ACCEL absent — amdxdna is not bound (is iommu=pt still on the boot line?)" >&2
return 1
fi
# Access, not existence: a rootless container cannot open a root:render
# 0660 node (its namespace maps one uid/gid, and keep-groups cannot grant
# an unmapped gid — measured 2026-10-19). Install the mode rule, re-trigger
# the node, then PROVE the node is other-accessible before the restart.
run_privileged install -m 0644 dankclaw/udev/98-halogen-npu-access.rules \
/etc/udev/rules.d/98-halogen-npu-access.rules
run_privileged udevadm control --reload-rules
run_privileged udevadm trigger --sysname-match="$(basename "$LLM_DRAGOON_DEV_ACCEL")"
# (`-o FILE` is not "other-writable" — it means "exists", and `-o` is also
# the OR operator inside test), so the bit is read straight off the mode.
if [[ $(( 8#$(stat -c '%a' "$LLM_DRAGOON_DEV_ACCEL") & 8#2 )) -eq 0 ]]; then
echo "ERROR: $LLM_DRAGOON_DEV_ACCEL is still $(stat -c '%a %U:%G' "$LLM_DRAGOON_DEV_ACCEL") after installing the access rule — the lane cannot open it rootless" >&2
return 1
fi
for xl in libxrt_coreutil.so.2 libxrt_core.so.2 libxrt_driver_xdna.so.2; do
if [[ ! -e "$LLM_DRAGOON_XRT_DIR/$xl" ]]; then
echo "ERROR: XRT NPU library $LLM_DRAGOON_XRT_DIR/$xl absent (Ubuntu libxrt2 + libxrt-npu2)" >&2
return 1
fi
done
for nm in ${LLM_DRAGOON_NPU_MODELS//,/ }; do
if [[ ! -d "$LLM_DRAGOON_NPU_DIR/$nm" ]]; then
echo "ERROR: NPU model $nm not staged in $LLM_DRAGOON_NPU_DIR" >&2
echo "frank-dragoon: stage it: bash scripts/download-frank-dragoon-npu.sh" >&2
return 1
fi
done
# The hold must exist BEFORE the engine starts and survive every boot
# (a resume hands the clock back too — the unit's WantedBy covers it).
run_privileged install -m 0755 dankclaw/host/halogen-fabric-clock \
/usr/local/sbin/halogen-fabric-clock
run_privileged install -m 0644 dankclaw/host/halogen-fabric-clock.service \
/etc/systemd/system/halogen-fabric-clock.service
run_privileged systemctl daemon-reload
run_privileged systemctl enable --now halogen-fabric-clock.service
if ! run_privileged /usr/local/sbin/halogen-fabric-clock status; then
echo "ERROR: fabric clock is not held — halogen refuses to start with HALOGEN_NPU_MODELS set" >&2
echo "frank-dragoon: journalctl -u halogen-fabric-clock.service -n 40" >&2
return 1
fi
elif [[ -x /usr/local/sbin/halogen-fabric-clock ]]; then
# Lane rolled back: hand the clock back to the driver and drop the boot
# hook. `disable` deletes the INSTALLED copy of this unit — unlike the
# tracked LLM unit symlinks the fleet's disable law protects, this file
# is repo-owned and the next lane-on deploy reinstalls it.
run_privileged systemctl disable halogen-fabric-clock.service 2>/dev/null || true
run_privileged /usr/local/sbin/halogen-fabric-clock release || true
fi
retire_halogen_dragoon_quadlet retire_halogen_dragoon_quadlet
# The predecessor on a re-deploy is THIS unit: stop it (ExecStop stops # The predecessor on a re-deploy is THIS unit: stop it (ExecStop stops
# the container) before waiting on the port. The stray sweep is keyed # the container) before waiting on the port. The stray sweep is keyed
@@ -1434,6 +1674,33 @@ deploy_llm_dragoon() {
run_privileged systemctl enable frank-dragoon.service run_privileged systemctl enable frank-dragoon.service
run_privileged systemctl restart frank-dragoon.service run_privileged systemctl restart frank-dragoon.service
wait_for_health frank-dragoon http://127.0.0.1:7770/health 1200 wait_for_health frank-dragoon http://127.0.0.1:7770/health 1200
# The lane's own gate. /health proves the engine is up, nothing about the
# NPU programs; one real embedding is the observable output. The first
# request after a boot is a warmup artifact (fleet law), so retry before
# calling a booted lane dead.
if [[ -n "$LLM_DRAGOON_NPU_MODELS" ]]; then
for nm in ${LLM_DRAGOON_NPU_MODELS//,/ }; do
lane_ok=0
for _try in 1 2 3 4 5; do
if curl -fsS -m 60 -H 'content-type: application/json' \
-d "{\"model\":\"$nm\",\"input\":[\"frank-dragoon npu lane gate\"]}" \
http://127.0.0.1:7770/v1/embeddings 2>/dev/null \
| python3 -c 'import json,sys
v = json.load(sys.stdin)["data"][0]["embedding"]
raise SystemExit(0 if isinstance(v, list) and len(v) and all(isinstance(x, (int, float)) for x in v) else 1)'; then
lane_ok=1
break
fi
sleep 10
done
if [[ "$lane_ok" != 1 ]]; then
echo "ERROR: NPU model $nm answered no vector on /v1/embeddings" >&2
echo "frank-dragoon: read the boot ledger: tail -100 /home/dank/logs/frank_dragoon.log" >&2
return 1
fi
echo "frank-dragoon: NPU lane verified — $nm embedded"
done
fi
} }
# deploy_llm_zealot — frank-zealot is llama.cpp (PrismML fork build) on the # deploy_llm_zealot — frank-zealot is llama.cpp (PrismML fork build) on the
@@ -1463,6 +1730,12 @@ LLM_ZEALOT_RUNTIME="${LLM_ZEALOT_RUNTIME:-/home/dank/GitPackages/llama-prism-b10
# 868.32 MiB worst-case VRAM (595.29 model + 248.10 compute in the boot ledger) # 868.32 MiB worst-case VRAM (595.29 model + 248.10 compute in the boot ledger)
# lands AFTER the engine's fit check, so it is not inside the q8_0 budget. # lands AFTER the engine's fit check, so it is not inside the q8_0 budget.
LLM_ZEALOT_MMPROJ="${LLM_ZEALOT_MMPROJ:-/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf}" LLM_ZEALOT_MMPROJ="${LLM_ZEALOT_MMPROJ:-/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf}"
# The refusal adapter is part of the payload identity now, so the deployer
# checks it beside the other two files — but it has its OWN fetcher, so a
# missing adapter must not send the operator to the bonsai9 script. Integrity
# (bytes + sha256) is the fetcher's job at download time; this gate only asks
# whether the file the env names exists, exactly like the two above.
LLM_ZEALOT_LORA="${LLM_ZEALOT_LORA:-/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-Uncensored-LoRA.gguf}"
deploy_llm_zealot() { deploy_llm_zealot() {
# Refuse rather than restart-loop: a unit whose runtime or payload is # Refuse rather than restart-loop: a unit whose runtime or payload is
@@ -1484,6 +1757,13 @@ deploy_llm_zealot() {
return 1 return 1
fi fi
done done
if [[ ! -f "$LLM_ZEALOT_LORA" ]]; then
echo "ERROR: frank-zealot refusal adapter absent at $LLM_ZEALOT_LORA" >&2
echo "frank-zealot: run scripts/download-frank-zealot-uncensored-lora.sh first;" >&2
echo "frank-zealot: the env names the adapter, so the unit would refuse to boot" >&2
echo "frank-zealot: without it — starting the unit anyway is a crash loop." >&2
return 1
fi
relink_unit frank-zealot relink_unit frank-zealot
run_privileged systemctl daemon-reload run_privileged systemctl daemon-reload
run_privileged systemctl enable frank-zealot.service run_privileged systemctl enable frank-zealot.service
@@ -1815,7 +2095,9 @@ deploy_service() {
copyparty) deploy_copyparty ;; copyparty) deploy_copyparty ;;
plex) deploy_plex ;; plex) deploy_plex ;;
tautulli) deploy_tautulli ;; tautulli) deploy_tautulli ;;
prometheus|grafana|loki|alloy|node-exporter|alertmanager) deploy_observability "$service" ;; alloy) deploy_alloy ;;
prometheus|grafana|loki|node-exporter|cadvisor|alertmanager|blackbox-exporter) deploy_observability "$service" ;;
gpu-exporter) deploy_gpu_exporter ;;
comfyui) deploy_comfyui ;; comfyui) deploy_comfyui ;;
*) deploy_compose_service "$service" ;; *) deploy_compose_service "$service" ;;
esac esac
+6 -4
View File
@@ -133,13 +133,15 @@ fi
valid_service() { valid_service() {
local service="$1" local service="$1"
case "$TARGET:$service" in case "$TARGET:$service" in
# dankclaw:searxng-network is client-only: a network quadlet with no
# remote dispatch handler, so the remote gate does not (must not) list it.
dankclaw:caddy|dankclaw:gluetun|dankclaw:searxng-network|dankclaw:searxng-valkey|\ dankclaw:caddy|dankclaw:gluetun|dankclaw:searxng-network|dankclaw:searxng-valkey|\
dankclaw:searxng|dankclaw:marreta|dankclaw:pihole|dankclaw:open-webui|dankclaw:gitea-postgres|\ dankclaw:searxng|dankclaw:marreta|dankclaw:pihole|dankclaw:open-webui|dankclaw:gitea-postgres|\
dankclaw:gitea-server|dankclaw:homepage|\ dankclaw:gitea-server|dankclaw:homepage|\
dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\ dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\
dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\ dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\
dankclaw:alertmanager|\ dankclaw:alertmanager|dankclaw:blackbox-exporter|\
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\ dankclaw:node-exporter|dankclaw:cadvisor|dankclaw:gpu-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\ dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
dankclaw:open-notebook|\ dankclaw:open-notebook|\
dankclaw:frank-tts-engine|dankclaw:frank-tts|\ dankclaw:frank-tts-engine|dankclaw:frank-tts|\
@@ -147,8 +149,8 @@ valid_service() {
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\ dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\ dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\ dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\
dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|\ dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|dankserver:cadvisor|dankserver:gpu-exporter|\
dankserver:frank-archon|\ dankserver:prowlarr|dankserver:frank-archon|\
frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\ frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\
frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\ frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\
frankbook-pro:readarr) frankbook-pro:readarr)
+151 -24
View File
@@ -20,9 +20,16 @@ of the Gram is its down-input energy. Rank halves: 0-255, 256-511
Output: the cast-rule lines (recipe format) on stdout, a report on stderr. Output: the cast-rule lines (recipe format) on stdout, a report on stderr.
Usage: derive-protected-experts.py <calib dir> [--min-share 0.03] Usage: derive-protected-experts.py <calib dir> [--min-share 0.03]
[--min-route-mult 3.0] [--min-route-mult 3.0] [--target-count 0]
--target-count N fills the byte bill: the bars pick the base set exactly as
the bar-only pass does, then the energy ranking admits experts one at a time
(each with its parity partner) until the count lands. The energy bar NEVER
relaxes, so asking for 48 bf16 experts never quietly buys 80; the ratio bar
walks down only when the ranking cannot produce that many candidates.
0 takes the bars as given.
""" """
import argparse import argparse
import collections
import glob import glob
import os import os
import re import re
@@ -32,6 +39,7 @@ import sys
import numpy as np import numpy as np
NAME_RE = re.compile(r"^gram\.r(\d)\.blk\.(\d+)\.ffn_down_exps\.(\d+)\.bin$") NAME_RE = re.compile(r"^gram\.r(\d)\.blk\.(\d+)\.ffn_down_exps\.(\d+)\.bin$")
CAST_RE = re.compile(r"^blk\.(\d+)\.ffn_\*_exps\.(\d+)\.weight")
def read_gram(path): def read_gram(path):
@@ -49,27 +57,8 @@ def read_gram(path):
return k, rank, int(rows), tr return k, rank, int(rows), tr
def main(): def select(data, min_share, min_route_mult):
ap = argparse.ArgumentParser() """(cast lines, report lines, count) for one pair of bars."""
ap.add_argument("calib_dir")
ap.add_argument("--min-share", type=float, default=0.03)
ap.add_argument("--min-route-mult", type=float, default=3.0)
a = ap.parse_args()
# layer -> global expert -> (rank, rows, energy)
data = {}
for path in sorted(glob.glob(os.path.join(
a.calib_dir, "gram.r*.ffn_down_exps.*.bin"))):
m = NAME_RE.match(os.path.basename(path))
if not m:
continue
rank, layer, expert = int(m.group(1)), int(m.group(2)), int(m.group(3))
k, _, rows, tr = read_gram(path)
data.setdefault(layer, {})[expert] = (rank, rows, tr)
if not data:
raise SystemExit("no per-expert down grams found")
lines, report = [], [] lines, report = [], []
total = 0 total = 0
for layer in sorted(data): for layer in sorted(data):
@@ -83,7 +72,7 @@ def main():
for e, (rank, rows, tr) in experts.items(): for e, (rank, rows, tr) in experts.items():
es, rs = tr / tot_e, rows / max(tot_r, 1) es, rs = tr / tot_e, rows / max(tot_r, 1)
rec = (e, rank, es, rs) rec = (e, rank, es, rs)
if es >= a.min_share and es >= a.min_route_mult * rs: if es >= min_share and es >= min_route_mult * rs:
qual.append(rec) qual.append(rec)
elif es > 0: elif es > 0:
pool.append(rec) pool.append(rec)
@@ -107,10 +96,148 @@ def main():
f"energy {es*100:5.2f}%, routing {rs*100:5.3f}%, " f"energy {es*100:5.2f}%, routing {rs*100:5.3f}%, "
f"ratio {es/max(rs,1e-12):6.1f}") f"ratio {es/max(rs,1e-12):6.1f}")
total += len(chosen) total += len(chosen)
return lines, report, total
def parity_repair(chosen, by_half, report, only=None):
"""Add the next-best candidate to every half — or to `only` — holding an odd count.
The four-bit GEMM takes each half's remainder as two equal tables, so an odd count
of bf16 experts on a half is not a style problem: the two tables would not match.
`by_half` is energy-descending, so the first unchosen candidate is the next most
energetic, which is the expert worth keeping. Returns how many were added.
"""
added = 0
for key in ([only] if only is not None else sorted(by_half)):
layer, half = key
n = sum(1 for (l, x) in chosen if l == layer and x // 256 == half)
if n % 2 == 0:
continue
for e, es, rs in by_half.get(key, []):
if (layer, e) not in chosen:
chosen[(layer, e)] = (es, rs)
added += 1
report.append(f"blk.{layer}: half {half} odd -> added e{e} "
f"(energy {es*100:.2f}%)")
break
else:
report.append(f"blk.{layer}: half {half} holds {n} and no candidate is "
f"left to make it even")
return added
def select_to_count(data, min_share, min_route_mult, target):
"""(lines, report, total, cutoff) for the set that LANDS the count.
The bars decide the base set; the count is then filled from the energy ranking,
one expert at a time, each addition taking its half's next-best candidate with it
so every rank's half stays even. Only the RATIO bar walks down to find candidates
— it is the law that says an expert is disproportionate and not merely busy, and
the caller's byte bill has to be filled — while the ENERGY bar holds, because that
bar is what the extra GiB of bf16 is supposed to buy. `cutoff` is the energy share
the set bottoms out at: the bar this campaign actually converted with.
"""
report = []
shares, ranked = {}, []
for layer in sorted(data):
experts = data[layer]
tot_e = sum(v[2] for v in experts.values())
tot_r = sum(v[1] for v in experts.values())
if tot_e <= 0 or tot_r <= 0:
report.append(f"blk.{layer}: empty recording, skipped")
continue
for e, (_rank, rows, tr) in experts.items():
es, rs = tr / tot_e, rows / max(tot_r, 1)
if es <= 0:
continue
shares[(layer, e)] = (es, rs)
ranked.append((es, layer, e, rs))
ranked.sort(key=lambda r: (-r[0], r[1], r[2]))
by_half = {}
for es, layer, e, rs in ranked:
by_half.setdefault((layer, e // 256), []).append((e, es, rs))
chosen = {k: v for k, v in shares.items()
if v[0] >= min_share and v[0] >= min_route_mult * v[1]}
total = len(chosen)
total += parity_repair(chosen, by_half, report)
mult = min_route_mult
while True:
pool = [(es, l, e, rs) for es, l, e, rs in ranked
if es >= mult * rs and (l, e) not in chosen]
if len(pool) + total >= target or mult <= 0.0:
break
mult -= 0.5
report.append(f"ratio bar {mult + 0.5:g} leaves {len(pool)} fill candidates "
f"for {target - total} slots; relaxing toward 0")
for es, layer, e, rs in pool:
if total >= target:
break
if (layer, e) in chosen:
continue
chosen[(layer, e)] = (es, rs)
total += 1
report.append(f"blk.{layer}: count fill -> added e{e} (energy {es*100:.2f}%)")
total += parity_repair(chosen, by_half, report, only=(layer, e // 256))
lines, cutoff = [], None
for (layer, e) in sorted(chosen):
es, rs = chosen[(layer, e)]
cutoff = es if cutoff is None else min(cutoff, es)
lines.append(f"blk.{layer}.ffn_*_exps.{e}.weight cast dtype=bf16")
return lines, report, total, cutoff
def main():
ap = argparse.ArgumentParser()
ap.add_argument("calib_dir")
ap.add_argument("--min-share", type=float, default=0.03)
ap.add_argument("--min-route-mult", type=float, default=3.0)
ap.add_argument("--target-count", type=int, default=0)
a = ap.parse_args()
# layer -> global expert -> (rank, rows, energy)
data = {}
for path in sorted(glob.glob(os.path.join(
a.calib_dir, "gram.r*.ffn_down_exps.*.bin"))):
m = NAME_RE.match(os.path.basename(path))
if not m:
continue
rank, layer, expert = int(m.group(1)), int(m.group(2)), int(m.group(3))
k, _, rows, tr = read_gram(path)
data.setdefault(layer, {})[expert] = (rank, rows, tr)
if not data:
raise SystemExit("no per-expert down grams found")
if a.target_count > 0:
lines, report, total, cutoff = select_to_count(
data, a.min_share, a.min_route_mult, a.target_count)
bar = f"energy cutoff {cutoff:.3g}" if cutoff is not None else "no candidates"
else:
lines, report, total = select(data, a.min_share, a.min_route_mult)
bar = f"min-share {a.min_share:.3g}"
# The emitted lines ARE the contract with the four-bit GEMM: every rank's half of
# every layer must hold an even number of bf16 experts. Judged on the output and
# not on the internal sets, so a slip in either pass is caught here rather than at
# engine load time.
halves = collections.Counter()
for rule in lines:
m = CAST_RE.match(rule)
if m:
halves[(int(m.group(1)), int(m.group(2)) // 256)] += 1
odd = {key: n for key, n in halves.items() if n % 2}
if odd:
print("PARITY LAW BROKEN: " + ", ".join(
f"blk.{lay} half {half} holds {n} protected experts"
for (lay, half), n in sorted(odd.items())), file=sys.stderr)
raise SystemExit(2)
print("\n".join(lines)) print("\n".join(lines))
print("\n".join(report), file=sys.stderr) print("\n".join(report), file=sys.stderr)
print(f"total protected: {total}", file=sys.stderr) print(f"total protected: {total} (target {a.target_count or 'none'}, {bar}, "
f"min-route-mult {a.min_route_mult:g})", file=sys.stderr)
if __name__ == "__main__": if __name__ == "__main__":
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# download-frank-dragoon-npu.sh — stage the NPU half of frank-dragoon: the
# vendor-precompiled AIE programs halogen-flash-server runs on the XDNA2 NPU
# when HALOGEN_NPU_MODELS names them. These are NOT model weights you can
# quantize yourself: XDNA2 executes only programs compiled by AMD's AIE
# compiler, so the only source is peonist-ai's per-model NPU repo, and the
# engine checks every file's size and sha256 against its own catalog at boot
# (/opt/halogen/npu/models.txt inside the image) and refuses to load a model
# whose files do not match.
#
# The manifest below IS that catalog block (dumped from the pinned 0.16.2
# image on 2026-10-19), so the files land in the layout the entrypoint
# expects — <dir>/<model-id>/devices/*, <dir>/<model-id>/<model-id>.hnpw and
# <dir>/<model-id>/tokenizer/tokenizer.json — and the endpoint can keep
# /models read-only and open no outbound connection at boot (HALOGEN_DOWNLOAD
# stays unset; the vendor's own README: with it unset the container makes no
# outbound connections at all).
#
# The fetch law, same as every sibling script: curl -f (an access error must
# not become a "successful" 153-byte artifact), the exact byte count and the
# sha256 from the manifest, --verify-only to re-check a staged set.
#
# Adding a second NPU model: read its block out of the image's catalog and add
# a case below —
# podman exec frank-dragoon-halogen grep "^file <model-id> " /opt/halogen/npu/models.txt
# podman exec frank-dragoon-halogen grep "^model <model-id> " /opt/halogen/npu/models.txt
# and add the model id to HALOGEN_NPU_MODELS in frank-dragoon.env.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}"
# The env file owns the staging path (it must sit inside the payload dir, which
# is the container's only /models mount); an exported FRANK_DRAGOON_NPU_DIR wins
# so the bats seam can stage somewhere disposable.
DEST="${FRANK_DRAGOON_NPU_DIR:-}"
if [ -z "$DEST" ] && [ -f "$ENV_FILE" ]; then
DEST="$(sed -nE 's/^FRANK_DRAGOON_NPU_DIR=(.*)$/\1/p' "$ENV_FILE" | tail -1)"
fi
: "${DEST:?FRANK_DRAGOON_NPU_DIR is not set and $ENV_FILE names no FRANK_DRAGOON_NPU_DIR}"
HF="https://huggingface.co"
MODELS="${HALOGEN_NPU_MODELS:-}"
if [ -z "$MODELS" ] && [ -f "$ENV_FILE" ]; then
MODELS="$(sed -nE 's/^HALOGEN_NPU_MODELS=(.*)$/\1/p' "$ENV_FILE" | tail -1)"
fi
VERIFY_ONLY=0
case "${1:-}" in
--verify-only) VERIFY_ONLY=1 ;;
"") ;;
*) echo "usage: ${0##*/} [--verify-only] (models come from HALOGEN_NPU_MODELS)" >&2; exit 2 ;;
esac
[ -n "$MODELS" ] || { echo "no NPU model named — set HALOGEN_NPU_MODELS or run with it in the environment" >&2; exit 1; }
# manifest <model-id> -> repo revision + one "path bytes sha256" per file.
manifest() {
case "$1" in
qwen3-embedding-0.6b)
NPU_REPO="peonist-ai/halogen-npu-qwen3-embedding-0.6b"
NPU_REV="4960fd9d4423dd15373ad21c83db48dbdb3b2798"
FILES=(
"devices/NOTICE.md 3248 0f267b3d2523ddf14997ee58660a492a9eaf10ccf6b08bf19dfdc85b9a478b91"
"devices/consts.hnpb 5086496 ae430e41545a0b1012938cda373b0c450364d3be631111cca3a8c43679f93336"
"devices/devices.hnpm 24047 4e9bca9d95d084fb89ce5b99f579a00666c8ca1efae2613c53e3a4878cc9f174"
"devices/u0.elf 4931968 b38f165886cb037b7d3951d9cb878473eaba58a432acd45416ff85fd88aa4e46"
"devices/u1.elf 5456000 b1811feee851b8af833b37721da92099d89772110815bbbe2c3fa1c276f0ff42"
"devices/u2.elf 6505552 406ea9a95b470628a7382e376f4a72cdf4d7aa4c59b4ddf0a4bdd139d63d6ff2"
"devices/u3.elf 6817216 bc205d51b523d770151a0c61968f7c70bf624412827580bb6fe0aaf4650dc2ee"
"devices/u4.elf 7444032 a5a91338a2603acdcd5c11df532033fda2d3f5479ea44ae87f98fafbdff9a62d"
"devices/u5.elf 7432416 6c2bf41835569e0eba25ded7c05c7264ae69c6700bdeb4791057d884f2a4c126"
"devices/u6.elf 4941232 919d0038ca088eafddf195f3466ff38db27d3635be1cffa5d4d4b5140902a68e"
"devices/u7.elf 5488304 d904cf8afae9428dd167960549e18cce5441745f18c62f8c50975dffc077d3c2"
"devices/u8.elf 4941232 db3aef69b17825854ee0dd2df475125a3f5c872f95c686793d6af1ee83596fa7"
"devices/u9.elf 5488304 d182727e551084db8fb9600441d8aa373c7f09a3d267ae0a5e921ef92326db1c"
"qwen3-embedding-0.6b.hnpw 813032248 b9f9901429df7b46ec17153aa3899f738adbea8e4356080da0c6e6586fb750d1"
"tokenizer/tokenizer.json 11423705 def76fb086971c7867b829c23a26261e38d9d74e02139253b38aeb9df8b4b50a"
) ;;
*)
echo "unknown NPU model '$1' — add its catalog block to manifest() in this script" >&2
return 1 ;;
esac
}
for MODEL in ${MODELS//,/ }; do
manifest "$MODEL" || exit 1
echo "== $MODEL ($NPU_REPO@$NPU_REV) =="
for entry in "${FILES[@]}"; do
read -r f want sha <<<"$entry"
path="$DEST/$MODEL/$f"
mkdir -p "$(dirname "$path")"
if [ "$VERIFY_ONLY" -eq 0 ]; then
if [ -f "$path" ] && [ "$(stat -c %s "$path")" = "$want" ]; then
echo "HAVE $f ($want bytes) — skipping fetch"
else
echo "FETCH $f -> $path"
# HF answers /resolve/ with a 302 to the Xet CDN and curl
# DROPS the Range header across redirects, so resolve the
# effective URL first and resume against the CDN (the sibling
# 62 GiB fetch hit this on 2026-10-18).
url=$(curl -sfIL -o /dev/null -w '%{url_effective}' "$HF/$NPU_REPO/resolve/$NPU_REV/$f") \
|| { echo "cannot resolve $NPU_REPO/$f" >&2; exit 1; }
curl -fL --retry 5 -C - -o "$path" "$url"
fi
fi
[ -f "$path" ] || { echo "MISSING $path (run without --verify-only)" >&2; exit 1; }
if [ -L "$path" ]; then
echo "SYMLINK $path — the serve gate refuses symlinks; replace with a real file" >&2
exit 1
fi
got="$(stat -c %s "$path")"
[ "$got" = "$want" ] || { echo "SIZE $path: $got != $want" >&2; exit 1; }
got="$(sha256sum "$path" | cut -d' ' -f1)"
[ "$got" = "$sha" ] || { echo "SHA256 $path: $got != $sha" >&2; exit 1; }
echo "OK $f ($want bytes, sha256)"
done
done
echo "DOWNLOAD_DONE $DEST ($MODELS)"
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Fetch the refusal-direction adapter that frank-zealot applies at load:
# AtomicChat/Ternary-Bonsai-2-9B-Uncensored-LoRA-GGUF — a **3.2 MB rank-1 LoRA**
# for prism-ml/Ternary-Bonsai-2-9B, not a checkpoint. Public repo: no auth
# header, and never a token on the command line (a Bearer token is
# world-readable in /proc/<pid>/cmdline).
#
# IT IS AN EDIT INSIDE THE COMPUTE GRAPH, NOT A SECOND MODEL. The card: B = -r
# (the refusal direction, row 23 of its own probe matrix), A = P^T(s *
# H(r^T W_fold)) built from the base pack's folded input basis, applied at every
# transformer layer (`target_layers = all`, `sites = 64`), with the embedding
# deliberately untouched because an embedding LoRA crashes this fork's graph.
# `--lora-scaled <file>:<scale>` is the flag pair: scale 1 is the card's
# "held-out refusals 0 %" point, scale 0 is the plain model again.
#
# THE RUNTIME IS A GATE, NOT A PREFERENCE. The card verified the adapter on
# `prism-b10777-e3de715` — the exact build frank-zealot runs — and says older
# forks (e.g. `prism-b10709`) refuse these packs with `unsupported
# prism.hadamard.version: 2`. The fork is already pinned for the payload's
# custom tensor type 142; this adapter adds a second reason it cannot move.
#
# PROVENANCE ASYMMETRY, STATED HONESTLY. The GGUF's own metadata names the pack
# the direction was fitted against:
# prism.abliterate.base_gguf = Ternary-Bonsai-2-9B-PTQ1_0.gguf
# prism.abliterate.base_sha256 = 3561df7ac7d26a71ffa991ac2db740b10841c3a350aa2676ccd3ff5d428b4e8b
# frank-zealot serves the PQ2_0 + MTP + Q8_0-embedding packing (its sha256 is
# c79fcdfa…, see download-frank-zealot-bonsai9.sh), which is NOT that file and is
# not on this host. The adapter still applies — same tensor names, and the
# metadata is provenance rather than a checksum gate — but its strength was
# calibrated on a sibling quantization. What proves the effect here is the
# behaviour of the endpoint (a refusal probe before and after the swap), not the
# metadata and not the card's table, which was measured on PTQ1_0.
#
# Fresh downloads, no resume; -f/--fail-with-body so an HTTP error is not a
# success, then byte count and sha256 (this repo is NOT LFS — the file is a
# plain git object, so the sha below is the content hash of the Hub's bytes as
# fetched, not an LFS oid). Idempotent: a complete file is skipped.
#
# Usage:
# scripts/download-frank-zealot-uncensored-lora.sh [DEST_DIR] [--verify-only]
set -euo pipefail
DEST=""
VERIFY_ONLY=0
for arg in "$@"; do
case "$arg" in
--verify-only) VERIFY_ONLY=1 ;;
*) DEST="$arg" ;;
esac
done
DEST="${DEST:-/home/dank/models/Bonsai2-9B-MTP}"
REPO="AtomicChat/Ternary-Bonsai-2-9B-Uncensored-LoRA-GGUF"
# name | bytes | sha256 of the Hub's bytes
FILES=(
"Ternary-Bonsai-2-9B-Uncensored-LoRA.gguf|3155424|861b9c099fb5cd81438ba85bad30590847c777b1e18112586be7f4f9ae5fb07b"
)
mkdir -p "$DEST"
status=0
for entry in "${FILES[@]}"; do
IFS='|' read -r name size sha <<<"$entry"
url="https://huggingface.co/${REPO}/resolve/main/${name}"
f="$DEST/$name"
have=0
if [[ -f "$f" ]] && [[ "$(stat -c%s "$f")" == "$size" ]]; then
if sha256sum -c --quiet <(printf '%s %s\n' "$sha" "$f"); then
have=1
fi
fi
if (( have )); then
echo "OK $name"
continue
fi
if (( VERIFY_ONLY )); then
echo "MISSING $name"
status=1
continue
fi
echo "FETCHING $name (from $REPO)"
tmp="$f.part"
curl -fsSL --fail-with-body -o "$tmp" "$url"
mv "$tmp" "$f"
if [[ "$(stat -c%s "$f")" != "$size" ]]; then
echo "SIZE FAIL $name ($(stat -c%s "$f") != $size)"
status=1
continue
fi
if ! sha256sum -c --quiet <(printf '%s %s\n' "$sha" "$f"); then
echo "SHA FAIL $name"
status=1
continue
fi
echo "VERIFIED $name"
done
if (( status == 0 )); then
echo "DOWNLOAD_DONE $DEST"
else
echo "DOWNLOAD_FAILED $DEST" >&2
fi
exit "$status"
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env bash
# download-qwen38-bf16.sh — RUNS ON DANKSERVER. Fetch and verify the vendor
# BF16 checkpoint Qwen/Qwen3.8-Flash-Next: the source the five-bit campaign
# converts, and the source the KLD reference is recorded from.
#
# Why the base and not a finetune: the campaign converts to compare against the
# publisher's own iq4r container, and that container was built from this
# checkpoint. Base-in, base-out; anything else confounds the gate. (The
# predecessor campaign ran ukisai/Swift-1.5-Qwen3.8-Flash-Next-BF16, whose key
# set is identical to this repo's but whose weights are not — none of its
# calibration grams, KLD reference or protected list carries over.)
#
# Why re-fetch instead of reusing: every BF16 copy of this family was destroyed
# in the 2026-10-15 storage purge — only the 113.6 GiB served .rad survived.
# 335.3 GiB of safetensors, 131 shards, ungated (the predecessor campaign
# verified the key set against this repo, so no gate letter is needed).
#
# Verification is four gates deep, because the failure this repo actually lived
# through is a fetch that reported success: `curl -o FILE URL` exits 0 on an
# HTTP 401/403 and writes the error body as the artifact — three shards of
# identical tiny size and a cheerful DONE in 20 seconds for 104 GiB.
#
# 1. shard census: the file count must equal the expected shard count, so a
# partially fetched fragment tree (the .partNofM trap) cannot pass.
# 2. per-shard completeness, from the shard's OWN safetensors header: the
# header declares where the tensor data ends, so every shard's true size is
# known offline. A torn tail is named here, by file, instead of surfacing
# as a load error five hours later.
# 3. index coverage: every shard model.safetensors.index.json references
# exists and is named.
# 4. total bytes against the number the Hub declared, then, unless --no-hub,
# every file's sha256 against the Hub's LFS record — including the shards
# already on disk, so a previous partial download is never trusted.
#
# The token never enters a command line (the 2026-09-16 leak law): it is read
# into HF_TOKEN in the environment, where a local process listing is the only
# reader. This repo is ungated, so the fetch simply runs without one when no
# token file exists. scripts/hfget.sh is curl+jq only — no Python, no venv, no
# hf_transfer — and it drops the pickle/ONNX weight duplicates by itself.
#
# usage: download-qwen38-bf16.sh [--fetch] [--no-hub] [--dir DIR]
# [--shards N] [--bytes B] [--repo ID]
# --fetch download first (resumable: re-run to continue), then verify
# --no-hub skip the Hub-side sha256 sweep; the other gates always run
# exit: 0 verified, 1 verification failed, 2 environment / usage error
set -euo pipefail
REPO="${FRANK_QWEN38_HF_REPO:-Qwen/Qwen3.8-Flash-Next}"
LOCAL_DIR="${FRANK_QWEN38_CKPT:-/home/dank/models/Qwen3.8-Flash-Next}"
# Hub census of 2026-10-27: 144 files, 131 of them .safetensors, together
# 360,023,351,514 bytes; the shards alone are 360,000,192,888. This gate counts
# shards only, so a config file edited on the Hub cannot fail it.
EXPECTED_SHARDS="${FRANK_QWEN38_SHARDS:-131}"
EXPECTED_BYTES="${FRANK_QWEN38_BYTES:-360000192888}"
MIN_FREE_MIB="${FRANK_QWEN38_MIN_FREE_MIB:-350000}"
FETCH=0
HUB=1
while [ $# -gt 0 ]; do
case "$1" in
--fetch) FETCH=1 ;;
--no-hub) HUB=0 ;;
--dir) LOCAL_DIR="$2"; shift ;;
--shards) EXPECTED_SHARDS="$2"; shift ;;
--bytes) EXPECTED_BYTES="$2"; shift ;;
--repo) REPO="$2"; shift ;;
*) echo "usage: ${0##*/} [--fetch] [--no-hub] [--dir DIR] [--shards N] [--bytes B] [--repo ID]" >&2
exit 2 ;;
esac
shift
done
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FAILS=0
note() { echo "$@"; FAILS=$((FAILS + 1)); }
if [ "$FETCH" = "1" ]; then
# Every fetch gate is a hard stop: skipping the download silently and then
# verifying a half tree is how a campaign ships a stale payload.
HFGET="$SELF_DIR/hfget.sh"
if [ ! -f "$HFGET" ]; then
echo "REFUSED: the fetch helper is not here: $HFGET" >&2
exit 2
fi
if ! command -v jq >/dev/null 2>&1; then
echo "REFUSED: hfget.sh needs jq on PATH" >&2
exit 2
fi
mkdir -p "$LOCAL_DIR"
# The campaign's peak-disk law starts at the fetch: the checkpoint and the
# reference container built from it are ~335 GiB each, and a tree that does
# not fit is discovered hours into a transfer, when it costs the most.
free_mib=$(df -m --output=avail "$LOCAL_DIR" | tail -1 | tr -dc '0-9')
if [ "${free_mib:-0}" -lt "$MIN_FREE_MIB" ]; then
echo "REFUSED: $free_mib MiB free at $LOCAL_DIR; the fetch wants $MIN_FREE_MIB MiB free" >&2
echo " (retire the peak-disk predecessors first — nothing is downloaded, so" >&2
echo " nothing is left half-written)" >&2
exit 2
fi
echo "fetch: $REPO -> $LOCAL_DIR ($free_mib MiB free)"
if [ -z "${HF_TOKEN:-}" ]; then
CREDSTORE="$HOME/.config/huggingface/credentials/https://huggingface.co"
if [ -f "$CREDSTORE" ]; then
HF_TOKEN="$(awk '/^ OauthToken: / {print $2; exit}' "$CREDSTORE")"
elif [ -f "$HOME/.cache/huggingface/token" ]; then
# The legacy single-line token file the hub library writes.
HF_TOKEN="$(tr -d '[:space:]' < "$HOME/.cache/huggingface/token")"
elif [ -f "$HOME/.hf_token" ]; then
HF_TOKEN="$(tr -d '[:space:]' < "$HOME/.hf_token")"
fi
fi
if [ -n "${HF_TOKEN:-}" ]; then
export HF_TOKEN
fi
bash "$HFGET" "$REPO" "$LOCAL_DIR" main
unset HF_TOKEN
fi
if [ ! -d "$LOCAL_DIR" ]; then
echo "REFUSED: $LOCAL_DIR does not exist (run with --fetch, or pass --dir)" >&2
exit 1
fi
INDEX="$LOCAL_DIR/model.safetensors.index.json"
if [ ! -f "$INDEX" ]; then
echo "REFUSED: $INDEX is missing — the checkpoint tree is not addressable without it" >&2
exit 1
fi
echo "== gate 1: shard census ($LOCAL_DIR)"
SHARDS=$(find "$LOCAL_DIR" -maxdepth 1 -type f -name '*.safetensors' | wc -l)
if [ "$SHARDS" != "$EXPECTED_SHARDS" ]; then
note "FAIL SHARD_COUNT: $SHARDS safetensors files on disk, expected $EXPECTED_SHARDS"
else
echo " ok: $SHARDS shards"
fi
echo "== gate 2: every shard is whole, judged by its own header"
# A safetensors file is [u64 header length][JSON header][tensor data]. The JSON
# declares where the data ends, so the file's true size is computable offline:
# 8 + header + the largest end offset. Truncation and error bodies both fall
# here, and each is reported with the file that caused it.
while IFS= read -r f; do
state=$(python3 - "$f" <<'PY'
import json, os, struct, sys
path = sys.argv[1]
size = os.path.getsize(path)
try:
with open(path, "rb") as fh:
(hlen,) = struct.unpack("<Q", fh.read(8))
if hlen == 0 or hlen > size:
print("NOT_SAFETENSORS")
raise SystemExit
header = json.loads(fh.read(hlen))
except Exception:
print("NOT_SAFETENSORS")
raise SystemExit
if not header:
print("NOT_SAFETENSORS")
raise SystemExit
end = max((v["data_offsets"][1] for v in header.values()
if isinstance(v, dict) and "data_offsets" in v), default=0)
want = 8 + hlen + end
print("OK" if want == size else f"TRUNCATED want={want} have={size}")
PY
)
case "$state" in
OK) ;;
NOT_SAFETENSORS*) note "FAIL NOT_SAFETENSORS: $(basename "$f") is not a safetensors file (an error body written as an artifact?)" ;;
TRUNCATED*) note "FAIL TRUNCATED: $(basename "$f") $state" ;;
esac
done < <(find "$LOCAL_DIR" -maxdepth 1 -type f -name '*.safetensors' | sort)
if [ "$FAILS" -eq 0 ]; then
echo " ok: every shard's data extent matches its size on disk"
fi
echo "== gate 3: the index's shards are all present"
INDEX_SHARDS=$(python3 - "$INDEX" <<'PY'
import json, sys
map_ = json.load(open(sys.argv[1]))["weight_map"]
print("\n".join(sorted(set(map_.values()))))
PY
)
while IFS= read -r name; do
[ -n "$name" ] || continue
if [ ! -f "$LOCAL_DIR/$name" ]; then
note "FAIL MISSING: $name is named by the index and is not on disk"
fi
done <<< "$INDEX_SHARDS"
if [ "$FAILS" -eq 0 ]; then
echo " ok: $(printf '%s\n' "$INDEX_SHARDS" | grep -c .) referenced shards on disk"
fi
echo "== gate 4: total bytes vs the Hub's declaration"
TOTAL=$(find "$LOCAL_DIR" -maxdepth 1 -type f -name '*.safetensors' -printf '%s\n' | awk '{s+=$1} END{print s+0}')
if [ "$TOTAL" != "$EXPECTED_BYTES" ]; then
note "FAIL TOTAL_BYTES: shards total $TOTAL bytes, expected $EXPECTED_BYTES"
else
echo " ok: $TOTAL bytes"
fi
if [ "$HUB" = "1" ]; then
echo "== gate 5: every file's sha256 vs the Hub's LFS record"
# size = file size; lfs.sha256 = sha256 of the file itself (LFS or not).
TMPD="$(mktemp -d)"
trap 'rm -rf "$TMPD"' EXIT
MANIFEST="$TMPD/tree.json"
PLAN="$TMPD/plan"
curl -sf "https://huggingface.co/api/models/$REPO/tree/main?recursive=true&expand=true" \
-o "$MANIFEST" || { echo "FAIL: the Hub API call failed — refusing to report success" >&2; exit 1; }
python3 - "$MANIFEST" > "$PLAN" <<'PY'
import json, sys
for e in json.load(open(sys.argv[1])):
if e.get("type") != "file":
continue
sha = (e.get("lfs") or {}).get("sha256") or e.get("sha") or ""
if sha:
print(e["path"], e["size"], sha)
PY
BAD=0
while read -r rel size sha; do
[ -n "${rel:-}" ] || continue
if [ ! -f "$LOCAL_DIR/$rel" ]; then
note "FAIL MISSING: $rel is on the Hub and not on disk"
continue
fi
have=$(stat -c %s "$LOCAL_DIR/$rel")
if [ "$have" != "$size" ]; then
note "FAIL SIZE: $rel is $have bytes, Hub says $size"
continue
fi
got=$(sha256sum "$LOCAL_DIR/$rel" | awk '{print $1}')
if [ "$got" != "$sha" ]; then
note "FAIL SHA256: $rel got $got want $sha"
BAD=$((BAD + 1))
fi
done < "$PLAN"
if [ "$BAD" -eq 0 ] && [ "$FAILS" -eq 0 ]; then
echo " ok: sha256 matches for every file"
fi
rm -rf "$TMPD"
trap - EXIT
fi
if [ "$FAILS" -gt 0 ]; then
echo "VERIFY_FAILED: $FAILS problem(s) in $LOCAL_DIR" >&2
exit 1
fi
echo "VERIFY_OK: $SHARDS shards, $TOTAL bytes of safetensors in $LOCAL_DIR"
echo "NEXT: build the BF16 reference container with scripts/build-qwen38-bf16-rad.sh"
+49 -31
View File
@@ -46,31 +46,35 @@ FRANK_ARCHON_TP=2
# stays EXACT for single-sequence decode and any step of <= 25 rows — see # stays EXACT for single-sequence decode and any step of <= 25 rows — see
# MAX_SEQS x (1 + SPEC_TOKENS) below. # MAX_SEQS x (1 + SPEC_TOKENS) below.
FRANK_ARCHON_TP_WIRE=wht6 FRANK_ARCHON_TP_WIRE=wht6
# Decode slots. A slot costs KV, never decode; the pool is sized to this # Decode slots. A slot costs KV and pinned RAM, never decode: the cache target is
# contract and nothing more. At 1.8.1 the budget resolver caps the cache at # exactly MAX_SEQS x MAX_MODEL_LEN and the checkpoint store asks (1 + what the
# 556,348 fp8 tokens — 2.8 sessions at 200,000 — so a THIRD full-height # retention policy keeps) per slot. Ordered 2026-10-20: FOUR slots over a pool of at
# conversation answers 400 while /health and every shorter chat work fine # least 700,000 fp8 tokens, rounding UP if the resolver cannot land it exactly. The
# (3 x 50k streams run at 77.6 t/s aggregate). Frank's trade: declare the two # target is therefore 4 x 200,000 = 800,000 tokens and HOST_POOL_MIB decides whether
# slots the engine can hold and let the freed ~1.1 GiB go to the expert plane, # the cache gets it (expert_vram_floor, core/place/planner.cpp:100-104: the card must
# instead of advertising a third slot that only exists at shorter prompts. # keep expert-plane-minus-host-room). The 2-slot shape was the answer to a
FRANK_ARCHON_MAX_SEQS=2 # 365,204-token pool that backed 1.8 full-height sessions; at this pool the third and
# fourth conversations are backed by the cache instead of over-committing it.
FRANK_ARCHON_MAX_SEQS=4
# Served window. The engine treats `kv group tokens >= max-model-len` as a HARD # Served window. The engine treats `kv group tokens >= max-model-len` as a HARD
# limit, and models.yml contextWindow MUST quote this number (over-length is a # limit, and models.yml contextWindow MUST quote this number (over-length is a
# refusal, not a truncation). The pool is sized for seqs x maxlen, not one # refusal, not a truncation). The pool is sized for seqs x maxlen, not one
# sequence. # sequence.
FRANK_ARCHON_MAX_MODEL_LEN=200000 FRANK_ARCHON_MAX_MODEL_LEN=200000
FRANK_ARCHON_PLACEMENT=expert_tiered FRANK_ARCHON_PLACEMENT=expert_tiered
# Pinned host pool for expert rows that do not fit VRAM. Under the budget # Pinned host pool for expert rows that do not fit VRAM, PINNED ONCE PER RANK: the
# resolver this is ALSO the lever that decides which experts stay on the card: # host must pin tp x this number, and the refusal that names it is
# the card's leftover goes to the expert plane first and the KV cache takes the # core/engine_bringup.cpp:250-265. Under the budget resolver this is ALSO the KV
# rest, so growing the pool moves experts off the card and shrinking it spills # lever -- the card keeps expert-plane-minus-this-room, so every MiB added moves one
# them onto VRAM the cache wanted. Under-sizing it is a boot failure (36,864 # MiB of experts off the card and into the cache. Derived from the boot ledger (a
# refused a boot). The ledger's coverage line is the referee, not this file. # 23.73 GiB floor at 44,928 room puts plane + 2 units at 67.60 GiB a rank), 49,152
# Measured, not guessed: 45,568 (PR #30) moved host_pool to 45.09 GiB and left # drops the floor to 19.6 GiB and leaves the cache ~6.9 GiB, which is enough to back
# vram_kv byte-identical at 4.20 GiB / 556,348 tokens on both ranks — extra # the whole 4 x 200,000 target; the leftover returns to the experts. Cost is paid
# host RAM above what the expert plane's footprint needs buys no cache. Back at # twice over: pinned RAM (tp x 48 GiB, against the TTM target below) and expert
# 44,928, the size that boots clean. # residency -- ~3.2 GiB a card of experts now lives in host RAM and is read over the
FRANK_ARCHON_HOST_POOL_MIB=44928 # x8 link when the router picks it. The ledger's coverage line is the referee, not
# this file: if vram_kv comes back under 700,000 tokens, raise this 1024 and redeploy.
FRANK_ARCHON_HOST_POOL_MIB=49152
# VRAM left unclaimed per card (publisher default 96 MiB); the engine's settle # VRAM left unclaimed per card (publisher default 96 MiB); the engine's settle
# floor is built from it. # floor is built from it.
FRANK_ARCHON_GPU_HEADROOM_MIB=96 FRANK_ARCHON_GPU_HEADROOM_MIB=96
@@ -93,16 +97,29 @@ FRANK_ARCHON_SPEC_TOKENS=2
FRANK_ARCHON_MAX_BATCHED_TOKENS=2048 FRANK_ARCHON_MAX_BATCHED_TOKENS=2048
# Step sharing. A prefill chunk that shares a step with decoding sequences holds # Step sharing. A prefill chunk that shares a step with decoding sequences holds
# the device for the chunk's whole time, and every decode row in that step waits # the device for the chunk's whole time, and every decode row in that step waits
# that long for its next token (the census: [[LLM Performance Diagnosis]] §9). # that long for its next token: measured at this shape, decode-only steps 15.5 ms
# `auto` leaves each decode row behind the chunk a row plus its draft window and # against chunk steps 166 ms, which is the harness's ~146 t/s falling to ~11 t/s
# keeps a share for whatever is waiting to be admitted; a number pins the chunk's # while another session prefills (law and census: [[frank-archon]]).
# cap; `off` is the pre-1.4 behaviour this endpoint ran for two eras, where the # `auto` is a PREFILL-SPEED dial, not a decoder dial: it reserves one row plus the
# chunk took the step and admission never ran. Interleave inserts N decode-only # draft window per decoding sequence (3 tokens) and an eighth of the budget per
# steps after a chunk step taken beside decoders: it protects their cadence and # waiting request or waiting prefill, and the source states it will not halve for
# holds a waiting request out of those steps, so it stays 0 until the post-migration # decode rows alone. Live proof: prefill_chunks_capped 5,018 across 62,386 chunk
# decode-vs-prefill buckets say it is wanted. # steps. A number is the only value that applies whenever the chunk shares a step,
# and it must not go under the prefill stager's smallest pass -- 1025 rows here,
# named on the boot line "prefill staging: ... a step of more than 1024 tokens" --
# because below it every routed layer reads its pooled experts synchronously, so
# 512 is a trap and the vendor measures the whole job slower there. Interleave
# inserts N decode-only steps after a chunk step taken beside decoders: it protects
# their cadence and holds a waiting request out of those steps. That is the decoder
# dial. ARMED at 1 on 2026-10-20: at N=1 a stream takes its token from the chunk step
# AND one from the held step, ~2 tokens per 182 ms instead of one per 166 ms; the
# price falls on the prefilling prompt (its chunks lose every held step) and on any
# request still waiting to enter. `--prefill-span` re-funds a prefill only when it is
# ALONE, so it cannot buy this back while decoders run. Meter:
# radiance:engine_steps_held_total must leave zero, and the §9 buckets must move
# before N rises.
FRANK_ARCHON_BUSY_PREFILL_CHUNK=auto FRANK_ARCHON_BUSY_PREFILL_CHUNK=auto
FRANK_ARCHON_PREFILL_INTERLEAVE=0 FRANK_ARCHON_PREFILL_INTERLEAVE=1
# Prefix cache: pinned host memory only. The disk tier was removed outright # Prefix cache: pinned host memory only. The disk tier was removed outright
# (--prefix-cache-dir and --prefix-cache-disk-mib are refusals), so what used to # (--prefix-cache-dir and --prefix-cache-disk-mib are refusals), so what used to
# be restored from NVMe is now re-prefilled. The tier is sized in ENTRIES and one # be restored from NVMe is now re-prefilled. The tier is sized in ENTRIES and one
@@ -118,9 +135,10 @@ FRANK_ARCHON_NGRAM_PLACEMENT=ram
# RESETS pages_limit to half of RAM on every boot; below the plan the load dies # RESETS pages_limit to half of RAM on every boot; below the plan the load dies
# mid-pin with an anonymous allocator error. ttm-preflight.sh raises the knob # mid-pin with an anonymous allocator error. ttm-preflight.sh raises the knob
# itself before refusing by name, so recovery is a boot, not a visit. # itself before refusing by name, so recovery is a boot, not a visit.
# Floor = 2 x HOST_POOL + 48,824 (static weights + arena) + PREFIX_CACHE + MNBT # Floor = 2 x HOST_POOL + 48,824 (static weights + arena) + PREFIX_CACHE + MNBT + the
# per the bats ledger test; at the 44,928 pool that computes to 148,920 MiB. # auto checkpoint store (MAX_SEQS x 10 slots of 111.98 MiB), per the bats ledger test;
FRANK_ARCHON_TTM_MIN_MIB=150000 # at the 49,152 pool with 4 slots that computes to 161,848 MiB.
FRANK_ARCHON_TTM_MIN_MIB=162000
FRANK_ARCHON_TTM_TARGET_MIB=204800 FRANK_ARCHON_TTM_TARGET_MIB=204800
# Escape hatch for experiments. Unset -> no extra argv; the serve script expands # Escape hatch for experiments. Unset -> no extra argv; the serve script expands
# this unquoted on purpose, so keep multi-flag values as one string. # this unquoted on purpose, so keep multi-flag values as one string.
+63 -8
View File
@@ -49,14 +49,21 @@ FRANK_DRAGOON_TOKENIZER_FILES="tokenizer.json chat_template.jinja tokenizer_conf
# stays at the image default, which is what was qualified) ------------------ # stays at the image default, which is what was qualified) ------------------
# Served id. OpenWebUI api_configs model_ids for the :7770 backend MUST name it. # Served id. OpenWebUI api_configs model_ids for the :7770 backend MUST name it.
HALOGEN_MODEL_ID=frank-dragoon HALOGEN_MODEL_ID=frank-dragoon
# PER-REQUEST ceiling: a longer prompt is a 400 naming the limit, never a # PER-REQUEST ceiling, set to the checkpoint's NATIVE window (262,144 — a
# truncation. The harness window must sit ~45k UNDER this (the 400-zone law on # longer prompt is a 400 naming the limit, never a truncation; stretching
# the page) — a window equal to the ceiling makes every long request a 400. # past native would need HALOGEN_ROPE_YARN and is not on plan). The harness
HALOGEN_CTX=200704 # `contextWindow` must sit ~45k under THIS number (the 400-zone law on the
# ONE pool behind both slots, sized so BOTH can carry a maximum-length request # page): with the window at 200,000 the gap is 62,144, which absorbs the
# at once. Pool positions buy concurrency; the per-request window never moves # chars/4 under-count. A window at or near the ceiling makes every long
# with them. Legal band: ctx <= pool <= slots * ctx (the seam enforces it), and # request a guaranteed 400.
# RAM the pool takes is RAM the n-gram table's page cache loses. HALOGEN_CTX=262144
# ONE pool behind both slots. The harness window is 200,000, so 401,408
# still carries TWO maximum harness-length conversations at once
# (2 x 200,000 + one 45,056 budget <= pool + cap headroom); two literal
# ceiling-length requests would queue instead — buying those means raising
# the pool, and RAM the pool takes is RAM the n-gram table's page cache
# loses. Pool positions buy concurrency; the per-request window never moves
# with them. Legal band: ctx <= pool <= slots * ctx (the seam enforces it).
HALOGEN_KV_POOL_POSITIONS=401408 HALOGEN_KV_POOL_POSITIONS=401408
# Two concurrent conversations: the MTP drafter is not slot-locked on this # Two concurrent conversations: the MTP drafter is not slot-locked on this
# engine, so the second slot is capacity, not a speculation tax. # engine, so the second slot is capacity, not a speculation tax.
@@ -80,3 +87,51 @@ HALOGEN_TOP_K=20
# Weights stay pinned: the engine pins by default and only the retired w4b build # Weights stay pinned: the engine pins by default and only the retired w4b build
# honored HALOGEN_FLASH_PIN_TRUNK=0, which this payload rejects. # honored HALOGEN_FLASH_PIN_TRUNK=0, which this payload rejects.
HALOGEN_WEIGHTS_LOCK=1 HALOGEN_WEIGHTS_LOCK=1
# --- NPU lane (XDNA2) -------------------------------------------------------
# HALOGEN_NPU_MODELS is the whole switch: EMPTY or unset = the endpoint exactly
# as it ran before this lane existed (no accel device, no XRT mount, no
# fabric-clock dependency, no /v1/embeddings). Rolling the lane back is blanking
# this one line and re-deploying — nothing else moves.
# Names are the vendor catalog's folder names (`/opt/halogen/npu/models.txt`
# inside the image) and the engine reads a model ONLY at /models/npu/<name>/ —
# HALOGEN_NPU_DIR is gone from the image and setting it is a boot refusal.
# The engine cannot fetch anything here: /models is read-only and
# HALOGEN_DOWNLOAD stays unset so the container opens no outbound connection at
# boot, so scripts/download-frank-dragoon-npu.sh must stage the files first
# (the serve gate and the deployer both refuse by name when a named model is
# missing). FRANK_DRAGOON_NPU_DIR therefore lives INSIDE the payload dir: the
# payload mount is the container's only handle on /models, and runc cannot
# mkdir a nested /models/npu mountpoint through a read-only bind — the lane's
# first boot died on precisely that (2026-10-19). Swap the payload dir and this
# path moves with it.
# Cost: ~1.5 GiB extra pinned host RAM per named model, and a hard dependency on
# the host holding the GPU fabric clock — on Strix Halo the iGPU and the NPU
# together hang the machine (and corrupt NPU results) while FCLK changes speed,
# so halogen refuses to start with this variable set unless the clock is held
# (dankclaw/host/halogen-fabric-clock; the deployer installs and gates it).
HALOGEN_NPU_MODELS=qwen3-embedding-0.6b
FRANK_DRAGOON_NPU_DIR=/home/dank/models/halogen-v2-abliterated/npu
# Node paths and the XRT directory are variables so the bats seam can drive
# these gates on a box with no NPU. Ubuntu's distro XRT (libxrt2 + libxrt-npu2)
# is mounted FILE BY FILE and THREE TIMES EACH — where the image looks for XRT
# (/opt/xilinx/xrt/lib), at its own path (those libraries find each other at
# their install path), and at /opt/xilinx/lib/<multiarch>/ (the NPU child
# searches that third prefix; without it the lane dies naming it and the
# supervisor takes the container down — 2026-10-21 boot loop). The basename of
# this directory IS the multiarch name. The vendor forbids mounting an
# /opt/xilinx/xrt of symlinks into the system dir, which dangle in the container.
FRANK_DRAGOON_DEV_ACCEL=/dev/accel/accel0
FRANK_DRAGOON_XRT_DIR=/usr/lib/x86_64-linux-gnu
# sysfs root the fabric-clock preflight reads (same knob the vendor's
# halogen-fabric-clock status reads; the seam points it at a fixture).
FRANK_DRAGOON_SYSFS=/sys
# Container network. Empty = podman's rootless default (`pasta`): a private
# netns that nothing else can join — podman refuses a second network while the
# primary is pasta, the published port is refused from dankclaw_default
# containers, and host.containers.internal resolves link-local, which
# open-notebook's URL guard refuses to store. Named = the app bridge, so
# open-notebook can dial http://frank-dragoon-halogen:8731 for the NPU
# endpoints. The engine's unauthenticated 8730 stays private either way (it
# binds the container's own loopback); only the API port becomes name-resolvable.
FRANK_DRAGOON_NETWORK=dankclaw_default
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=halogen-flash-server container (Quat3rnion NATIVE v2 abliterated Qwen3.8-Flash-Next, MTP spec decode, 2 slots over ONE 401408-position pool at ctx 200704, vision ON), root systemd unit (dankclaw, frank-dragoon, Strix Halo iGPU) Description=halogen-flash-server container (Quat3rnion NATIVE v2 abliterated Qwen3.8-Flash-Next, MTP spec decode, 2 slots over ONE 401408-position pool at ctx 262144, vision ON), root systemd unit (dankclaw, frank-dragoon, Strix Halo iGPU)
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
+21
View File
@@ -27,6 +27,16 @@
# below stop describing the process you are running. Nothing in this fleet # below stop describing the process you are running. Nothing in this fleet
# pins this container; that is a decision, not an oversight. # pins this container; that is a decision, not an oversight.
# #
# THE DEVICE LAW (since the 2026-10-19 Vulkan cutover). Synthesis computes on
# the gfx1151 iGPU — the SAME card frank-dragoon serves. It is a shared device,
# never a dedicated one, so two numbers matter and neither is what `rocm-smi`
# prints first: the memory the container may use is the RADV heap inside the
# GTT-backed aperture (the ~2 GiB DRM carve-out is not the working set), and the
# timing that matters is dragoon's, measured WITH a synthesis in flight. The
# rollback is one line: set QWENTTS_DEVICE empty, and the entrypoint passes no
# --device at all — the pre-iGPU CPU shape, no rebuild, no restart of anything
# else on the box.
#
# THE REGISTRY LAW (why the unit has an ExecStartPost at all). Registrations live # THE REGISTRY LAW (why the unit has an ExecStartPost at all). Registrations live
# in memory only, so a restart loses them, and re-registration alone does NOT warm # in memory only, so a restart loses them, and re-registration alone does NOT warm
# a voice — the warm pass has to synthesize something. Without it the façade # a voice — the warm pass has to synthesize something. Without it the façade
@@ -103,6 +113,17 @@ QWENTTS_LANG=auto
# round-trip outside it) is what absorbs the queue. # round-trip outside it) is what absorbs the queue.
QWENTTS_MAX_BATCH=1 QWENTTS_MAX_BATCH=1
# The compute device, by ggml device NAME. `Vulkan0` is the gfx1151 iGPU: the
# image carries the Mesa (RADV) ICD and no NVIDIA ICD, so this container's
# Vulkan enumeration has exactly one entry and it is the iGPU. The unit hands
# the container /dev/dri and keeps the render group; on this hybrid host the
# iGPU's render node is renderD129 and renderD128 is the NVIDIA card, which is
# why the unit passes the directory and lets the driver resolve the node.
#
# ROLLBACK: an empty value means no --device flag at all — the CPU shape this
# endpoint shipped with until 2026-10-19. One line, no rebuild.
QWENTTS_DEVICE=Vulkan0
# --- the boot warmer (dankclaw/voiceservice/tts-engine-warm.sh) -------------- # --- the boot warmer (dankclaw/voiceservice/tts-engine-warm.sh) --------------
# These three are read by the warm hook, not by tts-server. They live here # These three are read by the warm hook, not by tts-server. They live here
# because the hook is exec-ed into this container and therefore inherits this # because the hook is exec-ed into this container and therefore inherits this
+2 -2
View File
@@ -7,7 +7,7 @@
# whichever TTS engine wins the engine decision (dankclaw/voiceservice/app.py # whichever TTS engine wins the engine decision (dankclaw/voiceservice/app.py
# is a single stdlib-only file in a locally built image). Consumers — omp's # is a single stdlib-only file in a locally built image). Consumers — omp's
# TTS device, OpenWebUI, open-notebook — address a VOICE BY NAME # TTS device, OpenWebUI, open-notebook — address a VOICE BY NAME
# (voice=narrator1, or the harness' voice_id=narrator1) and never see a path: # (voice=wilwheaton, or the harness' voice_id=wilwheaton) and never see a path:
# a name is real exactly when /voices/<name>/reference.wav exists, and # a name is real exactly when /voices/<name>/reference.wav exists, and
# anything else is a 400 that lists the names that are. # anything else is a 400 that lists the names that are.
# #
@@ -65,7 +65,7 @@ TTS_UPSTREAM_MODEL=voxcpm2
# name still 400s with the list that exists — this covers the absent case only — # name still 400s with the list that exists — this covers the absent case only —
# and the journal marks such a line `(default)` so an unrequested voice never # and the journal marks such a line `(default)` so an unrequested voice never
# reads as a requested one. Any pack directory in /home/dank/voices is legal. # reads as a requested one. Any pack directory in /home/dank/voices is legal.
DEFAULT_VOICE=narrator1 DEFAULT_VOICE=wilwheaton
# CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not # CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not
# configured": the endpoint answers a 500 that says so instead of running # configured": the endpoint answers a 500 that says so instead of running
+13 -1
View File
@@ -1,5 +1,6 @@
# frank-zealot — llama.cpp (PrismML fork build 10777, CUDA) on the RTX 3090, # frank-zealot — llama.cpp (PrismML fork build 10777, CUDA) on the RTX 3090,
# :7771. Payload: Ternary-Bonsai-2-9B PQ2_0 with the MTP head in the file. # :7771. Payload: Ternary-Bonsai-2-9B PQ2_0 with the MTP head in the file, plus
# the refusal-direction adapter applied at load.
# #
# ONE OWNER: shape, payload identity, eras, laws and every measured number # ONE OWNER: shape, payload identity, eras, laws and every measured number
# live in serverAppConfigs-docs/LLM/frank-zealot.md. This file carries the # live in serverAppConfigs-docs/LLM/frank-zealot.md. This file carries the
@@ -13,6 +14,17 @@ MODEL_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-PQ2_0-MTP-Q8_0.g
# Q8_0 IS MANDATORY: the publisher states the BF16 tower can crash llama-server # Q8_0 IS MANDATORY: the publisher states the BF16 tower can crash llama-server
# under concurrent image requests, which is exactly --parallel 3. # under concurrent image requests, which is exactly --parallel 3.
MMPROJ_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf MMPROJ_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf
# Refusal-direction adapter (a rank-1 LoRA, 3.2 MB) applied at load; it edits
# the compute graph, it is not a second model. Scale 1 is the card's "held-out
# refusals 0 %" point. LORA_FILE="" is the documented opt-out — the plain pack
# again, no rebuild — and LORA_SCALE=0 is the same result through the flag.
# PROVENANCE ASYMMETRY: the adapter's own prism.abliterate.base_gguf names
# Ternary-Bonsai-2-9B-PTQ1_0.gguf as the pack its direction was fitted against,
# which is NOT this packing and is not on this host. The tensor names match, so
# it applies; the strength was calibrated on a sibling quantization, so the
# effect is proven by the endpoint's behaviour, never by the metadata.
LORA_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-Uncensored-LoRA.gguf
LORA_SCALE=1
# Served id — the harness/family name, not a fact about the payload. # Served id — the harness/family name, not a fact about the payload.
# Id-keyed consumers: OpenWebUI (model row, access_grant, api_configs), models.yml. # Id-keyed consumers: OpenWebUI (model row, access_grant, api_configs), models.yml.
ALIAS=frank-zealot ALIAS=frank-zealot
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=llama.cpp (PrismML build 10777) Ternary-Bonsai-2-9B PQ2_0-MTP GGUF (qwen35 hybrid, 2x262144 slots over one 524288 pool, unquantized f16 K+V, MTP off, mmproj Q8_0 vision) on the RTX 3090 (dankclaw, frank-zealot) Description=llama.cpp (PrismML build 10777) Ternary-Bonsai-2-9B PQ2_0-MTP GGUF + refusal adapter (qwen35 hybrid, 3x200704 slots over one 602112 pool, f16 K + q8_0 V, MTP armed, mmproj Q8_0 vision) on the RTX 3090 (dankclaw, frank-zealot)
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""gpu-exporter: unified per-GPU metrics for a mixed AMD/NVIDIA host.
Emits the `gpu_*` Prometheus families the host-system dashboard graphs:
gpu_utilization_percent gpu_memory_used_bytes gpu_memory_total_bytes
gpu_temperature_celsius gpu_power_watts
each labelled {gpu, vendor, model}. The scrape job adds `host`.
Two sources, both host-native — no container, no GPU passthrough:
* AMD (R9700s, the 8060S iGPU, the Raphael display GPU): the amdgpu sysfs tree.
Utilization comes ONLY from gpu_busy_percent — amd-smi reports gfx_busy_inst
as N/A on gfx1201 (verified 2026-10-20). VRAM is already bytes, temperature
is milli-degrees, power is micro-watts. Crucially power1_average is EMPTY on
the R9700s, so those cards legitimately have no gpu_power_watts line; a
missing file becomes an ABSENT metric, never a fabricated 0 that a dashboard
would plot as a real reading.
* NVIDIA (the RTX 3090 on Dankclaw): one `nvidia-smi --query-gpu` CSV. On
Dankserver there is an nvidia-smi binary but no NVIDIA GPU, so the command
fails; that is tolerated and simply yields no NVIDIA series.
Default mode prints one scrape to stdout and exits (the test harness, and any
`service ... --oneshot` one-off). `--serve` binds the HTTP endpoint for
Prometheus; it binds EXPLICITLY (loopback + the host LAN address), never a
wildcard, matching node-exporter's posture on these boxes.
"""
import argparse
import glob
import os
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
FAMILIES = [
("gpu_utilization_percent", "gauge", "GPU busy time as a percentage of wall time."),
("gpu_memory_used_bytes", "gauge", "Device memory in use, in bytes."),
("gpu_memory_total_bytes", "gauge", "Device memory capacity, in bytes."),
("gpu_temperature_celsius", "gauge", "Package temperature in degrees Celsius."),
("gpu_power_watts", "gauge", "Board power draw in watts."),
]
DEFAULT_NVIDIA_CMD = (
"nvidia-smi --query-gpu=index,name,utilization.gpu,memory.used,"
"memory.total,temperature.gpu,power.draw --format=csv,noheader,nounits"
)
def _read(path):
try:
with open(path) as fh:
return fh.read().strip()
except OSError:
return ""
def _pci_slot(device_dir):
uevent = _read(os.path.join(device_dir, "uevent"))
for line in uevent.splitlines():
if line.startswith("PCI_SLOT_NAME="):
return line.split("=", 1)[1]
return ""
def _pci_id(device_dir):
uevent = _read(os.path.join(device_dir, "uevent"))
for line in uevent.splitlines():
if line.startswith("PCI_ID="):
return "amd:" + line.split("=", 1)[1].lower()
return "amd"
def collect_amd(sys_dir):
for card in sorted(glob.glob(os.path.join(sys_dir, "card[0-9]*"))):
device = os.path.join(card, "device")
busy = _read(os.path.join(device, "gpu_busy_percent"))
if busy == "":
continue # not an amdgpu compute node (e.g. a render node w/o metrics)
labels = {"gpu": _pci_slot(device) or os.path.basename(card),
"vendor": "amd", "model": _pci_id(device)}
yield "gpu_utilization_percent", labels, busy
for name, leaf in (("gpu_memory_used_bytes", "mem_info_vram_used"),
("gpu_memory_total_bytes", "mem_info_vram_total")):
v = _read(os.path.join(device, leaf))
if v != "":
yield name, labels, v
temp = ""
for hwmon in sorted(glob.glob(os.path.join(device, "hwmon", "hwmon*", "temp1_input"))):
temp = _read(hwmon)
if temp != "":
break
if temp != "":
yield "gpu_temperature_celsius", labels, str(int(temp) / 1000.0)
power = _read(os.path.join(device, "power1_average"))
if power != "": # empty on the R9700s -> metric absent, not zero
yield "gpu_power_watts", labels, str(int(power) / 1e6)
def collect_nvidia(cmd):
if not cmd:
return
try:
out = subprocess.run(cmd, shell=True, capture_output=True, text=True,
timeout=10, check=True).stdout
except (subprocess.SubprocessError, OSError):
return # no NVIDIA GPU / no driver: tolerate, emit nothing
for line in out.splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) < 7:
continue
idx, name, util, mused, mtotal, temp, power = parts[:7]
labels = {"gpu": idx, "vendor": "nvidia", "model": name}
yield "gpu_utilization_percent", labels, util
yield "gpu_memory_used_bytes", labels, str(int(float(mused)) * 1048576)
yield "gpu_memory_total_bytes", labels, str(int(float(mtotal)) * 1048576)
if temp not in ("", "[N/A]"):
yield "gpu_temperature_celsius", labels, temp
if power not in ("", "[N/A]"):
yield "gpu_power_watts", labels, power
def collect(sys_dir, nvidia_cmd):
samples = {}
for source in (collect_amd(sys_dir), collect_nvidia(nvidia_cmd)):
for name, labels, value in source:
try:
value = str(float(value)) # normalise; drop trailing junk
except ValueError:
continue
samples.setdefault(name, []).append((labels, value))
return samples
def _fmt(value):
f = float(value)
if f == int(f) and abs(f) < 1e15:
return str(int(f))
return repr(f)
def render(samples):
out = []
for name, kind, help_text in FAMILIES:
out.append("# HELP %s %s" % (name, help_text))
out.append("# TYPE %s %s" % (name, kind))
for labels, value in sorted(samples.get(name, []), key=lambda x: sorted(x[0].items())):
lset = ",".join('%s="%s"' % (k, v) for k, v in sorted(labels.items()))
out.append("%s{%s} %s" % (name, lset, _fmt(value)))
return "\n".join(out) + "\n"
class Handler(BaseHTTPRequestHandler):
sys_dir = "/sys/class/drm"
nvidia_cmd = DEFAULT_NVIDIA_CMD
def do_GET(self): # noqa: N802 (http.server API)
if self.path.split("?")[0] not in ("/metrics", "/"):
self.send_error(404)
return
body = render(collect(self.sys_dir, self.nvidia_cmd)).encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain; version=0.0.4")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
def main(argv):
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--amdgpu-sysfs", default="/sys/class/drm")
ap.add_argument("--nvidia-cmd", default=DEFAULT_NVIDIA_CMD)
ap.add_argument("--serve", action="store_true", help="serve /metrics instead of one-shot")
ap.add_argument("--port", type=int, default=9401)
ap.add_argument("--bind", action="append", default=None,
help="explicit bind address (repeatable); env GPU_EXPORTER_BINDS "
"comma-list if unset. NEVER a wildcard.")
args = ap.parse_args(argv)
if not args.serve:
sys.stdout.write(render(collect(args.amdgpu_sysfs, args.nvidia_cmd)))
return 0
binds = args.bind
if not binds:
env = os.environ.get("GPU_EXPORTER_BINDS", "")
binds = [b.strip() for b in env.split(",") if b.strip()] or ["127.0.0.1"]
Handler.sys_dir = args.amdgpu_sysfs
Handler.nvidia_cmd = args.nvidia_cmd
servers = []
for b in binds:
if b in ("0.0.0.0", "::", "*"):
sys.stderr.write("refusing wildcard bind %s\n" % b)
return 2
servers.append(ThreadingHTTPServer((b, args.port), Handler))
sys.stderr.write("gpu-exporter serving :%d on %s\n" % (args.port, ",".join(binds)))
# EVERY socket must be served: a listen()ed-but-never-accept()ed bind looks
# healthy in `ss` yet times out every scrape once its SYN backlog fills —
# and the loopback deploy gate cannot see it. One thread per extra bind.
for s in servers[1:]:
threading.Thread(target=s.serve_forever, daemon=True).start()
servers[0].serve_forever()
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+35
View File
@@ -0,0 +1,35 @@
# Systemd USER unit: gpu-exporter (both Dankclaw and Dankserver).
#
# Install path: ~/.config/systemd/user/gpu-exporter.service
# Installed and enabled by deploy_gpu_exporter in scripts/deploy-remote.sh.
#
# Per-GPU metrics (utilization / VRAM / temperature / power) for the mixed
# AMD + NVIDIA fleet, scraped by Prometheus. Like signal-relay it is a plain
# user unit, not a quadlet: it runs python3 straight from the checkout (a pull
# updates it with no copy step), a hand-written user unit can be enabled where
# the podman v1 generator's units cannot, and with Linger=yes for dank it starts
# at boot. It reads the host's own cgroup/drm sysfs and shells to nvidia-smi, so
# it must run on the host — a container would need GPU/DRM passthrough for
# nothing gained.
#
# BINDING. The base unit serves loopback only. deploy_gpu_exporter writes a
# drop-in adding THIS host's LAN address (the same two-address posture
# node-exporter and cadvisor use), so the endpoint is never a wildcard. It is
# unauthenticated metrics on the LAN, identical to node-exporter :9100 and
# cadvisor :8080 — Prometheus reaches same-host via host.containers.internal and
# the Dankserver instance over the LAN from Dankclaw.
[Unit]
Description=GPU metrics exporter (amdgpu sysfs + nvidia-smi)
After=network-online.target
Wants=network-online.target
[Service]
# Loopback by default; the deploy drop-in appends the host LAN address.
Environment=GPU_EXPORTER_BINDS=127.0.0.1
ExecStart=/usr/bin/python3 /home/dank/serverAppConfigs/scripts/gpu-exporter.py --serve
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
+418
View File
@@ -0,0 +1,418 @@
#!/usr/bin/env python3
"""grams-to-imatrix.py — the engine's own calibration recording, re-voiced as a
llama.cpp importance matrix for `rad-convert --imatrix F`. RUNS ON DANKSERVER
beside the recording. Python 3 stdlib only: numpy must NOT be required, because
this runs in the plain container next to a 335 GiB checkpoint, not in a venv.
WHY. `rad-convert` takes its per-column activation second moments and its
expert-popularity profile from ONE input, an imatrix (core/format/imatrix.h).
Only the GGUF form carries a per-expert dimension — with the legacy flat form,
or with none, every expert in a layer is weighted by the layer's aggregate,
which is exactly the wrong thing on a 512-expert MoE. The calibration tap has
already measured everything; this tool only re-voices it, so the imatrix and the
protected-expert census (derive-protected-experts.py) are the SAME recording:
one measurement, two consumers, neither able to disagree about which experts are
hot.
INPUT. RADGRAM2 files in a RADIANCE_CALIB_DIR output directory
(arch/common/rad_arch.h, calib_write). Header law, same parse as
scripts/derive-protected-experts.py — 32 bytes, `<8sIIQI`: magic `RADGRAM2`,
u32 k, u32 rank, u64 rows, u32 domain, a pad word, then k*k f32 row-major. Two
kinds of name, both GGUF-style, both with the checkpoint's GLOBAL expert
numbering (rad_block_moe_fp8.h: ge = expert_base + e):
gram.r<rank>.blk.L.ffn_down_exps.bin the layer's pooled gram
gram.r<rank>.blk.L.ffn_down_exps.<e>.bin expert e's own gram
`rows` is that file's routing/token count and the matrix is Σ x xᵀ over those
rows, so **the diagonal is exactly the per-column activation second moment an
imatrix wants**, and `rows` is the count the reader hands back from `count()`.
Every off-diagonal term is dropped on purpose: the GGUF format has nowhere to
put a full matrix, and the quantiser asks for columns.
OUTPUT, per layer L (core/format/imatrix.h: names WITHOUT `.weight`, a MoE
layer's experts folded into ONE entry, so expert 9 is slice 9 of
`blk.L.ffn_down_exps`):
blk.L.ffn_down_exps in_sum2 [k, n_expert] expert-major, counts [1, n_expert]
blk.L.ffn_gate_up_exps the SAME matrices at the SAME offsets. A routed expert's
gate_up input is the same hidden-state rows as its down
input, so the second moment over them is identical and
so is the routing count. Two tensor infos over one
payload: the reader maps each half independently
(imatrix.cpp joins `<base>.in_sum2` with `<base>.counts`
by base name), and copying ~200 MB to state one fact
twice buys no correctness.
blk.L.ssm_inz [k] and [1], from the pooled gram: that row's input is
the same hidden state, so its columns take the pooled
diagonal and its count is the layer's pooled rows.
Then STOP. Nothing whose input width is not the gram's k — no ssm_out, no
attn_output, no mtp.*, no ple_ngram. importance_for() answers false when the
entry's cols do not equal the weight's width and the convert proceeds without
it: an entry the reader silently ignores is a lie in the artifact, worse than
an absent one. The corollary to check before trusting the two hidden-width
entries: the tap writes the gate_up gram at g.n_embd and the down gram at the
expert width, so the gram's k must equal the checkpoint's hidden width (its
config.json). Where they differ, gate_up/ssm_inz are inert, not wrong.
INHERITANCE. An expert with no own gram carries the layer's POOLED diagonal, and
its count stays its own routing rows — the rule the engine itself applies when it
picks a Hessian (libquant/lq_hess.h, calib_resolve: an expert's own gram only
once its rank files hold kOwnRowsPerK * K rows; below that a fit to so few rows
is worse than round-to-nearest, and those experts carry a fraction of a percent
of the routing). The count never inherits: popularity is `count`'s job. The count
of inherited experts is reported on stderr.
RANK FOLDING. TP=2 writes gram.r0.* and gram.r1.* with GLOBAL expert numbering,
so the ranks describe DISJOINT experts and the fold is a UNION into the one
[k, n_expert] entry. A pooled gram is the opposite case: every rank summed the
same stem over its own rows, so the fold is a SUM, rows included (calib_sum,
calib_rows). The same global expert in two per-expert files is legal only when
the numbers agree exactly; otherwise the recording is corrupt and the entry would
be whichever file sorted last, so refuse.
GGUF v3 (the only version imatrix.cpp accepts — version != 3 is a refusal):
`general.alignment` u32, `imatrix.chunk_size` u32, `imatrix.chunk_count` u32,
`imatrix.datasets` array of strings; tensors f32 (ggml type 0) with offsets
relative to the aligned data section. Every tensor offset is padded up to
`general.alignment` and the data section itself starts aligned. That is the
classic hand-rolled-writer bug: an unaligned offset makes the reader's extent
arithmetic point into the middle of the previous tensor's floats, and it reads
them without complaint.
chunk_count DERIVATION. The corpus row count is the LARGEST single-layer pooled
row total, not a sum over layers: every layer's tap accumulated over the whole
corpus, so adding the layers counts each token once per layer and inflates the
figure by the layer count. chunk_count = that row total // chunk_size, the
"chunks of chunk_size" line imatrix.cpp prints for itself.
REFUSALS, each exit 2 with a message naming what is missing:
NO_GRAMS the directory holds no RADGRAM2 down-projection gram
NOT_RADGRAM2 a gram whose magic is not RADGRAM2
SHORT a gram whose length is not 32 + k*k*4
NO_POOLED a layer with expert grams and no pooled gram to inherit from
K_MISMATCH a layer whose files disagree about k (rank pooled grams, or an
expert gram against its layer's pooled gram)
EXPERT_CONFLICT a global expert recorded twice with different numbers
EXISTS the -o path already holds a file (the trash law: move it)
CHUNK_COUNT the derived chunk count does not fit the u32 metadata field
USAGE. grams-to-imatrix.py <calib-dir> -o <out.gguf>
[--datasets NAME,...] [--chunk-size N] [--min-rows N] [-v]
"""
import argparse
import os
import re
import struct
import sys
MAGIC = b"RADGRAM2"
HDR = 32 # 8s I I Q I + pad, rad_arch.h
KOWN_ROWS_PER_K = 4 # libquant/lq_hess.h kOwnRowsPerK
ALIGNMENT = 32 # general.alignment, and the pad unit
POOLED_RE = re.compile(r"^gram\.r(\d+)\.blk\.(\d+)\.ffn_down_exps\.bin$")
EXPERT_RE = re.compile(r"^gram\.r(\d+)\.blk\.(\d+)\.ffn_down_exps\.(\d+)\.bin$")
GT_U32, GT_STR, GT_ARR = 4, 8, 9
GGML_F32 = 0
def refuse(msg):
sys.stderr.write("REFUSED %s\n" % msg)
sys.exit(2)
def read_gram(path):
"""(k, rank, rows, domain, diagonal) from one RADGRAM2 file."""
try:
with open(path, "rb") as f:
raw = f.read()
except OSError as e:
refuse("UNREADABLE %s: %s" % (path, e))
if len(raw) < HDR:
refuse("SHORT %s: %d bytes, under the %d-byte RADGRAM2 header"
% (path, len(raw), HDR))
magic, k, rank, rows, domain = struct.unpack_from("<8sIIQI", raw, 0)
if magic != MAGIC:
refuse("NOT_RADGRAM2 %s: magic %r, expected b'RADGRAM2'" % (path, magic))
if k <= 0:
refuse("SHORT %s: header claims k=%d, which describes no matrix" % (path, k))
need = HDR + k * k * 4
if len(raw) != need:
refuse("SHORT %s: %d bytes, expected %d (32 + k*k*4 for k=%d)"
% (path, len(raw), need, k))
# The diagonal only, read by stride out of the row-major matrix.
diag = [struct.unpack_from("<f", raw, HDR + (i * k + i) * 4)[0]
for i in range(k)]
return k, rank, rows, domain, diag
def load_dir(d, verbose):
"""{layer: {"pooled": gram|None, "experts": {global e: gram}}} + rotated count.
Files outside the ffn_down_exps stem are not ours to fold: the tap names a
pooled gram after every projection it taps, and those carry other widths."""
if not os.path.isdir(d):
refuse("NO_GRAMS %s: not a directory" % d)
layers, rotated = {}, 0
for name in sorted(os.listdir(d)):
m = POOLED_RE.match(name) or EXPERT_RE.match(name)
if not m:
continue
path = os.path.join(d, name)
if not os.path.isfile(path):
continue
pooled = bool(POOLED_RE.match(name))
layer = int(m.group(2))
k, rank, rows, domain, diag = read_gram(path)
if domain != 0:
rotated += 1
L = layers.setdefault(layer, {"pooled": None, "experts": {}})
if pooled:
P = L["pooled"]
if P is None:
L["pooled"] = {"k": k, "rows": rows, "diag": diag, "paths": [path]}
elif P["k"] != k:
refuse("K_MISMATCH layer %d: pooled grams %s (k=%d) and %s (k=%d)"
" disagree about the width"
% (layer, P["paths"][0], P["k"], path, k))
else:
# calib_sum: each rank summed the same stem over its own rows.
P["rows"] += rows
P["paths"].append(path)
for i in range(k):
P["diag"][i] += diag[i]
if verbose:
sys.stderr.write("pooled layer %d: %s k=%d rows=%d\n"
% (layer, name, k, rows))
continue
e = int(m.group(3))
own = L["experts"].get(e)
if own is None:
L["experts"][e] = {"k": k, "rows": rows, "diag": diag,
"path": path, "ranks": [rank]}
if verbose:
sys.stderr.write("expert layer %d r%d e%d: rows=%d\n"
% (layer, rank, e, rows))
continue
if own["rows"] != rows or own["diag"] != diag:
refuse("EXPERT_CONFLICT layer %d expert %d: %s (rows=%d) and %s"
" (rows=%d) record the same global expert with different"
" numbers" % (layer, e, own["path"], own["rows"], path, rows))
own["ranks"].append(rank)
sys.stderr.write("note: layer %d expert %d recorded identically by rank %s"
" and %s; folded once\n"
% (layer, e, own["path"], name))
if not layers:
refuse("NO_GRAMS %s: no RADGRAM2 gram.r<rank>.blk.<L>.ffn_down_exps"
"[.<expert>].bin files in the directory" % d)
return layers, rotated
def build_entries(layers, min_rows, verbose):
"""([ (name, dims, payload|None, key) ], census, layers emitted)."""
entries = []
census = {"inherited": 0, "experts": 0, "routing": 0, "rows": 0, "layers": 0}
for layer in sorted(layers):
P, E = layers[layer]["pooled"], layers[layer]["experts"]
if E and P is None:
refuse("NO_POOLED layer %d: %d expert gram(s) but no pooled gram (an"
" expected gram.r<rank>.blk.%d.ffn_down_exps.bin), so an expert"
" under the row threshold would have nothing to inherit"
% (layer, len(E), layer))
if P is None:
continue
k = P["k"]
census["layers"] += 1
census["rows"] = max(census["rows"], P["rows"])
dn = "blk.%d.ffn_down_exps" % layer
# A staging key names ONE payload. A gate_up entry reuses the down
# layer's bytes by carrying the same key with no payload of its own;
# in_sum2 and counts are different bytes and must never share a key.
ksum, kcnt = (layer, "sum2"), (layer, "counts")
kpgm, kpcnt = (layer, "pooled_sum2"), (layer, "pooled_counts")
# One entry holds one width: an expert gram recorded at another k would
# inherit (or be inherited by) a diagonal of the wrong length, and the
# reader would then answer `cols` from whichever file set dims[0].
for e in sorted(E):
if E[e]["k"] != k:
refuse("K_MISMATCH layer %d expert %d: %s holds k=%d and this"
" layer's pooled gram is k=%d" % (layer, e, E[e]["path"],
E[e]["k"], k))
if not E:
# No per-expert recording for this layer: emit the dense form
# ([k], [1]) rather than 512 identical slices, which would claim a
# per-expert measurement that was never taken.
entries.append((dn + ".in_sum2", [k],
struct.pack("<%df" % k, *P["diag"]), ksum))
entries.append((dn + ".counts", [1],
struct.pack("<f", float(P["rows"])), kcnt))
sys.stderr.write("note: layer %d has no per-expert grams; %s is"
" dense over the pooled gram\n" % (layer, dn))
else:
floor = min_rows if min_rows > 0 else KOWN_ROWS_PER_K * k
n_expert = max(E) + 1
sum2, counts = bytearray(), bytearray()
for e in range(n_expert):
own = E.get(e)
if own is not None and own["rows"] >= floor:
src = own["diag"]
rows = own["rows"]
else:
src = P["diag"]
rows = own["rows"] if own is not None else 0
census["inherited"] += 1
sum2 += struct.pack("<%df" % k, *src)
counts += struct.pack("<f", float(rows))
census["experts"] += 1
census["routing"] += rows
if min(E) != 0:
sys.stderr.write("note: layer %d has no gram for experts 0..%d;"
" those slices carry the pooled gram at count 0\n"
% (layer, min(E) - 1))
entries.append((dn + ".in_sum2", [k, n_expert], bytes(sum2), ksum))
entries.append((dn + ".counts", [1, n_expert], bytes(counts), kcnt))
gu = "blk.%d.ffn_gate_up_exps" % layer
entries.append((gu + ".in_sum2", [k, n_expert], None, ksum))
entries.append((gu + ".counts", [1, n_expert], None, kcnt))
if verbose:
sys.stderr.write("layer %d: k=%d n_expert=%d pooled_rows=%d"
" own-gram floor=%d\n"
% (layer, k, n_expert, P["rows"], floor))
inz = "blk.%d.ssm_inz" % layer
entries.append((inz + ".in_sum2", [k],
struct.pack("<%df" % k, *P["diag"]), kpgm))
entries.append((inz + ".counts", [1],
struct.pack("<f", float(P["rows"])), kpcnt))
return entries, census
def enc_str(v):
b = v.encode("utf-8")
return struct.pack("<Q", len(b)) + b
def write_gguf(path, entries, datasets, chunk_size, chunk_count):
"""Write GGUF v3 and return its size in bytes.
One payload per key: entries that share a key share the bytes and the
offset, so a second tensor info states the same fact without copying it."""
kv = bytearray()
kv += enc_str("general.alignment") + struct.pack("<I", GT_U32) \
+ struct.pack("<I", ALIGNMENT)
kv += enc_str("imatrix.chunk_size") + struct.pack("<I", GT_U32) \
+ struct.pack("<I", chunk_size)
kv += enc_str("imatrix.chunk_count") + struct.pack("<I", GT_U32) \
+ struct.pack("<I", chunk_count)
kv += enc_str("imatrix.datasets") + struct.pack("<I", GT_ARR) \
+ struct.pack("<I", GT_STR) + struct.pack("<Q", len(datasets))
for d in datasets:
kv += enc_str(d)
head = bytearray()
head += b"GGUF" + struct.pack("<I", 3)
head += struct.pack("<Q", len(entries)) + struct.pack("<Q", 4)
head += kv
payloads = {}
for name, _dims, payload, key in entries:
if payload is None:
continue
prev = payloads.get(key)
if prev is not None and prev != payload:
# Two different payloads on one key would ship whichever was
# appended last while BOTH tensor infos pointed at it: the counts
# payload landing over the in_sum2 payload is exactly how a
# hand-rolled GGUF writer loses half its data without an error.
refuse("INTERNAL %s: two different payloads share one staging key"
% name)
payloads.setdefault(key, payload)
infos = bytearray()
offsets, order, size = {}, [], 0
for name, dims, payload, key in entries:
if key not in payloads:
refuse("INTERNAL %s: no payload staged for its key" % name)
if key not in offsets:
offsets[key] = (size + ALIGNMENT - 1) // ALIGNMENT * ALIGNMENT
size = offsets[key] + len(payloads[key])
order.append(key)
infos += enc_str(name) + struct.pack("<I", len(dims))
for d in dims:
infos += struct.pack("<Q", d)
infos += struct.pack("<I", GGML_F32) + struct.pack("<Q", offsets[key])
body = bytes(head) + bytes(infos)
data_start = (len(body) + ALIGNMENT - 1) // ALIGNMENT * ALIGNMENT
with open(path, "wb") as f:
f.write(body)
f.write(b"\0" * (data_start - len(body)))
# The offsets in the tensor infos are the file's law, not a hint: each
# payload must land at its own padded offset. Writing them back to back
# instead leaves every reader that trusts the info — imatrix.cpp does —
# reading its neighbour's floats: plausible values, no error, wrong
# quantisation.
here = data_start
for key in order:
f.write(b"\0" * (data_start + offsets[key] - here))
f.write(payloads[key])
here = data_start + offsets[key] + len(payloads[key])
return f.tell()
def main():
ap = argparse.ArgumentParser(
description="Fold a radiance calibration recording into a GGUF v3 imatrix.")
ap.add_argument("calib_dir")
ap.add_argument("-o", "--out", required=True)
ap.add_argument("--datasets", default="",
help="comma-separated names for imatrix.datasets")
ap.add_argument("--chunk-size", type=int, default=4096,
help="rows per chunk for imatrix.chunk_size (default 4096)")
ap.add_argument("--min-rows", type=int, default=0,
help="own-gram row threshold (default 4*k, the engine's"
" kOwnRowsPerK law)")
ap.add_argument("-v", "--verbose", action="store_true")
a = ap.parse_args()
if os.path.exists(a.out):
# The repo's deletion law: an artifact that exists is moved to trash by
# a human, never silently overwritten by a re-run of the tool that made
# it, so a second convert cannot destroy the imatrix the first one fed.
refuse("EXISTS %s: an imatrix is already there; move it to"
" .AGENTTRASH/ or name another -o" % a.out)
if a.chunk_size <= 0:
refuse("BAD_CHUNK_SIZE --chunk-size %d is not positive" % a.chunk_size)
layers, rotated = load_dir(a.calib_dir, a.verbose)
entries, census = build_entries(layers, a.min_rows, a.verbose)
if not entries:
refuse("NO_GRAMS %s: no layer holds a pooled gram to fold" % a.calib_dir)
if rotated:
sys.stderr.write("note: %d gram(s) recorded in a rotated domain: their"
" diagonal is the rotated activation's second moment,"
" not the model's own\n" % rotated)
chunk_count = census["rows"] // a.chunk_size
if chunk_count > 0xFFFFFFFF:
# imatrix.chunk_count is a u32 in the reader's grammar, so there is no
# legal way to record a bigger one. Reaching this means either a chunk
# size picked for a far larger run or a row total read out of the wrong
# header offset — rows is a u64 and a u32 parse is off by 2**32 — and
# the reader would trust whatever number landed in the field.
refuse("CHUNK_COUNT %d = %d rows // %d does not fit the u32"
" imatrix.chunk_count field; raise --chunk-size"
% (chunk_count, census["rows"], a.chunk_size))
nbytes = write_gguf(a.out, entries, [d for d in a.datasets.split(",") if d],
a.chunk_size, chunk_count)
sys.stderr.write("IMATRIX_OK entries=%d layers=%d experts=%d inherited=%d"
" rows=%d routing=%d chunks=%d/%d bytes=%d out=%s\n"
% (len(entries), census["layers"], census["experts"],
census["inherited"], census["rows"], census["routing"],
chunk_count, a.chunk_size, nbytes, a.out))
if __name__ == "__main__":
main()
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# kld-gate-qwen38.sh — RUNS ON DANKSERVER (or anywhere the reports are). The campaign's
# acceptance gate: three KLD reports against ONE reference recording, one verdict.
#
# The reference is the BF16 container built from the vendor checkpoint (run-qwen38-bf16.sh
# kldref, in the same drained window that records the calibration grams). Every scored
# container is measured against that one recording, so the KL scale runs from 0 = the
# checkpoint itself downward in faithfulness. Three containers ride it:
#
# fp8.json the published FP8-native release — the achievable end of the interval
# iq4r.json the payload frank-archon serves — the bar to beat
# cand.json the arm under test — the subject
#
# The FP8 number is what makes the interval absolute instead of relative: "half the gap"
# is half of the distance between the 4-bit payload and the near-lossless release, not half
# of an invented distance to the checkpoint. That is also why the FP8 payload is FETCHED and
# scored, never converted: it is a second opinion about what this checkpoint tolerates, not a
# step of the campaign. It lands LAST (disk sequencing) — by then the 335 GiB bf16 container
# is gone, so its 172.8 GiB fits.
#
# The law, in order of severity, on the mean KL of one slice (--slice all | a corpus source):
# 1. KL(fp8) < KL(iq4r) the anchors are ordered. If the near-lossless
# release does NOT sit closer to the checkpoint than
# the 4-bit payload, the corpus, the tokenizer or the
# boot shape is wrong and no arm number means anything.
# 2. KL(fp8) < KL(cand) < KL(iq4r) the candidate lies strictly inside the interval —
# outside it, something is mis-measured, not better
# 3. KL(cand) <= KL(iq4r) - R x gap it closes at least R (--recover, default 0.5) of the
# gap = KL(iq4r) - KL(fp8): at least half the way from
# what is served toward the release (agreed 2026-10-27)
# 4. top-1(cand) >= top-1(iq4r) the agreement the harness keys on does not fall
#
# Booting the containers is NOT this script's job: `run-qwen38-bf16.sh score RAD OUT.json`
# writes the reports and owns the single engine argv (same pin, --tp 2, --kv-cache-dtype fp8,
# same step size, --deterministic, no speculation) that makes the three numbers comparable.
# This script is arithmetic, so it re-runs against old reports and is testable without a GPU.
#
# Usage: kld-gate-qwen38.sh FP8.json IQ4R.json CAND.json [--slice NAME] [--recover F]
set -uo pipefail
[ "$#" -ge 3 ] || { echo "usage: kld-gate-qwen38.sh FP8.json IQ4R.json CAND.json [--slice NAME] [--recover F]" >&2; exit 2; }
python3 - "$@" <<'PY'
import json, sys
a = sys.argv[1:]
opts, files = [], []
i = 0
while i < len(a):
if a[i].startswith("--"):
opts += [a[i], a[i + 1]]
i += 2
else:
files.append(a[i])
i += 1
if len(files) != 3:
sys.exit("usage: kld-gate-qwen38.sh FP8.json IQ4R.json CAND.json [--slice NAME] [--recover F]")
o = dict(zip(opts[0::2], opts[1::2]))
slice_ = o.get("--slice", "all")
recover = float(o.get("--recover", 0.5))
if not 0.0 < recover <= 1.0:
sys.exit(f"--recover must be in (0,1], got {recover}")
def die(msg):
print(msg, file=sys.stderr)
raise SystemExit(2)
def read(path):
try:
d = json.load(open(path))
except Exception as e:
die(f"cannot read the KLD report {path}: {e}")
if slice_ == "all":
g = d.get("all")
else:
g = (d.get("by_source") or {}).get(slice_)
if g is None:
die(f"{path} has no slice {slice_!r}; it has: "
f"{', '.join(sorted(d.get('by_source') or {})) or '(none)'}")
if g is None or "kld" not in g or "top1_agreement" not in g:
die(f"{path} has no {slice_!r} summary — wrong report version?")
return d, g
rows = []
for label, path in zip(("fp8", "iq4r", "cand"), files):
d, g = read(path)
rows.append((label, path, d.get("model", "?"), g["kld"]["mean"], g["kld"]["p99_9"],
g["top1_agreement"], g["positions"]))
print(f"reference: BF16 container; slice: {slice_}")
print(f"{'':6} {'model':38} {'KL mean':>10} {'KL p99.9':>10} {'top-1':>8} positions")
for label, path, model, kl, p999, top1, n in rows:
print(f"{label:6} {str(model)[:38]:38} {kl:10.6f} {p999:10.4f} {100*top1:7.2f}% {n}")
r = {label: (kl, top1) for label, _p, _m, kl, _q, top1, _n in rows}
kl_fp8, kl_iq4r, kl_cand = r["fp8"][0], r["iq4r"][0], r["cand"][0]
t_iq4r, t_cand = r["iq4r"][1], r["cand"][1]
gap = kl_iq4r - kl_fp8
ok = True
def law(n, text, passed):
global ok
ok = ok and passed
print(f" [{'PASS' if passed else 'FAIL'}] {n}. {text}")
law(1, f"anchors ordered: fp8 {kl_fp8:.6f} < iq4r {kl_iq4r:.6f} (gap {gap:.6f})",
gap > 0)
law(2, f"candidate inside the interval: {kl_fp8:.6f} < {kl_cand:.6f} < {kl_iq4r:.6f}",
kl_fp8 < kl_cand < kl_iq4r)
need = kl_iq4r - recover * gap
law(3, f"candidate closes {100*max((kl_iq4r - kl_cand)/gap, 0):.1f}% of the "
f"{gap:.6f} gap (needs {100*recover:.0f}%, i.e. KL <= {need:.6f})"
if gap > 0 else "gap is not positive; cannot score recovery",
gap > 0 and kl_cand <= need)
law(4, f"candidate top-1 {100*t_cand:.2f}% >= served iq4r top-1 {100*t_iq4r:.2f}%",
t_cand >= t_iq4r)
print("KLD GATE " + ("PASS" if ok else "FAIL"))
sys.exit(0 if ok else 1)
PY
+27 -17
View File
@@ -1,5 +1,6 @@
# open-notebook.env — configuration for **open-notebook**, the fleet's podcast # open-notebook.env — configuration for **open-notebook**, the fleet's podcast
# generator on Dankclaw (UI host port **8502**, API host port **5056**). Read by # generator on Dankclaw (user path https://notebook.franksdank.cloud; UI host
# port **8502**, API host port **5056 loopback-only**). Read by
# BOTH Quadlet units (dankclaw/open-notebook/quadlet/) as the FIRST # BOTH Quadlet units (dankclaw/open-notebook/quadlet/) as the FIRST
# EnvironmentFile= line, which is why every path and host name below is the # EnvironmentFile= line, which is why every path and host name below is the
# value the CONTAINER must see. # value the CONTAINER must see.
@@ -47,13 +48,17 @@
# #
# THE SCRIPTWRITER. ESPERANTO_LLM_TIMEOUT is the app's own default (180 s), kept # THE SCRIPTWRITER. ESPERANTO_LLM_TIMEOUT is the app's own default (180 s), kept
# visible beside the TTS number because both belong to one podcast run: the # visible beside the TTS number because both belong to one podcast run: the
# outline call and the transcript call are the slow text work, and frank-zealot # outline call and the transcript call are the slow text work, and the endpoint
# answers them well inside 180. Provider choice is made in the UI, not here — # provisioned today answers them well inside 180. The provider itself is chosen
# see [[Open Notebook]] for the one-time credential provisioning — and it is # in the database, not here — [[Open Notebook]] owns that provisioning and the
# frank-zealot on purpose: script writing asks for a JSON-schema response, which # address census behind it. Short version: frank-zealot was the plan (script
# the OpenAI-compatible llama.cpp endpoint is known to serve, while halogen's # writing asks for a JSON-schema response, which llama.cpp serves and halogen's
# structured-output behaviour on frank-dragoon is UNVERIFIED. halogen remains # behaviour on frank-dragoon is UNVERIFIED), but no Dankclaw LLM is dialable from
# the unproven alternative, not the fallback. # this container's netns, and the single address that reaches one —
# `host.containers.internal`, 169.254.1.2 — the app refuses to store at all,
# because link-local is a blocked address class. The writer is therefore
# frank-archon (`http://192.168.0.235:7770/v1`), the only fleet LLM that both
# answers from inside this netns and passes that check.
# #
# THE DATABASE COORDINATES. SURREAL_URL addresses the sibling container BY NAME # THE DATABASE COORDINATES. SURREAL_URL addresses the sibling container BY NAME
# on systemd-open-notebook (container DNS is scoped to the owning network, so # on systemd-open-notebook (container DNS is scoped to the owning network, so
@@ -73,15 +78,20 @@ SURREAL_PASS=
OPEN_NOTEBOOK_ENCRYPTION_KEY= OPEN_NOTEBOOK_ENCRYPTION_KEY=
OPEN_NOTEBOOK_PASSWORD= OPEN_NOTEBOOK_PASSWORD=
# WHERE THE BROWSER FINDS THE API. The UI auto-detects `<host-you-opened>:5055`, # WHERE THE BROWSER FINDS THE API. The guarded vhost
# and 5055 on this host belongs to seerr, so the API leaves the container on 5056 # (notebook.franksdank.cloud → Caddy → 8502) is the only user path, and the
# and this line has to say so. Both origins are listed in CORS_ORIGINS because # image's own Next.js layer rewrites `/api/*` to the FastAPI running beside it,
# the API is called cross-origin from the UI's own port: the LAN origin is the # so ONE origin serves UI and API and API_URL has to name that vhost. This is
# normal one, the localhost one covers a browser opened on Dankclaw itself. The # not cosmetic: unset, the app auto-detects `<host-you-opened>:5055` — the port
# app logs a warning at start while CORS_ORIGINS is unset and defaults to `*`, # upstream ships, and the one seerr holds on this host — and hands the browser
# which is a wildcard in front of an endpoint that serves every episode's audio. # an address nothing answers; pointed at the LAN `:5056` instead, every call
API_URL=http://192.168.0.143:5056 # leaves the guarded origin and an authenticated browser session does not ride
CORS_ORIGINS=http://192.168.0.143:8502,http://localhost:8502 # it. Same-origin traffic needs no CORS at all, but CORS_ORIGINS stays set to
# the one origin because the app warns at start while it is unset and defaults
# to `*` — a wildcard in front of an endpoint that serves every episode's
# audio. No trailing slash: the UI concatenates `${API_URL}/api/...`.
API_URL=https://notebook.franksdank.cloud
CORS_ORIGINS=https://notebook.franksdank.cloud
# Concurrency: see "THE CPU-SHUTDOWN GUARD" above. Defaults are 5 and 5. # Concurrency: see "THE CPU-SHUTDOWN GUARD" above. Defaults are 5 and 5.
TTS_BATCH_SIZE=1 TTS_BATCH_SIZE=1
+16 -5
View File
@@ -147,10 +147,21 @@ def main():
deep = corpus(160_000) deep = corpus(160_000)
wide = corpus(1_100_000) wide = corpus(1_100_000)
# 3.28 chars/token is the measured ratio of this corpus through the # The corpus's chars/token ratio is MEASURED here, not stored. A stored 3.28
# froggeric template (646,000 chars -> 196,919 tokens at the 2026-10-11 # (646,000 chars -> 196,919 tokens, 2026-10-11) had drifted to 3.20 by
# deploy); 98.5 % of the window leaves the response room under the ceiling. # 2026-10-20 (646,299 chars -> 201,876 tokens) as the repo's docs grew, and a
chars = int(0.985 * maxlen * 3.28) # stale constant sends leg B past --max-model-len, where the engine answers a
# perfectly legitimate 400 ("the request (201900 tokens) exceeds the available
# context size (200000)") and the leg reads as a capacity failure. One small
# request prices the ratio; 97 % of the window leaves the response room.
sample = prompt(wide, 4000, "CAL", "cal-marker")
cal, _, _ = post({"model": MODEL,
"messages": [{"role": "user", "content": sample}],
"max_tokens": 1, "temperature": 0.0,
"chat_template_kwargs": {"enable_thinking": False}})
ratio = len(sample) / cal["usage"]["prompt_tokens"]
print(f"[cal] corpus ratio {ratio:.2f} chars/token")
chars = int(0.97 * maxlen * ratio)
# Each marker's FIRST hyphen-separated word is unique, because show() # Each marker's FIRST hyphen-separated word is unique, because show()
# scores the hit on that word — a shared prefix would score every slot HIT # scores the hit on that word — a shared prefix would score every slot HIT
# on any answer that says the word. # on any answer that says the word.
@@ -176,7 +187,7 @@ def main():
print(f"[B-{s}] FAILED after {time.time() - t0:.1f}s: {e!r}") print(f"[B-{s}] FAILED after {time.time() - t0:.1f}s: {e!r}")
print(f"[B] total {time.time() - t0:.1f}s") print(f"[B] total {time.time() - t0:.1f}s")
# D — concurrency decode: seqs streams, 512 tokens each, ~25 % depth. # D — concurrency decode: seqs streams, 512 tokens each, ~25 % depth.
dchars = int(0.25 * maxlen * 3.28) dchars = int(0.25 * maxlen * ratio)
dbodies = [chat(deep, dchars, f"D{i + 1}-9137", f"flint-marker-{i}", 512) dbodies = [chat(deep, dchars, f"D{i + 1}-9137", f"flint-marker-{i}", 512)
for i in range(seqs)] for i in range(seqs)]
print(f"[D] {seqs} concurrent streams x 512 tokens, " print(f"[D] {seqs} concurrent streams x 512 tokens, "
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Re-chunk a KLD corpus so every document fits the container that scores it.
A KLD reference is recorded by booting one container and prefilling every corpus
document through it. radiance refuses a document the KV pool cannot hold on its
own (core/mem/kv.cpp `can_ever_fit`: a paged group holds `n_blocks *
block_size` tokens), and the number that matters is printed by the boot ledger,
not by a flag:
W kv group 'kv_attn' holds 7436 tokens but the context limit is 32768, so
a prompt longer than 7436 tokens is refused (400) ... or lower
--max-model-len to 7436
The BF16 qwen4exp container at the campaign's shape gets 270.00 MiB of cache --
the expert plane owns both cards -- so its real window is 7,436 tokens however
`--max-model-len` is spelled. The corpus the campaign inherited from the swift15
era is 197 documents of 8,193-11,970 tokens, because make-calib-corpus.py's
`--kld-min-prompt 8192` sets a floor and no ceiling: every one of them is
unscoreable there, and three drained windows have proved it.
This script moves the document boundaries and nothing else. Each record's token
ids -- counted with the same tokenizer the generator used -- are cut into
windows of at most `--max-doc-tokens`, so a record's windows concatenate back to
that record's own token sequence, unchanged: no token dropped, rewritten or
reworded. Each window gets its own `score_from = len - --score-tail`, the same
relation the generator wrote for the parent, so the reference scores 256
positions per window instead of per document: 100,864 measured positions over
the same text against the 50,432 the inherited corpus scored, at a shorter
context. The calibration corpus is untouched, so the quantization itself does
not move.
Usage:
rechunk-kld-corpus.py --corpus kld.jsonl --out kld.jsonl.new \
[--tokenizer-json PATH] [--max-doc-tokens 7168] [--score-tail 256] [--force]
--out must not exist and be non-empty without --force: the same
one-artifact-one-name law run-qwen38-bf16.sh applies to a score report. The file
is written through a temporary name and renamed, so an interrupted run leaves no
half corpus behind. A JSON summary of what was written goes to stdout.
"""
import argparse
import json
import os
import sys
import tempfile
def parse_args() -> argparse.Namespace:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--corpus", required=True,
help="the kld.jsonl to re-chunk (records: prompt, score_from, source)")
ap.add_argument("--out", required=True,
help="file to write; refuse if it exists and is non-empty without --force")
ap.add_argument("--tokenizer-json",
default=os.environ.get("FRANK_QWEN38_TOKENIZER",
"/home/dank/models/Qwen3.8-Flash-Next/tokenizer.json"),
help="the tokenizer the corpus was authored with (the same one the "
"manifest in the corpus output dir names)")
ap.add_argument("--max-doc-tokens", type=int, default=7168,
help="the widest document the scoring container can hold; 7,168 is "
"7x1024, the largest whole kilopage under the 7,436 the bf16 "
"container's ledger reports")
ap.add_argument("--score-tail", type=int, default=256,
help="positions scored at the end of every document, as the generator "
"recorded it")
ap.add_argument("--force", action="store_true",
help="overwrite a non-empty --out (after moving the old file to trash)")
return ap.parse_args()
def load_tokenizer(path: str):
try:
from tokenizers import Tokenizer
except ImportError as exc: # the corpus scripts carry their own venv
raise SystemExit(
f"REFUSED: tokenizers is not importable by {sys.executable} — run the "
f"corpus venv's python (on dankserver: /home/dank/venvs/radcorpus/bin/python) "
f"or set FRANK_QWEN38_TOKENIZER: {exc}") from None
if not os.path.isfile(path):
raise SystemExit(f"REFUSED: no tokenizer at {path}")
return Tokenizer.from_file(path)
def refuse(msg: str) -> None:
print(f"REFUSED: {msg}", file=sys.stderr)
sys.exit(2) # 2 is the code argparse uses for unusable arguments
def main() -> None:
a = parse_args()
if a.max_doc_tokens <= 0:
refuse(f"--max-doc-tokens must be positive (got {a.max_doc_tokens})")
if a.score_tail <= 0:
refuse(f"--score-tail must be positive (got {a.score_tail})")
if a.max_doc_tokens <= a.score_tail:
refuse(f"--max-doc-tokens {a.max_doc_tokens} cannot hold a {a.score_tail}-position "
"score tail: every window would be all tail and no context")
if not os.path.isfile(a.corpus):
refuse(f"no corpus at {a.corpus}")
if os.path.realpath(a.out) == os.path.realpath(a.corpus):
refuse(f"--out {a.out} is the corpus itself; move it to trash first — the trash law "
"means the old segmentation stays recoverable")
if os.path.exists(a.out) and os.path.getsize(a.out) > 0 and not a.force:
refuse(f"{a.out} exists and is not empty; pass --force after moving it to trash")
tok = load_tokenizer(a.tokenizer_json)
docs_in = docs_out = tokens_in = tokens_out = 0
longest_child = 0
clamped = scored = 0
sources: dict[str, int] = {}
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(a.out)) or ".",
prefix=".rechunk-")
try:
with os.fdopen(fd, "w", encoding="utf-8") as out:
with open(a.corpus, encoding="utf-8") as src:
for lineno, line in enumerate(src, 1):
if not line.strip():
continue
try:
rec = json.loads(line)
except json.JSONDecodeError as exc:
refuse(f"{a.corpus}:{lineno} is not JSON: {exc}")
prompt = rec.get("prompt")
if not isinstance(prompt, str):
refuse(f"{a.corpus}:{lineno} has no string 'prompt' field")
ids = tok.encode(prompt, add_special_tokens=False).ids
docs_in += 1
tokens_in += len(ids)
for start in range(0, max(len(ids), 1), a.max_doc_tokens):
window = ids[start:start + a.max_doc_tokens]
if not window:
continue
score_from = max(len(window) - a.score_tail, 0)
if len(window) < a.score_tail:
clamped += 1
scored += len(window) - score_from
child = {"prompt": tok.decode(window),
"score_from": score_from}
if "source" in rec:
child["source"] = rec["source"]
out.write(json.dumps(child, ensure_ascii=False) + "\n")
docs_out += 1
tokens_out += len(window)
longest_child = max(longest_child, len(window))
if "source" in rec:
for cls in str(rec["source"]).split("+"):
sources[cls] = sources.get(cls, 0) + 1
if tokens_out != tokens_in:
raise SystemExit(f"REFUSED: the windows hold {tokens_out} tokens and the corpus "
f"has {tokens_in} — re-chunking lost or invented tokens")
os.replace(tmp, a.out)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
os.chmod(a.out, 0o644)
print(json.dumps({
"corpus": a.corpus,
"out": a.out,
"tokenizer": os.path.basename(a.tokenizer_json),
"documents_in": docs_in,
"documents_out": docs_out,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"longest_document": longest_child,
"max_doc_tokens": a.max_doc_tokens,
"score_tail": a.score_tail,
"scored_positions": scored,
"clamped_score_from": clamped,
"source_documents": sources,
}, indent=1))
if __name__ == "__main__":
main()
@@ -0,0 +1,80 @@
# qwen4exp-qwen38-w5nl64-bf16trunk.recipe — every routed expert, BOTH projections, at five bits:
# u5 codes into libquant's 32-level w5nl table, an E4M3 scale a group of 64 under the fixed 2^-13
# second level, rotated by the 128-wide Hadamard, chosen by GPTQ against $CALIB and swept three
# times by coordinate descent. This is `w5nl64a8h` in the engine's served-encoding table — the top of
# the four-bit class — applied wider than any published radiance container of this checkpoint: the
# author's own w5 rung (`qwen4exp-w5dn64-i8-hc8m.recipe`) moves only `down`, because that rung was
# priced as a byte-for-error trade against prefill; this campaign buys the whole rung and pays for
# it out of the pinned host pool (+15.3 GiB of expert plane, `--host-pool-mib 61440` per rank), not
# out of the KV pool.
#
# Every other line is the served container's recipe (dump it with
# `rad-info --recipe /home/dank/models/rad/qwen3.8-next-flash-fp8-iq4r-moe.rad`), minus the rows this
# campaign leaves at the checkpoint:
#
# * the trunk's block linears, the shared expert and the hyper-connection mixers are NOT listed —
# dropping their i8/fp8 lines leaves them bf16, which is what the checkpoint holds. Measured bill
# +1.978 GiB of always-resident weight = ~131k tokens of KV off the pool.
# * `ssm_inz`/`ssm_out` STAY int8: the delta-net gates are the largest trunk rows (+1.9 GiB, 126k
# tokens) and the least sensitive per byte.
# * `mtp.draft_head` STAYS 2-bit and the PLE n-gram table STAYS one fixed E4M3 scale: the draft
# head's rung is cheap and its acceptance cost is a decode question this campaign does not touch,
# and bf16 for the n-gram table would pin +47.69 GiB of host RAM for a lookup.
#
# Protected experts: none in this file. `scripts/apply-protected-cast.sh` appends the derived set
# (48, two per layer so each rank's 256-expert half stays even — the four-bit GEMM takes the rest of
# a half as two equal tables by parity) from the fresh 12M-token energy/routing census, and they must
# sit ABOVE the expert row because the first rule that matches a weight decides it.
#
# CALIB=/home/dank/campaign/qwen38-w5nl64/calib \
# rad-convert /home/dank/models/Qwen3.8-Flash-Next \
# --recipe this.file --imatrix .../imatrix.gguf -o out.rad
#
# The arm driver (`scripts/convert-qwen38-w5nl64.sh`) generates the per-arm variants of the expert
# row from this template — cd=3 (control), cd=6, and `act_order=1` with NO cd= (libquant refuses the
# pair, lq_registry.cpp:105) — rather than keeping three near-duplicate recipes that can drift apart.
#
# The whole campaign, in the order that fits on one disk (every step runs on dankserver):
#
# 1. scripts/download-qwen38-bf16.sh --fetch the vendor BF16 checkpoint, 335.3 GiB
# 2. scripts/build-qwen38-bf16-rad.sh the BF16 reference container, another 335.3 GiB
# 3. one drained window, two boots:
# scripts/run-qwen38-bf16.sh kldref the KLD reference recording (the zero of scale)
# scripts/run-qwen38-bf16.sh calib the 12M-position calibration tap -> grams
# 4. trash qwen38-bf16.rad the peak-disk law: the converts need its room
# 5. scripts/grams-to-imatrix.py grams -> imatrix.gguf in the campaign dir
# 6. scripts/convert-qwen38-w5nl64.sh the arms, planned and billed before each one runs
# 7. fetch the published FP8-native release, then per container:
# scripts/run-qwen38-bf16.sh score RAD OUT fp8 anchor, served iq4r, each arm
# then scripts/kld-gate-qwen38.sh fp8.json iq4r.json cand.json
# 8. a winner is a trio edit plus `scripts/deploy.sh dankserver --service frank-archon`; the
# rollback is the commit before it. If the win needs the bigger pinned plane, raise
# FRANK_ARCHON_HOST_POOL_MIB and FRANK_ARCHON_TTM_TARGET_MIB in the SAME commit: the preflight
# floor is 2xHOST_POOL + 48,824 + prefix cache + MNBT + the store, and a pool raised without
# the TTM target is a boot refusal that Restart=always repeats forever, invisible to deploy.sh.
# PROTECTED-BEGIN — scripts/apply-protected-cast.sh splices the derived cast lines in here.
# They MUST sit above the expert rule: the first rule whose glob matches a weight decides it, so a
# cast line below the five-bit rule would never win. The set is derived from the campaign's own
# 12M-token recording (`derive-protected-experts.py --target-count 48` over the fresh grams), and the
# driver splices it into a per-arm COPY so this template stays the clean, reviewable baseline.
# PROTECTED-END
# Every routed expert, trunk and MTP layer alike, both projections, at five bits. The experts' scales
# sit between 2^-15 and 2^-10.6 under w4nl's top level of 22 and half a binade lower under w5nl's 30
# — inside E4M3's normal range under 2^-13 either way. The MTP layer has no recording and is rounded
# to nearest, which libquant says on stderr; it is one layer of 49 and only ever changes drafts.
blk.*.ffn_*_exps.*.weight gptq table=w5nl group=64 scale=fp8_e4m3 scale2=f32 block2=*x* scale2_value=0.0001220703125 transform=fwht128 rule=search cd=3 calib=$CALIB
# The delta-net's in/out gates, int8 with a bf16 scale a group of 128 searched for least error — the
# served recipe's lines, unchanged.
blk.*.ssm_inz.weight rtn codes=i8 group=128 scale=bf16 clamp=sym rule=search
blk.*.ssm_out.weight rtn codes=i8 group=128 scale=bf16 clamp=sym rule=search
# The MTP head's two mixing rows, E4M3 as served. They only ever change the drafts.
mtp.fc_hidden.weight rtn codes=fp8_e4m3 group=128 scale=f32
mtp.fc_embedding.weight rtn codes=fp8_e4m3 group=128 scale=f32
# The n-gram table at one fixed E4M3 scale, and the 2-bit draft head — both the served lines.
blk.*.ple_ngram.weight rtn codes=fp8_e4m3 block=*x* scale=bf16 rule=fixed scale_value=1.99317932128906e-4
mtp.draft_head.weight rtn codes=u2 zero=u8 group=128 scale=f16
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env bash
# run-qwen38-bf16.sh — RUNS ON DANKSERVER. Boot a container over the campaign's
# ONE measuring shape for the phases that need both R9700s. The engine owns the
# cards, so frank-archon is stopped first and restored on every exit path — the
# autoresearch pattern: stop, never mask (the unit file in /etc/systemd/system
# is a symlink into the checkout and mask fails on a relinked unit; the deploy
# restores it).
#
# kldref --kld-record the reference log-probabilities: the engine
# runs /home/dank/corpora/out/kld.jsonl (197 prompts, 50,432
# scored positions, score tail 256) through prefill, writes
# /home/dank/campaign/qwen38-w5nl64/kld-ref, and EXITS. The
# model behind it is the BF16 container from
# scripts/build-qwen38-bf16-rad.sh — the vendor checkpoint's
# own numbers, which is what makes it the zero point.
# score RAD OUT --kld-ref score ONE container against that recording and
# EXIT: the FP8-native anchor, the served iq4r, and every
# arm. Deliberately the SAME engine pin, the same TP, the
# same step size, the same fp8 KV, --deterministic and no
# speculation as kldref — KL and top-1 agreement are only
# comparable across boots when everything except the weights
# is identical, so this mode is one argv with --model and
# the two --kld-* flags changed, never a second shape.
# RAD must sit under the models dir (the container reads it
# through the ro /models mount); OUT lands on a writable
# mount because --kld-out F also writes F.rows beside it.
# calib serve on :7770 with the calibration tap open
# (RADIANCE_CALIB_DIR + RADIANCE_CALIB_TOKENS=12000000). A
# separate driver — scripts/calib-push.py --port 7770
# --container qwen38-bf16 --corpus
# /home/dank/corpora/out/calib.jsonl — pushes the corpus; at
# 12M pooled positions the engine writes the Grams and the
# tap switches itself off (calib-push prints TAP_DONE). Stop
# this script after that; the trap brings frank-archon back.
#
# Shape: the vendor's bf16 calibration lines — expert_tiered placement with the
# weights disk tier, deterministic (the resident set pinned to the loader's plan,
# so a recorded reference and a scored candidate see the same one), no
# speculation, fp8 KV, a 32,768-token window, the PLE n-gram table pinned to RAM,
# and the production chat template rather than the one baked into the container —
# every scored container is asked the same tokenised questions the reference
# answered. The window the flag spells is 32,768; the pool this payload's boot
# actually gets is 7,436 tokens, so kld.jsonl is re-chunked by
# scripts/rechunk-kld-corpus.py into documents of at most 7,168 tokens — the
# widest one the reference can hold. The kldref gate below re-checks the file
# against the declared window, and the re-chunker is what keeps that honest.
#
# NO --vram-kv-mib: it was REMOVED at 1.3.0 and naming it is a boot refusal, not
# a no-op — verified on the pinned image 2026-10-27: "E --vram-kv-mib was
# removed: the card is split between the weights and the cache from what the
# model needs." The resolver derives the split from --host-pool-mib,
# --gpu-headroom-mib and the activation arena; the rig inherits the first two from
# frank-archon.env, the file that owns them. On THIS payload the host pool is not
# the cache's supplier: the boot lands in vram_budget.cpp's `no lender` branch,
# where the experts keep their plan-fixed 25.29 GiB and the cache gets the residue.
# Measured twice — 12,288 and 49,152 MiB of host pool both printed the same
# "kv cache 271.25 MiB ... 7,436"; only --gpu-headroom-mib moved anything
# (768 -> 96 grew vram_weights 29.41 -> 30.06 GiB). The supplier the source names
# is the arena: vram_budget.cpp:117, "lower --max-num-batched-tokens (the arena
# scales with it)" — which is what the step size below now does.
#
# The pool, not the window, decides what the recorder accepts: it refuses any
# document longer than the pool, so the 32,768-token window was decorative while the
# cache held 7,436 — "E kld: document 196 is 8507 tokens and the KV pool holds
# 7436, so it can never be scored" on all 197 records, exit 1. The longest record
# is 10,554 tokens by the engine's own count (chars/3.6 for this file, not the
# chars/4.97 this repo measured on the K3 corpus).
#
# The step size is ONE value for the recording and every scoring pass:
# FRANK_QWEN38_MNBT, default 1,024. The gate's whole claim is that the reference,
# the served iq4r and every arm rode one shape, so the ceiling is set by the
# TIGHTEST payload the gate boots rather than by the bf16 reference: the quantised
# expert FILE tiers (the served iq4r, the arms) stream only at MNBT<=2048 and
# refuse the widened form above it. 2,048 was still the wrong default on this
# payload, because the arena the engine charges for a step comes straight out of
# the cache — 1.08 GiB at a 2,048-token step, 504.39 MiB at a 1,024-token one, and
# the experts keep their share either way. At 1,024 the cache is 866 MiB: 24,300
# tokens, which scores every one of the 197 documents (longest 11,970).
# Calibration keeps the wide 65,536 — grams are not logits, nothing downstream is
# compared against a step size, and that is where a big step pays.
#
# usage: scripts/run-qwen38-bf16.sh kldref|calib|score RAD OUT [--dry-run] [--image REF]
# (RAD and OUT are score's two positionals: RAD is a container under the models
# dir, OUT the report path whose directory is created if missing)
# --dry-run print the exact `podman run` argv, one shell-quoted argument a
# line, and exit 0: the service is NOT stopped, nothing is
# mounted, nothing is written — the offline test seam. It still
# validates the argv's own shape (paths through the /models mount,
# a step size that is a number), because an argv that could not
# boot is not worth printing.
# --image REF override the digest pin (a probe boot, or the test seam)
#
# knobs: FRANK_QWEN38_MODELS (the dir mounted at /models), FRANK_QWEN38_RAD (the
# bf16 container, kldref only), FRANK_QWEN38_CAMPAIGN (holds kld-ref/ and
# calib/), FRANK_QWEN38_KLD_CORPUS, FRANK_QWEN38_TPL (the template copy
# the container reads through /models), FRANK_QWEN38_LOG_DIR, and
# FRANK_QWEN38_MNBT — the step size shared by kldref and score (default
# 1,024: the file tier's ceiling is 2,048 and production runs there, but on
# this payload the arena charged for a wider step is taken from the cache),
# and FRANK_QWEN38_MAX_MODEL_LEN — the window every mode boots at (32,768;
# see the corpus census above, and raise it together with the corpus).
# kldref refuses (exit 2) when the longest record of the corpus it is about to
# read is longer than MAX_MODEL_LEN x 3 characters — chars/3 being the
# pessimistic token:char ratio, so the script says no before the engine finds out
# inside a drained window, after the checkpoint work is already done. score
# refuses when the reference dir is absent, when RAD is not on disk or not under
# the models mount, or when OUT already exists: the trash law means a second
# score over the same name is a deliberate act, and two numbers in one file is
# how a campaign loses its own history.
# Engine stdout goes to $LOG_DIR/qwen38_bf16_<mode>.log — the boot ledger is the
# record of what the resolver actually gave the cards; the journal never sees it.
# exit: the engine's code; 2 = refused before the service was touched
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/frank-archon.env"
IMAGE="${FRANK_ARCHON_IMAGE:?FRANK_ARCHON_IMAGE must name the radiance image}"
MODE=""
POS=()
DRY=0
while [ $# -gt 0 ]; do
case "$1" in
kldref|calib|score) MODE="$1" ;;
--dry-run) DRY=1 ;;
--image) IMAGE="$2"; shift ;;
*)
# score's two positional arguments, and nothing else may be bare.
if [ "$MODE" = score ] && [ "${#POS[@]}" -lt 2 ]; then
POS+=("$1")
else
echo "usage: ${0##*/} kldref|calib|score RAD OUT [--dry-run] [--image REF] (unknown argument: $1)" >&2
exit 2
fi ;;
esac
shift
done
if [ -z "$MODE" ]; then
echo "usage: ${0##*/} kldref|calib|score RAD OUT [--dry-run] [--image REF] (no mode given)" >&2
exit 2
fi
if [ "$MODE" = score ] && [ "${#POS[@]}" -ne 2 ]; then
echo "usage: ${0##*/} score RAD OUT — RAD is the container to score, OUT the report to write (got ${#POS[@]} argument(s))" >&2
exit 2
fi
MODELS="${FRANK_QWEN38_MODELS:-/home/dank/models}"
CAMPAIGN="${FRANK_QWEN38_CAMPAIGN:-/home/dank/campaign/qwen38-w5nl64}"
KLD_CORPUS="${FRANK_QWEN38_KLD_CORPUS:-/home/dank/corpora/out/kld.jsonl}"
RAD="${FRANK_QWEN38_RAD:-$MODELS/rad/qwen38-bf16.rad}"
TPL="${FRANK_QWEN38_TPL:-$MODELS/tpl.jinja}"
TEMPLATE="$SCRIPT_DIR/qwen38-froggeric-v22.5.jinja"
LOG_DIR="${FRANK_QWEN38_LOG_DIR:-/home/dank/logs}"
LOG="$LOG_DIR/qwen38_bf16_${MODE}.log"
KLD_REF_DIR="$CAMPAIGN/kld-ref"
CALIB_DIR="$CAMPAIGN/calib"
C=qwen38-bf16
SERVICE=frank-archon
# The card split, taken from the file that owns it instead of a private pair:
# --host-pool-mib also funds the KV cache (see the header), so hardcoding it
# here once starved the recorder. The `:?` guards keep an empty env fatal.
HOST_POOL_MIB="${FRANK_ARCHON_HOST_POOL_MIB:?FRANK_ARCHON_HOST_POOL_MIB must be set}"
GPU_HEADROOM_MIB="${FRANK_ARCHON_GPU_HEADROOM_MIB:?FRANK_ARCHON_GPU_HEADROOM_MIB must be set}"
# ONE step size for both logits modes. The file tier streams only at <=2,048 and
# the gate scores file-tier payloads (the served iq4r, the arms) against the
# reference, so the reference is recorded at a step the payloads can meet — never
# two numbers that a KL between them would have to forgive. 1,024 rather than the
# 2,048 ceiling: on this payload the arena charged for the step is the cache's only
# remaining supplier, and 1,024 is the rung that funds every corpus document.
MNBT="${FRANK_QWEN38_MNBT:-1024}"
if ! [[ "$MNBT" =~ ^[0-9]+$ ]]; then
echo "REFUSED: FRANK_QWEN38_MNBT must be a token count (got: $MNBT)" >&2
exit 2
fi
# The window every mode boots at. Over-length is a REFUSAL, not a truncation (the
# env file says so for production), so this is the number the corpus must fit in.
MAX_MODEL_LEN="${FRANK_QWEN38_MAX_MODEL_LEN:-32768}"
if ! [[ "$MAX_MODEL_LEN" =~ ^[0-9]+$ ]]; then
echo "REFUSED: FRANK_QWEN38_MAX_MODEL_LEN must be a token count (got: $MAX_MODEL_LEN)" >&2
exit 2
fi
# Every container this campaign boots is read through the ro /models mount, so
# the host path must live under it; the container-side spelling is what goes
# into the argv. Same rule for the container a score pass opens.
under_models() {
case "$1" in
"$MODELS"/*) printf '/models/%s' "${1#"$MODELS"/}" ;;
*) return 1 ;;
esac
}
# same_dir A B — the two name one directory, judged only when BOTH exist: a dry
# run has no fixtures on disk, and two absent paths are not the same place.
same_dir() {
[ -d "$1" ] && [ -d "$2" ] && [ "$(cd "$1" && pwd)" = "$(cd "$2" && pwd)" ]
}
if [ "$MODE" = score ]; then
SCORE_RAD="${POS[0]}"
SCORE_OUT="${POS[1]}"
OUT_DIR="$(dirname "$SCORE_OUT")"
MODEL_IN="$(under_models "$SCORE_RAD")" || {
echo "REFUSED: container to score $SCORE_RAD is not under the mounted models dir $MODELS — the engine cannot read it" >&2
exit 2
}
else
MODEL_IN="$(under_models "$RAD")" || {
echo "REFUSED: reference container $RAD is not under the mounted models dir $MODELS — the engine cannot read it" >&2
exit 2
}
fi
# The measuring shape. Identical for kldref and score by construction — this
# array is the only place the campaign's KL numbers are defined.
ARGS=(--tp 2 --placement expert_tiered --weights-disk-tier
--host-pool-mib "$HOST_POOL_MIB" --gpu-headroom-mib "$GPU_HEADROOM_MIB"
--deterministic --num-speculative-tokens 0
--kv-cache-dtype fp8 --max-model-len "$MAX_MODEL_LEN"
--ngram-placement ram
--override-chat-template /models/tpl.jinja)
MOUNTS=(-v "$MODELS:/models:ro")
TAPENV=()
case "$MODE" in
kldref)
# Recording is prefill-only over four slots at the campaign's single step size
# — 1,024, not production's 2,048 (frank-archon.env: FRANK_ARCHON_MAX_SEQS=4,
# FRANK_ARCHON_MAX_BATCHED_TOKENS=2048): the wider step costs the cache the
# room the corpus needs. Corpus read-only, record dir rw.
ARGS+=(--model "$MODEL_IN"
--kld-record /data/kld-ref --kld-corpus /data/kld.jsonl
--max-num-seqs 4 --max-num-batched-tokens "$MNBT")
MOUNTS+=(-v "$KLD_CORPUS:/data/kld.jsonl:ro"
-v "$KLD_REF_DIR:/data/kld-ref")
;;
score)
# The same argv with three words changed: which container, and that this
# time the engine reads a recording and writes a report instead of the
# reverse. The reference dir mounts READ-ONLY — a scoring pass must not be
# able to touch the zero point — and the report gets its own writable bind.
if [ "$OUT_DIR" = "$KLD_REF_DIR" ] || same_dir "$OUT_DIR" "$KLD_REF_DIR"; then
echo "REFUSED: score output $SCORE_OUT is inside the reference dir $KLD_REF_DIR, which this mode mounts read-only" >&2
exit 2
fi
ARGS+=(--model "$MODEL_IN"
--kld-ref /data/kld-ref --kld-out "/data/out/$(basename "$SCORE_OUT")"
--max-num-seqs 4 --max-num-batched-tokens "$MNBT")
MOUNTS+=(-v "$KLD_REF_DIR:/data/kld-ref:ro"
-v "$OUT_DIR:/data/out")
;;
calib)
# Calibration is exempt from the shared step: the tap counts pooled
# positions and nothing downstream is compared against a step size, so the
# wide step is free throughput (a disk-tier step costs the same at 1 token
# as at 64k).
ARGS+=(--model "$MODEL_IN" --port 7770
--max-num-seqs 8 --max-num-batched-tokens 65536)
MOUNTS+=(-v "$CALIB_DIR:/data/calib")
TAPENV=(-e RADIANCE_CALIB_DIR=/data/calib
-e RADIANCE_CALIB_TOKENS=12000000)
;;
esac
CMD=(podman run --rm --name "$C"
--network host --init
--security-opt seccomp=unconfined
--ulimit memlock=-1:-1
--device /dev/kfd --device /dev/dri
"${MOUNTS[@]}" "${TAPENV[@]}"
"$IMAGE" "${ARGS[@]}")
# ---- gate: the window must hold the corpus (kldref alone can meet it) --------
# score reads a recording instead of a corpus, and every arm declares at the
# checkpoint's training context, so neither can meet a sequence too long to boot.
# Measured on the raw JSON line — the pessimistic direction, and the same figure
# the census in the header quotes. It runs on a dry run too, because a refusal
# this cheap should never have to wait for the drained window to be discovered.
if [ "$MODE" = kldref ] && [ -f "$KLD_CORPUS" ]; then
read -r REC_NO REC_CHARS < <(awk '{ if (length($0) > best) { best = length($0); where = NR } } END { print where + 0, best + 0 }' "$KLD_CORPUS")
LIMIT_CHARS=$(( MAX_MODEL_LEN * 3 ))
if [ "$REC_CHARS" -gt "$LIMIT_CHARS" ]; then
echo "REFUSED: record $REC_NO of $KLD_CORPUS is $REC_CHARS chars, past the $LIMIT_CHARS a $MAX_MODEL_LEN-token window promises at the pessimistic chars/3 — raise FRANK_QWEN38_MAX_MODEL_LEN, and the container's declared --max-ctx with it, before booting the reference" >&2
exit 2
fi
echo "corpus fits: longest record $REC_CHARS chars (number $REC_NO) inside the $MAX_MODEL_LEN-token window (limit $LIMIT_CHARS chars)"
fi
if [ "$DRY" = 1 ]; then
echo "dry run: $MODE — the production unit is left running, nothing is mounted, nothing is written"
printf '%q\n' "${CMD[@]}"
exit 0
fi
# ---- gates that only a real run needs ---------------------------------------
if [ "$MODE" = score ]; then
if [ ! -d "$KLD_REF_DIR" ]; then
echo "REFUSED: no KLD reference at $KLD_REF_DIR — record it first in a drained window: scripts/run-qwen38-bf16.sh kldref" >&2
exit 2
fi
if [ ! -f "$SCORE_RAD" ]; then
echo "REFUSED: no container to score at $SCORE_RAD" >&2
exit 2
fi
if [ -e "$SCORE_OUT" ]; then
echo "REFUSED: $SCORE_OUT already exists — one report per name; move the old one to trash before re-scoring" >&2
exit 2
fi
mkdir -p "$OUT_DIR"
else
if [ ! -f "$RAD" ]; then
echo "REFUSED: no reference container at $RAD — build it first: scripts/build-qwen38-bf16-rad.sh" >&2
exit 2
fi
[ "$MODE" = kldref ] && [ ! -f "$KLD_CORPUS" ] && {
echo "REFUSED: no KLD corpus at $KLD_CORPUS — the record would be empty" >&2
exit 2
}
fi
if [ ! -f "$TEMPLATE" ]; then
echo "REFUSED: no chat template at $TEMPLATE" >&2
exit 2
fi
mkdir -p "$LOG_DIR"
# The prod template, staged where the ro /models mount reaches it: a reference
# recorded with the container's baked template scores a different tokenisation
# than the arms served with froggeric's, and the KL would be measuring that.
cp -f "$TEMPLATE" "$TPL" || {
echo "REFUSED: cannot stage the chat template at $TPL (the container reads it through /models)" >&2
exit 2
}
[ "$MODE" = kldref ] && mkdir -p "$KLD_REF_DIR"
[ "$MODE" = calib ] && mkdir -p "$CALIB_DIR"
restore() { sudo systemctl start "$SERVICE" >/dev/null 2>&1 || true; }
trap restore EXIT
echo "run-qwen38-bf16 $MODE: stopping $SERVICE ($(date -Is)); log: $LOG"
sudo systemctl stop "$SERVICE" >/dev/null 2>&1 || true
podman rm -f "$C" >/dev/null 2>&1 || true
"${CMD[@]}" 2>&1 | tee "$LOG"
rc=${PIPESTATUS[0]}
echo "run-qwen38-bf16 $MODE exited rc=$rc; $SERVICE restored; log: $LOG"
exit "$rc"
+14 -11
View File
@@ -15,18 +15,21 @@
# log states every budget before anything is allocated (weights vs KV # log states every budget before anything is allocated (weights vs KV
# split, the expert host pool's coverage of the worst case, the PLE pin, # split, the expert host pool's coverage of the worst case, the PLE pin,
# the P2P answer per pair) — read it after every resize. # the P2P answer per pair) — read it after every resize.
# Deployed shape (2026-10-19, radiance 1.8.1): THREE decode slots x 200,000 # Deployed shape (2026-10-20, radiance 1.8.1): FOUR decode slots x 200,000 tokens over a
# tokens, fp8 KV, 44 GiB/rank pinned expert pool, MTP depth 2, prefix cache in # >= 700,000-token fp8 KV pool, 48 GiB/rank pinned expert pool, MTP depth 2, prefix cache
# pinned host RAM, PLE pinned in RAM. The per-card split between the expert slab # in pinned host RAM, PLE pinned in RAM. The per-card split between the expert slab and the KV
# and the KV pool is NO LONGER an operator number — 1.3.0 removed the VRAM pair # pool is NO LONGER an operator number — 1.3.0 removed the VRAM pair and the budget
# and the budget resolver derives it from headroom, the cache bound and the host # resolver derives it from headroom, the cache bound and the host pool, so the slab
# pool, so the slab size and the pool's token count are LEDGER lines: after every # size and the pool's token count are LEDGER lines. After every boot read them, plus
# boot read them out of the log and check the pool still backs 3 x 200,000. # the two lines that size a prefill step — "activation arena: ... lends ... while
# steps are at most N tokens" (the arena levels the expert slab borrows) and
# "prefill staging: ... a step of more than N tokens ..." (the chunk size below which
# pooled experts are read synchronously) — and the cache's session backing.
# #
# The root unit (scripts/frank-archon.service, User=dank, # The root unit (scripts/frank-archon.service, User=dank,
# LimitMEMLOCK=infinity) grants the pin budget: 2 x 44 GiB expert pools + # LimitMEMLOCK=infinity) grants the pin budget: 2 x 48 GiB expert pools + the 47.7 GiB PLE
# the 47.7 GiB PLE table + 8 GiB prefix host tier — ~147 GiB of pinned # table + 8 GiB prefix host tier + the checkpoint store (slots x 112 MiB) — ~159 GiB of
# pages, which is ALSO why this script refuses to start when ttm's # pinned pages, which is ALSO why this script refuses to start when ttm's
# pages_limit/pages_max cap is under FRANK_ARCHON_TTM_MIN_MIB (see below). # pages_limit/pages_max cap is under FRANK_ARCHON_TTM_MIN_MIB (see below).
set -euo pipefail set -euo pipefail
@@ -45,7 +48,7 @@ set +a
: "${FRANK_ARCHON_IMAGE:?FRANK_ARCHON_IMAGE must name the radiance image}" : "${FRANK_ARCHON_IMAGE:?FRANK_ARCHON_IMAGE must name the radiance image}"
: "${FRANK_ARCHON_MODEL_DIR:?FRANK_ARCHON_MODEL_DIR must name the .rad dir}" : "${FRANK_ARCHON_MODEL_DIR:?FRANK_ARCHON_MODEL_DIR must name the .rad dir}"
: "${FRANK_ARCHON_MODEL_FILE:?FRANK_ARCHON_MODEL_FILE must name the .rad file}" : "${FRANK_ARCHON_MODEL_FILE:?FRANK_ARCHON_MODEL_FILE must name the .rad file}"
: "${FRANK_ARCHON_STATE_DIR:?FRANK_ARCHON_STATE_DIR must name the /data state dir}" : "${FRANK_ARCHON_STATE_DIR:?FRANK_ARCHON_STATE_DIR must name the host dir holding the rad-tune cache}"
: "${FRANK_ARCHON_CONTAINER:?FRANK_ARCHON_CONTAINER must name the container}" : "${FRANK_ARCHON_CONTAINER:?FRANK_ARCHON_CONTAINER must name the container}"
: "${FRANK_ARCHON_SERVED_NAME:?FRANK_ARCHON_SERVED_NAME must name the alias}" : "${FRANK_ARCHON_SERVED_NAME:?FRANK_ARCHON_SERVED_NAME must name the alias}"
: "${FRANK_ARCHON_PORT:?FRANK_ARCHON_PORT must be the port}" : "${FRANK_ARCHON_PORT:?FRANK_ARCHON_PORT must be the port}"
+129
View File
@@ -27,6 +27,11 @@
# the flash-server line reads it; it ships as a container; the root unit # the flash-server line reads it; it ships as a container; the root unit
# keeps LimitMEMLOCK=infinity for parity and to outlive the login session, # keeps LimitMEMLOCK=infinity for parity and to outlive the login session,
# exactly like frank-archon's. # exactly like frank-archon's.
# Since 2026-10-19 the container also carries the XDNA2 NPU lane
# (HALOGEN_NPU_MODELS): the vendor's precompiled AIE programs for the models
# named there run on the NPU inside THIS container and answer /v1/embeddings,
# beside the Flash model, on the same port. Opt-in — the variable empty is the
# pre-lane endpoint, byte for byte.
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -115,6 +120,128 @@ if [ ! -e "$DEV_KFD" ] || [ ! -d "$DEV_DRI" ]; then
echo "frank-dragoon: $DEV_KFD or $DEV_DRI missing — no gfx1151 to serve on" >&2 echo "frank-dragoon: $DEV_KFD or $DEV_DRI missing — no gfx1151 to serve on" >&2
exit 1 exit 1
fi fi
# --- NPU lane (XDNA2), opt-in via HALOGEN_NPU_MODELS ------------------------
# Empty = the endpoint exactly as it ran before the lane existed: no device, no
# extra mounts, no gate below. Set = three host preconditions, each refused BY
# NAME (the 2026-10-01 law: the serve gate and the deployer gate must agree):
# 1. the staged model files under $FRANK_DRAGOON_NPU_DIR — a path that MUST
# live INSIDE $FRANK_DRAGOON_MODEL_DIR, because the container's only
# handle on them is the read-only /models mount. A nested bind at
# /models/npu cannot work: runc has to mkdir its mountpoint and the parent
# bind is ro (`mkdirat .../merged/models/npu: read-only file system`,
# measured 2026-10-19), and the image ships no /models at all for it to
# land on. `$MODEL_DIR/npu/<id>` is visible at /models/npu/<id> through
# the existing mount, which is also where the vendor's catalog expects it.
# The engine re-checks size+sha256 against that catalog at boot; it cannot
# fetch anything here because /models is read-only and HALOGEN_DOWNLOAD
# stays unset — no outbound connection at boot),
# 2. the distro XRT's three libraries, each mounted THREE times: where the
# image looks for XRT (/opt/xilinx/xrt/lib), at their own path (the
# libraries find each other there), and at /opt/xilinx/lib/<multiarch>
# — the NPU child searches that third prefix too, and since the host's
# libxrt2 was reconfigured 2026-10-20 it is the ONLY prefix the child
# names: omitting it crashes the lane with `No such library
# '/opt/xilinx/lib/x86_64-linux-gnu/libxrt_core.so.2'` and the engine
# supervisor takes the dead lane down on the whole container (measured
# 2026-10-21: 88-restart boot loop). Real files only — an /opt/xilinx/xrt
# of symlinks into the system dir dangles inside the container, which the
# vendor calls out explicitly),
# 3. the GPU fabric clock HELD: on Strix Halo the iGPU and the NPU co-running
# hang the machine while FCLK changes speed, and the engine refuses to
# start when it moves — it cannot read host sysfs from inside the
# container, so the proof lives here, checked exactly the way the vendor's
# `halogen-fabric-clock status` checks it.
NPU_ARGS=()
NPU_MODELS="${HALOGEN_NPU_MODELS:-}"
if [ -n "$NPU_MODELS" ]; then
DEV_ACCEL="${FRANK_DRAGOON_DEV_ACCEL:-/dev/accel/accel0}"
NPU_DIR="${FRANK_DRAGOON_NPU_DIR:?HALOGEN_NPU_MODELS is set, so FRANK_DRAGOON_NPU_DIR must name the staged model dir}"
XRT_DIR="${FRANK_DRAGOON_XRT_DIR:-/usr/lib/x86_64-linux-gnu}"
SYSFS="${FRANK_DRAGOON_SYSFS:-/sys}"
# The lane's files have exactly one door into the container: the read-only
# payload mount. A staging dir OUTSIDE it would need a nested bind whose
# mountpoint runc cannot create through that read-only parent, so refuse the
# shape by name here rather than let runc die at container init (2026-10-19).
case "$NPU_DIR/" in
"$FRANK_DRAGOON_MODEL_DIR"/*) ;;
*) echo "frank-dragoon: FRANK_DRAGOON_NPU_DIR=$NPU_DIR is outside the payload dir $FRANK_DRAGOON_MODEL_DIR — the lane's files reach the container only through the read-only /models mount; move the staging dir inside it" >&2
exit 1 ;;
esac
if [ ! -e "$DEV_ACCEL" ]; then
echo "frank-dragoon: $DEV_ACCEL missing — the NPU lane needs the amdxdna node (is iommu=pt still on the boot line?)" >&2
exit 1
fi
# Existence is not access. This endpoint runs rootless, so the container's
# namespace maps exactly one uid/gid (dank) and a root:render 0660 node reads
# `nobody:nogroup` inside it — the engine then dies naming the node itself
# (measured 2026-10-19). --group-add keep-groups cannot help: an unmapped gid
# cannot be granted into a namespace that does not map it, verified with and
# without the flag from a shell already in `render`. The door is the udev mode.
# NB: `[ -o FILE ]` is NOT "other-writable" — it means "exists", and `-o` is
# also the OR operator inside test — so the permission bit is read directly.
if [ $(( 8#$(stat -c '%a' "$DEV_ACCEL") & 8#2 )) -eq 0 ]; then
echo "frank-dragoon: $DEV_ACCEL is $(stat -c '%a %U:%G' "$DEV_ACCEL") — a rootless container cannot open it; install dankclaw/udev/98-halogen-npu-access.rules and re-trigger udev (sudo udevadm control --reload-rules && sudo udevadm trigger --sysname-match=accel0)" >&2
exit 1
fi
for M in ${NPU_MODELS//,/ }; do
if [ ! -d "$NPU_DIR/$M" ]; then
echo "frank-dragoon: NPU model $M not staged in $NPU_DIR;" >&2
echo " fetch it: bash scripts/download-frank-dragoon-npu.sh" >&2
exit 1
fi
done
for L in libxrt_coreutil.so.2 libxrt_core.so.2 libxrt_driver_xdna.so.2; do
if [ ! -e "$XRT_DIR/$L" ]; then
echo "frank-dragoon: XRT NPU library $XRT_DIR/$L missing — the NPU lane cannot load (Ubuntu libxrt2 + libxrt-npu2)" >&2
exit 1
fi
REAL="$(readlink -f "$XRT_DIR/$L")"
MULTIARCH="${XRT_DIR##*/}"
NPU_ARGS+=(-v "$REAL:/opt/xilinx/xrt/lib/$L:ro" -v "$REAL:$XRT_DIR/$L:ro"
-v "$REAL:/opt/xilinx/lib/$MULTIARCH/$L:ro")
done
HELD=0
for D in "$SYSFS"/class/drm/card*/device; do
[ -e "$D/pp_dpm_fclk" ] || continue
[ -e "$D/power_dpm_force_performance_level" ] || continue
PERF="$(cat "$D/power_dpm_force_performance_level" 2>/dev/null || true)"
TOP="$(grep . "$D/pp_dpm_fclk" | tail -n 1)"
if [ "$PERF" = high ] || { [ "$PERF" = manual ] \
&& [ "$(grep -c '\*' "$D/pp_dpm_fclk")" = 1 ] && [ "${TOP%\*}" != "$TOP" ]; }; then
HELD=1
else
echo "frank-dragoon: fabric clock at $D is '$PERF', not held — the NPU lane refuses to start (GPU+NPU together need FCLK pinned)" >&2
echo " hold it: sudo halogen-fabric-clock hold" >&2
exit 1
fi
done
if [ "$HELD" = 0 ]; then
echo "frank-dragoon: no amdgpu device with a fabric-clock control under $SYSFS/class/drm — cannot prove FCLK is held" >&2
exit 1
fi
NPU_ARGS+=(--device "$DEV_ACCEL" \
-e HALOGEN_NPU_MODELS="$NPU_MODELS")
echo "frank-dragoon: NPU lane armed: $NPU_MODELS on $DEV_ACCEL (fabric clock held)" >&2
fi
# Container network. Empty = podman's rootless default (`pasta`), which is a
# private netns and CANNOT be joined by anything else: podman refuses a second
# network while the primary is pasta ("cannot set multiple networks without
# bridge network mode"), the endpoint's published port answers 200 on the host
# and on the LAN but is REFUSED from a dankclaw_default container (measured
# 2026-10-19), and `host.containers.internal:7770` answers 200 yet resolves to
# 169.254.1.2 — link-local, which open-notebook's URL guard refuses to store.
# So an endpoint on pasta has no address an app container can use; naming the
# app bridge here is the only shape that lets open-notebook dial
# http://frank-dragoon-halogen:8731 for the NPU endpoints. It does not widen
# the surface: the engine's unauthenticated 8730 is bound to the container's
# OWN loopback (/proc/net/tcp: 127.0.0.1:8730 vs 0.0.0.0:8731, checked the same
# day), so only the API port is reachable by name.
NET_ARGS=()
if [ -n "${FRANK_DRAGOON_NETWORK:-}" ]; then
NET_ARGS=(--network "$FRANK_DRAGOON_NETWORK")
fi
# Host-RAM preflight — the engine's pin gate, checked BEFORE podman run. # Host-RAM preflight — the engine's pin gate, checked BEFORE podman run.
# The engine refuses to pin when MemAvailable is below the checkpoint plus # The engine refuses to pin when MemAvailable is below the checkpoint plus
# a 16 GiB floor: pinned pages cannot be swapped, and under the floor the # a 16 GiB floor: pinned pages cannot be swapped, and under the floor the
@@ -189,6 +316,8 @@ podman run --name "$FRANK_DRAGOON_CONTAINER" \
${HALOGEN_WEIGHTS_LOCK:+-e HALOGEN_WEIGHTS_LOCK="$HALOGEN_WEIGHTS_LOCK"} \ ${HALOGEN_WEIGHTS_LOCK:+-e HALOGEN_WEIGHTS_LOCK="$HALOGEN_WEIGHTS_LOCK"} \
-v "$FRANK_DRAGOON_MODEL_DIR:/models:ro" \ -v "$FRANK_DRAGOON_MODEL_DIR:/models:ro" \
-v "$FRANK_DRAGOON_MODEL_DIR/tokenizer:/tokenizer:ro" \ -v "$FRANK_DRAGOON_MODEL_DIR/tokenizer:/tokenizer:ro" \
"${NET_ARGS[@]}" \
"${NPU_ARGS[@]}" \
"$FRANK_DRAGOON_IMAGE" \ "$FRANK_DRAGOON_IMAGE" \
> "$LOG" 2>&1 > "$LOG" 2>&1
+31
View File
@@ -33,6 +33,19 @@
# what gives this endpoint eyes; MMPROJ_FILE="" is the documented text-only # what gives this endpoint eyes; MMPROJ_FILE="" is the documented text-only
# opt-out. # opt-out.
# #
# A refusal-direction adapter rides on top of the pack (Frank, 2026-10-19):
# AtomicChat's rank-1 LoRA, applied at load by --lora-scaled. It is an edit
# inside the compute graph, not a second model — B = -r (the refusal direction),
# A built from the base pack's folded input basis, every transformer layer, the
# embedding deliberately untouched because an embedding LoRA crashes this fork's
# graph. The card verified it on prism-b10777-e3de715 — the build pinned here —
# and says older forks refuse the pack outright (`unsupported
# prism.hadamard.version: 2`), which makes the runtime lock doubly load-bearing.
# Its own metadata names the PTQ1_0 packing as the pack the direction was fitted
# against, so what proves the effect is a refusal probe against the live
# endpoint, not the metadata and not the card's table. LORA_FILE="" drops the
# flag and the endpoint is the plain pack again.
#
# Speculative decoding ships ON (Frank, 2026-10-19). The head is IN the file and # Speculative decoding ships ON (Frank, 2026-10-19). The head is IN the file and
# accepts well (0.798/0.617 per position, mean length 2.41, measured 2026-10-18); # accepts well (0.798/0.617 per position, mean length 2.41, measured 2026-10-18);
# what made it lose that evening was the bill, not the drafts — the draft context # what made it lose that evening was the bill, not the drafts — the draft context
@@ -136,6 +149,13 @@ fi
# Vision projector. No colon on purpose: an explicitly empty value means # Vision projector. No colon on purpose: an explicitly empty value means
# "text-only, on purpose" and must NOT be replaced by the default path. # "text-only, on purpose" and must NOT be replaced by the default path.
: "${MMPROJ_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf}" : "${MMPROJ_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf}"
# Refusal adapter. No colon on purpose, the same convention as MMPROJ_FILE: an
# explicitly empty LORA_FILE is the documented opt-out (the plain pack again)
# and must NOT be replaced by the default path. The scale travels INSIDE the
# same argv token as the file, so LORA_SCALE only matters while LORA_FILE names
# something; 0 is the card's "switch it off without touching the file" value.
: "${LORA_FILE=/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-Uncensored-LoRA.gguf}"
: "${LORA_SCALE:=1}"
# Fail loudly rather than half-loading: a missing file is a download that # Fail loudly rather than half-loading: a missing file is a download that
# has not finished, and Restart=always would turn it into a crash loop that # has not finished, and Restart=always would turn it into a crash loop that
@@ -149,6 +169,17 @@ if [[ -n "$MMPROJ_FILE" ]]; then
EXTRA_ARGS+=(--mmproj "$MMPROJ_FILE") EXTRA_ARGS+=(--mmproj "$MMPROJ_FILE")
fi fi
# The adapter reaches the engine as ONE token — --lora-scaled <file>:<scale> —
# so a scale can never arrive without its file. It is applied once, at load;
# nothing here hot-swaps it (--lora-init-without-apply plus POST /lora-adapters
# is that shape, and a process holding three conversations is not the place to
# re-parameterise weights mid-flight). A missing file is a fetch that never
# finished, so refuse rather than let Restart=always loop on it.
if [[ -n "$LORA_FILE" ]]; then
[[ -e "$LORA_FILE" ]] || { echo "frank-zealot: missing $LORA_FILE" >&2; exit 1; }
EXTRA_ARGS+=(--lora-scaled "${LORA_FILE}:${LORA_SCALE}")
fi
# Prometheus scraping is opt-IN in llama.cpp: without --metrics the server # Prometheus scraping is opt-IN in llama.cpp: without --metrics the server
# answers 501 on /metrics, and the fleet scrape then reports the endpoint DOWN. # answers 501 on /metrics, and the fleet scrape then reports the endpoint DOWN.
if [[ "$METRICS" = "1" ]]; then if [[ "$METRICS" = "1" ]]; then
+15
View File
@@ -265,6 +265,13 @@ for path in "${CHANGED[@]}"; do
add_test tests/voice_pack_unit.bats add_test tests/voice_pack_unit.bats
add_test tests/scripts_syntax.bats add_test tests/scripts_syntax.bats
;; ;;
# The KLD corpus re-chunker: its seam is its own (the campaign's
# one-token-series invariant is asserted there), so a change to it does
# not land on the generic scripts/* fallback below.
scripts/rechunk-kld-corpus.py)
add_test tests/qwen38_rechunk.bats
add_test tests/scripts_syntax.bats
;;
scripts/*) scripts/*)
add_test tests/scripts_syntax.bats add_test tests/scripts_syntax.bats
;; ;;
@@ -293,6 +300,14 @@ for path in "${CHANGED[@]}"; do
add_test tests/caddyfile.bats add_test tests/caddyfile.bats
add_test tests/deployment_scoping.bats add_test tests/deployment_scoping.bats
;; ;;
# The GPU exporter is a host process; its seam pins the collector, the
# user unit, the deployer function, the planner route, the Prometheus
# scrape jobs and the host-system GPU panels — a cross-file contract, so
# the whole seam rides on any of its own files.
scripts/gpu-exporter.py|scripts/gpu-exporter.service)
add_test tests/gpu_exporter.bats
add_test tests/deployment_scoping.bats
;;
dankclaw/udev/*) dankclaw/udev/*)
# Host-level udev rules are applied by manual sudo install + udevadm # Host-level udev rules are applied by manual sudo install + udevadm
# trigger, not dispatched as a service; nothing here is bats-testable. # trigger, not dispatched as a service; nothing here is bats-testable.
@@ -0,0 +1,55 @@
---
type: reference
title: Domain Docs
description: "Consumer rules for GLOSSARY.md and docs/adr/, and where this vault out-ranks them on operational facts."
tags: [agent, glossary, adr, documentation]
timestamp: 2026-10-20T00:00:00Z
---
# Domain Docs
How the engineering skills (`/grill-with-docs`, `/domain-modeling`,
`/improve-codebase-architecture`, `/to-spec`) should consume this repo's design
vocabulary when exploring the codebase.
## Before exploring, read these
- **`GLOSSARY.md`** at the repo root
- **`docs/adr/`**: read ADRs that touch the area you're about to work in
If either doesn't exist, **proceed silently**. Don't flag their absence; don't
suggest creating them upfront. `/domain-modeling` (reached via
`/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily
when terms or decisions actually get resolved. As of 2026-10-20 neither exists;
this page is the pointer, not a promise.
## Ownership against this vault
This repo already carries `serverAppConfigs-docs/` — the operational knowledge
base and, per repo law, the owner of operational facts (endpoint shapes, Eras,
Laws, runbooks). `GLOSSARY.md` / `docs/adr/` hold **design vocabulary and
decisions**. Where a term is defined in both, the vault page that owns the fact
wins — one fact, one owner. A glossary entry restating an endpoint number is
the bug, not the second source.
## Use the glossary's vocabulary
When output names a domain concept (an issue title, a refactor proposal, a
hypothesis, a test name), use the term as defined in `GLOSSARY.md`. Don't drift
to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal: either
you're inventing language the project doesn't use (reconsider) or there's a
real gap (note it for `/domain-modeling`).
## Flag ADR conflicts
If output contradicts an existing ADR, surface it explicitly rather than
silently overriding:
> _Contradicts ADR-0007 (…), but worth reopening because…_
## Related
- [[Issue Tracker]] — where specs and tickets built on this vocabulary are published.
- [[Agent Docs and Edits]] — the edit-safety rules that apply while touching any doc in this checkout.
@@ -0,0 +1,85 @@
---
type: reference
title: Issue Tracker
description: "How agent skills read and write Gitea issues here: credential-safe curl, label IDs, and wayfinding without sub-issues."
tags: [agent, gitea, issues, triage, api]
timestamp: 2026-10-20T00:00:00Z
---
# Issue Tracker
Issues and specs for this repo live as Gitea issues in `dank/serverAppConfigs`
on `http://192.168.0.143:3000` (Dankclaw). There is no `gh`/`glab` CLI here —
the door is the GitHub-compatible REST API driven with `curl`. The
mattpocock-lineage skills (`/triage`, `/to-tickets`, `/to-spec`, `/wayfinder`)
read this page as their tracker contract. The git workflow around issues and
PRs (branches, PR-only merges, attribution footer) is owned by
[[Deployment Flow]]; this page owns only the API shape.
## Auth
The token lives in the git credential file `~/.config/secrets/gitea`: one
line in git-credential URL form, `http://user:TOKEN@192.168.0.143`. Extract it
without echoing:
```bash
tok=$(sed -nE 's#^http://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)
```
**A token on a command line is world-readable in `/proc/<pid>/cmdline`** (the
same leak class AGENTS.md records for HF tokens). Never pass it with `-H` —
put it in a mode-600 curl config and pass `-K`:
```bash
cfg=$(mktemp); chmod 600 "$cfg"
printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"
# ... every curl below uses -K "$cfg"; rm -f "$cfg" when done
```
**GETs also accept HTTP Basic; writes do not** — a POST/PATCH with Basic auth
is refused with `422 {"message":"token is required"}` (measured 2026-10-20).
Use the `Authorization: token` header for everything.
## Conventions
`$API=http://192.168.0.143:3000/api/v1/repos/dank/serverAppConfigs`
- **Create an issue**: `curl -s -K "$cfg" -X POST -H 'Content-Type: application/json' -d "$(jq -n --arg t "$TITLE" --rawfile b "$BODY" '{title:$t, body:$b}')" $API/issues`
- **Read an issue**: `curl -s -K "$cfg" $API/issues/<number>` (fields: `number`, `title`, `body`, `labels[].name`, `state`)
- **List issues**: `curl -s -K "$cfg" "$API/issues?state=open&limit=50"` — add `&labels=<id>` to filter
- **Comment**: `curl -s -K "$cfg" -X POST -d "$(jq -n --rawfile b "$BODY" '{body:$b}')" $API/issues/<number>/comments`
- **Apply labels**: `POST $API/issues/<number>/labels` with `{"labels":[<label-ID numbers>]}` — Gitea wants numeric label **IDs**, not names; resolve them from `GET $API/labels` (see [[Triage Labels]] for which labels exist)
- **Remove a label**: `DELETE $API/issues/<number>/labels/<label-id>`
- **Close**: `PATCH $API/issues/<number>` with `{"state":"closed"}`
- **Blocking relationships**: this instance has no native sub-issues or dependencies — put `Part of #<parent>` / `Blocked by: #<n>, #<n>` lines at the top of the child body. A ticket is unblocked when every listed blocker is closed.
- **Attribution footer (repo law)**: every issue body you open ends with `cwd: <working directory> · session: <omp session id>` — full rule in [[Deployment Flow]].
**PRs as a request surface: no.** Issues and PRs have separate number spaces on
Gitea; a bare `#42` is an issue, a PR is spelled "PR #42".
## When a skill says "publish to the issue tracker"
Create a Gitea issue as above.
## When a skill says "fetch the relevant ticket"
`GET $API/issues/<number>` and read `title` + `body` + `labels[].name` +
comments.
## Wayfinding operations
Used by `/wayfinder`. The **map** is a single issue labelled `wayfinder:map`,
holding the Notes / Decisions-so-far / Fog body.
- **Map**: create an issue, apply the `wayfinder:map` label (create it via `POST $API/labels` `{"name":"wayfinder:map","color":"#6f42c1"}` if missing).
- **Child ticket**: an issue carrying `Part of #<map>` at the top of its body, plus a `wayfinder:<type>` label (`research`/`prototype`/`grilling`/`task`).
- **Blocking**: the `Blocked by:` body line (see Conventions).
- **Frontier**: list open issues (`?state=open`), drop any with an open `Blocked by` target or an assignee; lowest `number` wins.
- **Claim**: `POST $API/issues/<n>/assignees` with `{"assignees":["dank"]}` — actually assign it or it stays in the frontier; the session's first write.
- **Resolve**: post the answer as a comment, `PATCH` to `closed`, then update the map issue's body (GET → edit Decisions-so-far → PATCH) with a context pointer.
## Related
- [[Triage Labels]] — the role strings these operations apply.
- [[Domain Docs]] — what a skill must read before touching the codebase.
- [[SSH and Remote Access]] — reaching the host when an issue needs live evidence.
+18 -14
View File
@@ -52,28 +52,31 @@ Where the contract lives:
The arithmetic (the coupled contract every endpoint's maxTokens comment The arithmetic (the coupled contract every endpoint's maxTokens comment
encodes). The authoritative `contextWindow`/`maxTokens` pair per model is on the encodes). The authoritative `contextWindow`/`maxTokens` pair per model is on the
endpoint page ([[frank-archon]], [[frank-dragoon]], [[frank-zealot]]); what endpoint page ([[frank-archon]], [[frank-dragoon]], [[frank-zealot]]); what
follows is a **worked example** recomputed from the live files on 2026-10-18 — follows is a **worked example** recomputed from the live files on 2026-10-21 —
a snapshot, not fleet state — with windows 200,000 / 155,648 / 200,704 and a snapshot, not fleet state — with windows 200,000 / 200,000 / 200,704 and
maxTokens 16,384 / 45,056 / 16,384 (archon / dragoon / zealot): maxTokens 16,384 / 45,056 / 16,384 (archon / dragoon / zealot):
1. **Source trigger** = `thresholdPercent × source contextWindow`; at the live 1. **Source trigger** = `thresholdPercent × source contextWindow`; at the live
95 % that is 190,000 (archon), 147,866 (dragoon), 190,669 (zealot). The 95 % that is 190,000 (archon and dragoon), 190,669 (zealot). The
bundle clamps the percentage to 1–99 and converts it to a token threshold. bundle clamps the percentage to 1–99 and converts it to a token threshold.
2. **Output budget** = capped at the target model's `maxTokens` — every role 2. **Output budget** = capped at the target model's `maxTokens` — every role
here compacts to frank-zealot, so the budget is its 16,384. here compacts to frank-zealot, so the budget is its 16,384.
3. **REQUIREMENT**: prompt (≤ trigger) + budget ≤ the target's per-slot 3. **REQUIREMENT**: prompt (≤ trigger) + budget ≤ the fitting window. For a
window. **At 95 % this no longer holds**: 190,000 + 16,384 = 206,384 > dragoon session the fitting window is the ENGINE ceiling, not a slot: the
200,704. The registry's own comments design around an 85 % trigger (archon: 190,000 trigger is 62,144 under the 262,144 ceiling, so even ×1.03 est
170,000 est × 1.03 + 16,384 ≈ 191,500 ≤ 200,704, `models.yml:49-56`), which error plus the 45,056 request budget lands inside — that headroom is the
does hold. While `config.yml` and those comments disagree, treat 85 % as the reason the ceiling moved to native on 2026-10-21. For a session compacting
design point and re-run this arithmetic after ANY window change — a 400 from INTO frank-zealot the window is its 200,704-token slot, and **at 95 % this
the compaction target mid-conversation is the symptom. does not hold for archon/zealot sources**: 190,000 + 16,384 = 206,384 >
200,704. Treat 85 % as the design point until config.yml and the registry
comments agree, and re-run this arithmetic after ANY window change — a 400
from the compaction target mid-conversation is the symptom.
(Historic 2026-10-05 pairs, at `thresholdPercent: 70`: 140,492 + 36,864 = (Historic 2026-10-05 pairs, at `thresholdPercent: 70`: 140,492 + 36,864 =
177,356 ≤ 200,704 archon; 140,490 + 57,344 ≤ 200,700 dragoon; 140,492 + 177,356 ≤ 200,704 archon; 140,490 + 57,344 ≤ 200,700 dragoon; 140,492 +
32,768 ≤ 200,704 zealot.) 32,768 ≤ 200,704 zealot.)
Procedure (recompute from the LIVE files every time — windows rotate with Procedure (recompute from the LIVE files every time — windows rotate with
every swap; the block above is the 2026-10-18 state): every swap; the block above is the 2026-10-21 state):
1. Read the `config.yml` compaction block + `models.yml` entries for source 1. Read the `config.yml` compaction block + `models.yml` entries for source
and target. Every endpoint's `contextWindow` comment carries its own and target. Every endpoint's `contextWindow` comment carries its own
@@ -94,9 +97,10 @@ Known reconciliation trap: comments in models.yml have described compaction
as running "on the dankserver endpoint" while `remoteEndpoint` pointed at as running "on the dankserver endpoint" while `remoteEndpoint` pointed at
dankclaw — the comment/config disagreed. The comments now name the real dankclaw — the comment/config disagreed. The comments now name the real
target (`compaction.remoteEndpoint` = dankclaw :7771). **The config is target (`compaction.remoteEndpoint` = dankclaw :7771). **The config is
truth; fix comments in the same change.** A fresh instance of the same trap is truth; fix comments in the same change.** The 85 %-comments vs 95 %-config
open 2026-10-18: every `maxTokens` comment still says "compaction fires at disagreement is not academic: it is why long dragoon sessions kept drawing
85 % of the window" while `config.yml:84` sets `thresholdPercent: 95`. 400s at the old 200,704 ceiling — the trigger fires late AND the est
under-counts, and a window parked near the ceiling has no room for either.
`maxTokens` never changes for a reslice — it caps output; `contextWindow` caps `maxTokens` never changes for a reslice — it caps output; `contextWindow` caps
input. Boot-shape OOM laddering is a different problem input. Boot-shape OOM laddering is a different problem
([[Llama Cpp Serving Sizing]]). ([[Llama Cpp Serving Sizing]]).
@@ -0,0 +1,39 @@
---
type: reference
title: Triage Labels
description: "The five canonical triage roles mapped to this repo's Gitea label strings, plus the numeric-ID quirk."
tags: [agent, gitea, triage, labels]
timestamp: 2026-10-20T00:00:00Z
---
# Triage Labels
The mattpocock-lineage skills speak in terms of five canonical triage roles.
This page maps those roles to the actual label strings used in this repo's
tracker ([[Issue Tracker]] owns the API shape).
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the
corresponding label string from this table. Edit the "Label in our tracker"
column to match whatever vocabulary you actually use.
## Instance quirks (Dankclaw Gitea)
- Labels are applied by **numeric ID**, never by name: `POST /issues/<n>/labels`
with `{"labels":[<id>]}`; resolve names via `GET /labels`.
- The repo also carries the pre-existing `human-only` label from the manual era
(e.g. the podcast-destination issues). `ready-for-human` maps to itself; the
`(HUMAN ONLY)` title prefixes on existing issues stay as they are — the label
is what the skills read.
- An issue with **no** triage label is simply untriaged; absence is not a state.
## Related
- [[Issue Tracker]] — the auth-safe curl shape that applies these labels.
+3
View File
@@ -6,6 +6,9 @@ docs get edited safely, and how the agent's home is deployed.
- [[OMP Harness]] — The omp harness as configured here: compaction chain arithmetic, per-model sampler routing, and the context-lean audits. - [[OMP Harness]] — The omp harness as configured here: compaction chain arithmetic, per-model sampler routing, and the context-lean audits.
- [[Agent Docs and Edits]] — Two self-inflicted doc damage classes from parallel agent edits, with the audit checks and recovery order for each. - [[Agent Docs and Edits]] — Two self-inflicted doc damage classes from parallel agent edits, with the audit checks and recovery order for each.
- [[Command Center Dotfiles]] — How the dotfiles repo deploys: stow-equivalent packages, full-replica rsync, the pre-overwrite audit, transport gotchas. - [[Command Center Dotfiles]] — How the dotfiles repo deploys: stow-equivalent packages, full-replica rsync, the pre-overwrite audit, transport gotchas.
- [[Issue Tracker]] — How agent skills read and write Gitea issues here: credential-safe curl, label IDs, and wayfinding without sub-issues.
- [[Triage Labels]] — The five canonical triage roles mapped to this repo's Gitea label strings, plus the numeric-ID quirk.
- [[Domain Docs]] — Consumer rules for GLOSSARY.md and docs/adr/, and where this vault out-ranks them on operational facts.
## Related ## Related
@@ -8,7 +8,7 @@ tags:
- netavark - netavark
- rootless - rootless
- dankclaw - dankclaw
timestamp: 2026-09-02 timestamp: 2026-10-19
--- ---
# Container Network Namespaces # Container Network Namespaces
@@ -88,8 +88,53 @@ Verified outcomes of that recipe on 2026-09-02: from inside `hermes-dashboard`
published) and `:9119` (its own published port) connect; `:8080` and `:8787` published) and `:9119` (its own published port) connect; `:8080` and `:8787`
are **refused instantly** even though both listeners are bound to `0.0.0.0` in are **refused instantly** even though both listeners are bound to `0.0.0.0` in
the host netns. Probes to `host.containers.internal` (`169.254.1.2`) from that the host netns. Probes to `host.containers.internal` (`169.254.1.2`) from that
network time out rather than refuse — the alias is an address on the *default* network time out rather than refuse — but see the 2026-10-19 re-measure below,
`podman` network, and this container is not on it. where the same alias answers from another `dankclaw_default` container.
### 2026-10-19: which published ports a rootless container can actually dial
Probed from inside `open-notebook` (rootless, on `dankclaw_default` **and** the
private `systemd-open-notebook`), each address aimed at a live listener:
| Dial | Result | Mechanism |
|---|---|---|
| `192.168.0.143:3000` (Gitea) | **200** | published by a **rootless** container — rootless egress crosses the host netfilter where netavark installed that DNAT |
| `192.168.0.143:7770` (`frank-dragoon`) | refused | the container is **rootless on podman's default `pasta` netns** (the root unit's `User=dank` runs `podman run`, so it lands in dank's storage). Pasta forwards the published port in the host netns — the host and the LAN both answer 200 — but installs no netavark DNAT for traffic arriving from a rootless bridge, so the same address is refused from a container. This is NOT a root/rootless rule-set split: `podman ps` as `dank` lists it |
| `host.containers.internal:7770` (`169.254.1.2`) | **200** | the pasta forwarder is bound `0.0.0.0` on the host, so the gateway alias reaches it — and the alias is link-local, which is the dead end below |
| `192.168.0.143:7771` (`frank-zealot`) | refused | host-netns listener, unpublished — the rule above, reconfirmed |
| `10.89.1.1:7771` | refused | the bridge gateway address belongs to the rootlesskit netns, not to the host |
| `host.containers.internal:7771` (`169.254.1.2`) | **200** | the gateway alias is the one address that carries a rootless container to a host-netns listener. Why `hermes-dashboard` timed out on this alias in 2026-09-02 while `open-notebook` answers today is **not established**; the difference to check first is the second network and where the default route lands |
| `192.168.0.235:7770` (`frank-archon`) | **200** | a different machine: plain routing, no DNAT in the path |
The consequence that changed designs: **a rootless container's menu of local model
endpoints was only `frank-tts` (container DNS, same network) and `frank-archon` (LAN
address on another host).** A `pasta` endpoint has no address an app container can
use: the LAN address is refused, and the one alias that answers is link-local,
which some applications refuse to store at all ([[Open Notebook]] meets exactly
that refusal). Pasta cannot be *added to* either —
```
podman run --network pasta --network dankclaw_default …
Error: cannot set multiple networks without bridge network mode, selected mode pasta
```
so the only shape that makes such an endpoint nameable is **moving it onto
`dankclaw_default`** (`FRANK_DRAGOON_NETWORK` in `scripts/frank-dragoon.env`,
2026-10-19). For frank-dragoon that is not an exposure change: the engine's
unauthenticated token port binds the container's **own loopback**
(`/proc/net/tcp` on 2026-10-19: `127.0.0.1:8730`, `0.0.0.0:8731`), so only the
API port becomes name-resolvable, which is the same trust the published 7770
already carries on the LAN. `frank-zealot` (7771) stays a host-netns binary and
remains unreachable except through the link-local alias.
The move is verified from the consumer side, so the table above reads as the
**pre-move** census and not as today's menu: inside `open-notebook`,
`getent hosts frank-dragoon-halogen` → `10.89.1.39`, `GET :8731/v1/models`
lists both served models, and the engine's log carries the app's
`POST /v1/embeddings 200` from that address (2026-10-19) — see
[[Open Notebook]] for what runs over it. What the table still owns is the
pasta rule itself: nothing about pasta's forwarding changed, the endpoint was
moved out of it.
## Operational rules ## Operational rules
@@ -98,9 +143,13 @@ network time out rather than refuse — the alias is an address on the *default*
host — and never the reverse either: the deployer's health probe runs on the host — and never the reverse either: the deployer's health probe runs on the
host, so it sees host-netns listeners that the container itself cannot dial. host, so it sees host-netns listeners that the container itself cannot dial.
- To give a bridge-net container access to a host-netns listener, the options - To give a bridge-net container access to a host-netns listener, the options
are (a) move it to `Network=host`, or (b) publish the needed port. Option (b) are (a) move it to `Network=host`, or (b) publish the needed port — but
makes that port reachable from every netns and from the LAN — an exposure publishing is advertised **only inside the Podman that owns the container**:
change that needs operator approval before deployment, not a silent fix. a root container's published port stays invisible to a rootless consumer
(2026-10-19 table above), so the fix must be made on the same root/rootless
side as the consumer. Either way it is an exposure change — a published port
also answers on the LAN — and needs operator approval before deployment, not
a silent fix.
- `chrome-devtools-protocol` is intentionally `Network=host` with a loopback-only - `chrome-devtools-protocol` is intentionally `Network=host` with a loopback-only
bind so the browser tool stays unreachable from the LAN; do not "tidy" it onto bind so the browser tool stays unreachable from the LAN; do not "tidy" it onto
a bridge network — its two consumers (gateway, webui) are host-netns and reach a bridge network — its two consumers (gateway, webui) are host-netns and reach
@@ -130,10 +130,19 @@ Quickest unblock: `podman compose up -d --force-recreate <svc>`. Durable fix
## 4. Podman 5.x quadlet v1 generator quirks (verified live 2026-08-28) ## 4. Podman 5.x quadlet v1 generator quirks (verified live 2026-08-28)
- **Hard-fail keys** — the whole unit silently fails to generate - **Hard-fail keys abort the WHOLE BATCH, not one unit** (podman 5.7.0,
(`systemctl --user cat <unit>` shows "could not be found"): any live 2026-10-21): one unsupported key — e.g. `HostAddr=` — makes the
dash-prefixed `[Container]` option (`--shm-size=…` → `unsupported key … generator log `unsupported key … in group 'Container'` plus
in group 'Container'`); `Command=` is unsupported, use `Exec=`. `processing encountered some errors` and generate NOTHING for that
manager. The running containers keep going as orphans, so the failure
first appears on the next deploy as `Unit <x>.service not found` for a
service that is visibly up. Other offenders: any dash-prefixed
`[Container]` option (`--shm-size=…`), `Command=` (use `Exec=`).
The seam `tests/quadlet_image_pins.bats` fails the build on any
generator-rejected key in any host's units.
Also: an image whose ENTRYPOINT is the binary takes FLAGS ONLY in
`Exec=` — repeating the binary makes it a positional arg
(`unexpected /bin/blackbox_exporter`, live 2026-10-21).
- **HealthCmd space-splitting:** the v1 generator splits `HealthCmd` on - **HealthCmd space-splitting:** the v1 generator splits `HealthCmd` on
spaces into the healthcheck argv; a quoted probe spaces into the healthcheck argv; a quoted probe
`/bin/sh -c "pg_isready …"` lands as argv token `"pg_isready` → `/bin/sh -c "pg_isready …"` lands as argv token `"pg_isready` →
@@ -148,6 +157,16 @@ Quickest unblock: `podman compose up -d --force-recreate <svc>`. Durable fix
"unsupported key"`, `systemctl --user cat probe.service` (shows the "unsupported key"`, `systemctl --user cat probe.service` (shows the
generated ExecStart verbatim), then remove + reload. generated ExecStart verbatim), then remove + reload.
## 4a. Image pins (fleet law, 2026-10-21)
Every registry image in a quadlet is digest-pinned (`tag@sha256:…`);
locally built images (`localhost/*`, `franksdank/*`) are exempt — their
build is the pin. A rolling tag lets a rebuild move under a running
service with nobody approving it, and it makes a CVE audit ungradeable.
Gate: `tests/quadlet_image_pins.bats`. Where upstream publishes no version
tags (copyparty-ac stops at 1.19.15), pin `:latest@sha256:` and name the
version the config label carries in the comment beside it.
## 5. Rootless Quadlet log retrieval ## 5. Rootless Quadlet log retrieval
Step 0 — establish ownership: `systemctl --user status <name>.service` Step 0 — establish ownership: `systemctl --user status <name>.service`
@@ -116,6 +116,20 @@ as working when they could never report up. **Clean cutover includes prose** —
docs, unit docstrings, and the repo's rule file (the most expensive place to docs, unit docstrings, and the repo's rule file (the most expensive place to
be stale). be stale).
## Three rules that outlive the incident that taught them
* **A target can read down beside a healthy service.** Read `lastError` from
`/api/v1/targets` before believing an outage — llama.cpp refuses `/metrics` at the
protocol layer without `--metrics` (501) while `/health` answers 200 for weeks.
* **Alert state lives where the tool says it does.** Prometheus keeps rule state in
process memory — a restart re-stamps `activeAt`, so a "fresh" alert after a reboot is
not evidence of a fresh fault. Alertmanager **does** persist (`--storage.path` on a
host volume: silences + notification log, [[Alertmanager]]).
* **Never quote a candidate arm's flags or VRAM as fleet state.** A stopped production
unit (`failed` after `stop-sigterm timed out. Killing` — the known shutdown shape,
[[frank-zealot]]) beside an answering port means the metric measured a scratch boot;
the process shape can change between two censuses ten minutes apart.
## Order and verification ## Order and verification
1. Enumerate the surfaces (status board, metrics targets, alert rules, test 1. Enumerate the surfaces (status board, metrics targets, alert rules, test
@@ -110,9 +110,9 @@ only `models`, `input`, `output`, `custom_nodes` and the healthcheck file
## Recommended additions ## Recommended additions
Quantized preferred, sized for the 8060S iGPU (GTT-backed — `/system_stats` Quantized preferred. The iGPU's GTT aperture is not free RAM beside
reports 122.4 GB of device VRAM on `cuda:0`, `curl http://127.0.0.1:8188/system_stats` frank-dragoon — size every pick against *What actually fits beside
2026-10-18; the RTX 3090 is frank-zealot's). All repos verified 2026-10-05 and frank-dragoon* below (the RTX 3090 is frank-zealot's). All repos verified 2026-10-05 and
all four still resolve today (`huggingface.co/lightx2v/Qwen-Image-Lightning` all four still resolve today (`huggingface.co/lightx2v/Qwen-Image-Lightning`
`huggingface.co/unsloth/Qwen-Image-Edit-2511-GGUF` `huggingface.co/unsloth/Qwen-Image-Edit-2511-GGUF`
`huggingface.co/Comfy-Org/Qwen-Image-DiffSynth-ControlNets` `huggingface.co/Comfy-Org/Qwen-Image-DiffSynth-ControlNets`
@@ -133,8 +133,34 @@ sha256 checks) or a plain `hf download` with the token in the environment —
never on the command line. never on the command line.
Skip list: the original `city96/Qwen-Image-gguf` conversions (superseded by Skip list: the original `city96/Qwen-Image-gguf` conversions (superseded by
the 2.1 files already on disk), and any BF16 of #2/#4 (the iGPU does not need the 2.1 files already on disk), and any BF16 of #2/#4 — they double the disk
them at GTT capacity, and they double the disk bill). bill, and see *What actually fits beside frank-dragoon* for why raw GTT size
is not the real budget.
## What actually fits beside frank-dragoon
The 122 GB `/system_stats` figure is the GTT **aperture**, not free memory: on
an APU the aperture and `MemAvailable` are the same physical RAM, and
frank-dragoon pins tens of GiB of it. Measured 2026-10-21 with frank-dragoon
live: `MemAvailable` 15.3 GiB, iGPU busy 1.75 GiB — ComfyUI itself idles at
~10 MB, so the budget for a generation run is roughly what `free` says.
Weights must land inside that budget with slack for compute buffers and
activations (the mmproj-class lesson: price a model at ~1.5× its file set):
| Pipeline (weights) | Fits beside frank-dragoon? |
|---|---|
| Qwen-Image 2.1 int8_convrot + w4a8 + bf16 VAE (~14.2 GB) — the two proven workflows | **No** — thrash-or-fail; do not queue it while the endpoint serves |
| GGUF Q8_0 + w4a8 + small VAE (~13.3 GB) | No, same reason |
| Any BF16 combination | No |
| SDXL-Turbo / SD 1.5-class (~2–7 GB) | Yes, with real headroom |
**The law: one process owns the memory — same rule as the 3090 bench law.**
For a Qwen-Image render: stop and mask frank-dragoon
(`sudo systemctl stop frank-dragoon` + `mask` — the mask stops
`Restart=always` from firing mid-render), generate, unmask, verify
`curl 192.168.0.143:7770/health`. The proven workflows above then run with
the whole machine. Do not treat `/system_stats`'s 122 GB as an invitation.
## See Also ## See Also
@@ -129,6 +129,12 @@ bind-mounted to `/usr/local/bin/comfyui-healthcheck.py` and run as `HealthCmd`
- Do not let pip reinstall the torch family. The Containerfile filters it out on purpose. - Do not let pip reinstall the torch family. The Containerfile filters it out on purpose.
- The base image is about 32 GiB (`podman image inspect localhost/comfyui-rocm:gfx1151` → 33,765,227,471 B ≈ 31.4 GiB, 2026-10-18). A code update means a rebuild, because the code is baked in. - The base image is about 32 GiB (`podman image inspect localhost/comfyui-rocm:gfx1151` → 33,765,227,471 B ≈ 31.4 GiB, 2026-10-18). A code update means a rebuild, because the code is baked in.
- The empty-models note from the 2026-09-16 era is retired: `/home/dank/comfyui-data/models` now holds the Qwen-Image 2.1 payload — three `diffusion_models`, three `text_encoders`, two VAEs (~35 GB, `ls -l` 2026-10-18), listed in [[ComfyUI Qwen-Image Workflows]]. New model classes (LoRA, ControlNet) still need their directories created first: `models/lora` and `models/controlnet` do not exist yet. - The empty-models note from the 2026-09-16 era is retired: `/home/dank/comfyui-data/models` now holds the Qwen-Image 2.1 payload — three `diffusion_models`, three `text_encoders`, two VAEs (~35 GB, `ls -l` 2026-10-18), listed in [[ComfyUI Qwen-Image Workflows]]. New model classes (LoRA, ControlNet) still need their directories created first: `models/lora` and `models/controlnet` do not exist yet.
- **The 122 GB `/system_stats` VRAM figure is the GTT aperture, not free
memory.** On the APU it is system RAM, and frank-dragoon pins most of it
(`MemAvailable` 15.3 GiB with the endpoint live, measured 2026-10-21). A
Qwen-Image-class render beside the running endpoint is thrash-or-fail; the
stop-mask-render-verify procedure and the fit table are in
[[ComfyUI Qwen-Image Workflows]] → What actually fits beside frank-dragoon.
## See Also ## See Also
@@ -1,35 +1,36 @@
--- ---
type: reference type: reference
description: "The engine behind the fleet's speech endpoint: its runtime, private bridge, and the surface it publishes." description: "The engine behind the fleet's speech endpoint: its Vulkan build on the iGPU, its private bridge, and what it publishes."
tags: tags:
- dankclaw - dankclaw
- tts - tts
- voice - voice
- quadlet - quadlet
- cpu - vulkan
timestamp: 2026-10-18 timestamp: 2026-10-19
--- ---
# Frank TTS Engine # Frank TTS Engine
`frank-tts-engine` is the **arm A text-to-speech engine** on Dankclaw: [qwentts.cpp](https://github.com/ServeurpersoCom/qwentts.cpp)'s resident `tts-server` HTTP process, holding the Qwen3-TTS **1.7 B base talker (Q4_K_M)** and the **12 Hz tokenizer (Q8_0)** in RAM once and synthesizing from there. It listens on container port **8880** and is **deliberately not a fleet endpoint** — the endpoint consumers address is `frank-tts` on **7772**, which resolves voice names, re-encodes to mp3 and owns the timeout budget. This page is the engine runbook; the endpoint's contract lives with its own page. `frank-tts-engine` is the **arm A text-to-speech engine** on Dankclaw: [qwentts.cpp](https://github.com/ServeurpersoCom/qwentts.cpp)'s resident `tts-server` HTTP process, holding the Qwen3-TTS **1.7 B base talker (Q4_K_M)** and the **12 Hz tokenizer (Q8_0)** loaded once and synthesizing from there — **on the gfx1151 iGPU** (ggml's Vulkan backend, `--device Vulkan0`) since 2026-10-19, and in host RAM before that. It listens on container port **8880** and is **deliberately not a fleet endpoint** — the endpoint consumers address is `frank-tts` on **7772**, which resolves voice names, re-encodes to mp3 when asked, and owns the timeout budget. This page is the engine runbook; the endpoint's contract lives with its own page.
**Deployed 2026-10-18.** The chain ran in the order at the bottom of this page — fetch (1.4 GiB, byte + LFS oid verified, 18 s), image build against the pinned commit, unit + network install, container-health gate, warm pass — and the live state still reads the way this page describes it: unit `active` with no drop-ins, container healthy 31 s after the build, **4.0 GiB** resident on a host that kept 17 GiB available, `3 voice(s) registered and warm` in the journal, and no published port anywhere. Two things the first real boot taught: the warm hook's registration body must reach curl through a **file**, not an argument (Troubleshooting), and `STEP 3/13 RUN useradd` is the first honest proof of the image's non-root user. **Deployed 2026-10-18, cut over to the iGPU on 2026-10-19.** The first chain ran in the order at the bottom of this page — fetch (1.4 GiB, byte + LFS oid verified), image build against the pinned commit, unit + network install, container-health gate, warm pass — and the live state still reads the way this page describes it: unit `active` with no drop-ins, container healthy shortly after the build, `3 voice(s) registered and warm` in the journal, and no published port anywhere. Two things the first real boot taught: the warm hook's registration body must reach curl through a **file**, not an argument (Troubleshooting), and `STEP 3/13 RUN useradd` is the first honest proof of the image's non-root user.
**Host:** Dankclaw (`192.168.0.143`), rootless Podman, user `dank` — **Port:** 8880, **never published** — **Network:** the private `systemd-frank-tts-engine` bridge (10.89.5.0/24) and no other — **Deployer:** `frank-tts-engine` — **Consumers:** `frank-tts` (7772) and, through it, every voice request in the fleet including [[Open Notebook]]'s podcast renders. **Host:** Dankclaw (`192.168.0.143`), rootless Podman, user `dank` — **Port:** 8880, **never published** — **Network:** the private `systemd-frank-tts-engine` bridge (10.89.5.0/24) and no other — **Deployer:** `frank-tts-engine` — **Consumers:** `frank-tts` (7772) and, through it, every voice request in the fleet including [[Open Notebook]]'s podcast renders.
## Facts (verified 2026-10-18 against the pinned upstream commit, the Hub, and the CPU trial on this host) ## Facts (verified 2026-10-18 against the pinned upstream commit, the Hub and the CPU trial; the device rows re-verified on the live iGPU shape 2026-10-19)
| Item | Value | | Item | Value |
|------|-------| |------|-------|
| Engine | `tts-server` from qwentts.cpp, pinned at commit `5916bf7c79dbb4e66fc059cdc507a79a23a28bb4` (`dankclaw/voiceservice/Containerfile.engine`). The source is **not vendored**: the image clones that commit and its recorded `ggml` submodule, so the build needs outbound HTTPS and the `cfhash` label is what keeps an unchanged re-deploy offline | | Engine | `tts-server` from qwentts.cpp, pinned at commit `5916bf7c79dbb4e66fc059cdc507a79a23a28bb4` (`dankclaw/voiceservice/Containerfile.engine`). The source is **not vendored**: the image clones that commit and its recorded `ggml` submodule, so the build needs outbound HTTPS and the `cfhash` label is what keeps an unchanged re-deploy offline |
| Build line | `cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CCACHE=OFF -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF -DGGML_OPENMP=ON -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF -DGGML_VULKAN=OFF -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF && cmake --build build -j 8` — the exact line the 2026-10-18 trial ran on this box. `GGML_BLAS=OFF` is **forced**: Dankclaw has `libopenblas.so.0` and no BLAS dev headers, so upstream's own `Dockerfile` and `buildcpu.sh` (both of which pass BLAS on) cannot be copied. No `-DGGML_NATIVE` is passed, and ggml defaults it on, so the binary is `-march=native` for the machine that built it: **build this image on the host that runs it** | | Build line | `cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CCACHE=OFF -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF -DGGML_OPENMP=ON -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF -DGGML_VULKAN=ON -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF && cmake --build build -j 8`. **`GGML_VULKAN=ON` since 2026-10-19**, and that door needs three packages in the build stage: `libvulkan-dev`, `spirv-headers`, and **`glslc`** — which Ubuntu's `glslang-tools` does NOT ship (it ships `glslangValidator`, and `find_package(Vulkan COMPONENTS glslc REQUIRED)` fails without the package). The archive's `glslc` is 2023.8: `GL_KHR_cooperative_matrix` compiles, the coopmat2 / integer-dot / bf16 / micro-float shader families do not. `GGML_BLAS=OFF` is **forced**: Dankclaw has `libopenblas.so.0` and no BLAS dev headers, so upstream's own `Dockerfile` and `buildcpu.sh` (both pass BLAS on) cannot be copied. No `-DGGML_NATIVE` is passed, and ggml defaults it on, so the binary is `-march=native` for the machine that built it: **build this image on the host that runs it** |
| Image | built locally as `franksdank/frank-tts-engine:latest`, two stages from one digest-pinned base `docker.io/library/ubuntu:24.04@sha256:68ee22004a9109b44973313b02ff7b1de250f1de0f5ecb628d73f579aaff33ac` (index digest resolved 2026-10-18). Runtime stage: `libgomp1` (OpenMP is on), `curl` (the unit's `HealthCmd` runs as `/bin/sh -c`, and the warmer speaks HTTP), uid **10000**, `LD_LIBRARY_PATH=/app` because ggml builds shared by default and the link-time RPATH names the build tree | | Image | built locally as `franksdank/frank-tts-engine:latest`, two stages from one digest-pinned base `docker.io/library/ubuntu:24.04@sha256:68ee22004a9109b44973313b02ff7b1de250f1de0f5ecb628d73f579aaff33ac` (index digest resolved 2026-10-18). Runtime stage: `libgomp1` (OpenMP is on), `curl` (the unit's `HealthCmd` runs as `/bin/sh -c`, and the warmer speaks HTTP), `libvulkan1` + `mesa-vulkan-drivers` (the loader and the RADV ICD — a loader with no ICD enumerates ZERO devices, and the server then dies in device lookup after both GGUFs have been read), uid **10000**, `LD_LIBRARY_PATH=/app` because ggml builds shared by default and the link-time RPATH names the build tree. **No NVIDIA ICD on purpose**: exactly one Vulkan device in the enumeration is what makes the name `Vulkan0` safe to write down |
| Talker | `qwen-talker-1.7b-base-Q4_K_M.gguf`, **1 219 245 248 B**, sha256 `ea393ebaf2167ea23ce9fc18b093822851358a950d7075cd47ab4f6ce23e887d` (the Hub's LFS pointer oid) | | Talker | `qwen-talker-1.7b-base-Q4_K_M.gguf`, **1 219 245 248 B**, sha256 `ea393ebaf2167ea23ce9fc18b093822851358a950d7075cd47ab4f6ce23e887d` (the Hub's LFS pointer oid) |
| Tokenizer | `qwen-tokenizer-12hz-Q8_0.gguf`, **291 150 624 B**, sha256 `1883beeed99348fc35e23dd225e9082f93f6f8c109330a33d935baa8acdbfd94` | | Tokenizer | `qwen-tokenizer-12hz-Q8_0.gguf`, **291 150 624 B**, sha256 `1883beeed99348fc35e23dd225e9082f93f6f8c109330a33d935baa8acdbfd94` |
| Source of both | `https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF`, fetched by `scripts/download-frank-tts-engine-qwentts.sh` into `/home/dank/models/Qwen3-TTS-GGUF`, mounted read-only at `/models`. It has to be the **base** variant: the engine refuses every voice registration unless the model reports type `base`, so on `customvoice`/`voicedesign` the cloned-voice product does not exist. A 0.6 B talker (628 905 056 B) exists and is **not** the decided artifact | | Source of both | `https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF`, fetched by `scripts/download-frank-tts-engine-qwentts.sh` into `/home/dank/models/Qwen3-TTS-GGUF`, mounted read-only at `/models`. It has to be the **base** variant: the engine refuses every voice registration unless the model reports type `base`, so on `customvoice`/`voicedesign` the cloned-voice product does not exist. A 0.6 B talker (628 905 056 B) exists and is **not** the decided artifact |
| Memory | **5.7 GiB peak RSS** with both files resident (2026-10-18 trial). No `LimitMEMORY`/`MemoryMax`: a limit would turn a busy moment into an OOM kill in the middle of an utterance | | Device | `--device Vulkan0`: the gfx1151 iGPU, **shared with [[frank-dragoon]] as a co-tenant, never a handover**. Proven on the live pair, not inferred: `fuser -v /dev/dri/renderD129` lists `tts-server` beside `flash_serve`. Both gates (the RADV heap and dragoon's contention) are §5 of `report/tts-npu-spike-verdict-2026-10-19.md`, which also carries the change surface. **Rollback: `QWENTTS_DEVICE=` (empty) in `scripts/frank-tts-engine.env`, redeploy this one service** — the entrypoint then passes no `--device` at all, which is the CPU shape, no rebuild, nothing else restarts |
| Concurrency | `--max-batch 1`: a serialised FIFO. Warm RTF on this box is 0.55–0.67 against real time 1.0, and that margin is the entire headroom a live stream has; two concurrent syntheses split the same cores and the second caller's first byte additionally waits for the first utterance to finish | | Memory | **1.7 GiB host RSS** since the iGPU cutover — the weights ride the device. No `LimitMEMORY`/`MemoryMax`: a limit would turn a busy moment into an OOM kill in the middle of an utterance |
| Concurrency | `--max-batch 1`: a serialised FIFO. Warm RTF on the iGPU measures **0.20** (11.04 s of audio in 2.23 s) with `frank-dragoon` live on the same card, so a live stream has room to spare; a second concurrent synthesis would share the device with the first and make the caller behind it wait out the whole first utterance, so the FIFO stays a decision, not an accident |
| Network | `systemd-frank-tts-engine` (10.89.5.0/24, `dankclaw/voiceservice/quadlet/frank-tts-engine.network`) and **nothing else**, with **no `PublishPort` at all**. `frank-tts` joins the same bridge and dials `http://frank-tts-engine:8880` | | Network | `systemd-frank-tts-engine` (10.89.5.0/24, `dankclaw/voiceservice/quadlet/frank-tts-engine.network`) and **nothing else**, with **no `PublishPort` at all**. `frank-tts` joins the same bridge and dials `http://frank-tts-engine:8880` |
| Voices | `/home/dank/voices` mounted read-only at `/voices`, and it is the **warmer** that reads it, not the server: `tts-server` receives reference audio as base64 in a registration body and keeps its registry in memory only | | Voices | `/home/dank/voices` mounted read-only at `/voices`, and it is the **warmer** that reads it, not the server: `tts-server` receives reference audio as base64 in a registration body and keeps its registry in memory only |
| Health | `GET /health` → `{"status":"ok"}`, served by the HTTP thread rather than the synthesis queue, so a busy engine still reads healthy. Probed by the unit (`HealthCmd`, 30 s interval, 120 s start period) **inside the container's namespace**, because there is no published port to probe from outside | | Health | `GET /health` → `{"status":"ok"}`, served by the HTTP thread rather than the synthesis queue, so a busy engine still reads healthy. Probed by the unit (`HealthCmd`, 30 s interval, 120 s start period) **inside the container's namespace**, because there is no published port to probe from outside |
@@ -37,18 +38,11 @@ timestamp: 2026-10-18
## Why this engine, and not the alternatives ## Why this engine, and not the alternatives
The criterion Frank set on 2026-10-18 was **"whatever is better for live output"**, and live output is decided by time-to-first-audio and by whether the stream stays ahead of real time — not by benchmark throughput. RTF only has to stay under 1.0 with margin; what separates the arms is first-byte latency and **spread**. Measured on this host the same day (CPU; the full A/B is the section *"Decision: arm A for live output (2026-10-18)"* in , which is the source of these numbers): The criterion Frank set on 2026-10-18 was **"whatever is better for live output"**, and live output is decided by time-to-first-audio and by whether the stream stays ahead of real time — not by benchmark throughput. On that criterion the resident `tts-server` (arm A) beat the mainline per-request CLI path (arm B) on this box the same day, and the deciding property was **spread**: the resident server repeats the same first-audio latency every run, while the CLI path varies enough that some runs fall behind real time and a live listener hears the stream stall mid-sentence. That A/B was a CPU measurement and the endpoint has since moved to the iGPU, so the shape of the argument is what is kept here; no page carries its numbers any more, and they are not worth re-deriving.
| Measure | Resident `tts-server` (arm A, chosen) | Mainline per-request CLI path (arm B) | The rollback to the other backend stays in the repo untouched — `TTS_BACKEND=llama-tts-cli` in `scripts/frank-tts.env` still selects the per-request CLI engine, and that file's header documents what it costs.
|---|---|---|
| first audio byte | **86–451 ms** end to end; **94–112 ms** server-side | model load, per request |
| warm real-time factor while streaming | **0.55–0.67** | 0.93–0.99 |
| run-to-run spread | **±0.01** | ±0.25 |
| HTTP surface | serves `/v1/audio/speech` itself | none — façade CLI only |
The spread is the whole argument: at ±0.01 the engine finishes synthesizing 1.5–1.8× faster than the audio plays, every run; at ±0.25 some runs fall behind real time and a live listener hears the stream stall mid-sentence. The rollback path stays in the repo untouched — `TTS_BACKEND=llama-tts-cli` in `scripts/frank-tts.env` still selects the per-request CLI engine, and that file's header documents what it costs. ## Four laws this service lives under
## Three laws this service lives under
**1. The registry law → the unit warms at boot.** The voice registry is **memory only**: a restart forgets every voice, and re-registering one does **not** warm it — only synthesizing does. A boot that registers nothing leaves the façade answering 400 for every name, which is why `frank-tts-engine.container` carries **1. The registry law → the unit warms at boot.** The voice registry is **memory only**: a restart forgets every voice, and re-registering one does **not** warm it — only synthesizing does. A boot that registers nothing leaves the façade answering 400 for every name, which is why `frank-tts-engine.container` carries
@@ -64,10 +58,12 @@ frank-tts-engine-warm: voice narrator1 cold=1402ms warm=1812ms
frank-tts-engine-warm: 3 voice(s) registered and warm frank-tts-engine-warm: 3 voice(s) registered and warm
``` ```
**2. The thread law → nothing is ever pinned.** `tts-server` in this build has **no `-t`/`--threads` flag**: `src/backend.h` computes the CPU thread count as `std::thread::hardware_concurrency() / 2` (its comment: GEMM shares SIMD units across hyperthreads). Thread count therefore follows what the kernel *reports*, not what a cgroup *allows* — so `CPUQuota=`, `CPUAffinity=`, `taskset` or a slice silently changes the engine's GEMM tiling and its timings, with no log line. Nothing in this fleet pins this container. If a neighbour starves it, fix the neighbour. **2. The thread law → nothing is ever pinned.** `tts-server` in this build has **no `-t`/`--threads` flag**: `src/backend.h` computes the CPU thread count as `std::thread::hardware_concurrency() / 2` (its comment: GEMM shares SIMD units across hyperthreads). Thread count therefore follows what the kernel *reports*, not what a cgroup *allows* — so `CPUQuota=`, `CPUAffinity=`, `taskset` or a slice silently changes the engine's pool sizes and its timings, with no log line. The rule did not retire with the iGPU cutover: the binary still sizes those pools from what the kernel says, whatever the device ends up doing with the matmuls. Nothing in this fleet pins this container. If a neighbour starves it, fix the neighbour.
**3. Streaming is not buffered.** At the same seed, `response_format=pcm` (chunked, codec chunks pushed as they decode) and `response_format=wav` (buffered) differ in **92.2 % of samples**, −41.6 dB RMS. Nothing may compare the two paths, cache one against the other, or assume a seed pins output across `response_format`. `frank-tts` always asks for `wav`; the warmer asks for `wav`; the test seam is forbidden from pinning either. **3. Streaming is not buffered.** At the same seed, `response_format=pcm` (chunked, codec chunks pushed as they decode) and `response_format=wav` (buffered) differ in **92.2 % of samples**, −41.6 dB RMS. Nothing may compare the two paths, cache one against the other, or assume a seed pins output across `response_format`. `frank-tts` always asks for `wav`; the warmer asks for `wav`; the test seam is forbidden from pinning either.
**4. The device law → the card is shared, and the rollback is one line.** Synthesis computes on the gfx1151 **beside** [[frank-dragoon]], so a change to either tenant is a change to both. Two gates decide that pair, and both passed on 2026-10-19 with the other tenant live: the RADV heap a rootless container tenant is actually handed (not the ~2 GiB DRM carve-out), and dragoon's own first-token and decode rates with a synthesis in flight — unchanged. Their numbers, the recipe that measures them, and the full change surface are §5 of `report/tts-npu-spike-verdict-2026-10-19.md`; do not re-derive them here. What belongs on this page is the move: `QWENTTS_DEVICE=` (empty) in `scripts/frank-tts-engine.env`, re-deploy this one service, and synthesis is back on CPU — no rebuild, and dragoon never restarts.
## The voice registry contract ## The voice registry contract
A voice becomes known to the engine through `POST /v1/audio/voices`, and only these shapes are accepted: A voice becomes known to the engine through `POST /v1/audio/voices`, and only these shapes are accepted:
@@ -97,9 +93,9 @@ The **façade** on 7772 is the pair's public surface and carries the probe. Note
| `dankclaw/voiceservice/Containerfile.engine` | two-stage build of the pinned commit, proven cmake line, digest-pinned base, ships both scripts | | `dankclaw/voiceservice/Containerfile.engine` | two-stage build of the pinned commit, proven cmake line, digest-pinned base, ships both scripts |
| `dankclaw/voiceservice/tts-engine-start.sh` | image entrypoint (`/app/start.sh`): env → flags, refuses to boot without weights | | `dankclaw/voiceservice/tts-engine-start.sh` | image entrypoint (`/app/start.sh`): env → flags, refuses to boot without weights |
| `dankclaw/voiceservice/tts-engine-warm.sh` | boot warmer (`/app/warm-voices.sh`): register from `reference.wav` (body via a temp file), speak a fixed sentence twice, report cold/warm | | `dankclaw/voiceservice/tts-engine-warm.sh` | boot warmer (`/app/warm-voices.sh`): register from `reference.wav` (body via a temp file), speak a fixed sentence twice, report cold/warm |
| `dankclaw/voiceservice/quadlet/frank-tts-engine.container` | the unit: bridge only, no publish, env file, two read-only mounts, health probe, warm hook, `Restart=always` | | `dankclaw/voiceservice/quadlet/frank-tts-engine.container` | the unit: bridge only, no publish, env file, two read-only mounts, render access (`AddDevice=/dev/dri` + `GroupAdd=keep-groups`), health probe, warm hook, `Restart=always` |
| `dankclaw/voiceservice/quadlet/frank-tts-engine.network` | the private 10.89.5.0/24 bridge, with the subnet registry | | `dankclaw/voiceservice/quadlet/frank-tts-engine.network` | the private 10.89.5.0/24 bridge, with the subnet registry |
| `scripts/frank-tts-engine.env` | paths and numbers only, with the thread law and the registry law in its header | | `scripts/frank-tts-engine.env` | paths and numbers only, with the thread law, the registry law and the device law in its header |
| `scripts/download-frank-tts-engine-qwentts.sh` | weight fetcher: no resume, byte count + LFS oid check, `--verify-only` | | `scripts/download-frank-tts-engine-qwentts.sh` | weight fetcher: no resume, byte count + LFS oid check, `--verify-only` |
| `scripts/deploy-remote.sh` → `deploy_frank_tts_engine` | weights gate → build → quadlet → restart → container health → warm | | `scripts/deploy-remote.sh` → `deploy_frank_tts_engine` | weights gate → build → quadlet → restart → container health → warm |
| `tests/frank_tts_engine.bats` | the seam: generator output, entrypoint argv, warmer against a stub engine, planner and routing maps | | `tests/frank_tts_engine.bats` | the seam: generator output, entrypoint argv, warmer against a stub engine, planner and routing maps |
@@ -118,6 +114,25 @@ still open.
6. **Then the façade on 7772** (its own page), after the engine answered — that order is the whole point of step 6: publishing the façade in front of a dead engine would make every consumer fail at the first voice line. 6. **Then the façade on 7772** (its own page), after the engine answered — that order is the whole point of step 6: publishing the façade in front of a dead engine would make every consumer fail at the first voice line.
7. **Open:** [[Open Notebook]] is still not installed, so nothing renders a podcast yet; the voice leg it waits on now exists. 7. **Open:** [[Open Notebook]] is still not installed, so nothing renders a podcast yet; the voice leg it waits on now exists.
### 2026-10-19 — the iGPU cutover (PR #34, `6248f02`)
1. **Configure gate before touching anything** — the flag set, configured inside the
digest-pinned base on Dankclaw: `Found Vulkan: … found components: glslc` then
`Including Vulkan backend`. That probe is what caught `glslc` missing from
`glslang-tools`; the Containerfile's flag comment carries the finding.
2. **Deploy** — `./scripts/deploy.sh dankclaw --service frank-tts-engine`: image rebuild
(the `cfhash` moved), unit reinstall, health gate, warm pass
(`3 voice(s) registered and warm`), Playwright e2e 33/33.
3. **Prove the device, not the flag** — `sudo fuser -v /dev/dri/renderD129` lists
`tts-server` next to dragoon's `flash_serve`. Do this instead of reading the log: the
engine prints nothing about which backend it chose, so argv (`--device Vulkan0`) proves
intent and the render-node holder list proves use.
4. **Consumer path** — one `/v1/audio/speech` per voice through the façade on 7772; all
three answered with audio. The RTF figure in the Facts table comes from one engine-direct
WAV, where the duration is exact.
5. **Co-tenancy gate** — §5 of the verdict report: dragoon's first token and decode
unchanged with a 16.9 s synthesis occupying the card.
## Troubleshooting ## Troubleshooting
| Symptom | Meaning | Move | | Symptom | Meaning | Move |
@@ -131,3 +146,5 @@ still open.
| `podman exec` in the deploy says `no such file or directory` for `/app/warm-voices.sh` | the image predates the warmer | re-deploy: since 2026-10-18 `build_compose_image` hashes every file the image `COPY`s, not only `Containerfile.engine`, so editing the warmer rebuilds it — see | | `podman exec` in the deploy says `no such file or directory` for `/app/warm-voices.sh` | the image predates the warmer | re-deploy: since 2026-10-18 `build_compose_image` hashes every file the image `COPY`s, not only `Containerfile.engine`, so editing the warmer rebuilds it — see |
| The warm pass logs `registered, but the warm synthesis failed` for EVERY voice, while a hand-run `/app/warm-voices.sh` succeeds | `WARM_TEXT` arrived **with its quotes**. podman's `--env-file` does no shell parsing — it takes the literal remainder of the line — so the env-file reader got `"Warm up, one two three."` where `source` gives `Warm up, one two three.`; the quotes plus a backslash then make the synthesis body invalid JSON | the hook strips one matched pair and escapes what is left; prove the value with `podman exec frank-tts-engine env \| grep WARM_TEXT`. The seam pins both readers, because a test that only sources the env file cannot see this | | The warm pass logs `registered, but the warm synthesis failed` for EVERY voice, while a hand-run `/app/warm-voices.sh` succeeds | `WARM_TEXT` arrived **with its quotes**. podman's `--env-file` does no shell parsing — it takes the literal remainder of the line — so the env-file reader got `"Warm up, one two three."` where `source` gives `Warm up, one two three.`; the quotes plus a backslash then make the synthesis body invalid JSON | the hook strips one matched pair and escapes what is left; prove the value with `podman exec frank-tts-engine env \| grep WARM_TEXT`. The seam pins both readers, because a test that only sources the env file cannot see this |
| A `502` for `/v1/voices` where the engine answers `200` on `/health` from the same boot | the engine restarted and its in-memory registry is empty again — `/health` is model-loaded, NOT voices-registered | re-run `podman exec frank-tts-engine /app/warm-voices.sh`; that is the same distinction the `frank-tts` healthcheck enforces | | A `502` for `/v1/voices` where the engine answers `200` on `/health` from the same boot | the engine restarted and its in-memory registry is empty again — `/health` is model-loaded, NOT voices-registered | re-run `podman exec frank-tts-engine /app/warm-voices.sh`; that is the same distinction the `frank-tts` healthcheck enforces |
| Unit restarts every 10 s and the log ends on a **device** error *after* both GGUFs were read | the image has the Vulkan loader but no ICD, or the container has no render node — the device name then resolves to nothing | `podman exec frank-tts-engine ls /dev/dri` (it must see `renderD129`) and `podman exec frank-tts-engine dpkg -l \| grep -E 'mesa-vulkan\|libvulkan1'`; re-deploy to rebuild the image, or set `QWENTTS_DEVICE=` and re-deploy for the CPU shape |
| `frank-dragoon` slows while TTS speaks | the contention premise broke — a Mesa bump, a third GPU tenant, a bigger synthesis | `QWENTTS_DEVICE=` + re-deploy this service, then re-run the gate recipe in §5 of `report/tts-npu-spike-verdict-2026-10-19.md` before putting it back |
+23 -19
View File
@@ -21,7 +21,7 @@ never learn a path.
**Deployed 2026-10-18** — `./scripts/deploy.sh dankclaw --service frank-tts` **Deployed 2026-10-18** — `./scripts/deploy.sh dankclaw --service frank-tts`
both units `active`, no drop-ins, and the homepage card's `wip` gate came off in both units `active`, no drop-ins, and the homepage card's `wip` gate came off in
the same change. Probes on the deployed endpoint: `/health` answers the same change. Probes on the deployed endpoint: `/health` answers
`{"status":"ok","backend":"omni-http","voices":["narrator1","narrator2","narrator3"]}` `{"status":"ok","backend":"omni-http","voices":["dr.kern","garrickhagon","jeffhays","vaati","wilwheaton"]}`
and two different voices rendered the same sentence into **different** bytes and two different voices rendered the same sentence into **different** bytes
(24 kHz mono PCM16, 4.24 s, healthy RMS) — so name resolution is real, not a (24 kHz mono PCM16, 4.24 s, healthy RMS) — so name resolution is real, not a
pass-through of one default. The operator harness routes its `speech` role here; pass-through of one default. The operator harness routes its `speech` role here;
@@ -32,7 +32,7 @@ render needs the app installed, not this endpoint.
| --- | --- | | --- | --- |
| Host | Dankclaw (`192.168.0.143`), rootless podman, user unit `frank-tts.service` | | Host | Dankclaw (`192.168.0.143`), rootless podman, user unit `frank-tts.service` |
| Port | **7772**, published (`PublishPort=7772:7772`) — the rest of the `frank-*` port family is in [[Dankclaw Stack]] | | Port | **7772**, published (`PublishPort=7772:7772`) — the rest of the `frank-*` port family is in [[Dankclaw Stack]] |
| Routes | `GET /health`, `GET /v1/audio/voices`, `POST /v1/audio/speech`; everything else 404, and speech is POST-only | | Routes | `GET /health`, `GET /v1/audio/voices`, `POST /v1/audio/speech`; everything else 404, and speech is POST-only. The request's `model` field is **ignored** (measured 2026-10-19: `voxcpm2` and `tts-1` both synthesize the same way) — the voice comes solely from `voice`, so a consumer that must name a model may name it anything |
| Image | `localhost/franksdank/frank-tts:latest`, built from `dankclaw/voiceservice/Containerfile`, `Pull=never` | | Image | `localhost/franksdank/frank-tts:latest`, built from `dankclaw/voiceservice/Containerfile`, `Pull=never` |
| Networks | `dankclaw_default` first (it owns the default route and the consumers), then `frank-tts-engine.network` (10.89.5.0/24, the bridge to the engine) | | Networks | `dankclaw_default` first (it owns the default route and the consumers), then `frank-tts-engine.network` (10.89.5.0/24, the bridge to the engine) |
| Voice packs | `/home/dank/voices` mounted read-only at `/voices` — host state, never repo state | | Voice packs | `/home/dank/voices` mounted read-only at `/voices` — host state, never repo state |
@@ -42,8 +42,8 @@ render needs the app installed, not this endpoint.
## Naming a voice ## Naming a voice
A voice **is** a directory name. `voice=narrator1` resolves to A voice **is** a directory name. `voice=wilwheaton` resolves to
`/voices/narrator1/reference.wav`, and that file — not a manifest, not a config `/voices/wilwheaton/reference.wav`, and that file — not a manifest, not a config
entry — is what makes the name real. omp's TTS device may send `voice_id`; the entry — is what makes the name real. omp's TTS device may send `voice_id`; the
shim accepts either. A call that sends **neither** — the harness's normal shape, shim accepts either. A call that sends **neither** — the harness's normal shape,
because no harness setting carries a voice for this transport — speaks because no harness setting carries a voice for this transport — speaks
@@ -59,10 +59,11 @@ loudly instead of letting it half-work.
Names are user-visible: an unknown voice comes back as Names are user-visible: an unknown voice comes back as
`400 unknown voice 'x'; available voices: …`, and that list is the honest `400 unknown voice 'x'; available voices: …`, and that list is the honest
census. The podcast speakers are `narrator1`, `narrator2`, `narrator3` — census. As of 2026-10-20 the live packs are `dr.kern`, `garrickhagon`,
[[Open Notebook]] is configured with exactly those three, so renaming them `jeffhays`, `vaati`, `wilwheaton`. [[Open Notebook]]'s `dank-duo` profile speaks
breaks episode rendering, not just speech. A personal voice gets the person's `wilwheaton` (Frank) and `vaati` (Comrade), and `DEFAULT_VOICE` is `wilwheaton`.
name, lowercase. Renaming a pack that a speaker profile names breaks episode rendering, not just
speech. A personal voice gets the person's name, lowercase.
## Voice packs ## Voice packs
@@ -113,16 +114,19 @@ shows up as a failed synthesis, never as a down probe.
## Where the engine computes ## Where the engine computes
CPU, and only CPU is compiled in: `Containerfile.engine` builds `qwentts.cpp` with The **gfx1151 iGPU**, as a co-tenant beside `frank-dragoon`: the Vulkan build of
`-DGGML_VULKAN=OFF` written out explicitly, so the container carries `libggml-cpu` and no `qwentts.cpp` with `--device Vulkan0`, and `/dev/dri` handed to the engine container. That is
other backend. The realistic accelerator is the gfx1151 iGPU behind that flag — plus the shape since 2026-10-19. The flag, the device name, the render-node trap on this hybrid box
`/dev/dri` passthrough, the ComfyUI pattern — on the same payload and the same pinned and the one-line CPU rollback are owned by [[Frank TTS Engine]] — read that page before
commit, and the intended shape is **co-tenancy**: the engine resident on the card beside touching either tenant, because a change to one is a change to both. Co-tenancy was gated with
`frank-dragoon`, not a handover. Memory is not what decides that; shader contention is, and `frank-dragoon` live on the card (its first token and decode rates unchanged with a synthesis
the census plus both gates are in `report/tts-npu-spike-verdict-2026-10-19.md` §5. in flight); both gate recipes are §5 of `report/tts-npu-spike-verdict-2026-10-19.md`.
**The Strix Halo NPU is not a door at all**: XDNA2 executes only precompiled AIE
partition programs, and AMD's own Linux runtime opens `/dev/accel/accel0`, succeeds at the **The NPU is a door only for a model the vendor has already compiled for it.** XDNA2 runs nothing but
ioctls, then declines this model family's graph without emitting a single log line. The precompiled AIE partition programs, and AMD's own Linux runtime opens `/dev/accel/accel0`, succeeds at the
ioctls, then declines this model family's graph without emitting a single log line. That is why TTS stays on
the GPU — and it is not a general veto on the device: halogen ships its own precompiled catalog (embeddings,
decisions, rerankers) that does run there, which is what [[frank-dragoon]]'s NPU-lane section is about. The
measured record and the model-side contract live in `report/` at the repo root measured record and the model-side contract live in `report/` at the repo root
(`tts-npu-spike-verdict-2026-10-19.md`). (`tts-npu-spike-verdict-2026-10-19.md`).
@@ -218,5 +222,5 @@ harness bundle and measured the day of the flip:
| Endpoint answers, synthesis fails | the engine: `podman healthcheck run frank-tts-engine`, then `systemctl --user status frank-tts-engine.service` | | Endpoint answers, synthesis fails | the engine: `podman healthcheck run frank-tts-engine`, then `systemctl --user status frank-tts-engine.service` |
| First request of the day is ~0.6 s slower than the rest — or every name returns 400 | the warm pass did not run; it carries `-`, so it cannot fail the unit. `journalctl --user -u frank-tts-engine` for the warmer's lines, then `podman exec frank-tts-engine /app/warm-voices.sh` — the registry is memory-only (law 1 of [[Frank TTS Engine]]) | | First request of the day is ~0.6 s slower than the rest — or every name returns 400 | the warm pass did not run; it carries `-`, so it cannot fail the unit. `journalctl --user -u frank-tts-engine` for the warmer's lines, then `podman exec frank-tts-engine /app/warm-voices.sh` — the registry is memory-only (law 1 of [[Frank TTS Engine]]) |
| The unit never appears after a deploy | the generator dropped it: `frank-tts-engine.network` is missing from `~/.config/containers/systemd/` | | The unit never appears after a deploy | the generator dropped it: `frank-tts-engine.network` is missing from `~/.config/containers/systemd/` |
| A consumer rejects `narrator1` by listing 29 names (`af_heart … bm_fable`) and no request ever reaches this endpoint | that consumer is synthesizing **in the browser** with Kokoro.js, whose voice pack is fixed — OpenWebUI 0.11.4 ships it as the `browser-kokoro` TTS engine; see [[OpenWebUI]] | | A consumer rejects `wilwheaton` by listing 29 names (`af_heart … bm_fable`) and no request ever reaches this endpoint | that consumer is synthesizing **in the browser** with Kokoro.js, whose voice pack is fixed — OpenWebUI 0.11.4 ships it as the `browser-kokoro` TTS engine; see [[OpenWebUI]] |
--- ---
+74 -27
View File
@@ -6,25 +6,26 @@ tags:
- open-notebook - open-notebook
- podcasts - podcasts
- quadlet - quadlet
timestamp: 2026-10-18 timestamp: 2026-10-19
--- ---
# Open Notebook # Open Notebook
open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook)) is the notebook layer that turns existing notes and sources into a **spoken episode**: an outline, then a dialogue transcript, then one synthesized clip per speaker turn, concatenated with ffmpeg into an MP3. In the fleet it is the consumer that closes the loop Frank's audio stack was built for — the script is written by **frank-zealot**, the voices are rendered by **frank-tts**, and nothing leaves the LAN. open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook)) is the notebook layer that turns existing notes and sources into a **spoken episode**: an outline, then a dialogue transcript, then one synthesized clip per speaker turn, concatenated with ffmpeg into an MP3. In the fleet it is the consumer that closes the loop Frank's audio stack was built for — the script is written by **frank-archon**, the voices are rendered by **frank-tts**, and nothing leaves the LAN.
**It is NOT deployed.** This page documents repo-owned configuration — the Quadlet units, the env file, the deployer path, the homepage card — and the contract those files were written against. On 2026-10-18 no `open-notebook` or `open-notebook-surrealdb` container exists on Dankclaw (`podman ps -a` census, 2026-10-18), and the homepage card ships `wip: "down"` with `showLink: false`, so the card is hidden and reported `wip-down`. Re-censused 2026-10-19 and unchanged: no `*notebook*` unit in `systemctl --user list-unit-files`, nothing installed under `~/.config/containers/systemd/` by that name, no container in `podman ps` — the repo's three quadlet files have never been installed on the host. Deploying it is a deliberate next step, not a pending one. **Deployed 2026-10-19.** Both units installed and `active`, both containers healthy, SurrealDB migrated to v25 on first boot, and the first episode rendered end to end: a note in, an outline, 23 dialogue turns, 23 clips, and one **747-second MP3 on the host bind, produced in 301 seconds** — about 2.5× real time with every concurrency knob at 1. The download route answers `200 audio/mpeg` with the bearer token and **401 without it**. It is guarded like every other user service — `https://notebook.franksdank.cloud`, Anubis → Authentik → the app's own login — and the API is no longer published on the LAN at all.
**Host:** Dankclaw (rootless Podman, user `dank`) — **Ports:** 8502 UI and 5056 API, published on the LAN, **no vhost and no Caddy route** — **Deployer:** `open-notebook` (one service, two units) — **Docs:** https://github.com/lfnovo/open-notebook **Host:** Dankclaw (rootless Podman, user `dank`) — **User path:** `https://notebook.franksdank.cloud` (guarded) — **Published:** `0.0.0.0:8502` (the only upstream Caddy dials) and `127.0.0.1:5056` (the deployer's health gate, nothing else) — **Deployer:** `open-notebook` (one service, two units) — **Docs:** https://github.com/lfnovo/open-notebook
## Facts (verified 2026-10-18 against the pinned images, upstream source, and the live host) ## Facts (images and upstream source verified 2026-10-18; live-host columns re-read 2026-10-19 after deploy)
| Item | Value | | Item | Value |
|------|-------| |------|-------|
| App image | `docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212` (`open-notebook.container:79`) — the tag upstream's own `docker-compose.yml:27` pins for the multi-container shape. `v1-latest` is a moving tag, so the manifest-list digest (resolved from the registry 2026-10-18) is what makes this reproducible | | App image | `docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212` (`open-notebook.container:79`) — the tag upstream's own `docker-compose.yml:27` pins for the multi-container shape. `v1-latest` is a moving tag, so the manifest-list digest (resolved from the registry 2026-10-18) is what makes this reproducible |
| Database image | `docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723` (`open-notebook-surrealdb.container:66`) — that digest is **SurrealDB 2.7.0** (image built 2026-09-23; measured by running the digest: `surreal version`) | | Database image | `docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723` (`open-notebook-surrealdb.container:66`) — that digest is **SurrealDB 2.7.0** (image built 2026-09-23; measured by running the digest: `surreal version`) |
| Do NOT use | the `-single` tags (`v1-latest-single`). That variant bundles SurrealDB inside the app image and is deprecated upstream; here it would give the host a second, hidden database | | Do NOT use | the `-single` tags (`v1-latest-single`). That variant bundles SurrealDB inside the app image and is deprecated upstream; here it would give the host a second, hidden database |
| Ports | container 8502 (Next.js UI) + 5055 (FastAPI API) → published `0.0.0.0:8502:8502` and `0.0.0.0:5056:5055` (`open-notebook.container:83-84`). **Host 5055 is taken**: seerr publishes `127.0.0.1:5055` (`dankclaw/seerr/quadlet/seerr.container`, and `ss -ltn` on Dankclaw shows `127.0.0.1:5055` live, 2026-10-18). The API therefore leaves on 5056, which is what forces `API_URL` (below) | | Ports | container 8502 (Next.js UI) + 5055 (FastAPI API) → published `0.0.0.0:8502:8502` and `127.0.0.1:5056:5055` (`open-notebook.container`). **Host 5055 is taken**: seerr publishes `127.0.0.1:5055` (`dankclaw/seerr/quadlet/seerr.container`, and `ss -ltn` on Dankclaw shows it live). The API therefore leaves on 5056, and on loopback only: the vhost is its sole user path and the image's own Next.js layer rewrites `/api/*` to it, so nothing outside the container needs it |
| User path | `https://notebook.franksdank.cloud` → `anubis:8923` → post-auth listener `@notebook` → `192.168.0.143:8502` (`dankclaw/Caddyfile`); Authentik provider `notebook-proxy` (`external_host` the vhost with its trailing slash, `internal_host http://caddy:8017`); LAN record in `dankclaw/pihole/addn-hosts` |
| Database port | 8000 inside its own namespace, **never published** (`open-notebook-surrealdb.container` has no `PublishPort`). Upstream publishes `127.0.0.1:8000` for debugging (`docker-compose.yml:18`); this unit does not, and the seam asserts that it stays that way | | Database port | 8000 inside its own namespace, **never published** (`open-notebook-surrealdb.container` has no `PublishPort`). Upstream publishes `127.0.0.1:8000` for debugging (`docker-compose.yml:18`); this unit does not, and the seam asserts that it stays that way |
| Data | `open-notebook-data:/app/data` (named volume: uploads, LangGraph SQLite checkpoints, extraction caches) **plus** the interim host bind `/home/dank/media/podcasts:/app/data/podcasts` (`open-notebook.container:87-88`); database in the `open-notebook-db` named volume | | Data | `open-notebook-data:/app/data` (named volume: uploads, LangGraph SQLite checkpoints, extraction caches) **plus** the interim host bind `/home/dank/media/podcasts:/app/data/podcasts` (`open-notebook.container:87-88`); database in the `open-notebook-db` named volume |
| Networks | app: `dankclaw_default` first, then the private `systemd-open-notebook` (10.89.4.0/24, `open-notebook.network:33`); database: the private bridge only. Order is load-bearing — the generated `--network` flags keep file order, so the default route rides the network where `host.containers.internal` is proven | | Networks | app: `dankclaw_default` first, then the private `systemd-open-notebook` (10.89.4.0/24, `open-notebook.network:33`); database: the private bridge only. Order is load-bearing — the generated `--network` flags keep file order, so the default route rides the network where `host.containers.internal` is proven |
@@ -33,6 +34,19 @@ open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook))
| Env | `scripts/open-notebook.env` (repo, knobs) then `/home/dank/Containers/open-notebook/env` (host, secrets, mode 600) — two `EnvironmentFile=` lines per unit, repo file **first** because the last file wins | | Env | `scripts/open-notebook.env` (repo, knobs) then `/home/dank/Containers/open-notebook/env` (host, secrets, mode 600) — two `EnvironmentFile=` lines per unit, repo file **first** because the last file wins |
| Deployer | `deploy_open_notebook` in `scripts/deploy-remote.sh`: seed secrets → ensure both digest pins are in local storage → install both units + the network → `mkdir -p /home/dank/media/podcasts` → restart database, wait for its container health → restart app → `wait_for_health open-notebook http://127.0.0.1:5056/health 180` | | Deployer | `deploy_open_notebook` in `scripts/deploy-remote.sh`: seed secrets → ensure both digest pins are in local storage → install both units + the network → `mkdir -p /home/dank/media/podcasts` → restart database, wait for its container health → restart app → `wait_for_health open-notebook http://127.0.0.1:5056/health 180` |
## The guard chain, and the single origin
`notebook.franksdank.cloud` follows the fleet's exposure policy in this order, and the order is the law ([[Network Architecture]]):
1. **Provider first.** Authentik proxy provider `notebook-proxy` existed before any route pointed at the app: `external_host https://notebook.franksdank.cloud/` (exact vhost, trailing slash), `internal_host http://caddy:8017`, the three flows copied from `embedded-outpost`, Application `Open Notebook` (`notebook-guard`), and the provider attached to the embedded outpost's provider set. Flip the vhost first and authenticated traffic lands on the outpost's error page. Verify after the change that the outpost logged no `Failed to fetch outpost from API` (the null-flow wedge) and that an already-guarded vhost still answers.
2. **Then the route, in one deploy:** the guarded site block (`reverse_proxy anubis:8923` with `header_up X-Real-Ip {remote_host}` — the whole request, so no path ever reaches the app unchallenged) plus the post-auth `@notebook` handler on `http://:8017`, plus the LAN record in `dankclaw/pihole/addn-hosts`. The public Porkbun A record is a host action, not a repo file.
3. **One upstream, not two.** `reverse_proxy 192.168.0.143:8502`: the image's own Next.js layer rewrites `/api/*` to the FastAPI beside it (`next.config.ts`, `INTERNAL_API_URL` default `http://localhost:5055`), so the API needs no route and no outward face — hence `127.0.0.1:5056`. Caddy dials the published LAN port rather than a container name because this Caddy lives in root's Podman and cannot see a rootless container at all ([[Container Network Namespaces]]). SSE (`/api/search/ask`, chat) streams through the same bare proxy that carries OpenWebUI's chat at `ai.franksdank.cloud`; no `flush_interval` or timeout directive is needed, and adding a route timeout is precisely what would cut it.
4. **`API_URL` must name the vhost.** `GET /config` (the Next route) answers in priority order: `API_URL` env → autodetect from `Host` + `X-Forwarded-Proto` → `http://localhost:5055`. Autodetect appends **`:5055`**, a port that belongs to seerr on this host, so leaving `API_URL` unset hands the browser an address nothing answers and every call fails under a UI that loaded fine. `CORS_ORIGINS` is set to the same single origin only to keep the app from defaulting to `*`. `scripts/open-notebook.env` carries both.
The app's own login stays on behind the chain. It is not redundant: publishing the API on loopback does **not** hide it from this host's other containers, which can dial `open-notebook:5055` by name on `dankclaw_default`. The chain decides *who from outside* gets in; `OPEN_NOTEBOOK_PASSWORD` decides what anything that already reached the port may do.
**Single tenant — there are no user accounts, so this is an admin-side tool.** Upstream ships one global password and nothing else: `PasswordAuthMiddleware` compares `Bearer <OPEN_NOTEBOOK_PASSWORD>`, and the SurrealDB schema has no user/account/owner table (verified in the running image, 2026-10-20). One shared password therefore means **one shared workspace** — the guard chain can gate the door but cannot partition the data, and Authentik identity is never consumed. Multi-user is *approved but unbuilt* upstream (issue **#712**, "design phase, targeted post-v2"); OIDC/SSO was declined for now (**#607** closed). Real per-person isolation means **one deployment per person**, not a flag to flip. For that reason the homepage lists open-notebook under **admin**, not as a user-facing app.
## What the image actually runs ## What the image actually runs
One container, three processes under supervisord (`supervisord.conf` upstream): `uvicorn api.main:app --port 5055` (`:12`), the job worker (`:25`), and the Next.js frontend (`:39`). Two consequences the config depends on: One container, three processes under supervisord (`supervisord.conf` upstream): `uvicorn api.main:app --port 5055` (`:12`), the job worker (`:25`), and the Next.js frontend (`:39`). Two consequences the config depends on:
@@ -44,38 +58,63 @@ Also from the image itself: `WORKDIR /app` and `EXPOSE 8502 5055` (`Dockerfile:8
`DATA_FOLDER` is hard-coded `"./data"` (`open_notebook/config.py:5`) and `PODCASTS_FOLDER` is `"./data/podcasts"` (`:19`); the database stores each episode's audio path **relative** to that folder (`episodes/<uuid>/audio/<uuid>.mp3`), and the download route is `GET /api/podcasts/episodes/{id}/audio`. That is why covering one subdirectory with a bind mount is sufficient — nothing in the database has to change if the mount moves. `DATA_FOLDER` is hard-coded `"./data"` (`open_notebook/config.py:5`) and `PODCASTS_FOLDER` is `"./data/podcasts"` (`:19`); the database stores each episode's audio path **relative** to that folder (`episodes/<uuid>/audio/<uuid>.mp3`), and the download route is `GET /api/podcasts/episodes/{id}/audio`. That is why covering one subdirectory with a bind mount is sufficient — nothing in the database has to change if the mount moves.
## Provisioning the providers (after first boot; UI or REST, never a unit) ## What is provisioned (REST, 2026-10-19)
Credentials are stored in the database, encrypted, and are **not expressible in env or a Quadlet file**. The sequence is one-time, in **Manage → Models**, and both entries use provider type **OpenAI Compatible** (upstream: any server that implements `/v1/audio/speech` works this way — `docs/5-CONFIGURATION/local-tts.md:5`). Credentials live in the database, encrypted with `OPEN_NOTEBOOK_ENCRYPTION_KEY`, and are **not expressible in env or a Quadlet file** — the UI (**Manage → Models**) or the REST API are the only doors. Both use provider type `openai_compatible`, because any server that answers `/v1/audio/speech` or `/v1/chat/completions` works through it (`docs/5-CONFIGURATION/local-tts.md:5`).
1. **TTS credential** — base URL **`http://frank-tts:7772/v1`**. The `/v1` is part of the URL: the client posts to `<base_url>/audio/speech`, so a base URL without `/v1` 404s on every dialogue line (`local-tts.md:72-80`, the base-URL table, always carries `/v1`). `frank-tts` resolves by container name because the app is attached to `dankclaw_default`, the network frank-tts's unit joins (`dankclaw/voiceservice/quadlet/frank-tts.container:51-52`). If frank-tts is ever moved off that network, the published-port form is `http://192.168.0.143:7772/v1` — a published port is dialable at the host's LAN address from any netns. The API-key field stays empty; frank-tts does not authenticate. | Record | Value |
2. **LLM credential** — base URL **`http://host.containers.internal:7771/v1`**, model row `frank-zealot`. frank-zealot is a **host process** on `0.0.0.0:7771` (`ss -ltn` on Dankclaw shows the listener with no container owning it, 2026-10-18), and on this netavark host a bridge container reaches the host's LAN IP only at a *published* port — which is why Prometheus scrapes the same endpoint as `host.containers.internal:7771` (`shared/prometheus/prometheus.yml:89-111`, "not their own LAN addresses"). See [[Container Network Namespaces]]. `http://192.168.0.143:7771/v1` is the variant to try only if the gateway name ever stops resolving; it is expected to be refused from a bridge netns. |--------|-------|
3. **Model rows are created by name.** Discovery is optional (see the next section): type the model id and confirm the add. One base URL per credential, so the TTS and LLM credentials are necessarily two separate configurations. | Credential `frank-tts` | modality `text_to_speech`, base URL `http://frank-tts:7772/v1`, no API key (the shim does not authenticate) |
| Credential `frank-archon` | modality `language`, base URL `http://192.168.0.235:7770/v1` |
| Credential `frank-dragoon NPU` | modality `embedding`, base URL `http://frank-dragoon-halogen:8731/v1`, dummy API key (the lane does not authenticate) — a container name on `dankclaw_default`, which is the address shape that clears the URL guard below |
| Model row `voxcpm2` | type `text_to_speech` on the frank-tts credential. The name is decorative: frank-tts ignores the request's `model` field and synthesizes whatever `voice` names ([[Frank TTS]]) |
| Model row `frank-archon` | type `language`; `PUT /api/models/defaults` points chat, transformation and large-context at it |
| Model row `qwen3-embedding-0.6b` | type `embedding` on the NPU-lane credential; `default_embedding_model` points at it, so every note save embeds through the XDNA2 lane ([[frank-dragoon]]) |
| Speaker profile `dank-duo` | two speakers, Frank → `voice_id: wilwheaton`, Comrade → `vaati`, `voice_model` = the TTS row |
| Episode profile `dank-daily` | `num_segments: 4`, `speaker_config` = `dank-duo`, outline and transcript LLM = the archon row, briefing that ends "Never invent numbers." |
**Verify reachability before saving**, from inside the container, because both addresses are netns-dependent: Order that works over REST: credentials → model rows (`POST /api/models` with `credential: <credential id>`) → `PUT /api/models/defaults` → speaker profile (`voice_model: <model id>`) → episode profile (`speaker_config` / `outline_llm` / `transcript_llm`). `POST /api/models/{id}/test` proves each row: the language row answered with a real completion from frank-archon; the TTS row reached frank-tts and came back `unknown voice 'alloy'; available voices: narrator1, narrator2, narrator3` — the app's own test path hard-codes an OpenAI voice, so that message *is* the proof the leg works.
**`POST /api/podcasts/generate` takes profile NAMES, not record ids.** `episode_profile: "dank-daily"` works; the `episode_profile:…` id returns a bare `500 Failed to submit podcast generation job`, and the actual reason (`Episode profile '<id>' not found`) appears only in `podman logs open-notebook`.
**The scriptwriter is frank-archon, not the planned frank-zealot, because two rules collide on the address.** Measured from inside the app's netns 2026-10-19:
- `192.168.0.143:7771` (frank-zealot, a host-netns listener) and `192.168.0.143:7770` (frank-dragoon, a **root**-published port) both refuse the connection; `host.containers.internal:7771` answers. Full census: [[Container Network Namespaces]].
- The app refuses to **store** that one working address: its URL guard rejects any base URL that resolves inside link-local `169.254.0.0/16` (`open_notebook/utils/url_validation.py`, "Link-local addresses are not allowed (metadata endpoints)"), unconditionally — no env var touches it, and `AddHost=` cannot dodge it because the check runs on the resolved address.
`http://192.168.0.235:7770/v1` — another machine, plain RFC1918 — was, when this was first written, the only fleet LLM address that passed both tests; a container name on `dankclaw_default` passes them as well (the embedding credential below is stored that way), but frank-archon stays the scriptwriter for the reason in the next paragraph. Cost: frank-archon runs `--max-num-seqs 2` and the operator session itself lives there, so a render is a slow-down for whoever else is talking to that endpoint, not a stall; `ESPERANTO_LLM_TIMEOUT=180` covers an outline or transcript call comfortably.
**frank-dragoon can write transcripts, but only greedily — and open-notebook cannot ask for greedy.** Measured 2026-10-19 against the live endpoint: `response_format` as `json_schema` or `json_object` returns schema-valid JSON at `temperature: 0` and a **400** at any sampled temperature — halogen decodes greedy under a schema, and because its sampler default is server-side, omitting `temperature` is sampled, not greedy. That 400 is the trap: esperanto's `is_json_schema_unsupported_error` matches the words *not implemented* in it, so `_SchemaFallbackModel` in `podcast_creator/nodes.py` reads it as "this endpoint has no schema support", downgrades to `json_object`, gets the same 400, and **fails the run** — esperanto's own rescue (drop `response_format` entirely) fires only on the different message `'response_format.type' must be 'json_schema'`. The full law is on [[frank-dragoon]]. And the fix is not reachable from here: a model record is `{credential, name, provider, type}` and a model response adds only timestamps (`GET /openapi.json`, `ModelCreate`/`ModelResponse`) — **there is no per-row config field**, no way to attach `temperature: 0` to one row from the UI or the API. Making `frank-dragoon` the writer would therefore need something in the path that rewrites request bodies, which is a heavier tenant than the contention it removes. **frank-archon stays the scriptwriter.** Reachability stopped being the objection on 2026-10-19 — the endpoint joined `dankclaw_default`, which is exactly why the embedding lane below is dialable by name — but no network change reaches the no-per-row-config wall.
**Embeddings run on the XDNA2 lane — provisioned 2026-10-19.** `qwen3-embedding-0.6b` answers `/v1/embeddings` at `http://frank-dragoon-halogen:8731/v1`, the flash container's own bridge address; a container name on `dankclaw_default` resolves to `10.89.1.39`, plain RFC1918, so it clears the URL guard that ruled out `host.containers.internal` above. The lane's shape (one program, the token ceiling, what it costs the Flash model) is owned by [[frank-dragoon]]; the two shapes that bite on the app side are open-notebook's own:
- **`PUT /api/models/defaults` stores the record ID, not the name.** `"default_embedding_model": "qwen3-embedding-0.6b"` is accepted and echoed back, and then every embed dies in the worker with `Model with ID qwen3-embedding-0.6b not found`. The value has to be the row's `model:…` id — the other defaults in that same object are ids, so mirror them.
- **`POST /api/embed` answers 500 while succeeding.** Upstream's `EmbedResponse.command_id` declares `str` and the worker hands it a `RecordID`, so FastAPI raises on serialization *after* the vector is written. The truth is the worker line `Successfully embedded note … in 0.19s`, never the HTTP code. Saving a note (`POST /api/notes`) submits `embed_note` by itself, so that route is only needed to backfill old rows.
Proof from the app's own paths, 2026-10-19: `POST /api/models/{id}/test` → `Embedding dimensions: 1024`; two notes with nothing in common, then `POST /api/search` with `type: "vector"` (the enum is `text` or `vector`; the score field is `similarity`) — "a yellow waterbird beside a glowing beacon tower" put the grebe note at **0.659** and the boiler note at 0.292, and "quarterly depreciation schedule for industrial boilers" inverted it to **0.906** / 0.318. Embed cost 0.19–0.43 s per note. Semantic search, source ingestion and notebook-context questions are live; podcast generation never needed any of it.
**Secrets never go in a command line.** The API's bearer token *is* `OPEN_NOTEBOOK_PASSWORD`, and a `-H "Authorization: Bearer …"` argument is world-readable in `/proc/<pid>/cmdline`; put it in a mode-600 curl config and pass `-K`:
```bash ```bash
podman exec open-notebook curl -sS http://frank-tts:7772/health PW=$(grep -m1 '^OPEN_NOTEBOOK_PASSWORD=' /home/dank/Containers/open-notebook/env | cut -d= -f2- | tr -d '"')
podman exec open-notebook curl -sS http://host.containers.internal:7771/v1/models cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: Bearer %s"\n' "$PW" > "$cfg"
curl -s -K "$cfg" http://127.0.0.1:5056/api/models | head -c 400
rm -f "$cfg"
``` ```
**The blocker moved; it did not vanish.** frank-tts and frank-tts-engine are **deployed** (2026-10-18) and answer on 7772, so the voice leg works end to end — `podman exec open-notebook curl -sS http://frank-tts:7772/health` is the check to run the day the app exists. What still blocks a render is open-notebook itself: not installed on this host yet. When it is, the two provider rows above are the only wiring, and the seeded speaker profiles carrying OpenAI voice names are the first trap.
**Why frank-zealot and not frank-dragoon:** podcast scriptwriting asks the model for a JSON-schema response (with a plain-JSON fallback). The decision rests on each endpoint's documented request surface ([[frank-zealot]], [[frank-dragoon]]): zealot's is a known-good target for that call shape, dragoon's structured-output behaviour is **unverified**, so it is the unproven alternative, not the fallback. `ESPERANTO_LLM_TIMEOUT=180` (the app's own default) covers an outline or transcript call on zealot comfortably; upstream's note is to keep it under 600 so a slow call fails with a clear error instead of a UI timeout.
## Speakers and voices: 1–4, and the IDs are free text ## Speakers and voices: 1–4, and the IDs are free text
A **speaker profile** carries one to four speakers (`docs/3-USER-GUIDE/creating-podcasts.md:27`), each with a personality and a **Voice ID that is typed exactly and passed through to the TTS request untouched** (`:29`) — there is no whitelist, no enum, no validation against a voice list. So the fleet's voice-pack names work verbatim: A **speaker profile** carries one to four speakers (`docs/3-USER-GUIDE/creating-podcasts.md:27`), each with a personality and a **Voice ID that is typed exactly and passed through to the TTS request untouched** (`:29`) — there is no whitelist, no enum, no validation against a voice list. So the fleet's voice-pack names work verbatim:
| Speaker | Voice ID to type | | Speaker | Voice ID to type |
|---------|------------------| |---------|------------------|
| Speaker 1 | `narrator1` | | Speaker 1 | `wilwheaton` |
| Speaker 2 | `narrator2` | | Speaker 2 | `vaati` |
| Speaker 3 | `narrator3` | | Speaker 3 | `dr.kern` |
Those are the pack names frank-tts resolves from `/home/dank/voices` (`scripts/frank-tts.env` header: a voice is real exactly when `/voices/<name>/reference.wav` exists). Two traps, both from upstream's shipped defaults: Those are the pack names frank-tts resolves from `/home/dank/voices` (`scripts/frank-tts.env` header: a voice is real exactly when `/voices/<name>/reference.wav` exists). Two traps, both from upstream's shipped defaults:
- The three seeded speaker profiles carry **OpenAI voice names** (`nova`, `alloy`, …) and **no voice model at all** — they show a "Setup required" badge, and generation fails with a voice error until they are edited (`creating-podcasts.md:13`, `:33`; `local-tts.md:89`). Add a `narrator1`/`narrator2` pair (or a third/fourth) rather than editing the seeded ones in place. - The three seeded speaker profiles carry **OpenAI voice names** (`nova`, `alloy`, …) and **no voice model at all** — they show a "Setup required" badge, and generation fails with a voice error until they are edited (`creating-podcasts.md:13`, `:33`; `local-tts.md:89`). Add a `wilwheaton`/`vaati` pair (or a third/fourth) rather than editing the seeded ones in place.
- Two speakers with the same voice are hard to follow (`creating-podcasts.md:89`). Distinct packs per speaker is the point of the 1–4 shape. - Two speakers with the same voice are hard to follow (`creating-podcasts.md:89`). Distinct packs per speaker is the point of the 1–4 shape.
## Two buttons that fail against frank-tts, and why that is cosmetic ## Two buttons that fail against frank-tts, and why that is cosmetic
@@ -92,9 +131,11 @@ Defaults are `TTS_BATCH_SIZE=5` and `OPEN_NOTEBOOK_WORKER_MAX_TASKS=5` (`docs/5-
The clock next to it: `ESPERANTO_TTS_TIMEOUT` (`:91`) is patience **per dialogue line**, not per episode — a three-minute episode is dozens of separate POSTs, and the tail of a long turn on a CPU engine goes past upstream's 300 s default. It is set to **660, one round-trip outside `SHIM_TIMEOUT=600`** in `scripts/frank-tts.env` (frank-tts's own per-request ceiling): nested budgets go inside-out — the same rule that file states for `LLAMA_TIMEOUT` — so a frank-tts give-up arrives labelled as frank-tts's error instead of the worker's own timeout, and re-measuring frank-tts cannot silently strand this number below it. An abandoned request does not cancel the engine process; it only strands the episode in "generating". The clock next to it: `ESPERANTO_TTS_TIMEOUT` (`:91`) is patience **per dialogue line**, not per episode — a three-minute episode is dozens of separate POSTs, and the tail of a long turn on a CPU engine goes past upstream's 300 s default. It is set to **660, one round-trip outside `SHIM_TIMEOUT=600`** in `scripts/frank-tts.env` (frank-tts's own per-request ceiling): nested budgets go inside-out — the same rule that file states for `LLAMA_TIMEOUT` — so a frank-tts give-up arrives labelled as frank-tts's error instead of the worker's own timeout, and re-measuring frank-tts cannot silently strand this number below it. An abandoned request does not cancel the engine process; it only strands the episode in "generating".
What the guard costs, measured on that first render (the deployed shape): 23 dialogue clips, one POST each, **10–11 s per clip** with both knobs at 1 → 301 s of wall clock for 747 s of speech, i.e. **2.5× real time**, box still responsive. That ratio is the budget to hold any future TTS engine to: halve the per-clip time, halve the episode.
## Secrets, and the difference between blank and unset ## Secrets, and the difference between blank and unset
`OPEN_NOTEBOOK_PASSWORD` unset means **authentication off** (`environment-reference.md:46`) — every API route, including the episode-audio download, answers anyone who can reach the published port. That is a real exposure on a LAN-published service with no vhost and no guard chain in front of it, which is why `deploy_open_notebook` refuses to start either unit unless the seeded host file carries a value for all four secret keys (`OPEN_NOTEBOOK_ENCRYPTION_KEY`, `OPEN_NOTEBOOK_PASSWORD`, `SURREAL_PASSWORD`, `SURREAL_PASS`). `OPEN_NOTEBOOK_ENCRYPTION_KEY` is separately load-bearing: it is **required to save any provider credential**, and changing it later makes stored keys undecryptable (`:44`). `OPEN_NOTEBOOK_PASSWORD` unset means **authentication off** (`environment-reference.md:46`) — every API route, including the episode-audio download, answers anyone who can reach it. Moving the API to loopback did not remove that exposure, it only removed the LAN from it: any container on `dankclaw_default` dials `open-notebook:5055` by name, so an unset password is an open episode library to every container on this host, guarded or not. That is why `deploy_open_notebook` refuses to start either unit unless the seeded host file carries a value for all four secret keys (`OPEN_NOTEBOOK_ENCRYPTION_KEY`, `OPEN_NOTEBOOK_PASSWORD`, `SURREAL_PASSWORD`, `SURREAL_PASS`). `OPEN_NOTEBOOK_ENCRYPTION_KEY` is separately load-bearing: it is **required to save any provider credential**, and changing it later makes stored keys undecryptable (`:44`).
The tracked file declares the secrets **empty** and the host file supplies them; podman applies `--env-file` arguments in order and the last one wins (verified against the generated `ExecStart` line, 2026-10-18). Reordering the two `EnvironmentFile=` lines in a unit would blank every credential — that is the trap the seam's ordering assertion exists for. The tracked file declares the secrets **empty** and the host file supplies them; podman applies `--env-file` arguments in order and the last one wins (verified against the generated `ExecStart` line, 2026-10-18). Reordering the two `EnvironmentFile=` lines in a unit would blank every credential — that is the trap the seam's ordering assertion exists for.
@@ -102,7 +143,7 @@ The database has the same trap in a different spelling: the app reads `SURREAL_P
## Episode destination: interim, and reversible (issue #7) ## Episode destination: interim, and reversible (issue #7)
**Episode files currently exist in two places by design.** The app's own state stays in the `open-notebook-data` named volume; finished audio additionally lands on the host, because `/home/dank/media/podcasts` is bind-mounted over `/app/data/podcasts` (`open-notebook.container:88`). That is the **interim** answer to where episodes live: the destination is an open decision tracked as **Gitea issue #7, labelled `human-only` — agents must not work it.** Until Frank settles it, episode MP3s are ordinary files in his home directory, readable without `podman unshare`, which the database volume would require (measured with the pinned SurrealDB image). **Episode files currently exist in two places by design.** The app's own state stays in the `open-notebook-data` named volume; everything the render produces additionally lands on the host, because `/home/dank/media/podcasts` is bind-mounted over `/app/data/podcasts` (`open-notebook.container:88`). The first render confirmed the layout: `episodes/<uuid>/clips/0001.mp3 … 00NN.mp3` (one file per dialogue line, written as the worker goes) and `episodes/<uuid>/audio/<uuid>.mp3` (the ffmpeg concatenation). A half-finished render is therefore visible and greppable from the host without `podman unshare`. That is the **interim** answer to where episodes live: the destination is an open decision tracked as **Gitea issue #7, labelled `human-only` — agents must not work it.** Until Frank settles it, episode MP3s are ordinary files in his home directory.
Reversibility is one line: delete the `Volume=/home/dank/media/podcasts:/app/data/podcasts` line and the episodes go back to living solely inside the named volume. Nothing else in the config assumes the bind. See [[Storage Policy]] for the repo's storage conventions this interim state deliberately bends. Reversibility is one line: delete the `Volume=/home/dank/media/podcasts:/app/data/podcasts` line and the episodes go back to living solely inside the named volume. Nothing else in the config assumes the bind. See [[Storage Policy]] for the repo's storage conventions this interim state deliberately bends.
@@ -118,10 +159,11 @@ podman pull docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f7
## Rollback ## Rollback
- **Nothing deployed (today's state):** revert the commits. There is no host state to undo — no unit, no container, no volume, no `/home/dank/media/podcasts` directory beyond what the deployer would create. - **Take it off the air, in the reverse of the deploy order:** delete the guarded site block and the `@notebook` handler from `dankclaw/Caddyfile` and deploy `caddy`; then in Authentik delete the `notebook-guard` Application and the `notebook-proxy` provider and drop it from the embedded outpost's provider set. A **route with no provider 502s** while a provider with no route is inert — that asymmetry is why the chain unwinds Caddy-first.
- **Deployed:** `systemctl --user stop open-notebook.service open-notebook-surrealdb.service`, then move the three files (`open-notebook.container`, `open-notebook-surrealdb.container`, `open-notebook.network`) out of `~/.config/containers/systemd/` into `~/.AGENTTRASH/` — never `rm` — and `systemctl --user daemon-reload`. The generated units disappear with their sources. - **Stop the app:** `systemctl --user stop open-notebook.service open-notebook-surrealdb.service`, then move the three files (`open-notebook.container`, `open-notebook-surrealdb.container`, `open-notebook.network`) out of `~/.config/containers/systemd/` into `~/.AGENTTRASH/` — never `rm` — and `systemctl --user daemon-reload`. The generated units disappear with their sources.
- **Kept on purpose:** the `open-notebook-data` and `open-notebook-db` named volumes and `/home/dank/media/podcasts`. Episode audio and the notebook database are user data; removing them is a separate, deliberate act, and episode files are host files precisely so they can outlive the container. - **Kept on purpose:** the `open-notebook-data` and `open-notebook-db` named volumes and `/home/dank/media/podcasts`. Episode audio and the notebook database are user data; removing them is a separate, deliberate act, and episode files are host files precisely so they can outlive the container.
- **Homepage:** the card is `wip: "down"` + `showLink: false`, so retiring the service needs no card surgery beyond setting it back to that state. - **Provider credentials are database rows**, encrypted with `OPEN_NOTEBOOK_ENCRYPTION_KEY`; no repo file mirrors them. Regenerating that key while the database volume survives makes every stored credential undecryptable and forces re-entry — so a rollback that keeps the volume keeps the key file with it.
- **Homepage:** the card test in `tests/open_notebook.bats` pins the live state (`showLink: true`, link = the vhost), so retiring the service edits that test in the same commit as the card flip back to `wip: "down"`.
## Citations ## Citations
@@ -130,3 +172,8 @@ podman pull docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f7
- Registry (2026-10-18): `lfnovo/open_notebook` tags (`v1-latest`, `latest`, the `-single` set) and manifest-list digests; `surrealdb/surrealdb` `v2` → 2.7.0 - Registry (2026-10-18): `lfnovo/open_notebook` tags (`v1-latest`, `latest`, the `-single` set) and manifest-list digests; `surrealdb/surrealdb` `v2` → 2.7.0
- Local measurements (2026-10-18, workstation, pinned digests): `surreal start --help` (credential and bind options carry `[env: SURREAL_*]`); running the pinned SurrealDB digest with env-only credentials (credential init in the log, correct login authenticates, wrong password refused, `/surreal is-ready` → `OK` exit 0, named volume owned by the mapped uid); the v1-compatible Quadlet generator dry-run of all three unit files (two `--env-file` in order, two `--network` in order, named volume + host bind, both health probes, `--pull never`) - Local measurements (2026-10-18, workstation, pinned digests): `surreal start --help` (credential and bind options carry `[env: SURREAL_*]`); running the pinned SurrealDB digest with env-only credentials (credential init in the log, correct login authenticates, wrong password refused, `/surreal is-ready` → `OK` exit 0, named volume owned by the mapped uid); the v1-compatible Quadlet generator dry-run of all three unit files (two `--env-file` in order, two `--network` in order, named volume + host bind, both health probes, `--pull never`)
- Host evidence (Dankclaw, 2026-10-18, read-only): `ss -ltn` (`127.0.0.1:5055` held by seerr, `0.0.0.0:7771` a host listener, nothing on 7772/8502/5056/8000), `podman ps -a` (no open-notebook container, `dankclaw_default` present), subnet registry from `dankclaw/open-webui/quadlet/open-webui.network` - Host evidence (Dankclaw, 2026-10-18, read-only): `ss -ltn` (`127.0.0.1:5055` held by seerr, `0.0.0.0:7771` a host listener, nothing on 7772/8502/5056/8000), `podman ps -a` (no open-notebook container, `dankclaw_default` present), subnet registry from `dankclaw/open-webui/quadlet/open-webui.network`
- Deploy + provisioning (Dankclaw, 2026-10-19): first-boot log — migrations 1→25, `Credentials were provided, and no root users were found. The root user 'open_notebook' will be created`, `api`/`worker`/`frontend` all RUNNING under supervisord; `GET :8502/config` → the `API_URL` value; `GET /api/providers` (the `openai_compatible` modality list); `GET /openapi.json` schemas for `CreateCredentialRequest` / `ModelCreate` / `SpeakerProfileCreate` / `PodcastGenerateRequest`; the POSTs listed above; the note-save worker error `No embedding model configured.`
- Embedding lane (Dankclaw, 2026-10-19): `POST /api/credentials` → `credential:7fdqz09tt0c4xp68x7cm`, `POST /api/models` → `model:p3ki598dydfinu93tbny`, `POST /api/models/{id}/test` → `Embedding dimensions: 1024`; worker lines `Successfully embedded note … in 0.43s` / `0.19s`; the name-not-id failure `Model with ID qwen3-embedding-0.6b not found`; the `/api/embed` 500 body (`EmbedResponse.command_id`, `input_value=RecordID(table_name=command…)`); `POST /api/search` `similarity` 0.6590/0.2921 and 0.9059/0.3176 for the two-note pair; `podman logs frank-dragoon-halogen` showing `POST /v1/embeddings 200` from `10.89.1.21`. Both probe notes deleted afterwards.
- First render (2026-10-19): job `command:9rdojq5ne7dj9caicq20`, `execution_time: 301.21 s`, `episode:7ft03thj98sn0ek9xf29`; `episodes/d5fed29b-33e9-40ac-b46a-e0537c56c256/clips/0001.mp3 … 0023.mp3`; `…/audio/d5fed29b-….mp3` = 11,957,420 bytes, mp3 24 kHz mono, `ffprobe` duration 747.31 s; `GET /api/podcasts/episodes/{id}/audio` → 200 `audio/mpeg` with the bearer, 401 without.
- Upstream code read inside the pinned image (2026-10-19): `frontend/next.config.ts:26-43` (the `/api/*` rewrite and the one-port claim), `frontend/src/app/config/route.ts:122-173` (`/config` priority order; autodetect appends `:5055`), `frontend/src/app/api/_sse-proxy.ts` (SSE proxied with `no-transform`), `open_notebook/utils/url_validation.py` (link-local refusal, `is_blocked_ip`, no environment override anywhere in the file).
- Guard chain (2026-10-19): provider `notebook-proxy` created through `podman exec -i authentik-server ak shell` with the flows copied from `embedded-outpost` (pk 1), Application `Open Notebook`/`notebook-guard`, embedded-outpost provider set 9 → 10; afterwards the outpost logged zero `Failed to fetch outpost from API` and `seerr`/`grafana` still answered 200. Before the route existed, `notebook.franksdank.cloud` resolved to nothing (`000`).
+34 -15
View File
@@ -34,15 +34,16 @@ https://github.com/open-webui/open-webui
The picker is built from the `model` table in `webui.db` (one row per model) The picker is built from the `model` table in `webui.db` (one row per model)
plus the `model_ids` of each **enabled** OpenAI connection, NOT from live plus the `model_ids` of each **enabled** OpenAI connection, NOT from live
connection discovery — so the picker shows exactly what is seeded there. connection discovery — so the picker shows exactly what is seeded there.
As of 2026-10-07 the picker exposes **exactly one model: `frank-zealot`** *(unverified Since 2026-10-21 the picker exposes **all three fleet models to every
2026-10-18: `webui.db` is the authority; the fleet itself serves all three ids account**: `frank-dragoon`, `frank-archon`, `frank-zealot` (per Frank: open
`frank-dragoon`/`frank-zealot`/`frank-archon`, per the cards in `frank-archon` to everyone; the other two were already public). Proven with
`shared/homepage/services.json`)*. a `role=user` token: `/api/models` lists all three ids and a chat turn to
`frank-archon` answers end-to-end.
| Connection (`openai.api_configs` idx) | URL as recorded 2026-10-09 | Enabled | `model_ids` | Endpoint page | | Connection (`openai.api_configs` idx) | `openai.api_base_urls` (live 2026-10-21) | Enabled | `model_ids` | Endpoint page |
|---|---|---|---|---| |---|---|---|---|---|
| 0 | `http://host.containers.internal:7770/v1` | no (2026-10-07) | `frank-dragoon` | [[frank-dragoon]] | | 0 | `http://host.containers.internal:7770/v1` | yes | `frank-dragoon` | [[frank-dragoon]] |
| 1 | `http://192.168.0.235:7770/v1` | no (2026-10-07) | `frank-archon` | [[frank-archon]] | | 1 | `http://192.168.0.235:7770/v1` | yes | `frank-archon` | [[frank-archon]] |
| 2 | `http://host.containers.internal:7771/v1` | yes | `frank-zealot` | [[frank-zealot]] | | 2 | `http://host.containers.internal:7771/v1` | yes | `frank-zealot` | [[frank-zealot]] |
The URLs in the first column are the DB's recorded values and predate the 2026-08-22 The URLs in the first column are the DB's recorded values and predate the 2026-08-22
@@ -50,9 +51,10 @@ bridge move; on this network they are wrong — see the reachability gotcha belo
is the corrected law. The picker row for `frank-zealot` was re-keyed on 2026-10-18 after is the corrected law. The picker row for `frank-zealot` was re-keyed on 2026-10-18 after
that day's payload swap (see [[frank-zealot]] for what was deployed and when). that day's payload swap (see [[frank-zealot]] for what was deployed and when).
The `model` table carries exactly two decoration rows, `frank-zealot` and The `model` table carries three decoration rows — `frank-zealot`,
`frank-dragoon`. Both the context length and the description live **inside `frank-dragoon`, `frank-archon` (200,000 context, added 2026-10-21).
JSON columns** — `params` holds `{"context": {"length": …}}` and `meta` holds Both the context length and the description live **inside JSON columns** —
`params` holds `{"context": {"length": …}}` and `meta` holds
`{"description": …}`; there is no `context_length` or `description` column, so `{"description": …}`; there is no `context_length` or `description` column, so
`sqlite3` one-liners against those names fail. `params.context.length` is a copy `sqlite3` one-liners against those names fail. `params.context.length` is a copy
of the endpoint's per-slot window, so the authoritative number lives on the of the endpoint's per-slot window, so the authoritative number lives on the
@@ -290,9 +292,12 @@ on large sizes ⇒ check ComfyUI resources; 401 ⇒ the token is bad.
## Text-to-speech (frank-tts) ## Text-to-speech (frank-tts)
TTS is the fleet's one OpenAI-compatible speech endpoint — TTS is the fleet's one OpenAI-compatible speech endpoint. The config DB
`http://host.containers.internal:7772/v1` (`audio.tts.openai.api_base_url` `audio.tts.*` is set to engine `openai`, `audio.tts.openai.api_base_url`
in the config DB, 2026-10-18), model `frank-tts`, voice `narrator1`. That `http://192.168.0.143:7772/v1`, `audio.tts.voice` `wilwheaton` (re-keyed
2026-10-20 from the retired `narrator1`; 7772 is a published port so
`host.containers.internal` also reaches it, but the LAN IP is the canonical
on-host form). That
port is the rootless quadlet `frank-tts` (this repo: port is the rootless quadlet `frank-tts` (this repo:
`dankclaw/voiceservice/quadlet/frank-tts.container` `dankclaw/voiceservice/quadlet/frank-tts.container`
`scripts/frank-tts.env`), and its shim resolves `voice=<name>` against `scripts/frank-tts.env`), and its shim resolves `voice=<name>` against
@@ -306,8 +311,8 @@ ships an in-page Kokoro implementation (chunk carrying a frozen voice table
`onnx-community/Kokoro-82M-v1.0-ONNX`, plus a personal *Kokoro.js dtype* `onnx-community/Kokoro-82M-v1.0-ONNX`, plus a personal *Kokoro.js dtype*
setting). Its engine select takes `""` (Default) or `browser-kokoro`; when setting). Its engine select takes `""` (Default) or `browser-kokoro`; when
`browser-kokoro` is chosen the page synthesizes locally and rejects any `browser-kokoro` is chosen the page synthesizes locally and rejects any
voice outside its fixed pack — so `narrator1` fails with voice outside its fixed pack — so `wilwheaton` fails with
`Voice: "narrator1" not found. Should be one of: af_heart, …` **without a `Voice: "wilwheaton" not found. Should be one of: af_heart, …` **without a
single request reaching the server**. Personal audio settings are single request reaching the server**. Personal audio settings are
browser: no `user.settings` row and no container env carries an audio browser: no `user.settings` row and no container env carries an audio
override, yet the toast still appears ⇒ the choice lives in that browser's override, yet the toast still appears ⇒ the choice lives in that browser's
@@ -377,6 +382,20 @@ neither.
name: /selected model/i })`) and the welcome text "How can I help you name: /selected model/i })`) and the welcome text "How can I help you
today?"; that snapshot is also the cheapest proof the picker is populated. today?"; that snapshot is also the cheapest proof the picker is populated.
- **2026-10-21 — picker opened to all three models, for everyone.** Per
Frank: `frank-archon` accessible to everyone. Three-leg repair on
`webui.db` (snapshot `webui.db.bak-20261021` first): connection idx 1
`enable: true`, a `model` row `frank-archon` (params
`{"context":{"length":200000}}`, meta description from the endpoint card),
and an `access_grant` row `(resource_type='model', resource_id='frank-archon',
principal_id='*', 'read')` — `principal_id='*'` is the public grant, not a
role row. Verified as a plain `role=user` token: `/api/models` lists all
three ids, chat to `frank-archon` answers. Token-mint gotchas: the backend
package imports only with cwd `/app/backend`, and `WEBUI_SECRET_KEY` must
come from **PID 1's environ** (`podman exec` sessions do not inherit the
app process env; the `.webui_secret_key` file happens to equal it — use it
explicitly, `podman exec -e`).
## See Also ## See Also
- [[Dankclaw Stack]] - [[Dankclaw Stack]]
+2 -2
View File
@@ -4,7 +4,7 @@ description: "Plex Media Server is a **containerized media server on Dankserver*
tags: tags:
- dankserver - dankserver
- plex - plex
timestamp: 2026-09-15 timestamp: 2026-10-21
--- ---
# Plex # Plex
@@ -24,7 +24,7 @@ public traffic can be fronted by Caddy like every other vhost.
| Item | Value | | Item | Value |
|------|-------| |------|-------|
| Runtime | podman quadlet, `plex.service` (user manager) — migrated from the native `plexmediaserver.service` OS-package unit (2026-09) | | Runtime | podman quadlet, `plex.service` (user manager) — migrated from the native `plexmediaserver.service` OS-package unit (2026-09) |
| Image | `lscr.io/linuxserver/plex:1.43.4.10903-e5521bd8c-ls325` (pinned; verified == the ghcr `latest` manifest digest at pin time) | | Image | `lscr.io/linuxserver/plex:1.43.5.11029-6bf8af3f0-ls329` (pinned; verified == the registry's `latest` manifest digest at pin time: `sha256:8f655bad…e9e2334`) |
| Data root | `/var/lib/plexmediaserver` bind-mounted **in place** as `/config` (RW) — the migration moved zero bytes | | Data root | `/var/lib/plexmediaserver` bind-mounted **in place** as `/config` (RW) — the migration moved zero bytes |
| Media | `/mnt/entropy` mounted at the same absolute path **read-only** — every library path in the PMS database stayed valid, zero re-scan | | Media | `/mnt/entropy` mounted at the same absolute path **read-only** — every library path in the PMS database stayed valid, zero re-scan |
| Identity | fleet rootless model — container root maps to the `dank` user (1000:1000); the deployer chowns the data root on first cutover | | Identity | fleet rootless model — container root maps to the `dank` user (1000:1000); the deployer chowns the data root on first cutover |
+80 -39
View File
@@ -3,70 +3,111 @@ type: reference
description: "Alloy is Grafana's log and telemetry shipper." description: "Alloy is Grafana's log and telemetry shipper."
tags: tags:
- infrastructure - infrastructure
timestamp: 2026-09-11 timestamp: 2026-10-20
--- ---
# Alloy # Alloy
Alloy is Grafana's log and telemetry shipper. It replaces Promtail (end of life as of 2026-03-02, https://grafana.com/docs/loki/latest/send-data/promtail/). It is wired to forward container and journal logs to Loki, and every unit in the path is healthy — but as of 2026-10-18 it is delivering **zero** log lines. See "Delivery state" below. Alloy is Grafana's log and telemetry shipper. It replaces Promtail (end of life
as of 2026-03-02, https://grafana.com/docs/loki/latest/send-data/promtail/). It
forwards the host journal — including the rootless Quadlet containers' logs —
to Loki. Unlike its observability siblings it runs as a **root system service on
the host network**, because a rootless container cannot read the journal (see
*Why root* below).
**Host:** Dankclaw (one instance per host) — **Port:** 12345 (loopback) — **Docs:** https://grafana.com/docs/alloy/latest **Host:** Dankclaw (one instance per host) — **UI/health:** 127.0.0.1:12345
(loopback) — **Docs:** https://grafana.com/docs/alloy/latest
## Facts ## Facts
| Item | Value | | Item | Value |
|------|-------| |------|-------|
| Image | `franksdank/alloy:latest` (local build: upstream + a static busybox for the healthcheck) | | Image | `localhost/franksdank/alloy:latest` — built rootless, then mirrored into the root image store by the deployer (a root build fails on DNS in this host's build container) |
| Published | `127.0.0.1:12345` → 12345 (its own UI/health port) | | Unit | `dankclaw/observability/quadlet/alloy.container` → system `alloy.service`, installed at `/etc/containers/systemd/` (root manager) |
| Unit | `dankclaw/observability/quadlet/alloy.container` → `alloy.service` | | Config | `shared/alloy/config.alloy` → `/opt/alloy/config/config.alloy` on every deploy |
| Config | `shared/alloy/config.alloy`, installed to `/opt/alloy/config/config.alloy` on every deploy (`scripts/deploy-remote.sh:281-284`) | | Network | `host` — required so it can reach Loki's `127.0.0.1:3100` publish |
| Inputs | host journald socket, podman container logs, `/var/log` | | UI | `--server.http.listen-addr=127.0.0.1:12345` (loopback; nothing off-host reaches it) |
| Output | `http://loki:3100/loki/api/v1/push` (`shared/alloy/config.alloy:13-17`) | | Inputs | host journal: `system.journal` + `user-*.journal` (root reads both) |
| Successor to | Promtail (EOL 2026-03-02, https://grafana.com/docs/loki/latest/send-data/promtail/) | | Output | `http://127.0.0.1:3100/loki/api/v1/push` |
| Boot persistence | `Restart=always`/`RestartSec=10` + `WantedBy=default.target` (`dankclaw/observability/quadlet/alloy.container:58-63`); installed at `~/.config/containers/systemd/alloy.container`, reporting `generated` + `active` (`systemctl --user is-enabled|is-active alloy` 2026-10-18) | | Boot persistence | `Restart=always`/`RestartSec=10` + `WantedBy=multi-user.target`; boot persistence is the `[Install]` section (generated system units cannot be `enable`d here) |
| Delivery | **Configured and healthy, currently delivering zero data** — see "Delivery state" | | Successor to | Promtail (EOL 2026-03-02) |
| Deploy status | root cutover built and tested in-repo (2026-10-20); the **live** instance is still the old rootless unit until `./scripts/deploy.sh dankclaw --service alloy` runs |
## Why Alloy and not Promtail ## Why root (the load-bearing decision)
Promtail reached end-of-life on 2026-03-02 and Grafana Labs directs new installs to Alloy. This stack uses Alloy from day one: no Promtail config or unit has ever existed in this repository (`git log -S promtail` over the whole history returns no commit that added one; the compose-era service of the same name was already Alloy — `dankclaw/observability/quadlet/alloy.container:6-7`). A rootless Alloy delivered **zero** lines, silently and with a green health
check. Two independent causes, both proven on the host 2026-10-20:
1. **The journal is not readable by a rootless container.** The files are
`root:systemd-journal 0640` (`group:adm`, `other::---`). Even bind-mounted, a
rootless container has no supplementary groups, so opening them returns
`DENIED`. `loki.source.journal` then sees an empty view and the counter just
sits at 0 — no error logged.
2. **Container logs live in the *user* journal, not `ctr.log`.** The rootless
Quadlet containers log via the journald driver into `user-1000.journal`; the
old file globs (`/var/lib/containers/.../ctr.log`, `/var/log/containers/*.log`)
point at a root-storage layout that does not exist here.
**Root fixes both at once.** As root the container opens every journal file
under the machine-id directory — `system.journal` *and* `user-1000.journal` —
so a single `loki.source.journal` carries host services and the rootless
containers' logs together. Validated with a throwaway root probe before writing
the unit: **31,825 journal lines read, 26,966 pushed to Loki in 20 s, zero
drops**, and Loki's label set went from empty to populated.
### The two mounts that make it work
- **`/etc/machine-id` (ro) — load-bearing.** `sd_journal` locates the journal
directory by the *local* machine id. Without it the container opens nothing and
the journal source reads exactly 0 lines — silently, health green. This single
mount is the whole difference between a working and an empty pipeline; do not
remove it. (A root probe without it reads 0; with it, 31,825.)
- **`Network=host`.** Loki publishes `127.0.0.1:3100` (loopback only). No bridge
container — rootless or root — can reach the host's loopback, so Alloy must
share the host network to push to `127.0.0.1:3100`. Consequence: the UI binds
`127.0.0.1` directly (no `PublishPort`), so it stays loopback-only as before.
## Configuration ## Configuration
`shared/alloy/config.alloy` is the source of truth: `shared/alloy/config.alloy` is the source of truth — one `loki.source.journal`
(labelled `job="systemd-journal"`) forwarding to the loopback `loki.write`
- Reads journald logs from the host's `/run/systemd/journal/socket`, labelled `job="systemd-journal"` (`shared/alloy/config.alloy:20-26`). endpoint. There is deliberately **no** container-file source: container stdout is
- Reads container logs from two globs: `/var/lib/containers/storage/overlay-containers/*/userdata/ctr.log` (`job="podman"`) and `/var/log/containers/*.log` (`job="containers"`) (`config.alloy:29-35`). in the journal.
- Forwards everything to `http://loki:3100/loki/api/v1/push` by container name on `dankclaw_default` (`config.alloy:13-17`).
## Volumes ## Volumes
| Container path | Host path | | Container path | Host path |
|----------------|-----------| |----------------|-----------|
| `/etc/alloy` | `/opt/alloy/config` | | `/etc/alloy` | `/opt/alloy/config` (ro) |
| `/run/systemd/journal/socket` | host journald socket (read-only) | | `/etc/machine-id` | host machine id (ro) — **required** |
| `/var/log` | host logs (read-only) | | `/run/systemd/journal/socket` | host journald socket (ro) |
| `/var/lib/containers` | podman storage (read-only) | | `/var/log/journal` | persistent journal files (ro) |
| `/run/log/journal` | runtime journal files (ro) |
## The UI port is published on purpose ## The UI stays on loopback
Alloy's UI listens on the container's own `127.0.0.1:12345` by default, and Alloy 1.x refuses to configure the `ui` service from a config block ("service ui does not support being configured", `shared/alloy/config.alloy:8-10`). Under compose that meant **no reachable health endpoint at all**: the homepage status card for alloy read `down` continuously and a deploy had nothing to wait on. The unit therefore passes `--server.http.listen-addr=0.0.0.0:12345` (`dankclaw/observability/quadlet/alloy.container:46`) and publishes `127.0.0.1:12345` (`alloy.container:47`, confirmed by `ss -ltnp` 2026-10-18) — same posture as the other three: reachable from the host and from Caddy's network, and from nothing off-host. Alloy has no authentication, which is exactly why it is not published wider. The homepage card probes that same loopback port (`shared/homepage/services.json:233`). Alloy 1.x refuses to configure the `ui` service from a config block, so the UI
address is set on the command line instead. Under the old rootless unit that
## Delivery state (2026-10-18): healthy, shipping nothing meant `0.0.0.0` inside the container plus a published `127.0.0.1:12345`; under
`Network=host` the listen-addr is `127.0.0.1:12345` directly — the host's
The unit is `active`, the container reports `healthy`, the log says `Alloy is ready`, and the pipeline carries no data at all. Measured on 2026-10-18: loopback, reachable from the host and from nothing off-host. Alloy has no
authentication, which is exactly why it is never published wider. The homepage
- `curl -s http://127.0.0.1:12345/metrics` → `loki_source_journal_target_lines_total{component_id="loki.source.journal.journal"} 0` and `loki_source_file_files_active_total{component_id="loki.source.file.containers"} 0`. The journal source logs no error; the counter simply never moves. The journal files it would have to read through the `/var/log` bind are `root:systemd-journal 0640` with an ACL (`ls -l /var/log/journal/64a287d7…/` 2026-10-18). card and the deploy health gate both dial `http://127.0.0.1:12345/-/healthy`.
- Both file globs were refused at start (`journalctl --user -u alloy`, 2026-10-18T08:25:27Z), verbatim: `failed to create source, skipping … component_id=loki.source.file.containers error="failed to tail file, stat failed: stat /var/lib/containers/storage/overlay-containers/*/userdata/ctr.log: permission denied"` and the same message for `/var/log/containers/*.log: no such file or directory`.
- Downstream that means Loki holds zero streams — `GET /loki/api/v1/labels` returns `{"status":"success"}` with no `data` — see [[Loki]].
No fix is claimed here: the unit, the config and the push endpoint are all correct as written, and the gap is source-side access (and a Kubernetes-style glob that does not exist on this host). Anyone closing it should re-check the two counters above, not the health status — health is exactly what hides this failure mode.
## Gotchas ## Gotchas
- The journald socket mount is required for journal scraping. - **No `/etc/machine-id` mount ⇒ silent zero.** The health check still passes.
- Container log paths differ between docker (`/var/lib/docker/containers`) and podman (`/var/lib/containers/storage/...`); point the config at the right one per host. This is the failure mode that hid the broken pipeline; check
- One Alloy per host; do not run a single instance that tries to reach both hosts' logs. `loki_source_journal_target_lines_total` and the Loki label query, never
- The unit `Wants=`/`After=` `loki.service`: without a reachable Loki the write client fails and the pipeline backs up. `/healthy`.
- **`After=loki.service` is gone.** Alloy is on the system manager; Loki is a
rootless user unit — a system unit cannot order against a user unit. Alloy
retries its push until Loki answers, so ordering is unnecessary.
- **The image must be in the ROOT store.** The deployer builds rootless and
mirrors (`mirror_image_to_root`); a `Pull=never` root quadlet with only a
rootless tag fails at create.
- One Alloy per host; do not run a single instance that tries to reach both
hosts' logs.
## See Also ## See Also
@@ -81,7 +81,7 @@ Two provisioning rules, each verified live by an outage:
appears. Create the Application with `provider_id` set (the UI does this appears. Create the Application with `provider_id` set (the UI does this
automatically; raw API/ORM drives must do it explicitly). automatically; raw API/ORM drives must do it explicitly).
## OAuth2 relying-party quirks (this fork, verified live 2026-08-31) ## OAuth2 relying-party quirks (this fork; corrected live 2026-10-20)
The Grafana RP provider (`grafana`, pk 9, confidential client) was created and The Grafana RP provider (`grafana`, pk 9, confidential client) was created and
verified end-to-end via `ak shell` ORM drives. This fork's OAuth2 provider verified end-to-end via `ak shell` ORM drives. This fork's OAuth2 provider
@@ -100,23 +100,32 @@ implementation diverges from upstream in ways that bit us; all verified live:
- **Provider resolution is by `client_id` query param** on the global - **Provider resolution is by `client_id` query param** on the global
`/application/o/authorize/` route; per-slug authorize paths do not exist in `/application/o/authorize/` route; per-slug authorize paths do not exist in
this build. Token/userinfo live at `/application/o/token/` and this build. Token/userinfo live at `/application/o/token/` and
`/application/o/userinfo/?api=1`. `/application/o/userinfo/` — do **not** append `?api=1` to a Grafana API URL.
- **Property-mapping evaluator is broken → userinfo returns `sub` only.** - **NULL `authorization_flow` → authorize 500, `AttributeError: 'NoneType'
`PropertyMappingEvaluator.evaluate(*args, **kwargs)` forwards kwargs to object has no attribute 'slug'`.** A provider created via raw `ak shell`
`BaseEvaluator.evaluate(expression_source)`, so the first context kwarg `create()` leaves `authorization_flow` unset; the authorize view then crashes
becomes the *expression source* instead of the context; every call raises a building the flow planner (`flows/planner.py`) and returns "Server Error —
swallowed `PropertyMappingExpressionException` and the claim is skipped. Powered by authentik" before any claim is built. This was previously
Scope-grant filtering has the same class of bug: `ScopeMapping` has no mis-attributed to a broken evaluator; the crash is the missing flow. Fix: set
provider FK (linkage lives in the `ProviderPropertyMapping` through model) `authorization_flow = default-provider-authorization-implicit-consent` (the
so `filter(provider=...)` always yields an empty queryset and tokens asked flow every other provider in this instance already uses).
to carry scopes get zero. Net effect: userinfo/introspection answers with - **A scope mapping that returns a non-dict emits no claim.** authentik merges a
`{sub}` only — email/groups/entitlements claims cannot be delivered by this granted scope mapping's evaluated result into the token **as a mapping**: a
build until the evaluator is patched or upgraded. Clients consuming this dict contributes its keys as claims; a bare string is keyed under the
provider must key identity off `sub`. mapping's NAME, so nothing usable appears. `grafana-email-scope` returned the
- Scope mappings for this provider exist — `grafana-email-scope` and bare string `user.email`, so no `email` claim landed — the evaluator was
`grafana-profile-scope` are both rows in `authentik_core_propertymapping` never broken (the dict-valued `grafana-profile-scope` mapping has always
(read-only `SELECT`, 2026-10-18) — and will start emitting claims the moment delivered, and the stock OpenID mappings return dicts). Fix:
the evaluator bug is fixed; no config change is needed then. `return {"email": user.email, "email_verified": True}`. With no email, Grafana
v13 falls back to GET `<api_url>/emails`; authentik 404s it → "Login failed:
InternalError".
- **Grafana reads identity from the standard userinfo endpoint** (`API_URL` with
no `?api=1`); no `USE_ID_TOKEN` is needed once the `email` claim exists.
- **Net (2026-10-20):** the evaluator is fine; email/profile claims reach RP
clients. Both IdP fixes are idempotent in
`scripts/authentik-grafana-sso-fix.sh` (re-run after an ak-shell re-creation of
the provider or a blueprint import, which revert them). Grafana's half plus the
verified outcome (Viewer, `login = <sub hex>`, email present) are in [[Grafana]].
## LDAP provider objects (provisioned via `ak shell` ORM) ## LDAP provider objects (provisioned via `ak shell` ORM)
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more