use super::advance::{AGENT, RecLauncher, eventually_free, worker_config};
use super::fixtures::*;
use super::tool_control::{approval_control, gated_workflow, real_deps};
use crate::config::Workflow;
use crate::prompt::dispatch::advance::{AdvanceOutcome, run};
use crate::prompt::inbox;
use crate::prompt::resolve::WorkerConfig;
use crate::template::{GitRunner, RealGit};
use crate::workspace::hold;
use brazen::Content;
use serde_json::json;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
fn parked_workspace(ws: &Path, entries: &[(&str, String)]) -> PathBuf {
let git = RealGit::new();
git.run(ws, &["init", "--bare", "repo.git"]).unwrap();
let wt = crate::workspace::agent_worktree(ws, AGENT);
std::fs::create_dir_all(&wt).unwrap();
let branch = crate::workspace::agent_ref(AGENT);
git.run(&wt, &["init", "-b", &branch]).unwrap();
git.run(&wt, &["config", "user.email", "t@t"]).unwrap();
git.run(&wt, &["config", "user.name", "t"]).unwrap();
git.run(&wt, &["config", "core.hooksPath", "/dev/null"])
.unwrap();
std::fs::create_dir_all(wt.join("messages")).unwrap();
std::fs::write(wt.join("goal.md"), "the goal").unwrap();
for (name, body) in entries {
std::fs::write(wt.join("messages").join(name), body).unwrap();
}
git.run(&wt, &["add", "-A"]).unwrap();
git.run(&wt, &["commit", "-m", "fixture"]).unwrap();
std::fs::create_dir_all(ws.join("steps").join(AGENT).join("001")).unwrap();
wt
}
fn parked_tail() -> Vec<(&'static str, String)> {
let assistant = serde_json::to_string(&[
Content::ToolUse {
id: "t1".into(),
name: "bash".into(),
input: json!({"command": "true"}),
signature: None,
},
Content::ToolUse {
id: "t2".into(),
name: "bash".into(),
input: json!({"command": "false"}),
signature: None,
},
])
.unwrap();
let t1_result = serde_json::to_string(&[Content::ToolResult {
tool_use_id: "t1".into(),
content: vec![Content::Text("ok".into())],
is_error: false,
}])
.unwrap();
vec![
("001-user.md", "hi".to_string()),
("002-claude-sonnet-5.json", assistant),
("003-tool.json", t1_result),
]
}
fn mark_t2(ws: &Path, git: &RealGit) {
hold::write(
ws,
AGENT,
&hold::Held {
tool_use_id: "t2".into(),
tool: "bash".into(),
reason: "awaiting approval".into(),
},
git,
)
.unwrap();
}
#[test]
fn a_parked_branch_queues_mail_and_resumes_on_release() {
let scripts = TempDir::new().unwrap();
let control = approval_control(scripts.path());
let holder = TempDir::new().unwrap();
let ws = holder.path();
let wt = parked_workspace(ws, &parked_tail());
let git = RealGit::new();
mark_t2(ws, &git);
let clock = FixedClock::default();
inbox::deposit(ws, AGENT, "user", "status?", &clock).unwrap();
let adapter = unreachable_adapter();
let (sleeper, id) = (StubSleeper::default(), FixedIdGen);
let tools = StubToolExecutor::ok();
let rec = RecLauncher::default();
let mut deps = real_deps(&adapter, &sleeper, &git, &clock, &id, &tools, ws);
deps.launcher = &rec;
let mut cfg = || -> Result<WorkerConfig, crate::prompt::Error> {
Ok(WorkerConfig {
workflow: Workflow::parse(&gated_workflow(&control), Path::new("workflow.yaml"))
.unwrap(),
..worker_config()
})
};
let out = run(ws, AGENT, None, &deps, &mut cfg).unwrap();
assert!(matches!(out, AdvanceOutcome::Held), "got {out:?}");
assert!(tools.invocations.borrow().is_empty());
assert!(hold::read(ws, AGENT, &git).is_some());
assert!(!wt.join("messages/004-user.md").exists(), "mail must queue");
assert!(rec.invocations.borrow().is_empty());
assert!(eventually_free(ws, AGENT));
std::fs::write(ws.join("approval"), "yes").unwrap();
let out = run(ws, AGENT, None, &deps, &mut cfg).unwrap();
let AdvanceOutcome::ToolsPending(lease) = out else {
panic!("expected ToolsPending, got {out:?}");
};
let ran: Vec<String> = tools
.invocations
.borrow()
.iter()
.map(|c| c.1.clone())
.collect();
assert_eq!(ran, vec!["t2"], "only the held frontier executes");
assert!(wt.join("messages/004-tool.json").exists());
assert_eq!(hold::read(ws, AGENT, &git), None);
assert!(
!wt.join("messages/005-user.md").exists(),
"mail still queued"
);
drop(lease);
let adapter = StubAdapter::scripted([StubAdapter::reply_ok(&happy_response_bytes())]);
let mut deps = real_deps(&adapter, &sleeper, &git, &clock, &id, &tools, ws);
deps.launcher = &rec;
let out = run(ws, AGENT, None, &deps, &mut cfg).unwrap();
assert!(matches!(out, AdvanceOutcome::Terminal), "got {out:?}");
assert!(wt.join("messages/005-user.md").exists());
assert!(wt.join("messages/006-claude-sonnet-5.json").exists());
}
#[test]
fn a_stale_mark_is_cleared_and_the_hop_continues() {
let holder = TempDir::new().unwrap();
let ws = holder.path();
parked_workspace(ws, &super::advance::terminal_tail());
let git = RealGit::new();
mark_t2(ws, &git);
let (clock, id) = (FixedClock::default(), FixedIdGen);
let (adapter, sleeper) = (unreachable_adapter(), StubSleeper::default());
let tools = StubToolExecutor::ok();
let deps = real_deps(&adapter, &sleeper, &git, &clock, &id, &tools, ws);
let out = run(ws, AGENT, None, &deps, &mut super::advance::no_resolve).unwrap();
assert!(matches!(out, AdvanceOutcome::NothingToDo), "got {out:?}");
assert_eq!(hold::read(ws, AGENT, &git), None, "the stale mark is swept");
}
struct FailOn {
inner: RealGit,
needle: &'static str,
}
impl GitRunner for FailOn {
fn run(&self, dest: &Path, args: &[&str]) -> std::io::Result<()> {
if args.contains(&self.needle) {
return Err(std::io::Error::other(format!("poisoned {}", self.needle)));
}
self.inner.run(dest, args)
}
fn run_capture(&self, dest: &Path, args: &[&str]) -> std::io::Result<String> {
if args.contains(&self.needle) {
return Err(std::io::Error::other(format!("poisoned {}", self.needle)));
}
self.inner.run_capture(dest, args)
}
}
#[test]
fn a_failed_stale_sweep_surfaces_as_the_git_error_it_is() {
let holder = TempDir::new().unwrap();
let ws = holder.path();
parked_workspace(ws, &super::advance::terminal_tail());
mark_t2(ws, &RealGit::new());
let git = FailOn {
inner: RealGit::new(),
needle: "-d",
};
let (clock, id) = (FixedClock::default(), FixedIdGen);
let (adapter, sleeper) = (unreachable_adapter(), StubSleeper::default());
let tools = StubToolExecutor::ok();
let deps = real_deps(&adapter, &sleeper, &git, &clock, &id, &tools, ws);
let err = run(ws, AGENT, None, &deps, &mut super::advance::no_resolve).unwrap_err();
assert!(
matches!(
err,
crate::prompt::Error::Git {
op: "stale hold mark clear",
..
}
),
"{err:?}"
);
git.run(ws, &["--version"]).unwrap();
assert!(git.run_capture(ws, &["update-ref", "-d", "x"]).is_err());
}
#[test]
fn a_mark_over_a_missing_worktree_stays_parked() {
let holder = TempDir::new().unwrap();
let ws = holder.path();
RealGit::new()
.run(ws, &["init", "--bare", "repo.git"])
.unwrap();
let git = RealGit::new();
mark_t2(ws, &git);
let (clock, id) = (FixedClock::default(), FixedIdGen);
let (adapter, sleeper) = (unreachable_adapter(), StubSleeper::default());
let tools = StubToolExecutor::ok();
let deps = real_deps(&adapter, &sleeper, &git, &clock, &id, &tools, ws);
let out = run(ws, AGENT, None, &deps, &mut super::advance::no_resolve).unwrap();
assert!(matches!(out, AdvanceOutcome::NothingToDo), "got {out:?}");
assert!(hold::read(ws, AGENT, &git).is_some());
}
#[test]
fn a_stop_felling_the_resumes_control_is_the_stopped_terminal() {
let scripts = TempDir::new().unwrap();
let control = scripts.path().join("fixture-control.sh");
std::fs::write(&control, "#!/bin/sh\nexec sleep 60\n").unwrap();
std::fs::set_permissions(&control, std::fs::Permissions::from_mode(0o755)).unwrap();
let holder = TempDir::new().unwrap();
let ws = holder.path();
parked_workspace(ws, &parked_tail());
let git = RealGit::new();
mark_t2(ws, &git);
let (clock, id) = (FixedClock::default(), FixedIdGen);
let (adapter, sleeper) = (unreachable_adapter(), StubSleeper::default());
let tools = StubToolExecutor::ok();
let stop = std::sync::atomic::AtomicBool::new(true);
let rec = RecLauncher::default();
let mut deps = real_deps(&adapter, &sleeper, &git, &clock, &id, &tools, ws);
deps.stop = &stop;
deps.launcher = &rec;
let mut cfg = || -> Result<WorkerConfig, crate::prompt::Error> {
Ok(WorkerConfig {
workflow: Workflow::parse(&gated_workflow(&control), Path::new("workflow.yaml"))
.unwrap(),
..worker_config()
})
};
let out = run(ws, AGENT, None, &deps, &mut cfg).unwrap();
assert!(matches!(out, AdvanceOutcome::Terminal), "got {out:?}");
assert!(tools.invocations.borrow().is_empty());
assert!(hold::read(ws, AGENT, &git).is_some());
}