//! Completion gate parsing for autonomous loop mode.
//!
//! This module parses COMPLETION_GATE blocks from Claude's output to determine
//! if the task is truly complete. The format is inspired by Ralph's autonomous
//! development framework.
//!
//! Format:
//! ```
//! <COMPLETION_GATE>
//! ready: true|false
//! reason: "explanation of completion status"
//! progress: "summary of what was accomplished"
//! blockers: ["list", "of", "blockers"] (optional, only when ready: false)
//! </COMPLETION_GATE>
//! ```
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Represents a parsed COMPLETION_GATE block from Claude's output.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompletionGate {
/// Whether the task is ready to complete.
pub ready: bool,
/// Explanation of the completion status.
pub reason: Option<String>,
/// Summary of what was accomplished.
pub progress: Option<String>,
/// List of blockers if not ready.
pub blockers: Option<Vec<String>>,
/// Any additional fields that were parsed.
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl CompletionGate {
/// Parse a COMPLETION_GATE block from text output.
///
/// Returns None if no valid COMPLETION_GATE is found.
pub fn parse(text: &str) -> Option<Self> {
// Find the COMPLETION_GATE block
let start_tag = "<COMPLETION_GATE>";
let end_tag = "</COMPLETION_GATE>";
let start_idx = text.find(start_tag)?;
let end_idx = text.find(end_tag)?;
if end_idx <= start_idx {
return None;
}
let content = &text[start_idx + start_tag.len()..end_idx];
let content = content.trim();
// Try to parse as JSON first
if content.starts_with('{') {
if let Ok(gate) = serde_json::from_str::<CompletionGate>(content) {
return Some(gate);
}
}
// Fall back to YAML-like parsing
Self::parse_yaml_like(content)
}
/// Parse a YAML-like format (key: value lines).
fn parse_yaml_like(content: &str) -> Option<Self> {
let mut gate = CompletionGate::default();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim().to_lowercase();
let value = value.trim();
match key.as_str() {
"ready" => {
gate.ready = value.to_lowercase() == "true"
|| value == "yes"
|| value == "1";
}
"reason" => {
gate.reason = Some(Self::unquote(value));
}
"progress" => {
gate.progress = Some(Self::unquote(value));
}
"blockers" => {
// Try to parse as JSON array
if let Ok(blockers) = serde_json::from_str::<Vec<String>>(value) {
gate.blockers = Some(blockers);
} else {
// Single blocker as string
gate.blockers = Some(vec![Self::unquote(value)]);
}
}
_ => {
// Store unknown fields
if let Ok(json_val) = serde_json::from_str(value) {
gate.extra.insert(key, json_val);
} else {
gate.extra.insert(
key,
serde_json::Value::String(Self::unquote(value)),
);
}
}
}
}
}
Some(gate)
}
/// Remove surrounding quotes from a string value.
fn unquote(s: &str) -> String {
let s = s.trim();
if (s.starts_with('"') && s.ends_with('"'))
|| (s.starts_with('\'') && s.ends_with('\''))
{
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}
/// Find all COMPLETION_GATE blocks in the output and return the last one.
///
/// This is useful when Claude produces multiple completion gates during
/// a long-running task, and we want to use the final status.
pub fn parse_last(text: &str) -> Option<Self> {
let end_tag = "</COMPLETION_GATE>";
let mut last_gate = None;
let mut search_start = 0;
while let Some(end_idx) = text[search_start..].find(end_tag) {
let absolute_end = search_start + end_idx + end_tag.len();
if let Some(gate) = Self::parse(&text[..absolute_end]) {
last_gate = Some(gate);
}
search_start = absolute_end;
}
last_gate
}
}
/// State tracking for the circuit breaker in autonomous loop mode.
#[derive(Debug, Clone, Default)]
pub struct CircuitBreaker {
/// Number of consecutive runs without file changes.
pub runs_without_changes: u32,
/// Threshold for opening circuit due to no changes (default: 3).
pub no_change_threshold: u32,
/// Number of consecutive runs with the same error.
pub same_error_count: u32,
/// Threshold for opening circuit due to same error (default: 5).
pub same_error_threshold: u32,
/// Last error message seen.
pub last_error: Option<String>,
/// Total number of loop iterations.
pub iteration_count: u32,
/// Maximum allowed iterations (default: 10).
pub max_iterations: u32,
/// Whether the circuit is open (task should stop).
pub is_open: bool,
/// Reason why circuit was opened.
pub open_reason: Option<String>,
}
impl CircuitBreaker {
/// Create a new circuit breaker with default thresholds.
pub fn new() -> Self {
Self {
no_change_threshold: 3,
same_error_threshold: 5,
max_iterations: 10,
..Default::default()
}
}
/// Create with custom thresholds.
pub fn with_thresholds(no_change: u32, same_error: u32, max_iterations: u32) -> Self {
Self {
no_change_threshold: no_change,
same_error_threshold: same_error,
max_iterations,
..Default::default()
}
}
/// Record a new iteration. Returns true if circuit should remain closed.
pub fn record_iteration(&mut self, had_changes: bool, error: Option<&str>) -> bool {
self.iteration_count += 1;
// Check max iterations
if self.iteration_count >= self.max_iterations {
self.is_open = true;
self.open_reason = Some(format!(
"Maximum iterations ({}) reached",
self.max_iterations
));
return false;
}
// Track file changes
if had_changes {
self.runs_without_changes = 0;
} else {
self.runs_without_changes += 1;
if self.runs_without_changes >= self.no_change_threshold {
self.is_open = true;
self.open_reason = Some(format!(
"No file changes for {} consecutive runs",
self.runs_without_changes
));
return false;
}
}
// Track errors
match (error, &self.last_error) {
(Some(err), Some(last)) if err == last => {
self.same_error_count += 1;
if self.same_error_count >= self.same_error_threshold {
self.is_open = true;
self.open_reason = Some(format!(
"Same error repeated {} times: {}",
self.same_error_count, err
));
return false;
}
}
(Some(err), _) => {
self.last_error = Some(err.to_string());
self.same_error_count = 1;
}
(None, _) => {
self.same_error_count = 0;
self.last_error = None;
}
}
true // Circuit remains closed
}
/// Check if the circuit breaker is open.
pub fn should_stop(&self) -> bool {
self.is_open
}
/// Reset the circuit breaker.
pub fn reset(&mut self) {
*self = Self::with_thresholds(
self.no_change_threshold,
self.same_error_threshold,
self.max_iterations,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_yaml_format() {
let text = r#"
Some output before
<COMPLETION_GATE>
ready: true
reason: "All tests pass"
progress: "Implemented feature X"
</COMPLETION_GATE>
More output after
"#;
let gate = CompletionGate::parse(text).unwrap();
assert!(gate.ready);
assert_eq!(gate.reason.as_deref(), Some("All tests pass"));
assert_eq!(gate.progress.as_deref(), Some("Implemented feature X"));
}
#[test]
fn test_parse_not_ready() {
let text = r#"
<COMPLETION_GATE>
ready: false
reason: "Tests are failing"
blockers: ["Fix test_foo", "Fix test_bar"]
</COMPLETION_GATE>
"#;
let gate = CompletionGate::parse(text).unwrap();
assert!(!gate.ready);
assert_eq!(gate.reason.as_deref(), Some("Tests are failing"));
assert_eq!(
gate.blockers,
Some(vec!["Fix test_foo".to_string(), "Fix test_bar".to_string()])
);
}
#[test]
fn test_parse_json_format() {
let text = r#"
<COMPLETION_GATE>
{
"ready": true,
"reason": "Done",
"progress": "All good"
}
</COMPLETION_GATE>
"#;
let gate = CompletionGate::parse(text).unwrap();
assert!(gate.ready);
assert_eq!(gate.reason.as_deref(), Some("Done"));
}
#[test]
fn test_parse_last_gate() {
let text = r#"
<COMPLETION_GATE>
ready: false
reason: "Still working"
</COMPLETION_GATE>
Some more work...
<COMPLETION_GATE>
ready: true
reason: "Finally done"
</COMPLETION_GATE>
"#;
let gate = CompletionGate::parse_last(text).unwrap();
assert!(gate.ready);
assert_eq!(gate.reason.as_deref(), Some("Finally done"));
}
#[test]
fn test_no_gate() {
let text = "No completion gate here";
assert!(CompletionGate::parse(text).is_none());
}
#[test]
fn test_circuit_breaker_max_iterations() {
let mut cb = CircuitBreaker::with_thresholds(3, 5, 5);
for _ in 0..4 {
assert!(cb.record_iteration(true, None));
}
assert!(!cb.record_iteration(true, None)); // 5th iteration should trip
assert!(cb.is_open);
assert!(cb.open_reason.as_ref().unwrap().contains("Maximum iterations"));
}
#[test]
fn test_circuit_breaker_no_changes() {
let mut cb = CircuitBreaker::with_thresholds(3, 5, 10);
assert!(cb.record_iteration(false, None)); // 1st no change
assert!(cb.record_iteration(false, None)); // 2nd no change
assert!(!cb.record_iteration(false, None)); // 3rd no change - trips
assert!(cb.is_open);
assert!(cb.open_reason.as_ref().unwrap().contains("No file changes"));
}
#[test]
fn test_circuit_breaker_same_error() {
let mut cb = CircuitBreaker::with_thresholds(10, 3, 10);
let err = "Test failed";
assert!(cb.record_iteration(true, Some(err)));
assert!(cb.record_iteration(true, Some(err)));
assert!(!cb.record_iteration(true, Some(err))); // 3rd same error - trips
assert!(cb.is_open);
assert!(cb.open_reason.as_ref().unwrap().contains("Same error"));
}
#[test]
fn test_circuit_breaker_different_errors_ok() {
let mut cb = CircuitBreaker::with_thresholds(10, 3, 10);
assert!(cb.record_iteration(true, Some("error 1")));
assert!(cb.record_iteration(true, Some("error 2")));
assert!(cb.record_iteration(true, Some("error 3")));
// Different errors don't trip the circuit
assert!(!cb.is_open);
}
#[test]
fn test_circuit_breaker_changes_reset() {
let mut cb = CircuitBreaker::with_thresholds(3, 5, 10);
assert!(cb.record_iteration(false, None)); // 1 no change
assert!(cb.record_iteration(false, None)); // 2 no changes
assert!(cb.record_iteration(true, None)); // has changes - resets
assert!(cb.record_iteration(false, None)); // 1 no change again
assert!(cb.record_iteration(false, None)); // 2 no changes
// Still shouldn't trip because we had a change in between
assert!(!cb.is_open);
}
}