From 5c827dd3c13f83e16e8002ca901218ac2946f30e Mon Sep 17 00:00:00 2001 From: Gabe Hamilton Date: Thu, 21 May 2026 16:18:29 -0600 Subject: [PATCH] auto_gate: max_iterations is the cap, not max_iterations+1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was `next > max_iterations`. With max_iterations=3 a fresh gate would visit 4 times — 3 [F] Findings cycles plus a 4th force-pass evaluation, which was off-by-one for what users intuitively expect from "max_iterations". Concrete impact: a `findings_empty` policy that never sees a 0 count (no agent stage writes findings_count to context yet) would burn 3 full refine subprocesses before finally force-passing. With each refine taking ~5 minutes in real bootstrap runs, that's a 15-minute floor for every auto-gate the pipeline traverses. Change `>` to `>=` so the Nth visit (when max_iterations=N) force-passes if the policy still hasn't been satisfied. Adds regression test `auto_gate_at_nth_visit_with_findings_force_passes` that asserts visit 3 of a max=3 gate force-passes rather than looping. All previous tests still pass (the boundary they exercised — prior=N, max=N — still produces force-pass under both semantics). Documents the structural limitation in the handler doc comment: agent box stages have no current path to write `findings_count` to the runtime context, so for those gates the policy effectively reduces to "loop until max_iterations". Fixing that requires a separate change (parse agent stdout, or surface output_schema into context). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../forge-attractor/src/handlers/auto_gate.rs | 53 +++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/forge-attractor/src/handlers/auto_gate.rs b/crates/forge-attractor/src/handlers/auto_gate.rs index a8f925a..4c90b8f 100644 --- a/crates/forge-attractor/src/handlers/auto_gate.rs +++ b/crates/forge-attractor/src/handlers/auto_gate.rs @@ -7,8 +7,11 @@ use serde_json::Value; /// Handler for automatic gate nodes (hexagon shape with `auto_policy` attribute). /// /// Supports: -/// - `max_iterations` — hard cap on how many times the gate's loop can cycle. -/// When exceeded, the gate forces the "pass" edge. +/// - `max_iterations` — total cap on how many times this gate may evaluate. +/// `max_iterations=N` means "at most N evaluations" — the Nth evaluation +/// force-passes if the policy is still unsatisfied. So `max_iterations=3` +/// permits up to 2 findings → refine cycles, with the 3rd gate visit always +/// advancing the pipeline. /// - `auto_policy="findings_empty"` — routes to the "pass" edge when no findings /// are detected in the runtime context, otherwise routes to the "findings" edge. /// @@ -16,6 +19,13 @@ use serde_json::Value; /// /// Edge detection uses the `[P]` / `[F]` accelerator-key convention from the DOT /// edge labels (e.g. `[P] Pass`, `[F] Findings`). +/// +/// **Known limitation:** the `findings_empty` policy reads `findings_count` from +/// the runtime context, but agent-driven box stages have no built-in path to +/// write context variables — so for those stages the policy is effectively +/// "always_loop_until_max_iterations". The structural fix (parse agent stdout +/// for a `findings_count=N` line or surface it via output_schema) is tracked +/// separately; until then, set `max_iterations` low to bound wasted refines. #[derive(Debug, Default)] pub struct AutoGateHandler; @@ -50,7 +60,10 @@ impl NodeHandler for AutoGateHandler { label_is_findings(label) }); - let force_pass = next > max_iterations; + // Force-pass on the Nth evaluation when max_iterations=N, so the gate + // total visit count never exceeds max_iterations. Previously this used + // `>`, which silently permitted one extra refine cycle per gate. + let force_pass = next >= max_iterations; let policy_pass = if !force_pass { evaluate_policy(node, context) } else { @@ -229,6 +242,40 @@ mod tests { assert!(outcome.notes.unwrap().contains("max_iterations reached")); } + #[tokio::test(flavor = "current_thread")] + async fn auto_gate_at_nth_visit_with_findings_force_passes() { + // Regression: previously `force_pass = next > max_iterations` permitted + // an extra refine cycle past the cap. The 3rd visit to a max=3 gate + // with findings still present must now force-pass instead of looping. + let graph = parse_dot( + r#" + digraph G { + gate [shape=hexagon, auto_policy="findings_empty", max_iterations=3] + fix + pass + gate -> fix [label="[F] Findings"] + gate -> pass [label="[P] Pass"] + } + "#, + ) + .expect("graph should parse"); + let node = graph.nodes.get("gate").expect("gate should exist"); + let mut context = RuntimeContext::new(); + // 2 prior iterations → this is the 3rd visit, equal to the cap. + context.insert( + "gate.gate.iterations".to_string(), + Value::Number(2.into()), + ); + + let outcome = AutoGateHandler + .execute(node, &context, &graph) + .await + .expect("execution should succeed"); + + assert_eq!(outcome.suggested_next_ids, vec!["pass".to_string()]); + assert!(outcome.notes.unwrap().contains("max_iterations reached")); + } + #[tokio::test(flavor = "current_thread")] async fn auto_gate_findings_empty_with_zero_count_expected_pass() { let graph = parse_dot(