use std::collections::HashMap;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::{Error, Result};
use crate::events::{append_and_apply_unlocked, excerpt, for_each_event_probe};
use crate::lock::{LockedRun, RunLock};
use crate::paths::RunPaths;
use crate::projections::{read_manifest, read_node_opt};
use crate::reducer::apply_event;
use crate::schema::{Event, NodeId, RunId, Status};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CancelOutcome {
pub run_was_already_cancelled: bool,
pub nodes_cancelled: Vec<NodeId>,
pub nodes_already_terminal: Vec<NodeId>,
}
pub fn cancel_run(paths: &RunPaths, note: Option<&str>) -> Result<CancelOutcome> {
RunLock::with_lock(paths, |lock| cancel_run_unlocked(lock, paths, note))
}
pub fn cancel_run_unlocked(
lock: &LockedRun<'_>,
paths: &RunPaths,
note: Option<&str>,
) -> Result<CancelOutcome> {
let started = std::time::Instant::now();
let manifest = read_manifest(paths)?;
if manifest.status.is_terminal() && manifest.status != Status::Cancelled {
return Err(Error::RunAlreadyTerminal {
status: manifest.status,
});
}
let run_was_already_cancelled = manifest.status == Status::Cancelled;
let reason = note
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("cancelled by user");
let CancelLedger {
node_status,
prior_cancel,
} = read_cancel_ledger(paths)?;
let mut nodes_cancelled = Vec::new();
let mut nodes_already_terminal = Vec::new();
for (nid, log_status) in node_status {
let key = node_cancel_key(&paths.run_id, &nid);
if let Some(prior) = prior_cancel.get(&("node.report".to_owned(), key.clone())) {
if let Some(n) = read_node_opt(paths, &nid)? {
if n.status.is_terminal() {
nodes_already_terminal.push(nid);
continue;
}
}
apply_event(paths, prior)?;
nodes_cancelled.push(nid);
continue;
}
if log_status.is_terminal() {
nodes_already_terminal.push(nid);
continue;
}
let data = json!({
"success": false,
"cancelled": true,
"reason": reason,
"summary": "Run cancelled before agent reported.",
"discussion_items": [],
"spinoff_proposals": [],
"wrap_up_recommendations": []
});
append_and_apply_unlocked(lock, paths, "node.report", Some(&nid), Some(&key), data)?;
nodes_cancelled.push(nid);
}
if !run_was_already_cancelled {
let key = run_status_cancel_key(&paths.run_id);
if let Some(prior) = prior_cancel.get(&("run.status".to_owned(), key.clone())) {
apply_event(paths, prior)?;
} else {
let mut status_data = serde_json::Map::new();
status_data.insert("status".into(), "cancelled".into());
if let Some(n) = note.map(str::trim).filter(|s| !s.is_empty()) {
status_data.insert("note".into(), n.into());
}
append_and_apply_unlocked(
lock,
paths,
"run.status",
None,
Some(&key),
serde_json::Value::Object(status_data),
)?;
}
}
tracing::debug!(
target: "octl_core::cancel",
run_id = %paths.run_id,
held_ms = started.elapsed().as_millis() as u64,
nodes_cancelled = nodes_cancelled.len(),
nodes_already_terminal = nodes_already_terminal.len(),
"cancel transaction complete",
);
Ok(CancelOutcome {
run_was_already_cancelled,
nodes_cancelled,
nodes_already_terminal,
})
}
struct CancelLedger {
node_status: Vec<(NodeId, Status)>,
prior_cancel: HashMap<(String, String), Event>,
}
#[derive(Deserialize)]
struct CancelProbe {
kind: String,
#[serde(default)]
node_id: Option<NodeId>,
#[serde(default)]
idempotency_key: Option<String>,
#[serde(default)]
data: CancelProbeData,
}
#[derive(Deserialize, Default)]
struct CancelProbeData {
#[serde(default)]
status: Option<String>,
#[serde(default)]
success: Option<bool>,
#[serde(default)]
cancelled: Option<bool>,
}
fn read_cancel_ledger(paths: &RunPaths) -> Result<CancelLedger> {
let events_path = paths.checked_events()?;
let prefix = format!("run-cancel:{}:", paths.run_id.as_str());
let mut order: Vec<NodeId> = Vec::new();
let mut status: HashMap<NodeId, Status> = HashMap::new();
let mut prior_cancel: HashMap<(String, String), Event> = HashMap::new();
for_each_event_probe::<CancelProbe, _>(&events_path, |probe, raw| {
match probe.kind.as_str() {
"node.created" => {
if let Some(nid) = &probe.node_id {
if !status.contains_key(nid) {
order.push(nid.clone());
status.insert(nid.clone(), Status::Pending);
}
}
}
"node.status" => {
if let Some(nid) = &probe.node_id {
if let Some(cur) = status.get_mut(nid) {
if !cur.is_terminal() {
if let Some(ns) = probe.data.status.as_deref().and_then(parse_status) {
*cur = ns;
}
}
}
}
}
"node.report" => {
if let Some(nid) = &probe.node_id {
if let Some(cur) = status.get_mut(nid) {
if !cur.is_terminal() {
if let Some(ns) =
report_terminal_status(probe.data.success, probe.data.cancelled)
{
*cur = ns;
}
}
}
}
}
_ => {}
}
if let Some(key) = probe
.idempotency_key
.as_deref()
.filter(|k| k.starts_with(&prefix))
{
let entry = (probe.kind.clone(), key.to_owned());
if let std::collections::hash_map::Entry::Vacant(slot) = prior_cancel.entry(entry) {
let ev: Event =
serde_json::from_slice(raw).map_err(|e| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"cancel ledger: line matched a run-cancel key but is not a \
replayable event: {} [{e}]",
excerpt(raw)
),
})?;
slot.insert(ev);
}
}
Ok(())
})?;
order.sort_by_key(|id| {
id.as_str()
.strip_prefix("n-")
.and_then(|d| d.parse::<u64>().ok())
.unwrap_or(0)
});
let node_status = order
.into_iter()
.map(|id| {
let s = status[&id];
(id, s)
})
.collect();
Ok(CancelLedger {
node_status,
prior_cancel,
})
}
fn parse_status(s: &str) -> Option<Status> {
serde_json::from_value(Value::String(s.to_owned())).ok()
}
fn report_terminal_status(success: Option<bool>, cancelled: Option<bool>) -> Option<Status> {
if cancelled.unwrap_or(false) {
if success == Some(true) {
return None;
}
Some(Status::Cancelled)
} else {
match success {
Some(true) => Some(Status::Done),
Some(false) => Some(Status::Failed),
None => None,
}
}
}
fn node_cancel_key(run_id: &RunId, node_id: &NodeId) -> String {
format!("run-cancel:{}:node:{}", run_id.as_str(), node_id.as_str())
}
fn run_status_cancel_key(run_id: &RunId) -> String {
format!("run-cancel:{}:run-status", run_id.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::{append_and_apply_event, append_event_with_seq, read_all_events};
use crate::lock::ACQUIRE_COUNT;
use tempfile::TempDir;
fn report_count(paths: &RunPaths, nid: &str) -> usize {
read_all_events(&paths.events())
.unwrap()
.iter()
.filter(|e| {
e.kind == "node.report" && e.node_id.as_ref().map(NodeId::as_str) == Some(nid)
})
.count()
}
fn fresh_run(tmp: &TempDir) -> RunPaths {
let run_id = "01jxsnap000000000000000000";
let dir = tmp.path().join(run_id);
std::fs::create_dir_all(&dir).unwrap();
RunPaths::new(dir, run_id).unwrap()
}
fn nid(s: &str) -> NodeId {
NodeId::parse_str(s).unwrap()
}
fn bootstrap(paths: &RunPaths, count: usize) {
append_and_apply_event(
paths,
"run.created",
None,
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
)
.unwrap();
for i in 1..=count {
let node_id = nid(&format!("n-{i:04}"));
append_and_apply_event(
paths,
"node.created",
Some(&node_id),
None,
json!({ "kind": "spinoff" }),
)
.unwrap();
}
}
fn node_status(paths: &RunPaths, nid: &str) -> Status {
let id = NodeId::parse_str(nid).unwrap();
crate::read_node(paths, &id).unwrap().status
}
#[test]
fn cancel_running_run_converges_live_nodes_and_settles_run() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 2);
let out = cancel_run(&paths, Some("stop")).unwrap();
assert!(!out.run_was_already_cancelled);
assert_eq!(
out.nodes_cancelled
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0001", "n-0002"]
);
assert!(out.nodes_already_terminal.is_empty());
assert_eq!(node_status(&paths, "n-0001"), Status::Cancelled);
assert_eq!(
crate::read_manifest(&paths).unwrap().status,
Status::Cancelled
);
}
#[test]
fn cancel_done_run_is_refused_without_mutation() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 1);
append_and_apply_event(
&paths,
"node.report",
Some(&nid("n-0001")),
None,
json!({ "success": true }),
)
.unwrap();
append_and_apply_event(
&paths,
"run.status",
None,
None,
json!({ "status": "done" }),
)
.unwrap();
let before = read_all_events(&paths.events()).unwrap().len();
let err = cancel_run(&paths, None).unwrap_err();
assert!(
matches!(
err,
Error::RunAlreadyTerminal {
status: Status::Done
}
),
"got {err:?}"
);
assert_eq!(
read_all_events(&paths.events()).unwrap().len(),
before,
"a refused cancel must not append any event"
);
assert_eq!(crate::read_manifest(&paths).unwrap().status, Status::Done);
}
#[test]
fn recancel_cancelled_run_converges_straggler_node() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 2);
append_and_apply_event(
&paths,
"node.report",
Some(&nid("n-0001")),
None,
json!({ "success": false, "cancelled": true, "reason": "x" }),
)
.unwrap();
append_and_apply_event(
&paths,
"run.status",
None,
None,
json!({ "status": "cancelled" }),
)
.unwrap();
assert_eq!(node_status(&paths, "n-0002"), Status::Pending);
let out = cancel_run(&paths, None).unwrap();
assert!(out.run_was_already_cancelled);
assert_eq!(
out.nodes_cancelled
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0002"],
"only the straggler converges"
);
assert_eq!(
out.nodes_already_terminal
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0001"]
);
assert_eq!(node_status(&paths, "n-0002"), Status::Cancelled);
}
#[test]
fn recancel_fully_converged_run_is_a_clean_noop() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 1);
cancel_run(&paths, None).unwrap(); let before = read_all_events(&paths.events()).unwrap().len();
let out = cancel_run(&paths, None).unwrap();
assert!(out.run_was_already_cancelled);
assert!(out.nodes_cancelled.is_empty(), "nothing left to converge");
assert_eq!(
out.nodes_already_terminal
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0001"]
);
assert_eq!(
read_all_events(&paths.events()).unwrap().len(),
before,
"a fully-converged re-cancel appends nothing"
);
}
#[test]
fn already_terminal_node_is_not_over_reported() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 2);
append_and_apply_event(
&paths,
"node.report",
Some(&nid("n-0001")),
None,
json!({ "success": true }),
)
.unwrap();
let out = cancel_run(&paths, None).unwrap();
assert_eq!(
out.nodes_cancelled
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0002"]
);
assert_eq!(
out.nodes_already_terminal
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0001"]
);
assert_eq!(
node_status(&paths, "n-0001"),
Status::Done,
"Done node untouched"
);
assert_eq!(node_status(&paths, "n-0002"), Status::Cancelled);
}
#[test]
fn cancel_run_with_no_nodes_dir_settles_run_only() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
)
.unwrap();
let out = cancel_run(&paths, None).unwrap();
assert!(!out.run_was_already_cancelled);
assert!(out.nodes_cancelled.is_empty());
assert!(out.nodes_already_terminal.is_empty());
assert_eq!(
crate::read_manifest(&paths).unwrap().status,
Status::Cancelled
);
}
#[test]
fn blank_note_falls_back_to_default_reason_and_does_not_brick_cancel() {
for blank in ["", " ", "\n\t"] {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 1);
let out = cancel_run(&paths, Some(blank)).unwrap();
assert_eq!(
out.nodes_cancelled
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0001"],
"blank note {blank:?} still converges the live node"
);
assert_eq!(node_status(&paths, "n-0001"), Status::Cancelled);
let report = crate::read_node(&paths, &NodeId::parse_str("n-0001").unwrap())
.unwrap()
.last_report
.expect("cancel report recorded");
assert_eq!(report["reason"], "cancelled by user");
}
}
#[test]
fn nodes_are_converged_in_numeric_not_lexical_order() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
)
.unwrap();
for node in ["n-9999", "n-10000", "n-0001"] {
append_and_apply_event(
&paths,
"node.created",
Some(&nid(node)),
None,
json!({ "kind": "spinoff" }),
)
.unwrap();
}
let out = cancel_run(&paths, None).unwrap();
assert_eq!(
out.nodes_cancelled
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0001", "n-9999", "n-10000"],
);
}
#[test]
fn cancel_synthesizes_report_for_node_with_missing_projection() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 2);
let n2 = NodeId::parse_str("n-0002").unwrap();
std::fs::remove_file(paths.node(&n2)).unwrap();
assert!(
read_node_opt(&paths, &n2).unwrap().is_none(),
"projection gone"
);
let out = cancel_run(&paths, Some("stop")).unwrap();
assert_eq!(
out.nodes_cancelled
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0001", "n-0002"],
"the node with a missing projection is still cancelled"
);
assert!(out.nodes_already_terminal.is_empty());
assert_eq!(report_count(&paths, "n-0002"), 1);
assert_eq!(
crate::read_manifest(&paths).unwrap().status,
Status::Cancelled
);
}
#[test]
fn cancel_takes_the_run_lock_exactly_once() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 5);
ACQUIRE_COUNT.with(|c| c.set(0));
let out = cancel_run(&paths, Some("stop")).unwrap();
assert_eq!(out.nodes_cancelled.len(), 5);
assert_eq!(
ACQUIRE_COUNT.with(std::cell::Cell::get),
1,
"cancel must take the run lock exactly once, not once per node (N+1)"
);
}
#[test]
fn cancel_does_not_duplicate_a_node_report_already_in_the_log() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 1);
let node = nid("n-0001");
let key = node_cancel_key(&paths.run_id, &node);
RunLock::with_lock(&paths, |lock| {
append_event_with_seq(
lock,
&paths,
3,
"node.report",
Some(&node),
Some(&key),
json!({ "success": false, "cancelled": true, "reason": "x" }),
)
})
.unwrap();
assert_eq!(node_status(&paths, "n-0001"), Status::Pending);
assert_eq!(report_count(&paths, "n-0001"), 1);
let out = cancel_run(&paths, None).unwrap();
assert_eq!(
out.nodes_cancelled
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0001"],
);
assert_eq!(
report_count(&paths, "n-0001"),
1,
"the already-logged cancel report must not be duplicated"
);
assert_eq!(
node_status(&paths, "n-0001"),
Status::Cancelled,
"the already-logged cancel must be re-folded, not just skipped"
);
assert_eq!(
crate::read_manifest(&paths).unwrap().status,
Status::Cancelled
);
}
#[test]
fn cancel_does_not_duplicate_run_status_already_in_the_log() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0);
let key = run_status_cancel_key(&paths.run_id);
RunLock::with_lock(&paths, |lock| {
append_event_with_seq(
lock,
&paths,
2,
"run.status",
None,
Some(&key),
json!({ "status": "cancelled" }),
)
})
.unwrap();
assert_ne!(
crate::read_manifest(&paths).unwrap().status,
Status::Cancelled
);
let before = read_all_events(&paths.events()).unwrap().len();
let out = cancel_run(&paths, None).unwrap();
assert!(!out.run_was_already_cancelled);
assert_eq!(
read_all_events(&paths.events()).unwrap().len(),
before,
"no duplicate run.status appended when one is already logged"
);
assert_eq!(
crate::read_manifest(&paths).unwrap().status,
Status::Cancelled,
"the already-logged run.status must be re-folded, not just skipped"
);
}
#[test]
fn cancel_skips_node_terminal_in_log_despite_stale_live_projection() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 2);
let n1 = nid("n-0001");
RunLock::with_lock(&paths, |lock| {
append_event_with_seq(
lock,
&paths,
4,
"node.status",
Some(&n1),
None,
json!({ "status": "done" }),
)
})
.unwrap();
assert_eq!(
node_status(&paths, "n-0001"),
Status::Pending,
"projection is the stale, crash-stranded live status"
);
let out = cancel_run(&paths, Some("stop")).unwrap();
assert_eq!(
out.nodes_already_terminal
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0001"],
"the log-terminal node is reported already-terminal, not cancelled"
);
assert_eq!(
out.nodes_cancelled
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0002"],
);
assert_eq!(
report_count(&paths, "n-0001"),
0,
"no cancel over-write was appended for the log-terminal node"
);
}
#[test]
fn cancel_skips_node_with_unfolded_success_report_in_log() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 1); let n1 = nid("n-0001");
RunLock::with_lock(&paths, |lock| {
append_event_with_seq(
lock,
&paths,
3,
"node.report",
Some(&n1),
None,
json!({ "success": true }),
)
})
.unwrap();
assert_eq!(
node_status(&paths, "n-0001"),
Status::Pending,
"stale live projection (success report fsynced but not folded)"
);
let out = cancel_run(&paths, None).unwrap();
assert_eq!(
out.nodes_already_terminal
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0001"],
);
assert!(
out.nodes_cancelled.is_empty(),
"a node the log shows Done must not be cancelled"
);
assert_eq!(
report_count(&paths, "n-0001"),
1,
"only the original success report remains; no cancel was appended"
);
}
#[test]
fn cancel_ledger_streams_large_report_payloads() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 2);
let big = "x".repeat(64 * 1024);
append_and_apply_event(
&paths,
"node.report",
Some(&nid("n-0001")),
None,
json!({ "success": true, "summary": big }),
)
.unwrap();
assert_eq!(node_status(&paths, "n-0001"), Status::Done);
let out = cancel_run(&paths, Some("stop")).unwrap();
assert_eq!(
out.nodes_already_terminal
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0001"],
);
assert_eq!(
out.nodes_cancelled
.iter()
.map(NodeId::as_str)
.collect::<Vec<_>>(),
vec!["n-0002"],
);
}
}