use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
fn lernie_bin() -> std::path::PathBuf {
crate::test_support::lernie_binary()
}
const INHERITED_GIT_ENV: &[&str] = &[
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_PREFIX",
"GIT_COMMON_DIR",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
];
fn git(dest: &Path, args: &[&str]) {
let mut cmd = Command::new("git");
for var in INHERITED_GIT_ENV {
cmd.env_remove(var);
}
let out = cmd.arg("-C").arg(dest).args(args).output().expect("git");
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
const PARENT: &str = "20260101-p1";
const CHILD: &str = "20260101-p1-20260102-c1";
fn workspace_with_crashed_child() -> TempDir {
let ws = TempDir::new().unwrap();
let repo = ws.path().join("repo.git");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "--bare", "-b", "config/default"]);
let author = ws.path().join(".author");
let author_str = author.to_string_lossy().to_string();
git(
&repo,
&[
"worktree",
"add",
"--orphan",
"-b",
"config/default",
author_str.as_str(),
],
);
std::fs::write(author.join("version"), "1\n").unwrap();
git(&author, &["add", "-A"]);
git(&author, &["config", "user.email", "t@test.invalid"]);
git(&author, &["config", "user.name", "t"]);
git(&author, &["commit", "-m", "config: init"]);
git(&repo, &["worktree", "remove", author_str.as_str()]);
git(
&repo,
&["branch", &format!("agents/{PARENT}"), "config/default"],
);
git(
&repo,
&["branch", &format!("agents/{CHILD}"), "config/default"],
);
ws
}
fn died_deposit(ws: &Path) -> PathBuf {
ws.join("inbox")
.join(PARENT)
.join(format!("{CHILD}-001.md"))
}
#[test]
fn scan_verb_heals_a_crash_stranded_child() {
let ws = workspace_with_crashed_child();
let parent_inbox = ws.path().join("inbox").join(PARENT);
let _held = crate::prompt::inbox::try_acquire(&parent_inbox)
.unwrap()
.expect("free");
let out = Command::new(lernie_bin())
.arg("scan")
.arg(ws.path())
.output()
.expect("spawn lernie scan");
assert!(
out.status.success(),
"lernie scan: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("silent deaths: 1"), "got {stdout:?}");
let body = std::fs::read_to_string(died_deposit(ws.path())).unwrap();
assert!(body.contains("epitaph: died"), "got {body:?}");
assert!(body.contains(&format!("from: {CHILD}")), "got {body:?}");
}
#[test]
fn scan_verb_surfaces_a_broken_workspace_loudly() {
let ws = TempDir::new().unwrap();
let out = Command::new(lernie_bin())
.arg("scan")
.arg(ws.path())
.output()
.expect("spawn lernie scan");
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("lernie scan"));
}
#[test]
fn scan_names_a_root_whose_model_call_failed() {
let ws = workspace_with_crashed_child();
let child_inbox = ws.path().join("inbox").join(CHILD);
let _held = crate::prompt::inbox::try_acquire(&child_inbox)
.unwrap()
.expect("free");
let step = ws.path().join("steps").join(PARENT).join("001");
std::fs::create_dir_all(&step).unwrap();
std::fs::write(
step.join("response.json"),
"{\"type\":\"error\",\"kind\":\"parse_input\",\"message\":\"user accepts only text content\"}\n{\"type\":\"end\"}\n",
)
.unwrap();
let out = Command::new(lernie_bin())
.arg("scan")
.arg(ws.path())
.output()
.expect("spawn lernie scan");
assert!(
out.status.success(),
"lernie scan: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains(&format!("silent deaths: 1 ({PARENT})")),
"got {stdout:?}"
);
assert!(
!died_deposit(ws.path()).exists(),
"a root gets no deposit — the name is the surfacing"
);
}
#[test]
fn prompt_hot_path_runs_no_workspace_scan() {
let ws = workspace_with_crashed_child();
let harness = TempDir::new().unwrap();
let out = Command::new(lernie_bin())
.arg("prompt")
.arg(ws.path())
.arg("hi")
.env("LERNIE_HOME", harness.path())
.output()
.expect("spawn lernie prompt");
assert!(!out.status.success(), "prompt fails on the empty harness");
assert!(
!died_deposit(ws.path()).exists(),
"lernie prompt must not sweep the workspace (§2.11)"
);
}
#[test]
fn dispatch_hot_path_runs_no_workspace_scan() {
let ws = workspace_with_crashed_child();
let out = Command::new(lernie_bin())
.args(["dispatch", "no-such-role"])
.arg(ws.path())
.arg(PARENT)
.output()
.expect("spawn lernie dispatch");
assert!(!out.status.success(), "a config-undefined role is refused");
assert!(
!died_deposit(ws.path()).exists(),
"lernie dispatch must not sweep the workspace (§2.11)"
);
}