Skip to content

Fix/self healing#127

Open
Christiantyemele wants to merge 3 commits into
mainfrom
fix/self-healing
Open

Fix/self healing#127
Christiantyemele wants to merge 3 commits into
mainfrom
fix/self-healing

Conversation

@Christiantyemele

@Christiantyemele Christiantyemele commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Summary by Gitar

  • Self-healing and Recovery:
    • Implemented a submit_decision tool for structural LLM responses and added automated retry/recovery logic to the AgentRunner.
    • Added NODE_ERROR routing in pocketflow-core to allow the flow to retry failed nodes or escalate to human intervention.
    • Added state reconciliation in NexusNode to recover stuck tickets by resetting status.
  • Enhanced Model Discovery:
    • Updated discover_models for Anthropic, OpenAI, and Fireworks providers to use paginated API requests and fallback mechanisms.
    • Standardized default models (e.g., updating Fireworks default to deepseek-v3p1).
  • Miscellaneous:
    • Bumped package version to v1.1.7.

This will update automatically on new commits.

AgentFlow added 3 commits June 22, 2026 11:00
- Fireworks: paginate /inference/v1/models (limit=200, cursor-based),
  30s timeout, structured error logging, accept all
  accounts/fireworks/models/ prefixed models, minimal 2-model fallback
- Anthropic: paginate /v1/models (limit=1000, after_id cursor),
  30s timeout, structured error logging, minimal 2-model fallback
- OpenAI: request /v1/models with limit=500, structured error handling,
  minimal 2-model fallback (previously returned bare error)
- All providers: graceful degradation chain (API → CLI → hardcoded
  fallback with tracing::warn), no more silent failures
- Update default Fireworks model from llama-v3p1-8b-instruct to
  deepseek-v3p1 across fireworks.rs, fallback.rs, mod.rs, process.rs
- Add 3 new unit tests for fallback lists (Anthropic, OpenAI, Fireworks)
Implements a structural separation between LLM reasoning and decisions
using the tool calling API, eliminating the crash class where unparseable
JSON responses terminated the entire orchestration flow.

Primary path (Decision Tool):
- Inject a `submit_decision` tool alongside MCP tools so the LLM calls
  it structurally when ready to decide. Reasoning stays in text, the
  decision is a tool call with parsed JSON args. Works across all
  providers (Anthropic, OpenAI, Gemini, Fireworks).
- System prompt explicitly instructs: "Reason in text, decide via
  submit_decision tool call."

Fallback path (enhanced text extraction):
- 7 parsing strategies: clean JSON, markdown blocks (```json and
  ```), last JSON object, truncated JSON repair, line-by-line,
  loose key extraction, pattern matching.
- Retry with clarified prompt up to DECISION_RETRY_LIMIT=2 times.
- Safe default: returns `no_work` AgentDecision with [SELF-HEAL]
  prefix instead of crashing the flow.

Flow-level error recovery:
- Flow::run() catches node errors and retries up to max_retries=3.
- Routes to `node_error` or `awaiting_human` handlers before giving up.
- Emits `node_error_recovery` events to SharedStore for observability.

NexusNode self-healing:
- awaiting_human escalation: LLM can return action "awaiting_human"
  when stuck. Marks tickets as AwaitingHuman, suspends workers, stores
  reason in SharedStore for TUI visibility.
- completed_without_pr recovery: tickets marked completed with outcome
  "pr_opened" but no matching PR are reset to Open for re-assignment.
- Auto-resolve unrecognized STATUS.json statuses.

Configurable flow wiring:
- nexus → node_error → nexus (retry on LLM/api failures)
- forge_pair → node_error → nexus (retry on build failures)
- vessel → node_error → nexus (retry on merge failures)
- All nodes → awaiting_human → nexus (pause for human input)
Comment on lines +128 to +140
match flow_node.node.run(store).await {
Ok(action) => {
// Successful execution — clear retry tracker
if let Some(ref tracker) = &retry_tracker {
if tracker.node_name == current {
info!(
node = %current,
retries = tracker.count,
"Node recovered after retries — clearing retry tracker"
);
}
retry_tracker = None;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: Retry counter reset on unrelated node success defeats max_retries

In Flow::run, the retry tracker is cleared on any successful node execution, not just when the node that was retrying succeeds.

At flow.rs:131-140, retry_tracker = None; is placed inside the outer if let Some(ref tracker) block but outside the inner if tracker.node_name == current check. So whenever a recovery/intermediate node succeeds, the failure count for the actually-failing node is wiped.

Consider the wired routes in agentflow.rs: forge_pair and vessel route NODE_ERROR -> nexus. When forge_pair fails, the tracker becomes {forge_pair, 1} and control routes to nexus. nexus runs successfully and resets the tracker to None. nexus then routes back to forge_pair, which fails again — but retry_count restarts at 1. As a result retry_count can never exceed max_retries for any node whose error route points to a different node that succeeds. The intended max_retries safety cap (default 3) is bypassed, and the flow can livelock — making expensive agent/LLM/subprocess calls — until the far larger max_steps cap (10_000) is finally hit and bails. The added test test_flow_retry_on_node_error uses a self-routing node (empty routes -> self-loop), so it does not exercise the cross-node recovery path and misses this bug.

Fix: only clear the tracker when the recovering node itself succeeds (move the reset inside the node_name == current branch).

Only reset the retry tracker when the node that was being retried is the one that succeeded, so consecutive cross-node failures still accumulate toward max_retries.:

if let Some(ref tracker) = &retry_tracker {
    if tracker.node_name == current {
        info!(
            node = %current,
            retries = tracker.count,
            "Node recovered after retries — clearing retry tracker"
        );
        retry_tracker = None;
    }
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines +5 to +10
//
// Error Recovery: When a node's exec() phase fails, the system no longer
// crashes. Instead, it emits an error event and returns a fallback action
// that routes back to the calling node for retry or safe degradation.
// This enables self-healing behavior — the flow continues even when an
// individual agent encounteres a transient failure.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: node.rs docs claim error swallowing that code doesn't do; NODE_ERROR action path is dead

Two related inconsistencies introduced by this PR:

  1. The new doc comments on Node::run (node.rs:6-10, 26-29, 46-49) state that on error in any phase, run() "emits an error event and returns a fallback Action rather than crashing/propagating the error." The actual implementation (node.rs:56-77) still propagates every phase error with ?. The recovery is performed by the caller (Flow::run), not by run() itself. The comments are misleading for maintainers and contradict the code.

  2. NODE_ERROR ("node_error") is defined and the Flow::run Ok-branch handles a node returning this action (flow.rs:149-174), but no Node::post implementation in the codebase ever returns NODE_ERROR. That branch is currently dead code. Consider either having nodes actually return NODE_ERROR for recoverable internal failures, or removing/annotating the unused branch to avoid confusion.

Neither is a runtime bug, but both will mislead future readers about how self-healing actually works.

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown
CI failed: The build failed due to formatting inconsistencies, spelling errors in documentation, and Clippy lint violations in the agent-client and agent-nexus modules.

Overview

Three distinct CI jobs failed (Format, Spelling, and Clippy) due to style discrepancies, documentation typos, and non-idiomatic Rust code introduced in the recent 'self-healing' changes. All failures are direct results of the current PR and require cleanup.

Failures

Formatting Inconsistencies (confidence: high)

  • Type: test
  • Affected jobs: Format (82967462335)
  • Related to change: yes
  • Root cause: The codebase contains formatting violations in binary/src/bin/agentflow.rs and crates/agent-client/src/runner.rs that do not comply with rustfmt.
  • Suggested fix: Run cargo fmt --all locally and commit the formatted changes.

Spelling Errors (confidence: high)

  • Type: build
  • Affected jobs: Spelling (82967462306)
  • Related to change: yes
  • Root cause: Documentation comments contain typos: 'unparseable' should be 'unparsable', and 'encounteres' should be 'encounters'.
  • Suggested fix: Fix spelling in crates/agent-nexus/src/lib.rs, crates/agent-client/src/runner.rs, and crates/pocketflow-core/src/node.rs.

Clippy Violations (confidence: high)

  • Type: build
  • Affected jobs: Clippy (82967462235)
  • Related to change: yes
  • Root cause: Use of format! for static strings and manual slice-based prefix stripping (e.g., s[1..].find(...)) instead of using strip_prefix in crates/agent-client/src/runner.rs.
  • Suggested fix: Replace format! with .to_string() for static strings and refactor manual indexing to use strip_prefix as recommended by the compiler.

Summary

  • Change-related failures: 3 (Format, Spelling, and Clippy errors all stem from the recent feature addition).
  • Infrastructure/flaky failures: 0
  • Recommended action: The developer should run cargo fmt --all to fix formatting, manually correct the reported spelling typos, and apply the suggested refactorings (using strip_prefix and replacing format!) to satisfy Clippy.
Code Review ⚠️ Changes requested 0 resolved / 2 findings

Implements Decision Tool patterns and crash recovery, but requires fixes for the retry counter logic that incorrectly resets on unrelated node successes and the inconsistent error swallowing documentation.

⚠️ Bug: Retry counter reset on unrelated node success defeats max_retries

📄 crates/pocketflow-core/src/flow.rs:128-140 📄 crates/pocketflow-core/src/flow.rs:192-206 📄 binary/src/bin/agentflow.rs:221-235

In Flow::run, the retry tracker is cleared on any successful node execution, not just when the node that was retrying succeeds.

At flow.rs:131-140, retry_tracker = None; is placed inside the outer if let Some(ref tracker) block but outside the inner if tracker.node_name == current check. So whenever a recovery/intermediate node succeeds, the failure count for the actually-failing node is wiped.

Consider the wired routes in agentflow.rs: forge_pair and vessel route NODE_ERROR -> nexus. When forge_pair fails, the tracker becomes {forge_pair, 1} and control routes to nexus. nexus runs successfully and resets the tracker to None. nexus then routes back to forge_pair, which fails again — but retry_count restarts at 1. As a result retry_count can never exceed max_retries for any node whose error route points to a different node that succeeds. The intended max_retries safety cap (default 3) is bypassed, and the flow can livelock — making expensive agent/LLM/subprocess calls — until the far larger max_steps cap (10_000) is finally hit and bails. The added test test_flow_retry_on_node_error uses a self-routing node (empty routes -> self-loop), so it does not exercise the cross-node recovery path and misses this bug.

Fix: only clear the tracker when the recovering node itself succeeds (move the reset inside the node_name == current branch).

Only reset the retry tracker when the node that was being retried is the one that succeeded, so consecutive cross-node failures still accumulate toward max_retries.
if let Some(ref tracker) = &retry_tracker {
    if tracker.node_name == current {
        info!(
            node = %current,
            retries = tracker.count,
            "Node recovered after retries — clearing retry tracker"
        );
        retry_tracker = None;
    }
}
💡 Quality: node.rs docs claim error swallowing that code doesn't do; NODE_ERROR action path is dead

📄 crates/pocketflow-core/src/node.rs:5-10 📄 crates/pocketflow-core/src/node.rs:26-29 📄 crates/pocketflow-core/src/node.rs:46-49 📄 crates/pocketflow-core/src/node.rs:56-70 📄 crates/pocketflow-core/src/node.rs:100-102 📄 crates/pocketflow-core/src/flow.rs:148-162

Two related inconsistencies introduced by this PR:

  1. The new doc comments on Node::run (node.rs:6-10, 26-29, 46-49) state that on error in any phase, run() "emits an error event and returns a fallback Action rather than crashing/propagating the error." The actual implementation (node.rs:56-77) still propagates every phase error with ?. The recovery is performed by the caller (Flow::run), not by run() itself. The comments are misleading for maintainers and contradict the code.

  2. NODE_ERROR ("node_error") is defined and the Flow::run Ok-branch handles a node returning this action (flow.rs:149-174), but no Node::post implementation in the codebase ever returns NODE_ERROR. That branch is currently dead code. Consider either having nodes actually return NODE_ERROR for recoverable internal failures, or removing/annotating the unused branch to avoid confusion.

Neither is a runtime bug, but both will mislead future readers about how self-healing actually works.

🤖 Prompt for agents
Code Review: Implements Decision Tool patterns and crash recovery, but requires fixes for the retry counter logic that incorrectly resets on unrelated node successes and the inconsistent error swallowing documentation.

1. ⚠️ Bug: Retry counter reset on unrelated node success defeats max_retries
   Files: crates/pocketflow-core/src/flow.rs:128-140, crates/pocketflow-core/src/flow.rs:192-206, binary/src/bin/agentflow.rs:221-235

   In `Flow::run`, the retry tracker is cleared on *any* successful node execution, not just when the node that was retrying succeeds.
   
   At flow.rs:131-140, `retry_tracker = None;` is placed inside the outer `if let Some(ref tracker)` block but outside the inner `if tracker.node_name == current` check. So whenever a recovery/intermediate node succeeds, the failure count for the actually-failing node is wiped.
   
   Consider the wired routes in agentflow.rs: `forge_pair` and `vessel` route `NODE_ERROR -> nexus`. When `forge_pair` fails, the tracker becomes `{forge_pair, 1}` and control routes to `nexus`. `nexus` runs successfully and resets the tracker to `None`. `nexus` then routes back to `forge_pair`, which fails again — but `retry_count` restarts at 1. As a result `retry_count` can never exceed `max_retries` for any node whose error route points to a different node that succeeds. The intended `max_retries` safety cap (default 3) is bypassed, and the flow can livelock — making expensive agent/LLM/subprocess calls — until the far larger `max_steps` cap (10_000) is finally hit and bails. The added test `test_flow_retry_on_node_error` uses a self-routing node (empty routes -> self-loop), so it does not exercise the cross-node recovery path and misses this bug.
   
   Fix: only clear the tracker when the recovering node itself succeeds (move the reset inside the `node_name == current` branch).

   Fix (Only reset the retry tracker when the node that was being retried is the one that succeeded, so consecutive cross-node failures still accumulate toward max_retries.):
   if let Some(ref tracker) = &retry_tracker {
       if tracker.node_name == current {
           info!(
               node = %current,
               retries = tracker.count,
               "Node recovered after retries — clearing retry tracker"
           );
           retry_tracker = None;
       }
   }

2. 💡 Quality: node.rs docs claim error swallowing that code doesn't do; NODE_ERROR action path is dead
   Files: crates/pocketflow-core/src/node.rs:5-10, crates/pocketflow-core/src/node.rs:26-29, crates/pocketflow-core/src/node.rs:46-49, crates/pocketflow-core/src/node.rs:56-70, crates/pocketflow-core/src/node.rs:100-102, crates/pocketflow-core/src/flow.rs:148-162

   Two related inconsistencies introduced by this PR:
   
   1. The new doc comments on `Node::run` (node.rs:6-10, 26-29, 46-49) state that on error in any phase, `run()` "emits an error event and returns a fallback Action rather than crashing/propagating the error." The actual implementation (node.rs:56-77) still propagates every phase error with `?`. The recovery is performed by the caller (`Flow::run`), not by `run()` itself. The comments are misleading for maintainers and contradict the code.
   
   2. `NODE_ERROR` ("node_error") is defined and the `Flow::run` Ok-branch handles a node *returning* this action (flow.rs:149-174), but no `Node::post` implementation in the codebase ever returns `NODE_ERROR`. That branch is currently dead code. Consider either having nodes actually return `NODE_ERROR` for recoverable internal failures, or removing/annotating the unused branch to avoid confusion.
   
   Neither is a runtime bug, but both will mislead future readers about how self-healing actually works.

Tip

Comment Gitar fix CI or enable auto-apply: gitar auto-apply:on

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Important

Your trial ends in 2 days — upgrade now to keep code review, CI analysis, auto-apply, custom automations, and more.

Was this helpful? React with 👍 / 👎 | Gitar


// max_turns exceeded — recover gracefully instead of crashing
warn!(
agent = persona.id,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[logic] (confidence: 85%)

The recovery loop destructures LlmResponse::ToolCall with { name, args, .. }, discarding the LLM-provided id field. It then constructs assistant_tool_use and tool_result messages using a synthetic ID ("recovery-tool-{attempt}"). The main loop correctly uses the LLM's id (LlmResponse::ToolCall { id, name, args }). Using a synthetic ID that doesn't match the LLM's original tool call ID can cause API errors with providers like Anthropic that validate tool_use/tool_result ID consistency in conversation history.

Impact: When the LLM calls a non-decision tool during recovery, the conversation history will contain mismatched tool call IDs. LLM APIs that validate tool_use/tool_result ID pairing may reject the subsequent request, causing the recovery path to fail and fall back to a safe default decision unnecessarily.

Evidence:

Ok(LlmResponse::ToolCall { name, args, .. }) => {
    // ...
    let tool_call_id = format!("recovery-tool-{}", attempt);
    messages.push(Message::assistant_tool_use(
        tool_call_id.clone(),
        name,
        args,
    ));
    messages.push(Message::tool_result(tool_call_id, tool_result));

Suggestion: Capture the id field from LlmResponse::ToolCall and use it instead of generating a synthetic one, matching the pattern used in the main loop: LlmResponse::ToolCall { id, name, args } then Message::assistant_tool_use(id.clone(), name, args) and Message::tool_result(id, tool_result).

let issue_url = extract_json_string_value(text, "issue_url");

warn!(
action = %action,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[logic] (confidence: 75%)

Both extract_json_string_value and extract_quoted_value use find (first occurrence) to locate key patterns, while the main extraction logic in extract_decision (step 3) uses rfind (last occurrence). The step 3 comment explicitly states: 'we prefer the last one because the LLM may produce multiple JSON-like fragments during reasoning, and the final one is most likely the actual decision.' The fallback functions violate this principle by preferring the first occurrence.

Impact: When the LLM produces multiple JSON-like fragments (e.g., reasoning followed by a final decision), the fallback extraction may extract stale values from an earlier fragment instead of the final decision, causing the agent to take the wrong action.

Evidence:

fn extract_json_string_value(text: &str, key: &str) -> Option<String> {
    // ...
    for pattern in [&pattern_standard as &str, pattern_no_quotes_key] {
        if let Some(pos) = text.find(pattern) {
// ...
fn extract_quoted_value(text: &str, key: &str) -> Option<String> {
    // ...
    for pattern in &patterns {
        if let Some(pos) = text.find(pattern.as_str()) {

Suggestion: Change text.find(pattern) to text.rfind(pattern) in both extract_json_string_value and extract_quoted_value to be consistent with the main extraction logic's preference for the last occurrence.

@github-actions

Copy link
Copy Markdown

Snif Review

This change implements self-healing and crash recovery for the agent orchestration system. It adds a submit_decision tool for structured LLM responses with retry/recovery logic in AgentRunner, introduces NODE_ERROR routing in the flow engine to retry failed nodes or escalate to human intervention, adds state reconciliation in NexusNode to recover stuck tickets, and updates model discovery across providers with pagination and fallback lists (switching the Fireworks default from llama-v3p1-8b-instruct to deepseek-v3p1).

Found 2 issues. See inline comments for details.

Review details

Changed files:

  • Cargo.lock
  • binary/Cargo.toml
  • binary/src/bin/agentflow.rs
  • crates/agent-client/src/fallback.rs
  • crates/agent-client/src/fireworks.rs
  • crates/agent-client/src/runner.rs
  • crates/agent-nexus/src/lib.rs
  • crates/agentflow-tui/src/setup/mod.rs
  • crates/agentflow-tui/src/setup/model_discovery.rs
  • crates/pair-harness/src/process.rs
  • crates/pocketflow-core/src/flow.rs
  • crates/pocketflow-core/src/lib.rs
  • crates/pocketflow-core/src/node.rs

Context analyzed: 100 related files (34 via semantic similarity, 99 via keyword matching).

Stats: 2133 lines | glm-5p1 | 227s

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant