use serde::{Deserialize, Serialize};
use serde_json::Value;
const SCHEMA: &str = "harn.agent.repair_ledger.v1";
const MAX_FIXES: usize = 8;
const MAX_DIAGNOSTICS: usize = 12;
const MAX_TEXT_CHARS: usize = 220;
#[derive(Clone, Debug, Serialize)]
struct RepairFix {
file: String,
summary: String,
status: &'static str,
message_index: usize,
}
#[derive(Clone, Debug, Serialize)]
struct RepairLoop {
file: String,
kind: &'static str,
count: usize,
}
#[derive(Debug, Serialize)]
struct RepairLedger {
schema: &'static str,
fixes: Vec<RepairFix>,
current_diagnostics: Vec<String>,
open_loops: Vec<RepairLoop>,
}
#[derive(Deserialize)]
struct MutationReceipt {
mutation_status: MutationStatus,
changed_paths: Vec<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum MutationStatus {
Applied,
Unchanged,
NotApplied,
}
pub(super) fn append_repair_ledger_to_summary(summary: String, archived: &[Value]) -> String {
let Some(ledger) = build_repair_ledger(archived) else {
return summary;
};
let Ok(encoded) = serde_json::to_string_pretty(&ledger) else {
return summary;
};
format!("{summary}\n\n[repair-ledger {SCHEMA}]\n```json\n{encoded}\n```")
}
fn build_repair_ledger(archived: &[Value]) -> Option<RepairLedger> {
let mut fixes = Vec::new();
let mut current_diagnostics = Vec::new();
let mut verify_success_indices = Vec::new();
for (message_index, message) in archived.iter().enumerate() {
let text = message_text(message);
if text.trim().is_empty() {
continue;
}
let tool_name = tool_name(message);
let diagnostic_lines = failure_lines(&text);
let has_diagnostics = !diagnostic_lines.is_empty();
if is_verify_message(tool_name.as_deref(), &text) {
if !has_diagnostics {
if has_success_signal(&text) {
current_diagnostics.clear();
verify_success_indices.push(message_index);
}
} else {
current_diagnostics = diagnostic_lines;
}
}
if !has_diagnostics {
for file in applied_changed_paths(message) {
fixes.push(RepairFix {
summary: trim_for_ledger(&format!("Applied change to {file}")),
file,
status: "unverified",
message_index,
});
}
}
}
for fix in &mut fixes {
if verify_success_indices
.iter()
.any(|success_index| *success_index > fix.message_index)
{
fix.status = "verified-good";
}
}
let open_loops = open_loops_for(&fixes);
let fixes = bounded_fixes(fixes);
current_diagnostics.truncate(MAX_DIAGNOSTICS);
if fixes.is_empty() && current_diagnostics.is_empty() && open_loops.is_empty() {
return None;
}
Some(RepairLedger {
schema: SCHEMA,
fixes,
current_diagnostics,
open_loops,
})
}
fn bounded_fixes(mut fixes: Vec<RepairFix>) -> Vec<RepairFix> {
while fixes.len() > MAX_FIXES {
if let Some(pos) = fixes.iter().position(|fix| fix.status == "verified-good") {
fixes.remove(pos);
} else {
fixes.remove(0);
}
}
fixes
}
fn open_loops_for(fixes: &[RepairFix]) -> Vec<RepairLoop> {
let mut loops = Vec::new();
for fix in fixes.iter().filter(|fix| fix.status != "verified-good") {
if fix.file == "unknown" {
continue;
}
if loops.iter().any(|item: &RepairLoop| item.file == fix.file) {
continue;
}
let count = fixes
.iter()
.filter(|other| other.status != "verified-good" && other.file == fix.file)
.count();
if count >= 3 {
loops.push(RepairLoop {
file: fix.file.clone(),
kind: "repeated-unverified-fix",
count,
});
}
}
loops
}
fn message_text(message: &Value) -> String {
let Some(content) = message.get("content") else {
return value_text(message);
};
value_text(content)
}
fn value_text(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Array(items) => items.iter().map(value_text).collect::<Vec<_>>().join("\n"),
Value::Object(map) => ["text", "content", "body", "result", "message", "error"]
.iter()
.filter_map(|key| map.get(*key))
.map(value_text)
.collect::<Vec<_>>()
.join("\n"),
Value::Null => String::new(),
Value::Bool(_) | Value::Number(_) => value.to_string(),
}
}
fn tool_name(message: &Value) -> Option<String> {
["name", "tool_name", "tool"]
.iter()
.find_map(|key| message.get(*key).and_then(Value::as_str))
.map(|name| name.to_ascii_lowercase())
}
fn applied_changed_paths(message: &Value) -> Vec<String> {
let Some(data) = message.get("data") else {
return Vec::new();
};
let Ok(receipt) = serde_json::from_value::<MutationReceipt>(data.clone()) else {
return Vec::new();
};
if !matches!(receipt.mutation_status, MutationStatus::Applied) {
return Vec::new();
}
let mut changed_paths = Vec::new();
for path in receipt.changed_paths.iter().map(|path| path.trim()) {
if !path.is_empty() && !changed_paths.iter().any(|seen| seen == path) {
changed_paths.push(path.to_string());
}
}
changed_paths
}
fn is_verify_message(tool_name: Option<&str>, text: &str) -> bool {
let lower = text.to_ascii_lowercase();
tool_name.is_some_and(|name| {
[
"verify", "test", "check", "build", "run", "exec", "shell", "command",
]
.iter()
.any(|needle| name.contains(needle))
}) || [
"exit code",
"tests passed",
"test result",
"failed",
"error:",
"warning:",
]
.iter()
.any(|needle| lower.contains(needle))
}
fn has_success_signal(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
[
"exit code 0",
"exit status 0",
"tests passed",
"test result: ok",
"all tests passed",
"finished successfully",
"build succeeded",
]
.iter()
.any(|needle| lower.contains(needle))
}
fn failure_lines(text: &str) -> Vec<String> {
text.lines()
.filter(|line| super::compaction::is_failure_signal_line(line))
.map(trim_for_ledger)
.take(MAX_DIAGNOSTICS)
.collect()
}
fn trim_for_ledger(text: &str) -> String {
let trimmed = text.trim();
if trimmed.chars().count() <= MAX_TEXT_CHARS {
return trimmed.to_string();
}
let mut clipped = trimmed.chars().take(MAX_TEXT_CHARS).collect::<String>();
clipped.push_str("...");
clipped
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn appends_verified_repair_ledger() {
let archived = vec![
json!({
"role": "tool",
"name": "edit",
"content": "Applied edit to src/main.cpp: fixed include path",
"data": {"mutation_status": "applied", "changed_paths": ["src/main.cpp"]}
}),
json!({"role": "tool", "name": "run", "content": "All tests passed\nexit code 0"}),
];
let summary = append_repair_ledger_to_summary("base summary".to_string(), &archived);
assert!(summary.contains(SCHEMA));
assert!(summary.contains("src/main.cpp"));
assert!(summary.contains("verified-good"));
assert!(summary.contains("\"current_diagnostics\": []"));
}
#[test]
fn keeps_only_current_diagnostics_after_supersession() {
let archived = vec![
json!({"role": "tool", "name": "run", "content": "src/old.zig:1:1: error: stale failure"}),
json!({"role": "tool", "name": "run", "content": "All tests passed\nexit code 0"}),
json!({"role": "tool", "name": "run", "content": "src/new.zig:7:3: error: current failure"}),
];
let ledger = build_repair_ledger(&archived).expect("ledger");
let encoded = serde_json::to_string(&ledger).expect("json");
assert!(encoded.contains("current failure"));
assert!(!encoded.contains("stale failure"));
}
#[test]
fn leaves_summary_unchanged_without_repair_evidence() {
let archived = vec![json!({"role": "assistant", "content": "Thinking about the plan."})];
assert_eq!(
append_repair_ledger_to_summary("plain".to_string(), &archived),
"plain"
);
}
#[test]
fn rejects_edit_like_prose_without_a_mutation_receipt() {
let archived = vec![json!({
"role": "tool",
"name": "run",
"content": "prints/inspects updated after <runtime_feedback kind=\"post_turn\">"
})];
assert_eq!(
append_repair_ledger_to_summary("plain".to_string(), &archived),
"plain"
);
}
#[test]
fn uses_applied_changed_paths_instead_of_paths_from_rendered_prose() {
let archived = vec![json!({
"role": "tool",
"name": "edit",
"content": "<runtime_feedback kind=\"post_turn\">\nGuardrail prose mentions prints/inspects",
"data": {
"mutation_status": "applied",
"changed_paths": ["src/lib.rs"]
}
})];
let ledger = build_repair_ledger(&archived).expect("ledger");
assert_eq!(ledger.fixes.len(), 1);
assert_eq!(ledger.fixes[0].file, "src/lib.rs");
assert!(!ledger.fixes[0].summary.contains("runtime_feedback"));
assert!(!ledger.fixes[0].summary.contains("prints/inspects"));
}
#[test]
fn rejects_unapplied_or_malformed_mutation_receipts() {
for data in [
json!({"mutation_status": "not_applied", "changed_paths": ["src/lib.rs"]}),
json!({"mutation_status": "applied", "changed_paths": ["src/lib.rs", 7]}),
json!({"mutation_status": "applied"}),
] {
let archived = vec![json!({
"role": "tool",
"name": "edit",
"content": "Applied edit to src/lib.rs",
"data": data
})];
assert!(build_repair_ledger(&archived).is_none());
}
}
#[test]
fn bounds_fixes_and_preserves_unverified_entries() {
let archived = (0..12)
.map(|idx| {
json!({
"role": "tool",
"name": "edit",
"content": format!("Applied edit to src/file{idx}.rs"),
"data": {
"mutation_status": "applied",
"changed_paths": [format!("src/file{idx}.rs")]
}
})
})
.collect::<Vec<_>>();
let ledger = build_repair_ledger(&archived).expect("ledger");
assert_eq!(ledger.fixes.len(), MAX_FIXES);
assert!(ledger.fixes[0].file.contains("file4.rs"));
}
}