#!/usr/bin/env bash # Start doctate-server with the chosen ASR backend (whisper or canary). # # Coordinates the GPU host (minerva.lan) so the chosen ASR container is # the only heavy GPU resident: # - Whisper (~1.5-3 GB) coexists with Ollama (~3-4 GB) on the RTX 3060. # - Canary (~9.7 GB) does NOT — Ollama must be unloaded first, else # Canary OOMs on its last few MB of model weights. # See docs/canary.md §2.6 for the VRAM accounting. # # Flow: # 1. cargo build -p doctate-server (fail fast, BEFORE touching minerva) # 2. ssh minerva.lan: docker compose stop the OTHER ASR stack # 3. (canary only) unload all Ollama models — must happen BEFORE the # canary container starts, otherwise canary loads its model into a # still-occupied GPU and OOMs in the last few MB. Order matters: # „start canary, then free VRAM" is too late — canary's model load # runs in the container background as soon as `compose up -d` returns. # 4. ssh minerva.lan: docker compose up -d the chosen stack — for # canary, the GPU is now guaranteed empty. # 5. poll the chosen backend until it answers, with progress pulses # and periodic container-log snapshots so a stuck start is visible # 6. exec cargo run -p doctate-server with ASR_BACKEND= exported # # Usage: # scripts/run-server.sh whisper # scripts/run-server.sh canary # # Env overrides (all optional): # MINERVA_HOST default minerva.lan # WHISPER_STACK default /opt/stacks/doctate-whisper # CANARY_STACK default /opt/stacks/doctate-canary # WHISPER_HOST_PORT default 9001 # CANARY_HOST_PORT default 9002 # OLLAMA_HOST_PORT default 11434 # HEALTH_TIMEOUT_SECS default 600 (cold model-download room) # HEALTH_POLL_SECS default 3 (interval between probes) # LOG_SNAPSHOT_SECS default 30 (interval between log dumps) set -euo pipefail MINERVA_HOST="${MINERVA_HOST:-minerva.lan}" WHISPER_STACK="${WHISPER_STACK:-/opt/stacks/doctate-whisper}" CANARY_STACK="${CANARY_STACK:-/opt/stacks/doctate-canary}" WHISPER_HOST_PORT="${WHISPER_HOST_PORT:-9001}" CANARY_HOST_PORT="${CANARY_HOST_PORT:-9002}" OLLAMA_HOST_PORT="${OLLAMA_HOST_PORT:-11434}" HEALTH_TIMEOUT_SECS="${HEALTH_TIMEOUT_SECS:-600}" HEALTH_POLL_SECS="${HEALTH_POLL_SECS:-3}" LOG_SNAPSHOT_SECS="${LOG_SNAPSHOT_SECS:-30}" usage() { cat <&2 Usage: $0 Coordinates the GPU host (\$MINERVA_HOST) so only the matching ASR container runs (and, for canary, Ollama is unloaded), then starts doctate-server locally with the matching ASR_BACKEND env var. See header comment for env overrides. EOF exit 64 } [[ $# -eq 1 ]] || usage case "$1" in whisper|canary) BACKEND="$1" ;; *) usage ;; esac REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_ROOT/server" # ---- Step 1: build before touching minerva. ------------------------------- echo ">>> [1/6] cargo build -p doctate-server" cargo build -p doctate-server # ---- Step 2: pick stop/start targets. ------------------------------------- if [[ "$BACKEND" == "whisper" ]]; then STOP_STACK="$CANARY_STACK" START_STACK="$WHISPER_STACK" HEALTH_KIND="tcp" HEALTH_PORT="$WHISPER_HOST_PORT" HEALTH_DESC="TCP $MINERVA_HOST:$WHISPER_HOST_PORT" else STOP_STACK="$WHISPER_STACK" START_STACK="$CANARY_STACK" HEALTH_KIND="http" HEALTH_URL="http://$MINERVA_HOST:$CANARY_HOST_PORT/health" HEALTH_DESC="HTTP $HEALTH_URL" fi echo ">>> [2/6] $MINERVA_HOST: stop $STOP_STACK" ssh "$MINERVA_HOST" "cd '$STOP_STACK' && docker compose stop" # ---- Step 3 (canary only): unload Ollama models. -------------------------- # Ollama keeps the active model warm for `keep_alive` seconds (300 in # settings.toml at the time of writing). When the operator switches to # canary in the middle of that window, the model is still resident and # canary OOMs on the last few MB. We trigger a synchronous unload via # the Ollama API: posting `keep_alive: 0` with an empty prompt drops # the model immediately and frees VRAM. (See ollama/ollama issue #2546.) unload_ollama_models() { local base="http://$MINERVA_HOST:$OLLAMA_HOST_PORT" local ps_file ps_file=$(mktemp) trap 'rm -f "$ps_file"' RETURN if ! curl -fsS --max-time 5 "$base/api/ps" -o "$ps_file"; then echo " Ollama API unreachable at $base/api/ps — skipping unload" >&2 return 0 fi # Parse the JSON model list with python3 (avoid jq as a hard dep). local models models=$(python3 -c " import json, sys try: d = json.load(open('$ps_file')) for m in d.get('models', []): print(m.get('name', '')) except Exception: sys.exit(0) ") if [[ -z "$models" ]]; then echo " Ollama: no models loaded — VRAM already free" return 0 fi while IFS= read -r name; do [[ -z "$name" ]] && continue echo " unloading: $name" curl -fsS --max-time 10 "$base/api/generate" \ -H 'Content-Type: application/json' \ -d "{\"model\":\"$name\",\"prompt\":\"\",\"keep_alive\":0}" \ -o /dev/null \ || echo " (warn: unload request for $name returned non-2xx)" >&2 done <<< "$models" # Give the GPU driver a moment to actually reclaim the pages before # canary starts allocating its own. sleep 2 } if [[ "$BACKEND" == "canary" ]]; then echo ">>> [3/6] freeing VRAM: unloading Ollama models BEFORE canary starts" unload_ollama_models else echo ">>> [3/6] (whisper) Ollama coexistence is fine — skipping unload" fi # ---- Step 4: NOW start the chosen stack. ---------------------------------- # For canary this is the critical moment — the GPU has just been freed # by step 3, and canary's container will allocate into an empty GPU # instead of fighting Ollama for the last 20 MB. echo ">>> [4/6] $MINERVA_HOST: start $START_STACK" ssh "$MINERVA_HOST" "cd '$START_STACK' && docker compose up -d" # ---- Step 5: poll until the chosen backend answers. ----------------------- echo ">>> [5/6] waiting for $HEALTH_DESC (up to ${HEALTH_TIMEOUT_SECS}s)" probe_once() { if [[ "$HEALTH_KIND" == "tcp" ]]; then if (exec 3<>"/dev/tcp/$MINERVA_HOST/$HEALTH_PORT") 2>/dev/null; then exec 3<&- 3>&- return 0 fi return 1 else curl -fsS --max-time 5 "$HEALTH_URL" >/dev/null 2>&1 fi } start=$(date +%s) deadline=$(( start + HEALTH_TIMEOUT_SECS )) last_log_snapshot=0 attempt=0 while :; do if probe_once; then echo "" echo " backend is up after $(( $(date +%s) - start ))s" break fi attempt=$(( attempt + 1 )) printf '.' now=$(date +%s) if (( now >= deadline )); then echo "" echo "timeout waiting for $HEALTH_DESC after ${HEALTH_TIMEOUT_SECS}s" >&2 echo "--- container logs (tail 40) ---" >&2 ssh "$MINERVA_HOST" "cd '$START_STACK' && docker compose logs --tail 40" >&2 || true exit 1 fi # Periodic log snapshot: every LOG_SNAPSHOT_SECS, print elapsed time # and the last few log lines so the operator can SEE if the container # is loading, crashing, or stalled. if (( now - last_log_snapshot >= LOG_SNAPSHOT_SECS )); then echo "" elapsed=$(( now - start )) echo " [${elapsed}s elapsed] still waiting; recent container log:" ssh "$MINERVA_HOST" "cd '$START_STACK' && docker compose logs --tail 5 --no-log-prefix 2>&1" \ | sed 's/^/ | /' \ || echo " (could not read logs)" >&2 last_log_snapshot=$now fi sleep "$HEALTH_POLL_SECS" done # ---- Step 6: start the server. -------------------------------------------- # dotenvy::dotenv() in server/src/main.rs does NOT override env vars that # are already set, so this export wins over any ASR_BACKEND= in .env. echo ">>> [6/6] starting doctate-server (ASR_BACKEND=$BACKEND)" export ASR_BACKEND="$BACKEND" exec cargo run -p doctate-server