Swap the resident model without restarting mavend #68
Closed
claude
wants to merge 1 commits from
overnight/model-swap into overnight/web-crawler
pull from: overnight/model-swap
merge into: kami:overnight/web-crawler
kami:master
kami:task/725-capability-ledger-and-empirical-baseline
kami:task/692-heads-path-may-equal-model-path-and-noth
kami:task/694-staticcheck-and-deadcode-are-still-not-i
kami:task/682-go-1-25-5-and-x-text-0-14-0-carry-20-rea
kami:task/674-caveats
kami:task/673-mavgpud-serves-the-model-to-the-whole-la
kami:task/487-capture-device-doc
kami:task/487-capture-device
kami:task/487-wake-word-deploy
kami:task/487-wake-word-threshold
kami:task/487-wake-word-stage-two
kami:task/671-mavwaked-registers-as-a-voice-consumer-i
kami:task/670-cut-claude-md-to-200-lines
kami:task/515-deploy-mavwaked-workpc
kami:task/669-prune-claude-md
kami:task/668-e4b-phrasing
kami:task/668-title-capital
kami:task/668-kiwix-answers-a-question-it-cannot-answe
kami:task/666-only-a-stage-0-grammar-may-take-the-pers
kami:task/487-mavwaked-has-no-wake-word-only-an-energy
kami:task/486-deploy-the-workstation-transcriber
kami:task/486-move-stt-and-tts-to-the-workstation-wher
kami:task/665-crisperwhisper-2-russian
kami:task/664-routing-heads-in-go
kami:task/662-usage-harness-source-badge
kami:task/661-post-merge-usage-rerun
kami:task/661-routing-heads-step-3-train-the-multi-hea
kami:task/660-router-prompt-destination
kami:task/659-destination-fixture
kami:task/655-query-source-is-a-routing-decision-made
kami:task/654-a-pending-clarify-has-no-way-out-neither
kami:task/654-week-of-usage-eval-docs
kami:task/649-needs-kami-telegram-is-the-only-reach-an
kami:task/643-memorystore-search-decodes-and-unmarshal
kami:task/641-two-maps-grow-for-the-process-lifetime-w
kami:task/644-mavcaldav-is-built-documented-as-running
kami:task/642-the-store-caps-sqlite-at-one-connection
kami:task/647-factenrichmentworker-walks-the-pending-q
kami:task/646-v-637-follow-up-telegram-intake-has-no-d
kami:task/638-no-deadline-survives-the-turn-path-from
kami:task/637-inbound-telegram-turns-and-corrections-f
kami:task/636-correcting-a-turn-from-telegram-and-from
kami:task/634-an-act-alias-resolves-the-verb-but-not-t
kami:task/630-one-gesture-correction-on-chat-v-628
kami:task/629-persist-the-routing-trace-and-record-it
kami:task/631-mode-inventory-written-from-the-handlers
kami:task/586-defaultfactparser-uses-hand-written-russ
kami:task/633-reconcile-the-seed-labels-with-the-handl
kami:task/627-reminder-verbs-has-no-alarm-verb-so-an-a
kami:task/626-the-classifier-seeds-teach-an-older-inte
kami:task/546-route-with-a-fine-tuned-e5-small-instead
kami:task/586-measure-the-fact-parser
kami:fix/gofmt-ecosystem-acts
kami:task/584-media-store-a-failed-write-leaks-its-bud
kami:task/518-no-write-path-for-a-backdated-event-so-t
kami:task/287-qa-voice-session-quality-polish
kami:task/492-qa-plan-reconcile
kami:task/530-sweep-tail-four-files-the-russian-sweep
kami:task/405-score-how-often-a-real-utterance-reaches
kami:task/529-money-and-list-pick-a-mechanism
kami:task/528-sweep-tail-the-three-files-on-467
kami:task/527-embedder-open-set-phrasings-stop-being-r
kami:task/526-morphology-a-dictionary-answers-the-gram
kami:task/525-lexicons-the-finite-russian-sets-move-to
kami:task/524-entity-reference-ask-nexus-about-every-l
kami:task/523-risk-tiers-take-hexis-s-tier-for-a-hexis
kami:task/521-review-pr-111-query-strings-declension-h
kami:task/491-llama-server-core-dumps-on-every-sigterm
kami:task/479-bug-an-unconfigured-capability-does-not
kami:task/467-bug-spoken-task-capture-is-dead-the-rout
kami:task/463-deploy-mavwaked-and-mavenclient-run-nowh
kami:task/480-hearing-no-shipped-client-can-start-a-re
kami:task/432-ambient-calendar-intake-is-fragile-and-p
kami:task/431-board-surface-maven-holds-the-work-board
kami:task/433-reactivehandler-has-30-fields-and-is-pas
kami:task/371-swap-the-embedder-for-an-asymmetric-retr
kami:task/408-review-31-07-split-the-30-method-coreapi
kami:task/410-review-31-07-hand-rolled-string-enums-st
kami:task/423-review-pr50-split-internal-ipc-server-go
kami:task/422-review-pr50-split-cmd-mavend-tick-go-860
kami:task/409-review-31-07-finish-moving-mavweb-markup
kami:task/482-ambient-ingest-reads-a-notification-s-ti
kami:task/444-kuma-a-fact-per-monitor-so-she-can-name
kami:task/452-capability-model-homelab-docker-restart
kami:task/449-destructive-confirm-policy-risk-tiers-no
kami:task/453-grocery-list-items-table-fourth-append-o
kami:task/399-run-the-persona-checks-inside-the-daemon
kami:task/448-bounded-follow-up-state-pending-candidat
kami:task/455-conversation-repair-name-the-misroute-co
kami:task/454-go-mod-tidy
kami:task/458-pronunciation-dictionary-for-piper
kami:task/456-command-history-read-only-query-over-exi
kami:task/457-clarification-templates-for-the-router-s
kami:task/474-query-source-ordering-feeds-and-calendar
kami:task/469-reminders-spelled-out-times-fail-the-bod
kami:task/475-bug-the-praxis-attention-capability-is-u
kami:task/481-bug-a-transient-complaint-is-stored-as-a
kami:task/476-bug-the-router-transliterates-latin-enti
kami:task/385-decide-whether-a-parked-clarify-question
kami:task/377-backfill-routines
kami:task/421-weather-geocoder
kami:task/390-no-read-path-for-delivery-attempts
kami:task/386-recall-fixture-filler-note-ids
kami:task/473-bug-morning-item-has-no-required-flag
kami:task/465-bug-make-simulate-routes-with-an-empty
kami:task/467-bug-spoken-task-capture-is-dead
kami:task/466-bug-a-pending-clarify-is-global-so-one-u
kami:task/468-bug-pattern-detect-has-no-minimum-interv
kami:task/462-bug-checkfeminine-flags-second-person-ma
kami:task/443-safekey-drops-cyrillic-so-russian-calend
kami:task/471-bug-agendaquerygrammars-covers-today-but
kami:task/383-slottext-in-clarify-answer-would-clobber
kami:task/323-qa-phraser-coverage-is-65-3-but-the-llam
kami:task/498-bug-and-x-reach-the-model-with-no-determ
kami:task/506-strings-family-6-summaries-and-reports-i
kami:task/504-strings-family-4-act-and-smart-home-repl
kami:task/503-strings-family-3-query-answers-and-gaps
kami:task/502-strings-family-2-capture-acknowledgement
kami:task/501-strings-family-1-phrasing-fallbacks-into
kami:task/397-phrasechat-and-phrasequery-hide-model-fa
kami:task/396-the-reply-path-can-t-be-tested-llmreplie
kami:task/496-recall-a-cross-language-question-loses-i
kami:task/495-bug-x-escapes-the-personal-boundary-and
kami:task/499-llama-server-holds-7-9gb-rss-for-a-1-1gb
kami:task/470-bug-a-question-writes-invented-knowledge
kami:task/493-bug-the-memory-index-stores-the-raw-utte
kami:task/490-name-the-gap-world-questions-through-the
kami:task/485-run-the-big-model-on-the-workstation-wit
kami:task/489-workstation-deploy-mavgpud-on-workpc-and
kami:task/488-workstation-a-supervisor-that-keeps-llam
kami:task/483-docs-offload-design
kami:task/483-design-offload-ml-to-the-workstation-kee
kami:task/459-docs-refresh-the-qa-plan-against-the-liv
kami:task/446-doc-reorg-tier-the-tree-retire-the-three
kami:fix/367-voice-parks-routine-accept
kami:task/365-dialogue-slots-and-router-slots-are-hand
kami:task/364-snooze-does-nothing-at-runtime-the-gate
kami:task/447-retire-progress-md-the-backlog-and-the-f
kami:task/445-session-workflow
kami:overnight/eco-versioned-traces
kami:overnight/eco-entity-refs
kami:overnight/eco-degraded-suite
kami:overnight/netscan
kami:overnight/smarthome
kami:overnight/replay-simulator
kami:overnight/event-envelope
kami:overnight/coldstart-unlock
kami:overnight/voice-barge-in
kami:overnight/stt-golden-audio
kami:overnight/senses-speaker
kami:overnight/senses-hearing
kami:overnight/senses-media-vision
kami:overnight/mcp-tools
kami:overnight/mcp-client
kami:overnight/self-update
kami:overnight/web-crawler
kami:overnight/rss-feeds
kami:overnight/email-poller
kami:overnight/email-extract
kami:overnight/email-imap
kami:overnight/money-zenmoney
kami:overnight/task-priority
kami:overnight/task-capture
kami:overnight/behavior-profile
kami:overnight/day-plan
kami:overnight/ambient-calendar
kami:overnight/local-calendar
kami:overnight/memory-eval
kami:overnight/proactive-proposals
kami:overnight/split-voice-quiet
kami:overnight/nginx-maven-block
kami:overnight/stepup-chat-surface
kami:integration/small-batch
kami:docs/fix-drift
kami:fix/ru-wording
kami:integration/jul31
kami:overnight/resident-1.7b
kami:overnight/nudge-templates
kami:overnight/kiwix-rewrite
kami:overnight/eval-writeup
kami:overnight/fix-truncation
kami:overnight/kiwix-client
kami:overnight/ru-prompts
kami:overnight/external-data
kami:overnight/phrasing-grammar
kami:overnight/talk-eval
kami:overnight/prompt-context
kami:overnight/prompt-address
kami:overnight/eval-label-kill
kami:overnight/delivery-boundary
kami:overnight/address-check
kami:overnight/system-replies-pr
kami:overnight/clock-intent-pr
kami:overnight/embedder-backfill-pr
kami:overnight/embedder-marker-pr
kami:overnight/note-recall-pr
kami:overnight/thinking-off-pr
kami:overnight/dialogue-persist-pr
kami:overnight/persona-2p-pr
kami:overnight/clarify-expiry-pr
kami:overnight/clarify-rework
kami:overnight/phrasing
kami:overnight/bakeoff
kami:overnight/recall-margin
kami:overnight/router-on
kami:overnight/slot-extract
kami:overnight/embedder-e5
kami:overnight/router-refusal
kami:overnight/eval-rerun
kami:overnight/eval-harnesses
kami:overnight/eval-rerun-base
kami:overnight/fmt-gate
kami:overnight/routines-fire
kami:overnight/router-prompt
kami:overnight/away-leak
kami:overnight/recall-eval
kami:overnight/snooze-works
kami:overnight/clarify-wiring
kami:overnight/delivery-tests
kami:overnight/routine-accept
kami:overnight/llm-router-flag
kami:overnight/clarify-data-layer
kami:overnight/loop-rule-tests
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "overnight/model-swap"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Swapping the resident model no longer needs a restart.
internal/phraser/swap.goaddsLLMPhraser.Swap, reachable only through two new IPC methods (swap_model,model_status) and a new/modelspage in mavweb.The capability does not exist unless
phraser.swap_modelslists the gguf paths theoperator allows. Absolute paths only, exact match, validated at startup.
Why
docs/plans/05-model-swap.md/ Vikunja #250. With a CPT'd Qwen3-1.7B landing soon, theA/B loop is "edit
deploy/mavend.json, restart the daemon, lose the socket, the wakeloop and any in-flight turn". This makes it one authenticated click.
Safety properties, as code not prose
reaped before the new one launches. One 1.7B fits the Vega iGPU with
n_gpu_layers: 99; a blue/green overlap would OOM the laptop, so it is not offered.swap_test.goassertsmaxLive <= 1across a swap.quiescedrains in-flight requests (they finish on the oldmodel) and
acquirethen refuses arrivals withErrSwappinguntil the new server hasanswered
/v1/models. No turn is served half-swapped; refused turns degrade to theclassifier cascade, which is exactly what it is there for.
returns
RolledBack: truealongside the error, so the UI can say "she is stillanswering, with the old model". If the rollback also fails, the daemon logs that no
model is loaded and keeps serving from the classifier instead of pretending.
swapMuserialises swaps; a second concurrent one is refused.MethodSwapModelisAuthStepUpininternal/auth(same rung as mutating the tool allowlist), so a surface that cannot carry a passkey
gesture — voice, chat — is refused on shape alone.
POST /modelsgoes through thesame
stepUpOKasPOST /tools. Nothing callsSwapon a timer and no act or intentreaches it.
-m, so anything looser than an exact allowlist match would be a file-read primitive.The configured
model_pathis always allowed back to, listed or not — the way out ofa bad swap must not depend on remembering to allowlist what you are already running.
Also
llm.Clientnow guards its base URL behind an RWMutex and gainsSetBaseURL/BaseURL;LLMPhraser.OnSwapre-points every holder (LLM router, replier,mail extractor, memory evaluator) so they follow the new port. Without this the daemon
would fall to the classifier permanently after the first swap.
NewLLMPhraserAt(aborrowed server, tests and remote setups) leaves the launcher nil and
Swaprefuseswith
ErrSwapNotOwned— we do not kill a server we did not start.Deliberately not done
The plan's remote-backend half (
phraser.mode/remote_url, swapping between localand a llama-server on another box) is not here. It is a different problem — a model on
another host is not drained, killed or rolled back by this code — and folding it in
would have made the drain semantics guesswork.
ErrSwapNotOwnedis the honest refusalin the meantime.
Verified
make build— all 10 binaries, exit 0.make test— full-racesuite, exit 0.internal/phraser/swap_test.go(8 cases, against an injected fake fleet ofhttptest llama-servers, so no gguf and no GPU needed) covers repointing holders,
rollback on launch failure, rollback on probe failure, rollback-of-the-rollback
degrading cleanly, drain-then-refuse,
ErrSwapNotOwned, same-model no-op, empty path.Plus
cmd/mavweb/models_test.go(7 cases, incl. the step-up denial reaching nothing),internal/ipcoff-unless-wired,internal/authauthority + voice refusal,internal/configallowlist parsing and relative-path rejection,internal/llmre-pointing.
Vikunja #250
Loading a different gguf was a one-line edit to phraser.model_path plus a restart. It is now an owner-triggered IPC call, off unless configured. internal/phraser/swap.go holds the safety properties as code: - Never two models resident. The old llama-server is killed and reaped before the new one is launched. One 1.7B fits the Vega iGPU; a blue/green overlap would OOM the box, so it is not offered. - Atomic from a turn's point of view. Swap drains the in-flight turns (they finish on the old model), then refuses arrivals with ErrSwapping until the new server has answered /v1/models. No turn ever sees half a swap; refused turns fall back to the classifier cascade. - A failed load rolls back. If the new model does not start or does not probe, the previous one is reloaded and the call returns RolledBack with the error. If the rollback also fails the daemon says so and degrades to the classifier rather than pretending to serve. Holders of the completion client are re-pointed, not rebuilt: llm.Client guards its base URL and LLMPhraser.OnSwap re-points it, so the router, the replier, the mail extractor and the memory evaluator follow the new port without knowing a swap happened. Reach is deliberately narrow. phraser.swap_models is an exact-match allowlist of absolute paths a human wrote, rejected at startup otherwise, so "swap the model" can never mean "load any file on my disk"; the running model is always swappable back to. MethodSwapModel is AuthStepUp, the same rung as mutating the tool allowlist, and /models gates POST through the same stepUpOK the tools page uses. Nothing calls Swap on a timer and no act, intent or utterance reaches it. Vikunja #250Kill-then-load rather than blue/green is the right call for this box, and the file comment argues it instead of asserting it. One 1.7B fully offloaded to a Vega iGPU leaves no room for a second copy. The "keep the old one warm" design would OOM the laptop. The new server is probed for its own model identity before it is published, and that identity is reported instead of an echo of the request. A mislabelled gguf shows up on the page, not in a week of bad replies.
launchstaying nil inNewLLMPhraserAtsoSwaprefuses a server it did not start is the detail that keeps the eval harness safe. The allowlist is exact paths a human typed.cfg.Phraser.ModelPathis always included, so the way back never depends on remembering to list it.Four things.
1. Total failure reads as success-with-fallback.
SwapsetsRolledBack: trueon both failure paths, including the one where the rollback itself failed andp.beis nil.handleModelsbranches onres.RolledBackbefore anything else and renders:res.Modelis empty there, and she is not answering. Every phrasing path is on its template fallback and routing is on the classifier. This is the exact state the operator most needs to see, and the page tells him the opposite. Give the total failure its own flag, or leaveRolledBackfalse when nothing was rolled back to.2. The drain counts the phraser's own calls and nothing else.
acquireis called fromchatWithSystemandchatWithMessages. The LLM router, the replier, the mail extractor and the memory evaluator all hold an*llm.Clientbuilt byllmClientForand go straight toComplete. None of them touchinflightand none of them seeswapping. Soquiescecan return with a count of zero while the router is mid-generation, andold.Close()kills the server under it. Two documented properties fail:The gate belongs where the base URL is read.
llm.Clientalready goes throughBaseURL()under a lock, so anacquire-shaped hook there would cover every holder.3. A swap freezes the whole web UI. No deadline anywhere will break it.
Client.callholdsc.mufor the entire roundtrip,serveConnreads one frame at a time, and neither side sets a read deadline. The server-side ctx iscontext.Background(), so a browser giving up does not shorten anything. Worst case adds up: 90s drain, 60s launch, 30s probe, then a rollback of another 60s and 30s. For roughly four and a half minutes/dash,/history,/notificationsand every other mavweb page block on the same client mutex. No timeout frees them. The swap needs its own connection, or the response needs to come back before the load finishes.4.
POST /modelsis missing from the route table incmd/mavweb/main.go. That table makes each new route's gate a deliberate decision. This is the second route added since it landed that skips it. The gate itself is right and matches/toolsfor the right reason. Add the row.Smaller notes:
startLlamaProcpassesextractPort(cfg.Listen), the same fixed port every time. The new server binds the port the killed one just released, and the base URL does not change.SetBaseURLis then a no-op on every normal swap, and the observer mechanism is only load-bearing if the port ever does move. Say which one is intended.p.live, soLiveModel()keeps reporting the old model path./modelsthen shows afilerow naming a gguf that is not loaded next to amodelrow saying unknown. Clearp.livewhen there is no backend.Swapis still wired and a second attempt can recover without a restart. That is the more useful instruction to leave in the log.handleModelsreadsn_ctxoff the form, and the template renders no such input. Reachable only by hand-crafting a POST. CLAUDE.md pinsn_ctxat 4096 because the resident model is a Thinking variant. A non-Thinking gguf inherits a window sized for reasoning tokens it never emits. If the field is meant to be usable, render it next to each allowlisted path.NGpuLayershas the same shape.phraser.model_path. That is defensible as policy, and it is invisible on the page. One line saying so would close it. It matters more with PR 69 next in the stack. An update restarts the daemon, so it undoes any swap without saying it did.Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.
Pull request closed