mod support;
use std::fs;
use std::time::Duration;
use serde_json::Value;
use support::{FakeAgent, World, wait_until, wait_until_slow};
const FLOW_PASSING_TEST: &str = "stages:
- { name: build, action: agent, result_check: { exec: ['true'] } }
- { name: test, action: { exec: [\"true\"] } }
- { name: merge, action: { builtin: merge } }
";
const FLOW_FAILING_TEST: &str = "stages:
- { name: build, action: agent, result_check: { exec: ['true'] } }
- { name: test, action: { exec: [\"false\"] } }
- { name: merge, action: { builtin: merge } }
";
const FLOW_AGENT_ONLY: &str =
"stages:\n - { name: build, action: agent, result_check: { exec: ['true'] } }\n";
fn configure(world: &World, stages: &str, script_body: &str) {
let flow_directory = world.root().join(".agents/sloop/flows");
fs::create_dir_all(&flow_directory).expect("create flow directory");
fs::write(flow_directory.join("default.yaml"), stages).expect("write flow");
let script = world.root().join("fake-agent.sh");
fs::write(&script, format!("#!/bin/sh\nset -eu\n{script_body}")).expect("write fake agent");
fs::write(
world.root().join(".agents/sloop/config.yaml"),
format!(
"version: 1\nscheduler:\n max_parallel_tasks: 1\nagent:\n default_target: fake\n \
targets:\n fake:\n cmd: [\"sh\", {}, \"{{prompt}}\"]\n",
serde_json::to_string(&script.to_string_lossy()).expect("serialize script path"),
),
)
.expect("write config");
}
fn committing_agent() -> String {
"git -c user.name=agent -c user.email=agent@example.invalid commit --quiet --allow-empty \
-m work\nexit 0\n"
.to_owned()
}
fn post(world: &World, name: &str) -> String {
let ticket = world.write_ticket(name, "# Show history\nwork\n");
let output = world.sloop(&["post", ticket.to_str().unwrap(), "--manual"]);
assert!(
output.status.success(),
"post failed: {}",
String::from_utf8_lossy(&output.stderr)
);
World::json_stdout(&output)["data"]["ticket"]["id"]
.as_str()
.expect("ticket id")
.to_owned()
}
fn status(world: &World) -> Value {
let output = world.sloop(&["status"]);
assert!(output.status.success());
World::json_stdout(&output)["data"].clone()
}
fn show(world: &World, reference: &str) -> Value {
let output = world.sloop(&["show", reference]);
assert!(
output.status.success(),
"show {reference} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
World::json_stdout(&output)["data"]["value"].clone()
}
fn show_text(world: &World, reference: &str) -> String {
let output = world.sloop_plain(&["show", reference]);
assert!(
output.status.success(),
"show {reference} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stage<'a>(stages: &'a Value, name: &str) -> &'a Value {
stages
.as_array()
.expect("stages array")
.iter()
.find(|entry| entry["stage"] == name)
.unwrap_or_else(|| panic!("no stage `{name}` in {stages}"))
}
fn stalled_events(world: &World) -> Vec<Value> {
fs::read_to_string(world.daemon_log())
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter(|record| record["event"] == "run_output_stalled")
.collect()
}
#[test]
fn output_staleness_warns_once_per_silence_episode_and_marks_show() {
let world = World::configured();
world.configure_fake_agent_with_stall_report_after(
FakeAgent::new()
.output("initial output\n")
.block_until_released("first-silence")
.output("resumed output\n")
.block_until_released("second-silence")
.exit(0),
"5m",
);
world.commit_all("initial");
world.start_daemon();
let ticket = post(&world, "stalled-output.md");
assert!(world.sloop(&["run", &ticket]).status.success());
wait_until("the first silence starts", || {
world.fake_agent_reached("first-silence")
});
let run_id = world.run_id(1);
let alias = world.run_alias(1);
wait_until("initial output is captured", || {
String::from_utf8_lossy(&world.sloop_plain(&["logs", &alias]).stdout)
.contains("initial output")
});
world.tick(Duration::from_secs(4 * 60));
assert!(stalled_events(&world).is_empty());
assert!(!show_text(&world, &alias).contains("no output"));
world.tick(Duration::from_secs(61));
wait_until("the first stall warning is emitted", || {
stalled_events(&world).len() == 1
});
let events = stalled_events(&world);
assert_eq!(events[0]["level"], "warn");
assert_eq!(events[0]["fields"]["run_id"], run_id);
assert_eq!(events[0]["fields"]["ticket_id"], ticket);
assert_eq!(events[0]["fields"]["stage"], "build");
assert!(events[0]["fields"]["silent_for_ms"].as_i64().unwrap() >= 300_000);
assert!(show_text(&world, &alias).contains("no output 5m1s"));
assert!(
String::from_utf8_lossy(&world.sloop_plain(&["show"]).stdout).contains("no output 5m1s")
);
assert_eq!(
stage(&show(&world, &alias)["stages"], "build")["silent_for_ms"],
301_000
);
assert_eq!(stalled_events(&world).len(), 1);
world.release("first-silence");
wait_until("the second silence starts", || {
world.fake_agent_reached("second-silence")
});
wait_until("resumed output resets the marker", || {
!show_text(&world, &alias).contains("no output")
});
world.tick(Duration::from_secs(301));
wait_until("the later silence warns again", || {
stalled_events(&world).len() == 2
});
assert!(show_text(&world, &alias).contains("no output 5m1s"));
world.release("second-silence");
wait_until_slow("the run settles", || world.run_state(&run_id) == "merged");
}
#[test]
fn restart_measures_silence_from_the_last_real_output() {
let world = World::configured();
world.configure_fake_agent_with_stall_report_after(
FakeAgent::new()
.output("before restart\n")
.block_until_released("restart-silence")
.exit(0),
"5m",
);
world.commit_all("initial");
let daemon_pid = world.start_daemon()["data"]["pid"].as_u64().unwrap() as u32;
let ticket = post(&world, "restart-stalled-output.md");
assert!(world.sloop(&["run", &ticket]).status.success());
wait_until("the restart silence starts", || {
world.fake_agent_reached("restart-silence")
});
let run_id = world.run_id(1);
let alias = world.run_alias(1);
wait_until("output before restart is captured", || {
String::from_utf8_lossy(&world.sloop_plain(&["logs", &alias]).stdout)
.contains("before restart")
});
world.tick(Duration::from_secs(4 * 60));
world.kill_daemon(daemon_pid);
world.tick(Duration::from_secs(61));
let restarted_pid = world.start_daemon()["data"]["pid"].as_u64().unwrap() as u32;
wait_until("the restarted daemon reports the existing silence", || {
stalled_events(&world).len() == 1
});
let event = stalled_events(&world).remove(0);
assert_eq!(event["fields"]["run_id"], run_id);
assert!(event["fields"]["silent_for_ms"].as_i64().unwrap() >= 300_000);
assert!(show_text(&world, &alias).contains("no output 5m1s"));
world.kill_daemon(restarted_pid);
world.start_daemon();
wait_until("the warning episode is restored after restart", || {
show_text(&world, &alias).contains("no output 5m1s")
});
assert_eq!(stalled_events(&world).len(), 1);
world.release("restart-silence");
wait_until_slow("the recovered run settles", || {
!["claimed", "running", "stage"].contains(&world.run_state(&run_id).as_str())
});
}
#[test]
fn a_merged_run_shows_every_stage_passed_on_the_ticket_and_the_run() {
let world = World::configured();
configure(&world, FLOW_PASSING_TEST, &committing_agent());
world.commit_all("initial");
world.start_daemon();
let ticket = post(&world, "merged-history.md");
assert!(world.sloop(&["run", &ticket]).status.success());
wait_until_slow("the run merges", || {
status(&world)["tickets"]["merged"] == 1
});
let shown = show(&world, &ticket);
let runs = shown["runs"].as_array().expect("runs array");
assert_eq!(runs.len(), 1, "{runs:?}");
assert_eq!(runs[0]["alias"], world.run_alias(1));
assert_eq!(runs[0]["state"], "merged");
assert!(runs[0]["started_at_ms"].is_i64(), "{}", runs[0]);
assert!(runs[0]["finished_at_ms"].is_i64(), "{}", runs[0]);
for name in ["build", "test", "merge"] {
assert_eq!(stage(&runs[0]["stages"], name)["state"], "passed");
}
assert_eq!(runs[0]["reason"], Value::Null);
let run = show(&world, &world.run_alias(1));
assert_eq!(run["state"], "merged");
assert_eq!(run["reason"], Value::Null);
assert!(run["claimed_at_ms"].is_i64(), "{run}");
assert!(run["started_at_ms"].is_i64(), "{run}");
assert!(run["finished_at_ms"].is_i64(), "{run}");
let stages = &run["stages"];
assert_eq!(stages.as_array().expect("stages").len(), 3);
for name in ["build", "test", "merge"] {
let row = stage(stages, name);
assert_eq!(row["state"], "passed", "{row}");
assert_eq!(row["exit_code"], 0, "{row}");
assert_eq!(row["attempt"], 1, "{row}");
assert!(row["started_at_ms"].is_i64(), "{row}");
assert!(row["finished_at_ms"].is_i64(), "{row}");
}
let text = show_text(&world, &world.run_alias(1));
assert!(text.contains("stages:"), "{text}");
assert!(text.contains("build passed"), "{text}");
assert!(text.contains("agent exit: 0"), "{text}");
assert!(text.contains("timeline: claimed "), "{text}");
}
#[test]
fn an_agent_that_succeeds_before_a_failing_stage_reports_that_stage_as_the_reason() {
let world = World::configured();
configure(&world, FLOW_FAILING_TEST, &committing_agent());
world.commit_all("initial");
world.start_daemon();
let ticket = post(&world, "failing-stage.md");
assert!(world.sloop(&["run", &ticket]).status.success());
wait_until_slow("the run lands in needs_review", || {
status(&world)["tickets"]["needs_review"] == 1
});
let run = show(&world, &world.run_alias(1));
assert_eq!(run["state"], "needs_review");
assert_eq!(run["exit_code"], 0);
assert_eq!(run["agent_exit_code"], 0);
let reason = run["reason"].as_str().expect("a derived reason");
assert!(reason.contains("`test`"), "{reason}");
assert!(reason.contains("failed"), "{reason}");
assert!(reason.contains("exit 1"), "{reason}");
assert!(reason.contains("agent completed with commits"), "{reason}");
assert_eq!(stage(&run["stages"], "build")["state"], "passed");
let failed = stage(&run["stages"], "test");
assert_eq!(failed["state"], "failed");
assert_eq!(failed["exit_code"], 1);
assert_eq!(failed["verdict_source"], "exit_code");
assert_eq!(stage(&run["stages"], "merge")["state"], "pending");
let text = show_text(&world, &world.run_alias(1));
assert!(text.contains("agent exit: 0"), "{text}");
assert!(
!text.contains("\nexit: 0"),
"bare `exit:` must be gone: {text}"
);
assert!(text.contains("test failed"), "{text}");
assert!(text.contains("exit 1"), "{text}");
let shown = show(&world, &ticket);
let runs = shown["runs"].as_array().expect("runs array");
assert_eq!(runs[0]["state"], "needs_review");
assert_eq!(stage(&runs[0]["stages"], "test")["state"], "failed");
let ticket_text = show_text(&world, &ticket);
assert!(ticket_text.contains("runs:"), "{ticket_text}");
assert!(ticket_text.contains("test:FAIL"), "{ticket_text}");
}
#[test]
fn the_dashboard_lists_every_ticket_waiting_on_a_human() {
let world = World::configured();
configure(&world, FLOW_FAILING_TEST, &committing_agent());
world.commit_all("initial");
world.start_daemon();
let dashboard = |world: &World| World::json_stdout(&world.sloop(&["show"]))["data"].clone();
let dashboard_text = |world: &World| {
let output = world.sloop_plain(&["show"]);
assert!(output.status.success());
String::from_utf8_lossy(&output.stdout).into_owned()
};
assert_eq!(dashboard(&world)["attention"], serde_json::json!([]));
let calm_text = dashboard_text(&world);
assert!(!calm_text.contains("attention:"), "{calm_text}");
let ticket = post(&world, "needs-review.md");
assert!(world.sloop(&["run", &ticket]).status.success());
wait_until_slow("the run lands in needs_review", || {
status(&world)["tickets"]["needs_review"] == 1
});
let waiting = dashboard(&world);
let attention = waiting["attention"].as_array().expect("an attention array");
assert_eq!(attention.len(), 1, "{waiting}");
assert_eq!(attention[0]["id"], ticket.as_str());
assert_eq!(attention[0]["state"], "needs_review");
let recent_keys = waiting["recent"][0].as_object().expect("a recent row");
let attention_keys = attention[0].as_object().expect("an attention row");
assert_eq!(
attention_keys.keys().collect::<Vec<_>>(),
recent_keys.keys().collect::<Vec<_>>(),
"{waiting}"
);
assert_eq!(waiting["kind"], "dashboard");
assert!(waiting["recent"].is_array(), "{waiting}");
assert!(waiting["recent_total"].is_u64(), "{waiting}");
let text = dashboard_text(&world);
let section = text
.split("attention:\n")
.nth(1)
.unwrap_or_else(|| panic!("no attention section in {text}"));
let row = section.lines().next().expect("an attention row");
assert!(row.starts_with(" "), "{row}");
assert!(row.contains(&ticket), "{row}");
assert!(row.contains("needs_review"), "{row}");
assert!(!text.contains('\u{1b}'), "{text}");
assert!(!text.contains("next wake 2"), "{text}");
}
#[test]
fn a_ticket_with_several_runs_lists_them_newest_first() {
let world = World::configured();
configure(&world, FLOW_FAILING_TEST, "exit 0\n");
world.commit_all("initial");
world.start_daemon();
let ticket = post(&world, "two-runs.md");
for attempt in 1..=2 {
if attempt > 1 {
assert!(world.sloop(&["retry", &ticket]).status.success());
}
assert!(world.sloop(&["run", &ticket]).status.success());
wait_until_slow("the attempt fails", || {
status(&world)["tickets"]["failed"] == 1
});
}
let runs = show(&world, &ticket)["runs"].as_array().cloned().unwrap();
assert_eq!(runs.len(), 2, "{runs:?}");
assert_eq!(runs[0]["attempt"], 2);
assert_eq!(runs[1]["attempt"], 1);
assert_eq!(runs[0]["alias"], world.run_alias(2));
assert_eq!(runs[1]["alias"], world.run_alias(1));
}
#[test]
fn a_ticket_that_has_never_run_reports_no_runs() {
let world = World::configured();
configure(&world, FLOW_AGENT_ONLY, "exit 0\n");
world.commit_all("initial");
world.start_daemon();
let ticket = post(&world, "never-run.md");
assert_eq!(show(&world, &ticket)["runs"], Value::Array(Vec::new()));
assert!(show_text(&world, &ticket).contains("runs: none"));
}
#[test]
fn project_show_is_unchanged() {
let world = World::configured();
configure(&world, FLOW_AGENT_ONLY, "exit 0\n");
world.commit_all("initial");
world.start_daemon();
post(&world, "project-untouched.md");
let project = show(&world, "default");
assert!(project["tickets"].is_array(), "{project}");
assert_eq!(project["runs"], Value::Null);
}
#[test]
fn bare_sloop_prints_help_and_patterns_filter_before_limiting() {
let world = World::configured();
configure(&world, FLOW_AGENT_ONLY, "exit 0\n");
world.commit_all("initial");
world.start_daemon();
let first = post(&world, "audit.md");
let second = post(&world, "audit-helper.md");
let third = post(&world, "other-audit.md");
let bare = world.sloop(&[]);
assert!(bare.status.success());
let bare_data = &World::json_stdout(&bare)["data"];
assert_eq!(bare_data["kind"], "help");
assert!(
bare_data["text"].as_str().unwrap().contains("Usage: sloop"),
"{bare_data}"
);
let bare_plain = world.sloop_plain(&[]);
assert!(bare_plain.status.success());
assert!(
String::from_utf8_lossy(&bare_plain.stdout).contains("Usage: sloop"),
"{}",
String::from_utf8_lossy(&bare_plain.stdout)
);
let shown = world.sloop(&["show"]);
assert!(shown.status.success());
let shown_data = &World::json_stdout(&shown)["data"];
assert_eq!(shown_data["kind"], "dashboard");
assert_eq!(shown_data["recent"][0]["id"], third);
let status_alias = world.sloop_plain(&["status"]);
let status_new = world.sloop_plain(&["show"]);
assert_eq!(status_alias.stdout, status_new.stdout);
assert_eq!(
String::from_utf8_lossy(&status_alias.stderr),
"note: 'sloop status' is now 'sloop show'; this alias will be removed in a future release\n"
);
let list_alias = world.sloop_plain(&["list", "-2"]);
let list_new = world.sloop_plain(&["show", ".*", "-2"]);
assert_eq!(list_alias.stdout, list_new.stdout);
assert_eq!(
String::from_utf8_lossy(&list_alias.stderr),
"note: 'sloop list' is now 'sloop show'; this alias will be removed in a future release\n"
);
let exact = World::json_stdout(&world.sloop(&["show", "audit"]));
assert_eq!(exact["data"]["kind"], "ticket");
assert_eq!(exact["data"]["value"]["id"], first);
let partial = World::json_stdout(&world.sloop(&["show", "udi"]));
assert_eq!(partial["data"]["kind"], "matches");
assert_eq!(partial["data"]["tickets"].as_array().unwrap().len(), 3);
let limited = World::json_stdout(&world.sloop(&["show", "audit", "-2"]));
assert_eq!(limited["data"]["kind"], "ticket");
let limited = World::json_stdout(&world.sloop(&["show", "udi", "-2"]));
let ids = limited["data"]["tickets"]
.as_array()
.unwrap()
.iter()
.map(|ticket| ticket["id"].as_str().unwrap())
.collect::<Vec<_>>();
assert_eq!(ids, [third.as_str(), second.as_str()]);
let invalid = world.sloop(&["show", "["]);
assert_eq!(invalid.status.code(), Some(2));
assert!(
String::from_utf8_lossy(&invalid.stderr).contains("invalid ticket pattern `[`"),
"{}",
String::from_utf8_lossy(&invalid.stderr)
);
}
#[test]
fn show_follow_streams_a_ticket_until_it_settles_and_quiet_is_silent() {
let world = World::configured();
world.configure_fake_agent(
FakeAgent::new()
.block_until_released("follow")
.commit("work")
.exit(0),
);
world.commit_all("initial");
world.start_daemon();
let ticket = post(&world, "follow-ticket.md");
assert!(world.sloop(&["run", &ticket]).status.success());
wait_until("the followed run starts", || {
world.fake_agent_reached("follow")
});
let streaming = world.spawn_sloop(&["show", &ticket, "--follow"]);
let quiet = world.spawn_sloop(&["show", &ticket, "--follow", "--quiet"]);
world.release("follow");
let streaming = streaming
.wait_with_output()
.expect("wait for streaming show");
let quiet = quiet.wait_with_output().expect("wait for quiet show");
assert!(streaming.status.success(), "{:?}", streaming.status);
assert!(quiet.status.success(), "{:?}", quiet.status);
assert!(quiet.stdout.is_empty(), "quiet output: {:?}", quiet.stdout);
let events = String::from_utf8_lossy(&streaming.stdout);
assert!(
events.lines().any(|line| {
serde_json::from_str::<Value>(line)
.is_ok_and(|event| event["kind"] == "run_finished" && event["ticket"] == ticket)
}),
"streamed events: {events}"
);
let watch_alias = world.sloop(&["watch", &ticket]);
let watch_new = world.sloop(&["show", &ticket, "--follow"]);
assert_eq!(watch_alias.status.code(), watch_new.status.code());
assert_eq!(watch_alias.stdout, watch_new.stdout);
assert!(
String::from_utf8_lossy(&watch_alias.stderr)
.starts_with("note: 'sloop watch' is now 'sloop show --follow'")
);
let wait_alias = world.sloop(&["wait", &ticket, "--timeout", "5"]);
let wait_new = world.sloop(&["show", &ticket, "--follow", "--quiet"]);
assert_eq!(wait_alias.status.code(), wait_new.status.code());
assert_eq!(wait_alias.stdout, wait_new.stdout);
assert!(
String::from_utf8_lossy(&wait_alias.stderr)
.starts_with("note: 'sloop wait' is now 'sloop show --follow --quiet'")
);
}