A failed node with an effective `succeed` policy and no explicit recovery
route now finishes as `succeeded` and follows normal success routing. The
original failure stays on the outcome so the stage.completed event and the
checkpoint keep the diagnostic, and the outcome notes record which scope
promoted it.
- OnFailure gains a Succeed variant; Node::on_failure resolves the
deprecated auto_status=true attribute as an alias, with an explicit
on_failure winning
- The core executor applies the policy before the lifecycle observes the
result, so the recorded outcome, context keys, goal gates, events, and
routing all see the effective outcome; this replaces AutoStatusLifecycle
- Explicit routes take priority: a matching condition, preferred label,
suggested next node, or handler jump keeps the outcome failed. A failed
outcome takes an unconditional edge only under route, so under succeed
any edge selection is an explicit route
- succeed applies only to failed, matching exit; the auto_status alias no
longer promotes partially_succeeded
- Parallel branches promote after their retry loop, so a failed succeed
branch counts as succeeded in the parent aggregate
- Validation accepts succeed and adds an auto_status_deprecated warning
that suggests on_failure="succeed"
- Document the policy table, semantics, and deprecation; add a changelog
entry
Closes#807
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A node can now set its own on_failure attribute to override the
graph-level failed-node routing policy in either direction: a
best-effort node can keep route inside an exit graph, and a critical
node can exit while the rest of the graph keeps the default. An absent
node attribute inherits the graph policy.
- Node::on_failure returns Option<OnFailure> so absence means inherit
- Graph::resolve_on_failure(node_id) is the single resolution point,
returning ResolvedOnFailure { policy, scope } so the executor's
end-of-run message names the scope that stopped routing
- The core Graph trait method becomes resolve_on_failure(node_id); the
graph-scope failure message is unchanged
- The failed-human-gate fallthrough block stays independent of a
node-level route override
- Validation now accepts and value-checks node-level on_failure (it
previously warned that node placement had no effect) and keeps the
edge-placement warning with updated wording
- Document precedence in transitions, failures, and the DOT reference,
and extend today's changelog entry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Chraa21RK7i2KqHdZSJLb8
Follow-up cleanup on the catalog-free validation split. Same behavior,
fewer parallel code paths.
- Make the catalog an explicit `Option<&Catalog>` on `pipeline::validate`
instead of a `validate` / `validate_with_catalog` pair, so each call
site states whether catalog rules run.
- Collapse `preprocess_and_validate`, `preprocess_and_validate_structural`,
and `preprocess` into one function that takes `TransformOptions`. Its
`model_resolution` field is now the single source of truth for catalog
awareness, which drops a 12-argument signature and the
`too_many_arguments` allow.
- Replace the duplicated resolve-and-preprocess block in
`operations::validate` with one `validate_in_scope` helper, and drop the
HashSet -> Vec -> HashSet round trip on the catalog path.
- Extract `configured_default_provider`, previously duplicated between
`operations::create` and `operations::validate`.
- Delete `validate_manifest_with_environment_defaults`, which had no
callers outside its own module.
- Share the `server-model.fabro` fixture between the two CLI tests instead
of inlining it twice. The validate test now asserts the rendered output
through the usual snapshot helper, which also removes a hand-rolled
`std::fs::write` and its clippy allow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A node with no `shape` defaulted to `box`, which resolves to the agent
handler. That made a shapeless `script` node run as an LLM call prompted
with its own label, while the `script` was reported as inert — wrong
behavior behind a warning.
`script` is read by the command handler and by nothing else, so a
shapeless node that sets it is unambiguously a command node. `shape()`
now infers `parallelogram` in that case. An explicit `shape` still wins.
Two rules keep the inference honest:
- `script_prompt_conflict` — setting both `script` and `prompt` is an
error. No handler reads both. It fires regardless of shape so that
adding one cannot downgrade the error to a warning.
- `command_requires_script` — a command node without a script is an
error. Without this the original trap just moves: a node meant as a
command that omits its script silently becomes an agent again.
Also drops the `tool_command` alias in favor of `script` alone, routing
the six read sites through a new `Node::script()` accessor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diagnostics have carried a `fix` field all along, but the CLI renderer
never printed it — the suggestion was only reachable through --json. The
actionable half of every validation failure was invisible to the person
running the command.
print_diagnostics now emits the fix as a dim-labelled continuation line
under any diagnostic that has one, at both error and warning severity.
Gating it behind --verbose would defeat the point, and printing it only
for errors would read as "this warning has no fix" — the warning
suggestions are useful on their own. Diagnostics that set no fix simply
omit the line.
The severity match moved into print_diagnostic so the fix line is
appended once in the loop rather than copied into all five arms; the
rest of the diff is reindentation.
print_diagnostics is shared by validate, preflight, graph, exec, and
dry-run, so this covers all five. Eleven inline snapshots across four
files gain a fix line; every change is additive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The DOT parser created a node for every edge endpoint, and nothing
recorded whether a node came from a declaration or was synthesized from
an edge. The edge_target_exists rule only checked whether the node id
was present in the graph, which was always true by then, so a misspelled
endpoint became an attribute-free node that defaulted to shape=box — an
LLM stage. Validation emitted a prompt_on_llm_nodes warning and exited 0.
Node now carries `implicit`, set only when the parser synthesizes the
node from an edge endpoint. A declaration anywhere in the workflow
clears it, so order does not matter and subgraph declarations count.
Node::new leaves it false, so programmatic construction and graphs
deserialized from older checkpoints read as declared.
edge_target_exists treats an endpoint as valid only when it exists and
is declared, reporting each undeclared node once. The near-identical
missing-source and missing-target branches collapse into one path. The
import transform copies the flag onto spliced nodes so an edge-only node
inside an imported fragment is caught too.
parse_and_validate_human_gate had two edge-only nodes and now declares
them; it was an instance of the bug rather than a casualty of the fix.
No shipped workflow, docs example, or CLI fixture relied on the old
behavior.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>