fix(runtime): stop probing the local llama backend while a cloud provider is active - #324
Merged
Merged
Conversation
…ider is active A cloud-backed session opened with `/health` + `/props` against `http://127.0.0.1:8080`, warned that nothing answered, kept a 3 s footer poller running against it, and refreshed a local `ModelProfileManager` on every turn — all for a backend the session never talks to. The warnings read as an active-backend failure on a run whose real provider was healthy the whole time (issue #112, Yabloko Labs §9). `activeTextProviderIsLlamaServer` moves from `src/tui/local-turn-gate.ts` to `src/llm/provider/registry/active-text-provider.ts` (re-exported from its old home, so its callers and tests are untouched). It is a pure function of `resolveLlmConfig`, which does no I/O, so the answer is available at the top of `buildRuntime` — hundreds of lines before `ProviderRegistry.fromConfig` resolves the active provider. Detection stays KIND-based and conservative: an id resolving to no entry counts as local. Gated on that predicate: - bootstrap's `/health` line, the `/props` profile probe, and the context-window advice that only names llama-server flags; - the sidecar's `start_session` health probe and its `llm_unavailable` event; - the agent loop's turn-start `refresh()` and between-steps `refreshIfStale()`; - the TUI footer poller's `/health` tick and `/props` label fetch, and `select-context-usage`'s use of the poller's `n_ctx` — a stale local reading must not scale a cloud model's gauge. The `ModelProfileManager` is still constructed on a cloud boot (construction is pure field assignment): deleting it would leave a mid-turn fallover to a `llama-server` link running on a frozen `plain-instruct` profile with no way back. Only its probing is deferred, into `DeferredLocalBackendProbes`, which replays health + `/props` + slot discovery + the context advice exactly once for whichever path reaches local inference first — a provider switch (the loop's turn-start gate) or a cloud→local fallover (the fallback seam's new `prepareLink` hook, called with the chosen link before its completion is sent). Local embeddings are untouched: they hang off their own flags and their own port, and are still probed under a cloud text provider. Fixes #112
…ved local link Two review findings on the same class. F3. `ensureProbed()` assigned `this.inFlight = this.deps.restore()` outside the `try`, so a SYNCHRONOUS throw from `restore` escaped before the assignment: `restored` stayed `false`, `inFlight` stayed `null`, and the probes re-armed on every later call — three `ensureProbed()` calls ran `restore()` three times, contradicting the class's own "latched even on failure" contract. The existing test throws from an `async` function, whose rejection arrives after the assignment, so it passed either way. Moving the call inside the `try` makes the `finally` latch both shapes. Bootstrap's `restore` is `async` with a catch-all so this was latent there, but the class is exported. F1 (groundwork). `noteLinkServed()` / `takeLinkServed()` carry the fact that a `llama-server` link served an attempt while the active text provider was something else — a cloud->local fallover. Take-and-clear, so a recovered cloud primary quiets the local probes again after one turn. The two members are optional on the interface, so legacy and test wiring that implements only `isActive` + `ensureProbed` still type-checks.
…cal fallover
F1, the one confirmed regression against `main`.
`fallback.appendLocal` defaults to `true`, so a rate-limited or down
cloud primary falls over to the llama-server link on every turn under the
DEFAULT config shape — this is not an opt-in path. The loop's turn-start
gate reads `activeTextProvider`, which stays cloud for the whole outage,
so after the first fallover latched `ensureProbed()` nothing refreshed
the profile again: profile and GBNF grammar stayed pinned to whatever the
first fallover probed, and `observeCompletionModelId`'s staleness flag had
no consumer. `main` refreshed unconditionally at every turn start and did
not have this hole. Measured over three turns with the model hot-swapped
behind llama-server between turns 2 and 3:
before: turn1 props=1 | turn2 props=0 | turn3 props=0 (pinned)
main: turn1 props=1 | turn2 props=1 | turn3 props=1
after: turn1 props=1 | turn2 props=1 | turn3 props=1
Two arms, neither of which probes on a turn that never touches a local
link:
- bootstrap's `prepareLink` calls `noteLinkServed()` for a
`llama-server` link, and the loop's turn-start refresh gains a second
arm that fires on `takeLinkServed()` even while the active provider
is cloud. Take-and-clear: one trailing refresh after the outage ends,
then silence.
- `prepareLink` no longer returns empty-handed once the gate has
latched. It falls through to `profileManager.refreshIfStale()`, which
on a cloud-active turn is the only surviving consumer of the
staleness flag, and sits strictly closer to the request than the
loop's between-steps refresh it replaces.
The zero-request criterion is unchanged: both arms are reached only via
a `llama-server` link, so a cloud bootstrap, a cloud turn and a sidecar
cloud start still make zero local requests.
Pinned by a test that drives the REAL `createFallbackCompleter` seam and
the REAL `DeferredLocalBackendProbes` through a real `AgentLoop`, with
`prepareLink` wired verbatim from `bootstrap.ts`, a cloud primary that
429s and a fake llama-server whose model is swapped between turns 2 and
3. It asserts the cumulative `/props` counts (1, 2, 3), that `/health` is
still replayed only once, and — the load-bearing one — the profile id
each local completion was actually built with:
["gemma4-think", "gemma4-think", "qwen-think"]. Reverting either arm
reproduces the measured [1, 1, 1].
F2. The `localActive &&` clause in `resolveWindow` had zero coverage: dropping it survived all 244 files / 2638 tests of `src/tui`, and the PR body credited it with an acceptance criterion on the strength of that run. The existing poller-fallback test uses a state with no provider rows, where `active === undefined` reads as local, so it exercises the other side of the branch. Two tests. The killer: a cloud row is the active text route, `llmHealth.contextWindow` is 4096 (a llama-server `n_ctx` left in state after a local->cloud switch — `agent-event-reducer` deliberately PRESERVES `contextWindow` when an `llm_model_updated` omits it, which is exactly the shape `notifyCatalogModel` emits, so the stale value is reachable by design), and the row's model is in no catalogue. Guard present: `null`. Guard removed: 4096, a local slot size drawn as a cloud model's context gauge. Plus the control: the guard must not cost the local route its window.
F4. `updateUrl()` emitted `llm_model_updated {model: null,
contextWindow: null}` unconditionally. No request is made — the follow-up
`tick()` returns early on a cloud route — so the zero-request criterion
was never at risk, but "on a cloud route it emits nothing at all" was
inaccurate: `/llama <url>` on a cloud session blanked the tray label and
window that the active provider had put there. The new poller test
asserted `actions == []` for `start()` only.
The bookkeeping reset still runs unconditionally (so a later switch back
to local re-discovers the model); only the emit is gated, behind the same
`localTextActive()` predicate as `tick` and `refreshModelLabel`. Test
covers `updateUrl` + `refreshModelLabel` on a cloud route, with a local
control asserting the URL change is still announced.
F6. `prepareLink`'s docstring implied per-link warming. Bootstrap's implementation warms the one local backend the runtime owns — the `ModelProfileManager` over the shared `LlamaServerClient`, which reads `localModels.url` per request — so a second `llama-server` entry pointed at a different host is announced through the hook but probed against the configured URL. That is a pre-existing `ModelProfileManager` limitation (a singleton over one client, not a per-link cache), now stated where the hook is defined. F5. `activeTextProviderIsLlamaServer` is KIND-based while `LocalModelsOrchestrator.autoStartIfReady` is id-based (`!== "local-llama"`). Calling that "the same conservative default" overstated it: for a `llama-server` entry under a custom id the two disagree and the managed daemon is not auto-started. Left as-is on purpose — it errs toward less local activity and leaves no acceptance criterion unmet — but recorded as a divergence rather than an equivalence.
F1's fix opens a second arm on the loop's turn-start refresh — a `llama-server` link that SERVED the previous turn reopens it even while the active provider is cloud — so the criterion the whole PR rests on had to be re-proved past boot. Boots the real runtime on the default fallover shape (`appendLocal` defaults to true and a `llama-server` text entry is configured, so the chain really is `[cloudy, local-llama]`), runs three turns, and asserts `127.0.0.1:8080` sees zero requests after each one. The counting `fetchImpl` now answers the cloud provider with a real chat completion, because a cloud link that fails would never reach the chain's local tail and the assertion would be vacuous; the run is checked for exactly three cloud completions. Measured outside the suite as well: those three completions are the ONLY outbound requests the process makes.
…nd pin the fallover end to end Follow-up to F1's fix. Two mutations of the new `prepareLink` body — deleting `noteLinkServed()`, and deleting the `refreshIfStale()` fall-through — survived the whole suite, because the body was an inline closure inside `buildRuntime` that only a booted runtime driving a real fallover could reach, and no such test existed. A third (bootstrap not wiring `prepareLink` into its seam deps at all) survived too. `createLocalLinkPreparer` lifts the three decisions out of `buildRuntime` into `local-backend-gate.ts`, next to the gate they drive, with unit tests for each: the non-local early return, the served mark, restore without a double refresh, the fall-through on every later attempt, and the local-from-boot run where there is nothing to restore but the staleness flag still needs a consumer. The agent-loop fallover test now drives that same function instead of a verbatim copy of it. And the end-to-end case the PR body had listed as tested only at the seam: a booted runtime, a cloud primary flipped to 429, and the fallback chain routing three turns onto the llama-server link. Asserts `/health` = 1 and `/props` = 1 with `/props` landing BEFORE the local `/completion`, then `/props` = 2 and 3 on the following turns with `/health` still 1. All four mutations are now killed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #112
A cloud-backed session opened with
/health+/propsagainsthttp://127.0.0.1:8080, warned that nothing answered, kept a 3-second footer poller running against it, and refreshed a localModelProfileManageron every turn — for a backend the session never talks to. The warnings read as an active-backend failure on a run whose real provider was healthy the whole time.(The permalinks in the issue point at
9d525ef, months stale; every call site here was re-located onorigin/main@0ed39a7.)The gating predicate, and why it is safe to call this early
activeTextProviderIsLlamaServer(llm)moves out ofsrc/tui/local-turn-gate.tsintosrc/llm/provider/registry/active-text-provider.ts, besideresolveLlmConfig, and is re-exported from its old home so its existing callers and tests are untouched.src/runtime/andsrc/sidecar/now gate on the same predicate without importing fromsrc/tui/.It is safe at the very top of
buildRuntimebecauseresolveLlmConfig(config)is a pure function of config with no I/O — it either returns thellmblock or synthesizes the singlelocal-llamadefault fromlocalModels.*. Nothing about "is the active text provider local?" needsProviderRegistry.fromConfig, which is what made the previous triage passes read this as a bootstrap-hoisting job.Detection stays KIND-based (any
llama-serverentry, so a renamed entry is still gated) and keeps a deliberate conservative default: an active id resolving to no entry counts as local. The direction matters — a wrong "local" costs one wasted probe, a wrong "cloud" runs inference on an unprobed profile. A siblingproviderIdIsLlamaServer(llm, id)answers the same question for an arbitrary chain link.What is gated
src/runtime/bootstrap.ts/healthline, the/propsprobe insideresolveModelProfile, and theminUsableContextWindowwarning (local-only advice — its hints name--ctx-size/localModels.managed.contextSize).src/sidecar/main.tsstart_sessionhealth probe and itsllm_unavailableevent. Config is re-read per session, not closed over, since the shell can rewrite it between sessions.src/agent/agent-loop.tsrefresh()and the between-stepsrefreshIfStale(), behind one new optionallocalBackenddep. Absent (test/legacy wiring) reads as "always local", so pre-existing loop tests are byte-identical in behaviour.src/tui/llm-health/llm-health-poller.tstick(),refreshModelLabel()andupdateUrl()'s emit return early on a cloud route.src/tui/select-context-usage.tsn_ctxis only consulted while a local backend is the active route.The
ModelProfileManageris not deleted — only its probes are deferred. Construction is pure field assignment with no I/O (verified: the constructor only assigns). Deleting it on a cloud boot would leave a mid-turn fallover to allama-serverlink running on a frozenplain-instructprofile with no route back to the real one.The TUI poller: not polling, not polling-and-hiding
The poller re-reads config on its own tick rather than being started/stopped by each switch path. There are three of those (
ProvidersOrchestrator.setActiveText, the composer switch,save-provider-wizard), and a poller that had to be told about each would miss whichever is added next; a self-reading tick resumes within one interval after any of them, with no wiring. On a cloud route it emits nothing, sollmHealth.statusstaysunknownrather than latchingdown.updateUrl()is gated too, and separately fromtick(): it made no request (thetick()it schedules returns early), but it emittedllm_model_updated {model: null, contextWindow: null}unconditionally, so/llama <url>on a cloud session would blank the tray label the active provider had put there. The bookkeeping reset still runs unconditionally, so a later switch back to local re-discovers the model.Presentation was already partly correct —
selectComposerBackendMetareturnscloud → healthyand gates local status behindlocalConfigured— butresolveWindowinselect-context-usage.tsreadllmHealth.contextWindowunconditionally, so after a local→cloud switch the cloud model's gauge would be drawn against a llama-servern_ctx. That stale reading is reachable by design, not only by a lost race:agent-event-reducer.tsdeliberately preservescontextWindowwhen anllm_model_updatedomits it, which is exactly the shapenotifyCatalogModelemits. It is now guarded, and the guard has its own test.Overlap with the open TUI PRs: #318/#319 (context-chip token accounting + live recalc) and #320/#321 (context-gauge persistence, per-session model) all live in the context/model area. The only line here in their neighbourhood is the
resolveWindowguard inselect-context-usage.ts(source #2 of four). If one of those lands first this is a two-line rebase; flagging rather than coordinating, as asked.Lazy restore — including the cloud→local fallover
New
src/llm/local-backend-gate.ts:DeferredLocalBackendProbesreplays exactly once the probes boot skipped —/healthlogging, the/propsprofile + grammar + slot-pool refresh, and the context-window advice.ensureProbed()returnstrueonly for the call that performed them, so a caller whose next act is its own/propsrefresh skips it: one just landed. Concurrent callers await the same restore and reportfalse. It latches even on a throw — synchronous or asynchronous; therestore()call sits inside thetryprecisely so a sync throw cannot leave the probes re-arming on every call — and the bootstrap restore additionally swallows its own errors so a diagnostic cannot fail a link.Two paths reach local inference after a cloud boot, and both go through it:
(a) The operator switches back to a local provider.
isActive()re-reads live config, so the loop's turn-start gate opens on the next turn and callsensureProbed()before anything is sent.(b) The fallback chain falls over from a cloud link to a
llama-serverlink mid-turn. This is a gap this PR opens, not onemainhad — worth stating precisely, because an earlier revision of this description had it backwards.On
mainthe fallover was already safe. Verified on a cleanorigin/main@0ed39a7, booted runtime, cloud 429 → llama-server fallback:Boot ran
/health+/propsunconditionally, andmain's turn-startrefresh()was unconditional too, so a second/propslanded before the cloud attempts. The local link served with a real profile and a/props-sized slot pool. The onlymainscenario that matches "aplain-instructprofile, a one-slot pool and no/props" is a local server that is down at boot and at turn start — in which case the fallover completion fails a moment later anyway.The gap is created by this PR: boot skips both probes, and the loop's turn-start refresh is gated on the active provider, which is still cloud at the moment a fallover happens. So the link the chain picks would otherwise be the first thing in the process to touch llama-server, on deferred state.
prepareLinkis necessary because of the gating, not becausemainwas broken.Traced:
createFallbackCompleter/createFallbackStreamer(src/runtime/llm-fallback-seam.ts) driverunWithFallback, which callschain.advanceFrom(...)and re-runs the same attempt body against the next link; the attempt body resolved the link's provider viadeps.resolveSlice(providerId)and sent the completion immediately. Nothing insrc/llm/fallback/(resolveFallbackChain,run-with-fallback.ts,provider-fallback-chain.ts) touches profile, health or slot state — the chain is pure switching policy.The fix is a new optional
prepareLink?: (providerId) => Promise<void>onFallbackSeamDeps, awaited at the top of each attempt in both seams — the last point at which the chosen link is known and the completion has not been sent.resolveSlicecould not carry it (synchronous), and folding it into the attempt body would put bootstrap's knowledge of link kinds into the seam. Bootstrap wires it tocreateLocalLinkPreparer(...); on every other attempt it is one predicate call.Sustained fallover: the local link keeps serving, so it keeps being refreshed
fallback.appendLocaldefaults totrue, so a rate-limited or down cloud primary falls over to the llama-server link on every turn under the default config shape — this is not an opt-in path. A one-shot restore is not enough there. The operator can still swap the model behindllama-servermid-outage, and the active text provider stays cloud the whole time, which is what the loop's turn-start gate reads: after the first fallover latchedensureProbed(), nothing would refresh the profile again and the prompt would keep being built with the first model's chat template and GBNF grammar.observeCompletionModelIdwould keep flagging the manager stale with no consumer. Measured over three turns with the model hot-swapped between turns 2 and 3:createLocalLinkPreparer(inlocal-backend-gate.ts, so the three decisions are unit-testable rather than an inline closure inbuildRuntime) does, for allama-serverlink only:noteLinkServed()— the loop's turn-start refresh gains a second arm that fires ontakeLinkServed()even while the active provider is cloud. Take-and-clear, so a recovered primary buys exactly one trailing refresh and then the local probes go quiet again.ensureProbed()—truemeans the restore just ran and a fresh/propsalready landed; nothing further for this attempt.refreshIfStale()— on a cloud-active turn the loop's own between-steps refresh is gated off, which leaves this the only consumer of the staleness flag, and it sits closer to the request than the line it replaces.Every non-local link returns on step 0, so the zero-request criterion is unchanged: a cloud bootstrap, a cloud turn and a sidecar cloud start still make zero local requests. Re-verified after this change with a counting
fetchImplover a realcreateAgentRuntime— cloud bootstrap plus three cloud turns served by a working cloud provider produced threehttps://cloud.invalid/v1/chat/completionsrequests and nothing else at all:127.0.0.1:8080and127.0.0.1:19092both zero.Local embeddings are untouched — they hang off
memory.embeddings.enabled+localModels.embeddings.enabledand their own port (19092), and are still probed under a cloud text provider.Acceptance criteria
/health+/propsat CLI bootstrap on a cloud configsrc/runtime/local-probe-gating.test.ts— "makes zero local text requests…", exact counts0on127.0.0.1:8080, with a local-route control asserting1+1and a KIND control (llama-server under a custom id still probes)src/sidecar/local-probe-gating.test.ts— boots the realbootstrapSidecar, pushes an NDJSONstart_sessionat stdin, reads the response off stdout; asserts[]local URLs, plus a local-route control asserting the 2/healthcallssrc/agent/agent-loop-local-gate.test.ts— countingfetchPropsstub behind a realModelProfileManager:0on a cloud turn,1on a local turn,1with no gate wired (legacy parity). Pluslocal-probe-gating.test.ts— "…across three cloud TURNS, not just at boot", through the booted runtime on the default fallover chain shapewarn/errorrecord matching/llama|context window/llm-health-poller.test.ts— "probes nothing at TUI startup…":0checkLlamaServercalls,0/propsfetches, no actions emitted at all over several tick windows; "emits nothing fromupdateUrl…" for the ungated emit, with a local control; andselect-context-usage.test.ts— a cloud row plus a leftoverllmHealth.contextWindowof 4096 must resolve tonull, with a local-route controlPLAIN_INSTRUCT_PROFILEwith no probe; loop test asserts the profile is unchanged after a cloud turnlocal-llamalazily restores health/profile/context/slotslocal-probe-gating.test.ts— "lazily restores the local backend when the operator switches to it": boot cloud (0 requests),providerRegistry.setActive("local-llama")+ config flip, run a turn, assert/health= 1 and/props= 1 before inference. Plus the loop-level switch test and theDeferredLocalBackendProbesunit tests/healthon:19092= 1 while:8080= 0local-probe-gating.test.ts— "a cloud->local FALLOVER warms the link before it serves, turn after turn" boots the real runtime, flips the cloud provider to 429, and asserts/health= 1,/props= 1 with/propslanding before the local/completion, then/props= 2 and 3 over the next two turns with/healthstill 1. Plusllm-fallback-seam.test.tsordering (["prepare:cloud","serve:cloud","prepare:local","serve:local"], unary and streaming, through the real factories), thecreateLocalLinkPreparerunit tests, and the loop-level three-turn hot-swap testagent-loop-local-gate.test.ts— real seam + real gate + real preparer + realAgentLoop, cloud 429 on every turn, model swapped behind the fake llama-server between turns 2 and 3: cumulative/props[1, 2, 3],/healthreplayed once, and the profile each local completion was actually built with is["gemma4-think", "gemma4-think", "qwen-think"]llm-health-poller.test.ts— "resumes within one tick…", config flipped mid-run with no call on the pollerFully covered: all of them.
Test evidence
npm run lint(tsc --noEmit): clean.npx vitest run src/runtime src/sidecar src/agent src/tui/local-turn-gate.test.ts src/llm/fallback src/llm/provider/registry src/llm/local-backend-gate.test.ts src/llm/model-profile-manager.test.ts src/llm/llama-server-health.test.ts src/tui/llm-health src/tui/select-context-usage.test.ts src/tui/components/context-chip.test.tsx src/tui/components/context-panel.test.tsx src/tui/usage-at-pairs.test.ts src/tui/local-backend-readiness.test.ts→ 606 passed, 1 failed (48 files).src/sidecar/send-message-concurrency.test.ts > serialises two rapid send_message calls FIFO without crossing state. Pre-existing: it fails identically on a cleanorigin/main(0ed39a7) checkout in the same environment.npx vitest run src/tui(this change alters the poller and a context selector): 244 files, 2642 tests, all passed. (One unhandledEACCESspawning a fakellama-serverbinary inlocal-models-orchestrator-auto-update.test.ts— pre-existing environment noise, no test failed.)bootstrap.ts,sidecar/main.ts,agent-loop.ts,llm-health-poller.ts,select-context-usage.ts,llm-fallback-seam.ts) reverted toorigin/mainand the new tests kept, 15 of the new tests fail — both bootstrap cloud tests, the three-cloud-turns test, the embeddings one, the lazy-restore one, the end-to-end fallover one, the sidecar cloud test, three loop tests (cloud turn, hot switch, sustained fallover), bothprepareLinkordering tests, three poller gating tests and theresolveWindowguard test. The ones that still pass with the fix reverted are exactly the intended controls (local route still probes, legacy-deps parity) and the unit tests for the newlocal-backend-gate.tsmodule itself./healthgate, the boot/propsgate, both loop gates, the served-link arm, theprepareLinkawait (unary and streaming), bootstrap's wiring of it, all three decisions insidecreateLocalLinkPreparer, the gate's failure latch, the poller'stickandupdateUrlgates, theresolveWindowguard, and the predicate's conservative default — all 15 killed.Left out
Nothing from the issue's scope. Three adjacent things deliberately untouched, stated as limitations rather than as equivalences:
LocalModelsOrchestrator.autoStartIfReadyuses a different predicate. It also gates on the active provider, but by id (activeTextProvider !== "local-llama"), where everything in this PR is KIND-based. The two are not equivalent: for allama-serverentry under a custom id this PR's predicate says local and the orchestrator's says not-local, so the managed daemon is not auto-started for it. That is pre-existing, errs toward less local activity, and leaves no acceptance criterion here unmet — so it is left alone and recorded as a divergence, not folded into this diff.prepareLinkwarms the configured local URL, not the link's own URL. TheproviderIdsays which link is about to serve, not where it lives; bootstrap warms the one local backend the runtime owns — theModelProfileManagerover the sharedLlamaServerClient, which readslocalModels.urlper request. A secondllama-serverentry pointed at a different host is announced through the hook but probed against the configured URL. Pre-existingModelProfileManagerlimitation (a singleton over one client, not a per-link cache); multi-endpoint local links would need a manager per link before the hook could mean more. Now stated in the hook's docstring.notifyCatalogModelstill emits an optimistic label from the Models tab without probing. It costs no request, and the Models tab is an explicit local surface.