use super::advance::{RecLauncher, worker_config};
use super::fixtures::*;
use crate::config::Workflow;
use crate::prompt::dispatch::advance::{AdvanceOutcome, run};
use crate::prompt::inbox::{self, Launcher, inbox_dir, try_acquire};
use crate::prompt::resolve::WorkerConfig;
use crate::prompt::{Clock, Deps};
use crate::template::RealGit;
use std::cell::RefCell;
use std::io;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
struct DescentClock;
impl Clock for DescentClock {
fn now_iso8601(&self) -> String {
"iso".into()
}
fn now_compact(&self) -> String {
"ct1".into()
}
}
struct RevivingLauncher {
invocations: RefCell<Vec<String>>,
target: String,
outcome: RefCell<Option<String>>,
}
impl RevivingLauncher {
fn new(target: &str) -> Self {
Self {
invocations: RefCell::new(Vec::new()),
target: target.to_string(),
outcome: RefCell::new(None),
}
}
}
impl Launcher for RevivingLauncher {
fn launch(&self, ws: &Path, agent: &str) -> io::Result<()> {
self.invocations.borrow_mut().push(agent.to_string());
if agent != self.target || self.outcome.borrow().is_some() {
return Ok(());
}
let adapter = StubAdapter::scripted([StubAdapter::reply_ok(&happy_response_bytes())]);
let (sleeper, tools, stub_git) = (
StubSleeper::default(),
StubToolExecutor::ok(),
StubGit::ok(),
);
let (clock, id) = (FixedClock::default(), FixedIdGen);
let git = RealGit::new();
let mut deps = Deps {
adapter: &adapter,
sleeper: &sleeper,
git: &stub_git,
clock: &clock,
id_gen: &id,
tool_executor: &tools,
config_root: ws,
adapter_target: None,
stop: never_stopped(),
launcher: no_launch(),
};
deps.git = &git;
let out = run(ws, agent, None, &deps, &mut || Ok(worker_config()))
.map_err(|e| io::Error::other(e.to_string()))?;
*self.outcome.borrow_mut() = Some(format!("{out:?}"));
Ok(())
}
}
fn dispatched_child() -> (TempDir, PathBuf, &'static str, PathBuf, String) {
use crate::prompt::child_dispatch::{ChildDispatchRequest, run as dispatch_child};
use crate::workspace::fixture;
let (holder, ws) = fixture::workspace();
let parent = "20260101-a1";
let parent_wt = fixture::spawn_root(&ws, parent);
let child = dispatch_child(
&ChildDispatchRequest {
repo: &ws,
parent_branch: parent,
parent_worktree: &parent_wt,
role: "worker",
goal: "do it",
fork_point: None,
},
&RealGit::new(),
&DescentClock,
&FixedIdGen,
no_launch(),
)
.unwrap();
(holder, ws, parent, parent_wt, child)
}
fn advance_child(ws: &Path, child: &str, launcher: &dyn Launcher) -> AdvanceOutcome {
let adapter = StubAdapter::scripted([StubAdapter::reply_ok(&happy_response_bytes())]);
let (sleeper, tools, stub_git) = (
StubSleeper::default(),
StubToolExecutor::ok(),
StubGit::ok(),
);
let (clock, id) = (FixedClock::default(), FixedIdGen);
let git = RealGit::new();
let mut deps = valid_deps(&adapter, &sleeper, &stub_git, &clock, &id, &tools, ws);
deps.git = &git;
deps.launcher = launcher;
run(ws, child, None, &deps, &mut || Ok(worker_config())).unwrap()
}
#[test]
fn a_child_final_response_revives_the_parent_which_delivers_and_steps() {
let (_holder, ws, parent, parent_wt, child) = dispatched_child();
let launcher = RevivingLauncher::new(parent);
let out = advance_child(&ws, &child, &launcher);
assert!(matches!(out, AdvanceOutcome::Terminal), "{out:?}");
assert_eq!(
*launcher.invocations.borrow(),
vec![child.clone(), parent.into()]
);
assert_eq!(
launcher.outcome.borrow().as_deref(),
Some("Terminal"),
"the revived parent stepped to its own terminal event"
);
assert!(parent_wt.join(format!("messages/001-{child}.md")).exists());
assert_eq!(
std::fs::read_dir(inbox_dir(&ws, parent)).unwrap().count(),
0
);
assert!(
ws.join(format!("steps/{parent}/001/response.json"))
.exists()
);
}
#[test]
fn a_parent_with_a_held_lease_gets_no_second_driver() {
let (_holder, ws, parent, _parent_wt, child) = dispatched_child();
let held = try_acquire(&inbox_dir(&ws, parent)).unwrap().unwrap();
let launcher = RevivingLauncher::new(parent);
advance_child(&ws, &child, &launcher);
assert_eq!(
*launcher.invocations.borrow(),
vec![child.clone()],
"only the self-directed launch: the parent is already driven"
);
let pending: Vec<_> = std::fs::read_dir(inbox_dir(&ws, parent))
.unwrap()
.flatten()
.collect();
assert_eq!(pending.len(), 1);
let body = std::fs::read_to_string(pending[0].path()).unwrap();
assert!(body.contains("epitaph: final-response"), "got {body:?}");
drop(held);
}
const CHILD: &str = "20260101-a1-20260102-b2";
const PARENT: &str = "20260101-a1";
fn child_with_mail() -> TempDir {
let ws = TempDir::new().unwrap();
let wt = crate::workspace::agent_worktree(ws.path(), CHILD);
std::fs::create_dir_all(wt.join("messages")).unwrap();
std::fs::write(wt.join("goal.md"), "the goal").unwrap();
std::fs::write(wt.join("messages/001-user.md"), "hi").unwrap();
inbox::deposit(ws.path(), CHILD, PARENT, "go on", &DescentClock).unwrap();
ws
}
fn parent_deposit(ws: &Path) -> String {
let entries: Vec<_> = std::fs::read_dir(inbox_dir(ws, PARENT))
.unwrap()
.flatten()
.collect();
assert_eq!(entries.len(), 1, "exactly one result deposit");
std::fs::read_to_string(entries[0].path()).unwrap()
}
#[test]
fn a_stopped_child_deposits_without_reviving_the_parent() {
let ws = child_with_mail();
let (adapter, sleeper, git) = (unreachable_adapter(), StubSleeper::default(), StubGit::ok());
let (clock, id) = (FixedClock::default(), FixedIdGen);
let tools = StubToolExecutor::ok();
let stop = std::sync::atomic::AtomicBool::new(true);
let rec = RecLauncher::default();
let mut deps = valid_deps(&adapter, &sleeper, &git, &clock, &id, &tools, ws.path());
deps.stop = &stop;
deps.launcher = &rec;
let out = run(ws.path(), CHILD, None, &deps, &mut || Ok(worker_config())).unwrap();
assert!(matches!(out, AdvanceOutcome::Terminal));
assert!(
parent_deposit(ws.path()).contains("epitaph: stopped"),
"the stopped result reached the parent's inbox"
);
assert!(
rec.invocations.borrow().is_empty(),
"neither the branch nor its parent is relaunched"
);
}
#[test]
fn an_exhausted_child_deposits_without_reviving_the_parent() {
let ws = child_with_mail();
let (adapter, sleeper, git) = (unreachable_adapter(), StubSleeper::default(), StubGit::ok());
let (clock, id) = (FixedClock::default(), FixedIdGen);
let tools = StubToolExecutor::ok();
let rec = RecLauncher::default();
let mut deps = valid_deps(&adapter, &sleeper, &git, &clock, &id, &tools, ws.path());
deps.launcher = &rec;
let mut capped = || {
Ok(WorkerConfig {
workflow: Workflow::parse(
"events: {}\nbudgets:\n max_depth: 0\n",
Path::new("workflow.yaml"),
)
.unwrap(),
..worker_config()
})
};
let out = run(ws.path(), CHILD, None, &deps, &mut capped).unwrap();
assert!(matches!(out, AdvanceOutcome::Terminal));
assert!(
parent_deposit(ws.path()).contains("epitaph: budget-exhausted"),
"the exhaustion result reached the parent's inbox"
);
assert!(
rec.invocations.borrow().is_empty(),
"neither the branch nor its parent is relaunched"
);
}