diff --git a/Cargo.lock b/Cargo.lock
index b5ca659e1..ae4b13ec1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2579,6 +2579,7 @@ dependencies = [
"fabro-util",
"serde",
"serde_json",
+ "strum 0.28.0",
"thiserror 2.0.18",
"tokio",
"tokio-util",
diff --git a/docs/public/changelog/2026-08-26.mdx b/docs/public/changelog/2026-08-26.mdx
new file mode 100644
index 000000000..8580d1b6b
--- /dev/null
+++ b/docs/public/changelog/2026-08-26.mdx
@@ -0,0 +1,34 @@
+---
+title: "Explicit succeed failure policy"
+date: "2026-08-26"
+---
+
+`on_failure` now accepts a third policy, `succeed`, alongside `route` and
+`exit`. A failed node with an effective `succeed` policy and no explicit
+recovery route finishes as `succeeded` and follows normal success routing.
+The original failure details stay on the `stage.completed` event and in the
+checkpoint, and the outcome's notes record the promotion.
+
+Set it on a node to mark a best-effort step inside a strict graph, or on the
+graph to apply it everywhere:
+
+```dot
+digraph Review {
+ graph [on_failure="exit"]
+ start [shape=Mdiamond]
+ exit [shape=Msquare]
+ required_check [script="./required-check"]
+ optional_scan [script="./optional-scan" on_failure="succeed"]
+
+ start -> required_check -> optional_scan -> exit
+}
+```
+
+An explicit `condition="outcome=failed"` edge still takes priority over the
+promotion. A promoted outcome satisfies goal gates, and a failed parallel
+branch with a `succeed` policy counts as succeeded in its parent's result.
+
+`auto_status=true` is now a deprecated alias for `on_failure="succeed"`.
+Existing workflows keep working, and validation reports a new
+`auto_status_deprecated` warning with the replacement. The alias no longer
+promotes `partially_succeeded` outcomes; only `failed` outcomes are affected.
diff --git a/docs/public/docs.json b/docs/public/docs.json
index 28938510c..4a02c15a2 100644
--- a/docs/public/docs.json
+++ b/docs/public/docs.json
@@ -309,6 +309,7 @@
"group": "August 2026",
"icon": "clock-rotate-left",
"pages": [
+ "changelog/2026-08-26",
"changelog/2026-08-25",
"changelog/2026-08-23",
"changelog/2026-08-21",
diff --git a/docs/public/execution/failures.mdx b/docs/public/execution/failures.mdx
index 73b81bddb..76fb6ee2c 100644
--- a/docs/public/execution/failures.mdx
+++ b/docs/public/execution/failures.mdx
@@ -66,6 +66,27 @@ Set `on_failure` on a node to control that node alone. The node-level attribute
The policy applies only to `failed`. Other outcomes keep their normal routing behavior. For parallel nodes, the policy uses the completed parallel node's final outcome. It does not stop or cancel individual branches early.
+## Treat a failed node as succeeded
+
+Set `on_failure="succeed"` on a best-effort node so its failure never blocks the workflow. This pairs well with a strict graph default:
+
+```dot title="best-effort-node.fabro"
+digraph Review {
+ graph [on_failure="exit"]
+
+ start [shape=Mdiamond]
+ exit [shape=Msquare]
+ required_check [script="./required-check"]
+ optional_scan [script="./optional-scan" on_failure="succeed"]
+
+ start -> required_check -> optional_scan -> exit
+}
+```
+
+When `optional_scan` fails, Fabro first checks explicit recovery routes with the `failed` outcome. If none match, it rewrites the outcome to `succeeded` and routes the node as a success. Retries still run first; only the final outcome changes. The original failure stays on the `stage.completed` event and in the checkpoint, and the outcome's notes record the promotion. A promoted outcome satisfies a goal gate. Setting `on_failure="succeed"` on the graph applies it to every node.
+
+`succeed` applies only to `failed`. It does not change a `partially_succeeded` outcome. `auto_status=true` is the deprecated spelling of this policy; validation warns and suggests `on_failure="succeed"`.
+
## Retry layers
Fabro retries failures at three levels: **LLM retries** handle transient API errors inside a single model call, **turn-level retries** recover from dropped streams mid-response, and **node retries** re-execute the entire node handler when the first two levels aren't enough. These layers are independent — a node retry re-runs the full handler, which gets its own fresh set of LLM and turn-level retries.
@@ -332,8 +353,8 @@ A node failure does **not** automatically terminate the run. Fabro follows this
4. **Node retries** — re-execute the entire handler (per the retry policy)
5. **Direct jump** — use `jump_to_node` when the outcome supplies one
6. **Explicit edge routing** — look for a matching condition, preferred label, or suggested next node
-7. **Failure policy** — with an effective `on_failure="exit"` (node-level `on_failure` first, then graph-level), skip the unconditional edge; with `route` or no attribute, keep normal fallback routing
-8. **Unconditional edge** — in `route` mode, use an edge without a condition as the fallback
+7. **Failure policy** — with no explicit route, apply the effective `on_failure` (node-level `on_failure` first, then graph-level): `exit` skips the unconditional edge, `succeed` promotes the outcome to `succeeded` and routes it as a success, and `route` (or no attribute) keeps normal fallback routing
+8. **Unconditional edge** — in `route` mode, or after a `succeed` promotion, use an edge without a condition as the fallback
9. **Retry target** — if no edge was selected, check `retry_target` and `fallback_retry_target` on the node, then on the graph
10. **Run failure** — if none of the above produces a path forward, the run terminates
diff --git a/docs/public/execution/outcomes.mdx b/docs/public/execution/outcomes.mdx
index 58935f3af..3e2450835 100644
--- a/docs/public/execution/outcomes.mdx
+++ b/docs/public/execution/outcomes.mdx
@@ -83,24 +83,28 @@ In this example, if the agent returns a retryable failure and all 5 standard-pol
See [Retry policies](/execution/failures#retry-policies) for the available presets and backoff settings.
-## `auto_status`
+## Succeed on failure
-When `auto_status=true`, any non-`succeeded` and non-`skipped` outcome is silently overridden to `succeeded` after the handler completes. This is applied after the retry loop, so retries still happen normally — only the final outcome is overridden.
+When a node's effective `on_failure` policy is `succeed`, a `failed` outcome with no explicit recovery route is promoted to `succeeded`. This is applied after the retry loop, so retries still happen normally — only the final outcome changes. The original failure details stay on the `stage.completed` event and in the checkpoint, and the outcome's notes record the promotion.
| Attribute | Type | Default |
|---|---|---|
-| `auto_status` | Boolean | `false` |
+| `on_failure` | String | inherits the graph-level `on_failure` (default `route`) |
```dot
scan [
label="Scan",
shape=parallelogram,
- auto_status=true,
+ on_failure="succeed",
script="find . -name '*.log' | head -20"
]
```
-Use `auto_status` for nodes whose failure should never block the workflow — optional scans, best-effort cleanup steps, or informational commands where the output matters more than the exit code.
+Use `on_failure="succeed"` for nodes whose failure should never block the workflow — optional scans, best-effort cleanup steps, or informational commands where the output matters more than the exit code. An explicit `condition="outcome=failed"` edge still takes priority; the promotion applies only when no explicit route matches. The policy applies only to `failed` and leaves `partially_succeeded` unchanged. See [Failed-node routing policy](/workflows/transitions#failed-node-routing-policy) for the full set of policies.
+
+
+`auto_status=true` is the deprecated spelling of `on_failure="succeed"`. Fabro still accepts it as an alias, and validation reports an `auto_status_deprecated` warning with the replacement. Unlike the old attribute, the alias no longer promotes `partially_succeeded` outcomes.
+
## Goal gate interaction
@@ -111,7 +115,7 @@ make the workflow fail.
Nodes marked with `goal_gate=true` are checked when the workflow reaches the exit node. A goal gate is satisfied if its last outcome was `succeeded` **or** `partially_succeeded`. Any other outcome (`failed`, `skipped`) causes the workflow to fail, even though execution reached the exit.
-This means `allow_partial=true` on a goal gate node lets the gate pass even if the node exhausted its retries — the promoted `partially_succeeded` outcome counts as passing.
+This means `allow_partial=true` on a goal gate node lets the gate pass even if the node exhausted its retries — the promoted `partially_succeeded` outcome counts as passing. Likewise, a `succeeded` outcome promoted by `on_failure="succeed"` satisfies the gate.
See [Goal gates](/execution/failures#goal-gates) for retry target resolution and failure behavior.
diff --git a/docs/public/reference/dot-language.mdx b/docs/public/reference/dot-language.mdx
index 3f42716c2..f5378b2c9 100644
--- a/docs/public/reference/dot-language.mdx
+++ b/docs/public/reference/dot-language.mdx
@@ -77,7 +77,7 @@ rankdir=LR
| `rankdir` | Identifier | Layout direction: `LR` (left-to-right) or `TB` (top-to-bottom) |
| `model_stylesheet` | String | CSS-like rules for model assignment. The root value supports a MiniJinja template with `inputs` and `vars` (see [Model Stylesheets](/workflows/stylesheets)) |
| `default_max_retries` | Integer | Default retry count for all nodes (default: 0) |
-| `on_failure` | String | Failed-node routing policy: `route` (default) or `exit` |
+| `on_failure` | String | Failed-node policy when no explicit recovery route matches: `route` (default), `exit`, or `succeed` |
| `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 |
@@ -199,13 +199,13 @@ Other node types still need their shape, because their attributes don't identify
| `class` | String | Classes for [stylesheet](/workflows/stylesheets) targeting. Separate multiple classes with spaces. Commas are also accepted for compatibility. |
| `timeout` | Duration | Execution timeout (e.g. `900s`). An agent's wait for human input does not consume this budget. On a human node, this is the response deadline. |
| `max_visits` | Integer | Max times this node can execute in a run. Overrides the graph-level `max_node_visits` for this node. |
-| `on_failure` | String | Failed-node routing policy for this node: `route` or `exit`. Overrides the graph-level `on_failure`. |
+| `on_failure` | String | Failed-node policy for this node: `route`, `exit`, or `succeed`. Overrides the graph-level `on_failure`. See [Node Outcomes](/execution/outcomes#succeed-on-failure). |
| `max_retries` | Integer | Override default retry count |
| `retry_policy` | String | Named preset: `none`, `standard`, `aggressive`, `linear`, `patient` |
| `retry_target` | String | Node ID to jump to on retry |
| `fallback_retry_target` | String | Fallback node ID if primary `retry_target` is unreachable |
| `goal_gate` | Boolean | When `true`, workflow fails if this node didn't finish with `succeeded` or `partially_succeeded`. See [Node Outcomes](/execution/outcomes#goal-gate-interaction). |
-| `auto_status` | Boolean | When `true`, overrides any non-`succeeded`/non-`skipped` outcome to `succeeded` after the handler completes. See [Node Outcomes](/execution/outcomes#auto_status). |
+| `auto_status` | Boolean | Deprecated alias for `on_failure="succeed"`. Validation warns when it is present. |
| `allow_partial` | Boolean | When `true` and retries are exhausted on a retry-requesting failure, promotes the outcome to `partially_succeeded` instead of `failed`. Default `false`. See [Node Outcomes](/execution/outcomes#allow_partial). |
| `selection` | String | Edge tiebreaking strategy: `deterministic` (default) or `random` (weighted-random). Cannot be combined with conditional edges. |
diff --git a/docs/public/workflows/transitions.mdx b/docs/public/workflows/transitions.mdx
index 325ae7ec8..5cab4a79e 100644
--- a/docs/public/workflows/transitions.mdx
+++ b/docs/public/workflows/transitions.mdx
@@ -13,7 +13,7 @@ When a node completes, it produces an **outcome** with a [stage outcome](/execut
2. **Condition match** — Edges with a `condition` attribute are evaluated first. If one or more conditions match, the edge with the highest `weight` wins (lexical tiebreak on target node ID).
3. **Preferred label** — If the node's outcome includes a preferred label (for example, from a human gate selection), the edge whose `label` matches is chosen.
4. **Suggested next** — If the node suggests a specific next node ID, the edge pointing to that node is chosen.
-5. **Failure policy** — For a failed outcome with an effective `on_failure="exit"` policy (node-level `on_failure` first, then graph-level), Fabro skips the unconditional fallback.
+5. **Failure policy** — For a failed outcome with no explicit route, the effective `on_failure` policy (node-level `on_failure` first, then graph-level) decides what happens next. `exit` skips the unconditional fallback. `succeed` promotes the outcome to `succeeded` and routes it as a success. `route` continues to the unconditional fallback.
6. **Unconditional fallback** — Edges without conditions are considered last, again using `weight` then lexical tiebreak.
7. **Retry target** — For a failed outcome with no selected edge, Fabro checks node-level and graph-level `retry_target` and `fallback_retry_target` values.
@@ -21,10 +21,13 @@ If no edge or retry target supplies a next node, the workflow ends. A failed nod
## Failed-node routing policy
-The `on_failure` attribute controls whether a failed node can take an unconditional edge:
+The `on_failure` attribute controls what happens to a failed node when no explicit recovery route matches:
-- `route` keeps the existing routing behavior. It is the default.
-- `exit` stops normal fallback routing after explicit routes fail to match.
+| Policy | Effective outcome | Fallback routing |
+|---|---|---|
+| `route` (default) | stays `failed` | takes the unconditional edge |
+| `exit` | stays `failed` | skips the unconditional edge; the run ends unless a retry target applies |
+| `succeed` | becomes `succeeded` | uses normal success routing |
Set it at the graph level to apply the policy to every node, or on a node to control that node alone. A node-level `on_failure` overrides the graph level. A node without the attribute inherits the graph policy.
@@ -60,17 +63,38 @@ digraph Build {
}
```
-The `exit` policy only applies to the `failed` outcome. It does not change routing for `succeeded`, `partially_succeeded`, or `skipped` outcomes.
+Use `succeed` for a best-effort node whose failure must not block the workflow. Its failure becomes a `succeeded` outcome, so the node's normal success routing applies:
-Conditioned edges, matching preferred labels, and matching suggested node IDs are explicit recovery routes. They still take priority under `exit`. An unmatched preferred label or suggested node ID does not make an unconditional edge explicit.
+```dot title="best-effort-node.fabro"
+digraph Review {
+ graph [on_failure="exit"]
-Retry targets also remain available. Fabro checks them after it skips the unconditional fallback. Use graph-level `max_node_visits` or node-level `max_visits` to bound workflows whose retry targets return to a failing path.
+ start [shape=Mdiamond]
+ exit [shape=Msquare]
+ required_check [script="./required-check"]
+ optional_scan [script="./optional-scan" on_failure="succeed"]
-A failed human gate never falls through to an unconditional edge, regardless of policy. Node-level `on_failure="route"` does not change that; route an interrupted gate explicitly with `condition="outcome=failed"`.
+ start -> required_check -> optional_scan -> exit
+}
+```
+
+Under `succeed`, Fabro first checks explicit routes against the original `failed` outcome. If a `condition="outcome=failed"` edge, a matching preferred label, a matching suggested next node, or a handler jump applies, the outcome stays `failed` and that route is taken. Otherwise Fabro rewrites the outcome to `succeeded` before it records the node, so goal gates, the run context, events, and routing all see the promoted outcome. Edge selection then runs again: `condition="outcome=succeeded"` edges and unconditional edges apply. The original failure details stay on the `stage.completed` event and in the checkpoint, and the outcome's notes record which scope promoted it. A promoted outcome is not `failed`, so retry targets do not apply to it.
+
+Both `exit` and `succeed` apply only to the `failed` outcome. They do not change routing for `succeeded`, `partially_succeeded`, or `skipped` outcomes.
+
+Conditioned edges, matching preferred labels, and matching suggested node IDs are explicit recovery routes. They take priority under every policy. An unmatched preferred label or suggested node ID does not make an unconditional edge explicit.
+
+Retry targets also remain available under `exit`. Fabro checks them after it skips the unconditional fallback. Use graph-level `max_node_visits` or node-level `max_visits` to bound workflows whose retry targets return to a failing path.
+
+A failed human gate never falls through to an unconditional edge as a failure, regardless of policy. Node-level `on_failure="route"` does not change that; route an interrupted gate explicitly with `condition="outcome=failed"`. Under `succeed`, an interrupted gate with no explicit route is promoted like any other node and then follows its success routing.
When `exit` stops routing, Fabro checkpoints the failed node without a next node and ends the run as failed. It does not execute the graph's exit node or emit an edge selection for an edge it did not take. An explicit recovery route can still reach the exit node normally.
-For a parallel node, `exit` sees the final outcome returned by the parallel handler. It can stop routing for a failed parallel outcome. It does not add branch-level fail-fast behavior, and a `partially_succeeded` parallel outcome continues normally.
+For a parallel node, `exit` and `succeed` see the final outcome returned by the parallel handler. `exit` can stop routing for a failed parallel outcome; `succeed` promotes it. Neither adds branch-level fail-fast behavior, and a `partially_succeeded` parallel outcome continues normally. Inside the fan-out, a branch node whose effective policy is `succeed` counts as `succeeded` in the parent's aggregate when it fails. Branches have no edge routing, so there is no explicit route to prefer.
+
+
+`auto_status=true` is the deprecated spelling of node-level `on_failure="succeed"`. Fabro still accepts it as an alias and validation warns with the replacement. See [Node Outcomes](/execution/outcomes#succeed-on-failure).
+
## Edge attributes
@@ -201,7 +225,7 @@ gate -> fast_path [condition="outcome=succeeded"]
gate -> slow_path
```
-For a failed outcome, `on_failure="exit"` skips this fallback after explicit routes are checked. The default `on_failure="route"` keeps the behavior shown above.
+For a failed outcome, `on_failure="exit"` skips this fallback after explicit routes are checked, and `on_failure="succeed"` promotes the outcome to `succeeded` before taking it. The default `on_failure="route"` keeps the behavior shown above.
## Weight tiebreaking
diff --git a/lib/apps/fabro-cli/tests/it/cmd/validate.rs b/lib/apps/fabro-cli/tests/it/cmd/validate.rs
index dad1ac0eb..0af6cf8fa 100644
--- a/lib/apps/fabro-cli/tests/it/cmd/validate.rs
+++ b/lib/apps/fabro-cli/tests/it/cmd/validate.rs
@@ -390,11 +390,29 @@ fn invalid_node_on_failure_is_a_validation_failure() {
Workflow: InvalidNodeOnFailure (3 nodes, 2 edges)
Graph: [FIXTURES]/on_failure_node_invalid.fabro
error [node: work]: Node 'work' has invalid on_failure value 'stop' (on_failure_valid)
- fix: Use one of: route, exit
+ fix: Use one of: route, exit, succeed
× Validation failed
");
}
+#[test]
+fn deprecated_auto_status_warns_with_succeed_policy_replacement() {
+ let context = test_context!();
+ let mut cmd = context.validate();
+ cmd.arg(fixture("auto_status_deprecated.fabro"));
+ fabro_snapshot!(context.filters(), cmd, @"
+ success: true
+ exit_code: 0
+ ----- stdout -----
+ ----- stderr -----
+ Workflow: DeprecatedAutoStatus (3 nodes, 2 edges)
+ Graph: [FIXTURES]/auto_status_deprecated.fabro
+ warning [node: scan]: Node 'scan' sets deprecated 'auto_status=true' (auto_status_deprecated)
+ fix: Use on_failure=\"succeed\" instead
+ Validation: OK
+ ");
+}
+
#[test]
fn invalid_on_failure_is_a_validation_failure() {
let context = test_context!();
@@ -408,7 +426,7 @@ fn invalid_on_failure_is_a_validation_failure() {
Workflow: InvalidOnFailure (2 nodes, 1 edges)
Graph: [FIXTURES]/on_failure_invalid.fabro
error: Graph has invalid on_failure value 'stop' (on_failure_valid)
- fix: Use one of: route, exit
+ fix: Use one of: route, exit, succeed
× Validation failed
");
}
diff --git a/lib/components/fabro-validate/src/rules/auto_status_deprecated.rs b/lib/components/fabro-validate/src/rules/auto_status_deprecated.rs
new file mode 100644
index 000000000..5f258a52b
--- /dev/null
+++ b/lib/components/fabro-validate/src/rules/auto_status_deprecated.rs
@@ -0,0 +1,163 @@
+use fabro_graphviz::graph::Graph;
+
+use crate::{Diagnostic, LintRule, Severity};
+
+pub(super) fn rule() -> Box {
+ Box::new(Rule)
+}
+
+/// `auto_status=true` is the deprecated spelling of `on_failure="succeed"`.
+/// The runtime still honors it as an alias; this rule points workflows at the
+/// explicit policy.
+struct Rule;
+
+impl LintRule for Rule {
+ fn name(&self) -> &'static str {
+ "auto_status_deprecated"
+ }
+
+ fn apply(&self, graph: &Graph) -> Vec {
+ let mut nodes: Vec<_> = graph
+ .nodes
+ .values()
+ .filter(|node| node.attrs.contains_key("auto_status"))
+ .collect();
+ nodes.sort_unstable_by(|a, b| a.id.cmp(&b.id));
+
+ nodes
+ .into_iter()
+ .map(|node| {
+ let (message, fix) = if node.attrs.contains_key("on_failure") {
+ (
+ format!(
+ "Node '{}' sets deprecated 'auto_status', which is ignored because \
+ 'on_failure' is set",
+ node.id
+ ),
+ "Remove 'auto_status'",
+ )
+ } else if node.auto_status() {
+ (
+ format!("Node '{}' sets deprecated 'auto_status=true'", node.id),
+ "Use on_failure=\"succeed\" instead",
+ )
+ } else {
+ (
+ format!(
+ "Node '{}' sets deprecated 'auto_status', which has no effect \
+ unless it is true",
+ node.id
+ ),
+ "Remove 'auto_status'",
+ )
+ };
+ Diagnostic {
+ rule: self.name().to_string(),
+ severity: Severity::Warning,
+ message,
+ node_id: Some(node.id.clone()),
+ fix: Some(fix.to_string()),
+ ..Diagnostic::default()
+ }
+ })
+ .collect()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use fabro_graphviz::graph::{AttrValue, Graph};
+
+ use super::Rule;
+ use crate::rules::test_support::{minimal_graph, node_with_attrs};
+ use crate::{LintRule, Severity};
+
+ fn graph_with_auto_status(value: AttrValue, on_failure: Option<&str>) -> Graph {
+ let mut graph = minimal_graph();
+ let mut node = match on_failure {
+ Some(policy) => node_with_attrs("work", &[("on_failure", policy)]),
+ None => node_with_attrs("work", &[]),
+ };
+ node.attrs.insert("auto_status".to_string(), value);
+ graph.nodes.insert("work".to_string(), node);
+ graph
+ }
+
+ #[test]
+ fn accepts_graphs_without_auto_status() {
+ let mut graph = minimal_graph();
+ graph.nodes.insert(
+ "work".to_string(),
+ node_with_attrs("work", &[("on_failure", "succeed")]),
+ );
+ assert!(Rule.apply(&graph).is_empty());
+ }
+
+ #[test]
+ fn warns_for_auto_status_true_and_suggests_succeed_policy() {
+ let graph = graph_with_auto_status(AttrValue::Boolean(true), None);
+
+ let diagnostics = Rule.apply(&graph);
+
+ assert_eq!(diagnostics.len(), 1);
+ assert_eq!(diagnostics[0].severity, Severity::Warning);
+ assert_eq!(
+ diagnostics[0].message,
+ "Node 'work' sets deprecated 'auto_status=true'"
+ );
+ assert_eq!(diagnostics[0].node_id.as_deref(), Some("work"));
+ assert_eq!(
+ diagnostics[0].fix.as_deref(),
+ Some("Use on_failure=\"succeed\" instead")
+ );
+ }
+
+ #[test]
+ fn warns_that_auto_status_is_ignored_when_on_failure_is_set() {
+ let graph = graph_with_auto_status(AttrValue::Boolean(true), Some("exit"));
+
+ let diagnostics = Rule.apply(&graph);
+
+ assert_eq!(diagnostics.len(), 1);
+ assert_eq!(diagnostics[0].severity, Severity::Warning);
+ assert_eq!(
+ diagnostics[0].message,
+ "Node 'work' sets deprecated 'auto_status', which is ignored because 'on_failure' is set"
+ );
+ assert_eq!(diagnostics[0].fix.as_deref(), Some("Remove 'auto_status'"));
+ }
+
+ #[test]
+ fn warns_for_auto_status_false() {
+ let graph = graph_with_auto_status(AttrValue::Boolean(false), None);
+
+ let diagnostics = Rule.apply(&graph);
+
+ assert_eq!(diagnostics.len(), 1);
+ assert_eq!(diagnostics[0].severity, Severity::Warning);
+ assert_eq!(
+ diagnostics[0].message,
+ "Node 'work' sets deprecated 'auto_status', which has no effect unless it is true"
+ );
+ assert_eq!(diagnostics[0].fix.as_deref(), Some("Remove 'auto_status'"));
+ }
+
+ #[test]
+ fn reports_nodes_in_id_order() {
+ let mut graph = minimal_graph();
+ for id in ["zeta", "alpha"] {
+ let mut node = node_with_attrs(id, &[]);
+ node.attrs
+ .insert("auto_status".to_string(), AttrValue::Boolean(true));
+ graph.nodes.insert(id.to_string(), node);
+ }
+
+ let ids: Vec<_> = Rule
+ .apply(&graph)
+ .into_iter()
+ .map(|diagnostic| diagnostic.node_id.unwrap())
+ .collect();
+
+ assert_eq!(ids, ["alpha", "zeta"]);
+ }
+}
diff --git a/lib/components/fabro-validate/src/rules/mod.rs b/lib/components/fabro-validate/src/rules/mod.rs
index 2d0366ab7..c73657718 100644
--- a/lib/components/fabro-validate/src/rules/mod.rs
+++ b/lib/components/fabro-validate/src/rules/mod.rs
@@ -1,4 +1,5 @@
mod all_conditional_edges;
+mod auto_status_deprecated;
mod backend_valid;
mod command_requires_script;
mod condition_syntax;
@@ -65,6 +66,7 @@ pub fn built_in_rules() -> Vec> {
all_conditional_edges::rule(),
orphan_custom_outcome::rule(),
on_failure_valid::rule(),
+ auto_status_deprecated::rule(),
script_absolute_cd::rule(),
command_requires_script::rule(),
import_error::rule(),
diff --git a/lib/components/fabro-validate/src/rules/on_failure_valid.rs b/lib/components/fabro-validate/src/rules/on_failure_valid.rs
index 8f0d87987..ee3b8dd38 100644
--- a/lib/components/fabro-validate/src/rules/on_failure_valid.rs
+++ b/lib/components/fabro-validate/src/rules/on_failure_valid.rs
@@ -97,7 +97,7 @@ mod tests {
let mut graph = minimal_graph();
assert!(Rule.apply(&graph).is_empty());
- for value in ["route", "exit"] {
+ for value in ["route", "exit", "succeed"] {
graph.attrs.insert(
"on_failure".to_string(),
AttrValue::String(value.to_string()),
@@ -109,7 +109,7 @@ mod tests {
#[test]
fn accepts_supported_node_values() {
let mut graph = minimal_graph();
- for value in ["route", "exit"] {
+ for value in ["route", "exit", "succeed"] {
graph.nodes.insert(
"work".to_string(),
node_with_attrs("work", &[("on_failure", value)]),
@@ -136,7 +136,7 @@ mod tests {
);
assert_eq!(
diagnostics[0].fix.as_deref(),
- Some("Use one of: route, exit")
+ Some("Use one of: route, exit, succeed")
);
}
@@ -176,7 +176,7 @@ mod tests {
assert_eq!(diagnostics[0].node_id.as_deref(), Some("work"));
assert_eq!(
diagnostics[0].fix.as_deref(),
- Some("Use one of: route, exit")
+ Some("Use one of: route, exit, succeed")
);
}
diff --git a/lib/components/fabro-workflow/src/context.rs b/lib/components/fabro-workflow/src/context.rs
index 4d4ebebe0..d2e055e12 100644
--- a/lib/components/fabro-workflow/src/context.rs
+++ b/lib/components/fabro-workflow/src/context.rs
@@ -169,8 +169,49 @@ use fabro_graphviz::Fidelity;
use fabro_types::{ParallelBranchId, RunId, StageId};
use serde::{Deserialize, Serialize};
-use crate::error::Error;
+use crate::error::{Error, FailureSignature, FailureSignatureExt};
use crate::event::StageScope;
+use crate::outcome::{Outcome, OutcomeExt};
+
+/// Applies the context values derived from a completed node result.
+///
+/// Edge-policy projection and the durable `after_record` lifecycle use this
+/// same function so conditional routes observe identical values.
+pub(crate) fn apply_recorded_outcome_context(
+ context: &Context,
+ node_id: &str,
+ outcome: &Outcome,
+ retry_count: u32,
+) {
+ let failure_class = outcome.classified_failure_category();
+ let failure_signature = failure_class
+ .map(|category| {
+ let signature_hint = outcome
+ .failure
+ .as_ref()
+ .and_then(|failure| failure.signature.as_deref());
+ FailureSignature::new(node_id, category, signature_hint, outcome.failure_reason())
+ .to_string()
+ })
+ .unwrap_or_default();
+
+ context.set(
+ keys::retry_count_key(node_id),
+ serde_json::json!(retry_count),
+ );
+ context.set(keys::OUTCOME, serde_json::json!(outcome.status.to_string()));
+ context.set(
+ keys::FAILURE_CLASS,
+ serde_json::json!(failure_class.map_or(String::new(), |class| class.to_string())),
+ );
+ context.set(
+ keys::FAILURE_SIGNATURE,
+ serde_json::json!(failure_signature),
+ );
+ if let Some(preferred_label) = &outcome.preferred_label {
+ context.set(keys::PREFERRED_LABEL, serde_json::json!(preferred_label));
+ }
+}
/// Keys whose values changed or were added in `after` relative to `before`.
/// Takes `after` by value so changed entries move instead of clone.
diff --git a/lib/components/fabro-workflow/src/graph.rs b/lib/components/fabro-workflow/src/graph.rs
index 1381828aa..734cfc64c 100644
--- a/lib/components/fabro-workflow/src/graph.rs
+++ b/lib/components/fabro-workflow/src/graph.rs
@@ -5,10 +5,11 @@ use std::sync::Arc;
use fabro_core::error::{Error as CoreError, Result as CoreResult};
use fabro_core::graph::{EdgeSelection as CoreEdgeSelection, EdgeSpec, Graph, NodeSpec};
+use fabro_core::outcome::NodeResult;
use fabro_graphviz::graph::types::{Edge as GvEdge, Graph as GvGraph, Node as GvNode};
use fabro_types::ResolvedOnFailure;
-use crate::context::Context;
+use crate::context::{self, Context};
use crate::outcome::{BilledModelUsage, Outcome};
// ---- WorkflowNode ----
@@ -120,6 +121,20 @@ impl Graph for WorkflowGraph {
})
}
+ fn project_result_context(
+ &self,
+ node: &Self::Node,
+ result: &NodeResult,
+ context: &Context,
+ ) {
+ context::apply_recorded_outcome_context(
+ context,
+ node.id(),
+ &result.outcome,
+ result.attempts.saturating_sub(1),
+ );
+ }
+
fn check_goal_gates(
&self,
outcomes: &HashMap,
diff --git a/lib/components/fabro-workflow/src/graph/routing.rs b/lib/components/fabro-workflow/src/graph/routing.rs
index 0de75ab97..e34dffec3 100644
--- a/lib/components/fabro-workflow/src/graph/routing.rs
+++ b/lib/components/fabro-workflow/src/graph/routing.rs
@@ -1,7 +1,7 @@
use std::collections::HashMap;
+use fabro_core::graph::EdgeSelectionReason;
use fabro_graphviz::graph::types::{Edge as GvEdge, Graph as GvGraph, Node as GvNode};
-use fabro_types::OnFailure;
use rand::Rng;
use crate::condition::evaluate_condition;
@@ -11,7 +11,7 @@ use crate::outcome::Outcome;
/// Result of edge selection: the chosen edge and the reason it was selected.
pub(crate) struct SelectedGraphEdge<'a> {
pub(crate) edge: &'a GvEdge,
- pub(crate) reason: &'static str,
+ pub(crate) reason: EdgeSelectionReason,
}
/// Check whether a node is a terminal (exit) node.
@@ -44,7 +44,7 @@ pub(crate) fn select_edge<'a>(
if !condition_matched.is_empty() {
return pick_edge(&condition_matched, selection).map(|edge| SelectedGraphEdge {
edge,
- reason: "condition",
+ reason: EdgeSelectionReason::Condition,
});
}
@@ -56,7 +56,7 @@ pub(crate) fn select_edge<'a>(
if normalize_label(label) == normalized_pref {
return Some(SelectedGraphEdge {
edge,
- reason: "preferred_label",
+ reason: EdgeSelectionReason::PreferredLabel,
});
}
}
@@ -69,16 +69,12 @@ pub(crate) fn select_edge<'a>(
if edge.condition().is_none_or(str::is_empty) && edge.to == *suggested_id {
return Some(SelectedGraphEdge {
edge,
- reason: "suggested_next",
+ reason: EdgeSelectionReason::SuggestedNext,
});
}
}
}
- if outcome.status.is_failure() && graph.resolve_on_failure(node).policy() == OnFailure::Exit {
- return None;
- }
-
if blocks_unconditional_failure_fallthrough(node, outcome) {
return None;
}
@@ -91,7 +87,7 @@ pub(crate) fn select_edge<'a>(
if !unconditional.is_empty() {
return pick_edge(&unconditional, selection).map(|edge| SelectedGraphEdge {
edge,
- reason: "unconditional",
+ reason: EdgeSelectionReason::Unconditional,
});
}
@@ -238,6 +234,7 @@ mod tests {
use std::collections::HashMap;
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
+ use fabro_types::{OnFailure, ResolvedOnFailure};
use super::*;
use crate::context::Context;
@@ -257,20 +254,6 @@ mod tests {
g
}
- fn set_on_failure(graph: &mut Graph, on_failure: OnFailure) {
- graph.attrs.insert(
- "on_failure".to_string(),
- AttrValue::String(on_failure.to_string()),
- );
- }
-
- fn set_node_on_failure(graph: &mut Graph, node_id: &str, value: &str) {
- graph.nodes.get_mut(node_id).unwrap().attrs.insert(
- "on_failure".to_string(),
- AttrValue::String(value.to_string()),
- );
- }
-
#[test]
fn normalize_label_lowercase_and_trim() {
assert_eq!(normalize_label(" Yes "), "yes");
@@ -394,41 +377,25 @@ mod tests {
let context = Context::new();
let sel = select_edge(node, &outcome, &context, &g, "deterministic").unwrap();
assert_eq!(sel.edge.to, "b");
- assert_eq!(sel.reason, "unconditional");
+ assert_eq!(sel.reason, EdgeSelectionReason::Unconditional);
}
#[test]
- fn failed_outcome_takes_unconditional_edge_in_default_and_route_modes() {
- for policy in [None, Some(OnFailure::Route)] {
- let mut graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
- if let Some(policy) = policy {
- set_on_failure(&mut graph, policy);
- }
- let node = graph.nodes.get("a").unwrap();
- let outcome = Outcome::fail_classify("boom");
-
- let selected =
- select_edge(node, &outcome, &Context::new(), &graph, "deterministic").unwrap();
-
- assert_eq!(selected.edge.to, "b");
- assert_eq!(selected.reason, "unconditional");
- }
- }
-
- #[test]
- fn exit_policy_blocks_unconditional_edge_for_failed_outcome() {
- let mut graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
- set_on_failure(&mut graph, OnFailure::Exit);
+ fn failed_outcome_selects_unconditional_edge() {
+ let graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
let node = graph.nodes.get("a").unwrap();
let outcome = Outcome::fail_classify("boom");
- assert!(select_edge(node, &outcome, &Context::new(), &graph, "deterministic").is_none());
+ let selected =
+ select_edge(node, &outcome, &Context::new(), &graph, "deterministic").unwrap();
+
+ assert_eq!(selected.edge.to, "b");
+ assert_eq!(selected.reason, EdgeSelectionReason::Unconditional);
}
#[test]
- fn exit_policy_allows_unconditional_edge_for_non_failed_outcomes() {
- let mut graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
- set_on_failure(&mut graph, OnFailure::Exit);
+ fn non_failed_outcomes_select_unconditional_edge() {
+ let graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
let node = graph.nodes.get("a").unwrap();
let mut partial = Outcome::success();
partial.status = StageOutcome::PartiallySucceeded;
@@ -437,19 +404,18 @@ mod tests {
let selected =
select_edge(node, &outcome, &Context::new(), &graph, "deterministic").unwrap();
assert_eq!(selected.edge.to, "b");
- assert_eq!(selected.reason, "unconditional");
+ assert_eq!(selected.reason, EdgeSelectionReason::Unconditional);
}
}
#[test]
- fn exit_policy_allows_matching_failure_condition() {
+ fn failure_condition_is_an_explicit_selection() {
let mut recovery = Edge::new("a", "recover");
recovery.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=failed".to_string()),
);
- let mut graph = make_graph_with_edges(vec![recovery, Edge::new("a", "fallback")]);
- set_on_failure(&mut graph, OnFailure::Exit);
+ let graph = make_graph_with_edges(vec![recovery, Edge::new("a", "fallback")]);
let node = graph.nodes.get("a").unwrap();
let outcome = Outcome::fail_classify("boom");
@@ -457,18 +423,17 @@ mod tests {
select_edge(node, &outcome, &Context::new(), &graph, "deterministic").unwrap();
assert_eq!(selected.edge.to, "recover");
- assert_eq!(selected.reason, "condition");
+ assert_eq!(selected.reason, EdgeSelectionReason::Condition);
}
#[test]
- fn exit_policy_allows_matching_preferred_and_suggested_routes() {
+ fn preferred_and_suggested_routes_are_explicit_selections() {
let mut preferred = Edge::new("a", "preferred");
preferred.attrs.insert(
"label".to_string(),
AttrValue::String("Recover".to_string()),
);
- let mut graph = make_graph_with_edges(vec![preferred, Edge::new("a", "suggested")]);
- set_on_failure(&mut graph, OnFailure::Exit);
+ let graph = make_graph_with_edges(vec![preferred, Edge::new("a", "suggested")]);
let node = graph.nodes.get("a").unwrap();
let mut preferred_outcome = Outcome::fail_classify("boom");
@@ -482,7 +447,7 @@ mod tests {
)
.unwrap();
assert_eq!(selected.edge.to, "preferred");
- assert_eq!(selected.reason, "preferred_label");
+ assert_eq!(selected.reason, EdgeSelectionReason::PreferredLabel);
let mut suggested_outcome = Outcome::fail_classify("boom");
suggested_outcome.suggested_next_ids = vec!["suggested".to_string()];
@@ -495,118 +460,26 @@ mod tests {
)
.unwrap();
assert_eq!(selected.edge.to, "suggested");
- assert_eq!(selected.reason, "suggested_next");
+ assert_eq!(selected.reason, EdgeSelectionReason::SuggestedNext);
}
#[test]
- fn exit_policy_blocks_fallback_for_unmatched_routing_hints() {
- let mut graph = make_graph_with_edges(vec![Edge::new("a", "fallback")]);
- set_on_failure(&mut graph, OnFailure::Exit);
- let node = graph.nodes.get("a").unwrap();
-
- let mut preferred_outcome = Outcome::fail_classify("boom");
- preferred_outcome.preferred_label = Some("missing".to_string());
- assert!(
- select_edge(
- node,
- &preferred_outcome,
- &Context::new(),
- &graph,
- "deterministic"
- )
- .is_none()
+ fn promoted_outcome_selects_succeeded_condition() {
+ let mut on_success = Edge::new("a", "next");
+ on_success.attrs.insert(
+ "condition".to_string(),
+ AttrValue::String("outcome=succeeded".to_string()),
);
-
- let mut suggested_outcome = Outcome::fail_classify("boom");
- suggested_outcome.suggested_next_ids = vec!["missing".to_string()];
- assert!(
- select_edge(
- node,
- &suggested_outcome,
- &Context::new(),
- &graph,
- "deterministic"
- )
- .is_none()
- );
- }
-
- #[test]
- fn node_exit_overrides_graph_route_and_blocks_unconditional_edge() {
- for graph_policy in [None, Some(OnFailure::Route)] {
- let mut graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
- if let Some(policy) = graph_policy {
- set_on_failure(&mut graph, policy);
- }
- set_node_on_failure(&mut graph, "a", "exit");
- let node = graph.nodes.get("a").unwrap();
- let outcome = Outcome::fail_classify("boom");
-
- assert!(
- select_edge(node, &outcome, &Context::new(), &graph, "deterministic").is_none()
- );
- }
- }
-
- #[test]
- fn node_route_overrides_graph_exit_and_takes_unconditional_edge() {
- let mut graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
- set_on_failure(&mut graph, OnFailure::Exit);
- set_node_on_failure(&mut graph, "a", "route");
+ let graph = make_graph_with_edges(vec![on_success, Edge::new("a", "fallback")]);
let node = graph.nodes.get("a").unwrap();
- let outcome = Outcome::fail_classify("boom");
+ let mut outcome = Outcome::fail_classify("boom");
+ outcome.apply_on_failure(ResolvedOnFailure::node(OnFailure::Succeed));
let selected =
select_edge(node, &outcome, &Context::new(), &graph, "deterministic").unwrap();
- assert_eq!(selected.edge.to, "b");
- assert_eq!(selected.reason, "unconditional");
- }
-
- #[test]
- fn node_policy_does_not_affect_routing_from_other_nodes() {
- let mut graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
- set_node_on_failure(&mut graph, "b", "exit");
- let node = graph.nodes.get("a").unwrap();
- let outcome = Outcome::fail_classify("boom");
-
- let selected =
- select_edge(node, &outcome, &Context::new(), &graph, "deterministic").unwrap();
-
- assert_eq!(selected.edge.to, "b");
- assert_eq!(selected.reason, "unconditional");
- }
-
- #[test]
- fn invalid_node_on_failure_value_inherits_graph_policy() {
- let mut graph = make_graph_with_edges(vec![Edge::new("a", "b")]);
- set_on_failure(&mut graph, OnFailure::Exit);
- set_node_on_failure(&mut graph, "a", "bogus");
- let node = graph.nodes.get("a").unwrap();
- let outcome = Outcome::fail_classify("boom");
-
- assert!(select_edge(node, &outcome, &Context::new(), &graph, "deterministic").is_none());
- }
-
- #[test]
- fn failed_human_gate_ignores_node_route_override() {
- let mut graph = make_graph_with_edges(vec![
- Edge::new("gate", "approve"),
- Edge::new("gate", "skip"),
- ]);
- set_on_failure(&mut graph, OnFailure::Exit);
- set_node_on_failure(&mut graph, "gate", "route");
- let gate = graph.nodes.get_mut("gate").unwrap();
- gate.attrs.insert(
- "shape".to_string(),
- AttrValue::String("hexagon".to_string()),
- );
- let node = graph.nodes.get("gate").unwrap().clone();
- let outcome = Outcome::fail_deterministic(
- "human interaction interrupted before an answer was provided",
- );
-
- assert!(select_edge(&node, &outcome, &Context::new(), &graph, "deterministic").is_none());
+ assert_eq!(selected.edge.to, "next");
+ assert_eq!(selected.reason, EdgeSelectionReason::Condition);
}
#[test]
@@ -627,7 +500,7 @@ mod tests {
let context = Context::new();
let sel = select_edge(node, &outcome, &context, &g, "deterministic").unwrap();
assert_eq!(sel.edge.to, "success_path");
- assert_eq!(sel.reason, "condition");
+ assert_eq!(sel.reason, EdgeSelectionReason::Condition);
}
#[test]
@@ -649,7 +522,7 @@ mod tests {
let context = Context::new();
let sel = select_edge(node, &outcome, &context, &g, "deterministic").unwrap();
assert_eq!(sel.edge.to, "fix");
- assert_eq!(sel.reason, "preferred_label");
+ assert_eq!(sel.reason, EdgeSelectionReason::PreferredLabel);
}
#[test]
@@ -663,7 +536,7 @@ mod tests {
let context = Context::new();
let sel = select_edge(node, &outcome, &context, &g, "deterministic").unwrap();
assert_eq!(sel.edge.to, "path2");
- assert_eq!(sel.reason, "suggested_next");
+ assert_eq!(sel.reason, EdgeSelectionReason::SuggestedNext);
}
#[test]
@@ -679,7 +552,7 @@ mod tests {
let context = Context::new();
let sel = select_edge(node, &outcome, &context, &g, "deterministic").unwrap();
assert_eq!(sel.edge.to, "high");
- assert_eq!(sel.reason, "unconditional");
+ assert_eq!(sel.reason, EdgeSelectionReason::Unconditional);
}
#[test]
@@ -692,7 +565,7 @@ mod tests {
let context = Context::new();
let sel = select_edge(node, &outcome, &context, &g, "deterministic").unwrap();
assert_eq!(sel.edge.to, "alpha");
- assert_eq!(sel.reason, "unconditional");
+ assert_eq!(sel.reason, EdgeSelectionReason::Unconditional);
}
#[test]
@@ -709,7 +582,7 @@ mod tests {
let context = Context::new();
let sel = select_edge(node, &outcome, &context, &g, "deterministic").unwrap();
assert_eq!(sel.edge.to, "cond_path");
- assert_eq!(sel.reason, "condition");
+ assert_eq!(sel.reason, EdgeSelectionReason::Condition);
}
#[test]
@@ -722,7 +595,7 @@ mod tests {
let context = Context::new();
let sel = select_edge(node, &outcome, &context, &g, "random").unwrap();
assert!(sel.edge.to == "b" || sel.edge.to == "c");
- assert_eq!(sel.reason, "unconditional");
+ assert_eq!(sel.reason, EdgeSelectionReason::Unconditional);
}
#[test]
@@ -740,29 +613,26 @@ mod tests {
let context = Context::new();
let sel = select_edge(node, &outcome, &context, &g, "random").unwrap();
assert_eq!(sel.edge.to, "approve");
- assert_eq!(sel.reason, "preferred_label");
+ assert_eq!(sel.reason, EdgeSelectionReason::PreferredLabel);
}
#[test]
fn select_edge_failed_human_gate_does_not_fall_through_to_unconditional() {
- for policy in [OnFailure::Route, OnFailure::Exit] {
- let mut graph = make_graph_with_edges(vec![
- Edge::new("gate", "approve"),
- Edge::new("gate", "skip"),
- ]);
- set_on_failure(&mut graph, policy);
- let mut node = graph.nodes.get("gate").unwrap().clone();
- node.attrs.insert(
- "shape".to_string(),
- AttrValue::String("hexagon".to_string()),
- );
- let outcome = Outcome::fail_deterministic(
- "human interaction interrupted before an answer was provided",
- );
- let context = Context::new();
+ let graph = make_graph_with_edges(vec![
+ Edge::new("gate", "approve"),
+ Edge::new("gate", "skip"),
+ ]);
+ let mut node = graph.nodes.get("gate").unwrap().clone();
+ node.attrs.insert(
+ "shape".to_string(),
+ AttrValue::String("hexagon".to_string()),
+ );
+ let outcome = Outcome::fail_deterministic(
+ "human interaction interrupted before an answer was provided",
+ );
+ let context = Context::new();
- assert!(select_edge(&node, &outcome, &context, &graph, "deterministic").is_none());
- }
+ assert!(select_edge(&node, &outcome, &context, &graph, "deterministic").is_none());
}
#[test]
@@ -773,23 +643,20 @@ mod tests {
AttrValue::String("outcome=failed".to_string()),
);
let approve = Edge::new("gate", "approve");
- for policy in [OnFailure::Route, OnFailure::Exit] {
- let mut graph = make_graph_with_edges(vec![fail.clone(), approve.clone()]);
- set_on_failure(&mut graph, policy);
- let mut node = graph.nodes.get("gate").unwrap().clone();
- node.attrs.insert(
- "shape".to_string(),
- AttrValue::String("hexagon".to_string()),
- );
- let outcome = Outcome::fail_deterministic(
- "human interaction interrupted before an answer was provided",
- );
- let context = Context::new();
+ let graph = make_graph_with_edges(vec![fail, approve]);
+ let mut node = graph.nodes.get("gate").unwrap().clone();
+ node.attrs.insert(
+ "shape".to_string(),
+ AttrValue::String("hexagon".to_string()),
+ );
+ let outcome = Outcome::fail_deterministic(
+ "human interaction interrupted before an answer was provided",
+ );
+ let context = Context::new();
- let sel = select_edge(&node, &outcome, &context, &graph, "deterministic").unwrap();
- assert_eq!(sel.edge.to, "retry");
- assert_eq!(sel.reason, "condition");
- }
+ let sel = select_edge(&node, &outcome, &context, &graph, "deterministic").unwrap();
+ assert_eq!(sel.edge.to, "retry");
+ assert_eq!(sel.reason, EdgeSelectionReason::Condition);
}
#[test]
diff --git a/lib/components/fabro-workflow/src/handler/parallel.rs b/lib/components/fabro-workflow/src/handler/parallel.rs
index edd09a56b..35796115c 100644
--- a/lib/components/fabro-workflow/src/handler/parallel.rs
+++ b/lib/components/fabro-workflow/src/handler/parallel.rs
@@ -528,7 +528,7 @@ async fn run_branches(
);
let mut attempt = 0_u32;
- let outcome = loop {
+ let mut outcome = loop {
attempt = attempt.saturating_add(1);
let attempt_result = node_handler::execute_single_attempt(
&target,
@@ -569,6 +569,12 @@ async fn run_branches(
backoff_or_cancel(delay, &branch_services).await?;
permit = acquire_branch_permit(&semaphore, &branch_services).await?;
};
+ // Branches have no edge routing, so `succeed` has no
+ // explicit recovery route to defer to: a failed branch
+ // under that policy always counts as succeeded in the
+ // parent's aggregate, with its failure kept on the
+ // outcome.
+ outcome.apply_on_failure(graph.resolve_on_failure(&target));
let context_updates = branch_context_updates(
&parent_snapshot,
@@ -1401,6 +1407,122 @@ mod tests {
}
}
+ /// Fails the named branch node and succeeds everywhere else.
+ struct FailNamedBranchHandler(&'static str);
+
+ #[async_trait]
+ impl Handler for FailNamedBranchHandler {
+ async fn execute(
+ &self,
+ node: &Node,
+ _context: &Context,
+ _graph: &Graph,
+ _run_dir: &Path,
+ _services: &EngineServices,
+ ) -> Result {
+ if node.id == self.0 {
+ Ok(Outcome::fail_classify("branch boom"))
+ } else {
+ Ok(Outcome::success())
+ }
+ }
+ }
+
+ /// Mutates a test graph to opt a branch into the `succeed` policy.
+ type PolicyEdit = fn(&mut Graph);
+
+ async fn run_parallel_with_failing_branch_a(graph: &Graph, node: &Node) -> Outcome {
+ let mut services = make_services();
+ services.registry = Arc::new(super::super::HandlerRegistry::new(Box::new(
+ FailNamedBranchHandler("branch_a"),
+ )));
+ ParallelHandler
+ .execute(
+ node,
+ &test_context(),
+ graph,
+ Path::new("/tmp/test"),
+ &services,
+ )
+ .await
+ .unwrap()
+ }
+
+ #[tokio::test]
+ async fn parallel_handler_failed_branch_without_policy_is_partial() {
+ let (node, graph) = parallel_graph();
+
+ let outcome = run_parallel_with_failing_branch_a(&graph, &node).await;
+
+ assert_eq!(outcome.status, StageOutcome::PartiallySucceeded);
+ let results: Vec =
+ serde_json::from_value(outcome.context_updates[keys::PARALLEL_RESULTS].clone())
+ .unwrap();
+ assert!(results[0].status.is_failure());
+ assert_eq!(results[1].status, StageOutcome::Succeeded);
+ }
+
+ #[tokio::test]
+ async fn parallel_handler_succeed_policy_counts_failed_branch_as_succeeded() {
+ let cases: [(&str, PolicyEdit); 3] = [
+ ("node", |graph| {
+ graph.nodes.get_mut("branch_a").unwrap().attrs.insert(
+ "on_failure".to_string(),
+ AttrValue::String("succeed".to_string()),
+ );
+ }),
+ ("graph", |graph| {
+ graph.attrs.insert(
+ "on_failure".to_string(),
+ AttrValue::String("succeed".to_string()),
+ );
+ }),
+ ("alias", |graph| {
+ graph
+ .nodes
+ .get_mut("branch_a")
+ .unwrap()
+ .attrs
+ .insert("auto_status".to_string(), AttrValue::Boolean(true));
+ }),
+ ];
+ for (scope, apply) in cases {
+ let (node, mut graph) = parallel_graph();
+ apply(&mut graph);
+
+ let outcome = run_parallel_with_failing_branch_a(&graph, &node).await;
+
+ assert_eq!(outcome.status, StageOutcome::Succeeded, "scope {scope}");
+ assert_eq!(
+ outcome.notes.as_deref(),
+ Some("Parallel node dispatched 2 branches (2 succeeded, 0 failed)"),
+ "scope {scope}"
+ );
+ let results: Vec =
+ serde_json::from_value(outcome.context_updates[keys::PARALLEL_RESULTS].clone())
+ .unwrap();
+ assert!(
+ results
+ .iter()
+ .all(|result| result.status == StageOutcome::Succeeded),
+ "scope {scope}"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn parallel_handler_exit_policy_does_not_change_branch_outcomes() {
+ let (node, mut graph) = parallel_graph();
+ graph.attrs.insert(
+ "on_failure".to_string(),
+ AttrValue::String("exit".to_string()),
+ );
+
+ let outcome = run_parallel_with_failing_branch_a(&graph, &node).await;
+
+ assert_eq!(outcome.status, StageOutcome::PartiallySucceeded);
+ }
+
#[tokio::test]
async fn parallel_handler_no_branches() {
let outcome = ParallelHandler
diff --git a/lib/components/fabro-workflow/src/lifecycle/auto_status.rs b/lib/components/fabro-workflow/src/lifecycle/auto_status.rs
deleted file mode 100644
index fb48bbde7..000000000
--- a/lib/components/fabro-workflow/src/lifecycle/auto_status.rs
+++ /dev/null
@@ -1,37 +0,0 @@
-use async_trait::async_trait;
-use fabro_core::error::Result as CoreResult;
-use fabro_core::lifecycle::RunLifecycle;
-use fabro_core::outcome::NodeResult;
-use fabro_core::state::ExecutionState;
-
-use crate::graph::{WorkflowGraph, WorkflowNode};
-use crate::outcome::{BilledModelUsage, StageOutcome};
-
-type WfRunState = ExecutionState