From 3f5d49f5c477fc2f4c291ad165979ba19973f4ad Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 23 Mar 2026 14:20:24 -0400 Subject: [PATCH] Attractor spec hunks 9-11: edge selection fallback and default_max_retries rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove any-edge fallback from select_edge() in deterministic mode; random mode retains it as an enhancement over the base spec - Restrict preferred_label and suggested_next_ids matching to unconditional edges only (already applied in prior work, tests added here) - Rename default_max_retry → default_max_retries across codebase (code, docs, fixtures, skills) and change default from 3 to 0 - Update transitions.mdx to document edge selection cascade accurately Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/examples/clone-substack.mdx | 2 +- docs/examples/definition-of-done.mdx | 4 +- docs/examples/semantic-port.mdx | 2 +- docs/examples/solitaire.mdx | 2 +- docs/execution/failures.mdx | 2 +- docs/reference/dot-language.mdx | 2 +- docs/workflows/transitions.mdx | 2 +- lib/crates/fabro-graphviz/src/graph/types.rs | 12 +- lib/crates/fabro-workflows/src/engine.rs | 124 ++++++++++++------ .../fabro-workflows/tests/integration.rs | 63 +++++---- .../references/dot-language.md | 2 +- test/attractor/consensus_task.dot | 2 +- test/attractor/green_test_complex.dot | 2 +- test/attractor/green_test_moderate.dot | 2 +- test/attractor/green_test_vague.dot | 2 +- test/attractor/refactor_test_complex.dot | 2 +- test/attractor/refactor_test_moderate.dot | 2 +- test/attractor/refactor_test_vague.dot | 2 +- test/attractor/reference_template.dot | 2 +- test/attractor/semport.dot | 2 +- test/attractor/solitaire_fast.dot | 2 +- .../clone-substack/clone-substack.fabro | 2 +- .../spec-dod-multimodel.fabro | 2 +- .../definition-of-done/spec-dod.fabro | 2 +- .../semantic-port/semantic-port.fabro | 2 +- 25 files changed, 147 insertions(+), 98 deletions(-) diff --git a/docs/examples/clone-substack.mdx b/docs/examples/clone-substack.mdx index 8999b0ff7..fd9ad1799 100644 --- a/docs/examples/clone-substack.mdx +++ b/docs/examples/clone-substack.mdx @@ -43,7 +43,7 @@ infrastructure: integration tests with canned data, smoke tests with live \ gemini-2.5-flash-lite, manual test option with real models. Deployment readiness for \ GitHub to Railway validated by code review only — no live deployment execution.", rankdir=LR, - default_max_retry=3, + default_max_retries=3, retry_target="plan_fanout", fallback_retry_target="plan_fanout", model_stylesheet=" diff --git a/docs/examples/definition-of-done.mdx b/docs/examples/definition-of-done.mdx index c1df84855..ba66640d0 100644 --- a/docs/examples/definition-of-done.mdx +++ b/docs/examples/definition-of-done.mdx @@ -26,7 +26,7 @@ The simpler variant uses one model throughout, with sequential audits across mul digraph SpecDoD { graph [ goal="Satisfy every Definition of Done checkbox across both specs (unified-llm-spec.md, coding-agent-loop-spec.md). The implementation is in Rust under crates/. Do NOT modify the spec files. Only modify implementation code.", - default_max_retry="3", + default_max_retries="3", retry_target="triage", model_stylesheet=" * { model: claude-opus-4-6;} @@ -282,7 +282,7 @@ The multi-model variant applies the same audit-triage-fix-verify structure but u digraph SpecDoDMultiModel { graph [ goal="Satisfy every Definition of Done checkbox across both specs (unified-llm-spec.md, coding-agent-loop-spec.md). The implementation is in Rust under crates/. Do NOT modify the spec files. Only modify implementation code. Uses multi-model consensus: Opus 4.6 and GPT-5.2 compete on audits and planning, GPT-5.2-codex and Opus 4.6 alternate on implementation.", - default_max_retry="3", + default_max_retries="3", retry_target="triage_merge", default_fidelity="full", model_stylesheet=" diff --git a/docs/examples/semantic-port.mdx b/docs/examples/semantic-port.mdx index 5ca419e36..63543b53c 100644 --- a/docs/examples/semantic-port.mdx +++ b/docs/examples/semantic-port.mdx @@ -18,7 +18,7 @@ digraph SemanticPort { graph [ goal="Port semantic changes from upstream Python repository to our Go implementation", rankdir=LR, - default_max_retry=3, + default_max_retries=3, model_stylesheet=" * { model: claude-sonnet-4-5;} .hard { model: claude-opus-4-6; } diff --git a/docs/examples/solitaire.mdx b/docs/examples/solitaire.mdx index fb7e8a472..e2c5768e2 100644 --- a/docs/examples/solitaire.mdx +++ b/docs/examples/solitaire.mdx @@ -18,7 +18,7 @@ digraph BuildSolitaire { graph [ goal="Build a terminal-based solitaire (Klondike) game in Python", rankdir=LR, - default_max_retry=3, + default_max_retries=3, retry_target="impl_setup", fallback_retry_target="impl_logic", model_stylesheet=" diff --git a/docs/execution/failures.mdx b/docs/execution/failures.mdx index 801612bf4..d820951f7 100644 --- a/docs/execution/failures.mdx +++ b/docs/execution/failures.mdx @@ -102,7 +102,7 @@ The engine resolves retry configuration in this order: 1. Node attribute `retry_policy` — named preset 2. Node attribute `max_retries` — count only, default backoff -3. Graph attribute `default_max_retry` — applies to all nodes without explicit config (default: **3**) +3. Graph attribute `default_max_retries` — applies to all nodes without explicit config (default: **3**) #### What gets retried diff --git a/docs/reference/dot-language.mdx b/docs/reference/dot-language.mdx index 1c5b78c66..6962bcb90 100644 --- a/docs/reference/dot-language.mdx +++ b/docs/reference/dot-language.mdx @@ -76,7 +76,7 @@ rankdir=LR | `goal` | String | Workflow objective — guides agent behavior and retrospectives | | `rankdir` | Identifier | Layout direction: `LR` (left-to-right) or `TB` (top-to-bottom) | | `model_stylesheet` | String | CSS-like rules for model assignment (see [Model Stylesheets](/workflows/stylesheets)) | -| `default_max_retry` | Integer | Default retry count for all nodes (default: 3) | +| `default_max_retries` | Integer | Default retry count for all nodes (default: 3) | | `retry_target` | String | Default node ID to jump to on retry | | `fallback_retry_target` | String | Fallback retry target if primary target fails | | `default_fidelity` | String | Default [fidelity level](/execution/context) for all nodes | diff --git a/docs/workflows/transitions.mdx b/docs/workflows/transitions.mdx index d41e011fa..44c58eb62 100644 --- a/docs/workflows/transitions.mdx +++ b/docs/workflows/transitions.mdx @@ -167,7 +167,7 @@ picker -> path_a [weight=3] picker -> path_b [weight=1] ``` -In this example, `path_a` is chosen ~75% of the time and `path_b` ~25%. Edges with weight ≤ 0 are treated as weight 1. The cascade priority (conditions → preferred label → suggested next → unconditional → fallback) is unchanged — randomness only affects the pick-one-from-candidates step within each tier. +In this example, `path_a` is chosen ~75% of the time and `path_b` ~25%. Edges with weight ≤ 0 are treated as weight 1. The cascade priority (conditions → preferred label → suggested next → unconditional) is unchanged — randomness only affects the pick-one-from-candidates step within each tier. `selection="random"` cannot be combined with conditional edges on the same node. Validation rejects this combination because condition evaluation order would conflict with random selection. Use unconditional edges with weights instead. diff --git a/lib/crates/fabro-graphviz/src/graph/types.rs b/lib/crates/fabro-graphviz/src/graph/types.rs index 5da77102b..5d9729c18 100644 --- a/lib/crates/fabro-graphviz/src/graph/types.rs +++ b/lib/crates/fabro-graphviz/src/graph/types.rs @@ -389,12 +389,12 @@ impl Graph { .unwrap_or("") } - /// Graph-level `default_max_retry` (default 3). - pub fn default_max_retry(&self) -> i64 { + /// Graph-level `default_max_retries` (default 0). + pub fn default_max_retries(&self) -> i64 { self.attrs - .get("default_max_retry") + .get("default_max_retries") .and_then(AttrValue::as_i64) - .unwrap_or(3) + .unwrap_or(0) } /// Graph-level `retry_target`. @@ -720,9 +720,9 @@ mod tests { } #[test] - fn graph_default_max_retry() { + fn graph_default_max_retries() { let g = Graph::new("empty"); - assert_eq!(g.default_max_retry(), 3); + assert_eq!(g.default_max_retries(), 0); } #[test] diff --git a/lib/crates/fabro-workflows/src/engine.rs b/lib/crates/fabro-workflows/src/engine.rs index 355b32faa..4367126d3 100644 --- a/lib/crates/fabro-workflows/src/engine.rs +++ b/lib/crates/fabro-workflows/src/engine.rs @@ -206,7 +206,7 @@ fn build_retry_policy(node: &Node, graph: &Graph) -> RetryPolicy { } let max_retries = node .max_retries() - .unwrap_or_else(|| graph.default_max_retry()); + .unwrap_or_else(|| graph.default_max_retries()); // max_retries=0 means 1 attempt (no retries) let max_attempts = u32::try_from(max_retries + 1).unwrap_or(1).max(1); RetryPolicy { @@ -482,25 +482,27 @@ pub fn select_edge<'a>( }); } - // Step 2: Preferred label match + // Step 2: Preferred label match (unconditional edges only) if let Some(pref) = &outcome.preferred_label { let normalized_pref = normalize_label(pref); for edge in &edges { - if let Some(label) = edge.label() { - if normalize_label(label) == normalized_pref { - return Some(EdgeSelection { - edge, - reason: "preferred_label", - }); + if edge.condition().is_none_or(str::is_empty) { + if let Some(label) = edge.label() { + if normalize_label(label) == normalized_pref { + return Some(EdgeSelection { + edge, + reason: "preferred_label", + }); + } } } } } - // Step 3: Suggested next IDs + // Step 3: Suggested next IDs (unconditional edges only) for suggested_id in &outcome.suggested_next_ids { for edge in &edges { - if edge.to == *suggested_id { + if edge.condition().is_none_or(str::is_empty) && edge.to == *suggested_id { return Some(EdgeSelection { edge, reason: "suggested_next", @@ -526,11 +528,7 @@ pub fn select_edge<'a>( }); } - // Fallback: any edge - pick_edge(&edges, selection).map(|edge| EdgeSelection { - edge, - reason: "fallback", - }) + None } // --- Goal gate enforcement --- @@ -2324,7 +2322,7 @@ impl WorkflowRunEngine { ) .await; - return Err(error); + return Ok((error.to_fail_outcome(), context)); } break; } @@ -2394,10 +2392,8 @@ impl WorkflowRunEngine { } }; - let last_outcome = node_outcomes - .get(completed_nodes.last().unwrap_or(&String::new())) - .cloned() - .unwrap_or_else(Outcome::success); + let mut last_outcome = Outcome::success(); + last_outcome.notes = Some("Pipeline completed".to_string()); let run_usage: Option = node_outcomes .values() @@ -2623,17 +2619,17 @@ mod tests { let mut graph = Graph::new("test"); graph .attrs - .insert("default_max_retry".to_string(), AttrValue::Integer(2)); + .insert("default_max_retries".to_string(), AttrValue::Integer(2)); let policy = build_retry_policy(&node, &graph); assert_eq!(policy.max_attempts, 3); // 2 retries + 1 initial } #[test] - fn build_retry_policy_no_attrs_uses_graph_default_3() { + fn build_retry_policy_no_attrs_uses_graph_default_0() { let node = Node::new("n"); let graph = Graph::new("test"); let policy = build_retry_policy(&node, &graph); - assert_eq!(policy.max_attempts, 4); // default_max_retry=3 + 1 + assert_eq!(policy.max_attempts, 1); // default_max_retries=0 + 1 } #[test] @@ -2717,8 +2713,8 @@ mod tests { ); let graph = Graph::new("test"); let policy = build_retry_policy(&node, &graph); - // Unknown preset should fall back to graph default_max_retry=3 - assert_eq!(policy.max_attempts, 4); + // Unknown preset should fall back to graph default_max_retries=0 + assert_eq!(policy.max_attempts, 1); } // --- normalize_label tests --- @@ -3044,6 +3040,44 @@ mod tests { assert_eq!(sel.reason, "condition"); } + #[test] + fn select_edge_deterministic_no_fallback_when_no_condition_matches() { + let mut e1 = Edge::new("a", "path1"); + e1.attrs.insert( + "condition".to_string(), + AttrValue::String("outcome=fail".to_string()), + ); + let mut e2 = Edge::new("a", "path2"); + e2.attrs.insert( + "condition".to_string(), + AttrValue::String("outcome=error".to_string()), + ); + let g = make_graph_with_edges(vec![e1, e2]); + let node = g.nodes.get("a").unwrap(); + let outcome = Outcome::success(); + let context = Context::new(); + assert!(select_edge(node, &outcome, &context, &g, "deterministic").is_none()); + } + + #[test] + fn select_edge_random_no_fallback_when_no_condition_matches() { + let mut e1 = Edge::new("a", "path1"); + e1.attrs.insert( + "condition".to_string(), + AttrValue::String("outcome=fail".to_string()), + ); + let mut e2 = Edge::new("a", "path2"); + e2.attrs.insert( + "condition".to_string(), + AttrValue::String("outcome=error".to_string()), + ); + let g = make_graph_with_edges(vec![e1, e2]); + let node = g.nodes.get("a").unwrap(); + let outcome = Outcome::success(); + let context = Context::new(); + assert!(select_edge(node, &outcome, &context, &g, "random").is_none()); + } + // --- check_goal_gates tests --- #[test] @@ -4006,11 +4040,15 @@ mod tests { }; let outcome = engine.run(&g, &config).await.unwrap(); + // Pipeline outcome is always SUCCESS when goal gates are satisfied assert_eq!(outcome.status, StageStatus::Success); - assert_eq!( - outcome.notes.as_deref(), - Some("auto-status: handler completed without writing status") - ); + assert_eq!(outcome.notes.as_deref(), Some("Pipeline completed")); + + // The auto_status note is on the per-node status.json + let status_path = dir.path().join("nodes").join("work").join("status.json"); + let status: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&status_path).unwrap()).unwrap(); + assert_eq!(status["status"], "success"); } #[tokio::test] @@ -4265,11 +4303,15 @@ mod tests { }; let outcome = engine.run(&g, &config).await.unwrap(); + // Pipeline outcome is always SUCCESS when goal gates are satisfied assert_eq!(outcome.status, StageStatus::Success); - assert_eq!( - outcome.notes.as_deref(), - Some("auto-status: handler completed without writing status") - ); + assert_eq!(outcome.notes.as_deref(), Some("Pipeline completed")); + + // The auto_status note is on the per-node status.json + let status_path = dir.path().join("nodes").join("work").join("status.json"); + let status: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&status_path).unwrap()).unwrap(); + assert_eq!(status["status"], "success"); } // --- Gap #15: Interviewer.inform() tests --- @@ -4429,7 +4471,7 @@ mod tests { .insert("goal".to_string(), AttrValue::String("loop".to_string())); // Disable default retries to keep test fast g.attrs - .insert("default_max_retry".to_string(), AttrValue::Integer(0)); + .insert("default_max_retries".to_string(), AttrValue::Integer(0)); let mut start = Node::new("start"); start.attrs.insert( @@ -4771,7 +4813,7 @@ mod tests { workflow_slug: None, }; - // The engine returns Err because the Fail outcome has no outgoing fail edge, + // The engine returns a Fail outcome because there is no outgoing fail edge, // but panic.txt should already be written by the panic handler. let _result = engine.run(&g, &config).await; @@ -4865,7 +4907,7 @@ mod tests { g.attrs .insert("goal".to_string(), AttrValue::String("test".to_string())); g.attrs - .insert("default_max_retry".to_string(), AttrValue::Integer(0)); + .insert("default_max_retries".to_string(), AttrValue::Integer(0)); let mut start = Node::new("start"); start.attrs.insert( @@ -5085,7 +5127,7 @@ mod tests { g.attrs .insert("goal".to_string(), AttrValue::String("test".to_string())); g.attrs - .insert("default_max_retry".to_string(), AttrValue::Integer(0)); + .insert("default_max_retries".to_string(), AttrValue::Integer(0)); g.attrs .insert("max_node_visits".to_string(), AttrValue::Integer(100)); @@ -5204,7 +5246,7 @@ mod tests { AttrValue::Duration(Duration::from_millis(50)), ); g.attrs - .insert("default_max_retry".to_string(), AttrValue::Integer(0)); + .insert("default_max_retries".to_string(), AttrValue::Integer(0)); let mut start = Node::new("start"); start.attrs.insert( @@ -5270,7 +5312,7 @@ mod tests { AttrValue::Duration(Duration::from_millis(100)), ); g.attrs - .insert("default_max_retry".to_string(), AttrValue::Integer(0)); + .insert("default_max_retries".to_string(), AttrValue::Integer(0)); let mut start = Node::new("start"); start.attrs.insert( @@ -5339,7 +5381,7 @@ mod tests { AttrValue::Duration(Duration::ZERO), ); g.attrs - .insert("default_max_retry".to_string(), AttrValue::Integer(0)); + .insert("default_max_retries".to_string(), AttrValue::Integer(0)); let mut start = Node::new("start"); start.attrs.insert( @@ -5397,7 +5439,7 @@ mod tests { g.attrs .insert("goal".to_string(), AttrValue::String("test".to_string())); g.attrs - .insert("default_max_retry".to_string(), AttrValue::Integer(0)); + .insert("default_max_retries".to_string(), AttrValue::Integer(0)); let mut start = Node::new("start"); start.attrs.insert( diff --git a/lib/crates/fabro-workflows/tests/integration.rs b/lib/crates/fabro-workflows/tests/integration.rs index 72028512d..c3c58db18 100644 --- a/lib/crates/fabro-workflows/tests/integration.rs +++ b/lib/crates/fabro-workflows/tests/integration.rs @@ -586,15 +586,21 @@ async fn human_gate_aborted_input_fails_closed_without_fail_route() { workflow_slug: None, }; - let error = engine + let outcome = engine .run(&graph, &config) .await - .expect_err("aborted human gate should fail closed"); + .expect("engine should return Ok with fail outcome"); + assert_eq!( + outcome.status, + StageStatus::Fail, + "aborted human gate should fail closed" + ); assert!( - error - .to_string() - .contains("stage gate failed with no outgoing fail edge"), - "unexpected error: {error}" + outcome + .failure_reason() + .unwrap_or("") + .contains("no outgoing fail edge"), + "unexpected outcome: {outcome:?}" ); let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap(); @@ -3206,8 +3212,8 @@ async fn manager_loop_max_cycles_exceeded_e2e() { .failure_reason() .unwrap() .contains("Max cycles")); - // Overall pipeline outcome is from last completed node (manager) = Fail - assert_eq!(outcome.status, StageStatus::Fail); + // Pipeline reached exit with goal gates satisfied — per spec, SUCCESS. + assert_eq!(outcome.status, StageStatus::Success); } // =========================================================================== @@ -7833,8 +7839,8 @@ async fn hook_stage_start_skip_bypasses_node() { let config = make_run_config(dir.path()); let outcome = engine.run(&graph, &config).await.unwrap(); - // When the only work node is skipped, the final outcome reflects that - assert_eq!(outcome.status, StageStatus::Skipped); + // Pipeline reached exit with goal gates satisfied — per spec, SUCCESS. + assert_eq!(outcome.status, StageStatus::Success); // response.md should NOT exist for the work node (it was skipped) assert!( @@ -7889,8 +7895,8 @@ async fn hook_stage_start_matcher_filters_by_node_id() { let config = make_run_config(dir.path()); let outcome = engine.run(&graph, &config).await.unwrap(); - // step2 is the last completed node and was skipped - assert_eq!(outcome.status, StageStatus::Skipped); + // Pipeline reached exit with goal gates satisfied — per spec, SUCCESS. + assert_eq!(outcome.status, StageStatus::Success); // step1 should have executed (response.md exists) assert!( @@ -8419,8 +8425,8 @@ async fn hook_matcher_regex_pattern() { let config = make_run_config(dir.path()); let outcome = engine.run(&graph, &config).await.unwrap(); - // Both step nodes were skipped, so the last outcome is Skipped - assert_eq!(outcome.status, StageStatus::Skipped); + // Pipeline reached exit with goal gates satisfied — per spec, SUCCESS. + assert_eq!(outcome.status, StageStatus::Success); // Both step1 and step2 should be skipped assert!( @@ -11709,7 +11715,7 @@ fn circuit_breaker_self_loop_graph(signature_limit: Option) -> Graph { let mut graph = make_graph_with_start_exit("CircuitBreakerSelfLoop"); graph .attrs - .insert("default_max_retry".to_string(), AttrValue::Integer(0)); + .insert("default_max_retries".to_string(), AttrValue::Integer(0)); // High visit limit so the circuit breaker fires first graph .attrs @@ -11752,7 +11758,7 @@ fn circuit_breaker_restart_graph(signature_limit: Option) -> Graph { let mut graph = make_graph_with_start_exit("CircuitBreakerRestart"); graph .attrs - .insert("default_max_retry".to_string(), AttrValue::Integer(0)); + .insert("default_max_retries".to_string(), AttrValue::Integer(0)); graph .attrs .insert("max_node_visits".to_string(), AttrValue::Integer(100)); @@ -12150,7 +12156,7 @@ async fn e2e_failure_signature_persisted_in_context() { let mut graph = make_graph_with_start_exit("SignatureContextTest"); graph .attrs - .insert("default_max_retry".to_string(), AttrValue::Integer(0)); + .insert("default_max_retries".to_string(), AttrValue::Integer(0)); let mut work = Node::new("work"); work.attrs.insert( @@ -12194,9 +12200,9 @@ async fn e2e_failure_signature_persisted_in_context() { }; let outcome = engine.run(&graph, &config).await.unwrap(); - // Pipeline reaches exit (terminal), last completed node is "work" (Fail). - // The engine doesn't execute exit handlers, just breaks on terminal nodes. - assert_eq!(outcome.status, StageStatus::Fail); + // Pipeline reaches exit (terminal) with goal gates satisfied. + // Per spec, reaching exit with satisfied goal gates returns SUCCESS. + assert_eq!(outcome.status, StageStatus::Success); // Verify checkpoint has failure_signature in context let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap(); @@ -12223,7 +12229,7 @@ async fn e2e_failure_signature_hint_overrides_reason_in_context() { let mut graph = make_graph_with_start_exit("SignatureHintTest"); graph .attrs - .insert("default_max_retry".to_string(), AttrValue::Integer(0)); + .insert("default_max_retries".to_string(), AttrValue::Integer(0)); let mut work = Node::new("work"); work.attrs.insert( @@ -12558,7 +12564,7 @@ async fn e2e_circuit_breaker_multi_stage_impl_verify_cycle() { let mut graph = make_graph_with_start_exit("ImplVerifyCycle"); graph .attrs - .insert("default_max_retry".to_string(), AttrValue::Integer(0)); + .insert("default_max_retries".to_string(), AttrValue::Integer(0)); graph .attrs .insert("max_node_visits".to_string(), AttrValue::Integer(100)); @@ -13026,7 +13032,7 @@ impl Handler for KeepaliveHandler { async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() { // Parse a DOT graph with stall_timeout set to 200ms let dot = r#"digraph StallTest { - graph [goal="Test stall watchdog", stall_timeout="50ms", default_max_retry=0] + graph [goal="Test stall watchdog", stall_timeout="50ms", default_max_retries=0] start [shape=Mdiamond] work [type="hanging", label="Work"] exit [shape=Msquare] @@ -13095,7 +13101,7 @@ async fn e2e_stall_watchdog_kept_alive_by_handler_events() { // Parse a DOT graph with stall_timeout 200ms, but the handler emits events // every 100ms for 500ms total — the watchdog should NOT trigger. let dot = r#"digraph StallAliveTest { - graph [goal="Test stall keepalive", stall_timeout="100ms", default_max_retry=0] + graph [goal="Test stall keepalive", stall_timeout="100ms", default_max_retries=0] start [shape=Mdiamond] work [type="keepalive", label="Work"] exit [shape=Msquare] @@ -13148,7 +13154,7 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() { // Parse a DOT graph with stall_timeout="0s" — watchdog should be disabled, // and a short sleep handler should complete successfully. let dot = r#"digraph StallDisabledTest { - graph [goal="Test stall disabled", stall_timeout="0s", default_max_retry=0] + graph [goal="Test stall disabled", stall_timeout="0s", default_max_retries=0] start [shape=Mdiamond] work [type="slow", label="Work"] exit [shape=Msquare] @@ -13220,7 +13226,7 @@ async fn e2e_stall_watchdog_with_explicit_timeout_override() { // A short stall_timeout of 50ms should trigger faster than the default 1800s. // This tests that the graph attribute is actually respected. let dot = r#"digraph StallOverrideTest { - graph [goal="Test stall override", stall_timeout="50ms", default_max_retry=0] + graph [goal="Test stall override", stall_timeout="50ms", default_max_retries=0] start [shape=Mdiamond] work [type="hanging", label="Work"] exit [shape=Msquare] @@ -13515,8 +13521,9 @@ async fn asset_collection_local_sandbox_on_failure() { .run(&graph, &config) .await .expect("run should succeed"); - // The pipeline completes (handler returned Fail, not an error), but assets should still be collected - assert_eq!(outcome.status, StageStatus::Fail); + // The pipeline completes with goal gates satisfied — per spec, SUCCESS at exit node. + // Assets should still be collected regardless of intermediate node failures. + assert_eq!(outcome.status, StageStatus::Success); let assets_dir = run_dir .path() diff --git a/skills/fabro-create-workflow/references/dot-language.md b/skills/fabro-create-workflow/references/dot-language.md index d4f6cbaa3..3830e1ec0 100644 --- a/skills/fabro-create-workflow/references/dot-language.md +++ b/skills/fabro-create-workflow/references/dot-language.md @@ -45,7 +45,7 @@ Comments: `//` line and `/* */` block. | `goal` | String | Workflow objective (required) | | `rankdir` | Identifier | Layout: `LR` or `TB` | | `model_stylesheet` | String | CSS-like model assignment rules | -| `default_max_retry` | Integer | Default retry count for all nodes (default: 3) | +| `default_max_retries` | Integer | Default retry count for all nodes (default: 3) | | `retry_target` | String | Default node to jump to on retry | | `fallback_retry_target` | String | Fallback retry target | | `default_fidelity` | String | Default fidelity for all nodes | diff --git a/test/attractor/consensus_task.dot b/test/attractor/consensus_task.dot index 4a228268f..37d22f0b2 100644 --- a/test/attractor/consensus_task.dot +++ b/test/attractor/consensus_task.dot @@ -5,7 +5,7 @@ digraph Workflow { rankdir="LR", context_fidelity_default="truncate", context_thread_default="consensus-task", - default_max_retry="3", + default_max_retries="3", retry_target="CheckDoD", fallback_retry_target="Start" ]; diff --git a/test/attractor/green_test_complex.dot b/test/attractor/green_test_complex.dot index a42757c87..f74a62a66 100644 --- a/test/attractor/green_test_complex.dot +++ b/test/attractor/green_test_complex.dot @@ -2,7 +2,7 @@ digraph dttf { graph [ goal="Build DTTF: a bitmap-to-TrueType font converter with custom quadratic Bezier tracer", rankdir=LR, - default_max_retry=3, + default_max_retries=3, retry_target="impl_setup", model_stylesheet=" * { model: gpt-5.2-codex;reasoning_effort: medium; } diff --git a/test/attractor/green_test_moderate.dot b/test/attractor/green_test_moderate.dot index d54fe3be3..a8b1e38fb 100644 --- a/test/attractor/green_test_moderate.dot +++ b/test/attractor/green_test_moderate.dot @@ -2,7 +2,7 @@ digraph linkcheck { graph [ goal="Build a Go CLI tool that crawls URLs, checks links for HTTP status, and reports broken links with robots.txt support", rankdir=LR, - default_max_retry=3, + default_max_retries=3, retry_target="impl_setup", model_stylesheet=" * { model: gpt-5.2-codex;reasoning_effort: medium; } diff --git a/test/attractor/green_test_vague.dot b/test/attractor/green_test_vague.dot index aaf0747d4..18de36274 100644 --- a/test/attractor/green_test_vague.dot +++ b/test/attractor/green_test_vague.dot @@ -2,7 +2,7 @@ digraph solitaire { graph [ goal="Build a terminal-based Klondike Solitaire game in Go with TUI", rankdir=LR, - default_max_retry=3, + default_max_retries=3, retry_target="impl_setup", model_stylesheet=" * { model: gpt-5.2-codex;reasoning_effort: medium; } diff --git a/test/attractor/refactor_test_complex.dot b/test/attractor/refactor_test_complex.dot index e99f113bc..37cb950aa 100644 --- a/test/attractor/refactor_test_complex.dot +++ b/test/attractor/refactor_test_complex.dot @@ -2,7 +2,7 @@ digraph dttf { graph [ goal="Build DTTF: a tool that converts bitmap glyph images to valid TrueType fonts", rankdir=LR, - default_max_retry=3, + default_max_retries=3, retry_target="impl_setup", fallback_retry_target="impl_loader", model_stylesheet=" diff --git a/test/attractor/refactor_test_moderate.dot b/test/attractor/refactor_test_moderate.dot index 35380461b..5ccba1afd 100644 --- a/test/attractor/refactor_test_moderate.dot +++ b/test/attractor/refactor_test_moderate.dot @@ -2,7 +2,7 @@ digraph linkcheck { graph [ goal="Build a Go CLI tool that checks URLs for broken links with configurable depth and multiple output formats", rankdir=LR, - default_max_retry=3, + default_max_retries=3, retry_target="impl_setup", fallback_retry_target="impl_crawler", model_stylesheet=" diff --git a/test/attractor/refactor_test_vague.dot b/test/attractor/refactor_test_vague.dot index 2a00a559f..25feb7b7a 100644 --- a/test/attractor/refactor_test_vague.dot +++ b/test/attractor/refactor_test_vague.dot @@ -2,7 +2,7 @@ digraph solitaire { graph [ goal="Build a terminal-based solitaire (Klondike) game", rankdir=LR, - default_max_retry=3, + default_max_retries=3, retry_target="impl_setup", fallback_retry_target="impl_game_logic", model_stylesheet=" diff --git a/test/attractor/reference_template.dot b/test/attractor/reference_template.dot index 5186539cb..c8bee0ca2 100644 --- a/test/attractor/reference_template.dot +++ b/test/attractor/reference_template.dot @@ -2,7 +2,7 @@ digraph reference_template { graph [ goal="$goal", rankdir=LR, - default_max_retry=3, + default_max_retries=3, retry_target="implement", fallback_retry_target="debate_consolidate", provenance_version="1", diff --git a/test/attractor/semport.dot b/test/attractor/semport.dot index bb7137454..9a18175c9 100644 --- a/test/attractor/semport.dot +++ b/test/attractor/semport.dot @@ -1,5 +1,5 @@ digraph Workflow { - graph [ label="Semantic Port Tracking Loop", goal="We want to intelligently track and port semantic changes from the upstream openai-agents-python repository to our Go implementation.\n\nWe want to fetch the latest commits from inspiration/openai-agents-python, analyze each new commit for semantic changes (not just syntax), and intelligently coalesce/merge those changes into our Go codebase while respecting Go idioms and our existing architecture.\n\nWe want to track the disposition of each upstream commit in semport/ledger.tsv with three states: 'new' (unprocessed), 'implemented' (changes made), or 'acknowledged' (reviewed but no changes needed).\n\nWe want to make sure we are surgical in this monorepo and follow pre-existing coding conventions and standards.", rankdir="LR", context_fidelity_default="truncate", context_thread_default="semport-tracking", default_max_retry="4" ]; + graph [ label="Semantic Port Tracking Loop", goal="We want to intelligently track and port semantic changes from the upstream openai-agents-python repository to our Go implementation.\n\nWe want to fetch the latest commits from inspiration/openai-agents-python, analyze each new commit for semantic changes (not just syntax), and intelligently coalesce/merge those changes into our Go codebase while respecting Go idioms and our existing architecture.\n\nWe want to track the disposition of each upstream commit in semport/ledger.tsv with three states: 'new' (unprocessed), 'implemented' (changes made), or 'acknowledged' (reviewed but no changes needed).\n\nWe want to make sure we are surgical in this monorepo and follow pre-existing coding conventions and standards.", rankdir="LR", context_fidelity_default="truncate", context_thread_default="semport-tracking", default_max_retries="4" ]; FinalizeAndUpdateLedger [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="6) Finalize & update ledger", model="gpt-5.2-codex", llm_prompt="**Finalize implementation and update ledger in one step.**\\n\\n1. Synthesize the port plan from .ai/semport_plan_sonnet.md and implementation results into .ai/semport_implementation_summary.md. List which upstream commits were processed, what changes were made (with file:line references), and the disposition ('implemented').\\n\\n2. Update the ledger using:\\n```\\npython3 semport/ledger.py update implemented\\npython3 semport/ledger.py sort\\n```\\n\\n3. Verify with `python3 semport/ledger.py stats` to see progress.\\n\\n4. **Commit all changes** (implementation + ledger update) with a clear message:\\n ```\\n git add -A\\n git commit -m \"semport: implement - \"\\n ```\\n Example: `git commit -m \"semport: implement a776d80 - nest handoff history by default\"`\\n\\nKeep our goal $goal in mind. Then loop back to process the next commit.", margin="0.1,0.08", max_agent_turns="8", node_type="stack.observe", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="1200"]; diff --git a/test/attractor/solitaire_fast.dot b/test/attractor/solitaire_fast.dot index 96fe431e0..94ff980dd 100644 --- a/test/attractor/solitaire_fast.dot +++ b/test/attractor/solitaire_fast.dot @@ -2,7 +2,7 @@ digraph solitaire { graph [ goal="Build a terminal-based solitaire (Klondike) game", rankdir=LR, - default_max_retry=3, + default_max_retries=3, retry_target="impl_setup", fallback_retry_target="impl_game_logic", model_stylesheet=" diff --git a/test/docs/examples/clone-substack/clone-substack.fabro b/test/docs/examples/clone-substack/clone-substack.fabro index f8e07312c..545cd1c0f 100644 --- a/test/docs/examples/clone-substack/clone-substack.fabro +++ b/test/docs/examples/clone-substack/clone-substack.fabro @@ -20,7 +20,7 @@ infrastructure: integration tests with canned data, smoke tests with live \ gemini-2.5-flash-lite, manual test option with real models. Deployment readiness for \ GitHub to Railway validated by code review only — no live deployment execution.", rankdir=LR, - default_max_retry=3, + default_max_retries=3, retry_target="plan_fanout", fallback_retry_target="plan_fanout", model_stylesheet=" diff --git a/test/docs/examples/definition-of-done/spec-dod-multimodel.fabro b/test/docs/examples/definition-of-done/spec-dod-multimodel.fabro index 5dd78cf51..a283889b8 100644 --- a/test/docs/examples/definition-of-done/spec-dod-multimodel.fabro +++ b/test/docs/examples/definition-of-done/spec-dod-multimodel.fabro @@ -1,7 +1,7 @@ digraph SpecDoDMultiModel { graph [ goal="Satisfy every Definition of Done checkbox across both specs (unified-llm-spec.md, coding-agent-loop-spec.md). The implementation is in Rust under crates/. Do NOT modify the spec files. Only modify implementation code. Uses multi-model consensus: Opus 4.6 and GPT-5.2 compete on audits and planning, GPT-5.2-codex and Opus 4.6 alternate on implementation.", - default_max_retry="3", + default_max_retries="3", retry_target="triage_merge", default_fidelity="full", model_stylesheet=" diff --git a/test/docs/examples/definition-of-done/spec-dod.fabro b/test/docs/examples/definition-of-done/spec-dod.fabro index c4ba539cf..bcf78b950 100644 --- a/test/docs/examples/definition-of-done/spec-dod.fabro +++ b/test/docs/examples/definition-of-done/spec-dod.fabro @@ -1,7 +1,7 @@ digraph SpecDoD { graph [ goal="Satisfy every Definition of Done checkbox across both specs (unified-llm-spec.md, coding-agent-loop-spec.md). The implementation is in Rust under crates/. Do NOT modify the spec files. Only modify implementation code.", - default_max_retry="3", + default_max_retries="3", retry_target="triage", model_stylesheet=" * { model: claude-opus-4-6;} diff --git a/test/docs/examples/semantic-port/semantic-port.fabro b/test/docs/examples/semantic-port/semantic-port.fabro index 47c7639d2..ddbad86ed 100644 --- a/test/docs/examples/semantic-port/semantic-port.fabro +++ b/test/docs/examples/semantic-port/semantic-port.fabro @@ -2,7 +2,7 @@ digraph SemanticPort { graph [ goal="Port semantic changes from upstream Python repository to our Go implementation", rankdir=LR, - default_max_retry=3, + default_max_retries=3, model_stylesheet=" * { model: claude-sonnet-4-5;} .hard { model: claude-opus-4-6; }