Skip to main content

ljos_cli/
persona_session.rs

1//! A persona's session: the runner that thinks as the persona, in a pane
2//! the person can watch and talk to, kept across hand-offs.
3//!
4//! Each persona has a home directory. Its runner starts there, as the seat
5//! named after the persona, so its memories, ballots and trust rows are
6//! the persona's. A runner's `[[harness]]` table names how it starts and
7//! how it resumes the latest session of the directory it starts in, so
8//! the home is the session key: the second
9//! hand-off resumes the first conversation, and no id is stored. A pane
10//! that is still open is handed the next task in place. A task is written
11//! to the persona's inbox and the pane is told one line naming the file,
12//! since a long text typed into a runner's prompt submits at its first
13//! line break.
14//!
15//! The pane opens in the first tool that answers ([`crate::tools`]): a
16//! `[[tool]]` the file declares, else herdr through its agent API, else
17//! tmux. A tool that refuses is named in what `hand` returns. The pane
18//! script resumes a runner that exits non-zero, [`RESTARTS`] times in
19//! [`RESTART_WINDOW_S`] seconds before it gives up; a runner that exits 0
20//! is done.
21
22use std::path::{Path, PathBuf};
23
24use anyhow::{bail, Context, Result};
25
26use crate::tools::{self, Tool, Vars};
27
28/// The tmux session persona windows open in. A herdr workspace is
29/// labelled `persona-NAME` instead.
30pub const PERSONA_SESSION: &str = "ljos-personas";
31
32/// How many times a pane resumes a runner that failed, within
33/// [`RESTART_WINDOW_S`], before it leaves the pane to the person.
34pub const RESTARTS: u32 = 3;
35
36/// The window the restart count is kept over.
37pub const RESTART_WINDOW_S: u64 = 60;
38
39/// The argv that starts the runner named `runner` in a persona's home,
40/// from its `[[harness]]` table: `start` the first time (the runner's name
41/// alone when unset), `resume` after, which continues the latest session
42/// of the directory it starts in. A runner with no `resume` starts fresh
43/// each time. `None` when the table names no such runner.
44#[must_use]
45pub fn runner_argv_in(all: &crate::Harnesses, runner: &str, resume: bool) -> Option<Vec<String>> {
46    let h = all.harness.iter().find(|h| h.name == runner)?;
47    let start = if h.start.is_empty() {
48        vec![h.name.clone()]
49    } else {
50        h.start.clone()
51    };
52    Some(if resume && !h.resume.is_empty() {
53        h.resume.clone()
54    } else {
55        start
56    })
57}
58
59/// [`runner_argv_in`] over this machine's runners file.
60#[must_use]
61pub fn runner_argv(runner: &str, resume: bool) -> Option<Vec<String>> {
62    runner_argv_in(
63        &crate::harnesses_from(&crate::harnesses_path()).ok()?,
64        runner,
65        resume,
66    )
67}
68
69/// The runners this machine names.
70#[must_use]
71pub fn runner_names() -> Vec<String> {
72    crate::harnesses_from(&crate::harnesses_path())
73        .map(|all| all.harness.into_iter().map(|h| h.name).collect())
74        .unwrap_or_default()
75}
76
77/// The `[[tool]]` tables this machine declares.
78fn declared_tools() -> Vec<Tool> {
79    crate::harnesses_from(&crate::harnesses_path())
80        .map(|all| all.tool)
81        .unwrap_or_default()
82}
83
84/// `$XDG_STATE_HOME/ljos/personas/NAME`: where the persona's runner works
85/// and keeps its session.
86#[must_use]
87pub fn home(name: &str) -> PathBuf {
88    std::env::var_os("XDG_STATE_HOME")
89        .filter(|v| !v.is_empty())
90        .map(PathBuf::from)
91        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
92        .unwrap_or_else(|| PathBuf::from(".local/state"))
93        .join("ljos")
94        .join("personas")
95        .join(name)
96}
97
98/// The script a persona's pane runs: the runner as the seat named after
99/// the persona, in its home, then a shell left open for the person. A
100/// runner that fails is resumed with `again`, [`RESTARTS`] times in
101/// [`RESTART_WINDOW_S`] seconds before the pane gives up. `.runner.pid`
102/// holds the script's pid while a runner is up.
103#[must_use]
104pub fn pane_script(name: &str, argv: &[String], again: &[String], home: &Path) -> String {
105    let words = |a: &[String]| {
106        a.iter()
107            .map(|w| tools::sh_quote(w))
108            .collect::<Vec<_>>()
109            .join(" ")
110    };
111    let again = if again.is_empty() { argv } else { again };
112    format!(
113        "#!/bin/sh\nprintf '\\033]2;%s\\007' {n}\ncd {h} || exit 1\necho $$ > .runner.pid\n\
114         LJOS_SEAT={n} {cmd}\ncode=$?\ntries=0\nsince=$(date +%s)\n\
115         while [ \"$code\" -ne 0 ]; do\n  now=$(date +%s)\n  \
116         if [ $((now - since)) -ge {window} ]; then since=$now; tries=0; fi\n  \
117         tries=$((tries + 1))\n  if [ \"$tries\" -gt {max} ]; then\n    \
118         echo \"persona {name}: the runner failed $tries times in {window}s; not restarting\"\n    break\n  fi\n  \
119         echo \"persona {name}: the runner exited $code; resuming, $tries of {max}\"\n  sleep 1\n  \
120         LJOS_SEAT={n} {again}\n  code=$?\ndone\nrm -f .runner.pid\n\
121         echo \"persona {name}: the runner exited; this pane stays for reading\"\n\
122         exec \"${{SHELL:-/bin/sh}}\" -i\n",
123        n = tools::sh_quote(name),
124        h = tools::sh_quote(&home.display().to_string()),
125        cmd = words(argv),
126        again = words(again),
127        window = RESTART_WINDOW_S,
128        max = RESTARTS,
129    )
130}
131
132/// The placeholders a tool's verbs see for this persona.
133fn vars_for(name: &str, home: &Path, pane: &str) -> Vars {
134    let script = home.join("pane.sh").display().to_string();
135    Vars::default()
136        .with("name", name)
137        .with("label", format!("persona-{name}"))
138        .with("session", PERSONA_SESSION)
139        .with("home", home.display().to_string())
140        .with("script_q", tools::sh_quote(&script))
141        .with("script", script)
142        .with("pane", pane)
143}
144
145/// The tool and pane a persona's last pane opened in, from `.pane`.
146fn read_record(home: &Path) -> Option<(String, String)> {
147    let text = std::fs::read_to_string(home.join(".pane")).ok()?;
148    let mut lines = text.lines();
149    let tool = lines.next()?.trim().to_string();
150    let pane = lines.next().unwrap_or("").trim().to_string();
151    (!tool.is_empty()).then_some((tool, pane))
152}
153
154fn write_record(home: &Path, tool: &str, pane: &str) -> Result<()> {
155    std::fs::write(home.join(".pane"), format!("{tool}\n{pane}\n"))
156        .with_context(|| format!("{}", home.join(".pane").display()))
157}
158
159/// Whether the pane script's runner is up: `.runner.pid` names a live
160/// process.
161fn runner_up(home: &Path) -> bool {
162    std::fs::read_to_string(home.join(".runner.pid"))
163        .ok()
164        .and_then(|t| t.trim().parse::<u32>().ok())
165        .is_some_and(crate::pid_alive)
166}
167
168/// What a pane is called where the person looks for it.
169fn describe(tool: &str, pane: &str, name: &str) -> String {
170    match tool {
171        "tmux" => format!("tmux {PERSONA_SESSION}:{name}"),
172        _ if pane.is_empty() => format!("{tool} persona-{name}"),
173        _ => format!("{tool} pane {pane}"),
174    }
175}
176
177/// A persona's pane as it stands.
178enum Pane {
179    /// No pane, or one its tool no longer has.
180    Gone,
181    /// The pane is there and its runner has exited.
182    Idle(Tool, String),
183    /// The pane is there and its runner is up.
184    Live(Tool, String),
185}
186
187fn pane_state(name: &str, home: &Path, declared: &[Tool]) -> Pane {
188    let Some((tool_name, pane)) = read_record(home) else {
189        // A window opened before panes kept a record.
190        let legacy = std::process::Command::new("tmux")
191            .args(["list-windows", "-t", PERSONA_SESSION, "-F", "#W"])
192            .stderr(std::process::Stdio::null())
193            .output()
194            .is_ok_and(|o| {
195                String::from_utf8_lossy(&o.stdout)
196                    .lines()
197                    .any(|w| w == name)
198            });
199        return match tools::ordered(declared)
200            .into_iter()
201            .find(|t| t.name == "tmux")
202        {
203            Some(tmux) if legacy => Pane::Live(tmux, String::new()),
204            _ => Pane::Gone,
205        };
206    };
207    let Some(tool) = tools::ordered(declared)
208        .into_iter()
209        .find(|t| t.name == tool_name)
210    else {
211        return Pane::Gone;
212    };
213    let vars = vars_for(name, home, &pane);
214    if !tools::answers(&tools::fill(&tool.alive, &vars)) {
215        return Pane::Gone;
216    }
217    if runner_up(home) {
218        Pane::Live(tool, pane)
219    } else {
220        Pane::Idle(tool, pane)
221    }
222}
223
224/// Where a persona's pane is open now, with its runner up.
225#[must_use]
226pub fn live_pane(name: &str) -> Option<String> {
227    match pane_state(name, &home(name), &declared_tools()) {
228        Pane::Live(tool, pane) => Some(describe(&tool.name, &pane, name)),
229        _ => None,
230    }
231}
232
233/// Hand `task` to the persona `name`, whose runner is `runner`: into its
234/// open pane, back into a pane whose runner exited, or a new pane that
235/// continues its session (or starts one). Returns where it runs, and any
236/// tool that refused on the way.
237///
238/// # Errors
239///
240/// An unknown runner, an unwritable inbox, pane script or `.pane`
241/// record, no tool that opens a pane, a refused `respawn`, or the line
242/// not reaching the pane.
243pub fn hand(name: &str, runner: &str, task: &str) -> Result<String> {
244    let home = home(name);
245    let inbox = home.join("inbox");
246    std::fs::create_dir_all(&inbox)
247        .with_context(|| format!("persona {name}: {}", inbox.display()))?;
248    // Two tasks in one second must not share a file.
249    let millis = std::time::SystemTime::now()
250        .duration_since(std::time::UNIX_EPOCH)
251        .map_or(0, |d| d.as_millis());
252    let file = inbox.join(format!("{millis}-{}.md", std::process::id()));
253    std::fs::write(&file, task)?;
254    let line = format!(
255        "Read {} and do what it asks, through ljos; it is your next task as {name}.",
256        file.display()
257    );
258    let declared = declared_tools();
259    let started = home.join(".started");
260    let argv_for = |resume: bool| {
261        runner_argv(runner, resume).with_context(|| {
262            format!(
263                "persona {name}: runner {runner:?} is not a [[harness]] in {}",
264                crate::harnesses_path().display()
265            )
266        })
267    };
268    let script = home.join("pane.sh");
269    match pane_state(name, &home, &declared) {
270        Pane::Live(tool, pane) => {
271            tools::send(&tool, &vars_for(name, &home, &pane).with("line", line))
272                .with_context(|| format!("persona {name}: the line did not reach its pane"))?;
273            return Ok(describe(&tool.name, &pane, name));
274        }
275        Pane::Idle(tool, pane) if !tool.respawn.is_empty() => {
276            let again = argv_for(true)?;
277            std::fs::write(&script, pane_script(name, &again, &again, &home))?;
278            let vars = vars_for(name, &home, &pane);
279            tools::run(&tool.name, &tools::fill(&tool.respawn, &vars))?;
280            tools::wait_ready(&tool, &vars);
281            tools::send(&tool, &vars.with("line", line))
282                .with_context(|| format!("persona {name}: the line did not reach its pane"))?;
283            return Ok(describe(&tool.name, &pane, name));
284        }
285        Pane::Idle(..) | Pane::Gone => {}
286    }
287    let argv = argv_for(started.exists())?;
288    let again = argv_for(true)?;
289    std::fs::write(&script, pane_script(name, &argv, &again, &home))?;
290    let only = std::env::var("LJOS_PANE_TOOL")
291        .ok()
292        .filter(|v| !v.is_empty());
293    let here = tools::available(&declared, only.as_deref());
294    if here.is_empty() {
295        bail!("persona {name}: {}", tools::doctor_state(&declared).1);
296    }
297    let mut refused = Vec::new();
298    for tool in here {
299        let vars = vars_for(name, &home, "");
300        let pane = match tools::open(&tool, &vars) {
301            Ok(p) => p,
302            Err(e) => {
303                refused.push(format!("{e:#}"));
304                continue;
305            }
306        };
307        write_record(&home, &tool.name, &pane)?;
308        let _ = std::fs::write(&started, crate::now_utc());
309        let vars = vars_for(name, &home, &pane);
310        tools::wait_ready(&tool, &vars);
311        tools::send(&tool, &vars.with("line", line))
312            .with_context(|| format!("persona {name}: the line did not reach {}", tool.name))?;
313        let mut said = describe(&tool.name, &pane, name);
314        if !refused.is_empty() {
315            said.push_str(&format!("; before it, {}", refused.join("; ")));
316        }
317        return Ok(said);
318    }
319    bail!("persona {name}: every tool refused: {}", refused.join("; "))
320}
321
322/// Whether herdr is installed and its server answers. `herdr workspace list`
323/// needs the socket, so it fails when no server is running.
324#[must_use]
325pub fn herdr_up() -> bool {
326    which::which("herdr").is_ok()
327        && std::process::Command::new("herdr")
328            .args(["workspace", "list"])
329            .stdout(std::process::Stdio::null())
330            .stderr(std::process::Stdio::null())
331            .status()
332            .is_ok_and(|st| st.success())
333}
334
335/// The herdr agent name for persona `name`.
336#[must_use]
337pub fn herdr_name(name: &str) -> String {
338    format!("persona-{name}")
339}
340
341/// The status string `herdr agent get` reports for persona `name`, when
342/// the command answers with one.
343#[must_use]
344pub fn herdr_agent_status(name: &str) -> Option<String> {
345    if !herdr_up() {
346        return None;
347    }
348    let out = std::process::Command::new("herdr")
349        .args(["agent", "get", &herdr_name(name)])
350        .stderr(std::process::Stdio::null())
351        .output()
352        .ok()?;
353    if !out.status.success() {
354        return None;
355    }
356    let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
357    v["result"]["agent"]["status"]
358        .as_str()
359        .or_else(|| v["result"]["status"].as_str())
360        .or_else(|| v["status"].as_str())
361        .map(str::to_string)
362}
363
364/// A message passed between personas. `Cast` is a task, `Call` expects an
365/// outcome, `Event` is a notice, `Exit` is a stop.
366#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
367#[serde(tag = "type", rename_all = "snake_case")]
368pub enum ActorMessage {
369    Cast {
370        caller: String,
371        task: String,
372    },
373    Call {
374        request_id: String,
375        caller: String,
376        command: String,
377        timeout_ms: u64,
378    },
379    Event {
380        event: String,
381        payload: serde_json::Value,
382    },
383    Exit {
384        reason: String,
385    },
386}
387
388impl ActorMessage {
389    /// The task text `hand` writes, with the message kept in a comment.
390    #[must_use]
391    pub fn to_task_content(&self) -> String {
392        match self {
393            Self::Cast { task, caller } => format!(
394                "<!-- actor_msg: {} -->\nFrom {caller}:\n{task}",
395                serde_json::to_string(self).unwrap_or_default()
396            ),
397            Self::Call {
398                command,
399                caller,
400                request_id,
401                timeout_ms,
402            } => format!(
403                "<!-- actor_msg: {} -->\nCall {request_id} from {caller} (timeout {timeout_ms}ms):\n{command}",
404                serde_json::to_string(self).unwrap_or_default()
405            ),
406            Self::Event { event, payload } => format!(
407                "<!-- actor_msg: {} -->\nEvent {event}:\n{payload}",
408                serde_json::to_string(self).unwrap_or_default()
409            ),
410            Self::Exit { reason } => format!(
411                "<!-- actor_msg: {} -->\nExit requested: {reason}",
412                serde_json::to_string(self).unwrap_or_default()
413            ),
414        }
415    }
416}
417
418/// Hand a structured message to the persona `name`.
419///
420/// # Errors
421///
422/// The same as [`hand`].
423pub fn hand_actor(name: &str, runner: &str, msg: &ActorMessage) -> Result<String> {
424    hand(name, runner, &msg.to_task_content())
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn actor_message_serializes_and_deserializes() {
433        let msg = ActorMessage::Cast {
434            caller: "lead-architect".into(),
435            task: "Verify consensus bounds".into(),
436        };
437        let json = serde_json::to_string(&msg).expect("json");
438        assert!(json.contains("\"type\":\"cast\""));
439        assert!(json.contains("\"caller\":\"lead-architect\""));
440        let de: ActorMessage = serde_json::from_str(&json).expect("from json");
441        assert_eq!(de, msg);
442        let call = ActorMessage::Call {
443            request_id: "req-1".into(),
444            caller: "evaluator".into(),
445            command: "ljos consensus issue-12".into(),
446            timeout_ms: 5000,
447        };
448        let call_json = serde_json::to_string(&call).expect("json");
449        assert!(call_json.contains("\"type\":\"call\""));
450        let de_call: ActorMessage = serde_json::from_str(&call_json).expect("from json");
451        assert_eq!(de_call, call);
452    }
453
454    #[test]
455    fn herdr_name_formats_consistently() {
456        assert_eq!(herdr_name("reviewer"), "persona-reviewer");
457    }
458
459    #[test]
460    fn a_runner_starts_fresh_then_resumes_its_home_session() {
461        let all: crate::Harnesses = toml::from_str(concat!(
462            "[[harness]]\nname = \"grok\"\nresume = [\"grok\", \"--continue\"]\n",
463            "[[harness]]\nname = \"plain\"\nstart = [\"plain-cli\", \"--tui\"]\n",
464        ))
465        .unwrap();
466        assert_eq!(runner_argv_in(&all, "grok", false).unwrap(), ["grok"]);
467        assert_eq!(
468            runner_argv_in(&all, "grok", true).unwrap(),
469            ["grok", "--continue"]
470        );
471        assert_eq!(
472            runner_argv_in(&all, "plain", true).unwrap(),
473            ["plain-cli", "--tui"],
474            "no resume, a fresh start"
475        );
476        assert!(runner_argv_in(&all, "nobody", false).is_none());
477    }
478
479    #[test]
480    fn the_pane_runs_the_runner_as_the_persona_and_stays_open() {
481        let s = pane_script(
482            "buildengineer",
483            &["grok".into(), "--continue".into()],
484            &["grok".into(), "--continue".into()],
485            Path::new("/s/personas/buildengineer"),
486        );
487        assert!(s.contains("cd '/s/personas/buildengineer'"));
488        assert!(s.contains("LJOS_SEAT='buildengineer' 'grok' '--continue'"));
489        assert!(s.trim_end().ends_with("-i"));
490    }
491
492    /// A runner that fails is resumed until the restart budget runs out; one
493    /// that exits 0 is not run again. Neither leaves a pid file behind.
494    #[test]
495    fn the_pane_resumes_a_failing_runner_a_bounded_number_of_times() {
496        let dir = tempfile::tempdir().unwrap();
497        let count = dir.path().join("runs");
498        let fail = vec![
499            "sh".to_string(),
500            "-c".into(),
501            format!("echo run >> {}; exit 3", count.display()),
502        ];
503        let script = pane_script("p", &fail, &fail, dir.path())
504            .replace("exec \"${SHELL:-/bin/sh}\" -i\n", "");
505        let script = script.replace("sleep 1\n", "");
506        let out = std::process::Command::new("sh")
507            .arg("-c")
508            .arg(&script)
509            .output()
510            .unwrap();
511        let said = String::from_utf8_lossy(&out.stdout);
512        let runs = std::fs::read_to_string(&count).unwrap().lines().count();
513        assert_eq!(runs as u32, 1 + RESTARTS, "{said}");
514        assert!(said.contains("not restarting"), "{said}");
515        assert!(!dir.path().join(".runner.pid").exists());
516        let ok = vec![
517            "sh".to_string(),
518            "-c".into(),
519            format!("echo run >> {}.ok", count.display()),
520        ];
521        let script =
522            pane_script("p", &ok, &fail, dir.path()).replace("exec \"${SHELL:-/bin/sh}\" -i\n", "");
523        std::process::Command::new("sh")
524            .arg("-c")
525            .arg(&script)
526            .status()
527            .unwrap();
528        let runs = std::fs::read_to_string(format!("{}.ok", count.display()))
529            .unwrap()
530            .lines()
531            .count();
532        assert_eq!(runs, 1, "a runner that exits 0 is done");
533        assert!(!dir.path().join(".runner.pid").exists());
534    }
535
536    #[test]
537    fn a_pane_record_round_trips_and_names_the_pane() {
538        let dir = tempfile::tempdir().unwrap();
539        write_record(dir.path(), "herdr", "w1:p2").unwrap();
540        assert_eq!(
541            read_record(dir.path()),
542            Some(("herdr".to_string(), "w1:p2".to_string()))
543        );
544        assert_eq!(describe("herdr", "w1:p2", "rev"), "herdr pane w1:p2");
545        assert_eq!(describe("tmux", "", "rev"), "tmux ljos-personas:rev");
546        assert!(!runner_up(dir.path()), "no pid file, no runner");
547        std::fs::write(
548            dir.path().join(".runner.pid"),
549            std::process::id().to_string(),
550        )
551        .unwrap();
552        assert!(runner_up(dir.path()));
553    }
554}