use octl_core::{Node, ReportOrigin, WorkerExit};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Teardown {
Full,
PreserveWork,
SourceRelative,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerminalOutcome {
Merged,
Blocked,
Failed,
Cancelled,
PlainSuccess,
}
impl TerminalOutcome {
#[must_use]
pub fn classify(n: &Node) -> Option<Self> {
let report = n.last_report.as_ref()?;
let success = report.get("success").and_then(Value::as_bool);
let cancelled = report.get("cancelled").and_then(Value::as_bool) == Some(true);
let origin_present = report.get(octl_core::REPORT_ORIGIN_KEY).is_some();
let origin = ReportOrigin::from_report(report);
if cancelled {
return Some(TerminalOutcome::Cancelled);
}
if ReportOrigin::report_is_confirmed_merge(report) {
return Some(TerminalOutcome::Merged);
}
match success {
Some(false) => Some(
if is_supervisor_failure(report, origin_present, origin.as_ref()) {
TerminalOutcome::Failed
} else {
TerminalOutcome::Blocked
},
),
Some(true) => Some(TerminalOutcome::PlainSuccess),
None => Some(TerminalOutcome::Blocked),
}
}
#[must_use]
pub fn teardown(self) -> Teardown {
match self {
TerminalOutcome::Merged => Teardown::Full,
TerminalOutcome::Blocked | TerminalOutcome::Failed => Teardown::PreserveWork,
TerminalOutcome::Cancelled | TerminalOutcome::PlainSuccess => Teardown::SourceRelative,
}
}
#[must_use]
pub fn is_explicit_merge(self) -> bool {
matches!(self, TerminalOutcome::Merged)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeathObservation {
Alive,
DeadWithinGrace,
DeadGraceElapsed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LiveVerdict {
WorkerFailed(WorkerExit),
AttentionRequired,
CrashBackstopFailed,
DeferGrace,
Alive,
}
#[must_use]
pub fn classify_live_node(worker_exit: Option<WorkerExit>, death: DeathObservation) -> LiveVerdict {
if let Some(exit) = worker_exit {
return if exit.is_failure() {
LiveVerdict::WorkerFailed(exit)
} else {
LiveVerdict::AttentionRequired
};
}
match death {
DeathObservation::Alive => LiveVerdict::Alive,
DeathObservation::DeadWithinGrace => LiveVerdict::DeferGrace,
DeathObservation::DeadGraceElapsed => LiveVerdict::CrashBackstopFailed,
}
}
fn is_supervisor_failure(
report: &Value,
origin_present: bool,
origin: Option<&ReportOrigin>,
) -> bool {
match origin {
Some(ReportOrigin::Supervisor) => return true,
Some(ReportOrigin::Agent | ReportOrigin::RunMerge { .. }) => return false,
None => {}
}
if origin_present {
return false;
}
let Some(reason) = report.get("reason").and_then(Value::as_str) else {
return false;
};
reason == super::WORKER_EXITED_NONZERO_REASON
|| reason == super::WORKER_KILLED_BY_SIGNAL_REASON
|| reason == super::NO_WORKER_REASON
|| reason.starts_with(AGENT_DIED_REASON_PREFIX)
}
const AGENT_DIED_REASON_PREFIX: &str = "agent-";
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use octl_core::{Kind, NodeId, RunId, Status};
use serde_json::json;
fn node_with_report(report: Option<Value>) -> Node {
Node {
schema_version: 1,
node_id: NodeId::parse_str("n-0001").unwrap(),
run_id: RunId::parse_str("01jxsnap000000000000000000").unwrap(),
parent_node_id: None,
kind: Kind::Spinoff,
status: Status::Failed,
task: None,
worktree_path: None,
branch: None,
base_sha: None,
tmux_window: None,
tmux_identity: None,
agent_pid: None,
agent_pid_start_time: None,
supervisor_pid: None,
children: vec![],
started_at: None,
updated_at: Utc::now(),
last_report: report,
last_processed_report_seq_by_child: serde_json::Map::new(),
retry_attempts: 0,
worker_exit: None,
pending_merge: None,
first_death_at: None,
awaiting_input: None,
}
}
#[test]
fn terminal_outcome_teardown_table() {
let rows: &[(&str, Value, TerminalOutcome, Teardown)] = &[
(
"explicit merge succeeds -> done + full teardown",
json!({ "success": true, "via": "explicit-merge" }),
TerminalOutcome::Merged,
Teardown::Full,
),
(
"blocked handoff -> preserve branch + worktree",
json!({ "success": false, "reason": "blocked-on-human" }),
TerminalOutcome::Blocked,
Teardown::PreserveWork,
),
(
"told worker-exit failure -> failed, preserve",
json!({ "success": false, "reason": "worker-exited-nonzero" }),
TerminalOutcome::Failed,
Teardown::PreserveWork,
),
(
"confirmed-death backstop -> failed, preserve",
json!({ "success": false, "reason": "agent-died" }),
TerminalOutcome::Failed,
Teardown::PreserveWork,
),
(
"run cancel -> cancelled, source-relative preserve",
json!({ "success": false, "cancelled": true, "reason": "cancelled by user" }),
TerminalOutcome::Cancelled,
Teardown::SourceRelative,
),
(
"plain success without run merge -> source-relative",
json!({ "success": true }),
TerminalOutcome::PlainSuccess,
Teardown::SourceRelative,
),
];
for (why, report, want_outcome, want_teardown) in rows {
let n = node_with_report(Some(report.clone()));
let got = TerminalOutcome::classify(&n).expect("terminal report classifies");
assert_eq!(got, *want_outcome, "outcome: {why}");
assert_eq!(got.teardown(), *want_teardown, "teardown: {why}");
}
}
#[test]
fn only_explicit_merge_force_deletes() {
let merged = node_with_report(Some(json!({ "success": true, "via": "explicit-merge" })));
assert!(TerminalOutcome::classify(&merged)
.unwrap()
.is_explicit_merge());
let spoofed = node_with_report(Some(json!({ "success": false, "via": "explicit-merge" })));
let outcome = TerminalOutcome::classify(&spoofed).unwrap();
assert!(!outcome.is_explicit_merge(), "success:false is not a merge");
assert_eq!(outcome.teardown(), Teardown::PreserveWork);
}
#[test]
fn raw_selfmerge_death_backstop_preserves_work() {
let mut report = json!({ "success": false, "reason": "agent-died" });
octl_core::ReportOrigin::Supervisor.stamp(&mut report);
let n = node_with_report(Some(report));
let outcome = TerminalOutcome::classify(&n).expect("classifies");
assert_eq!(outcome, TerminalOutcome::Failed);
assert_eq!(
outcome.teardown(),
Teardown::PreserveWork,
"a raw-selfmerge-then-death failure must preserve the branch + worktree"
);
assert!(
!outcome.is_explicit_merge(),
"a raw-git self-merge is NOT a recorded run merge — never force-deletable"
);
}
#[test]
fn no_report_is_not_terminal() {
assert_eq!(TerminalOutcome::classify(&node_with_report(None)), None);
}
#[test]
fn cancel_beats_spoofed_merge_marker() {
for report in [
json!({ "success": false, "cancelled": true, "via": "explicit-merge" }),
json!({ "success": true, "cancelled": true, "via": "explicit-merge" }),
] {
let n = node_with_report(Some(report.clone()));
let outcome = TerminalOutcome::classify(&n).unwrap();
assert_eq!(outcome, TerminalOutcome::Cancelled, "report: {report}");
assert_eq!(
outcome.teardown(),
Teardown::SourceRelative,
"cancel must never force-delete: {report}"
);
}
}
#[test]
fn typed_origin_classifies_without_string_sniffing() {
let merged = node_with_report(Some(json!({
"success": true,
"origin": { "kind": "run-merge", "op_id": "op-1", "worker_oid": "abc" }
})));
let outcome = TerminalOutcome::classify(&merged).expect("classifies");
assert_eq!(outcome, TerminalOutcome::Merged);
assert_eq!(outcome.teardown(), Teardown::Full);
let sup = node_with_report(Some(json!({
"success": false,
"reason": "some-future-reason-not-in-the-legacy-list",
"origin": { "kind": "supervisor" }
})));
let outcome = TerminalOutcome::classify(&sup).expect("classifies");
assert_eq!(outcome, TerminalOutcome::Failed);
assert_eq!(outcome.teardown(), Teardown::PreserveWork);
let agent = node_with_report(Some(json!({
"success": false,
"reason": "agent-said-so",
"origin": { "kind": "agent" }
})));
let outcome = TerminalOutcome::classify(&agent).expect("classifies");
assert_eq!(outcome, TerminalOutcome::Blocked);
assert_eq!(outcome.teardown(), Teardown::PreserveWork);
}
#[test]
fn agent_origin_cannot_forge_a_merge_via_string() {
let forged = node_with_report(Some(json!({
"success": true,
"via": "explicit-merge",
"origin": { "kind": "agent" }
})));
let outcome = TerminalOutcome::classify(&forged).expect("classifies");
assert_eq!(
outcome,
TerminalOutcome::PlainSuccess,
"an agent-origin report must not be classified Merged on a forged via"
);
assert!(!outcome.is_explicit_merge());
assert_eq!(outcome.teardown(), Teardown::SourceRelative);
}
#[test]
fn malformed_origin_does_not_downgrade_to_legacy_via_or_reason() {
let forged_merge = node_with_report(Some(json!({
"success": true,
"via": "explicit-merge",
"origin": "garbage-not-an-object"
})));
let outcome = TerminalOutcome::classify(&forged_merge).expect("classifies");
assert_eq!(
outcome,
TerminalOutcome::PlainSuccess,
"a present-but-malformed origin must not unlock the legacy via merge path"
);
assert!(!outcome.is_explicit_merge());
assert_eq!(outcome.teardown(), Teardown::SourceRelative);
let unknown_neg = node_with_report(Some(json!({
"success": false,
"reason": "worker-exited-nonzero",
"origin": { "kind": "not-a-real-variant" }
})));
assert_eq!(
TerminalOutcome::classify(&unknown_neg),
Some(TerminalOutcome::Blocked)
);
}
#[test]
fn legacy_reports_without_origin_classify_via_strings() {
let legacy_merge =
node_with_report(Some(json!({ "success": true, "via": "explicit-merge" })));
assert_eq!(
TerminalOutcome::classify(&legacy_merge),
Some(TerminalOutcome::Merged)
);
let legacy_fail = node_with_report(Some(
json!({ "success": false, "reason": "worker-exited-nonzero" }),
));
assert_eq!(
TerminalOutcome::classify(&legacy_fail),
Some(TerminalOutcome::Failed)
);
let legacy_blocked = node_with_report(Some(
json!({ "success": false, "reason": "blocked-on-human" }),
));
assert_eq!(
TerminalOutcome::classify(&legacy_blocked),
Some(TerminalOutcome::Blocked)
);
}
#[test]
fn live_verdict_table() {
let clean = WorkerExit {
code: Some(0),
signal: None,
at: Utc::now(),
};
let nonzero = WorkerExit {
code: Some(2),
signal: None,
at: Utc::now(),
};
let killed = WorkerExit {
code: None,
signal: Some(9),
at: Utc::now(),
};
assert_eq!(
classify_live_node(Some(nonzero), DeathObservation::Alive),
LiveVerdict::WorkerFailed(nonzero)
);
assert_eq!(
classify_live_node(Some(killed), DeathObservation::DeadGraceElapsed),
LiveVerdict::WorkerFailed(killed)
);
assert_eq!(
classify_live_node(Some(clean), DeathObservation::DeadGraceElapsed),
LiveVerdict::AttentionRequired
);
assert_eq!(
classify_live_node(None, DeathObservation::Alive),
LiveVerdict::Alive
);
assert_eq!(
classify_live_node(None, DeathObservation::DeadWithinGrace),
LiveVerdict::DeferGrace
);
assert_eq!(
classify_live_node(None, DeathObservation::DeadGraceElapsed),
LiveVerdict::CrashBackstopFailed
);
}
}