Releases: geeks-accelerator/ollama-herd
Release list
v0.9.2 — anonymous telemetry, routing observability, persistent settings
Added
-
Anonymous community telemetry, on by default. The router sends one summary a day: which models ran, request and token counts, latency percentiles, and error counts by category. Never prompts, never completions, never raw error text, never your hostname. Opt out with
FLEET_NODE_TELEMETRY=false, or from the dashboard's new Community Telemetry section. A one-time notice is printed the first timeherd-nodestarts, before anything is ever sent, and the opt-out is honoured on that first run — a node that has opted out writes no files at all, not even the identifier. Every field that can be sent is published at ollamaherd.com/telemetry; that page is the contract, and the payload builder mirrors it under tests that fail the build if the two drift.Identity is a random
install_idUUID stored in~/.fleet-manager/install_id— delete it and you are a new install, delete it and opt out and you are gone. It is never derived from anything about the machine; tests assert it is neither the hostname in any form nor a hash of it, so it cannot be turned into a stable fingerprint later.Reporting is per herd, not per machine. The router is the only component that knows a fleet is one fleet, so it sends one payload containing a
devices[]row per node (chip, memory, cores, agent and Ollama version, that node's request share). Fleet totals are derived from those rows server-side rather than sent as scalars, so a total can never disagree with its parts. Each device carries adevice_idderived from itsnode_idbut hashed and salted with the herd'sinstall_id: stable enough to aggregate across days, and impossible to reverse to a hostname or correlate across herds. -
Name your herd for the public leaderboard — a second, separate opt-in on top of telemetry. Set it in the dashboard or via
FLEET_NODE_HERD_NICKNAME. Telemetry alone is never public; a nickname is the only field that is, and the UI says so before you set one. Names are limited to 30 characters of letters, numbers, spaces,-,_,., validated where they are typed rather than only in the browser. -
X-Fleet-Affinitymakes session routing visible. Every scored endpoint now reportsmatchedwhen a request went back to the node already holding that conversation, ornewwhen it did not — and omits the header entirely on routes that never score, because "did not apply" and "missed" are different facts.It reports the routing decision, not a cache hit, and deliberately so: Ollama folds llama.cpp's
cache_nback intoprompt_n(ollama/ollama#16428), soprompt_eval_countcannot yield a hit ratio and inferring one from it produced a false "zero prefix-cache reuse" report here once already. Time-to-first-token is the honest proof: a matched turn shows a large drop. -
usage.prompt_tokens_details.cached_tokensis reported on backends that actually measure it (MLX), and omitted — never zeroed — on backends that cannot (Ollama). Zero means "measured, nothing reused"; absent means "cannot measure". Conflating them is a bug vLLM shipped and SGLang still has. -
The Ollama and mlx-lm versions are collected in the heartbeat and surfaced to the router, so a fleet can answer "which runtimes are actually out there?" before a version-gated model or a changed default lands.
Changed
-
Session affinity now decays with queue depth. A pinned node contributes the full bonus when idle and progressively less as its queue grows, so a warm but saturated node stops winning while an idle peer sits free. Every production router does this — NVIDIA Dynamo, Ray Serve, SGLang — and a flat bonus is the bug vLLM's production-stack shipped
loadawarerouting to fix. Signal 3 already prices congestion, so this shrinks the bonus rather than adding a second penalty, and because decay can only shrink, affinity still never outweighs a thermal warning. -
MLX prompt cache raised from 4 to 10, matching
mlx_lm.server's own default. The previous value had no recorded rationale and sat below upstream, silently halving how many conversations could keep their KV cache warm — which is the actual limit on how many sessions affinity can honour.
Fixed
-
Telemetry now sends a missed day on startup instead of sleeping past it. The scheduler slept until the next 00:05 UTC before its first send, so start time of day decided whether an install reported at all: a router started at 00:10 UTC waited 23h55m, and one restarted daily after 00:05 never sent. Both are indistinguishable from "nobody uses this" on the receiving end. Found because our own fleet ran 12 hours of clean uptime with zero automatic sends.
-
The published telemetry opt-out worked in name only. Moving the sender from the node to the router silently repointed which environment variables it read (
ServerSettingscarries a different prefix), soFLEET_NODE_TELEMETRY=false— the opt-out documented on the website — stopped disabling anything, with no error anywhere, because "off" and "unset" are indistinguishable in a boolean default. Both that andFLEET_NODE_HERD_NICKNAMEare now read correctly, with tests pinning the names as a published contract. -
Dashboard toggles now survive a restart.
POST /dashboard/api/settingsonly mutated settings in memory, so every Feature Toggle silently reverted on the next start. They are now written to~/.fleet-manager/envwith a line-based writer that preserves the comments and hand edits in that file, and the response reportsnot_persisted/restart_requiredinstead of implying an effect it cannot deliver. Merely annoying forauto_pull; it would have been a broken promise for a telemetry opt-out. -
The gotomy.ai platform panel is removed from Settings — that service is shut down.
Removed
- The account-based platform telemetry opt-ins are untouched, but the dashboard's platform-connection UI is gone along with the service it targeted.
v0.9.1 — session affinity, two health checks, three fixes
A fixes-and-hardening release on top of 0.9.0 — no breaking changes, no new dependencies. Three reliability bugs found while operating the local fleet, plus a new routing signal and two health checks that came out of the same soak.
Added
- Session affinity — an 8th scoring signal. A conversation is now pinned to the node already holding its warm prefix cache, so turn N+1 reuses the processed prompt (llama.cpp's
get_common_prefixskips already-seen tokens) instead of re-prefilling ~30K tokens on a fresh node — which also removes the decode interference that a cold prefill inflicts on any stream co-resident on the new node. Keyed by an explicitsession:<id>request tag when present, elseclient_ip|model; TTL 900s. The bonus (20) sits below thermal (50) so a hot node never wins on affinity alone. Seedocs/fleet-manager-routing-engine.md§ Signal 8. decode_degradedhealth check (#39). Watches per-model TPOT —(latency − time_to_first_token) / (completion_tokens − 1)— against each model's own rolling baseline, so it isolates genuine decode contention from queueing and prefill. Fires WARNING when decode slows against a node's own history rather than a fleet-wide constant.pin_cannot_fithealth check (#40). Surfaces a pinned model that can never load on its node because the pinned set can't physically co-reside, instead of letting the preloader retry it forever.
Changed
- Queue concurrency is capped at what the backend will actually decode. Per-
node:modelconcurrency is nowmin(memory_slots, OLLAMA_NUM_PARALLEL)— Ollama admits onlynum_parallelrequests into decode at once, so provisioning more queue workers than that just built a second queue in front of the real one. Nodes reportnum_parallelin the heartbeat; the router mirrors it the same way it already mirrors the hot-model cap.
Fixed
FLEET_NODE_NODE_IDnow actually sets the node id.herd-nodepassed its empty--node-iddefault explicitly intoNodeSettings, and an explicit kwarg shadows pydantic-settings' env lookup — so the env var was inert and the node fell back tosocket.gethostname(). On macOS the hostname is network-derived when the staticHostNameis unset, so it silently changed between networks (bbat home →Neons-Mac-Studiotravelling) and orphaned every pin bound to the old id. The same shadowing affectedFLEET_NODE_ROUTER_URL; both are fixed.- Image requests carrying
image/input_imagecontent parts no longer fall back to a blind text model. The image-detection guard only recognisedimage_url; requests using the other two shapes slipped past it, routed togpt-oss, and 400'd — while the guard built to prevent exactly that sat inert. The detector now matches the full superset the runtimes accept and skips non-dict messages. - A stale dashboard tab can no longer wedge the router. The
/dashboard/eventsSSE loop now checksrequest.is_disconnected()each iteration and wraps the stream so an exception is logged and returned rather than escaping, whileCancelledErrorstill propagates — a disconnected tab stops generating work instead of accumulating it on the event loop.
v0.9.0 — OpenAI Codex support, image routing, and the first release since 0.7.0
The first release since 0.7.0, and it contains breaking changes. Nothing between 0.7.0 and this was ever published, so upgrading from 0.7.0 lands all of 0.8.0, 0.8.1, and 0.8.2 at once — see those sections below for the full detail. This section is the upgrade guide: what breaks, and what's new enough to matter.
Versioned 0.9.0 rather than 0.8.2 deliberately: a 0.7.0 → 0.8.2 jump reads like "two patches on a release I already have," which would invite a casual deploy straight into removed env vars and a retired header. Under 0.x SemVer, MINOR is where breaking changes belong.
⚠️ Breaking — read before upgrading from 0.7.0
- Legacy single-server MLX env vars are gone.
FLEET_NODE_MLX_AUTO_START,FLEET_NODE_MLX_AUTO_START_MODEL,FLEET_NODE_MLX_URL,FLEET_NODE_MLX_KV_BITS,FLEET_NODE_MLX_PROMPT_CACHE_SIZE/BYTES,FLEET_NODE_MLX_DRAFT_MODEL,FLEET_NODE_MLX_NUM_DRAFT_TOKENS. Migrate to a one-entryFLEET_NODE_MLX_SERVERSarray. (FLEET_MLX_ENABLEDand the server-sideFLEET_MLX_URLfallback remain; theherd/herd-nodeCLI,FLEET_*internals and~/.fleet-manager/are unchanged.) X-Fleet-Modelis retired in favour ofX-Fleet-Served-Model.X-Fleet-Fallbackalso changed meaning: it is now an always-present"true"/"false"boolean, not a model name emitted only on substitution.- Queue-full is now
429, not503. Ollama's "maximum pending requests exceeded" is no longer retried (retrying a saturated node amplified the flood). POST /fleet/pincan now refuse.409when the pinned set can't physically co-reside ("force": trueoverrides),400for an unknownnode_id. Previously every pin was accepted silently — which is how a 307 GB pinned set on a 512 GB box produced hours of thrash.- Image requests can now fail instead of silently succeeding. A request carrying images will no longer fall back to a model that can't see them; it fails loudly rather than returning a confident answer about an image the model never received.
- Backend client errors surface as themselves. A 4xx from Ollama (e.g. "model does not support tools") now reaches you as that 4xx with the backend's message, instead of an opaque
500. - The built-in default
anthropic_model_mapis now empty. It previously hard-codedqwen3-coder:30b/qwen3:32b/qwen3:14b— model names a given deployment may never have pulled. Deployments that relied on that built-in default (never setFLEET_ANTHROPIC_MODEL_MAP) now get auto-routing instead (best loaded model per tier), which is strictly more likely to resolve to something they actually have. Explicit maps set via env are unaffected. See the auto-routing feature below.
Headline features
-
OpenAI Codex support — a native Responses API at
/v1/responses. Codex removed Chat Completions in Feb 2026 (wire_api = "chat"is gone), so this is the only endpoint current Codex can speak. Agentic coding is verified end-to-end against a realcodex-cli 0.145.0-alpha.18: it ran pytest, read sources, created a module from scratch, patched files viaapply_patch, and reached green on its own. Zero configuration — agpt-5-codex/gpt-5.6-solid auto-routes to the best coding model you have loaded, the same resolver Claude Code uses. Seedocs/guides/codex-integration.md.Getting there meant bridging a gap that only appears with local models: Codex's tool descriptions document an API its tool schema doesn't expose, and local models call what the prose names. Herd now normalises three cases automatically, each logged at WARNING:
- Tools hidden inside an
additional_toolsinput item (thesol/terra/luna"Responses-Lite" slugs, including the ChatGPT Desktop default) are extracted, and the grammar-constrainedcustomexectool is bridged to something Ollama function-calling can express. Without this the model has nothing callable and rationalises the failure — openai/codex#31894. - A top-level
exec_commandcall — a nested tool only reachable from inside code-mode JavaScript — is rewritten as acustom_tool_callon its host tool. Passing it through makes Codex stop with no error displayed. - An
apply_patchtool call is rewritten as anexec_commandheredoc.apply_patchis a binary Codex injects on the sandbox PATH, not a tool; calling it as one returnsunsupported call: apply_patch, and the session can then read and execute but never write.
- Tools hidden inside an
-
Images route to a model that can see them, on every endpoint. An image-bearing request auto-selects a vision-capable model even when the conversation's model is a code-tuned one, and image content now reaches Ollama as its
imageslist instead of being silently dropped in translation. A dropped image is worse than a dropped tool call: it produces a fluent, specific, wrong answer while every server-side metric reports success. -
Distributed MLX inference — run one model across multiple Macs via
mlx.launch(ringover LAN today;jaccl/Thunderbolt 5 targeted). The herd sees one endpoint whether one Mac or four are behind it. -
Fleet control API —
GET /fleet/limits,POST /fleet/pin(withwaitfor readiness),DELETE /fleet/pin/{model}. -
mlx:models reachable over the OpenAI endpoint, not just Anthropic — OpenAI-only clients get the fast backend instead of a slow fallback. -
Canonical
X-Fleet-*headers on every proxied response, so a caller can always tell what actually ran. -
Per-request strict mode (
X-Fleet-No-Fallback) and a per-client concurrency cap (FLEET_CLIENT_MAX_IN_FLIGHT, default off). -
FLEET_ANTHROPIC_MODEL_MAPis now optional — Claude Code works with zero configuration. Aclaude-*id with no explicit mapping is resolved to the best currently-loaded local model for its tier (coding models preferred for Claude Code's workload; a loaded vision model chosen automatically for image requests), falling back to the best on-disk model, then a configureddefault. So a fresh install routes to whatever the user pulled — no hand-written map that has to match your downloads, and no map entry silently pointing at a model you never pulled. Explicit map entries still win as per-alias overrides; setFLEET_ANTHROPIC_AUTO_ROUTE=falseto require an explicit map (the pre-0.9 behaviour). Seedocs/reference/anthropic-auto-routing.md.
Notable fixes
finish_reasonis recorded on every trace, so a turn that ends mid-task is distinguishable from one that exhausted its token budget without eyeballingcompletion_tokens.- A
num_ctxoverride that cannot apply now says so, once, instead of logging like it worked.FLEET_NUM_CTX_OVERRIDESsets the context a cold load comes up with; it cannot shrink a resident model without forcing an unload/reload. Previously it logged an "injected" line and a "stripped" line per request — 393 pairs in nine hours — which read as a working feature doing nothing. - OpenAI function calling was dropped in both directions on
/v1/chat/completions; tool calls now survive the round trip. /v1/modelsemits the schema Codex actually decodes. Codex validates against its own undocumented, strictly-typed schema and fails the whole decode on the first problem —visibility: "public"alone (not in itslist/hide/noneenum) emptied the model picker, which pushes ChatGPT Desktop onto its Lite slugs.supports_visionis now reported per model rather than hardcodedfalse. The field set is not converged; seedocs/issues.md.- Image requests are never answered by a blind model (the 2026-04-23 incident class — see below).
- Failed-request traces no longer vanish, so the dashboard's success rate stops hiding failures.
- Model sizes come from Ollama's real
/api/tagsdata instead of being guessed from the name — the guess called a 290 GB model "10 GB" and defeated the memory gate. - Models are now sized by what they actually cost in RAM — weights plus KV cache. Every "will this fit?" decision previously counted on-disk weights and ignored the KV cache, which scales with context and routinely dwarfs the weights:
qwen3-coder:30bis 18.6 GB of weights and 122.9 GB resident at its default 262K context, so the gate was under-counting it by 5.4×. The router now learns each model's KV cost per token from heartbeat data it was already receiving ((resident − weights) / context_length) and predicts the real footprint at the context the model will actually run with. The preloader gate,/fleet/pinadmission and the scorer all share one estimator. Models the fleet has never observed keep their previous sizing — evidence tightens these gates, guesswork doesn't. Seedocs/issues/model-sizing-ignores-kv-cache.md. - Preloading warms a model at the same
num_ctxrequests will use. It previously warmed at the model's default and let the first real request reload it at the override — pointless churn, and it made the model's cost unknowable at load time. - The hot-model cap is no longer hardcoded to 3 — nodes report their own, and
free_slotsfollows it.
Recommended alongside this release
Upgrade Ollama to 0.32.1. Not required, but measured on the same hardware: glm-4.7-flash 13.7 → 77.8 tok/s (the glm4moelite expert-offload bug is fixed upstream), gpt-oss:120b 50.9 → 74.5, and prefix caching demonstrably works. See docs/plans/ollama-0.32-upgrade-and-mlx-evaluation.md.
v0.7.0 — Native text embedding server (fastembed), Node Models dashboard
Native text embedding backend and full-fleet Node Models dashboard. The embed timeout incident from June 1 exposed a fundamental contention problem: OLLAMA_NUM_PARALLEL=2 means a running gpt-oss:120b inference holds both Ollama slots, so nomic-embed-text requests queue indefinitely inside Ollama regardless of available hardware (14% CPU, 291 GB free RAM). The structural fix routes text embedding out of Ollama entirely — a dedicated fastembed server on port 11439 handles nomic-embed-text via ONNX Runtime with zero inference slot contention. Verified on the local fleet: 573 embed requests over 24h, avg 792ms, 0.0% error rate, no timeouts. Dashboard now shows cards for every model backend (Ollama, MLX, native fastembed, vision embedding) with live per-model stats, renamed from "Request Queues" to "Node Models" to reflect the expanded scope.
Added
-
Native text embedding server (fastembed, port 11439). A dedicated FastAPI server runs
nomic-ai/nomic-embed-text-v1.5-Q(130 MB int8, 768 dims, 8192 token context) via ONNX Runtime — no PyTorch, no Ollama. The router intercepts/api/embedcalls fornomic-embed-textbefore they reach Ollama and proxies them to the best available node's text embedding server.fastembed>=0.4.0added to the existing--extra embeddinggroup (sameuv sync --extra embeddingcommand as vision embeddings — one command enables both). Model weights download automatically on first request (~130 MB from HuggingFace) and are cached in~/.fleet-manager/models/text-embedding/. Zero contention with LLM inference slots. Seesrc/fleet_manager/node/text_embedding_server.py,src/fleet_manager/node/text_embedding_models.py,src/fleet_manager/server/routes/text_embedding_compat.py. -
4 new health checks (32 → 36 total):
embed_error_rate— WARNING at ≥5 embed errors/hour, CRITICAL at ≥25/hour. Closes the observability gap that let 202 embed timeouts accumulate undetected for 1.5h on June 1.text_embedding_backend_missing— WARNING when nomic-embed-text weights are cached on disk butfastembedisn't installed. Fix:uv sync --extra embedding.text_embedding_ollama_bypass— WARNING when nomic-embed-text is available in Ollama but the native server isn't running, meaning embed requests still contend for LLM inference slots. Includes Apple Silicon platform note.nomic_loaded_in_ollama— INFO when the native server is running and handling all embed traffic but nomic-embed-text remains loaded in Ollama's hot set, consuming VRAM and a model slot unnecessarily.
-
TextEmbeddingModelandTextEmbeddingMetricsheartbeat fields. Node heartbeats now reporttext_embedding(available models + cached status),text_embedding_port, andtext_embedding_status(backend_available,cached_model_count). The router registry copies these fields toNodeInfoand/fleet/statusincludes them in node serialization. -
Dashboard "Node Models" — cards for all backends. Renamed from "Request Queues". Now shows a card for every model receiving traffic, not just Ollama-queued models: Ollama (grey badge), MLX (purple), native fastembed (green), and vision embedding (cyan). Cards for instant backends (fastembed, vision) show 24h completed/failed counts and avg latency from a 60s-TTL trace DB cache rather than live queue depth. "DL ON DEMAND" amber chip appears when model weights are not yet cached. Stats counter in the header includes all backends, not just Ollama queues.
Fixed
-
Embed timeout visibility and resilience. The
/api/embedhandler previously had norecord_trace()calls — all embed outcomes (success and failure) were invisible to the dashboard and health checks. Added trace recording for all paths. Added a retry loop (1s then 3s backoff) onReadTimeoutbefore returning 504, matching the retry behavior of the LLM streaming path. The 504 response includes a human-readable hint when the timeout is likely caused by a model download in progress. -
embed_error_rateSQL usesmodel LIKE '%embed%'(nottags LIKE '%embed%'). Using thetagscolumn caused false positives — the local VOD processing pipeline tags its LLM requests with "embed" as a pipeline stage label. Restricting tomodelcolumn matches only actual embedding models. -
filelockDEBUG log suppression in fastembed download path. fastembed's HuggingFace download emits ~20 DEBUG lines per file lock during the initial model download. Suppressed withlogging.getLogger("filelock").setLevel(logging.WARNING)in the text embedding server.
Changed
CLAUDE.mdupdated: architecture table + routes updated for new text embedding modules; current state reflects nomic-embed-text → native fastembed; health count 32→36; test count 986→1006;--extra embeddingdescription updated to mention fastembed;skills/grep updated.
v0.6.2 — Trace store resilience, WAL hardening, log rotation race fix
Reliability hardening for the trace_store SQLite layer and structured logging — both addressing a real production incident on the local fleet that ran undetected for ~4 days. The headline finding: a long-running read transaction held off WAL checkpoints, the WAL grew to 2.5 GB, and the 5-second busy_timeout couldn't absorb the resulting writer contention. ~40,000 background record_trace tasks failed with database is locked over May 10-15 while requests themselves still succeeded — observability was the only visible casualty (dashboard reqs_24h quietly dropped to 0). Adjacent: the daily log rotation handler raced between herd and herd-node writing to the same file, leaving one day's log growing for the entire incident window. Both are fixed; both now have health checks or architectural separation to prevent silent recurrence. Initial fix (busy_timeout + retry + autocheckpoint, committed 2026-05-15) reduced failure amplitude but didn't eliminate it — see the follow-up Part C + A fix below, which addresses the structural cause and was verified clean under live load on 2026-05-16.
Fixed
-
TraceStoreandLatencyStorenow use a dedicated read connection (Part C indocs/plans/trace-store-read-connection-and-checkpoint.md). Each store opens twoaiosqliteconnections:_dbfor writes,_read_dbfor every dashboard analytics + scoring read path. Two purposes — (1) aiosqlite serializes operations per-connection through one background thread, so a slow read on the shared connection blocks queued writes for the read's duration; with separate connections they run concurrently in separate threads. (2) Read snapshots pin the WAL checkpoint barrier; on a separate connection the writer's view of the WAL can advance independently. Combined effect verified on the local fleet 2026-05-16 — a 30-concurrent-write + 120-dashboard-poll burst held the WAL at 410 KB peak vs 103 MB on the same workload before this change.PRAGMA query_only=1on the read connection rejects accidental writes immediately rather than silently competing with the writer. 7 new tests cover routing (reads →_read_db, writes →_db), query-only enforcement, and connection lifecycle. -
Explicit periodic
PRAGMA wal_checkpoint(PASSIVE)every 10 seconds on each store's writer connection (Part A in same plan). Defense-in-depth on top ofwal_autocheckpoint=100— autocheckpoint is tied to write volume, so under bursty traffic the WAL can sit at 99 pages for a long time while readers accumulate snapshots; by the time the 100th page write fires autocheckpoint, those snapshots have pinned the checkpoint barrier so far back that very little can advance. Tying checkpoints to wall-clock makes them fire in the gaps between reader snapshots rather than only when a write lands on a threshold. PASSIVE is non-blocking, so this is safe to run on a tight cadence. Logged at DEBUG every tick; promotes to INFO if a tick comes back contested with >100 unwritten pages — surfaces sustained contention without flooding the log under healthy operation. 3 new tests cover the return shape, closed-connection safety, and error-swallow behavior (must never crash the background task). -
TraceStoreandLatencyStoreSQLite write resilience.PRAGMA busy_timeoutbumped from 5s → 30s in both stores so a transient WAL checkpoint stall can't immediately fail writes.TraceStore.record_tracenow retries ondatabase is lockederrors with exponential backoff (200ms → 800ms → 2s, 3 attempts) before giving up — so the busy_timeout absorbs short contention and the retry loop covers longer stalls, for ~90s cumulative patience before a trace is declared lost. AddedPRAGMA wal_autocheckpoint=100to both stores to bound WAL growth even when an external reader is slow. Failures after all retries are exhausted are counted in a rolling deque so the new health check (below) can surface them without operators having to grep logs. See the 2026-05-15 observation indocs/observations.mdfor the full incident timeline. -
Daily log rotation race between
herdandherd-node. Both processes previously calledsetup_loggingwith the same default file path (~/.fleet-manager/logs/herd.jsonl) and registered their ownTimedRotatingFileHandler. At UTC midnight, one process would renameherd.jsonl→herd.jsonl.YYYY-MM-DDand the other would keep writing to the renamed inode via its still-open file descriptor for the rest of the file's life. Observed in the wild: a single day's log grew to 131 MB while peer days were 6 MB; the orphaned file kept receiving writes for five days after its supposed rotation. Fix:setup_logging(log_name=...)is now parameterized; router usesherd(default, back-compat) and node usesherd-node. Each process owns its rotation. Cross-file audits (grep -c '"level": "ERROR"' ~/.fleet-manager/logs/herd*.jsonl*) still work via glob.
Added
trace_store_write_failureshealth check (now 32 distinct checks). ReadsTraceStore.get_write_failure_count(window_s=300)and emits a WARNING at 1+ failures in the last 5 minutes, CRITICAL at 50+. Closes the observability black hole that hid the May 10-15 incident — the only visible signal before this wasdashboard reqs_24h=0for a router that was clearly serving traffic, which is easy to dismiss as "nobody's running anything right now." 10 new tests intests/test_server/test_trace_store_resilience.pycover retry-on-locked-then-succeed, retry-exhaustion-then-record-failure, non-lock-errors-don't-retry, window-pruning semantics, and the severity threshold transitions.
Changed
CLAUDE.md"Gotchas" — two new entries to keep future operators (and AI agents reviewing logs) from repeating the failure modes that delayed detection of this incident. (1) JSONL log scans must use'"level": "ERROR"'with a space after the colon —json.dumpswrites whitespace by default and the no-space variant silently matches zero lines. A wrong grep pattern was the root cause for ~4 days of "clean fleet" soak reports during the incident. (2) Trace DB write failures are invisible from the dashboard becauserecord_traceis fire-and-forget — explicit pointer to the new health check + the three most common root causes (long-running read, disk-full, staledb-shm/-wal).
v0.6.1 — MLX supervisor hardening, speculative decoding, vision embedding honesty
Reliability + observability hardening on top of 0.6.0's multi-MLX foundation. The big wins: an MLX supervisor that can no longer get stuck restarting an orphan-port forever (orphan reap + crash-window quarantine), vision embedding chips that stop lying when the backend isn't installed, a brew install ollama-herd path that actually works, a tunable FLEET_MLX_MAX_INFLIGHT_PER_MODEL for operators willing to trade memory for batched throughput, dashboard color semantics that finally agree with the product's "idle hardware is waste" thesis, and speculative decoding live on the dedicated 30B compactor for an immediate Claude-Code-summarization-pass speedup. Source-read research in docs/research/mlx-lm-stability-and-concurrency.md confirmed v0.31.3 is the right pin; v0.31.2 would have traded our quarantine-able bug for two uncontainable ones.
Added
- Speculative decoding enabled on the dedicated context compactor (port 11441,
Qwen3-Coder-30B-A3B-Instruct-4bit). Re-tested on our pinnedmlx-lm==0.31.3and confirmed it works on standard transformer MoEs with aQwen3-1.7B-4bitdraft (≈94 tok/s on M3 Ultra, noArraysCacheerror). Live config in~/.fleet-manager/envadds"draft_model":"mlx-community/Qwen3-1.7B-4bit","num_draft_tokens":4to the 30B-A3B server entry only — every Claude Code request's pre-summarization pass benefits, which is the hot path. Not enabled on port 11440 (Qwen3-Coder-Next-4bit, the main coding model): the Qwen3-Next architecture uses Mamba/SSM-style linear attention layers that mlx-lm represents with a non-trimmableArraysCache, so spec decoding still hits ml-explore/mlx-lm#1081 atspeculative_generate_step:531. The earlier "always blocked onArraysCache" framing was wrong — the bug is architecture-specific, not version-specific. Updated post-mortem indocs/issues/mlx-speculative-decoding-blocked.md. Per-specdraft_model+num_draft_tokenswere already plumbed throughMlxServerSpec.from_dictin 0.6.0 — this release is config-only on the production fleet.
Fixed
-
FLEET_MLX_MAX_INFLIGHT_PER_MODELenv var — tunable per-model concurrent-request cap on the MLX proxy. Default1(strict serialization, matching historical behavior). Bump to2or3to letmlx_lm.server'sBatchGeneratorprocess multiple requests in one inference pass — empirically validated 2026-04-27 on the local fleet (3 concurrent requests took 0.55s wall vs 1.10s sum, confirming real parallelism). The default stays conservative because each in-flight request carries its own KV cache state, so 2× concurrent 100K-token prefills is 2× memory pressure; concurrent paths inmlx_lm.serverhave historically been bug magnets (#965, #1166 — both fixed in v0.31.3, but the pattern is real). Operator opt-in only. Full source-read + live test indocs/research/mlx-lm-stability-and-concurrency.md. 5 new tests cover defaults, clamping (negative/zero values floor to 1), the 2-concurrent-acquires-then-third-blocks behavior, and per-model semaphore independence. Confirmed in the same research that mlx-lm v0.31.3 is the right pin even though it's where we hit #1208 — downgrading to v0.31.2 would trade our quarantine-able bug for two uncontainable ones (#1166 Qwen3-Next concurrent crash + #1181 thread-local stream crash). -
MLX supervisor now detects and SIGKILLs orphan
mlx_lm.serverprocesses on the configured port before spawning its own. A previous herd-node session killed viapkill -9 -f "bin/herd-node"(without also killingmlx_lm.server) leaves the MLX subprocesses alive —Popen(start_new_session=True)makes them survive their parent's death; they get reparented to launchd and keep holding ports 11440 / 11441. The next supervisor startup couldn't bind, exited rc=1 in ~2 seconds, and the quarantine guard kicked in against a process that didn't exist while the orphan kept serving requests. Result was 17 hours ofmlx_server_quarantinedwarnings against a fleet that was actually serving traffic, just not under any supervisor's control. Newfind_orphan_mlx_pids_on_port()(psutil-based, identity-strict — only kills processes whose cmdline mentionsmlx_lm.serverAND whose net_connections show binding to our port).MlxSupervisor.start()calls it before its ownPopenand SIGKILLs anything found, with a loud WARNING explaining what was reaped. 8 new tests for the strict identity check (right cmdline + right port = kill; wrong port = leave alone; non-mlx process on right port = leave alone; permission errors handled). Operator restart recipe inCLAUDE.mdupdated topkill -9 -f "bin/herd|mlx_lm.server"so the manual path doesn't reproduce the trap. See observation indocs/observations.md2026-04-27. -
MLX supervisor now quarantines a crash-looping subprocess instead of restarting it forever. On 2026-04-26 a stuck-state in
mlx_lm.server v0.31.3(latest) triggered a 420-restart, 2.5-hour crash loop on the local fleet — the supervisor's_monitorcorrectly detected each crash and restarted at the (capped) 60s exponential-backoff cadence, but had no upper bound on how many times it would keep doing that. Now: ≥5 crashes within a 5-minute rolling window switches the supervisor into "quarantined" state with a 10-minute restart interval, and surfaces a CRITICALmlx_server_quarantinedhealth-check recommendation pointing at logs + likely causes. Quarantine clears automatically once a restart stays up for the full window — so transient bursts still recover gracefully; only persistent failures get throttled. Tunable via_QUARANTINE_FAILURE_COUNT/_QUARANTINE_WINDOW_S/_QUARANTINE_RESTART_INTERVALinmlx_supervisor.py. 8 new tests for the threshold + windowing logic. Underlying mlx-lm bug filed upstream as ml-explore/mlx-lm#1208. See observation indocs/observations.md2026-04-26. -
Vision embedding chips no longer lie about availability when
onnxruntimeis missing. The dashboard previously rendered DINOv2 / SigLIP / CLIP chips on a node card whenever the model weights were cached on disk, regardless of whether the embedding backend (onnxruntime) could actually load them. Operators saw "available" chips and assumed the service worked; the first real/embedcall returned HTTP 500. The collector now probes foronnxruntimeimport on every heartbeat and refuses to advertise vision-embedding models when the backend isn't loadable, so the chips disappear (honest) instead of misleading. Paired with a newvision_backend_missinghealth check (WARNING) that fires when weights ARE cached but the backend is missing — operators see "Vision embedding backend not installed on<node>. Runuv sync --extra embedding..." in the Recommendations panel instead of silently-disappearing chips. Also closes the recurring root cause: the local-deploy snippet inCLAUDE.mdwasuv sync(without--extra embedding), which is destructive — every restart stripped the embedding deps. Updated touv sync --all-extrasso optional capabilities stay resident across restarts. Newvision_embedding_status: dictfield on heartbeat +NodeStatecarries{backend_available, cached_model_count}so future health checks have a clean signal to read. 9 new tests intests/test_server/test_health_vision_backend.pycovering both fires-on-missing and silent-when-fine paths plus the collector probe directly. -
brew install ollama-herdnow actually works. The Homebrew formula atgeeks-accelerator/homebrew-ollama-herdhad been broken throughout 0.5.x — Homebrew'spip install --no-binary :all:policy forced source builds forpydantic-core, which required Rust to bootstrapmaturin, which the formula didn't depend on; sixpyproject.tomldeps (cryptography,cffi,pycparser,tiktoken,regex,websockets) were also missing from the formula'sresourceblocks; andpydantic-corewas version-mismatched against the bundledpydantic(2.45.0 vs the required 2.41.5). Fix shipped to the tap asgeeks-accelerator/homebrew-ollama-herd@71856f3(no PyPI republish needed). Verified end-to-end on macOS Apple Silicon: clean fresh-user install in ~5 minutes, all critical imports clean, bothherdandherd-nodeCLIs functional. The release checklist inCLAUDE.mdwas updated to make the brew end-to-end install test a non-negotiable gate so this class of failure can't recur. Background and post-mortem indocs/observations.md(entry: 2026-04-25).
Added
- Platform-aware thermal signal — new
ThermalMetricson the heartbeat withstate(nominal/warning/unknown),temperature_c, andsourcefields. Linux nodes now report real peak temps frompsutil.sensors_temperatures()(scanningcoretemp/k10temp/zenpower/cpu_thermaldrivers) and flagwarningabove 85°C. macOS and Windows honestly reportunknown— Apple Silicon'smachdep.xcpmis Intel-only,powermetricsrequires sudo, andpmset -g thermonly reports past events;psutil.sensors_temperatures()isn't implemented on macOS at all. The dashboard's.bar-thermaloverlay now uses the reported signal when available and falls back to the CPU≥95% proxy only when state isunknown, so Linux operators get first-class thermal detection and macOS operators keep the existing behavior with a clean seam for future upgrades. Seesrc/fleet_manager/common/system_metrics.py::get_thermal_metrics. /dashboard/color-statesdev route — renders the utilization bar in every Axis B warning state side-by-side (normal / memory warning / memory critical / CPU thermal) plus gradient sweeps for CPU (cyan→purple) and memory (soft-blue→deep-purple). Uses the live dashboard CSS + JS so any change to production styling reflects here automatically. Linked fromdocs/guides/dashboard-color-reference.mdand used by the marketin...
v0.6.0 — Multi-MLX servers, Claude Code reliability, layered context management
Multi-MLX, Claude Code reliability, and layered context management. The long-context failure modes that made Claude Code CLI feel broken around 30K tokens on local Qwen3-Coder models are systematically addressed: tool-schema fixup for the llama.cpp#20164 optional-param trap, mechanical tool-result clearing with stable-cut prefix-cache preservation, LLM-based compactor with force_all escape, pre-inference 413 cap, MLX wall-clock timeout. Multi-MLX-server support lets a single node run main + dedicated-compactor models side-by-side without Ollama eviction risk. Tool-use reliability layer repairs malformed JSON tool-call arguments. Ollama watchdog removed after it caused production incidents.
Added
- Multi-MLX-server support — the node agent can now spawn N
mlx_lm.serversubprocesses on N ports simultaneously, with per-server memory-pressure gate, per-URL health reporting, and multi-node aggregation. Closesdocs/issues/multi-mlx-server-support.md. Config shape:FLEET_NODE_MLX_SERVERS='[{"model":"mlx-community/Qwen3-Coder-Next-4bit","port":11440,"kv_bits":8},{"model":"mlx-community/Qwen3-Coder-30B-A3B-Instruct-4bit","port":11441,"kv_bits":8}]'. Legacy single-server config (FLEET_NODE_MLX_AUTO_START_MODEL+FLEET_NODE_MLX_URL) is synthesized into a one-entry list when the new var is unset — no breaking change.MlxSupervisorSet(node/mlx_supervisor.py) — owns N childMlxSupervisorinstances, parallel start/stop, one failure doesn't block the others, per-child status snapshots (healthy/starting/unhealthy/memory_blocked/stopped) published in the heartbeat.- Memory-pressure startup gate (
memory_gate_ok(),estimate_model_size_gb()) — before spawning each server, estimates weight size from the HuggingFace disk cache and compares topsutil.virtual_memory().available. Refuses to start when the total (model + headroom) won't fit.FLEET_NODE_MLX_MEMORY_HEADROOM_GBdefault 10 GB. Surfaces the skip reason in the heartbeat so the operator sees WHY on the dashboard, not just that the server is down. - Multi-node aggregation —
FLEET_NODE_MLX_BIND_HOST=0.0.0.0exposes MLX servers on the LAN. Heartbeat carries per-server{port, model, status, model_size_gb, kv_bits, last_ok_ts}; the router'sNodeRegistry.resolve_mlx_url(model)walks every online node and returns the LAN URL of whichever healthy server hosts the model.MlxProxynow takes an optionalurl_resolvercallable with a per-URLhttpx.AsyncClientcache, so a slow server's connection pool can't back-pressure into a fast one. Back-compat: legacybase_urlpositional still works. - Dashboard per-URL health table — each node card renders a compact MLX servers table showing port, short model name, colour-coded status, size in GB, and time-since-last-healthy. Drops in below the model chip row; absent on nodes without MLX configured.
- Two new health checks:
mlx_memory_blocked(WARNING when a server skipped start due to memory gate) andmlx_server_down(CRITICAL when a server that should be healthy has failed; WARNING when stuck instarting). Fix hints point at the three most common causes: missing weights, wiped--kv-bitspatch, port collision from a leftover subprocess. - Context compactor to a dedicated MLX server — enables
FLEET_CONTEXT_COMPACTION_ENABLED=truewithFLEET_CONTEXT_COMPACTION_MODEL=mlx:mlx-community/Qwen3-Coder-30B-A3B-Instruct-4bitso summarization runs on an 80B-class MoE (3B active) model without competing for the main coding model's MLX process. Shipped side-by-side on the Mac Studio: Next-4bit @ 41.8 GB on port 11440 + 30B-A3B @ 16 GB on port 11441, both hot, ~260 GB RAM headroom remaining. - 45 new tests:
MlxServerSpec.from_dictvalidation, memory gate accept/reject/unknown-size paths, HF cache walk,MlxSupervisorSetparallel orchestration + one-failure isolation + duplicate-port dedup + healthy-models filter, registry resolution (single node, multi-node, bare/prefixed, offline/unhealthy skip, full-map aggregation),MlxProxyresolver priority + exception fallback + per-URL client cache isolation + unresolvable-URL error, and two per-server health check emitters.
Fixed
-
Per-port MLX log files —
mlx_lm.serversubprocesses now write to~/.fleet-manager/logs/mlx-server-<port>.log(e.g.mlx-server-11440.log) instead of sharing a singlemlx-server.log. Uncovered during post-deploy investigation of a timed-out request: with both MLX subprocesses appending to the same log file, per-server crash diagnosis was effectively impossible. Single-server deploys get a stablemlx-server-11440.logpath; the old sharedmlx-server.logis left untouched for archival value but no longer written to. Also updated the module docstring path reference sotail -f ~/.fleet-manager/logs/mlx-server-*.logdoes the right thing for any deploy shape. -
FLEET_MLX_WALL_CLOCK_TIMEOUT_Sguidance — default stays at 300s (reasonable for most workloads), but the configuration reference now explicitly calls out that long Claude Code sessions (2000+ messages) on Qwen3-Coder-Next-4bit routinely run 200-245s and need600to avoid edge-case 300.5s-type timeouts. No default change; the existing env var has always been tunable — just making the tuning knob discoverable for the workload that most benefits from it. -
Four field-survey-driven Claude Code CLI enhancements (P1–P4). Landed after a competitive audit of 13+ open-source Claude Code proxies (musistudio/claude-code-router 32.8k⭐, nicedreamzapp/claude-code-local, and others). Full research in
docs/research/claude-code-proxy-techniques-survey.md; priority matrix + rationale indocs/plans/claude-code-enhancements-from-field-survey.md.- P1 — Expanded JSON repair patterns.
tool_call_repair.pynow falls through to a four-pattern regex catalog (Pattern A:parameter=key>value, Pattern B:<parameter_key>value, Pattern C: malformed"arguments"objects, Pattern D: single-arg tool inference via an 8-entry defaults table for Bash/Read/Write/Glob/Grep/WebFetch/WebSearch/TodoWrite) whenjson-repairproduces schema-invalid output. Adapted fromnicedreamzapp/claude-code-local'srecover_garbled_tool_json. Still schema-gated — no repair substitutes unless it passes_structurally_valid_against_schema. 10 new tests covering each pattern + end-to-end single-arg inference. The repair cascade is now strict-parse → json-repair → regex-patterns → pass-through original. - P2 —
FLEET_ANTHROPIC_TOOLS_DENY. Comma-separated list of Claude Code tool names to strip from every/v1/messagesrequest before translation (e.g."WebSearch,WebFetch,NotebookEdit"). Saves 200–600 prompt tokens per turn depending on which tools get removed. Pairs with client-sidepermissions.denyin.claude/settings.json— client-side only blocks execution, this removes the definitions from the wire entirely. Names matched exactly (case-sensitive). 7 new tests covering empty deny, single/multi strip, whitespace tolerance, exact-not-substring, strip-everything. - P3 — Size-based model escalation.
FLEET_ANTHROPIC_SIZE_ESCALATION_TOKENS+FLEET_ANTHROPIC_SIZE_ESCALATION_MODELlet operators auto-route prompts over N tokens to a different (larger) model without changing the normalFLEET_ANTHROPIC_MODEL_MAP. Example: map Sonnet toqwen3-coder:30bfor fast turns, escalate tomlx:Qwen3-Coder-Next-4bitabove 50K tokens. Trades small-request throughput for large-request quality where it matters. Pre-route token count uses the same_total_tokens()used by context management so it's consistent with clearing/compaction decisions. - P4 — Warm-prompt preload on MLX supervisor start. After
mlx_lm.serverpasses its health check,MlxSupervisorfires a fire-and-forget 1-token request to prime the prompt cache with the system prompt prefix. Based onwaybarrios/vllm-mlx's reported 1.3–2.25× TTFT improvement on the first real request. Non-fatal on failure (DEBUG log). Confirmed live after deploy —mlx_lm.server warmup complete — prompt cache primedappears ~15s after supervisor start. - P6 — Documentation. New "Stability techniques for long-context local sessions" section in
docs/guides/claude-code-integration.mdcoveringpermissions.denypairing, the 80/20 token-range rule, fresh-session cadence, and the new size-escalation knobs. Three new env vars documented indocs/configuration-reference.md.
- P1 — Expanded JSON repair patterns.
-
Per-tier model routing:
claude-haiku-*→gpt-oss:120b(Ollama);claude-sonnet-*/claude-opus-*→mlx:Qwen3-Coder-Next-4bit.FLEET_ANTHROPIC_MODEL_MAPedit only — zero code change. Lets Claude Code users trade speed for quality per-invocation (claude --model claude-haiku-4-5). Haiku goes to a smaller hot Ollama model (fast, already pinned) while heavier tiers stay on the 80B MoE. Different model families on different tiers also diversifies failure modes — if Qwen3 has a bad day, haiku still works. Seedocs/plans/claude-code-performance-improvements.md§#4. -
Tool-call JSON repair:
server/tool_call_repair.py+ metrics. Local coding models occasionally emittool_use.inputwith minor syntax errors (trailing commas, unescaped quotes, missing brackets). Without repair, Claude Code's strict SDK parser rejects these and the session errors. New module uses thejson-repairlibrary (added to core deps, pure Python ~100KB) to attempt recovery, validates the repaired dict against the tool'sinput_schema, and only substitutes the repaired version if it passes structural check. Never hides real failures silently — every repair attempt logs at WARNING, and `tool_repair: {attempts, succes...
v0.5.2 — SSE watchdog, Fleet Intelligence bug fixes, connection failure tracking
Highlights
- SSE watchdog — dashboard auto-reconnects after 10s of silence, preventing stale node status after network drops
- Connection failure tracking — node agent tracks connection failures, health engine surfaces them, Fleet Intelligence includes them in briefings
- Fleet Intelligence enriched — per-model traffic breakdown, per-node disk space, all health warnings, previous briefing continuity
- 2 bug fixes —
report.scoreAttributeError andavg_latency_msKeyError in Fleet Intelligence prompt (briefings were silently failing) - 17 health checks total
Fixes
- Dashboard header stats no longer go stale (innerHTML race condition)
- Dashboard SSE connection established before footer DOM exists
- Fleet Intelligence prompt now correctly accesses
report.vitals.health_scoreandavg_ttft_ms
See CHANGELOG.md for full details.
Install: pip install ollama-herd==0.5.2
v0.5.0 — Smart benchmarks, dynamic context, Fleet Intelligence, 8-tab dashboard
Highlights
- Smart benchmark system — auto-discovers fleet, fills available memory with recommended models, benchmarks LLM chat, embeddings, and image generation simultaneously
- Dynamic num_ctx management — measures actual token usage, auto-adjusts context windows to free KV cache memory
- Fleet Intelligence — LLM-powered briefings that analyze fleet health using the fleet's own models
/api/pullendpoint — pull models through the router, auto-selects best node by available memory- Dashboard overhaul — gradient progress bars, animated health ring, model badges by type, in-place SSE updates
- 16 health checks total
Fixes
- Context recommendation uses total tokens (prompt+completion) instead of prompt-only
- Node card DOM flashing eliminated with in-place SSE updates
- Fleet Intelligence prompt restricted to real commands only
See CHANGELOG.md for full details.
Install: pip install ollama-herd==0.5.0
v0.4.1 — Thinking model support, queue depth API, 15 health checks
Highlights
- Thinking model support — auto-detects DeepSeek-R1, QwQ, phi-4-reasoning and inflates token budgets to prevent empty responses
- Queue depth API —
GET /fleet/queuefor client-side backoff decisions - KV cache bloat detection — health check detects when
OLLAMA_NUM_PARALLELis too high - Stream reliability checks — client disconnect and incomplete stream tracking
- 15 health checks total
Fixes
- Embeddings proxy was incorrectly routing through chat pipeline
shutil.which()couldn't find mflux/DiffusionKit installed viauv tool- Client disconnects and incomplete streams now tracked correctly
See CHANGELOG.md for full details.
Install: pip install ollama-herd==0.4.1