Skip to main content

agentmux_srv/drone/executor/
engine.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! DAG executor — topological sort + per-layer runner.
5//!
6//! Phase 1 is sequential per layer (one block at a time). Phase 2
7//! parallelizes independent branches with `tokio::spawn`.
8
9use std::collections::{HashMap, HashSet, VecDeque};
10use std::sync::Arc;
11
12use serde_json::Value;
13use tokio::sync::mpsc;
14
15use super::blocks;
16use crate::drone::data_flow::ExecutionScope;
17use crate::drone::types::{BlockKind, BlockState, FlowEdge, FlowNode, DroneGraph};
18
19/// Streaming events emitted to the frontend during a run.
20#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
21#[ts(export, export_to = "../../../frontend/types/rpc/")]
22#[serde(tag = "kind", rename_all = "snake_case")]
23pub enum RunEvent {
24    RunStarted { run_id: String, drone_id: String },
25    BlockStarted { run_id: String, block_id: String },
26    BlockDone {
27        run_id: String,
28        block_id: String,
29        #[ts(type = "unknown")]
30        output: Value,
31    },
32    BlockError {
33        run_id: String,
34        block_id: String,
35        error: String,
36    },
37    RunDone {
38        run_id: String,
39        #[ts(type = "unknown")]
40        output: Value,
41    },
42    RunFailed { run_id: String, error: String },
43}
44
45pub struct RunHandle {
46    pub run_id: String,
47    pub events: mpsc::UnboundedReceiver<RunEvent>,
48    pub final_states: Arc<tokio::sync::Mutex<HashMap<String, BlockState>>>,
49}
50
51/// Run the drone end-to-end. Returns a handle whose `events` channel
52/// surfaces per-block lifecycle, and a `final_states` mutex callers can
53/// snapshot for the run-history record.
54pub async fn run_drone(
55    drone_id: String,
56    graph: DroneGraph,
57) -> Result<RunHandle, String> {
58    let run_id = uuid::Uuid::new_v4().to_string();
59    let (tx, rx) = mpsc::unbounded_channel();
60    let final_states: Arc<tokio::sync::Mutex<HashMap<String, BlockState>>> =
61        Arc::new(tokio::sync::Mutex::new(HashMap::new()));
62
63    let states_for_task = final_states.clone();
64    let drone_id_for_evt = drone_id.clone();
65    let run_id_for_task = run_id.clone();
66    let tx_for_task = tx.clone();
67    tokio::spawn(async move {
68        let _ = tx_for_task.send(RunEvent::RunStarted {
69            run_id: run_id_for_task.clone(),
70            drone_id: drone_id_for_evt,
71        });
72        match execute(&run_id_for_task, &graph, &tx_for_task, &states_for_task).await {
73            Ok(output) => {
74                let _ = tx_for_task.send(RunEvent::RunDone {
75                    run_id: run_id_for_task,
76                    output,
77                });
78            }
79            Err(e) => {
80                let _ = tx_for_task.send(RunEvent::RunFailed {
81                    run_id: run_id_for_task,
82                    error: e,
83                });
84            }
85        }
86    });
87
88    Ok(RunHandle {
89        run_id,
90        events: rx,
91        final_states,
92    })
93}
94
95async fn execute(
96    run_id: &str,
97    graph: &DroneGraph,
98    tx: &mpsc::UnboundedSender<RunEvent>,
99    final_states: &Arc<tokio::sync::Mutex<HashMap<String, BlockState>>>,
100) -> Result<Value, String> {
101    if graph.nodes.is_empty() {
102        return Err("drone has no blocks".to_string());
103    }
104
105    let layers = topological_layers(graph)?;
106    let mut scope = ExecutionScope::new();
107    let mut response_output: Option<Value> = None;
108    let nodes_by_id: HashMap<String, &FlowNode> =
109        graph.nodes.iter().map(|n| (n.id.clone(), n)).collect();
110    // Blocks whose execution is pruned by a Condition's non-matching
111    // branch (or transitively, because all their incoming edges came
112    // from skipped/pruned sources). They consume no events and run no
113    // block runner — important so the false-branch's API/agent calls
114    // do not fire side effects when the condition is true.
115    let mut skipped: HashSet<String> = HashSet::new();
116
117    for layer in layers {
118        for block_id in layer {
119            let node = nodes_by_id
120                .get(&block_id)
121                .ok_or_else(|| format!("internal: missing node {block_id}"))?;
122            let kind = block_kind_of(node)?;
123
124            if should_skip(&block_id, graph, &nodes_by_id, &scope, &skipped) {
125                skipped.insert(block_id.clone());
126                mark_state(
127                    final_states,
128                    &block_id,
129                    BlockState {
130                        status: "skipped".to_string(),
131                        output: None,
132                        error: None,
133                        started_at: None,
134                        completed_at: Some(now_ms()),
135                    },
136                )
137                .await;
138                continue;
139            }
140
141            let _ = tx.send(RunEvent::BlockStarted {
142                run_id: run_id.to_string(),
143                block_id: block_id.clone(),
144            });
145            // Captured once at BlockStarted so the running / done / error
146            // transitions all carry the same start timestamp — otherwise
147            // the run-history record loses per-block duration (reagent P2).
148            let started_at = now_ms();
149            mark_state(
150                final_states,
151                &block_id,
152                BlockState {
153                    status: "running".to_string(),
154                    output: None,
155                    error: None,
156                    started_at: Some(started_at),
157                    completed_at: None,
158                },
159            )
160            .await;
161
162            let result = match kind {
163                BlockKind::Variables => blocks::variables::run(node, &mut scope).await,
164                BlockKind::Api => blocks::api::run(node, &scope).await,
165                BlockKind::Condition => blocks::condition::run(node, &scope).await,
166                BlockKind::Response => blocks::response::run(node, &scope).await,
167                BlockKind::Agent => blocks::agent::run(node, &scope).await,
168            };
169
170            match result {
171                Ok(output) => {
172                    scope.outputs.insert(block_id.clone(), output.clone());
173                    let _ = tx.send(RunEvent::BlockDone {
174                        run_id: run_id.to_string(),
175                        block_id: block_id.clone(),
176                        output: output.clone(),
177                    });
178                    mark_state(
179                        final_states,
180                        &block_id,
181                        BlockState {
182                            status: "done".to_string(),
183                            output: Some(output.clone()),
184                            error: None,
185                            started_at: Some(started_at),
186                            completed_at: Some(now_ms()),
187                        },
188                    )
189                    .await;
190                    if matches!(kind, BlockKind::Response) {
191                        // Response emits `{ "value": <resolved-template> }`;
192                        // unwrap to the bare value so the run's final
193                        // output is the user-facing string (or whatever
194                        // the template resolved to), not the wrapper
195                        // JSON. (codex P3 on PR #755.)
196                        response_output = Some(
197                            output
198                                .get("value")
199                                .cloned()
200                                .unwrap_or(output),
201                        );
202                    }
203                }
204                Err(e) => {
205                    let _ = tx.send(RunEvent::BlockError {
206                        run_id: run_id.to_string(),
207                        block_id: block_id.clone(),
208                        error: e.clone(),
209                    });
210                    mark_state(
211                        final_states,
212                        &block_id,
213                        BlockState {
214                            status: "error".to_string(),
215                            output: None,
216                            error: Some(e.clone()),
217                            started_at: Some(started_at),
218                            completed_at: Some(now_ms()),
219                        },
220                    )
221                    .await;
222                    return Err(format!("block {block_id} failed: {e}"));
223                }
224            }
225        }
226    }
227
228    Ok(response_output.unwrap_or(Value::Null))
229}
230
231async fn mark_state(
232    states: &Arc<tokio::sync::Mutex<HashMap<String, BlockState>>>,
233    id: &str,
234    state: BlockState,
235) {
236    let mut g = states.lock().await;
237    g.insert(id.to_string(), state);
238}
239
240use agentmux_common::time::now_ms;
241
242/// Returns the layered topological order of the graph. Each layer is
243/// the set of blocks whose dependencies are already satisfied —
244/// independent branches run together.
245pub fn topological_layers(graph: &DroneGraph) -> Result<Vec<Vec<String>>, String> {
246    let mut indegree: HashMap<String, usize> = HashMap::new();
247    let mut outedges: HashMap<String, Vec<String>> = HashMap::new();
248    let known: HashSet<String> = graph.nodes.iter().map(|n| n.id.clone()).collect();
249    for n in &graph.nodes {
250        indegree.entry(n.id.clone()).or_insert(0);
251        outedges.entry(n.id.clone()).or_insert_with(Vec::new);
252    }
253    for e in &graph.edges {
254        if !known.contains(&e.source) || !known.contains(&e.target) {
255            return Err(format!(
256                "edge references unknown node ({} → {})",
257                e.source, e.target
258            ));
259        }
260        *indegree.entry(e.target.clone()).or_insert(0) += 1;
261        outedges
262            .entry(e.source.clone())
263            .or_insert_with(Vec::new)
264            .push(e.target.clone());
265    }
266
267    let mut layers: Vec<Vec<String>> = Vec::new();
268    let mut frontier: VecDeque<String> = indegree
269        .iter()
270        .filter_map(|(id, deg)| if *deg == 0 { Some(id.clone()) } else { None })
271        .collect();
272
273    while !frontier.is_empty() {
274        let mut layer: Vec<String> = Vec::new();
275        let next_frontier: Vec<String> = frontier.drain(..).collect();
276        for id in &next_frontier {
277            layer.push(id.clone());
278        }
279        // Stable order by id within a layer (for deterministic tests).
280        layer.sort();
281        for id in &layer {
282            if let Some(targets) = outedges.get(id).cloned() {
283                for t in targets {
284                    if let Some(d) = indegree.get_mut(&t) {
285                        if *d > 0 {
286                            *d -= 1;
287                            if *d == 0 {
288                                frontier.push_back(t);
289                            }
290                        }
291                    }
292                }
293            }
294        }
295        layers.push(layer);
296    }
297
298    let scheduled: usize = layers.iter().map(|l| l.len()).sum();
299    if scheduled != graph.nodes.len() {
300        return Err("drone contains a cycle".to_string());
301    }
302    Ok(layers)
303}
304
305fn block_kind_of(node: &FlowNode) -> Result<BlockKind, String> {
306    let kind = node
307        .data
308        .get("kind")
309        .and_then(|v| v.as_str())
310        .ok_or_else(|| format!("node {} has no `kind` field", node.id))?;
311    BlockKind::parse(kind).ok_or_else(|| format!("unknown block kind: {kind}"))
312}
313
314/// Decide whether `node_id` should be pruned given the per-run state.
315///
316/// A block runs iff at least one of its incoming edges is "active":
317///   * The edge's source is NOT in `skipped`, AND
318///   * If the source is a Condition block, the edge's `source_handle`
319///     ("true" / "false") matches the boolean stored in the Condition's
320///     `result` output.
321///
322/// Blocks with no incoming edges are root nodes and never skip.
323/// Topological ordering guarantees every source has already been
324/// processed (run or skipped) by the time we evaluate a target here.
325fn should_skip(
326    node_id: &str,
327    graph: &DroneGraph,
328    nodes_by_id: &HashMap<String, &FlowNode>,
329    scope: &ExecutionScope,
330    skipped: &HashSet<String>,
331) -> bool {
332    let mut had_incoming = false;
333    let mut had_active = false;
334    for edge in &graph.edges {
335        if edge.target != node_id {
336            continue;
337        }
338        had_incoming = true;
339        if edge_is_active(edge, nodes_by_id, scope, skipped) {
340            had_active = true;
341            break;
342        }
343    }
344    had_incoming && !had_active
345}
346
347/// True iff the given edge carries control + data flow under the
348/// current scope. See `should_skip` for the full ruleset.
349fn edge_is_active(
350    edge: &FlowEdge,
351    nodes_by_id: &HashMap<String, &FlowNode>,
352    scope: &ExecutionScope,
353    skipped: &HashSet<String>,
354) -> bool {
355    if skipped.contains(&edge.source) {
356        return false;
357    }
358    let src_node = match nodes_by_id.get(&edge.source) {
359        Some(n) => n,
360        None => return true,
361    };
362    let is_condition = src_node
363        .data
364        .get("kind")
365        .and_then(|v| v.as_str())
366        == Some("condition");
367    if !is_condition {
368        return true;
369    }
370    let cond_result = scope
371        .outputs
372        .get(&edge.source)
373        .and_then(|v| v.get("result"))
374        .and_then(|v| v.as_bool());
375    match (edge.source_handle.as_deref(), cond_result) {
376        (Some("true"), Some(r)) => r,
377        (Some("false"), Some(r)) => !r,
378        // Unhandled / pre-spec edges off a Condition — be permissive
379        // (Phase 2 will tighten this once the canvas always sets a
380        // source_handle on condition edges).
381        _ => true,
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::drone::types::{FlowEdge, FlowNode, NodePosition};
389    use serde_json::json;
390
391    fn n(id: &str, kind: &str) -> FlowNode {
392        FlowNode {
393            id: id.to_string(),
394            position: NodePosition::default(),
395            data: json!({ "kind": kind }),
396            node_type: None,
397        }
398    }
399
400    fn e(id: &str, src: &str, dst: &str) -> FlowEdge {
401        FlowEdge {
402            id: id.to_string(),
403            source: src.to_string(),
404            target: dst.to_string(),
405            source_handle: None,
406            target_handle: None,
407        }
408    }
409
410    #[test]
411    fn topo_orders_diamond() {
412        // a → b, a → c, b → d, c → d
413        let g = DroneGraph {
414            nodes: vec![n("a", "variables"), n("b", "api"), n("c", "api"), n("d", "response")],
415            edges: vec![
416                e("e1", "a", "b"),
417                e("e2", "a", "c"),
418                e("e3", "b", "d"),
419                e("e4", "c", "d"),
420            ],
421        };
422        let layers = topological_layers(&g).unwrap();
423        assert_eq!(layers.len(), 3);
424        assert_eq!(layers[0], vec!["a"]);
425        assert_eq!(layers[1], vec!["b", "c"]);
426        assert_eq!(layers[2], vec!["d"]);
427    }
428
429    #[test]
430    fn topo_rejects_cycle() {
431        let g = DroneGraph {
432            nodes: vec![n("a", "variables"), n("b", "api")],
433            edges: vec![e("e1", "a", "b"), e("e2", "b", "a")],
434        };
435        assert!(topological_layers(&g).is_err());
436    }
437
438    #[test]
439    fn topo_rejects_unknown_node_in_edge() {
440        let g = DroneGraph {
441            nodes: vec![n("a", "variables")],
442            edges: vec![e("e1", "a", "ghost")],
443        };
444        assert!(topological_layers(&g).is_err());
445    }
446
447    #[test]
448    fn topo_single_node_one_layer() {
449        let g = DroneGraph {
450            nodes: vec![n("a", "response")],
451            edges: vec![],
452        };
453        let layers = topological_layers(&g).unwrap();
454        assert_eq!(layers, vec![vec!["a".to_string()]]);
455    }
456
457    // ────────────────────────────────────────────────────────────────
458    // Condition branch pruning (codex P2 on PR #755)
459    // ────────────────────────────────────────────────────────────────
460
461    fn eh(id: &str, src: &str, dst: &str, handle: Option<&str>) -> FlowEdge {
462        FlowEdge {
463            id: id.to_string(),
464            source: src.to_string(),
465            target: dst.to_string(),
466            source_handle: handle.map(|s| s.to_string()),
467            target_handle: None,
468        }
469    }
470
471    fn nodes_map<'a>(g: &'a DroneGraph) -> HashMap<String, &'a FlowNode> {
472        g.nodes.iter().map(|n| (n.id.clone(), n)).collect()
473    }
474
475    fn scope_with_cond(cond_id: &str, result: bool) -> ExecutionScope {
476        let mut scope = ExecutionScope::new();
477        scope
478            .outputs
479            .insert(cond_id.to_string(), json!({ "result": result }));
480        scope
481    }
482
483    #[test]
484    fn skip_root_node_runs() {
485        // No incoming edges — always runs regardless of state.
486        let g = DroneGraph {
487            nodes: vec![n("a", "variables")],
488            edges: vec![],
489        };
490        let nodes = nodes_map(&g);
491        assert!(!should_skip(
492            "a",
493            &g,
494            &nodes,
495            &ExecutionScope::new(),
496            &HashSet::new()
497        ));
498    }
499
500    #[test]
501    fn skip_unconditional_chain_runs() {
502        // a → b (a is plain Variables, not a condition).
503        let g = DroneGraph {
504            nodes: vec![n("a", "variables"), n("b", "api")],
505            edges: vec![e("e1", "a", "b")],
506        };
507        let nodes = nodes_map(&g);
508        assert!(!should_skip(
509            "b",
510            &g,
511            &nodes,
512            &ExecutionScope::new(),
513            &HashSet::new()
514        ));
515    }
516
517    #[test]
518    fn skip_condition_false_branch_when_result_true() {
519        // c (condition, true) → t via "true" handle (active)
520        //                    → f via "false" handle (inactive)
521        let g = DroneGraph {
522            nodes: vec![n("c", "condition"), n("t", "api"), n("f", "api")],
523            edges: vec![
524                eh("e1", "c", "t", Some("true")),
525                eh("e2", "c", "f", Some("false")),
526            ],
527        };
528        let nodes = nodes_map(&g);
529        let scope = scope_with_cond("c", true);
530        let skipped = HashSet::new();
531        assert!(!should_skip("t", &g, &nodes, &scope, &skipped));
532        assert!(should_skip("f", &g, &nodes, &scope, &skipped));
533    }
534
535    #[test]
536    fn skip_condition_true_branch_when_result_false() {
537        let g = DroneGraph {
538            nodes: vec![n("c", "condition"), n("t", "api"), n("f", "api")],
539            edges: vec![
540                eh("e1", "c", "t", Some("true")),
541                eh("e2", "c", "f", Some("false")),
542            ],
543        };
544        let nodes = nodes_map(&g);
545        let scope = scope_with_cond("c", false);
546        let skipped = HashSet::new();
547        assert!(should_skip("t", &g, &nodes, &scope, &skipped));
548        assert!(!should_skip("f", &g, &nodes, &scope, &skipped));
549    }
550
551    #[test]
552    fn skip_transitive_through_skipped_source() {
553        // c (condition, true) → f via "false" (skipped) → x
554        // x's only incoming is from skipped `f`, so x must also skip
555        // — guards against false-branch side effects past depth 1.
556        let g = DroneGraph {
557            nodes: vec![n("c", "condition"), n("f", "api"), n("x", "agent")],
558            edges: vec![
559                eh("e1", "c", "f", Some("false")),
560                e("e2", "f", "x"),
561            ],
562        };
563        let nodes = nodes_map(&g);
564        let scope = scope_with_cond("c", true);
565        let mut skipped = HashSet::new();
566        skipped.insert("f".to_string());
567        assert!(should_skip("x", &g, &nodes, &scope, &skipped));
568    }
569
570    #[test]
571    fn join_runs_if_any_incoming_active() {
572        // a (active) → d, b (skipped) → d
573        // d has one active source — Phase 1 any-active semantics.
574        let g = DroneGraph {
575            nodes: vec![n("a", "variables"), n("b", "api"), n("d", "response")],
576            edges: vec![e("e1", "a", "d"), e("e2", "b", "d")],
577        };
578        let nodes = nodes_map(&g);
579        let mut skipped = HashSet::new();
580        skipped.insert("b".to_string());
581        assert!(!should_skip(
582            "d",
583            &g,
584            &nodes,
585            &ExecutionScope::new(),
586            &skipped
587        ));
588    }
589
590    #[test]
591    fn condition_edge_without_handle_is_permissive() {
592        // Pre-spec edges off a Condition block lack a source_handle.
593        // Don't accidentally prune them — Phase 2 will require the
594        // handle once the canvas always sets it.
595        let g = DroneGraph {
596            nodes: vec![n("c", "condition"), n("x", "api")],
597            edges: vec![eh("e1", "c", "x", None)],
598        };
599        let nodes = nodes_map(&g);
600        let scope = scope_with_cond("c", false);
601        assert!(!should_skip("x", &g, &nodes, &scope, &HashSet::new()));
602    }
603
604    #[tokio::test]
605    async fn execute_preserves_started_at_in_block_state() {
606        // Reagent P2: BlockDone used to overwrite started_at with None,
607        // making per-block duration uncomputable from the persisted
608        // run record. Verify the timestamp survives the running -> done
609        // transition.
610        let mut vars_node = n("v1", "variables");
611        // Variables block reads `entries: [{name, value}]`, not `vars`.
612        // Earlier draft used the wrong key — the test still passed
613        // because it only asserts started_at, but the Variables block
614        // was running an empty-entries no-op rather than the intended
615        // path. (reagent P2 on PR #755 round 7.)
616        vars_node.data = json!({
617            "kind": "variables",
618            "entries": [{ "name": "v", "value": 1 }]
619        });
620        let mut resp_node = n("r1", "response");
621        resp_node.data = json!({ "kind": "response", "template": "done" });
622        let g = DroneGraph {
623            nodes: vec![vars_node, resp_node],
624            edges: vec![e("e1", "v1", "r1")],
625        };
626
627        let handle = run_drone("wf1".to_string(), g).await.unwrap();
628        let mut rx = handle.events;
629        while let Some(ev) = rx.recv().await {
630            if matches!(ev, RunEvent::RunDone { .. } | RunEvent::RunFailed { .. }) {
631                break;
632            }
633        }
634
635        let states = handle.final_states.lock().await;
636        let v1 = states.get("v1").expect("v1 state");
637        let r1 = states.get("r1").expect("r1 state");
638        assert_eq!(v1.status, "done");
639        assert!(
640            v1.started_at.is_some() && v1.started_at.unwrap() > 0,
641            "v1 started_at must survive the done transition; got {:?}",
642            v1.started_at
643        );
644        assert!(v1.completed_at.is_some());
645        assert!(v1.completed_at.unwrap() >= v1.started_at.unwrap());
646        assert!(r1.started_at.is_some());
647        assert!(r1.completed_at.is_some());
648    }
649
650    #[test]
651    fn flow_edge_serializes_with_camelcase_handle_fields() {
652        // Wire format must match xyflow + the frontend TS
653        // `DroneFlowEdge` shape (sourceHandle / targetHandle).
654        // Snake-case would silently drop the field on the frontend
655        // roundtrip, leaving the executor's branch-pruning permissive.
656        let edge = FlowEdge {
657            id: "e1".to_string(),
658            source: "a".to_string(),
659            target: "b".to_string(),
660            source_handle: Some("true".to_string()),
661            target_handle: None,
662        };
663        let json = serde_json::to_string(&edge).unwrap();
664        assert!(
665            json.contains("\"sourceHandle\":\"true\""),
666            "expected sourceHandle in JSON; got {json}"
667        );
668        // Roundtrip preserves the handle.
669        let parsed: FlowEdge = serde_json::from_str(&json).unwrap();
670        assert_eq!(parsed.source_handle.as_deref(), Some("true"));
671    }
672
673    #[tokio::test]
674    async fn execute_skips_pruned_branch_end_to_end() {
675        // Variables(v=10) → Condition({{var.v}} < 5) → Response("hit_true")
676        //                                            ↘ Response("hit_false")
677        // Result: cond is false, true-branch Response is skipped,
678        // false-branch Response runs and becomes the run output.
679        //
680        // The Variables block reads `entries: [{name, value}]` (not
681        // `vars`), so the data shape mirrors what the canvas emits.
682        // An earlier draft of this test used the wrong key and the
683        // condition passed by coincidence (unresolved `{{var.v}}`
684        // string-compared against `5`); reagent caught it.
685        let mut vars_node = n("v1", "variables");
686        vars_node.data = json!({
687            "kind": "variables",
688            "entries": [{ "name": "v", "value": 10 }]
689        });
690        let mut cond_node = n("c1", "condition");
691        cond_node.data = json!({
692            "kind": "condition",
693            "expr": "{{var.v}} < 5"
694        });
695        let mut t_resp = n("rt", "response");
696        t_resp.data = json!({
697            "kind": "response",
698            "template": "hit_true"
699        });
700        let mut f_resp = n("rf", "response");
701        f_resp.data = json!({
702            "kind": "response",
703            "template": "hit_false"
704        });
705        let g = DroneGraph {
706            nodes: vec![vars_node, cond_node, t_resp, f_resp],
707            edges: vec![
708                e("e1", "v1", "c1"),
709                eh("e2", "c1", "rt", Some("true")),
710                eh("e3", "c1", "rf", Some("false")),
711            ],
712        };
713
714        let handle = run_drone("wf1".to_string(), g).await.unwrap();
715        // Drain events to completion.
716        let mut rx = handle.events;
717        let mut got_done_ids: Vec<String> = Vec::new();
718        let mut final_output: Option<Value> = None;
719        while let Some(ev) = rx.recv().await {
720            match ev {
721                RunEvent::BlockDone { block_id, .. } => got_done_ids.push(block_id),
722                RunEvent::RunDone { output, .. } => {
723                    final_output = Some(output);
724                    break;
725                }
726                RunEvent::RunFailed { error, .. } => panic!("run failed: {error}"),
727                _ => {}
728            }
729        }
730
731        // false-branch ran; true-branch was skipped — no BlockDone for "rt".
732        assert!(got_done_ids.contains(&"rf".to_string()));
733        assert!(
734            !got_done_ids.contains(&"rt".to_string()),
735            "true branch must NOT run when condition is false; got: {got_done_ids:?}"
736        );
737
738        // Final state for the skipped block records "skipped".
739        let states = handle.final_states.lock().await;
740        let rt_state = states.get("rt").expect("rt state recorded");
741        assert_eq!(rt_state.status, "skipped");
742
743        // The run's response output is the false-branch's resolution,
744        // unwrapped from Response's `{ "value": ... }` envelope so
745        // downstream consumers see the bare string (codex P3).
746        assert_eq!(final_output, Some(json!("hit_false")));
747    }
748}