The agent's status is the CLI's word, not whether a process runs

Desktop said "running" as soon as `lembas service run` was started, while the CLI was still
dialling, waiting for a terminal that holds the link, or refused by the server -- the reason went
only to agent.log. Now the supervisor reads what the CLI itself writes: `service.json` in its
state directory (connecting, linked, waiting + why, stopped + why) for the pid it started, the
line it printed before that, and its last line when it ends. A service the app did not start is
described the same way.

A run the CLI ends with 0 because trying again will not help (a refused token, no login) is no
longer restarted every second: it waits five minutes, or until linking again starts it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-09 08:15:31 +00:00
1 parent 2958be0a23
commit 7ee394b549
4 files changed
+322 -19

No files matched your search

+228 -12
View File
@@ -4,6 +4,12 @@
//! the web UI run here, in this computer's projects. It is the CLI's own service loop, unchanged; //! the web UI run here, in this computer's projects. It is the CLI's own service loop, unchanged;
//! the app only starts it, restarts it when it fails, and stops it (SIGTERM first, so a turn in //! the app only starts it, restarts it when it fails, and stops it (SIGTERM first, so a turn in
//! progress ends cleanly) when the agent is switched off or the app quits. //! progress ends cleanly) when the agent is switched off or the app quits.
//!
//! What it shows is the CLI's own word: a process that runs is not an agent that is linked. While
//! it runs, the state is what the service writes to its `service.json` (connecting, linked,
//! waiting and why, stopped and why); before that, the line it printed; when it ends, its last
//! line. A service the CLI says cannot go on (a refused token, no login) is not restarted every
//! second: it waits until linking again starts it.
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::path::PathBuf; use std::path::PathBuf;
@@ -20,6 +26,8 @@ use crate::cli;
const RESTART_FOR_UPDATE: i32 = 75; const RESTART_FOR_UPDATE: i32 = 75;
const BACKOFF: Duration = Duration::from_secs(10); const BACKOFF: Duration = Duration::from_secs(10);
const STOP_GRACE: Duration = Duration::from_secs(15); const STOP_GRACE: Duration = Duration::from_secs(15);
/// How long to wait before trying again after the CLI said trying again will not help.
const REFUSED_WAIT: Duration = Duration::from_secs(300);
#[derive(Debug, Clone, PartialEq, Serialize)] #[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "state", content = "detail", rename_all = "snake_case")] #[serde(tag = "state", content = "detail", rename_all = "snake_case")]
@@ -28,25 +36,83 @@ pub enum State {
Off, Off,
/// This computer is not signed in to the server yet. /// This computer is not signed in to the server yet.
NotLinked, NotLinked,
/// The process is started; the CLI has not said anything of its link yet.
Starting, Starting,
/// The CLI is dialling the server.
Connecting,
/// The CLI says it is linked: agent chats can use this computer.
Running, Running,
/// Another `lembas service` on this computer holds the link (its pid). /// Not linked right now, and trying again: the CLI's own reason (a terminal holds the link,
Elsewhere(u32), /// the server cannot be reached, the link dropped).
/// It ended with an error; trying again shortly. Waiting(String),
/// The CLI will not try again by itself: the server refused this computer's token, or the
/// login is missing. Linking again is the way out.
Refused(String),
/// Another `lembas service` on this computer holds the link.
Elsewhere(Elsewhere),
/// It ended with an error; trying again shortly. The CLI's last words, where it said any.
Failed(String), Failed(String),
} }
/// A `lembas service` this app did not start, and what it says of its link.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Elsewhere {
pub pid: u32,
/// It says it is linked.
pub linked: bool,
/// Why not, when it is not (its own words; empty while it has said nothing).
pub why: String,
}
impl State { impl State {
pub fn label(&self) -> String { pub fn label(&self) -> String {
match self { match self {
State::Off => "Agent: off".into(), State::Off => "Agent: off".into(),
State::NotLinked => "Agent: this computer is not linked".into(), State::NotLinked => "Agent: this computer is not linked".into(),
State::Starting => "Agent: starting…".into(), State::Starting => "Agent: starting…".into(),
State::Connecting => "Agent: connecting…".into(),
State::Running => "Agent: running".into(), State::Running => "Agent: running".into(),
State::Elsewhere(pid) => format!("Agent: running as a service (pid {pid})"), State::Waiting(why) => format!("Agent: waiting — {why}"),
State::Refused(why) => format!("Agent: refused — {why}"),
State::Elsewhere(e) if e.linked => {
format!("Agent: running as a service (pid {})", e.pid)
}
State::Elsewhere(e) => format!("Agent: a service (pid {}) is not linked", e.pid),
State::Failed(why) => format!("Agent: stopped — {why}"), State::Failed(why) => format!("Agent: stopped — {why}"),
} }
} }
/// Whether this is a state the CLI's own status file decides, while the process runs.
fn from_cli(&self) -> bool {
matches!(
self,
State::Starting | State::Connecting | State::Running | State::Waiting(_)
)
}
}
/// What the CLI's status says, as a state of the app's. `before`: the state shown now, so that a
/// new dial after a failed one keeps the reason it is waiting (the CLI writes `connecting` at
/// every try, `waiting` between them) instead of flickering between the two.
pub fn from_link(link: &cli::Link, before: &State) -> State {
let why = link.detail.clone().unwrap_or_default();
match link.state.as_str() {
"linked" => State::Running,
"connecting" => match before {
State::Waiting(w) if link.attempts > 1 => State::Waiting(w.clone()),
_ => State::Connecting,
},
"waiting" => State::Waiting(why),
"stopped" => State::Refused(why),
_ => State::Starting,
}
}
/// What a running process has said before its status file says anything: the one line the
/// CLI's service writes while a terminal on this computer holds the link for `/remote`.
fn from_output(line: &str) -> Option<State> {
line.contains("holds the link")
.then(|| State::Waiting(line.trim().to_string()))
} }
type Notify = Arc<dyn Fn(&State) + Send + Sync>; type Notify = Arc<dyn Fn(&State) + Send + Sync>;
@@ -124,15 +190,33 @@ impl Agent {
continue; continue;
} }
if let Some(pid) = cli::running_service().filter(|p| Some(*p) != self.child_pid()) { if let Some(pid) = cli::running_service().filter(|p| Some(*p) != self.child_pid()) {
if !self.set(generation, State::Elsewhere(pid)) { // Not ours, but this computer's agent all the same: say what it says.
let link = cli::link_of(pid);
let linked = link.as_ref().is_some_and(|l| l.state == "linked");
let why = link
.map(|l| match l.detail {
Some(d) if !d.is_empty() => d,
_ => l.state,
})
.unwrap_or_default();
if !self.set(
generation,
State::Elsewhere(Elsewhere {
pid,
linked,
why: if linked { String::new() } else { why },
}),
) {
return; return;
} }
self.pause(generation, BACKOFF); self.pause(generation, Duration::from_secs(3));
continue; continue;
} }
if !self.set(generation, State::Starting) { if !self.set(generation, State::Starting) {
return; return;
} }
// Where this run's output starts in the log: its last line is its reason.
let from = std::fs::metadata(&self.log).map(|m| m.len()).unwrap_or(0);
let child = match self.spawn() { let child = match self.spawn() {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@@ -146,6 +230,7 @@ impl Agent {
continue; continue;
} }
}; };
let pid = child.id();
{ {
let mut g = self.inner.lock().unwrap(); let mut g = self.inner.lock().unwrap();
if g.generation != generation { if g.generation != generation {
@@ -155,23 +240,39 @@ impl Agent {
} }
g.child = Some(child); g.child = Some(child);
} }
self.set(generation, State::Running); let code = self.wait_child(generation, pid, from);
let code = self.wait_child(generation);
let mut g = self.inner.lock().unwrap(); let mut g = self.inner.lock().unwrap();
if g.generation != generation { if g.generation != generation {
return; return;
} }
g.child = None; g.child = None;
drop(g); drop(g);
let said = last_line(&self.log, from);
match code { match code {
Some(RESTART_FOR_UPDATE) => continue, Some(RESTART_FOR_UPDATE) => continue,
Some(0) => { Some(0) => {
// It ended by itself without an error: nothing to do but start it again. // It ended by itself without an error: the CLI's word for "starting again
self.pause(generation, Duration::from_secs(1)); // will not help" -- the server refused the token, or there is no login.
// Waiting a long while (linking again starts it at once).
let why = cli::link_of(pid)
.filter(|l| l.state == "stopped")
.and_then(|l| l.detail)
.or_else(|| said.map(|s| strip_prefix(&s)));
match why {
Some(why) => {
if !self.set(generation, State::Refused(why)) {
return;
}
self.pause(generation, REFUSED_WAIT);
}
None => self.pause(generation, Duration::from_secs(1)),
}
} }
other => { other => {
let why = other.map_or("ended by a signal".to_string(), |c| { let why = said.map(|s| strip_prefix(&s)).unwrap_or_else(|| {
other.map_or("ended by a signal".to_string(), |c| {
format!("exit status {c}") format!("exit status {c}")
})
}); });
if !self.set(generation, State::Failed(why)) { if !self.set(generation, State::Failed(why)) {
return; return;
@@ -217,7 +318,11 @@ impl Agent {
} }
/// Poll the child until it exits (its exit code), or until this supervisor is replaced. /// Poll the child until it exits (its exit code), or until this supervisor is replaced.
fn wait_child(&self, generation: u64) -> Option<i32> { /// Meanwhile the state shown is what the CLI says of its link: its status file once it has
/// written one, before that the line it printed (a terminal holding the link).
fn wait_child(&self, generation: u64, pid: u32, from: u64) -> Option<i32> {
// Every other poll (once a second) the status is read again.
let mut read = true;
loop { loop {
{ {
let mut g = self.inner.lock().unwrap(); let mut g = self.inner.lock().unwrap();
@@ -230,6 +335,21 @@ impl Agent {
Some(Err(_)) | None => return None, Some(Err(_)) | None => return None,
} }
} }
if read {
let now = self.state();
if now.from_cli() {
let next = match cli::link_of(pid) {
Some(link) => from_link(&link, &now),
None => last_line(&self.log, from)
.and_then(|l| from_output(&l))
.unwrap_or(State::Starting),
};
if !self.set(generation, next) {
return None;
}
}
}
read = !read;
thread::sleep(Duration::from_millis(500)); thread::sleep(Duration::from_millis(500));
} }
} }
@@ -283,3 +403,99 @@ fn terminate(mut child: Child) {
let _ = child.kill(); let _ = child.kill();
let _ = child.wait(); let _ = child.wait();
} }
/// The last line the log holds after `from` (this run's output), if it wrote any. Only the end of
/// the file is read.
fn last_line(log: &std::path::Path, from: u64) -> Option<String> {
use std::io::{Read, Seek, SeekFrom};
let mut f = File::open(log).ok()?;
let len = f.metadata().ok()?.len();
let start = from.max(len.saturating_sub(4096));
if start >= len {
return None;
}
f.seek(SeekFrom::Start(start)).ok()?;
let mut buf = Vec::new();
f.take(len - start).read_to_end(&mut buf).ok()?;
String::from_utf8_lossy(&buf)
.lines()
.rev()
.map(str::trim)
.find(|l| !l.is_empty())
.map(String::from)
}
/// `lembas service: not logged in` → `not logged in`: the CLI's error line without its name.
fn strip_prefix(line: &str) -> String {
line.strip_prefix("lembas service: ")
.unwrap_or(line)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn link(state: &str, detail: Option<&str>, attempts: u32) -> cli::Link {
cli::Link {
state: state.into(),
detail: detail.map(String::from),
pid: 1,
attempts,
}
}
#[test]
fn the_state_shown_is_the_clis_word_for_its_link() {
let s = State::Starting;
assert_eq!(from_link(&link("linked", None, 1), &s), State::Running);
assert_eq!(
from_link(&link("connecting", None, 1), &s),
State::Connecting
);
let waiting = from_link(
&link("waiting", Some("could not reach the instance"), 1),
&s,
);
assert_eq!(
waiting,
State::Waiting("could not reach the instance".into())
);
// The next dial keeps the reason it is waiting, rather than flicker.
assert_eq!(from_link(&link("connecting", None, 2), &waiting), waiting);
assert_eq!(
from_link(&link("stopped", Some("refused"), 2), &waiting),
State::Refused("refused".into())
);
// Not running is never shown as running.
for state in ["connecting", "waiting", "stopped", "?"] {
assert_ne!(from_link(&link(state, None, 1), &s), State::Running);
}
}
#[test]
fn a_terminal_holding_the_link_is_said() {
assert_eq!(
from_output("a terminal here holds the link (/remote); waiting for it to close"),
Some(State::Waiting(
"a terminal here holds the link (/remote); waiting for it to close".into()
))
);
assert_eq!(from_output("linked"), None);
}
#[test]
fn the_last_line_is_this_runs_own() {
let p = std::env::temp_dir().join(format!("llembas-agent-log-{}", std::process::id()));
std::fs::write(&p, "old run\nlinked\n").unwrap();
let from = std::fs::metadata(&p).unwrap().len();
assert_eq!(last_line(&p, from), None);
let mut f = OpenOptions::new().append(true).open(&p).unwrap();
std::io::Write::write_all(&mut f, b"connecting\nlembas service: not logged in\n\n")
.unwrap();
let said = last_line(&p, from).unwrap();
assert_eq!(said, "lembas service: not logged in");
assert_eq!(strip_prefix(&said), "not logged in");
std::fs::remove_file(&p).unwrap();
}
}
+55
View File
@@ -8,6 +8,8 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::OnceLock; use std::sync::OnceLock;
use serde::Deserialize;
static BUNDLED: OnceLock<PathBuf> = OnceLock::new(); static BUNDLED: OnceLock<PathBuf> = OnceLock::new();
/// Where the bundle put the CLI: the app's resource directory (`/usr/lib/llembas-desktop/` from /// Where the bundle put the CLI: the app's resource directory (`/usr/lib/llembas-desktop/` from
@@ -108,6 +110,32 @@ fn running_in(state: &Path) -> Option<u32> {
(pid != 0 && alive(pid)).then_some(pid) (pid != 0 && alive(pid)).then_some(pid)
} }
/// What a running `lembas service run` says of its link to the server, as it writes it to
/// `service.json` in its state directory at every change: `connecting`, `linked`, `waiting`
/// (not linked now, trying again; `detail` says why) or `stopped` (it will not try again;
/// `detail` says why -- the server refused the token, or speaks another protocol). `pid` is the
/// process that wrote it: a file left by an earlier run is not this one's word.
#[derive(Clone, Debug, PartialEq, Deserialize)]
pub struct Link {
pub state: String,
#[serde(default)]
pub detail: Option<String>,
#[serde(default)]
pub pid: u32,
#[serde(default)]
pub attempts: u32,
}
/// The link status the CLI's service wrote last, if it was `pid` that wrote it.
pub fn link_of(pid: u32) -> Option<Link> {
link_in(&state_dir()).filter(|l| l.pid == pid)
}
fn link_in(state: &Path) -> Option<Link> {
let text = std::fs::read_to_string(state.join("service.json")).ok()?;
serde_json::from_str(&text).ok()
}
/// This computer's host name: what the CLI sent the server as `device_host` at login /// This computer's host name: what the CLI sent the server as `device_host` at login
/// (`os.hostname()`, the kernel's node name), so the server can tell which device is this one. /// (`os.hostname()`, the kernel's node name), so the server can tell which device is this one.
#[cfg(unix)] #[cfg(unix)]
@@ -166,6 +194,33 @@ mod tests {
fs::remove_dir_all(&d).unwrap(); fs::remove_dir_all(&d).unwrap();
} }
#[test]
fn the_link_status_is_read_as_the_service_writes_it() {
let d = scratch("link");
assert_eq!(link_in(&d), None);
// As `lembas service run` writes it (src/service.ts): LinkStatus and its pid.
fs::write(
d.join("service.json"),
r#"{"state":"waiting","instance":"https://ai.example","since":"2026-01-01T00:00:00.000Z","detail":"could not reach the instance; trying again in 4s","attempts":3,"pid":4242}"#,
)
.unwrap();
let l = link_in(&d).unwrap();
assert_eq!(l.state, "waiting");
assert_eq!(l.pid, 4242);
assert_eq!(l.attempts, 3);
assert!(l.detail.unwrap().starts_with("could not reach"));
// `linked` carries no detail.
fs::write(
d.join("service.json"),
r#"{"state":"linked","instance":"x","since":"y","attempts":1,"pid":7}"#,
)
.unwrap();
assert_eq!(link_in(&d).unwrap().detail, None);
fs::write(d.join("service.json"), "not json").unwrap();
assert_eq!(link_in(&d), None);
fs::remove_dir_all(&d).unwrap();
}
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn a_lock_counts_only_while_its_process_lives() { fn a_lock_counts_only_while_its_process_lives() {
+13 -3
View File
@@ -9,8 +9,10 @@ const AGENT = {
off: () => [t("The agent is off."), ""], off: () => [t("The agent is off."), ""],
not_linked: () => [t("This computer is not linked to the server yet."), ""], not_linked: () => [t("This computer is not linked to the server yet."), ""],
starting: () => [t("The agent is starting…"), ""], starting: () => [t("The agent is starting…"), ""],
connecting: () => [t("The agent is connecting to the server…"), ""],
running: () => [t("The agent is running: agent chats can use this computer."), "is-ok"], running: () => [t("The agent is running: agent chats can use this computer."), "is-ok"],
elsewhere: (pid) => [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"], waiting: (why) => [t("The agent is not connected: %(why)s. It keeps trying.", { why }), "is-error"],
refused: (why) => [t("The agent will not connect: %(why)s. Link this computer again.", { why }), "is-error"],
failed: (why) => [t("The agent stopped: %(why)s. Trying again shortly.", { why }), "is-error"], failed: (why) => [t("The agent stopped: %(why)s. Trying again shortly.", { why }), "is-error"],
} }
@@ -18,10 +20,18 @@ let folderError = ""
let linking = null // { user_code, uri } while the computer is being linked let linking = null // { user_code, uri } while the computer is being linked
let linkError = "" let linkError = ""
/** The agent's state in a sentence, and its class: the CLI's word for its link. */
export function agentWords(s) { export function agentWords(s) {
const a = s.agent
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length) const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
if (s.agent.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"] if (a.state === "elsewhere") {
return (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail) const pid = a.detail?.pid
if (!a.detail?.linked) return [t("A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s", { pid, why: a.detail?.why || "…" }), "is-error"]
if (noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
return [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"]
}
if (a.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
return (AGENT[a.state] ?? AGENT.off)(a.detail || "…")
} }
function section(id, title, ...body) { function section(id, title, ...body) {
+26 -4
View File
@@ -9,12 +9,35 @@ const WORDS = {
off: () => t("agent off"), off: () => t("agent off"),
not_linked: () => t("not linked"), not_linked: () => t("not linked"),
starting: () => t("agent starting…"), starting: () => t("agent starting…"),
connecting: () => t("agent connecting…"),
running: () => t("agent running"), running: () => t("agent running"),
waiting: () => t("agent waiting"),
refused: () => t("agent refused"),
elsewhere: () => t("agent running (service)"), elsewhere: () => t("agent running (service)"),
elsewhere_waiting: () => t("agent waiting"),
failed: () => t("agent stopped"), failed: () => t("agent stopped"),
no_folders: () => t("agent: no folders"), no_folders: () => t("agent: no folders"),
} }
/**
* The pill's state: what the CLI says of its link, not whether a process runs. Linked but allowed
* into no folder is said too -- a green light would say it works when it cannot.
*/
export function pillState(s) {
const a = s.agent
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
const linked = a.state === "running" || (a.state === "elsewhere" && a.detail?.linked)
if (linked && noFolders) return "no_folders"
if (a.state === "elsewhere" && !a.detail?.linked) return "elsewhere_waiting"
return a.state
}
/** Why the agent is not working, in the CLI's words, when it said. */
export function agentWhy(a) {
if (a.state === "elsewhere") return a.detail?.why || ""
return typeof a.detail === "string" ? a.detail : ""
}
export function renderBar(s) { export function renderBar(s) {
let host = "" let host = ""
try { try {
@@ -22,12 +45,11 @@ export function renderBar(s) {
} catch {} } catch {}
$("host").textContent = host $("host").textContent = host
$("host").title = s.server || "" $("host").title = s.server || ""
// Connected but allowed into no folder: a green light would say it works when it cannot. const state = pillState(s)
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length) const reason = agentWhy(s.agent)
const state = s.agent.state === "running" && noFolders ? "no_folders" : s.agent.state
$("agent").dataset.state = state $("agent").dataset.state = state
$("agent-text").textContent = WORDS[state] ? WORDS[state]() : state $("agent-text").textContent = WORDS[state] ? WORDS[state]() : state
$("agent").title = state === "failed" ? t("Agent stopped: %(why)s", { why: s.agent.detail }) : t("Agent") $("agent").title = reason ? t("Agent: %(why)s", { why: reason }) : t("Agent")
$("update").hidden = !s.update $("update").hidden = !s.update
$("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update") $("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update")
$("update").title = s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : "" $("update").title = s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : ""