Run GGUF models through llama-server, and stop orphaning them
The second half of the llama.cpp work: a session can now name a downloaded model and talk to it. `llama-server` is spawned through the same transport as any other driver, polled until the model is loaded, then driven over its OpenAI-compatible streaming endpoint and translated into the same events the Claude driver emits -- so the transcript, the SSE stream and the phone need to know nothing new. **The conversation is rebuilt from the transcript, not held in the driver.** llama-server is stateless between requests, so the whole history goes with every one, and the obvious place to keep it is a Vec in the driver. That fails the requirement: memory in a driver is invisible to a second device and gone on restart, and this app is meant to work across devices. Reading it back also means the model is prompted with exactly what the phone was shown -- including a reply that was interrupted half way, which is in the transcript because the deltas were already emitted. That leaves the Claude driver as the odd one out rather than this one: the CLI's memory of a conversation is a cache in front of the same transcript, not a second truth. Said so at the top of llama.rs, because it is the sort of inconsistency that gets "fixed" in the wrong direction. Session settings arrive as a driver-interpreted `params` map rather than new typed fields, so the shared schema does not grow one dialect's vocabulary. Context size, gpu layers and threads become server flags; temperature and the rest ride on each request, so changing them need not reload a model. **Also fixes an orphan this feature would have created.** Drivers set kill_on_drop, which covers a session being deleted -- but nothing drops on the way out of a SIGTERM, so signalling the server left its children running. For the Claude CLI that is untidy; for a llama-server holding a model it is gigabytes belonging to nobody. The server now stops its sessions on SIGTERM and SIGINT. Found by killing a test server and noticing two 600 MB processes still resident. Remote llama sessions are refused rather than half-working: the model is reached over HTTP, and forwarding that port to an ssh host is the "reach this port" operation the transport does not have yet. Verified end to end against a real model: downloaded Qwen3-0.6B Q8_0 through the app's own download route, spawned a session on it, and held a two-turn conversation -- "my favourite colour is teal" then "what is my favourite colour?", answered "teal", which is the transcript replay doing its job. Token counts arrive. An earlier attempt with the IQ2_XXS quant produced fluent nonsense, which turned out to be the quantisation rather than the pipeline: llama-cli produces the same from that file directly. Four unit tests cover the fold and the path guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
e50d1a2bbf
commit
3deeffd1e7
8 files changed
+949
-17
No files matched your search
@@ -12,6 +12,7 @@
|
||||
pub mod claude;
|
||||
pub mod driver;
|
||||
pub mod echo;
|
||||
pub mod llama;
|
||||
pub mod transcript;
|
||||
pub mod transport;
|
||||
|
||||
@@ -28,6 +29,7 @@ use crate::config::{Config, DriverKind, HostConfig, ProviderConfig, SessionConfi
|
||||
use claude::ClaudeDriver;
|
||||
use driver::{Driver, Event, ImageRef, SessionStatus};
|
||||
use echo::EchoDriver;
|
||||
use llama::LlamaDriver;
|
||||
use transcript::{SeqEvent, Transcript};
|
||||
use transport::Transport;
|
||||
|
||||
@@ -52,6 +54,8 @@ pub struct SpawnSpec {
|
||||
pub model: Option<String>,
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub permission_mode: Option<String>,
|
||||
/// Driver-interpreted settings; see `SessionConfig::params`.
|
||||
pub params: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// One row of `GET /sessions`.
|
||||
@@ -124,6 +128,11 @@ impl LiveSession {
|
||||
self.driver.interrupt();
|
||||
}
|
||||
|
||||
/// Stops this session's process without deleting anything.
|
||||
pub fn shutdown(&self) {
|
||||
self.driver.shutdown();
|
||||
}
|
||||
|
||||
pub fn compact(&self) {
|
||||
self.driver.compact();
|
||||
}
|
||||
@@ -184,6 +193,10 @@ pub struct SessionManager {
|
||||
/// Per-session directories (transcript, attachments, produced images)
|
||||
/// live under here, each named by session id.
|
||||
data_dir: PathBuf,
|
||||
/// Downloaded GGUF models, shared by every session that names one --
|
||||
/// which is why they live beside the session directories rather than
|
||||
/// inside one.
|
||||
models_dir: PathBuf,
|
||||
inner: RwLock<Inner>,
|
||||
}
|
||||
|
||||
@@ -193,7 +206,7 @@ impl SessionManager {
|
||||
/// crash-recovery story; the echo driver just starts fresh over the
|
||||
/// same transcript. Must be called inside a tokio runtime (each
|
||||
/// session spawns its event pump).
|
||||
pub fn new(config_path: PathBuf, data_dir: PathBuf) -> Result<Self> {
|
||||
pub fn new(config_path: PathBuf, data_dir: PathBuf, models_dir: PathBuf) -> Result<Self> {
|
||||
let config = Config::load(&config_path)?;
|
||||
crate::private::create_dir(&data_dir)?;
|
||||
|
||||
@@ -204,7 +217,13 @@ impl SessionManager {
|
||||
// shows as exited rather than taking the whole server down
|
||||
// with it, and can still be deleted from the phone.
|
||||
match resolve(&config, meta).and_then(|(provider, host)| {
|
||||
launch(meta.clone(), &provider, host.as_ref(), &data_dir)
|
||||
launch(
|
||||
meta.clone(),
|
||||
&provider,
|
||||
host.as_ref(),
|
||||
&data_dir,
|
||||
&models_dir,
|
||||
)
|
||||
}) {
|
||||
Ok(session) => {
|
||||
live.insert(meta.id.clone(), session);
|
||||
@@ -217,6 +236,7 @@ impl SessionManager {
|
||||
let manager = Self {
|
||||
config_path,
|
||||
data_dir,
|
||||
models_dir,
|
||||
inner: RwLock::new(Inner { config, live }),
|
||||
};
|
||||
manager.seed_providers()?;
|
||||
@@ -265,6 +285,22 @@ impl SessionManager {
|
||||
|
||||
/// Every session, in config order, with live status joined in. A
|
||||
/// session that failed to relaunch reports as exited.
|
||||
/// Stops every session's process, for a server that is going away.
|
||||
///
|
||||
/// Drivers set `kill_on_drop`, which covers a session being deleted
|
||||
/// while the server keeps running -- but not the server itself being
|
||||
/// signalled, because nothing drops on the way out of a SIGTERM. That
|
||||
/// leaves the children orphaned, which for a `llama-server` holding a
|
||||
/// model means gigabytes of memory nobody owns any more. So exiting
|
||||
/// asks them all to stop first.
|
||||
pub fn shutdown_all(&self) {
|
||||
let inner = self.inner.read().unwrap();
|
||||
for session in inner.live.values() {
|
||||
session.shutdown();
|
||||
}
|
||||
tracing::info!("stopped {} session process(es)", inner.live.len());
|
||||
}
|
||||
|
||||
pub fn sessions(&self) -> Vec<SessionInfo> {
|
||||
let inner = self.inner.read().unwrap();
|
||||
inner
|
||||
@@ -346,10 +382,17 @@ impl SessionManager {
|
||||
model: spec.model.or_else(|| provider.models.first().cloned()),
|
||||
cwd: spec.cwd,
|
||||
permission_mode: spec.permission_mode,
|
||||
params: spec.params,
|
||||
created: now(),
|
||||
};
|
||||
|
||||
let session = launch(meta.clone(), &provider, host.as_ref(), &self.data_dir)?;
|
||||
let session = launch(
|
||||
meta.clone(),
|
||||
&provider,
|
||||
host.as_ref(),
|
||||
&self.data_dir,
|
||||
&self.models_dir,
|
||||
)?;
|
||||
let mut candidate = inner.config.clone();
|
||||
candidate.sessions.push(meta);
|
||||
if let Err(err) = candidate.save(&self.config_path) {
|
||||
@@ -455,6 +498,7 @@ fn launch(
|
||||
provider: &ProviderConfig,
|
||||
host: Option<&HostConfig>,
|
||||
data_dir: &Path,
|
||||
models_dir: &Path,
|
||||
) -> Result<Arc<LiveSession>> {
|
||||
let dir = data_dir.join(&meta.id);
|
||||
crate::private::create_dir(&dir)?;
|
||||
@@ -471,6 +515,14 @@ fn launch(
|
||||
|
||||
let driver: Box<dyn Driver> = match provider.kind {
|
||||
DriverKind::Echo => Box::new(EchoDriver::new(sink.clone())),
|
||||
DriverKind::LlamaCpp => Box::new(LlamaDriver::spawn(
|
||||
&meta,
|
||||
provider,
|
||||
&Transport::for_host(host),
|
||||
models_dir,
|
||||
&transcript_path,
|
||||
sink.clone(),
|
||||
)?),
|
||||
DriverKind::ClaudeCli => Box::new(ClaudeDriver::spawn(
|
||||
&meta,
|
||||
provider,
|
||||
@@ -534,6 +586,7 @@ mod tests {
|
||||
|
||||
fn echo_spec() -> SpawnSpec {
|
||||
SpawnSpec {
|
||||
params: Default::default(),
|
||||
provider: crate::config::ECHO_PROVIDER.to_string(),
|
||||
host: None,
|
||||
title: None,
|
||||
@@ -591,7 +644,12 @@ mod tests {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = dir.path().join("config.ron");
|
||||
let data_dir = dir.path().join("sessions");
|
||||
let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager");
|
||||
let manager = SessionManager::new(
|
||||
config_path.clone(),
|
||||
data_dir.clone(),
|
||||
data_dir.join("models"),
|
||||
)
|
||||
.expect("manager");
|
||||
|
||||
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
||||
// Untitled sessions are named after the provider that runs them.
|
||||
@@ -646,9 +704,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn questions_round_trip_through_answer() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let manager =
|
||||
SessionManager::new(dir.path().join("config.ron"), dir.path().join("sessions"))
|
||||
.expect("manager");
|
||||
let manager = SessionManager::new(
|
||||
dir.path().join("config.ron"),
|
||||
dir.path().join("sessions"),
|
||||
dir.path().join("models"),
|
||||
)
|
||||
.expect("manager");
|
||||
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
||||
let session = manager.session(&info.id).expect("live session");
|
||||
|
||||
@@ -685,7 +746,12 @@ mod tests {
|
||||
let config_path = dir.path().join("config.ron");
|
||||
let data_dir = dir.path().join("sessions");
|
||||
|
||||
let manager = SessionManager::new(config_path.clone(), data_dir.clone()).expect("manager");
|
||||
let manager = SessionManager::new(
|
||||
config_path.clone(),
|
||||
data_dir.clone(),
|
||||
data_dir.join("models"),
|
||||
)
|
||||
.expect("manager");
|
||||
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
||||
let session = manager.session(&info.id).expect("live session");
|
||||
let mut rx = session.subscribe();
|
||||
@@ -699,7 +765,8 @@ mod tests {
|
||||
// A new manager over the same state: the session is back, and new
|
||||
// events continue the sequence rather than restarting it -- which
|
||||
// is what makes a phone's cursor survive a backend restart.
|
||||
let manager = SessionManager::new(config_path, data_dir).expect("manager restart");
|
||||
let manager = SessionManager::new(config_path, data_dir.clone(), data_dir.join("models"))
|
||||
.expect("manager restart");
|
||||
let listed = manager.sessions();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, info.id);
|
||||
|
||||
Reference in new issue
Block a user