#!/usr/bin/env bash # serve-frank-dragoon.sh — frank-dragoon endpoint: halogen-flash-server 0.16.2 # (closed-source engine, image ghcr.io/peonist-ai/halogen-flash-server, # digest-pinned in frank-dragoon.env) serving Quat3rnion's NATIVE v2 # conversion (2026-10-11) of the abliterated Qwen3.8-Flash-Next on Dankclaw's # Strix Halo iGPU, host :7770. 2026-10-17 cutover from the Swift1.5-finetune # shape — revert the swap commit to roll back (the swift15 payload and the # 27B one both stay on disk). # # The engine is driven by `podman run` DIRECTLY (the fleet's frank-archon # pattern): the image starts engine + OpenAI frontend in one container. # The frontend listens on 8731 inside (HALOGEN_API_PORT default) and is # published as host 7770; the engine's own token port (8730, no auth) # stays unpublished on the container's loopback. HALOGEN_CHECKPOINT # selects the file inside the /models mount; the n-gram lookup table # (HALOGEN_NGRAM_TABLE, the card sets it explicitly though the documented # fallback is "beside the checkpoint") and the vision tower # (HALOGEN_VISION_TOWER) ride in the same directory. # # Gates mirror the deployer EXACTLY (the 2026-10-01 crash-loop lesson — # serve gate and deploy gate disagreeing = green deploy, red unit): # checkpoint/n-gram/vision byte-exact, tokenizer directory present with # every named file a FLAT REGULAR FILE (a HF cache snapshot's symlinks # into blobs/ dangle inside the mount), GPU device nodes present. # # Why containers instead of a bare binary: the payload is .hgn and only # 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, # 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 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}" if [ ! -f "$ENV_FILE" ]; then echo "frank-dragoon: env file not found: $ENV_FILE" >&2 exit 1 fi set -a # shellcheck disable=SC1090 source "$ENV_FILE" set +a : "${FRANK_DRAGOON_IMAGE:?FRANK_DRAGOON_IMAGE must name the digest-pinned image}" : "${FRANK_DRAGOON_CONTAINER:?FRANK_DRAGOON_CONTAINER must name the container}" : "${FRANK_DRAGOON_PUBLISH:?FRANK_DRAGOON_PUBLISH must be host:container}" : "${FRANK_DRAGOON_MODEL_DIR:?FRANK_DRAGOON_MODEL_DIR must name the payload dir}" : "${FRANK_DRAGOON_CHECKPOINT:?FRANK_DRAGOON_CHECKPOINT must name the .hgn inside it}" : "${FRANK_DRAGOON_CHECKPOINT_BYTES:?FRANK_DRAGOON_CHECKPOINT_BYTES must pin the size}" : "${FRANK_DRAGOON_NGRAM_TABLE:?FRANK_DRAGOON_NGRAM_TABLE must name the lookup table}" : "${FRANK_DRAGOON_NGRAM_BYTES:?FRANK_DRAGOON_NGRAM_BYTES must pin the table size}" : "${FRANK_DRAGOON_VISION_TOWER:?FRANK_DRAGOON_VISION_TOWER must name the tower}" : "${FRANK_DRAGOON_VISION_BYTES:?FRANK_DRAGOON_VISION_BYTES must pin the tower size}" : "${FRANK_DRAGOON_TOKENIZER_FILES:?FRANK_DRAGOON_TOKENIZER_FILES must list the tokenizer dir contents}" : "${HALOGEN_MODEL_ID:?HALOGEN_MODEL_ID must be the served alias (frank-dragoon)}" : "${HALOGEN_CTX:?HALOGEN_CTX must be set (per-slot window)}" : "${HALOGEN_KV_POOL_POSITIONS:?HALOGEN_KV_POOL_POSITIONS must be set}" : "${HALOGEN_KV_SLOTS:?HALOGEN_KV_SLOTS must be set}" : "${HALOGEN_SPEC_ADAPT:?HALOGEN_SPEC_ADAPT must be set (0 = head always on)}" # Capture podman's output BEFORE grepping: under `set -o pipefail` the # `podman images | grep -q` pattern races (grep exits on first match, # podman dies SIGPIPE 141, pipefail turns the 141 into "no match" — the # 2026-09-30 deploy refusal). DIGESTS="$(podman images --format '{{.RepoDigests}}')" if ! grep -qF "${FRANK_DRAGOON_IMAGE#*@}" <<<"$DIGESTS"; then echo "frank-dragoon: image $FRANK_DRAGOON_IMAGE not in podman storage;" >&2 echo " pull it: podman pull ${FRANK_DRAGOON_IMAGE%@*}" >&2 exit 1 fi # Size-exact gates on all three payload files: a truncated curl exits 0 on # some failures and a partial .hgn loads far enough to look alive, so the # byte counts from the Hub tree API are the contract (fleet fetch law). ck() { # ck if [ ! -f "$2" ]; then echo "frank-dragoon: $1 $2 missing;" >&2 echo " fetch it: bash scripts/download-frank-dragoon-v2abl.sh" >&2 exit 1 fi local sz sz="$(stat -c %s "$2")" if [ "$sz" != "$3" ]; then echo "frank-dragoon: $1 is $sz bytes, expected $3 (partial download?)" >&2 exit 1 fi } ck checkpoint "$FRANK_DRAGOON_MODEL_DIR/$FRANK_DRAGOON_CHECKPOINT" "$FRANK_DRAGOON_CHECKPOINT_BYTES" ck n-gram-table "$FRANK_DRAGOON_MODEL_DIR/$FRANK_DRAGOON_NGRAM_TABLE" "$FRANK_DRAGOON_NGRAM_BYTES" ck vision-tower "$FRANK_DRAGOON_MODEL_DIR/$FRANK_DRAGOON_VISION_TOWER" "$FRANK_DRAGOON_VISION_BYTES" # The payload ships the tokenizer as a FLAT DIRECTORY. A HuggingFace cache # snapshot symlinks each file into a sibling blobs/, and those symlinks # dangle inside the container mount — so the directory must be real and # every named file must be a regular file, not a link. TOK_DIR="$FRANK_DRAGOON_MODEL_DIR/tokenizer" if [ -L "$TOK_DIR" ] || [ ! -d "$TOK_DIR" ]; then echo "frank-dragoon: tokenizer dir $TOK_DIR missing or a symlink (HF cache snapshot?);" >&2 echo " fetch it: bash scripts/download-frank-dragoon-v2abl.sh" >&2 exit 1 fi for TF in $FRANK_DRAGOON_TOKENIZER_FILES; do if [ ! -f "$TOK_DIR/$TF" ] || [ -L "$TOK_DIR/$TF" ]; then echo "frank-dragoon: tokenizer file $TOK_DIR/$TF missing or a symlink;" >&2 echo " fetch it: bash scripts/download-frank-dragoon-v2abl.sh" >&2 exit 1 fi done # GPU access is mandatory but the node paths are variables so the seam can # drive the gate on a box without an AMD GPU. DEV_KFD="${FRANK_DRAGOON_DEV_KFD:-/dev/kfd}" DEV_DRI="${FRANK_DRAGOON_DEV_DRI:-/dev/dri}" if [ ! -e "$DEV_KFD" ] || [ ! -d "$DEV_DRI" ]; then echo "frank-dragoon: $DEV_KFD or $DEV_DRI missing — no gfx1151 to serve on" >&2 exit 1 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/` is visible at /models/npu/ 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 twice (where the image # looks for XRT, and at its own path — the libraries find each other there; # 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 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")" NPU_ARGS+=(-v "$REAL:/opt/xilinx/xrt/lib/$L:ro" -v "$REAL:$XRT_DIR/$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. # 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 # n-gram lookup pages in from disk on every long prompt. A RAM-short host # (a co-tenant spike, a sibling engine dying) crash-loops the unit — each # boot dies in ~5 s at the pin gate — until the RAM clears. The 2026-10-06 # cutover hit this: 8 boots, MemAvailable 74.77 GiB vs the 78.11 GiB needed, # self-resolved in ~2 min when the dying 27B engine freed RAM. Check here and # WAIT for the RAM to clear, so a transient dip boots once instead of # crash-looping; if it never clears, refuse loudly. MEMINFO="${FRANK_DRAGOON_MEMINFO:-/proc/meminfo}" PIN_KB=$(( FRANK_DRAGOON_CHECKPOINT_BYTES / 1024 )) NEED_KB=$(( PIN_KB + 16 * 1048576 )) ram_avail_kb() { awk '/^MemAvailable:/{print $2; exit}' "$MEMINFO"; } MEMAVAIL_KB="$(ram_avail_kb)" || MEMAVAIL_KB="" if [ -n "$MEMAVAIL_KB" ] && [ "$MEMAVAIL_KB" -lt "$NEED_KB" ]; then WAIT_S="${FRANK_DRAGOON_RAM_WAIT:-600}" GIB() { awk -v kb="$1" 'BEGIN{printf "%.2f", kb/1048576}'; } echo "frank-dragoon: host RAM below the pin gate — MemAvailable $(GIB "$MEMAVAIL_KB") GiB < needed $(GIB "$NEED_KB") GiB ($(GIB "$PIN_KB") GiB checkpoint + 16.00 GiB floor); waiting up to ${WAIT_S}s for host RAM to clear" >&2 deadline=$(( $(date +%s) + WAIT_S )) while [ "$(date +%s)" -lt "$deadline" ]; do sleep 5 MEMAVAIL_KB="$(ram_avail_kb)" || MEMAVAIL_KB="" if [ -n "$MEMAVAIL_KB" ] && [ "$MEMAVAIL_KB" -ge "$NEED_KB" ]; then break fi done if [ -z "$MEMAVAIL_KB" ] || [ "$MEMAVAIL_KB" -lt "$NEED_KB" ]; then echo "frank-dragoon: MemAvailable $(GIB "${MEMAVAIL_KB:-0}") GiB after ${WAIT_S}s; refusing to start (the engine would refuse the pin)" >&2 exit 1 fi echo "frank-dragoon: MemAvailable cleared to $(GIB "$MEMAVAIL_KB") GiB; starting" >&2 fi for CTR in ${FRANK_DRAGOON_LEGACY_CONTAINERS:-} "$FRANK_DRAGOON_CONTAINER"; do podman rm -f "$CTR" >/dev/null 2>&1 || true done LOG="${FRANK_DRAGOON_LOG:-/home/dank/logs/frank_dragoon.log}" mkdir -p "$(dirname "$LOG")" # --ipc=host for the udev-granted supplementary group, seccomp unconfined # for the ROCm ioctl surface, and unbounded memlock (the unit's # LimitMEMLOCK=infinity is inherited, so runc never has to RAISE it). NO # --ulimit nofile: Dankclaw's hard limit is 524288, rootless runc cannot # raise a hard limit and dies with "error setting rlimit type 7: # operation not permitted" (measured 2026-10-05 cutover); the model card # and both sibling serve scripts pass no nofile either. No comment lines # inside the backslash chain — a mid-chain # terminates the continuation # (the 2026-10-05 exit-127 crash loop on the sibling serve script). podman run --name "$FRANK_DRAGOON_CONTAINER" \ --publish "$FRANK_DRAGOON_PUBLISH" \ --ipc=host \ --ulimit memlock=-1:-1 \ --device "$DEV_KFD" --device "$DEV_DRI" \ --group-add keep-groups \ --security-opt seccomp=unconfined \ -e HALOGEN_MODEL_ID="$HALOGEN_MODEL_ID" \ -e HALOGEN_CHECKPOINT="/models/$FRANK_DRAGOON_CHECKPOINT" \ -e HALOGEN_NGRAM_TABLE="/models/$FRANK_DRAGOON_NGRAM_TABLE" \ -e HALOGEN_VISION_TOWER="/models/$FRANK_DRAGOON_VISION_TOWER" \ -e HALOGEN_TOKENIZER=/tokenizer \ -e HALOGEN_CTX="$HALOGEN_CTX" \ -e HALOGEN_KV_POOL_POSITIONS="$HALOGEN_KV_POOL_POSITIONS" \ -e HALOGEN_KV_SLOTS="$HALOGEN_KV_SLOTS" \ -e HALOGEN_HOST_RESERVE_GIB="${HALOGEN_HOST_RESERVE_GIB:-20}" \ -e HALOGEN_SPEC_ADAPT="$HALOGEN_SPEC_ADAPT" \ -e HALOGEN_MAX_TOK="${HALOGEN_MAX_TOK:-8192}" \ -e HALOGEN_MAX_TOKENS_CAP="${HALOGEN_MAX_TOKENS_CAP:-65536}" \ -e HALOGEN_QUEUE_TIMEOUT="${HALOGEN_QUEUE_TIMEOUT:-7200}" \ -e HALOGEN_TEMPERATURE="${HALOGEN_TEMPERATURE:-1.0}" \ -e HALOGEN_TOP_P="${HALOGEN_TOP_P:-0.95}" \ -e HALOGEN_TOP_K="${HALOGEN_TOP_K:-20}" \ ${HALOGEN_WEIGHTS_LOCK:+-e HALOGEN_WEIGHTS_LOCK="$HALOGEN_WEIGHTS_LOCK"} \ -v "$FRANK_DRAGOON_MODEL_DIR:/models:ro" \ -v "$FRANK_DRAGOON_MODEL_DIR/tokenizer:/tokenizer:ro" \ "${NET_ARGS[@]}" \ "${NPU_ARGS[@]}" \ "$FRANK_DRAGOON_IMAGE" \ > "$LOG" 2>&1 # FOREGROUND `podman run`: the engine's stdout/stderr are this script's # output, so the boot ledger (kv pool, prompt cache, MTP head, any clamp) # lands in $LOG as documented. podman run returns the container's exit # code, so a crash is a nonzero ExecStart and the unit's Restart=always # stays real; systemd stop goes through the unit's ExecStop and the run # client forwards SIGTERM to the engine, which drains on it.