mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
Unified dry-run via Handler::simulate() (#14)
This PR introduces a unified dry-run mechanism by adding a
`Handler::simulate()` trait method and a `dispatch_handler()` routing
function that selects between `simulate()` and `execute()` based on
`services.dry_run`. Previously, dry-run behavior was scattered
inconsistently across handlers—`CommandHandler` checked
`services.dry_run` inline, `AgentHandler`/`PromptHandler`/`FanInHandler`
relied on the backend being `None`, and `WaitHandler`/`HumanHandler` had
no dry-run support at all (sleeping or blocking on input for real). This
made dry-run behavior fragile and difficult to extend to new handlers.
The new design adds an `Outcome::simulated(node_id)` factory for
standardized dry-run results, a default `simulate()` implementation on
the `Handler` trait that returns a generic simulated success, and
per-handler overrides where custom context updates are needed.
`CommandHandler` populates empty output/stderr, `AgentHandler` and
`PromptHandler` set simulated
`last_stage`/`last_response`/`response.{id}` context keys,
`FanInHandler` calls `heuristic_select()` without an LLM, `HumanHandler`
auto-selects the first choice, and `ParallelHandler` dispatches child
branches through `dispatch_handler()` while skipping git worktree
operations. The inline `dry_run` check in `CommandHandler::execute()` is
removed, and both call sites in the engine (`execute_with_retry` and
parallel branch dispatch) now route through `dispatch_handler()`.
All existing dry-run tests are updated to test `simulate()` directly,
and new tests verify that `dispatch_handler()` correctly routes based on
the `dry_run` flag and that each handler's `simulate()` produces the
expected context updates and outcome structure.
### Fabro Details
<details>
<summary>Ran 7 stages in 24m 57s for $4.72</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 0s | – | 0 |
| preflight_lint | 0s | – | 0 |
| implement | 0s | $3.29 | 0 |
| simplify | 0s | $1.42 | 0 |
| verify | 0s | – | 0 |
| **Total** | **24m 57s** | **$4.72** | **0** |
</details>
<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (10 nodes and 13
edges)</summary>
```dot
digraph ImplementAndSimplify {
graph [
goal="Implement and simplify",
model_stylesheet="
* { backend: api; model: claude-opus-4-6;}
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan."]
simplify [label="Simplify", prompt="@prompts/simplify.md"]
verify [label="Verify", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1 && cargo test 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=success"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=success"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=success"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify -> verify
verify -> exit [condition="outcome=success"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Fabro Assistant <assistant@fabro.dev>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7ce7a842cc
commit
da3610af59
9 changed files with 478 additions and 69 deletions
|
|
@ -1017,7 +1017,14 @@ impl WorkflowRunEngine {
|
|||
|
||||
// Gap #11: Panic safety -- catch panics from handler execution
|
||||
let result = {
|
||||
let future = handler.execute(node, context, graph, run_dir, &self.services);
|
||||
let future = crate::handler::dispatch_handler(
|
||||
handler,
|
||||
node,
|
||||
context,
|
||||
graph,
|
||||
run_dir,
|
||||
&self.services,
|
||||
);
|
||||
let panic_safe = AssertUnwindSafe(future).catch_unwind();
|
||||
// Gap #2: Timeout enforcement -- wrap with tokio::time::timeout
|
||||
let timed_result = if let Some(duration) = node_timeout {
|
||||
|
|
|
|||
|
|
@ -196,8 +196,38 @@ pub(crate) fn truncate(s: &str, max_chars: usize) -> &str {
|
|||
}
|
||||
}
|
||||
|
||||
/// Shared simulate implementation for LLM-backed handlers (agent & prompt).
|
||||
/// Produces a simulated outcome with standard context updates.
|
||||
pub(crate) fn simulate_llm_handler(node: &Node) -> Outcome {
|
||||
let simulated_text = format!("[Simulated] Response for stage: {}", node.id);
|
||||
let mut outcome = Outcome::simulated(&node.id);
|
||||
outcome
|
||||
.context_updates
|
||||
.insert(keys::LAST_STAGE.to_string(), serde_json::json!(node.id));
|
||||
outcome.context_updates.insert(
|
||||
keys::LAST_RESPONSE.to_string(),
|
||||
serde_json::json!(truncate(&simulated_text, 200)),
|
||||
);
|
||||
outcome.context_updates.insert(
|
||||
keys::response_key(&node.id),
|
||||
serde_json::json!(&simulated_text),
|
||||
);
|
||||
outcome
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Handler for AgentHandler {
|
||||
async fn simulate(
|
||||
&self,
|
||||
node: &Node,
|
||||
_context: &Context,
|
||||
_graph: &Graph,
|
||||
_run_dir: &Path,
|
||||
_services: &EngineServices,
|
||||
) -> Result<Outcome, FabroError> {
|
||||
Ok(simulate_llm_handler(node))
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
node: &Node,
|
||||
|
|
@ -353,37 +383,28 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codergen_handler_simulation_mode() {
|
||||
async fn codergen_handler_simulate() {
|
||||
let handler = AgentHandler::new(None);
|
||||
let mut node = Node::new("plan");
|
||||
node.attrs.insert(
|
||||
"prompt".to_string(),
|
||||
AttrValue::String("Plan the implementation".to_string()),
|
||||
);
|
||||
let node = Node::new("plan");
|
||||
let context = Context::new();
|
||||
let graph = Graph::new("test");
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let outcome = handler
|
||||
.execute(&node, &context, &graph, tmp.path(), &make_services())
|
||||
.simulate(&node, &context, &graph, tmp.path(), &make_services())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.status, crate::outcome::StageStatus::Success);
|
||||
assert_eq!(outcome.notes.as_deref(), Some("Stage completed: plan"));
|
||||
|
||||
// Check files were written
|
||||
let prompt_path = tmp.path().join("nodes").join("plan").join("prompt.md");
|
||||
assert!(prompt_path.exists());
|
||||
let prompt_content = std::fs::read_to_string(&prompt_path).unwrap();
|
||||
assert_eq!(prompt_content, "Plan the implementation");
|
||||
|
||||
let response_path = tmp.path().join("nodes").join("plan").join("response.md");
|
||||
assert!(response_path.exists());
|
||||
let response_content = std::fs::read_to_string(&response_path).unwrap();
|
||||
assert!(response_content.contains("[Simulated]"));
|
||||
|
||||
let status_path = tmp.path().join("nodes").join("plan").join("status.json");
|
||||
assert!(status_path.exists());
|
||||
assert_eq!(outcome.notes.as_deref(), Some("[Simulated] plan"));
|
||||
assert_eq!(
|
||||
outcome.context_updates.get(keys::LAST_STAGE),
|
||||
Some(&serde_json::json!("plan"))
|
||||
);
|
||||
assert!(outcome.context_updates.contains_key(keys::LAST_RESPONSE));
|
||||
assert_eq!(
|
||||
outcome.context_updates.get(&keys::response_key("plan")),
|
||||
Some(&serde_json::json!("[Simulated] Response for stage: plan"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -27,6 +27,32 @@ pub struct CommandHandler;
|
|||
|
||||
#[async_trait]
|
||||
impl Handler for CommandHandler {
|
||||
async fn simulate(
|
||||
&self,
|
||||
node: &Node,
|
||||
_context: &Context,
|
||||
_graph: &Graph,
|
||||
_run_dir: &Path,
|
||||
_services: &EngineServices,
|
||||
) -> Result<Outcome, FabroError> {
|
||||
let script = node
|
||||
.attrs
|
||||
.get("script")
|
||||
.or_else(|| node.attrs.get("tool_command"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let mut outcome = Outcome::simulated(&node.id);
|
||||
outcome.notes = Some(format!("[Simulated] Command skipped: {script}"));
|
||||
outcome
|
||||
.context_updates
|
||||
.insert(keys::COMMAND_OUTPUT.to_string(), serde_json::json!(""));
|
||||
outcome
|
||||
.context_updates
|
||||
.insert(keys::COMMAND_STDERR.to_string(), serde_json::json!(""));
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
node: &Node,
|
||||
|
|
@ -46,18 +72,6 @@ impl Handler for CommandHandler {
|
|||
return Ok(Outcome::fail_classify("No script specified"));
|
||||
}
|
||||
|
||||
if services.dry_run {
|
||||
let mut outcome = Outcome::success();
|
||||
outcome.notes = Some(format!("[Simulated] Command skipped: {script}"));
|
||||
outcome
|
||||
.context_updates
|
||||
.insert(keys::COMMAND_OUTPUT.to_string(), serde_json::json!(""));
|
||||
outcome
|
||||
.context_updates
|
||||
.insert(keys::COMMAND_STDERR.to_string(), serde_json::json!(""));
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
let language = node
|
||||
.attrs
|
||||
.get("language")
|
||||
|
|
@ -188,7 +202,36 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dry_run_skips_execution() {
|
||||
async fn simulate_skips_execution() {
|
||||
let handler = CommandHandler;
|
||||
let mut node = Node::new("script_node");
|
||||
node.attrs.insert(
|
||||
"script".to_string(),
|
||||
AttrValue::String("echo hello".to_string()),
|
||||
);
|
||||
let context = Context::new();
|
||||
let graph = Graph::new("test");
|
||||
let run_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let outcome = handler
|
||||
.simulate(&node, &context, &graph, run_dir.path(), &make_services())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.status, StageStatus::Success);
|
||||
assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]"));
|
||||
assert!(outcome.notes.as_deref().unwrap().contains("echo hello"));
|
||||
assert_eq!(
|
||||
outcome.context_updates.get(keys::COMMAND_OUTPUT),
|
||||
Some(&serde_json::json!(""))
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.context_updates.get(keys::COMMAND_STDERR),
|
||||
Some(&serde_json::json!(""))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_routes_to_simulate_in_dry_run() {
|
||||
let handler = CommandHandler;
|
||||
let mut node = Node::new("script_node");
|
||||
node.attrs.insert(
|
||||
|
|
@ -202,16 +245,18 @@ mod tests {
|
|||
let mut services = make_services();
|
||||
services.dry_run = true;
|
||||
|
||||
let outcome = handler
|
||||
.execute(&node, &context, &graph, run_dir.path(), &services)
|
||||
.await
|
||||
.unwrap();
|
||||
let outcome = crate::handler::dispatch_handler(
|
||||
&handler,
|
||||
&node,
|
||||
&context,
|
||||
&graph,
|
||||
run_dir.path(),
|
||||
&services,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.status, StageStatus::Success);
|
||||
assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]"));
|
||||
assert!(outcome.notes.as_deref().unwrap().contains("echo hello"));
|
||||
// No stdout/stderr logs should be written
|
||||
let stage_dir = run_dir.path().join("nodes").join("script_node");
|
||||
assert!(!stage_dir.join("stdout.log").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -28,6 +28,37 @@ impl FanInHandler {
|
|||
|
||||
#[async_trait]
|
||||
impl Handler for FanInHandler {
|
||||
async fn simulate(
|
||||
&self,
|
||||
node: &Node,
|
||||
context: &Context,
|
||||
_graph: &Graph,
|
||||
_run_dir: &Path,
|
||||
_services: &EngineServices,
|
||||
) -> Result<Outcome, FabroError> {
|
||||
let results = context.get(keys::PARALLEL_RESULTS);
|
||||
let Some(results) = results else {
|
||||
return Ok(Outcome::fail_deterministic(
|
||||
"No parallel results to evaluate",
|
||||
));
|
||||
};
|
||||
|
||||
let best = heuristic_select(&results);
|
||||
|
||||
let mut outcome = Outcome::simulated(&node.id);
|
||||
outcome.context_updates.insert(
|
||||
keys::PARALLEL_FAN_IN_BEST_ID.to_string(),
|
||||
serde_json::json!(best.id),
|
||||
);
|
||||
outcome.context_updates.insert(
|
||||
keys::PARALLEL_FAN_IN_BEST_OUTCOME.to_string(),
|
||||
serde_json::json!(best.status),
|
||||
);
|
||||
// Override the generic simulated notes with handler-specific detail.
|
||||
outcome.notes = Some(format!("[Simulated] Selected best candidate: {}", best.id));
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
node: &Node,
|
||||
|
|
@ -520,4 +551,31 @@ mod tests {
|
|||
Some(&serde_json::json!("branch_b"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fan_in_simulate_uses_heuristic() {
|
||||
let handler = FanInHandler::new(None);
|
||||
let node = Node::new("fan_in");
|
||||
let context = Context::new();
|
||||
context.set(
|
||||
keys::PARALLEL_RESULTS,
|
||||
serde_json::json!([
|
||||
{"id": "branch_a", "status": "fail"},
|
||||
{"id": "branch_b", "status": "success"},
|
||||
]),
|
||||
);
|
||||
let graph = Graph::new("test");
|
||||
let run_dir = Path::new("/tmp/test");
|
||||
|
||||
let outcome = handler
|
||||
.simulate(&node, &context, &graph, run_dir, &make_services())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.status, StageStatus::Success);
|
||||
assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]"));
|
||||
assert_eq!(
|
||||
outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID),
|
||||
Some(&serde_json::json!("branch_b"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,49 @@ impl HumanHandler {
|
|||
|
||||
#[async_trait]
|
||||
impl Handler for HumanHandler {
|
||||
async fn simulate(
|
||||
&self,
|
||||
node: &Node,
|
||||
_context: &Context,
|
||||
graph: &Graph,
|
||||
_run_dir: &Path,
|
||||
_services: &EngineServices,
|
||||
) -> Result<Outcome, FabroError> {
|
||||
let edges = graph.outgoing_edges(&node.id);
|
||||
let first_choice = edges.iter().find(|e| !e.freeform());
|
||||
|
||||
if let Some(edge) = first_choice {
|
||||
let label = edge.label().filter(|l| !l.is_empty()).unwrap_or(&edge.to);
|
||||
let key = parse_accelerator_key(label);
|
||||
let mut outcome = Outcome::simulated(&node.id);
|
||||
outcome.preferred_label = Some(label.to_string());
|
||||
outcome.suggested_next_ids = vec![edge.to.clone()];
|
||||
outcome.context_updates.insert(
|
||||
keys::HUMAN_GATE_SELECTED.to_string(),
|
||||
serde_json::json!(key),
|
||||
);
|
||||
outcome
|
||||
.context_updates
|
||||
.insert(keys::HUMAN_GATE_LABEL.to_string(), serde_json::json!(label));
|
||||
Ok(outcome)
|
||||
} else if let Some(edge) = edges.first() {
|
||||
// Only freeform edges — pick the first one
|
||||
let mut outcome = Outcome::simulated(&node.id);
|
||||
outcome.suggested_next_ids = vec![edge.to.clone()];
|
||||
outcome.context_updates.insert(
|
||||
keys::HUMAN_GATE_SELECTED.to_string(),
|
||||
serde_json::json!("freeform"),
|
||||
);
|
||||
outcome.context_updates.insert(
|
||||
keys::HUMAN_GATE_LABEL.to_string(),
|
||||
serde_json::json!("[Simulated] auto-selected"),
|
||||
);
|
||||
Ok(outcome)
|
||||
} else {
|
||||
Ok(Outcome::simulated(&node.id))
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
node: &Node,
|
||||
|
|
@ -466,4 +509,26 @@ mod tests {
|
|||
assert_eq!(recordings.len(), 1);
|
||||
assert_eq!(recordings[0].0.question_type, QuestionType::Freeform);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn simulate_selects_first_choice() {
|
||||
let interviewer = Arc::new(AutoApproveInterviewer);
|
||||
let handler = HumanHandler::new(interviewer);
|
||||
let graph = build_graph_with_human_gate();
|
||||
let node = graph.nodes.get("gate").unwrap();
|
||||
let context = Context::new();
|
||||
let run_dir = Path::new("/tmp/test");
|
||||
|
||||
let outcome = handler
|
||||
.simulate(node, &context, &graph, run_dir, &make_services())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.status, crate::outcome::StageStatus::Success);
|
||||
assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]"));
|
||||
assert_eq!(
|
||||
outcome.context_updates.get(keys::HUMAN_GATE_SELECTED),
|
||||
Some(&serde_json::json!("A"))
|
||||
);
|
||||
assert_eq!(outcome.suggested_next_ids, vec!["approve"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,19 @@ pub trait Handler: Send + Sync {
|
|||
services: &EngineServices,
|
||||
) -> Result<Outcome, FabroError>;
|
||||
|
||||
/// Produce a simulated result for dry-run mode.
|
||||
/// Override for handlers that need custom context updates.
|
||||
async fn simulate(
|
||||
&self,
|
||||
node: &Node,
|
||||
_context: &Context,
|
||||
_graph: &Graph,
|
||||
_run_dir: &Path,
|
||||
_services: &EngineServices,
|
||||
) -> Result<Outcome, FabroError> {
|
||||
Ok(Outcome::simulated(&node.id))
|
||||
}
|
||||
|
||||
/// Determines whether an error should be retried.
|
||||
/// Default implementation retries transient errors only.
|
||||
fn should_retry(&self, err: &FabroError) -> bool {
|
||||
|
|
@ -89,6 +102,27 @@ pub trait Handler: Send + Sync {
|
|||
}
|
||||
}
|
||||
|
||||
/// Route to [`Handler::simulate`] when `services.dry_run` is true, otherwise
|
||||
/// [`Handler::execute`].
|
||||
pub async fn dispatch_handler(
|
||||
handler: &dyn Handler,
|
||||
node: &Node,
|
||||
context: &Context,
|
||||
graph: &Graph,
|
||||
run_dir: &Path,
|
||||
services: &EngineServices,
|
||||
) -> Result<Outcome, FabroError> {
|
||||
if services.dry_run {
|
||||
handler
|
||||
.simulate(node, context, graph, run_dir, services)
|
||||
.await
|
||||
} else {
|
||||
handler
|
||||
.execute(node, context, graph, run_dir, services)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps handler type strings to handler implementations.
|
||||
pub struct HandlerRegistry {
|
||||
handlers: HashMap<String, Box<dyn Handler>>,
|
||||
|
|
@ -313,4 +347,43 @@ mod tests {
|
|||
let handler = registry.resolve(&node);
|
||||
let _ = handler;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_handler_routes_to_simulate_when_dry_run() {
|
||||
let handler = TestHandler {
|
||||
_name: "test".to_string(),
|
||||
};
|
||||
let node = Node::new("my_node");
|
||||
let context = Context::new();
|
||||
let graph = Graph::new("test");
|
||||
let run_dir = std::path::Path::new("/tmp/test");
|
||||
let mut services = EngineServices::test_default();
|
||||
services.dry_run = true;
|
||||
|
||||
let outcome = dispatch_handler(&handler, &node, &context, &graph, run_dir, &services)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.status, crate::outcome::StageStatus::Success);
|
||||
assert_eq!(outcome.notes.as_deref(), Some("[Simulated] my_node"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_handler_routes_to_execute_when_not_dry_run() {
|
||||
let handler = TestHandler {
|
||||
_name: "test".to_string(),
|
||||
};
|
||||
let node = Node::new("my_node");
|
||||
let context = Context::new();
|
||||
let graph = Graph::new("test");
|
||||
let run_dir = std::path::Path::new("/tmp/test");
|
||||
let mut services = EngineServices::test_default();
|
||||
services.dry_run = false;
|
||||
|
||||
let outcome = dispatch_handler(&handler, &node, &context, &graph, run_dir, &services)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.status, crate::outcome::StageStatus::Success);
|
||||
// execute() returns success with no notes
|
||||
assert!(outcome.notes.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,6 +205,68 @@ struct BranchResult {
|
|||
|
||||
#[async_trait]
|
||||
impl Handler for ParallelHandler {
|
||||
async fn simulate(
|
||||
&self,
|
||||
node: &Node,
|
||||
context: &Context,
|
||||
graph: &Graph,
|
||||
run_dir: &Path,
|
||||
services: &EngineServices,
|
||||
) -> Result<Outcome, FabroError> {
|
||||
let branches = graph.outgoing_edges(&node.id);
|
||||
if branches.is_empty() {
|
||||
return Ok(Outcome::fail_classify("No branches for parallel node"));
|
||||
}
|
||||
|
||||
// Dispatch each branch child via dispatch_handler (which will call simulate)
|
||||
let mut branch_results: Vec<BranchResult> = Vec::new();
|
||||
for edge in &branches {
|
||||
let target_id = &edge.to;
|
||||
if let Some(target_node) = graph.nodes.get(target_id) {
|
||||
let handler = services.registry.resolve(target_node);
|
||||
let branch_context = context.clone_context();
|
||||
let outcome = super::dispatch_handler(
|
||||
handler,
|
||||
target_node,
|
||||
&branch_context,
|
||||
graph,
|
||||
run_dir,
|
||||
services,
|
||||
)
|
||||
.await?;
|
||||
branch_results.push(BranchResult {
|
||||
id: target_id.clone(),
|
||||
outcome,
|
||||
head_sha: None,
|
||||
worktree_path: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let total = branch_results.len();
|
||||
context.set(keys::PARALLEL_BRANCH_COUNT, serde_json::json!(total));
|
||||
|
||||
let results_json: Vec<serde_json::Value> = branch_results
|
||||
.iter()
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"id": r.id,
|
||||
"status": r.outcome.status.to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
context.set(keys::PARALLEL_RESULTS, serde_json::json!(results_json));
|
||||
|
||||
let join_node = find_join_node(&branch_results, graph);
|
||||
|
||||
let mut outcome = Outcome::simulated(&node.id);
|
||||
outcome.notes = Some(format!(
|
||||
"[Simulated] Parallel node dispatched {total} branches"
|
||||
));
|
||||
outcome.jump_to_node = join_node;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
node: &Node,
|
||||
|
|
@ -447,15 +509,15 @@ impl Handler for ParallelHandler {
|
|||
dry_run,
|
||||
};
|
||||
let handler = registry.resolve(target_node);
|
||||
let outcome = handler
|
||||
.execute(
|
||||
target_node,
|
||||
&setup.branch_context,
|
||||
&graph,
|
||||
&run_dir,
|
||||
&branch_services,
|
||||
)
|
||||
.await?;
|
||||
let outcome = super::dispatch_handler(
|
||||
handler,
|
||||
target_node,
|
||||
&setup.branch_context,
|
||||
&graph,
|
||||
&run_dir,
|
||||
&branch_services,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Checkpoint commit after branch execution (capture head_sha)
|
||||
let head_sha = if has_git {
|
||||
|
|
@ -922,4 +984,48 @@ mod tests {
|
|||
assert_eq!(parse_error_policy("ignore"), ErrorPolicy::Ignore);
|
||||
assert_eq!(parse_error_policy("unknown"), ErrorPolicy::Continue);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parallel_handler_simulate() {
|
||||
let services = make_services();
|
||||
let mut node = Node::new("par");
|
||||
node.attrs.insert(
|
||||
"shape".to_string(),
|
||||
AttrValue::String("component".to_string()),
|
||||
);
|
||||
let context = Context::new();
|
||||
let mut graph = Graph::new("test");
|
||||
graph.nodes.insert("par".to_string(), node.clone());
|
||||
graph
|
||||
.nodes
|
||||
.insert("branch_a".to_string(), Node::new("branch_a"));
|
||||
graph
|
||||
.nodes
|
||||
.insert("branch_b".to_string(), Node::new("branch_b"));
|
||||
// Add a fan_in node reachable from both branches
|
||||
graph
|
||||
.nodes
|
||||
.insert("fan_in".to_string(), Node::new("fan_in"));
|
||||
graph.edges.push(Edge::new("par", "branch_a"));
|
||||
graph.edges.push(Edge::new("par", "branch_b"));
|
||||
graph.edges.push(Edge::new("branch_a", "fan_in"));
|
||||
graph.edges.push(Edge::new("branch_b", "fan_in"));
|
||||
|
||||
let run_dir = Path::new("/tmp/test");
|
||||
let mut dry_services = services;
|
||||
dry_services.dry_run = true;
|
||||
|
||||
let outcome = ParallelHandler
|
||||
.simulate(&node, &context, &graph, run_dir, &dry_services)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.status, StageStatus::Success);
|
||||
assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]"));
|
||||
assert!(outcome.notes.as_deref().unwrap().contains("2 branches"));
|
||||
assert_eq!(outcome.jump_to_node, Some("fan_in".to_string()));
|
||||
|
||||
let branch_count = context.get(keys::PARALLEL_BRANCH_COUNT);
|
||||
assert_eq!(branch_count, Some(serde_json::json!(2)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,17 @@ impl PromptHandler {
|
|||
|
||||
#[async_trait]
|
||||
impl Handler for PromptHandler {
|
||||
async fn simulate(
|
||||
&self,
|
||||
node: &Node,
|
||||
_context: &Context,
|
||||
_graph: &Graph,
|
||||
_run_dir: &Path,
|
||||
_services: &EngineServices,
|
||||
) -> Result<Outcome, FabroError> {
|
||||
Ok(super::agent::simulate_llm_handler(node))
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
node: &Node,
|
||||
|
|
@ -156,31 +167,36 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_handler_simulation_mode() {
|
||||
async fn prompt_handler_simulate() {
|
||||
let handler = PromptHandler::new(None);
|
||||
let mut node = Node::new("classify");
|
||||
node.attrs.insert(
|
||||
"prompt".to_string(),
|
||||
AttrValue::String("Classify this".to_string()),
|
||||
);
|
||||
let node = Node::new("classify");
|
||||
let context = Context::new();
|
||||
let graph = Graph::new("test");
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let outcome = handler
|
||||
.execute(&node, &context, &graph, tmp.path(), &make_services())
|
||||
.simulate(&node, &context, &graph, tmp.path(), &make_services())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.status, crate::outcome::StageStatus::Success);
|
||||
|
||||
let response_content = std::fs::read_to_string(
|
||||
tmp.path()
|
||||
.join("nodes")
|
||||
.join("classify")
|
||||
.join("response.md"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(response_content.contains("[Simulated]"));
|
||||
assert_eq!(outcome.notes.as_deref(), Some("[Simulated] classify"));
|
||||
assert_eq!(
|
||||
outcome
|
||||
.context_updates
|
||||
.get(crate::context::keys::LAST_STAGE),
|
||||
Some(&serde_json::json!("classify"))
|
||||
);
|
||||
assert!(outcome
|
||||
.context_updates
|
||||
.contains_key(crate::context::keys::LAST_RESPONSE));
|
||||
assert_eq!(
|
||||
outcome
|
||||
.context_updates
|
||||
.get(&crate::context::keys::response_key("classify")),
|
||||
Some(&serde_json::json!(
|
||||
"[Simulated] Response for stage: classify"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -215,6 +215,15 @@ impl Outcome {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a simulated success outcome for dry-run mode.
|
||||
#[must_use]
|
||||
pub fn simulated(node_id: &str) -> Self {
|
||||
Self {
|
||||
notes: Some(format!("[Simulated] {node_id}")),
|
||||
..Self::success()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the failure reason message, if any.
|
||||
pub fn failure_reason(&self) -> Option<&str> {
|
||||
self.failure.as_ref().map(|f| f.message.as_str())
|
||||
|
|
@ -440,4 +449,13 @@ mod tests {
|
|||
let parsed: StageStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, StageStatus::PartialSuccess);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_simulated_factory() {
|
||||
let o = Outcome::simulated("my_node");
|
||||
assert_eq!(o.status, StageStatus::Success);
|
||||
assert_eq!(o.notes.as_deref(), Some("[Simulated] my_node"));
|
||||
assert!(o.failure.is_none());
|
||||
assert!(o.context_updates.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue