Share one inference engine across every lilbee process a user runs#547
Merged
Conversation
Two lilbee servers on one data dir silently compete: the second overwrites server.port and spawns a second engine fleet, doubling resident model memory. serve now holds an OS file lock next to server.port for its whole lifetime; a second instance waits up to ten seconds for a dying predecessor to hand off, then exits with a clear error. The kernel releases the lock the moment its holder dies, however it died, so a crashed or killed server leaves no stale state to repair. A signal-terminated server also left no trace in server.log, making killed sessions indistinguishable from crashes when reading a diagnostics bundle. The hard-exit handler now logs the received signal, and the fleet teardown logs whether the fleet was stopped (group count, duration) or left warm for the next launch.
…rvisors A supervisor that manages several data dirs (the Obsidian plugin's shared root) needs one-server-ever across all of them, not just per data dir. It can now pass LILBEE_EXCLUSIVE_SCOPE=<dir>: the server holds an OS lock there for its lifetime and names itself in a sidecar, so a refused start can report, or gracefully take over from, the holder. Refused starts exit with a distinct code (3) so supervisors can tell 'another server owns this' from a crash without parsing output. Replacing a running server no longer requires signalling foreign processes: POST /api/shutdown (token-authed) delivers SIGTERM to the server's own process shortly after the response flushes, so the fleet teardown and shutdown logging behave identically however the stop arrives.
The per-group SIGTERM grace was 10s and groups stop sequentially, so a pathological teardown (four hung llama-servers) could take 40s while a supervisor's stop grace and a successor's lock-acquire grace both assumed seconds. llama-server holds no persistent state, so an early SIGKILL is safe: the per-group grace drops to 2.5s, bounding the whole teardown near 10s, and the lock handoff grace rises to 15s so it always exceeds the worst-case teardown with margin. The budget chain is documented at both constants so the next change keeps it monotonic.
On a SIGTERM-driven shutdown the fleet stops on a dedicated thread while SystemExit unwinds the main thread, so serve()'s finally released the OS locks several seconds before the process actually exited. A successor could acquire them and start its fleet while the predecessor's models still occupied memory; with the eager fleet build, the successor's VRAM plan probe could land inside that window and under-plan permanently. serve now joins the teardown thread before releasing, so a held lock always means the memory is still owned, and the take-over handoff serializes in the kernel regardless of supervisor timing.
Live state files recorded pid/ports but carried an empty launches payload; only detach() persisted the per-role model/ctx/slots contract. A second lilbee that wants to use a running sibling's fleet needs that contract to know what the proxy serves without reverse-engineering it from /running. SwapManager now captures the launch payload at start, carries it forward through adopt, and writes it in every state write, which also lets detach() drop its payload parameter since the manager already holds the current contract.
States the principle behind lilbee's one process boundary (a separate process only where the kernel cannot share the resource through the filesystem), why the engine crosses it and the retrieval index does not, what in-process retrieval buys, and the coordination price deliberately paid for it.
The shared engine needs three primitives no process can corrupt by dying: a build lock so two simultaneous starts never both construct an engine, a per-process user lock the kernel releases on any death, and a last-out probe that try-acquires every peer lock, cleaning up after dead processes in passing and reporting whether any live peer remains. All three are directory-scoped and agnostic to what they front. No consumers yet; the fleet provider wires them in with the acquisition ladder.
Sharing a running engine is only safe between lilbee processes whose bundled engine build matches: a release is tested against the llama.cpp, llama-swap, and gguf-parser versions it ships, and behavior (chat templates, tokenizer fixes, server flags) varies across builds even when the HTTP surface does not. The engine wheel now carries its pinned source versions as a pin string, regenerated by the wheel build from engine-versions.env and asserted in sync by a test; engine_pin() resolves it at runtime, treating a bring-your-own llama-server path as its own identity and older pin-less wheels as their wheel version. Every state write records the pin, so a binder can refuse an engine built from sources its own lilbee never shipped.
bind() points a swap manager at a live engine's proxy with no ownership taken: the binder writes no state, and its shutdown merely drops the binding, leaving the engine serving whoever else uses it. Whether a bind is allowed is decided by the contract module: the engine must serve every configured (role, model) pair and carry the same engine build pin; values the planner derives (ctx, slots) are accepted from the running engine rather than recomputed, since they legitimately vary with GPU occupancy at plan time. No production callers yet: the acquisition ladder wires these in next.
…ditional engine stop Binding exists to skip planning, so the contract's wanted side becomes the configured (role, model) pairs rather than planned launches, whose derived fields vary with GPU occupancy. stop_engine() stops whatever a dir's state files record through the records themselves, never a process handle, so it works on engines this process did not build: the off switch behind lilbee engine stop and the last-user-out path.
…uild Every fleet start now runs the acquisition ladder under a cross-process build lock: bind to the machine slot's running engine when healthy, pin-equal groups cover every configured (role, model) pair; build into the empty slot otherwise; and overflow to the config root's private engine dir when the slot is occupied by an incompatible engine. Two simultaneous starts can never both build, and a departing last user (stop-if-last runs under the same lock) can never race an arriving binder. Engine lifetime moves to kernel-refcounted membership: each provider holds a user lock in every engine dir it uses; releasing last stops the engine unless keep_engine_warm opts into persistence, which now decides only whether the engine outlives lilbee. The idle TTL applies in every mode, so even a persistent engine releases weights when unused. A config change stops the shared engine for every user (peers rediscover) while keeping membership. The warm detach/adopt machinery this replaces is removed from the provider; reloads rebuild into the dir their groups already live in.
A shared engine can be restarted out from under a user: another process changes a model, or the engine dies. That surfaces as a connection-kind provider error; embed, rerank, and non-streaming chat now drop the swap refs and retry once, which routes the call back through the acquisition ladder to rediscover the new proxy ports or rebuild. Membership is already held, so rediscovery is cheap and race-free. One retry only; a second failure surfaces. Mid-stream cuts still surface to the caller as retry errors by design, and the next call recovers. All-replica-dead now takes the same path, so a fleet whose every replica vanished gets one rebuild before the emptiness is reported.
lilbee engine stop becomes the unconditional off switch: it stops whatever the machine slot and this root's private overflow record, whichever process started them, instead of only reaping detached warm fleets (find_detached_state, its last consumer gone, is deleted). The keep_engine_warm and idle-ttl settings copy, config comments, and the usage guide's engine-lifecycle section now describe the shared engine: one engine for every lilbee process, stopped when the last one exits, warm as the opt-in to outlive lilbee, TTL applying in every mode. The MCP server's parent-death handler releases engine membership before its hard exit, since os._exit skips atexit and a dying agent host must not leave the engine unaccounted for.
detach(), adopt(), the detached state flag, keep_detached, sweep_owned, owner-liveness checks, and live-sibling sparing all maintained one idea: that a fleet belongs to the process that spawned it. The ladder made that idea obsolete: engines are machine infrastructure discovered by contract, membership is kernel-refcounted, and the build lock guarantees a single builder per engine dir. Reaping simplifies to one rule that can never disagree with binding: an engine answering on its proxy is in use and spared, whoever started it; anything else is stopped through its record and cleaned. Owner fields are no longer written (legacy files still parse), and the teardown primitives keep direct tests now that the owner-flavored suites are gone.
The engine-lifecycle section now describes what ships: the machine slot, the acquisition ladder (bind by models + engine pin, build into the empty slot, overflow privately on incompatibility), kernel-refcounted membership with last-out stop, warm as the opt-in to outlive lilbee, the ttl applying in every mode, and the one reap rule that can never disagree with binding. The two honest costs (restart blast radius on config changes, the unauthenticated localhost proxy) get their lines, and the stale owner-era comments in the swap manager now describe pid segments as uniqueness rather than ownership.
Four concurrent opencode agents on one box, each on its own project root and per-agent HOME, all pinned to one machine engine slot. Phases: fixtures with per-project knowledge bases, a single-agent baseline, then four-way load, with assertions that make the shared engine load-bearing: servers two through four bind instead of building, exactly one llama-swap/llama-server set serves everyone, VRAM stays flat against the baseline, every seeded task verifies, no server crashes, and the engine stops with an empty membership dir after the last exit. Runs on a GPU pod after the opencode QA bootstrap.
Seeds the engine cache from the prebuilt cu124 wheel (pods never compile llama.cpp), runs the opencode QA bootstrap against this branch, then executes the harness and records the exit code under /root/harness-results.
The first harness run left eight llama-swap processes and 20GB of VRAM behind. Three root causes, each reproduced in isolation on the pod: Membership holds released on a different thread never released at all. filelock instances are thread-local by default, so a hold acquired on the fleet warm-up thread read as unlocked on the teardown path, skipped its own release, and then counted its own still-held lock as a live peer: stop-if-last never fired, for any front. The hold is now created with thread_local=False. An incompatible engine nobody uses used to push every arrival into private overflow forever. A fleet built while only some configured models were installed serves a partial contract; once its builder exited, it poisoned the machine slot. The ladder now replaces an incompatible incumbent that has no live user locks (machine and private dirs alike) instead of overflowing around it; overflow remains for incumbents actively in use. Catalog commands eager-warmed the fleet. model pull built the services container, which warms by default, so a pure download spawned an engine mid-pull, and with only the chat model installed yet, that engine was exactly the partial-contract poisoner above. pull, show, rm, and browse now suppress eager start like model list already did.
Seed the engine cache with executable bits (zipfile extraction drops them; a non-executable gguf-parser silently degrades planning to file-size estimates), advertise the served chat context to opencode so its requests fit the engine's slot size, stop servers through POST /api/shutdown with a TERM fallback, run the post-ingest engine stop from every project root, and reset each project's knowledge base and agent HOME so reruns start clean.
Blast-radius pass over the fix commits: architecture.md still described unconditional overflow on an incompatible slot, the bind helper's docstring implied coexistence the ladder no longer guarantees, and the harness extracted the server token in two places. Also pins that a process probing a dir where it holds membership sees itself as a live user: the hold and the probe construct filelock instances with different arguments for the same path, and a filelock upgrade changing its per-path singleton semantics must fail this test rather than silently delete a live lock.
Agent clients size num_predict from their own prompt estimate, which under-counts against the server tokenizer; run 2 of the 4-agent harness lost an agent the moment it read a whole doc into one turn, because the requested reservation left the un-droppable prompt no room even though the window had ~14k tokens spare. The window fitter now honors the requested reservation while it fits and falls back to the default generation room otherwise; llama-server stops at the context edge anyway, so a greedy request degrades output room instead of erroring. Context overflow still raises when even the clamped reserve cannot fit the system messages, tools, and final turn. Also stops the harness fixture from pointing documents_dir at the kb source dir, which made lilbee add copy the kb into itself.
CI's Linux legs caught what macOS's flock semantics hid: user locks are fcntl-based there, so two lilbee instances in one process (per-instance Lilbee objects under services_scope, exactly what the integration tests run) constructing separate FileLock objects on the same pid-named path either falsely succeed or trip filelock's deadlock detection, failing real ingests. User locks now share one process-wide reentrant instance per path (is_singleton, not thread-local): holds are counted, the lock file is unlinked only when the last in-process hold releases, and the liveness probe treats a path this process holds as a live user instead of reacquiring it. Covered with a genuine subprocess peer (in-process peers short-circuit on the shared instance, so only a real process boundary exercises the kernel-refusal path). Also unwraps the scope-refusal assertion in the serve test: the console hard-wraps at terminal width, and CI's long tmp paths split the directory name across lines.
Windows CI runs the LOCALAPPDATA arm, leaving the XDG default line uncovered there; the split is already excluded on the win32 side, so exclude its mirror too.
The integration suites kept deadlocking despite the singleton fix, and the survivor is a three-way interaction: filelock's deadlock-detection registry is thread-local, so a hold acquired on a pooled worker thread but released on the teardown thread orphans the worker's registry entry; once the released instance is collected and the pool reuses that worker, a fresh instance's infinite blocking acquire false-positives as a deadlock. Reproduced exactly with a pinned worker plus a collection between holds. The detection only applies to infinite blocking acquires, and the pid-named user lock has no legitimate contender (another process cannot share our pid; our own process re-enters the singleton), so a finite timeout both sidesteps the false positive and turns any genuinely pathological hang into a loud Timeout instead of a silent stall.
tobocop2
marked this pull request as ready for review
July 18, 2026 19:20
The architecture sections and the new lock/ladder comments carried the reasoning story alongside the invariant; per the development guide, comments state the invariant and stop. Process-boundaries and engine- lifecycle sections in architecture.md cut to roughly half, multi- sentence comment blocks reduced to one or two lines, and the smell- trigger sweep confirms the branch adds none of the flagged patterns.
Three pod scripts that turn the shared-engine claims into measured results: setup provisions a serving lilbee with a warm engine, the sweep drives an OpenAI-compatible load generator against /v1 at rising concurrency (plus direct-to-engine cells so lilbee's overhead is a delta), and the soak loops concurrent chat streams and CLI engine cycles for hours with per-round invariants on VRAM, process counts, and membership locks, killing an engine process every fifth round to prove self-healing.
… dead slots in place Chaos soak findings on an A100 (SIGKILL a random engine process every 5th round, 4 concurrent chat streams + a CLI engine cycle per round): - A SIGKILLed llama-swap proxy surfaces as a raw httpx transport error, not a ProviderError, so _with_rediscover never fired and a long-running serve returned 500 for every chat until restarted while fresh CLI processes recovered fine. Rediscovery now classifies transport errors through is_connection_failure, chat_with_tools gets the same wrapper, and streaming chat primes its first frame inside the wrapper so a dead proxy fails where it can be rediscovered. The primed stream is a small closable wrapper so a caller that truncates before iterating still releases the request slot. - With one swap group dead and a live member holding the machine slot, an arriving process treated the slot as an incompatible incumbent in active use and built a full second engine in the overflow dir, loading duplicate 37GB weights. The ladder now rebuilds the slot in place when the healthy groups are pin-equal and serve only wanted models: that engine is the contract's own, and its members recover through rediscovery.
The comment said SERVER_LOCK_TIMEOUT must exceed a worst-case teardown of roughly four groups times _STOP_TIMEOUT_S. That names the wrong constant and the wrong path: the teardown the successor actually waits on runs through stop_engine into _stop_stale_swap, spending _ORPHAN_STOP_TIMEOUT_S plus the kill and reap waits per group, which across four groups exceeds the 15s budget several times over once SIGKILL escalation is involved. Sizing the budget to that worst case would make every ordinary restart wait on a pathological one, so the budget stays and the comment now says what it really covers: a teardown whose SIGTERMs are honored, with the successor exiting on the lock-refusal code otherwise. The test asserting the old arithmetic is removed rather than kept green -- it pinned the false premise, and no true numeric invariant exists when the real worst case is meant to exceed the budget.
contract_matches, _healthy_groups_ours, and _bind_all_in_dir each decoded state.launches into (role, model) pairs. Only the first wrapped the decode; the other two relied on contract_matches having run first on the same state to prove decodability, so reordering or dropping that guard turned a non-match into an unhandled KeyError/TypeError/ValueError in the bind ladder. All three now go through decoded_launches/served_pairs, which return None for an undecodable record, making that a value every caller handles. One InstanceLaunch.from_state remains in the codebase. The added guard pushed _bind_all_in_dir past the complexity limit, so its per-group evaluation is extracted to _bindable_group.
The writeup and 92K of raw per-cell JSON and soak CSV were machine- and run-specific output, not documentation, and the harness audit found the committed numbers not reproducible from any committed script anyway. Removed along with the results directory, and result dirs under docs/benchmarks are now ignored so they cannot come back. The pre-existing prose benchmarks referenced from the README (godot-level-generator, vision-ocr) are untouched.
_drop_swap_refs closed every pool client outright. That was safe while only terminal shutdown and config-change teardown reached it, but this branch wired it into _with_rediscover, which runs on any single connection-kind failure from chat, chat_stream, chat_tools, embed, or rerank. Pool clients own their httpx.Client, so a chat proxy blip severed the client another thread was mid-embed-batch on, and killed a streamed response already handed to a caller (failures past the first frame are not retried). It also bypassed the invariant the same file enforces in _retire_clients: never close a client a reader could still hold. Live pools now go through that in_flight-checked retirement, so idle clients close and busy ones stay retired for a later pass. Terminal shutdown passes close_all=True and still closes whatever remains.
keep_engine_warm was read from whichever process happened to exit last, using that process's config root. The machine engine slot is per OS user and shared across installations that configure it differently, and the setting is deliberately not part of the bind compatibility contract, so the 'explicit opt-in for the engine to outlive lilbee' was actually decided by an arbitrary sibling: a default-config mcp or per-project serve exiting last stopped an engine a keep_engine_warm user had built and expected warm. The opt-in now belongs to the engine rather than to a process. Acquiring membership marks the engine dir when this user wants it warm, on bind as well as on build, which is what makes the setting mean anything on a shared slot. Last-out reads that mark unioned with its own current setting, since the setting does not affect the load and a mid-session flip therefore never re-acquires. stop_engine drops the mark, so it lives exactly as long as the engine instance it describes and a rebuilt engine starts from whoever opts in next. Also pins the bind path's membership, which nothing asserted: dropping the _hold_membership call on the bind branch left 363 tests in the fleet, engine-lock and swap-manager suites passing, while the observable failure is a peer's clean exit stopping an engine a binder is actively using -- the multi-process bug this ladder exists to prevent. Both bind tests now assert membership in the dir they bound, and both fail without the call.
invalidate_load_cache and drop_loaded_models_async ran stop_engine on the machine-shared slot even while other lilbee processes held live membership and in-flight requests against it, so a model switch in one terminal interrupted an agent mid-request in another. _restart_engines is gone rather than guarded: a config change now drops this provider's membership and stops the engine only when it was the last user, and the next use re-runs the acquisition ladder, which already binds a matching engine and overflows to a private dir rather than evicting a live incompatible one. A stale-config engine is still never left warm. The chat window's output reservation is capped rather than rescued. The clamp only fired when the fit had already failed, so a client reserving 8000 of an 8192-token window left a 64-token prompt budget in which the final turn still fit: the fit 'succeeded' and the entire conversation was silently evicted, on exactly the over-reserving agent clients the clamp exists for. A reservation now only ever buys the prompt more room, never less. The machine engine slot moves out of the cache directory. It holds the state files recording a running llama-swap's pid and ports, the refcount lock dir, and the build lock -- the only handle any out-of-process stop has on a running fleet. A cleaner emptying ~/.cache mid-run orphaned a fleet holding VRAM and emptied engine-users/, so live_users_exist reported the slot free and the next process built a second fleet on top of the orphan. It now uses a state dir (XDG_STATE_HOME, Application Support on macOS, LOCALAPPDATA on Windows), which also replaces the third hand-rolled copy of the platform ladder with one shared helper next to default_data_dir. _SwapState is promoted to SwapState: it became the currency the bind ladder is written in, passed across swap_manager, provider and contract, so it is no longer a private name.
The API shutdown raised SIGTERM from a threading.Timer set to 0.2 s, a wall-clock guess that the 202 would have flushed by then. Litestar's BackgroundTask already gives the ordering the timer was approximating: it runs after the response has been handed to the transport, which the kernel then delivers even across process exit. Attaching the signal to the route's response deletes the constant, the timer, the race, and the signal and threading imports the handler no longer needs. Also restores the _live_children test the diff had reduced to an isinstance(list) check. That primitive feeds the orphan reaping for llama-servers that outlive their llama-swap, and a version returning an empty list unconditionally passed the smoke test while leaving those processes holding VRAM. It spawns a real child again and asserts the pid comes back, with a companion covering the already-exited process.
…eout A failed bind ran two full scans of the dir, each calling find_live_state plus an HTTP health probe per group: once deciding bind eligibility and again deciding whether the incumbent was replaceable. The two passes observed different instants, so an engine dying between them could make the decisions disagree within a single build-lock hold. They now read one _healthy_states snapshot. The probe also borrowed the fleet module's 10 s general HTTP budget for what is a loopback liveness check. Every probe runs while holding the cross-process build lock that gates every other lilbee start, so a single wedged proxy port (SYN-accepted but unresponsive) stalled those starts for tens of seconds. It gets its own timeout instead; a local proxy that cannot answer /running in two seconds is not usable for inference either. The finding also described a third scan via _slot_occupied, which an earlier fix on this branch had already replaced with the membership check. Also drops the last of the restart wording: _with_rediscover's docstring still said a config change restarts a peer's engine, which stopped being true when that path became a membership release.
The membership scheme rests on the kernel releasing a lock on any death, so no pid bookkeeping can go stale. filelock quietly drops that guarantee: when flock returns ENOSYS (FUSE, some NFS mounts) it rewrites the lock object into a SoftFileLock and emits only a Python warning. The failure that follows is worse than a stale lock. That fallback's acquire path opens the lock file with O_TRUNC and unlinks it before re-acquiring, so a process merely probing a live member's lock destroys it: live_users_exist then reports an empty slot while members are serving, the last-out stop kills an engine in use, a builder replaces the slot under live users, and the build lock loses cross-process exclusion so two processes build at once. The engine dir is now asked, once per mount, what it actually supports, by taking a throwaway lock and checking what filelock turned it into. Where the answer is no, the shared slot is skipped for this config root's private dir and the reason is logged with the variable that overrides the location. Sharing is the thing that needs the guarantee, so declining to share is the honest response rather than continuing on locks that do not lock. The probe file is per process. A shared one would put every lilbee start in a queue for a question about the filesystem, and this runs while the build lock is held.
Five catalog commands each carried their own copy of cfg.worker_pool_eager_start = False plus a paraphrase of the same comment, so the rule that catalog commands never warm the fleet held only by discipline and the next such command would have warmed it silently. The override now happens in one helper the commands call instead of apply_overrides, and a test walks the registered commands rather than the call sites, so a command added later fails there. The shared-engine QA scripts hardcoded this branch name and fetched their bootstrap from a raw GitHub URL built from it, which would 404 the moment the branch merged and was deleted, aborting the run before the repo was even cloned. They now read LILBEE_QA_BRANCH and default to main. SCENARIOS.md asserted as present-tense fact that lilbee has no expert offload and that the plan was blocked on it, while this same PR ships it: the launcher emits --override-tensor from cpu_moe, or from n_cpu_moe for the first N layers. Corrected in the model table, the explanation section, and the prompt that asked an agent to build what already exists. The prompt asking the planner to choose the split itself is still open and stays.
… knobs Three comments still asserted that the launch cannot over-commit VRAM past what placement reserved. That stopped being true when a tensor-split chat gained the ability to serve several full-context sequences: placement reserves KV for one window, while the launch may serve up to the chat slot count of them, so the served total can reach several times the reserve. What actually holds it is the per-device test in fit_split_ctx, which measures each card's real free bytes at launch minus what the embed and rerank servers already took. The comments now say that, and separate the per-slot ceiling (which does keep any single sequence inside the plan) from the total (which does not). Noted at the main-GPU skew reserve why it matters more than it did: gguf-parser ignores --main-gpu in split-mode layer and under-models what llama.cpp concentrates there, and the unclaimed headroom that happened to cushion that skew is what the extra windows now spend. Also documents LILBEE_CPU_MOE, LILBEE_N_CPU_MOE and LILBEE_ENGINE_DIR in the usage guide. All three are live env vars automatically, since Config takes an env prefix, and expert offload had no mention anywhere in the docs despite being one of this branch's headline features.
The llama-swap config was written with a plain write_text, which truncates first, so a process dying mid-write left an empty or half-written file that the next spawn handed to the engine via --config. It now goes through the same tmp-then-rename the state write already used, shared as one helper. The temp name deliberately stays derived from the destination rather than randomised. Both config and state filenames embed the writing process's pid, and the cleanup sweep reads that pid back out to remove a dead writer's leftovers while leaving a live writer's file in flight alone; a random name would break that. The sweep now also covers config leftovers, which it previously could not see. engine_pin documents itself as never raising, because it runs on every state write, but its pre-pin fallback called importlib.metadata.version inside the handler. That raises when lilbee_engine is importable with no distribution metadata: an extracted wheel on sys.path, a vendored copy, or a dist whose name does not normalise to lilbee-engine. It degrades to a marker now. atomic_write_text moves from the CLI's agent-config module to core, where the fleet can use it without providers depending on cli, with its tests.
… slots The state file carried a lilbee_version written on every write and parsed back on every read, with no reader left after its only consumer was deleted. Removed rather than kept for a future caller. llama-swap's log comment still placed its output in the data root beside server.log; it goes in the engine dir now, so it sits with the engine it belongs to. The engine-stop docs claimed the command frees everything from any terminal, when it reaches the machine slot and the current root's overflow dir only, so a private engine built for another project root needs the command run from there. The config-change paragraph still described the old behaviour where one process's model change restarted a shared engine; it now says what happens instead. serve's two lock refusals ended in the same log/print/exit tail, now one helper. Two function-local imports pulled in what the module already had at top. The fake machine slots the fleet tests build were created with mkdtemp and never removed, leaving a directory per test run in the system temp dir. They now live under the test's tmp_path, which also makes each test's slot visible where its other fixtures are.
The server lifespan wrapped the embedding check in a try/except and logged "Embedding model validated" no matter what came back. validate_model() is a bool predicate that cannot raise, and must not: search and chat are built to degrade when no embedder is available. So the except was dead and the success line was false whenever the model was missing. Branch on the returned bool instead, naming the unavailable model in the warning, and reuse the services handle already built for the provider pre-load. Its test stubbed a RuntimeError the real method cannot produce and so asserted nothing; it now covers the false-return path and the warning.
…rm suppression at the seam The fleet passes expert offload for every role it launches, but the self-check gated it on the embed role. An MoE embedding model with offload configured therefore got a launch with no --override-tensor from the diagnostic and one with it from the fleet, so the check could fail a full-VRAM load the fleet would have offloaded: the self-check-disagrees-with-fleet case this shared-flags work exists to remove. The flash-attn, cache-type and batch-size differences stay, since those are deliberate and documented for embed. The catalog warm test asserted that invoking each command flipped the eager-start flag, which is the implementation line rather than any behaviour. Replacing it with a per-command spy on the warm looked better but was worse: those commands never reach get_services under these fixtures, so it passed with the suppression removed entirely. The contract is now covered as a chain instead, one end asserting every registered command routes through the override helper and the other running the helper and get_services and asserting no warm follows. Both fail when their half is broken.
Picking the slot count called the fit function once per candidate, descending. Each of those calls is a complete binary search whose probes each shell out to gguf-parser on a multi-GB GGUF, and the whole search runs while this process holds the cross-process build lock that every other lilbee start waits on with no deadline. The descending order was worst exactly where it hurts: on a tight card where no count above one fits, it paid for every candidate before giving up. Bisection is sound because the fit is non-increasing in the slot count, since more sequences divide the same headroom, so once a count fails no larger one can succeed. The probe count now grows with the log of the slot ceiling rather than the ceiling itself, and a test asserts that budget rather than the outcome alone.
Shutdown took the in-process build lock with no deadline, and set the shutdown latch only after it had it. Both ends of that are wrong. Every _shut_down check in the provider runs after acquiring the same lock, so a warm or reload thread queued behind shutdown could not see the flag when its turn came and went on to build a fleet the shutdown then had to undo; the latch is now set first, so a queued thread bails immediately. The wait itself is bounded. A wedged engine start holds the lock, and an unbounded wait turns that into a process that cannot exit, which is how it surfaced: a teardown blocked on this lock with a live warm-up thread. On timeout the teardown proceeds anyway, because whatever an in-flight builder leaves is recorded in the engine dir's state files and reaped from those records by the next start, while a shutdown that never returns leaves nothing to recover with.
The process-count helpers were pgrep -fc with an || echo 0 fallback. On the procps-ng that the pods run, pgrep -c prints the count even when it is zero and still exits nonzero, so the fallback appended a second zero and the round's CSV row broke across two lines. Reproduced by simulating that exit behaviour: the old form yields 0 newline 0, the new one a single 0 on both procps-ng and BSD. Chaos rounds exempted their stream and CLI checks from the failure list entirely, which made a clean result unfalsifiable on precisely the rounds the chaos exists to test. Those rounds now record their own markers and their own total, so an interrupted stream is visible as a chaos degradation rather than dropped, while a genuine breach still counts. Process counts returning to baseline was listed as a per-round invariant but never checked, so a kill that orphaned a llama-server passed. Baselines are captured up front and compared each round. The summary grep is anchored to the field so a chaos marker is not miscounted as a hard breach, and the script now exits nonzero when there are breaches instead of reporting them and succeeding.
…ight now available_memory_for_fit builds its budget from total device capacity, deliberately stable so a catalog entry's verdict does not depend on the moment it is asked. The expert-offload headroom added to it read psutil's available memory instead, so the same model could fit or not fit depending on what else the machine was doing, and the budget shrank precisely when another model was already resident, which is when a user is most likely to be looking at the catalog. It now scales installed RAM by the same fraction, matching the basis of the number it is added to.
The start docstring claimed the freshly allocated ports prevent the bind failure llama-swap reports as 'exited prematurely'. They narrow it, they do not prevent it. The ports come from binding and closing ephemeral sockets, while llama-swap starts each upstream lazily on its first request, so a member port can sit unbound until that request arrives and anything else on the box may take it meanwhile. Keeping the scheme: llama-swap's startPort assigns a fixed sequential range at config load with no spawn-time probe, so it would widen the window across instances rather than close it. The docstring now records both the reason the built-in is not used and the residual race that remains.
Three catalog tests patched free_system_memory while the headroom reads installed RAM, so they measured whichever machine ran them: green on a 64 GB box, red on a CI runner. Two parent-death tests patched reset_services on its source module, but mcp_server binds that name at import, so the patch never took and the cleanup path was never exercised. Both were passing for the wrong reason or failing for an unrelated one. Reverting the cleanup now fails its tests, which it did not before.
Main's #570 made the agent-config write owner-only in place; this branch had moved the same helper to core.system. Main's version is the better one, so the move is dropped and the callers point back at it. Startup's embedding-model check keeps the exception tolerance main has: an embedder whose validation raises leaves the server usable for everything that does not embed, so it must not stop the server from starting.
The branch had this branch covered only by a stub that happens to lack the method, so a stub gaining it would drop the coverage silently.
The GPU work that landed on main split the chat KV cache into separate K and V types, because llama.cpp refuses a quantized V cache without flash attention while a quantized K cache needs nothing. This branch still forced both to f16 whenever flash attention was off, which would have downgraded the K cache on every such host for no reason. Main's semantics win, and the tests that pinned the old single-type behaviour now pin the split. Device probing keeps both shapes: the build precondition that runs before an incumbent is stopped, and main's report of whether every offered device was refused, so _probe_engine_devices returns both. build_server_argv gained flags from both sides and went over the complexity limit; its attention and KV flags move to a helper.
…uild storms After the shared engine dies while a lilbee process is still alive, the process recovering from the connection failure kept its own engine membership, so it counted itself a live user of the machine slot and rebuilt in a private overflow dir instead of the shared slot; its stale membership lock then made every other process overflow too, ending in N private engines and N times the VRAM. The rediscover path now releases this process's memberships before retrying, without stopping any engine, so the retry rebinds a recovered engine or rebuilds the shared one. A manual GPU placement or a gpu_devices pin did not flow into the engine pin, so a process configured to run on specific cards could silently bind an engine placed elsewhere. Both now key the pin, so a divergent process builds its own engine. The placeable set that the acquisition ladder binds against was computed from coarse per-role checks that ignored the planner's co-placement budget, so on a unified-memory box past its budget a role that fits alone but cannot co-tenant stayed "wanted", bind never matched the running engine, and the shared engine was torn down and rebuilt on every process start. The set now honors what the planner would actually place.
tobocop2
added a commit
that referenced
this pull request
Jul 23, 2026
Brings in the shared-engine work (#547), the raised single-machine fleet ceilings and off-daemon crawl conversion (#583), the orphaned-engine cleanup (#589), and the deterministic vision-dispatch tests (#591). Conflict resolutions: - settings_map.py: additive; keep ingest_workers alongside main's new mcp_tool_threads and crawl_convert_workers. - provider.py routing helpers: additive; keep the dispatch reserve (_ROUTE_LOCK / _reserve_least_in_flight) beside main's shared-engine ladder helpers. - provider.py _warm_ttl_seconds: adopt main's decoupling (keep_engine_warm now controls bind_lifetime, not the idle timer) and layer only the ingest-warmth override on top, so an active ingest still forces ttl 0 while a merely-warm idle engine unloads on the normal window. - test_fleet_provider._FakeReplica: additive; keep reserve/release beside main's close. - test_ingest_warmth: update the keep_engine_warm case to main's semantics (governs lifetime, not ttl).
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.
Problem
Every lilbee process builds its own model engine. Open the TUI while a coding agent uses
lilbee mcp, or runlilbee servealongside either, and each process spawns a full llama-server fleet: identical weights load twice, and on small machines both fleets degrade or OOM.A second
lilbee serveon one data dir also silently overwritesserver.portand the session token.Solution
One inference engine per OS user, shared by every lilbee process that user runs against the same model setup. Two accounts on one box get one engine each, since the slot lives in a per-user directory.
How a process gets its engine
Each process runs a short ladder under a cross-process build lock.
It binds to the machine slot's running engine when that engine is healthy and serves the configured models. Binders spawn nothing and write nothing; they point at the recorded proxy ports.
It builds into the slot when the slot is empty. An engine that no live process is using is replaced in place, so leftovers never poison the slot.
It overflows to the config root's private dir only when the slot holds a live incompatible engine in active use. That is exactly the case of two genuinely different model setups running at once.
What "compatible" means
The contract is the per-role models plus the bundled engine-build pin. lilbee version is deliberately not part of it: releases sharing an engine pin share an engine, while differing pins never run on a build they were not tested against.
Lifetime
Membership is kernel-refcounted. Each process holds a user lock the OS releases on any death, and the last clean exit stops the engine, leaving the machine clean by default.
keep_engine_warmis the explicit opt-in for the engine to outlive lilbee. The idle TTL applies in every mode, so even a persistent engine releases weights when unused.lilbee engine stopremains the unconditional off switch.What this deletes
The old ownership machinery (detach/adopt, owner-liveness checks, sibling sparing, the ps-scan sweep) is removed outright.
Server groundwork
lilbee servegains a per-data-dir OS lock, plus an opt-in wider one that engages only whenLILBEE_EXCLUSIVE_SCOPEnames a directory, for supervisors that need at most one server across several roots. It also gains a token-authedPOST /api/shutdownand shutdown logging, and the fleet teardown's SIGTERM grace drops from ten seconds to 2.5 on every path that stops a fleet, not only on serve.Hardening found by QA
Everything below was discovered while load-testing and benchmarking the shared engine, not planned up front. Each item is a capability or a correctness rule that was missing rather than broken by this branch.
Expert offload was missing entirely
A sparse mixture-of-experts model activates a fraction of its weights per token, so its experts tolerate system memory far better than a dense model's layers would.
llama.cpp has supported offloading them since well before the version lilbee pins, and lilbee emitted none of it: no
--cpu-moe, no--n-cpu-moe, no--override-tensor. Every model therefore had to fit VRAM outright, which is why large MoE coders appeared to need a datacenter card.cpu_moeandn_cpu_moenow emit those flags, gated on the GGUF declaring routed experts so the flag is never a silent no-op on a dense model. No engine bump was needed; the pinned build already had this.The estimator did not know about offload either
gguf-parser was sizing models as though every expert were resident, so the planner budgeted against a footprint that never materializes and granted fewer batching slots than the real residency warranted.
gguf-parser accepts
--override-tensorwith the same pattern syntax llama.cpp uses. The estimate offloads what the launch offloads. The pattern is copied from llama.cpp rather than shared with it: the launch passes--cpu-moe/--n-cpu-moeand lets the pinned llama-server expand its own regex, while the estimator is handed our copy. The two move together only as long as that copy is kept in step with upstream, which the constant says where it is defined.The catalog called runnable models unrunnable
The fit chip that marks a model "won't run" was computed against GPU VRAM alone. With offload, a model larger than VRAM runs fine, so the catalog was discouraging pulls that now work.
The budget borrows system RAM when offload is configured, and only on hosts whose budget really is device memory. Apple unified memory, non-NVIDIA hosts, and CPU-only hosts already report system RAM, and adding it twice would invent capacity.
Quantized KV could produce a command line that would not load
Quantized KV cache requires flash attention, and nothing checked the pairing. With
flash_attentionoff and the defaultq8_0cache, the launch emitted--flash-attn offalongside--cache-type-k q8_0.Both the launch and the estimator now fall back to f16 so their sizing agrees.
The setup self-check disagreed with the fleet
The self-check built its own llama-server command line and re-derived the chat flags in a hand-written expression, so it carried that same unloadable pairing and knew nothing about offload. A user with offload configured would have watched the diagnostic fail on a configuration the fleet launches fine.
Both callers now share one set of flag decisions instead of two copies.
Smaller fixes from the same passes
Catalog commands (
model pull/show/rm/browse) no longer eager-start the fleet, so a download can never spawn a partial engine mid-pull. A chat request whose output reservation exceeds the served window is clamped instead of rejected, so agent clients that over-reserve keep working. The membership locks survive cross-thread release, multiple lilbee instances in one process, and thread-pool reuse.Acceptance test
A four-agent load harness ships with the branch (
tools/qa/shared-engine): four opencode agents on one GPU box, each in its own project, all served by one engine. It exercises binding instead of building, VRAM against a single-agent baseline, task completion, and teardown leaving zero engines and zero locks.On the load and stability numbers
An audit of that harness found its invariant checks too weak to cite as evidence, so the throughput, VRAM-flat, and leak-free figures previously quoted here have been removed rather than restated. The VRAM check is one-sided, the documented "process counts return to baseline" invariant is not implemented, the one-engine assertions accept a range that admits the duplicate-engine regression they exist to catch, and the bind check samples before the agents send any requests. The published percentiles also were not reproducible from any committed script. Run output is machine-specific and is no longer version-controlled.
That work is tracked separately and lands before any number from this harness is quoted again.
An earlier soak run is still why two of the fixes above exist: on the pre-fix build, killing the llama-swap proxy orphaned its VRAM, built a duplicate engine in the overflow slot, and left the resident server erroring until restart. That reproduction stands on its own.
Folded in from #572
From a deep-retrieval review of a file this branch owns (commit
68c3e504; #572 closed in favour of this PR):The server lifespan wrapped the embedding check in a try/except and logged "Embedding model validated" regardless of outcome.
validate_model()is a bool predicate that cannot raise, and must not: search and chat degrade deliberately when no embedder is available. The except was dead and the success line was false whenever the model was missing. Its test stubbed aRuntimeErrorthe real method cannot produce, so it asserted nothing.Now branches on the returned bool, names the unavailable model in the warning, and reuses the services handle already built for the provider pre-load instead of calling
get_services()twice. The test covers the false-return path.