use super::reflexion::ReflectionLog;
use super::types::SafeStateMode;
pub const MAX_SAFE_STATE_ABORTS: u8 = 2;
pub const SAFE_STATE_TAG: &str = "[safe-state]";
#[derive(Debug, Default)]
pub struct SafeStateEngine {
last_green_turn: Option<String>,
aborts_used: u8,
fired_this_streak: bool,
last_green_fingerprint: Option<super::worktree_probe::TreeFingerprint>,
}
impl SafeStateEngine {
pub fn new() -> Self {
Self::default()
}
pub fn green_fingerprint(&self) -> Option<&super::worktree_probe::TreeFingerprint> {
self.last_green_fingerprint.as_ref()
}
pub fn set_green_fingerprint(&mut self, fp: Option<super::worktree_probe::TreeFingerprint>) {
self.last_green_fingerprint = fp;
}
#[allow(clippy::too_many_arguments)] pub fn decide(
&mut self,
mode: SafeStateMode,
fresh_green: bool,
due: bool,
edits_since_verify: u32,
current_turn_id: Option<&str>,
reflections: &ReflectionLog,
excerpts: &[(String, String)],
resolve_restore: impl FnOnce(&str) -> Option<usize>,
) -> Option<String> {
if mode == SafeStateMode::Off {
return None;
}
if fresh_green {
self.last_green_turn = current_turn_id.map(str::to_owned);
}
if !due {
self.fired_this_streak = false;
return None;
}
if self.aborts_used >= MAX_SAFE_STATE_ABORTS {
return None; }
if self.fired_this_streak {
return None; }
if edits_since_verify == 0 {
return None; }
let Some(green) = self.last_green_turn.as_deref() else {
return None; };
self.fired_this_streak = true;
self.aborts_used = self.aborts_used.saturating_add(1);
let restored_files = if mode == SafeStateMode::Auto {
resolve_restore(green)
} else {
None
};
Some(format_safe_state(
reflections.block().as_deref(),
excerpts,
restored_files,
))
}
}
fn format_safe_state(
reflections_block: Option<&str>,
excerpts: &[(String, String)],
restored_files: Option<usize>,
) -> String {
let mut s = format!(
"{SAFE_STATE_TAG} Repeated tool failures have left the working tree in an \
unverified state on top of the last check that passed. The current \
approach has failed. The failure ladder has run its course — an \
alternate method was tried (rung 1) and a recovery was requested \
(rung 2) — so this rung aborts the approach and asks for a fresh plan \
from the last known-good state.\n"
);
if let Some(block) = reflections_block {
s.push_str(block);
}
if !excerpts.is_empty() {
s.push_str("\n\nTools that just failed this streak — do not return to any of these:\n");
for (tool, excerpt) in excerpts {
s.push_str(&format!(" - {tool}: {excerpt}\n"));
}
}
match restored_files {
Some(n) => s.push_str(&format!(
"\nThe harness has reverted {n} file{} to that last known-good state, \
so the tree you are looking at is the one that passed. Your \
post-check edits are gone — do not try to undo them again. Propose \
ONE new approach (not a menu) that differs from everything above, \
and verify it before going further.\n",
if n == 1 { "" } else { "s" }
)),
None => s.push_str(
"\nConsider undoing the edits you've made since that last passing check \
before continuing — your own post-check changes only, not unrelated \
working-tree state — so you re-plan from a state that was known to work \
rather than on top of a broken one. Then propose ONE new approach (not a \
menu) that differs from everything above, and verify it before going \
further.\n",
),
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::agent_loop::reflexion::ReflectionLog;
#[test]
fn off_mode_is_a_no_op() {
let mut engine = SafeStateEngine::new();
let refl = ReflectionLog::new();
let msg = engine.decide(
SafeStateMode::Off,
true,
true,
1,
Some("u1"),
&refl,
&[("edit".into(), "no match".into())],
|_| None,
);
assert!(msg.is_none(), "off never emits");
assert_eq!(
engine.last_green_turn.as_deref(),
None,
"off does not stamp green"
);
assert_eq!(engine.aborts_used, 0, "off spends nothing");
}
#[test]
fn advisory_fires_when_due_mutated_and_green_seen() {
let mut engine = SafeStateEngine::new();
let refl = ReflectionLog::new();
engine.decide(
SafeStateMode::Advisory,
true,
false,
0,
Some("u1"),
&refl,
&[],
|_| None,
);
let msg = engine.decide(
SafeStateMode::Advisory,
false,
true,
1,
Some("u2"),
&refl,
&[("edit".into(), "no match".into())],
|_| None,
);
assert!(msg.is_some(), "due + mutated + green seen fires advisory");
}
#[test]
fn declines_when_green_never_seen() {
let mut engine = SafeStateEngine::new();
let refl = ReflectionLog::new();
let msg = engine.decide(
SafeStateMode::Advisory,
false,
true,
1,
Some("u2"),
&refl,
&[("edit".into(), "no match".into())],
|_| None,
);
assert!(msg.is_none(), "no green seen -> decline");
}
#[test]
fn declines_when_no_unverified_edits() {
let mut engine = SafeStateEngine::new();
let refl = ReflectionLog::new();
engine.decide(
SafeStateMode::Advisory,
true,
false,
0,
Some("u1"),
&refl,
&[],
|_| None,
);
let msg = engine.decide(
SafeStateMode::Advisory,
false,
true,
0,
Some("u2"),
&refl,
&[],
|_| None,
);
assert!(msg.is_none(), "no unverified edits -> decline");
}
#[test]
fn bounded_at_max_two_aborts() {
let mut engine = SafeStateEngine::new();
let refl = ReflectionLog::new();
engine.decide(
SafeStateMode::Advisory,
true,
false,
0,
Some("u1"),
&refl,
&[],
|_| None,
);
assert!(
engine
.decide(
SafeStateMode::Advisory,
false,
true,
1,
Some("u2"),
&refl,
&[],
|_| None,
)
.is_some()
);
engine.decide(
SafeStateMode::Advisory,
false,
false,
1,
Some("u2"),
&refl,
&[],
|_| None,
);
assert!(
engine
.decide(
SafeStateMode::Advisory,
false,
true,
1,
Some("u2"),
&refl,
&[],
|_| None,
)
.is_some()
);
assert_eq!(engine.aborts_used, 2);
engine.decide(
SafeStateMode::Advisory,
false,
false,
1,
Some("u2"),
&refl,
&[],
|_| None,
);
assert!(
engine
.decide(
SafeStateMode::Advisory,
false,
true,
1,
Some("u2"),
&refl,
&[],
|_| None,
)
.is_none(),
"hard cap declines a third abort"
);
}
#[test]
fn once_per_streak() {
let mut engine = SafeStateEngine::new();
let refl = ReflectionLog::new();
engine.decide(
SafeStateMode::Advisory,
true,
false,
0,
Some("u1"),
&refl,
&[],
|_| None,
);
assert!(
engine
.decide(
SafeStateMode::Advisory,
false,
true,
1,
Some("u2"),
&refl,
&[],
|_| None,
)
.is_some()
);
assert!(
engine
.decide(
SafeStateMode::Advisory,
false,
true,
1,
Some("u2"),
&refl,
&[],
|_| None,
)
.is_none(),
"no second abort within one streak"
);
engine.decide(
SafeStateMode::Advisory,
false,
false,
1,
Some("u2"),
&refl,
&[],
|_| None,
);
assert!(
engine
.decide(
SafeStateMode::Advisory,
false,
true,
1,
Some("u2"),
&refl,
&[],
|_| None,
)
.is_some()
);
}
#[test]
fn green_marker_stamps_only_on_fresh_green() {
let mut engine = SafeStateEngine::new();
let refl = ReflectionLog::new();
assert_eq!(
engine.last_green_turn.as_deref(),
None,
"nothing stamped before any green"
);
engine.decide(
SafeStateMode::Advisory,
true,
false,
0,
Some("u1"),
&refl,
&[],
|_| None,
);
assert_eq!(engine.last_green_turn.as_deref(), Some("u1"));
engine.decide(
SafeStateMode::Advisory,
false,
true,
1,
Some("u2"),
&refl,
&[],
|_| None,
);
assert_eq!(
engine.last_green_turn.as_deref(),
Some("u1"),
"a stale/red boundary keeps the earlier green stamp"
);
}
#[test]
fn message_carries_reflections_excerpts_and_green_turn() {
let mut engine = SafeStateEngine::new();
engine.decide(
SafeStateMode::Advisory,
true,
false,
0,
Some("u1"),
&ReflectionLog::new(),
&[],
|_| None,
);
let mut refl = ReflectionLog::new();
refl.record("edit(a.rs)");
let msg = engine
.decide(
SafeStateMode::Advisory,
false,
true,
1,
Some("u2"),
&refl,
&[("bash".into(), "command failed".into())],
|_| None,
)
.expect("fires");
assert!(msg.contains("edit(a.rs)"), "carries an abandoned approach");
assert!(
msg.contains("bash: command failed"),
"carries a failure excerpt"
);
assert!(!msg.contains("u1"), "internal turn id must not leak: {msg}");
assert!(!msg.contains("/rewind"), "names no bogus command: {msg}");
assert!(
msg.contains("undoing the edits"),
"still offers the revert in actionable terms: {msg}"
);
}
#[test]
fn message_asks_for_one_approach_not_a_menu() {
let mut engine = SafeStateEngine::new();
let refl = ReflectionLog::new();
engine.decide(
SafeStateMode::Advisory,
true,
false,
0,
Some("u1"),
&refl,
&[],
|_| None,
);
let msg = engine
.decide(
SafeStateMode::Advisory,
false,
true,
1,
Some("u2"),
&refl,
&[],
|_| None,
)
.expect("fires");
assert!(
msg.contains("ONE new approach"),
"the replan demands a single approach, not a menu"
);
}
#[test]
fn advisory_performs_no_file_write_even_when_a_clean_restore_exists() {
use crate::agent::tools::snapshots;
use crate::sync_util::LockExt;
let _g = snapshots::TEST_GATE.lock_ignore_poison();
snapshots::clear();
let dir = std::env::temp_dir().join(format!("dirge-safe-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("a.txt");
std::fs::write(&p, "v0").unwrap();
snapshots::begin_turn("u1");
snapshots::capture(&p);
std::fs::write(&p, "v1").unwrap(); snapshots::begin_turn("u2");
snapshots::capture(&p);
std::fs::write(&p, "v2").unwrap();
let mut engine = SafeStateEngine::new();
let refl = ReflectionLog::new();
engine.decide(
SafeStateMode::Advisory,
true,
false,
0,
Some("u1"),
&refl,
&[],
|_| None,
);
let msg = engine.decide(
SafeStateMode::Advisory,
false,
true,
1,
Some("u2"),
&refl,
&[("edit".into(), "no match".into())],
|_| None,
);
assert!(msg.is_some(), "advisory fires");
assert_eq!(
std::fs::read_to_string(&p).unwrap(),
"v2",
"advisory performs no file write"
);
let restored = snapshots::restore_after_green_turn("u1");
assert_eq!(restored.len(), 1, "a clean restore target exists");
assert_eq!(
std::fs::read_to_string(&p).unwrap(),
"v1",
"and it lands on green"
);
snapshots::clear();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn advisory_never_invokes_the_restore_resolver() {
let mut engine = SafeStateEngine::new();
let mut called = false;
engine.decide(
SafeStateMode::Advisory,
true,
false,
0,
Some("u1"),
&ReflectionLog::new(),
&[],
|_| {
called = true;
None
},
);
let msg = engine
.decide(
SafeStateMode::Advisory,
false,
true,
1,
Some("u2"),
&ReflectionLog::new(),
&[],
|_| {
called = true;
Some(3)
},
)
.expect("fires");
assert!(!called, "advisory must not reach the restore path");
assert!(msg.contains("Consider undoing"), "advisory wording: {msg}");
}
#[test]
fn auto_declines_to_advisory_when_coverage_is_incomplete() {
let mut engine = SafeStateEngine::new();
engine.decide(
SafeStateMode::Auto,
true,
false,
0,
Some("u1"),
&ReflectionLog::new(),
&[],
|_| None,
);
let msg = engine
.decide(
SafeStateMode::Auto,
false,
true,
1,
Some("u2"),
&ReflectionLog::new(),
&[],
|_| None, )
.expect("fires");
assert!(
msg.contains("Consider undoing"),
"a declined restore reads exactly like advisory: {msg}"
);
assert!(!msg.contains("harness has reverted"), "{msg}");
}
#[test]
fn auto_restore_replaces_the_undo_instruction() {
let mut engine = SafeStateEngine::new();
engine.decide(
SafeStateMode::Auto,
true,
false,
0,
Some("u1"),
&ReflectionLog::new(),
&[],
|_| None,
);
let msg = engine
.decide(
SafeStateMode::Auto,
false,
true,
1,
Some("u2"),
&ReflectionLog::new(),
&[],
|green| {
assert_eq!(green, "u1", "restores from the GREEN turn marker");
Some(3)
},
)
.expect("fires");
assert!(msg.contains("reverted 3 files"), "{msg}");
assert!(
!msg.contains("Consider undoing"),
"must not also tell the model to undo: {msg}"
);
assert!(msg.contains("ONE new approach"), "single-action rule holds");
}
#[test]
fn resolver_is_not_invoked_when_gates_decline() {
let mut engine = SafeStateEngine::new();
let mut called = false;
engine.decide(
SafeStateMode::Auto,
false,
true,
1,
Some("u1"),
&ReflectionLog::new(),
&[],
|_| {
called = true;
Some(1)
},
);
assert!(!called, "no green stamp must decline before restoring");
let mut engine = SafeStateEngine::new();
let mut called2 = false;
engine.decide(
SafeStateMode::Auto,
true,
false,
0,
Some("u1"),
&ReflectionLog::new(),
&[],
|_| None,
);
engine.decide(
SafeStateMode::Auto,
false,
true,
0, Some("u2"),
&ReflectionLog::new(),
&[],
|_| {
called2 = true;
Some(1)
},
);
assert!(!called2, "nothing to revert must decline before restoring");
}
#[test]
fn off_never_invokes_the_resolver() {
let mut engine = SafeStateEngine::new();
let mut called = false;
let out = engine.decide(
SafeStateMode::Off,
true,
true,
9,
Some("u1"),
&ReflectionLog::new(),
&[],
|_| {
called = true;
Some(1)
},
);
assert!(out.is_none());
assert!(!called, "off is a no-op, including the destructive path");
}
}