diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea625cb..f0e1688e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Cross-package release notes for relayburn. Package changelogs contain package-le ## [Unreleased] +- `burn flow` now connects the main rail across turn boundaries, so session DAGs render as one continuous inference flow in JSON, Mermaid, and SVG. + ## [4.0.0] - 2026-06-23 - **BREAKING (`relayburn-sdk`):** the published Rust SDK no longer re-exports its low-level `analyze`-layer internals (detector/aggregator functions and helper types such as `PricingTable`, `CompareTable`, `CompareCell`) — these were never the intended embedding surface. Embed through the verb layer instead: `LedgerHandle` methods / `summary_report` / `hotspots` / `compare`. CLI, MCP, and `@relayburn/sdk` behavior is unchanged. diff --git a/crates/relayburn-cli/tests/fixtures/flow-svg/flow-session-claude-3turn.svg b/crates/relayburn-cli/tests/fixtures/flow-svg/flow-session-claude-3turn.svg index 61f30fce..d957a200 100644 --- a/crates/relayburn-cli/tests/fixtures/flow-svg/flow-session-claude-3turn.svg +++ b/crates/relayburn-cli/tests/fixtures/flow-svg/flow-session-claude-3turn.svg @@ -23,7 +23,9 @@ + + diff --git a/crates/relayburn-sdk/src/analyze/flow_graph.rs b/crates/relayburn-sdk/src/analyze/flow_graph.rs index 50fca12d..4a176ae3 100644 --- a/crates/relayburn-sdk/src/analyze/flow_graph.rs +++ b/crates/relayburn-sdk/src/analyze/flow_graph.rs @@ -30,7 +30,10 @@ //! //! # Edge semantics //! -//! - [`FlowEdgeKind::Default`] — sequential within a rail. +//! - [`FlowEdgeKind::Default`] — sequential within a rail. Within one +//! turn this is model-driven continuation; across turns it spans an +//! intervening user prompt and does **not** imply autonomous model +//! continuation. //! - [`FlowEdgeKind::Dispatch`] — main rail's `Task` `ToolUse` → //! first node on the spawned subagent rail. //! - [`FlowEdgeKind::Return`] — last node of a subagent rail → the @@ -39,6 +42,13 @@ //! - [`FlowEdgeKind::Unattached`] — connects the synthetic turn anchor //! to a `Subagent` flagged `attributes["unattached"] = true`. Surfaces //! the orphan case loudly so renderers can highlight it. +//! +//! When a turn's terminal main-rail tool dispatches a subagent, both +//! the tool's cross-turn `Default` edge and the subagent's `Return` +//! edge target the next main inference. This intentional diamond keeps +//! main-rail continuity and cross-rail completion as independent causal +//! paths rather than treating the dispatched branch as a replacement +//! for the user-mediated main rail. use serde::{Deserialize, Serialize}; @@ -177,7 +187,8 @@ pub struct FlowNode { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum FlowEdgeKind { - /// Sequential within a rail. + /// Sequential within a rail. Across main-rail turns, spans the + /// intervening user prompt rather than autonomous continuation. Default, /// Main rail → subagent rail at the dispatching `Task` `ToolUse`. Dispatch, @@ -232,7 +243,7 @@ pub struct FlowGraph { pub truncated: bool, /// All nodes, in causal (turn → rail → in-rail) order. pub nodes: Vec, - /// All edges. Order is: per-turn sequential edges first, then + /// All edges. Order is: sequential rail edges first, then /// dispatch / return edges, then unattached edges. pub edges: Vec, } @@ -267,6 +278,9 @@ impl FlowOpts { /// /// `session_id` is captured on the returned graph; when `trees` is /// non-empty the first tree's `session_id` will match. +/// Trees are projected in ascending [`TurnSpanTree::turn_number`] order +/// regardless of caller-provided slice order, and `max_turns` retains +/// the earliest turns in that chronology. pub fn flow_graph_from_trees( session_id: &str, trees: &[TurnSpanTree], @@ -292,17 +306,21 @@ struct Builder { /// session. Rails are session-scoped — every subagent dispatch /// gets a fresh rail rather than reusing. next_rail: u32, - /// Tracks the last node id emitted on the main rail across turns, - /// so a subagent rail's `Return` edge can target the first node - /// of the *next* main-rail inference. - last_main_node_per_turn: Vec<(u32, String)>, + /// Last node emitted on the session's main rail. Seeded into each + /// turn so the next main-rail entry gets a `Default` edge from the + /// previous turn's terminal node. Empty turns leave it unchanged. + last_main_id: Option, } impl Builder { fn add_turn(&mut self, tree: &TurnSpanTree) { let turn_x = (tree.turn_number as i32) * INTER_TURN_GAP; let main_rail = 0; - let mut prev_main_id: Option = None; + // The main rail is session-scoped. Carry its terminal node into + // this turn so the first emitted main node continues the chain. + // If the turn has no main nodes, the carried value survives for + // the next non-empty turn. + let mut prev_main_id = self.last_main_id.clone(); let mut inference_index: u32 = 0; let mut main_node_y: i32 = 0; @@ -431,11 +449,7 @@ impl Builder { let first_id = self.emit_subagent_rail(tree, child, rail, turn_x, unattached_y, None); if let Some(first_id) = first_id { - let anchor = prev_main_id.clone().or_else(|| { - self.last_main_node_per_turn - .last() - .map(|(_, id)| id.clone()) - }); + let anchor = prev_main_id.clone(); if let Some(from) = anchor { self.unattached_edges.push(FlowEdge { from, @@ -452,9 +466,7 @@ impl Builder { } } - if let Some(id) = prev_main_id { - self.last_main_node_per_turn.push((tree.turn_number, id)); - } + self.last_main_id = prev_main_id; } /// Walk a subagent's children and emit a sequential rail. Returns @@ -704,12 +716,18 @@ fn span_duration(node: &SpanNode) -> i64 { /// public entrypoints so the projection contract has one home. fn build_with_finalize(session_id: &str, trees: &[TurnSpanTree], opts: FlowOpts) -> FlowGraph { let total_turn_count = u32::try_from(trees.len()).unwrap_or(u32::MAX); + // Public callers can construct trees directly and need not inherit + // the ledger query's chronological ordering. Normalize before both + // truncation and edge emission so main-rail Default edges and the + // turn-number-based Return timeline use the same direction. + let mut trees: Vec<&TurnSpanTree> = trees.iter().collect(); + trees.sort_by_key(|tree| tree.turn_number); let max_turns = opts.effective_max_turns(); let take = match max_turns { Some(cap) => (cap as usize).min(trees.len()), None => trees.len(), }; - let trees = &trees[..take]; + trees.truncate(take); let turn_count = u32::try_from(trees.len()).unwrap_or(u32::MAX); let truncated = turn_count < total_turn_count; @@ -740,6 +758,8 @@ fn build_with_finalize(session_id: &str, trees: &[TurnSpanTree], opts: FlowOpts) #[cfg(test)] mod tests { + use std::collections::{HashMap, VecDeque}; + use super::*; use crate::analyze::span_tree::{SpanKind, SpanNode, SpanStatus}; @@ -786,6 +806,44 @@ mod tests { SpanNode::new(SpanKind::Turn, "turn") } + fn assert_acyclic(graph: &FlowGraph) { + let mut indegree: HashMap<&str, usize> = graph + .nodes + .iter() + .map(|node| (node.id.as_str(), 0)) + .collect(); + let mut outgoing: HashMap<&str, Vec<&str>> = HashMap::new(); + for edge in &graph.edges { + *indegree + .get_mut(edge.to.as_str()) + .expect("edge target should be a graph node") += 1; + outgoing + .entry(edge.from.as_str()) + .or_default() + .push(edge.to.as_str()); + } + + let mut ready: VecDeque<&str> = indegree + .iter() + .filter_map(|(&id, °ree)| (degree == 0).then_some(id)) + .collect(); + let mut visited = 0; + while let Some(id) = ready.pop_front() { + visited += 1; + for &next in outgoing.get(id).into_iter().flatten() { + let degree = indegree + .get_mut(next) + .expect("edge target should be a graph node"); + *degree -= 1; + if *degree == 0 { + ready.push_back(next); + } + } + } + + assert_eq!(visited, graph.nodes.len(), "flow graph contains a cycle"); + } + #[test] fn empty_trees_produce_empty_graph() { let graph = flow_graph_from_trees("sess-1", &[], FlowOpts::default()); @@ -828,6 +886,54 @@ mod tests { assert_eq!(graph.edges[0].to, "msg-0:inf-1"); } + #[test] + fn two_plain_turns_are_connected_by_default_edge() { + let mut root0 = turn_root(); + root0.children.push(inference("claude-sonnet", "req-1")); + let mut root1 = turn_root(); + root1.children.push(inference("claude-sonnet", "req-2")); + + let graph = flow_graph_from_trees( + "sess-1", + &[make_tree(0, root0), make_tree(1, root1)], + FlowOpts::default(), + ); + + assert_eq!(graph.edges.len(), 1); + assert_eq!( + graph.edges[0], + FlowEdge { + from: "msg-0:inf-0".into(), + to: "msg-1:inf-0".into(), + kind: FlowEdgeKind::Default, + } + ); + } + + #[test] + fn empty_turn_does_not_break_main_rail_continuity() { + let mut root0 = turn_root(); + root0.children.push(inference("claude-sonnet", "req-1")); + let root1 = turn_root(); + let mut root2 = turn_root(); + root2.children.push(inference("claude-sonnet", "req-2")); + + let graph = flow_graph_from_trees( + "sess-1", + &[ + make_tree(0, root0), + make_tree(1, root1), + make_tree(2, root2), + ], + FlowOpts::default(), + ); + + assert_eq!(graph.edges.len(), 1); + assert_eq!(graph.edges[0].from, "msg-0:inf-0"); + assert_eq!(graph.edges[0].to, "msg-2:inf-0"); + assert_eq!(graph.edges[0].kind, FlowEdgeKind::Default); + } + #[test] fn tool_use_under_inference_emits_node_and_default_edge() { let mut inf = inference("claude-sonnet", "req-1"); @@ -904,6 +1010,73 @@ mod tests { assert_eq!(return_edge.to, "msg-1:inf-0"); } + #[test] + fn terminal_dispatch_keeps_main_continuity_and_subagent_return_paths() { + let mut task = tool_use("Task", "tu-task"); + task.children.push(subagent("agent-1", false)); + let mut inf = inference("claude-sonnet", "req-1"); + inf.children.push(task); + let mut root0 = turn_root(); + root0.children.push(inf); + + let mut root1 = turn_root(); + root1.children.push(inference("claude-sonnet", "req-2")); + + let graph = flow_graph_from_trees( + "sess-1", + &[make_tree(0, root0), make_tree(1, root1)], + FlowOpts::default(), + ); + + assert!(graph.edges.iter().any(|edge| { + edge.kind == FlowEdgeKind::Default + && edge.from == "msg-0:tu-tu-task" + && edge.to == "msg-1:inf-0" + })); + assert!(graph.edges.iter().any(|edge| { + edge.kind == FlowEdgeKind::Return + && edge.from == "msg-0:sa-agent-1" + && edge.to == "msg-1:inf-0" + })); + assert_acyclic(&graph); + } + + #[test] + fn unsorted_trees_are_normalized_before_cross_turn_edges() { + let mut root1 = turn_root(); + root1.children.push(inference("claude-sonnet", "req-2")); + + let mut task = tool_use("Task", "tu-task"); + task.children.push(subagent("agent-1", false)); + let mut inf = inference("claude-sonnet", "req-1"); + inf.children.push(task); + let mut root0 = turn_root(); + root0.children.push(inf); + + let graph = flow_graph_from_trees( + "sess-1", + // Deliberately reverse chronological order. If Default + // edges followed this slice while Return edges followed + // turn_number, the dispatch diamond would contain a cycle. + &[make_tree(1, root1), make_tree(0, root0)], + FlowOpts::default(), + ); + + let emitted_turns: Vec = graph.nodes.iter().map(|node| node.turn_number).collect(); + assert!(emitted_turns.windows(2).all(|pair| pair[0] <= pair[1])); + assert!(graph.edges.iter().any(|edge| { + edge.kind == FlowEdgeKind::Default + && edge.from == "msg-0:tu-tu-task" + && edge.to == "msg-1:inf-0" + })); + assert!(graph.edges.iter().any(|edge| { + edge.kind == FlowEdgeKind::Return + && edge.from == "msg-0:sa-agent-1" + && edge.to == "msg-1:inf-0" + })); + assert_acyclic(&graph); + } + #[test] fn return_edge_resolves_to_same_turn_next_inference() { // Setup: a single turn with two main-rail inferences. The first diff --git a/tests/fixtures/cli-golden/snapshots/flow-json.stdout.txt b/tests/fixtures/cli-golden/snapshots/flow-json.stdout.txt index ee59bb39..1c7c4a4a 100644 --- a/tests/fixtures/cli-golden/snapshots/flow-json.stdout.txt +++ b/tests/fixtures/cli-golden/snapshots/flow-json.stdout.txt @@ -137,11 +137,21 @@ "to": "msg-c1-1:tu-tu-c1-r1", "kind": "default" }, + { + "from": "msg-c1-1:tu-tu-c1-r1", + "to": "msg-c1-2:inf-0", + "kind": "default" + }, { "from": "msg-c1-2:inf-0", "to": "msg-c1-2:tu-tu-c1-e1", "kind": "default" }, + { + "from": "msg-c1-2:tu-tu-c1-e1", + "to": "msg-c1-3:inf-0", + "kind": "default" + }, { "from": "msg-c1-3:inf-0", "to": "msg-c1-3:tu-tu-c1-b1", diff --git a/tests/fixtures/cli-golden/snapshots/flow-mermaid.stdout.txt b/tests/fixtures/cli-golden/snapshots/flow-mermaid.stdout.txt index e6605263..f848d182 100644 --- a/tests/fixtures/cli-golden/snapshots/flow-mermaid.stdout.txt +++ b/tests/fixtures/cli-golden/snapshots/flow-mermaid.stdout.txt @@ -12,6 +12,8 @@ graph LR msg_c1_3_tu_tu_c1_b1["T2: Bash"]:::toolUse msg_c1_1_inf_0 --> msg_c1_1_tu_tu_c1_r1 + msg_c1_1_tu_tu_c1_r1 --> msg_c1_2_inf_0 msg_c1_2_inf_0 --> msg_c1_2_tu_tu_c1_e1 + msg_c1_2_tu_tu_c1_e1 --> msg_c1_3_inf_0 msg_c1_3_inf_0 --> msg_c1_3_tu_tu_c1_b1 ```