use chrono::{Duration, Utc};
use serde_json::{json, Value};
use octl_core::report::validate_report_payload;
use octl_core::{
append_and_apply_idempotent, read_manifest_opt, read_node_opt, AppendOutcome, LockedRun,
MergeTxn, NodeId, RunLock, RunPaths, VIA_EXPLICIT_MERGE,
};
use crate::run::landed::{landing_signal, LandedMethod, LandingInputs};
use crate::supervise::pid_file::pid_live_with_identity;
const DRIVER_STALE_AFTER_MINS: i64 = 30;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Recovery {
NothingPending,
DriverAlive,
CannotVerify,
Completed,
Rejected { reason: String },
Superseded,
}
enum Verdict {
Complete,
Reject { reason: String },
CannotVerify,
}
pub(crate) fn recover_run(paths: &RunPaths, git: &str) {
let node_ids = RunLock::with_shared_lock(&paths.lock(), || {
let mut ids = Vec::new();
let entries = match std::fs::read_dir(paths.nodes_dir()) {
Ok(v) => v,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ids),
Err(e) => return Err(octl_core::Error::io(paths.nodes_dir(), e)),
};
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Ok(nid) = NodeId::parse_str(stem) else {
continue;
};
if read_node_opt(paths, &nid)?.is_some_and(|n| n.pending_merge.is_some()) {
ids.push(nid);
}
}
Ok(ids)
});
let node_ids = match node_ids {
Ok(v) => v,
Err(e) => {
tracing::warn!(
target: "orchestratectl::supervise",
error = %e,
"merge-recovery: could not enumerate nodes; will retry next tick"
);
return;
}
};
for nid in node_ids {
match recover_node(paths, &nid, git) {
Recovery::Completed => tracing::info!(
target: "orchestratectl::supervise",
node_id = %nid,
"merge-recovery: completed a crashed merge transaction (git-verified landed)"
),
Recovery::Rejected { reason } => tracing::warn!(
target: "orchestratectl::supervise",
node_id = %nid, reason = %reason,
"merge-recovery: rejected a crashed merge transaction; work preserved"
),
_ => {}
}
}
}
pub(crate) fn recover_node(paths: &RunPaths, node_id: &NodeId, git: &str) -> Recovery {
let probed = RunLock::with_shared_lock(&paths.lock(), || {
let node = read_node_opt(paths, node_id)?;
let manifest = read_manifest_opt(paths)?;
let source_repo = manifest.as_ref().and_then(|m| m.source_repo.clone());
let txn = node.as_ref().and_then(|n| n.pending_merge.clone());
let worktree = node.as_ref().and_then(|n| n.worktree_path.clone());
let terminal = node.as_ref().is_some_and(|n| n.status.is_terminal());
Ok((txn, source_repo, worktree, terminal))
});
let (txn, source_repo, worktree_path, terminal) = match probed {
Ok(v) => v,
Err(_) => return Recovery::CannotVerify,
};
let Some(txn) = txn else {
return Recovery::NothingPending;
};
if terminal {
return Recovery::NothingPending;
}
if driver_is_alive(&txn) {
return Recovery::DriverAlive;
}
let verdict = classify(&txn, source_repo.as_deref(), worktree_path.as_deref(), git);
let verdict = match verdict {
Verdict::CannotVerify => return Recovery::CannotVerify,
v => v,
};
let run_id = paths.run_id.as_str().to_string();
let outcome = RunLock::with_lock(paths, |lock| {
let Some(node) = read_node_opt(paths, node_id)? else {
return Ok(Recovery::Superseded);
};
let still_pending = node
.pending_merge
.as_ref()
.is_some_and(|t| t.op_id == txn.op_id);
if !still_pending {
return Ok(Recovery::Superseded);
}
if node.status.is_terminal() {
abort_txn(
paths,
lock,
node_id,
&run_id,
&txn.op_id,
"node terminalized before recovery could complete",
)?;
return Ok(Recovery::Superseded);
}
match &verdict {
Verdict::Complete => {
let report = completion_report(&txn);
let key = format!("explicit-merge:{run_id}:{node_id}");
match append_and_apply_idempotent(
paths,
lock,
"node.report",
Some(node_id),
&key,
|_seq| Ok(report.clone()),
)? {
AppendOutcome::Conflict { .. } => Ok(Recovery::Superseded),
_ => Ok(Recovery::Completed),
}
}
Verdict::Reject { reason } => {
match abort_txn(paths, lock, node_id, &run_id, &txn.op_id, reason)? {
AppendOutcome::Conflict { .. } => Ok(Recovery::Superseded),
_ => Ok(Recovery::Rejected {
reason: reason.clone(),
}),
}
}
Verdict::CannotVerify => Ok(Recovery::CannotVerify),
}
});
outcome.unwrap_or(Recovery::CannotVerify)
}
fn driver_is_alive(txn: &MergeTxn) -> bool {
let Some(pid) = txn.driver_pid else {
return false;
};
if pid <= 0 || !pid_live_with_identity(pid as u32, txn.driver_pid_start_secs) {
return false;
}
if txn.driver_pid_start_secs.is_some() {
return true;
}
Utc::now().signed_duration_since(txn.started_at) < Duration::minutes(DRIVER_STALE_AFTER_MINS)
}
fn abort_txn(
paths: &RunPaths,
lock: &LockedRun<'_>,
node_id: &NodeId,
run_id: &str,
op_id: &str,
reason: &str,
) -> octl_core::Result<AppendOutcome> {
let key = format!("merge-aborted:{run_id}:{node_id}:{op_id}");
let data = json!({ "op_id": op_id, "reason": reason });
append_and_apply_idempotent(
paths,
lock,
octl_core::KIND_MERGE_ABORTED,
Some(node_id),
&key,
|_seq| Ok(data.clone()),
)
}
#[derive(PartialEq, Eq)]
enum Landing {
Landed,
NotLanded,
Unverifiable,
}
fn classify(
txn: &MergeTxn,
source_repo: Option<&str>,
worktree_path: Option<&str>,
git: &str,
) -> Verdict {
let (repo, source_now) =
match resolve_source(source_repo, worktree_path, &txn.source_branch, git) {
Some(v) => v,
None => return Verdict::CannotVerify,
};
if source_now == txn.expected_source_oid {
return Verdict::Reject {
reason: "source ref unchanged — merge never landed".to_string(),
};
}
match worker_landed(
&repo,
&source_now,
&txn.worker_oid,
txn.base_sha.as_deref(),
git,
) {
Landing::Unverifiable => Verdict::CannotVerify,
Landing::NotLanded => Verdict::Reject {
reason: "source ref moved but the worker's recorded work is not integrated".to_string(),
},
Landing::Landed => {
match worker_landed(
&repo,
&txn.expected_source_oid,
&txn.worker_oid,
txn.base_sha.as_deref(),
git,
) {
Landing::Landed => Verdict::Reject {
reason: "worker content was already integrated before the merge; the source \
move was unrelated"
.to_string(),
},
Landing::NotLanded => Verdict::Complete,
Landing::Unverifiable => Verdict::CannotVerify,
}
}
}
}
fn resolve_source(
source_repo: Option<&str>,
worktree_path: Option<&str>,
source_branch: &str,
git: &str,
) -> Option<(String, String)> {
[source_repo, worktree_path]
.into_iter()
.flatten()
.find_map(|repo| read_oid(git, repo, source_branch).map(|oid| (repo.to_string(), oid)))
}
fn worker_landed(
repo: &str,
source_rev: &str,
worker_oid: &str,
base_sha: Option<&str>,
git: &str,
) -> Landing {
let inputs = LandingInputs {
source_repo: Some(repo),
source_branch: Some(source_rev),
worktree_path: None,
branch: Some(worker_oid),
base_sha,
report: None,
};
let signal = landing_signal(&inputs, git);
match signal.method {
LandedMethod::GitVerified if signal.landed => Landing::Landed,
LandedMethod::GitVerified => Landing::NotLanded,
_ => Landing::Unverifiable,
}
}
fn completion_report(txn: &MergeTxn) -> Value {
let mut report = json!({
"success": true,
"summary": format!(
"recovered crashed merge of {} into {} (op {})",
txn.worker_branch, txn.source_branch, txn.op_id
),
"via": VIA_EXPLICIT_MERGE,
});
octl_core::ReportOrigin::RunMerge {
op_id: Some(txn.op_id.clone()),
worker_oid: Some(txn.worker_oid.clone()),
}
.stamp(&mut report);
validate_report_payload(&report)
.expect("recovery completion report is a constant, valid §7.3 payload");
report
}
pub(crate) fn read_oid(git: &str, repo: &str, rev: &str) -> Option<String> {
if repo.is_empty() || rev.is_empty() || rev.starts_with('-') {
return None;
}
let out = std::process::Command::new(git)
.arg("-C")
.arg(repo)
.args(["rev-parse", "--verify", "--end-of-options", rev])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
let ok = matches!(sha.len(), 40 | 64) && sha.chars().all(|c| c.is_ascii_hexdigit());
ok.then_some(sha)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::process::Command;
use octl_core::{append_and_apply_event, read_node_opt, Status};
use serde_json::json;
use crate::supervise::cleanup::git_bin;
fn git(repo: &Path, args: &[&str]) -> String {
let out = Command::new(git_bin())
.arg("-C")
.arg(repo)
.args(args)
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn repo_with_worker() -> (tempfile::TempDir, String, String) {
let dir = tempfile::TempDir::new().unwrap();
let repo = dir.path();
git(repo, &["init", "-q", "-b", "main"]);
git(repo, &["config", "user.email", "t@t"]);
git(repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("f"), "base\n").unwrap();
git(repo, &["add", "f"]);
git(repo, &["commit", "-qm", "base"]);
let base = git(repo, &["rev-parse", "HEAD"]);
git(repo, &["checkout", "-q", "-b", "wt/worker"]);
std::fs::write(repo.join("f"), "base\nwork\n").unwrap();
git(repo, &["commit", "-qam", "worker work"]);
let worker_tip = git(repo, &["rev-parse", "HEAD"]);
git(repo, &["checkout", "-q", "main"]);
(dir, base, worker_tip)
}
fn fresh_run(tmp: &tempfile::TempDir, repo: &Path) -> RunPaths {
let run_id = "01jxsnap000000000000000000";
let dir = tmp.path().join(run_id);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({
"kind": "spinoff",
"lifecycle": "autonomous",
"title": "t",
"source_branch": "main",
"source_repo": repo.to_str().unwrap(),
}),
)
.unwrap();
append_and_apply_event(
&paths,
"node.created",
Some(&NodeId::parse_str("n-0001").unwrap()),
None,
json!({
"kind": "spinoff",
"worktree_path": repo.to_str().unwrap(),
"branch": "wt/worker",
}),
)
.unwrap();
paths
}
fn record_txn(paths: &RunPaths, expected_source_oid: &str, worker_oid: &str, base: &str) {
let txn = json!({
"op_id": "01jxop00000000000000000000",
"source_branch": "main",
"worker_branch": "wt/worker",
"expected_source_oid": expected_source_oid,
"worker_oid": worker_oid,
"base_sha": base,
"driver_pid": 2_000_000_000,
"driver_pid_start_secs": null,
"started_at": "2026-08-15T00:00:00Z",
});
append_and_apply_event(
paths,
octl_core::KIND_MERGE_STARTED,
Some(&NodeId::parse_str("n-0001").unwrap()),
None,
txn,
)
.unwrap();
}
#[test]
fn recovers_git_mutated_event_not_appended_by_completing() {
let (repo_dir, base, worker_tip) = repo_with_worker();
let repo = repo_dir.path();
git(repo, &["merge", "-q", "--ff-only", "wt/worker"]);
assert_ne!(git(repo, &["rev-parse", "main"]), base);
let home = tempfile::TempDir::new().unwrap();
let paths = fresh_run(&home, repo);
record_txn(&paths, &base, &worker_tip, &base);
let nid = NodeId::parse_str("n-0001").unwrap();
let n = read_node_opt(&paths, &nid).unwrap().unwrap();
assert!(n.pending_merge.is_some());
assert_eq!(n.status, Status::Pending);
let outcome = recover_node(&paths, &nid, &git_bin());
assert_eq!(outcome, Recovery::Completed);
let n = read_node_opt(&paths, &nid).unwrap().unwrap();
assert_eq!(n.status, Status::Done, "completed merge terminalizes Done");
assert!(
n.pending_merge.is_none(),
"transaction cleared on completion"
);
let via = n
.last_report
.as_ref()
.and_then(|r| r.get("via"))
.and_then(|v| v.as_str());
assert_eq!(
via,
Some(VIA_EXPLICIT_MERGE),
"explicit-merge report adopted"
);
}
#[test]
fn rejects_merge_started_with_no_git_mutation() {
let (repo_dir, base, worker_tip) = repo_with_worker();
let repo = repo_dir.path();
assert_eq!(git(repo, &["rev-parse", "main"]), base);
let home = tempfile::TempDir::new().unwrap();
let paths = fresh_run(&home, repo);
record_txn(&paths, &base, &worker_tip, &base);
let nid = NodeId::parse_str("n-0001").unwrap();
let outcome = recover_node(&paths, &nid, &git_bin());
assert!(
matches!(outcome, Recovery::Rejected { .. }),
"no git mutation → reject, got {outcome:?}"
);
let n = read_node_opt(&paths, &nid).unwrap().unwrap();
assert_eq!(n.status, Status::Pending, "rejected merge leaves node live");
assert!(
n.pending_merge.is_none(),
"transaction cleared on rejection"
);
assert_eq!(git(repo, &["rev-parse", "wt/worker"]), worker_tip);
}
#[test]
fn leaves_transaction_when_driver_alive() {
let (repo_dir, base, worker_tip) = repo_with_worker();
let repo = repo_dir.path();
let home = tempfile::TempDir::new().unwrap();
let paths = fresh_run(&home, repo);
let pid = std::process::id();
let txn = json!({
"op_id": "01jxop00000000000000000000",
"source_branch": "main",
"worker_branch": "wt/worker",
"expected_source_oid": base,
"worker_oid": worker_tip,
"base_sha": base,
"driver_pid": pid as i32,
"driver_pid_start_secs": crate::supervise::watchdog::pid_start_time(pid),
"started_at": "2026-08-15T00:00:00Z",
});
append_and_apply_event(
&paths,
octl_core::KIND_MERGE_STARTED,
Some(&NodeId::parse_str("n-0001").unwrap()),
None,
txn,
)
.unwrap();
let nid = NodeId::parse_str("n-0001").unwrap();
assert_eq!(
recover_node(&paths, &nid, &git_bin()),
Recovery::DriverAlive
);
assert!(read_node_opt(&paths, &nid)
.unwrap()
.unwrap()
.pending_merge
.is_some());
}
#[test]
fn rejects_when_source_moved_without_worker_content() {
let (repo_dir, base, worker_tip) = repo_with_worker();
let repo = repo_dir.path();
std::fs::write(repo.join("g"), "unrelated\n").unwrap();
git(repo, &["add", "g"]);
git(repo, &["commit", "-qm", "unrelated"]);
assert_ne!(git(repo, &["rev-parse", "main"]), base);
let home = tempfile::TempDir::new().unwrap();
let paths = fresh_run(&home, repo);
record_txn(&paths, &base, &worker_tip, &base);
let nid = NodeId::parse_str("n-0001").unwrap();
let outcome = recover_node(&paths, &nid, &git_bin());
assert!(
matches!(outcome, Recovery::Rejected { .. }),
"unrelated move → fail closed, got {outcome:?}"
);
let n = read_node_opt(&paths, &nid).unwrap().unwrap();
assert_eq!(n.status, Status::Pending);
assert!(n.pending_merge.is_none());
}
#[test]
fn completion_uses_recorded_worker_oid_not_mutable_branch() {
let (repo_dir, base, worker_tip) = repo_with_worker();
let repo = repo_dir.path();
git(repo, &["merge", "-q", "--ff-only", "wt/worker"]);
git(repo, &["checkout", "-q", "wt/worker"]);
std::fs::write(repo.join("h"), "moved\n").unwrap();
git(repo, &["add", "h"]);
git(repo, &["commit", "-qm", "worker moved on"]);
git(repo, &["checkout", "-q", "main"]);
assert_ne!(git(repo, &["rev-parse", "wt/worker"]), worker_tip);
let home = tempfile::TempDir::new().unwrap();
let paths = fresh_run(&home, repo);
record_txn(&paths, &base, &worker_tip, &base);
let nid = NodeId::parse_str("n-0001").unwrap();
assert_eq!(recover_node(&paths, &nid, &git_bin()), Recovery::Completed);
assert_eq!(
read_node_opt(&paths, &nid).unwrap().unwrap().status,
Status::Done
);
}
#[test]
fn rejects_when_worker_content_already_in_expected_source() {
let (repo_dir, base, worker_tip) = repo_with_worker();
let repo = repo_dir.path();
git(repo, &["merge", "-q", "--ff-only", "wt/worker"]);
let expected = git(repo, &["rev-parse", "main"]);
assert_eq!(expected, worker_tip);
std::fs::write(repo.join("g"), "unrelated\n").unwrap();
git(repo, &["add", "g"]);
git(repo, &["commit", "-qm", "unrelated"]);
let home = tempfile::TempDir::new().unwrap();
let paths = fresh_run(&home, repo);
record_txn(&paths, &expected, &worker_tip, &base);
let nid = NodeId::parse_str("n-0001").unwrap();
let outcome = recover_node(&paths, &nid, &git_bin());
assert!(
matches!(outcome, Recovery::Rejected { .. }),
"content already in expected → no fabricated completion, got {outcome:?}"
);
assert_eq!(
read_node_opt(&paths, &nid).unwrap().unwrap().status,
Status::Pending
);
}
#[test]
fn cannot_verify_leaves_transaction_pending() {
let (repo_dir, base, worker_tip) = repo_with_worker();
let repo = repo_dir.path().to_path_buf();
let home = tempfile::TempDir::new().unwrap();
let paths = fresh_run(&home, &repo);
record_txn(&paths, &base, &worker_tip, &base);
let nid = NodeId::parse_str("n-0001").unwrap();
drop(repo_dir);
assert_eq!(
recover_node(&paths, &nid, &git_bin()),
Recovery::CannotVerify
);
assert!(read_node_opt(&paths, &nid)
.unwrap()
.unwrap()
.pending_merge
.is_some());
}
}