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:
irisandClaude Opus 5 committed 2026-08-28 05:23:12 -04:00
1 parent e50d1a2bbf
commit 3deeffd1e7
8 files changed
+949 -17

No files matched your search

+76 -9
View File
@@ -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);