stash
This commit is contained in:
parent
731652d330
commit
c4facd1562
5 changed files with 475 additions and 40 deletions
131
src/config.rs
131
src/config.rs
|
|
@ -20,6 +20,14 @@ pub struct ScopeRule {
|
||||||
pub prefix: String,
|
pub prefix: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Periodic {
|
||||||
|
pub path: String, // e.g., ".loop/cleaner.md"
|
||||||
|
pub name: String, // derived from filename stem: "cleaner"
|
||||||
|
pub cadence: u32,
|
||||||
|
pub guards: Vec<String>, // from guard-after directives
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub max_tail: usize,
|
pub max_tail: usize,
|
||||||
|
|
@ -28,6 +36,9 @@ pub struct Config {
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
pub scope_rules: Vec<ScopeRule>,
|
pub scope_rules: Vec<ScopeRule>,
|
||||||
pub guards: Vec<String>,
|
pub guards: Vec<String>,
|
||||||
|
pub judge_every: Option<u32>,
|
||||||
|
pub max_judge_failures: u32,
|
||||||
|
pub periodics: Vec<Periodic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
|
@ -42,6 +53,10 @@ impl Config {
|
||||||
let mut model: Option<String> = None;
|
let mut model: Option<String> = None;
|
||||||
let mut scope_rules = Vec::new();
|
let mut scope_rules = Vec::new();
|
||||||
let mut guards = Vec::new();
|
let mut guards = Vec::new();
|
||||||
|
let mut judge_every: Option<u32> = None;
|
||||||
|
let mut max_judge_failures: u32 = 3;
|
||||||
|
let mut periodics = Vec::new();
|
||||||
|
let mut pending_guard_afters: Vec<(String, String, usize)> = Vec::new(); // (periodic_name, command, line_num)
|
||||||
for (line_num, raw_line) in content.lines().enumerate() {
|
for (line_num, raw_line) in content.lines().enumerate() {
|
||||||
// Strip comments
|
// Strip comments
|
||||||
let line = match raw_line.find('#') {
|
let line = match raw_line.find('#') {
|
||||||
|
|
@ -99,6 +114,103 @@ impl Config {
|
||||||
prefix: value.to_string(),
|
prefix: value.to_string(),
|
||||||
}),
|
}),
|
||||||
"guard" => guards.push(value.to_string()),
|
"guard" => guards.push(value.to_string()),
|
||||||
|
"judge-every" => {
|
||||||
|
let n = value.parse::<u32>().map_err(|_| {
|
||||||
|
format!(
|
||||||
|
"{}:{}: invalid judge-every value '{}'",
|
||||||
|
path.display(),
|
||||||
|
line_num + 1,
|
||||||
|
value
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"{}:{}: judge-every must be > 0",
|
||||||
|
path.display(),
|
||||||
|
line_num + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
judge_every = Some(n);
|
||||||
|
}
|
||||||
|
"max-judge-failures" => {
|
||||||
|
let n = value.parse::<u32>().map_err(|_| {
|
||||||
|
format!(
|
||||||
|
"{}:{}: invalid max-judge-failures value '{}'",
|
||||||
|
path.display(),
|
||||||
|
line_num + 1,
|
||||||
|
value
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"{}:{}: max-judge-failures must be > 0",
|
||||||
|
path.display(),
|
||||||
|
line_num + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
max_judge_failures = n;
|
||||||
|
}
|
||||||
|
"periodic" => {
|
||||||
|
// Split value at last whitespace → path + cadence
|
||||||
|
let trimmed = value.trim();
|
||||||
|
let split_pos = trimmed.rfind(char::is_whitespace).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"{}:{}: periodic requires '<path> <cadence>'",
|
||||||
|
path.display(),
|
||||||
|
line_num + 1
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let ppath = trimmed[..split_pos].trim();
|
||||||
|
let cadence_str = trimmed[split_pos..].trim();
|
||||||
|
let cadence = cadence_str.parse::<u32>().map_err(|_| {
|
||||||
|
format!(
|
||||||
|
"{}:{}: invalid periodic cadence '{}'",
|
||||||
|
path.display(),
|
||||||
|
line_num + 1,
|
||||||
|
cadence_str
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if cadence == 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"{}:{}: periodic cadence must be > 0",
|
||||||
|
path.display(),
|
||||||
|
line_num + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Derive name from filename stem
|
||||||
|
let name = Path::new(ppath)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"{}:{}: cannot derive name from periodic path '{}'",
|
||||||
|
path.display(),
|
||||||
|
line_num + 1,
|
||||||
|
ppath
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
periodics.push(Periodic {
|
||||||
|
path: ppath.to_string(),
|
||||||
|
name,
|
||||||
|
cadence,
|
||||||
|
guards: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"guard-after" => {
|
||||||
|
// Split at first whitespace → periodic name + command
|
||||||
|
let trimmed = value.trim();
|
||||||
|
let split_pos = trimmed.find(char::is_whitespace).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"{}:{}: guard-after requires '<periodic-name> <command>'",
|
||||||
|
path.display(),
|
||||||
|
line_num + 1
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let pname = trimmed[..split_pos].trim().to_string();
|
||||||
|
let cmd = trimmed[split_pos..].trim().to_string();
|
||||||
|
pending_guard_afters.push((pname, cmd, line_num + 1));
|
||||||
|
}
|
||||||
other => {
|
other => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"{}:{}: unknown directive '{}'",
|
"{}:{}: unknown directive '{}'",
|
||||||
|
|
@ -110,6 +222,22 @@ impl Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach guard-after commands to their matching periodics
|
||||||
|
for (pname, cmd, ln) in pending_guard_afters {
|
||||||
|
let found = periodics.iter_mut().find(|p| p.name == pname);
|
||||||
|
match found {
|
||||||
|
Some(p) => p.guards.push(cmd),
|
||||||
|
None => {
|
||||||
|
return Err(format!(
|
||||||
|
"{}:{}: guard-after references unknown periodic '{}'",
|
||||||
|
path.display(),
|
||||||
|
ln,
|
||||||
|
pname
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Config {
|
Ok(Config {
|
||||||
max_tail,
|
max_tail,
|
||||||
log_dir,
|
log_dir,
|
||||||
|
|
@ -117,6 +245,9 @@ impl Config {
|
||||||
model,
|
model,
|
||||||
scope_rules,
|
scope_rules,
|
||||||
guards,
|
guards,
|
||||||
|
judge_every,
|
||||||
|
max_judge_failures,
|
||||||
|
periodics,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
309
src/main.rs
309
src/main.rs
|
|
@ -14,7 +14,7 @@ use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{self, Child, Command, Stdio};
|
use std::process::{self, Child, Command, Stdio};
|
||||||
|
|
||||||
use config::{Backend, Config};
|
use config::{Backend, Config, Periodic};
|
||||||
|
|
||||||
const CONF_PATH: &str = ".loop/yoke.conf";
|
const CONF_PATH: &str = ".loop/yoke.conf";
|
||||||
const NOTES_PATH: &str = ".loop/notes.md";
|
const NOTES_PATH: &str = ".loop/notes.md";
|
||||||
|
|
@ -91,6 +91,15 @@ const GREEN: &str = "\x1b[38;5;46m";
|
||||||
const ORANGE: &str = "\x1b[38;5;208m";
|
const ORANGE: &str = "\x1b[38;5;208m";
|
||||||
const RED: &str = "\x1b[38;5;196m";
|
const RED: &str = "\x1b[38;5;196m";
|
||||||
const BLUE: &str = "\x1b[38;5;75m";
|
const BLUE: &str = "\x1b[38;5;75m";
|
||||||
|
const MAGENTA: &str = "\x1b[38;5;207m";
|
||||||
|
|
||||||
|
fn capitalize_first(s: &str) -> String {
|
||||||
|
let mut chars = s.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn log(msg: &str) {
|
fn log(msg: &str) {
|
||||||
eprintln!("{}{}[yoke]{} {}", ORANGE, BOLD, RESET, msg);
|
eprintln!("{}{}[yoke]{} {}", ORANGE, BOLD, RESET, msg);
|
||||||
|
|
@ -657,6 +666,63 @@ fn invoke_agent(runner: &mut LoopRunner, config: &Config, iteration: u32, prior_
|
||||||
invoke_process(runner, config, prompt, label, "iteration", iteration, prior_total)
|
invoke_process(runner, config, prompt, label, "iteration", iteration, prior_total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Invoke a periodic agent with its protocol file.
|
||||||
|
/// Returns true if the invocation succeeded.
|
||||||
|
fn invoke_periodic(runner: &mut LoopRunner, config: &Config, periodic: &Periodic, iteration: u32) -> bool {
|
||||||
|
let prompt = format!("Read {} and follow its instructions.", periodic.path);
|
||||||
|
let (status, _) = invoke_process(
|
||||||
|
runner, config, &prompt, &periodic.name,
|
||||||
|
&format!("periodic-{}", periodic.name), iteration, 0.0,
|
||||||
|
);
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run guard-after commands for a periodic agent.
|
||||||
|
/// Results are written to a separate file so the worker isn't confused.
|
||||||
|
fn run_periodic_guards(periodic: &Periodic, max_tail: usize) {
|
||||||
|
if periodic.guards.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let results_path = PathBuf::from(format!(".loop/periodic-{}-results.md", periodic.name));
|
||||||
|
let results = guard::run_guards(&periodic.guards, max_tail, &results_path);
|
||||||
|
|
||||||
|
// Render a compact table for periodic guard results
|
||||||
|
if !results.is_empty() {
|
||||||
|
let cmd_w = results.iter().map(|r| r.name.len()).max().unwrap_or(10).max(10);
|
||||||
|
let stat_w = 8;
|
||||||
|
let time_w = 6;
|
||||||
|
let top = format!(" ┌{}┬{}┬{}┐", "─".repeat(cmd_w + 2), "─".repeat(stat_w), "─".repeat(time_w + 1));
|
||||||
|
let bottom = format!(" └{}┴{}┴{}┘", "─".repeat(cmd_w + 2), "─".repeat(stat_w), "─".repeat(time_w + 1));
|
||||||
|
eprintln!("{}", top);
|
||||||
|
for r in &results {
|
||||||
|
let cmd_padded = format!("{:<w$}", r.name, w = cmd_w);
|
||||||
|
let status_cell = if r.skipped {
|
||||||
|
format!("{}SKIPPED {}", ORANGE, RESET)
|
||||||
|
} else if r.passed {
|
||||||
|
format!(" {} PASS {} ", GREEN, RESET)
|
||||||
|
} else {
|
||||||
|
format!(" {} FAIL {} ", RED, RESET)
|
||||||
|
};
|
||||||
|
let time_cell = if r.skipped {
|
||||||
|
format!(" - ")
|
||||||
|
} else {
|
||||||
|
let secs = r.elapsed_secs.round() as u64;
|
||||||
|
format!("{:>3}s ", secs)
|
||||||
|
};
|
||||||
|
eprintln!(" │ {} │{}│ {} │", cmd_padded, status_cell, time_cell);
|
||||||
|
}
|
||||||
|
eprintln!("{}", bottom);
|
||||||
|
|
||||||
|
let all_passed = results.iter().all(|r| r.passed);
|
||||||
|
if !all_passed {
|
||||||
|
log(&format!(
|
||||||
|
"{}WARNING: periodic '{}' guard(s) failed — results in {}{}",
|
||||||
|
ORANGE, periodic.name, format!(".loop/periodic-{}-results.md", periodic.name), RESET
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Render a full-width box-drawn banner for the judge verdict.
|
/// Render a full-width box-drawn banner for the judge verdict.
|
||||||
/// Includes the first ~2 lines of the verdict reason text.
|
/// Includes the first ~2 lines of the verdict reason text.
|
||||||
///
|
///
|
||||||
|
|
@ -1540,21 +1606,67 @@ fn init(mode: &str) -> i32 {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Outcome of a plan loop run.
|
||||||
|
enum PlanLoopOutcome {
|
||||||
|
/// STATUS: DONE + guards pass (no embedded judge).
|
||||||
|
Done,
|
||||||
|
/// STATUS: DONE + guards pass + embedded judge PASS (judge-every mode).
|
||||||
|
JudgePass,
|
||||||
|
/// Consecutive judge failures hit max-judge-failures.
|
||||||
|
JudgeBailout,
|
||||||
|
/// User interrupted (SIGINT).
|
||||||
|
Interrupt,
|
||||||
|
/// Hard error (spawn failure, missing files, etc.).
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
/// Core plan-loop runner that can be called standalone or nested inside brute.
|
/// Core plan-loop runner that can be called standalone or nested inside brute.
|
||||||
///
|
///
|
||||||
/// - `config`: already-loaded Config
|
/// - `config`: already-loaded Config
|
||||||
/// - `plan_path`: path to the plan file (e.g. PLAN_PATH or SUB_PLAN_PATH)
|
/// - `plan_path`: path to the plan file (e.g. PLAN_PATH or SUB_PLAN_PATH)
|
||||||
/// - `dry_run`: if true, skip Claude invocation (one iteration only)
|
/// - `dry_run`: if true, skip Claude invocation (one iteration only)
|
||||||
/// - `nested`: if true, running inside brute (adjusts output banners)
|
/// - `nested`: if true, running inside brute (adjusts output banners)
|
||||||
///
|
fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool) -> PlanLoopOutcome {
|
||||||
/// Returns Ok(()) on success (STATUS: DONE + guards pass), Err(i32) with exit code on failure.
|
|
||||||
fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool) -> Result<(), i32> {
|
|
||||||
// Preflight — skip guard-results clear when nested (brute handles it)
|
// Preflight — skip guard-results clear when nested (brute handles it)
|
||||||
preflight(plan_path, !nested);
|
preflight(plan_path, !nested);
|
||||||
|
|
||||||
|
// Validate judge-every requires judge.md
|
||||||
|
if config.judge_every.is_some() && !Path::new(JUDGE_PATH).exists() {
|
||||||
|
log_error("judge-every is set but .loop/judge.md not found");
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate periodic protocol paths exist and are non-empty
|
||||||
|
for periodic in &config.periodics {
|
||||||
|
let p = Path::new(&periodic.path);
|
||||||
|
if !p.exists() {
|
||||||
|
log_error(&format!(
|
||||||
|
"periodic '{}' references missing file: {}",
|
||||||
|
periodic.name, periodic.path
|
||||||
|
));
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
match fs::read_to_string(p) {
|
||||||
|
Ok(content) if content.trim().is_empty() => {
|
||||||
|
log_error(&format!(
|
||||||
|
"periodic '{}' protocol file is empty: {}",
|
||||||
|
periodic.name, periodic.path
|
||||||
|
));
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log("Preflight OK");
|
log("Preflight OK");
|
||||||
|
|
||||||
// Build protected files list dynamically based on the plan path
|
// Build protected files list dynamically based on the plan path
|
||||||
let protected: Vec<&str> = vec![PROTOCOL_PATH, plan_path, CONF_PATH];
|
let mut protected: Vec<&str> = vec![PROTOCOL_PATH, plan_path, CONF_PATH];
|
||||||
|
// Include periodic protocol paths in the protected set
|
||||||
|
let periodic_paths: Vec<String> = config.periodics.iter().map(|p| p.path.clone()).collect();
|
||||||
|
for pp in &periodic_paths {
|
||||||
|
protected.push(pp.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
// Backup protected files and create the runner (Drop handles cleanup)
|
// Backup protected files and create the runner (Drop handles cleanup)
|
||||||
let backup_dir = backup_files(&protected);
|
let backup_dir = backup_files(&protected);
|
||||||
|
|
@ -1564,10 +1676,12 @@ fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool)
|
||||||
let mut iteration: u32 = 0;
|
let mut iteration: u32 = 0;
|
||||||
let mut total_cost: f64 = 0.0;
|
let mut total_cost: f64 = 0.0;
|
||||||
|
|
||||||
|
let mut consecutive_judge_failures: u32 = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if signal::interrupted() {
|
if signal::interrupted() {
|
||||||
log("Interrupted \u{2014} shutting down");
|
log("Interrupted \u{2014} shutting down");
|
||||||
return Err(130);
|
return PlanLoopOutcome::Interrupt;
|
||||||
}
|
}
|
||||||
iteration += 1;
|
iteration += 1;
|
||||||
eprintln!();
|
eprintln!();
|
||||||
|
|
@ -1625,16 +1739,16 @@ fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool)
|
||||||
total_cost += iter_cost;
|
total_cost += iter_cost;
|
||||||
if !success {
|
if !success {
|
||||||
log_error("Claude invocation failed — aborting loop");
|
log_error("Claude invocation failed — aborting loop");
|
||||||
return Err(1);
|
return PlanLoopOutcome::Error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if signal::interrupted() {
|
if signal::interrupted() {
|
||||||
log("Interrupted \u{2014} shutting down");
|
log("Interrupted \u{2014} shutting down");
|
||||||
return Err(130);
|
return PlanLoopOutcome::Interrupt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run guards
|
// Run worker guards
|
||||||
let guards_passed = run_all_guards(config);
|
let guards_passed = run_all_guards(config);
|
||||||
|
|
||||||
if guards_passed {
|
if guards_passed {
|
||||||
|
|
@ -1642,34 +1756,139 @@ fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool)
|
||||||
"{}{}All guards passed{}",
|
"{}{}All guards passed{}",
|
||||||
GREEN, BOLD, RESET
|
GREEN, BOLD, RESET
|
||||||
));
|
));
|
||||||
|
|
||||||
if dry_run {
|
|
||||||
log("(dry-run) Guards passed \u{2014} exiting after one iteration");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_done() {
|
|
||||||
eprintln!();
|
|
||||||
eprintln!(
|
|
||||||
"{}{} STATUS: DONE and all guards pass \u{2014} loop complete {}",
|
|
||||||
GREEN, BOLD, RESET
|
|
||||||
);
|
|
||||||
eprintln!();
|
|
||||||
return Ok(());
|
|
||||||
} else {
|
|
||||||
log("Guards passed but STATUS is not DONE \u{2014} continuing");
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
log(&format!(
|
log(&format!(
|
||||||
"{}Some guards failed \u{2014} Claude will see results next iteration{}",
|
"{}Some guards failed \u{2014} Claude will see results next iteration{}",
|
||||||
ORANGE, RESET
|
ORANGE, RESET
|
||||||
));
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if dry_run {
|
if dry_run {
|
||||||
|
if guards_passed {
|
||||||
|
log("(dry-run) Guards passed \u{2014} exiting after one iteration");
|
||||||
|
return PlanLoopOutcome::Done;
|
||||||
|
} else {
|
||||||
log("(dry-run) Exiting after one iteration");
|
log("(dry-run) Exiting after one iteration");
|
||||||
return Err(1);
|
return PlanLoopOutcome::Error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fire periodic agents (if at cadence)
|
||||||
|
for periodic in &config.periodics {
|
||||||
|
if iteration % periodic.cadence == 0 {
|
||||||
|
eprintln!();
|
||||||
|
let upper_name = capitalize_first(&periodic.name);
|
||||||
|
eprintln!(
|
||||||
|
"{}{}┌─ {} {}┐{}",
|
||||||
|
BOLD, MAGENTA,
|
||||||
|
upper_name,
|
||||||
|
"─".repeat(37usize.saturating_sub(upper_name.len() + 2)),
|
||||||
|
RESET
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{}{}│ Periodic agent (iteration {:>4}) │{}",
|
||||||
|
BOLD, MAGENTA, iteration, RESET
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{}{}└────────────────────────────────────────┘{}",
|
||||||
|
BOLD, MAGENTA, RESET
|
||||||
|
);
|
||||||
|
let ok = invoke_periodic(&mut runner, config, periodic, iteration);
|
||||||
|
if !ok {
|
||||||
|
log(&format!(
|
||||||
|
"{}WARNING: periodic '{}' invocation failed — continuing{}",
|
||||||
|
ORANGE, periodic.name, RESET
|
||||||
|
));
|
||||||
|
}
|
||||||
|
run_periodic_guards(periodic, config.max_tail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Judge-every logic: embedded judge checks at configured cadence
|
||||||
|
if let Some(judge_every) = config.judge_every {
|
||||||
|
if guards_passed && is_done() {
|
||||||
|
// Always fire judge when worker signals DONE
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
"{}{}┌─ Judge (DONE) ─────────────────────────┐{}",
|
||||||
|
BOLD, BLUE, RESET
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{}{}│ Worker DONE — invoking judge │{}",
|
||||||
|
BOLD, BLUE, RESET
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{}{}└────────────────────────────────────────┘{}",
|
||||||
|
BOLD, BLUE, RESET
|
||||||
|
);
|
||||||
|
let pass = invoke_judge(&mut runner, config, iteration);
|
||||||
|
if pass {
|
||||||
|
return PlanLoopOutcome::JudgePass;
|
||||||
|
}
|
||||||
|
consecutive_judge_failures += 1;
|
||||||
|
if consecutive_judge_failures >= config.max_judge_failures {
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
"{}{} {} consecutive judge FAILs \u{2014} bailing out {}",
|
||||||
|
RED, BOLD, config.max_judge_failures, RESET
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
return PlanLoopOutcome::JudgeBailout;
|
||||||
|
}
|
||||||
|
log(&format!(
|
||||||
|
"{}Judge FAIL ({}/{}) \u{2014} resetting STATUS for retry{}",
|
||||||
|
ORANGE, consecutive_judge_failures, config.max_judge_failures, RESET
|
||||||
|
));
|
||||||
|
reset_notes_status();
|
||||||
|
continue;
|
||||||
|
} else if guards_passed && iteration % judge_every == 0 {
|
||||||
|
// Mid-loop quality checkpoint
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
"{}{}┌─ Mid-loop Judge ───────────────────────┐{}",
|
||||||
|
BOLD, BLUE, RESET
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{}{}│ Quality checkpoint (iteration {:>4}) │{}",
|
||||||
|
BOLD, BLUE, iteration, RESET
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{}{}└────────────────────────────────────────┘{}",
|
||||||
|
BOLD, BLUE, RESET
|
||||||
|
);
|
||||||
|
let pass = invoke_judge(&mut runner, config, iteration);
|
||||||
|
if pass {
|
||||||
|
consecutive_judge_failures = 0;
|
||||||
|
} else {
|
||||||
|
consecutive_judge_failures += 1;
|
||||||
|
if consecutive_judge_failures >= config.max_judge_failures {
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
"{}{} {} consecutive judge FAILs \u{2014} bailing out {}",
|
||||||
|
RED, BOLD, config.max_judge_failures, RESET
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
return PlanLoopOutcome::JudgeBailout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Either way, worker continues — verdict.md has feedback
|
||||||
|
}
|
||||||
|
// When judge-every is set, the judge handles the DONE check above.
|
||||||
|
// If we reach here without DONE, just continue the loop.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original exit check (only reached when judge-every is NOT set)
|
||||||
|
if guards_passed && is_done() {
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
"{}{} STATUS: DONE and all guards pass \u{2014} loop complete {}",
|
||||||
|
GREEN, BOLD, RESET
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
return PlanLoopOutcome::Done;
|
||||||
|
}
|
||||||
|
// Guards failed or not done — continue iterating
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1699,8 +1918,9 @@ fn run_loop(dry_run: bool) -> i32 {
|
||||||
));
|
));
|
||||||
|
|
||||||
match run_plan_loop(&config, PLAN_PATH, dry_run, false) {
|
match run_plan_loop(&config, PLAN_PATH, dry_run, false) {
|
||||||
Ok(()) => 0,
|
PlanLoopOutcome::Done | PlanLoopOutcome::JudgePass => 0,
|
||||||
Err(code) => code,
|
PlanLoopOutcome::JudgeBailout | PlanLoopOutcome::Error => 1,
|
||||||
|
PlanLoopOutcome::Interrupt => 130,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1719,9 +1939,6 @@ enum BruteResult {
|
||||||
/// Protected files for the brute runner.
|
/// Protected files for the brute runner.
|
||||||
const BRUTE_PROTECTED_FILES: &[&str] = &[PROTOCOL_PATH, JUDGE_PATH, CONF_PATH];
|
const BRUTE_PROTECTED_FILES: &[&str] = &[PROTOCOL_PATH, JUDGE_PATH, CONF_PATH];
|
||||||
|
|
||||||
/// Max consecutive judge failures before the brute loop bails out.
|
|
||||||
const MAX_JUDGE_FAILURES: u32 = 3;
|
|
||||||
|
|
||||||
fn run_brute_inner(dry_run: bool) -> BruteResult {
|
fn run_brute_inner(dry_run: bool) -> BruteResult {
|
||||||
// Load config
|
// Load config
|
||||||
let config = match Config::load(Path::new(CONF_PATH)) {
|
let config = match Config::load(Path::new(CONF_PATH)) {
|
||||||
|
|
@ -1831,12 +2048,23 @@ fn run_brute_core(config: &Config, plan_path: &str, dry_run: bool) -> BruteResul
|
||||||
} else {
|
} else {
|
||||||
log("Using plan runner as worker...");
|
log("Using plan runner as worker...");
|
||||||
match run_plan_loop(config, plan_path, false, true) {
|
match run_plan_loop(config, plan_path, false, true) {
|
||||||
Ok(()) => log("Plan runner completed successfully"),
|
PlanLoopOutcome::JudgePass => {
|
||||||
Err(130) => {
|
// Embedded judge already passed — skip standalone judge
|
||||||
|
log("Plan runner completed with embedded judge PASS");
|
||||||
|
return BruteResult::Pass;
|
||||||
|
}
|
||||||
|
PlanLoopOutcome::Done => {
|
||||||
|
// No embedded judge — fall through to standalone judge
|
||||||
|
log("Plan runner completed successfully");
|
||||||
|
}
|
||||||
|
PlanLoopOutcome::JudgeBailout => {
|
||||||
|
return BruteResult::Bailout;
|
||||||
|
}
|
||||||
|
PlanLoopOutcome::Interrupt => {
|
||||||
log("Interrupted \u{2014} shutting down");
|
log("Interrupted \u{2014} shutting down");
|
||||||
return BruteResult::Interrupt;
|
return BruteResult::Interrupt;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
PlanLoopOutcome::Error => {
|
||||||
log_error("Plan runner failed — aborting brute loop");
|
log_error("Plan runner failed — aborting brute loop");
|
||||||
return BruteResult::Error;
|
return BruteResult::Error;
|
||||||
}
|
}
|
||||||
|
|
@ -1895,12 +2123,13 @@ fn run_brute_core(config: &Config, plan_path: &str, dry_run: bool) -> BruteResul
|
||||||
}
|
}
|
||||||
|
|
||||||
consecutive_failures += 1;
|
consecutive_failures += 1;
|
||||||
|
let max_failures = config.max_judge_failures;
|
||||||
log(&format!(
|
log(&format!(
|
||||||
"{}Judge says FAIL ({}/{}) \u{2014} {}{}",
|
"{}Judge says FAIL ({}/{}) \u{2014} {}{}",
|
||||||
ORANGE,
|
ORANGE,
|
||||||
consecutive_failures,
|
consecutive_failures,
|
||||||
MAX_JUDGE_FAILURES,
|
max_failures,
|
||||||
if consecutive_failures >= MAX_JUDGE_FAILURES {
|
if consecutive_failures >= max_failures {
|
||||||
"bailing out"
|
"bailing out"
|
||||||
} else {
|
} else {
|
||||||
"worker will see verdict next iteration"
|
"worker will see verdict next iteration"
|
||||||
|
|
@ -1908,11 +2137,11 @@ fn run_brute_core(config: &Config, plan_path: &str, dry_run: bool) -> BruteResul
|
||||||
RESET
|
RESET
|
||||||
));
|
));
|
||||||
|
|
||||||
if consecutive_failures >= MAX_JUDGE_FAILURES {
|
if consecutive_failures >= max_failures {
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{}{} {} consecutive judge FAILs \u{2014} bailing out {}",
|
"{}{} {} consecutive judge FAILs \u{2014} bailing out {}",
|
||||||
RED, BOLD, MAX_JUDGE_FAILURES, RESET
|
RED, BOLD, max_failures, RESET
|
||||||
);
|
);
|
||||||
eprintln!();
|
eprintln!();
|
||||||
return BruteResult::Bailout;
|
return BruteResult::Bailout;
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,32 @@ allow .
|
||||||
# the task. Prefer a test suite or linter that validates behaviour.
|
# the task. Prefer a test suite or linter that validates behaviour.
|
||||||
|
|
||||||
# guard cargo check
|
# guard cargo check
|
||||||
|
|
||||||
|
# ── Judge cadence ─────────────────────────────────────────────────────
|
||||||
|
# By default the judge runs only after the worker signals DONE.
|
||||||
|
# Set judge-every to fire the judge as a quality checkpoint every N
|
||||||
|
# worker iterations (it still always fires on DONE too).
|
||||||
|
#
|
||||||
|
# judge-every 5
|
||||||
|
|
||||||
|
# Max consecutive judge failures before bailing out (default: 3).
|
||||||
|
# The counter resets to 0 after any passing verdict.
|
||||||
|
#
|
||||||
|
# max-judge-failures 3
|
||||||
|
|
||||||
|
# ── Periodic agents (cadence-based supplementary agents) ──────────────
|
||||||
|
# Invoke an additional agent protocol at a fixed cadence (every N
|
||||||
|
# worker iterations). Useful for code cleanup, review passes, etc.
|
||||||
|
# The agent name is derived from the filename (cleaner.md → "cleaner").
|
||||||
|
#
|
||||||
|
# periodic <protocol-path> <every-N-iterations>
|
||||||
|
#
|
||||||
|
# periodic .loop/cleaner.md 10
|
||||||
|
|
||||||
|
# Guards that run after a specific periodic agent completes. Results
|
||||||
|
# are written to .loop/periodic-<name>-results.md (separate from the
|
||||||
|
# worker's guard-results.md so the worker isn't confused).
|
||||||
|
#
|
||||||
|
# guard-after <periodic-name> <command>
|
||||||
|
#
|
||||||
|
# guard-after cleaner cargo test
|
||||||
|
|
|
||||||
|
|
@ -59,3 +59,20 @@ allow .
|
||||||
# the task. Prefer a test suite or linter that validates behaviour.
|
# the task. Prefer a test suite or linter that validates behaviour.
|
||||||
|
|
||||||
# guard cargo check
|
# guard cargo check
|
||||||
|
|
||||||
|
# ── Periodic agents (cadence-based supplementary agents) ──────────────
|
||||||
|
# Invoke an additional agent protocol at a fixed cadence (every N
|
||||||
|
# worker iterations). Useful for code cleanup, review passes, etc.
|
||||||
|
# The agent name is derived from the filename (cleaner.md → "cleaner").
|
||||||
|
#
|
||||||
|
# periodic <protocol-path> <every-N-iterations>
|
||||||
|
#
|
||||||
|
# periodic .loop/cleaner.md 10
|
||||||
|
|
||||||
|
# Guards that run after a specific periodic agent completes. Results
|
||||||
|
# are written to .loop/periodic-<name>-results.md (separate from the
|
||||||
|
# worker's guard-results.md so the worker isn't confused).
|
||||||
|
#
|
||||||
|
# guard-after <periodic-name> <command>
|
||||||
|
#
|
||||||
|
# guard-after cleaner cargo test
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,32 @@ allow .
|
||||||
# the task. Prefer a test suite or linter that validates behaviour.
|
# the task. Prefer a test suite or linter that validates behaviour.
|
||||||
|
|
||||||
# guard cargo check
|
# guard cargo check
|
||||||
|
|
||||||
|
# ── Judge cadence ─────────────────────────────────────────────────────
|
||||||
|
# By default the judge runs only after the worker signals DONE.
|
||||||
|
# Set judge-every to fire the judge as a quality checkpoint every N
|
||||||
|
# worker iterations (it still always fires on DONE too).
|
||||||
|
#
|
||||||
|
# judge-every 5
|
||||||
|
|
||||||
|
# Max consecutive judge failures before bailing out (default: 3).
|
||||||
|
# The counter resets to 0 after any passing verdict.
|
||||||
|
#
|
||||||
|
# max-judge-failures 3
|
||||||
|
|
||||||
|
# ── Periodic agents (cadence-based supplementary agents) ──────────────
|
||||||
|
# Invoke an additional agent protocol at a fixed cadence (every N
|
||||||
|
# worker iterations). Useful for code cleanup, review passes, etc.
|
||||||
|
# The agent name is derived from the filename (cleaner.md → "cleaner").
|
||||||
|
#
|
||||||
|
# periodic <protocol-path> <every-N-iterations>
|
||||||
|
#
|
||||||
|
# periodic .loop/cleaner.md 10
|
||||||
|
|
||||||
|
# Guards that run after a specific periodic agent completes. Results
|
||||||
|
# are written to .loop/periodic-<name>-results.md (separate from the
|
||||||
|
# worker's guard-results.md so the worker isn't confused).
|
||||||
|
#
|
||||||
|
# guard-after <periodic-name> <command>
|
||||||
|
#
|
||||||
|
# guard-after cleaner cargo test
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue