Local engine model management: core, gateway, and protocol - #486
Conversation
Local inference engines now have real model management backing the UI: - EngineConfig gains typed, optional parameters (context_length, device, huge_pages, mmap, lazy_weights, max_output_tokens, max_concurrency) that Joshua maps to its serve flags (--n-ctx/--device/--huge-pages/--mmap/ --lazy-weights/--max-output-tokens/--max-concurrency); llama.cpp maps context_length to --ctx-size. extra_args remains the escape hatch and still wins on the command line. - The engine registry detects servers already running on the host (joshua serve / llama-server, Linux, best-effort) and reports them as running with the models they serve, so the UI shows what is actually loaded even when a server was started outside RustyClaw. - stop() now receives the engine config so engines scope their kill to their own port instead of pkill'ing every matching server on the host; Load/Unload/Start are honest about externally-run servers (refuse to kill them, report which port they are on, don't spawn a duplicate). - provider_models_with_local_fallback() merges the live provider API list with the engine's on-disk model list (GGUF scans for Joshua/llama.cpp), clears the fetch error when local models exist, and reports which models are loaded — the data behind on-disk model pickers. - Joshua's spawn reports a real error when the server never answers (instead of a forever "may still be loading"). - The gateway's per-turn model-call deadline is configurable via RUSTYCLAW_MODEL_TIMEOUT_SECS (default 180) — local engines on loaded machines can exceed the old hard-coded cap while prefilling. - Wire/protocol: EngineConfigSet client command, EngineInfoDto carries the full engine config, ProviderModelListResult carries loaded-model ids. Config example + CHANGELOG updated.
- joshua_serve_flags only emits --device/--huge-pages for the values Joshua actually accepts (auto/cpu/metal/cuda and transparent/2mb/1gb/ huge), so a hostile free-form config value can no longer inject shell syntax into the spawn command. Numeric fields were already safe. - Joshua's spawn no longer declares failure when the server has simply not answered yet: after the health-probe window it checks whether the spawned process is still alive and reports "still loading" if so, reserving the error for a process that actually exited. Switching to a large model no longer shows a scary error while the model loads fine moments later. - llama.cpp remove resolves the model name back to the scanned on-disk path (file stem, possibly inside a per-repo subdirectory) before deleting, and fails with the available list when nothing matches — previously it rm -f'd a non-existent path and reported phantom success while the file stayed on disk. - ProviderModels.models/.error gain the doc comments the style guide requires for public fields.
|
All four Devin review findings are addressed in
Verified: |
Protocol: the enriched payloads now ride in new frames with new pinned discriminants instead of widening existing messages — the wire format is positional bincode and deserialize_frame rejects trailing bytes, so adding fields to ProviderModelListResult/EngineInfoDto broke older peers (the project's own protocol rule: new capabilities need new frames). EngineConfigList (after EngineListResult) and ProviderModelLoadedList (after ProviderModelListResult) carry the engine configs and the loaded- model markers; the old frames are byte-identical to before. Joshua start: only a server on the engine's own configured port counts as 'already running'. A joshua started outside RustyClaw on another port no longer prevents the configured server from starting (or Restart from bringing it back). llama.cpp stop: reports honestly — 'no llama-server is running on port N' when nothing matched, instead of always claiming success while a detected server on another port keeps serving. llama.cpp start: applies the typed context window (--ctx-size) on manual starts too, matching the auto-start path. Clients gain minimal arms for the two new frames here; the desktop/TUI consume them in their UI PRs.
- Joshua spawn: the 'process exited' hard failure is only reported on Linux, where process inspection exists; on other platforms a slow load falls back to the informational 'may still be loading' instead of a false failure. Same for the llama.cpp stop check. - llama.cpp auto-start: the service definition always passes the resolved --port (not only when one is configured), so the port-scoped stop can actually identify auto-started servers instead of silently matching nothing. - llama.cpp stop: on non-Linux (no process inspection) it falls back to stopping every llama-server rather than claiming success while one keeps running. - llama.cpp start: the configured models directory is interpolated via sh_quote instead of naive single quotes. - running_server_cmdlines: no unused-variable warning on non-Linux builds (cfg_attr allow with the Linux-only body). - Joshua load: 'already loaded' is only claimed when the configured endpoint actually serves the model; a joshua started outside RustyClaw on another port no longer makes Load report success while the configured server has nothing loaded.
|
Second-round Devin findings addressed in
All review threads across #486/#487/#488 are resolved. CI is re-running (Lint already green). |
- Bump WIRE_PROTOCOL_VERSION to 4: EngineConfig gained its typed parameter fields (the shipped EngineConfigSet payload widened positionally) and the EngineConfigList / ProviderModelLoadedList frames are new, so a mismatched peer must fail at the first affected frame instead of mis-parsing. - Joshua: sh_quote the model path and every extra_arg interpolated into the serve command line; server_process_alive is Linux-only (dead code and an unused parameter on other platforms). - llama.cpp: stop() keeps its port/pattern bindings inside the Linux block (no unused-variable warnings on macOS/non-Linux); start() sh_quotes extra_args; load() falls back to the typed context_length. - engine_start_command: llama.cpp built-in flags now come first so a hand-written --port/--ctx-size in extra_args overrides them (auto-start). - Ollama: load() applies the typed context_length (--num-ctx) when no per-load override is given; list_models marks models resident per /api/ps instead of never marking anything loaded. - LM Studio: list_models no longer claims every listed model is running.
- llama.cpp list_models: a model served by a running llama-server but
living outside the scanned models dir (e.g. started manually with an
explicit --model path) was dropped from the list — loaded ids are now
surfaced like Joshua's list_models does.
- Port-scoped stop/liveness patterns terminate the digit run (joshua
pkill '...127.0.0.1:{port}( |$)' and llamacpp '...--port {port}( |$)', and
joshua's server_process_alive now requires a non-digit after the port),
so a short port (1234/808) can no longer match a server on 12345/8080.
- Engine status only reports Running when a detected server sits on the
engine's own configured port (or the configured endpoint answers); a
foreign server on another port no longer hides the Start button or
turns Stop into a no-op that claims success.
- validate_engine_config() rejects structurally unusable configs (port 0)
before they are persisted or reach the pkill/pgrep patterns; the
gateway's EngineConfigSet handler refuses and reports the error instead
of relying on the port's u16 type alone.
| async fn server_process_alive(port: u16) -> bool { | ||
| let needle = format!("127.0.0.1:{}", port); | ||
| crate::engines::running_server_cmdlines("joshua serve") | ||
| .await | ||
| .iter() | ||
| .any(|line| { | ||
| line.match_indices(&needle).any(|(end, _)| { | ||
| line[end..] | ||
| .chars() | ||
| .next() | ||
| .is_none_or(|c| !c.is_ascii_digit()) | ||
| }) | ||
| }) | ||
| } |
There was a problem hiding this comment.
🔴 Starting a slow-loading local model always reports a bogus failure
The check for whether the just-started server is still alive scans for the port text and then inspects the first character of the match itself instead of the character following it (line.match_indices(&needle) at crates/rustyclaw-core/src/engines/joshua.rs:223), so the check can never succeed and a model that is merely slow to load is declared crashed.
Impact: On Linux, starting a large local model that needs more than ten seconds to become responsive always ends with an error telling the user the process exited, even though the server is running fine and finishes loading moments later.
Why the alive-check can never return true
String::match_indices yields (start_index, matched_str) pairs — the index is the start of the match, not its end. The closure binds it as end and evaluates line[end..].chars().next(), which is therefore the first character of the needle itself, i.e. '1' of "127.0.0.1:<port>". Since '1'.is_ascii_digit() is true, is_none_or(|c| !c.is_ascii_digit()) is false for every match, so server_process_alive returns false unconditionally.
In spawn_server (crates/rustyclaw-core/src/engines/joshua.rs:186-201) the 20×500 ms health-probe loop falls through to the Linux block, server_process_alive(port) reports false, and the function bails with "…its process exited — the model file may be invalid…" even though the process is alive and still prefilling. This defeats the purpose of the new branch, which exists precisely so slow loads are not reported as failures.
The intended index is start + needle.len().
| async fn server_process_alive(port: u16) -> bool { | |
| let needle = format!("127.0.0.1:{}", port); | |
| crate::engines::running_server_cmdlines("joshua serve") | |
| .await | |
| .iter() | |
| .any(|line| { | |
| line.match_indices(&needle).any(|(end, _)| { | |
| line[end..] | |
| .chars() | |
| .next() | |
| .is_none_or(|c| !c.is_ascii_digit()) | |
| }) | |
| }) | |
| } | |
| async fn server_process_alive(port: u16) -> bool { | |
| let needle = format!("127.0.0.1:{}", port); | |
| crate::engines::running_server_cmdlines("joshua serve") | |
| .await | |
| .iter() | |
| .any(|line| { | |
| line.match_indices(&needle).any(|(start, m)| { | |
| line[start + m.len()..] | |
| .chars() | |
| .next() | |
| .is_none_or(|c| !c.is_ascii_digit()) | |
| }) | |
| }) | |
| } |
Was this helpful? React with 👍 or 👎 to provide feedback.
| async fn stop(&self, cfg: &EngineConfig) -> Result<String> { | ||
| // Scoped to the configured port: `pkill -f 'joshua serve'` would | ||
| // also kill servers started manually on other ports. The pattern | ||
| // terminates the digit run (`( |$)`), so a short port such as 808 | ||
| // cannot match a server running on 8080. | ||
| let port = cfg.port.unwrap_or(DEFAULT_PORT); | ||
| Self::sh(&format!( | ||
| "pkill -f 'joshua serve .*127.0.0.1:{}( |$)' 2>/dev/null; echo 'stopped'", | ||
| port | ||
| )) | ||
| .await |
There was a problem hiding this comment.
🟨 Client-supplied engine port is interpolated into pkill/pgrep shell patterns
EngineConfigSet lets a connected client set an arbitrary engine port, which is later interpolated into sh -c command lines used for process matching (pkill -f 'joshua serve .*127.0.0.1:{port}( |$)' at crates/rustyclaw-core/src/engines/joshua.rs:443-446, and the llama.cpp equivalent at crates/rustyclaw-core/src/engines/llamacpp.rs:253-267). The value is a u16, so no shell metacharacters can reach the command line, and validate_engine_config (crates/rustyclaw-core/src/engines/mod.rs:131-136) additionally rejects 0. The residual risk is behavioural rather than injective: the pattern is regex-matched against every process command line, so an attacker-chosen port could be crafted to match unrelated processes and have them killed by the pkill on the stop path.
Was this helpful? React with 👍 or 👎 to provide feedback.
The squashed backend kept the placeholder arms for the new ProviderModelLoadedList/EngineConfigList frames; keep the desktop's real handling (patched configs + loaded markers + the configs_received guard).
* feat(engines): local model management — core, gateway, and protocol Local inference engines now have real model management backing the UI: - EngineConfig gains typed, optional parameters (context_length, device, huge_pages, mmap, lazy_weights, max_output_tokens, max_concurrency) that Joshua maps to its serve flags (--n-ctx/--device/--huge-pages/--mmap/ --lazy-weights/--max-output-tokens/--max-concurrency); llama.cpp maps context_length to --ctx-size. extra_args remains the escape hatch and still wins on the command line. - The engine registry detects servers already running on the host (joshua serve / llama-server, Linux, best-effort) and reports them as running with the models they serve, so the UI shows what is actually loaded even when a server was started outside RustyClaw. - stop() now receives the engine config so engines scope their kill to their own port instead of pkill'ing every matching server on the host; Load/Unload/Start are honest about externally-run servers (refuse to kill them, report which port they are on, don't spawn a duplicate). - provider_models_with_local_fallback() merges the live provider API list with the engine's on-disk model list (GGUF scans for Joshua/llama.cpp), clears the fetch error when local models exist, and reports which models are loaded — the data behind on-disk model pickers. - Joshua's spawn reports a real error when the server never answers (instead of a forever "may still be loading"). - The gateway's per-turn model-call deadline is configurable via RUSTYCLAW_MODEL_TIMEOUT_SECS (default 180) — local engines on loaded machines can exceed the old hard-coded cap while prefilling. - Wire/protocol: EngineConfigSet client command, EngineInfoDto carries the full engine config, ProviderModelListResult carries loaded-model ids. Config example + CHANGELOG updated. * fix(engines): address Devin review findings on #486 - joshua_serve_flags only emits --device/--huge-pages for the values Joshua actually accepts (auto/cpu/metal/cuda and transparent/2mb/1gb/ huge), so a hostile free-form config value can no longer inject shell syntax into the spawn command. Numeric fields were already safe. - Joshua's spawn no longer declares failure when the server has simply not answered yet: after the health-probe window it checks whether the spawned process is still alive and reports "still loading" if so, reserving the error for a process that actually exited. Switching to a large model no longer shows a scary error while the model loads fine moments later. - llama.cpp remove resolves the model name back to the scanned on-disk path (file stem, possibly inside a per-repo subdirectory) before deleting, and fails with the available list when nothing matches — previously it rm -f'd a non-existent path and reported phantom success while the file stayed on disk. - ProviderModels.models/.error gain the doc comments the style guide requires for public fields. * fix(engines): address Devin review findings (protocol, start, stop, ctx) Protocol: the enriched payloads now ride in new frames with new pinned discriminants instead of widening existing messages — the wire format is positional bincode and deserialize_frame rejects trailing bytes, so adding fields to ProviderModelListResult/EngineInfoDto broke older peers (the project's own protocol rule: new capabilities need new frames). EngineConfigList (after EngineListResult) and ProviderModelLoadedList (after ProviderModelListResult) carry the engine configs and the loaded- model markers; the old frames are byte-identical to before. Joshua start: only a server on the engine's own configured port counts as 'already running'. A joshua started outside RustyClaw on another port no longer prevents the configured server from starting (or Restart from bringing it back). llama.cpp stop: reports honestly — 'no llama-server is running on port N' when nothing matched, instead of always claiming success while a detected server on another port keeps serving. llama.cpp start: applies the typed context window (--ctx-size) on manual starts too, matching the auto-start path. Clients gain minimal arms for the two new frames here; the desktop/TUI consume them in their UI PRs. * fix(engines): address second-round Devin review findings - Joshua spawn: the 'process exited' hard failure is only reported on Linux, where process inspection exists; on other platforms a slow load falls back to the informational 'may still be loading' instead of a false failure. Same for the llama.cpp stop check. - llama.cpp auto-start: the service definition always passes the resolved --port (not only when one is configured), so the port-scoped stop can actually identify auto-started servers instead of silently matching nothing. - llama.cpp stop: on non-Linux (no process inspection) it falls back to stopping every llama-server rather than claiming success while one keeps running. - llama.cpp start: the configured models directory is interpolated via sh_quote instead of naive single quotes. - running_server_cmdlines: no unused-variable warning on non-Linux builds (cfg_attr allow with the Linux-only body). - Joshua load: 'already loaded' is only claimed when the configured endpoint actually serves the model; a joshua started outside RustyClaw on another port no longer makes Load report success while the configured server has nothing loaded. * fix(engines): address third-round Devin review findings - Bump WIRE_PROTOCOL_VERSION to 4: EngineConfig gained its typed parameter fields (the shipped EngineConfigSet payload widened positionally) and the EngineConfigList / ProviderModelLoadedList frames are new, so a mismatched peer must fail at the first affected frame instead of mis-parsing. - Joshua: sh_quote the model path and every extra_arg interpolated into the serve command line; server_process_alive is Linux-only (dead code and an unused parameter on other platforms). - llama.cpp: stop() keeps its port/pattern bindings inside the Linux block (no unused-variable warnings on macOS/non-Linux); start() sh_quotes extra_args; load() falls back to the typed context_length. - engine_start_command: llama.cpp built-in flags now come first so a hand-written --port/--ctx-size in extra_args overrides them (auto-start). - Ollama: load() applies the typed context_length (--num-ctx) when no per-load override is given; list_models marks models resident per /api/ps instead of never marking anything loaded. - LM Studio: list_models no longer claims every listed model is running. * fix(engines): address fourth-round Devin review findings - llama.cpp list_models: a model served by a running llama-server but living outside the scanned models dir (e.g. started manually with an explicit --model path) was dropped from the list — loaded ids are now surfaced like Joshua's list_models does. - Port-scoped stop/liveness patterns terminate the digit run (joshua pkill '...127.0.0.1:{port}( |$)' and llamacpp '...--port {port}( |$)', and joshua's server_process_alive now requires a non-digit after the port), so a short port (1234/808) can no longer match a server on 12345/8080. - Engine status only reports Running when a detected server sits on the engine's own configured port (or the configured endpoint answers); a foreign server on another port no longer hides the Start button or turns Stop into a no-op that claims success. - validate_engine_config() rejects structurally unusable configs (port 0) before they are persisted or reach the pkill/pgrep patterns; the gateway's EngineConfigSet handler refuses and reports the error instead of relying on the port's u16 type alone. * feat(desktop): local engines dialog, parameters editor, and running-model pickers The Local Engines & Models dialog becomes a real management surface and the composer picker reflects what is actually available locally: - Each engine tab gains a Parameters editor persisted via EngineConfigSet (context window, device, huge pages, mmap, lazy weights, max output tokens, max concurrency, default model picked from the local model list, auto-start). Applied on the next Start/Load, with a Restart button to apply immediately. - The model table marks loaded models as "running" (from the engine registry's host process detection) alongside on-disk ones, and the composer's model dropdown appends a "● running" marker to models the local engine reports as loaded. - Load/Unload give real feedback: the clicked row's button shows "Loading…" until the gateway answers, and the outcome (success or an honest error) is rendered inline in the dialog, dismissible. - The dialog auto-loads the active engine's model list when it opens, instead of waiting for a tab click. - The engines dialog's EngineModelAction now carries per-model context overrides for Joshua (--n-ctx). * fix(desktop): address Devin review findings - Engine configs and loaded-model markers now arrive in their own frames (EngineConfigList / ProviderModelLoadedList) matching the protocol change on the backend: the panel entries get their config patched from EngineConfigList, and the picker's running markers come from ProviderModelLoadedList. - Load actions honour the context window saved in the engine parameters (the gateway maps it per engine: --n-ctx / --ctx-size / --num-ctx), so the saved value actually applies for llama.cpp and Ollama instead of being silently ignored. - The Restart button now works for engines with an unrelated server running on another port (backend start guard is port-scoped); it stops and restarts the engine's own server. * style(desktop): satisfy rustfmt in the loaded-markers handler * fix(desktop): address third-round Devin review findings - A load/unload action whose answer never arrives (dropped connection) left the model buttons stuck on Loading… forever: the Disconnected handler now clears the in-flight action marker and its result. - Saving engine parameters no longer blanks out enabled/endpoint/port/ models_dir/extra_args when the EngineConfigList snapshot has not arrived: the Save button is disabled (with a hint) until the real configs are in, since the panel entries are placeholders before that. - Drop the stray, unreachable global_settings.rs dialog from the branch (it was never declared as a module and references types that do not exist); the on-disk copy is left untouched.
What & why
Local inference engines (Joshua, llama.cpp, Ollama, LM Studio, exo) now have real model-management backing for the UI: pickers show what is actually available on disk, the engine layer knows which servers are already running on the host, and engine parameters are typed config instead of opaque
extra_args.Changes
Engine config & parameters (
rustyclaw-core/src/engines)EngineConfiggains typed, optional parameters:context_length,device,huge_pages,mmap,lazy_weights,max_output_tokens,max_concurrency(all defaulted — existing configs parse unchanged). Joshua maps them to its serve flags (--n-ctx,--device,--huge-pages,--mmap,--lazy-weights,--max-output-tokens,--max-concurrency); llama.cpp mapscontext_length→--ctx-size. Rawextra_argsremain the escape hatch and still win on the command line.joshua_serve_flags()centralises the flag mapping for both the spawn path and the auto-start service definitions.Running-server detection
running_server_cmdlines()/parse_server_cmdlines()(Linux, best-effort) findjoshua serveandllama-serverprocesses already on the host — including ones started outside RustyClaw — sostatus()reports them as running andlist_models()marks the models they serve as loaded. This is what lets the UI say which local models are running.stop()now receives the engine config and scopes its kill to the engine's own port (no morepkill -f 'joshua serve'nuking every server on the machine).load/unload/startare honest about externally-run servers: report which port they're on, refuse to kill them, and don't spawn a duplicate.Provider model-list fallback
provider_models_with_local_fallback()(and a detailed variant) merges the live provider API list with the engine's own model list — for the file-based engines that is a scan of the models directory, so pickers show on-disk models even when the server isn't running — and reports which models are loaded. llama.cpp'slist_modelsgained the same on-disk scan.Honest failures
joshua serve --model …manually") instead of a forever "may still be loading", and reports when joshua isn't installed.Configurable model-call deadline (
rustyclaw-gateway/src/dispatch.rs)RUSTYCLAW_MODEL_TIMEOUT_SECSoverrides it (default stays 180).Wire / protocol
EngineConfigSetclient command (persists an engine's full config toconfig.toml).EngineInfoDtocarries the engine's fullEngineConfigso clients can round-trip it.ProviderModelListResultcarriesloadedmodel ids (appended last, positional-bincode safe).Testing
cargo check --workspace, clippy clean on touched crates.