1use std::path::{Path, PathBuf};
23
24use anyhow::{bail, Context, Result};
25
26use crate::tools::{self, Tool, Vars};
27
28pub const PERSONA_SESSION: &str = "ljos-personas";
31
32pub const RESTARTS: u32 = 3;
35
36pub const RESTART_WINDOW_S: u64 = 60;
38
39#[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#[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#[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
77fn declared_tools() -> Vec<Tool> {
79 crate::harnesses_from(&crate::harnesses_path())
80 .map(|all| all.tool)
81 .unwrap_or_default()
82}
83
84#[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#[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
132fn 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
145fn 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
159fn 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
168fn 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
177enum Pane {
179 Gone,
181 Idle(Tool, String),
183 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 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#[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
233pub 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 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#[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#[must_use]
337pub fn herdr_name(name: &str) -> String {
338 format!("persona-{name}")
339}
340
341#[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#[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 #[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
418pub 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 #[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}