Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/conductor/engine/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1618,6 +1618,16 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]:
for a in self.config.agents
for r in a.routes
]
+ [
{
"from": a.name,
"to": o.route,
"when": f"selection == '{o.value}'",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggestion: add a regression test alongside test_workflow_started_includes_routes (tests/test_engine/test_event_emission.py:163) that asserts a human_gate's option routes appear in event.data["routes"] with when == "selection == '<value>'". Cheap insurance that the silent regression motivating this PR can't sneak back in.

}
for a in self.config.agents
if a.type == "human_gate" and a.options
for o in a.options
]
+ [
{
"from": p.name,
Expand Down
84 changes: 79 additions & 5 deletions src/conductor/web/frontend/src/components/graph/graph-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,13 +230,83 @@ export function buildGraphElements(
});
}

// Classify edges as forward or back-edges using a DFS from $start.
// Back-edges are loop-back routes (e.g. plan_reviewer → planner when
// approved=false). Feeding them to Dagre as-is causes it to greedily
// reverse arbitrary edges to break cycles, which scrambles the ranking
// and produces disjointed layouts. Pre-classifying lets us pass each
// back-edge to Dagre in REVERSED direction so the layout reflects the
// true forward DAG, while we still render the edge in its original
// direction.
const backEdgeIds = findBackEdges(flowNodes, flowEdges, '$start');

// Apply dagre layout to top-level nodes only (non-children)
applyDagreLayout(flowNodes, flowEdges);
applyDagreLayout(flowNodes, flowEdges, backEdgeIds);

return { nodes: flowNodes, edges: flowEdges };
}

function applyDagreLayout(flowNodes: Node<GraphNodeData>[], flowEdges: Edge[]): void {
/**
* Identify back-edges via DFS from the entry node. An edge u→v is a back-edge
* iff v is an ancestor of u in the DFS tree (i.e. v is currently on the DFS
* stack when we visit u→v). Operates on top-level node IDs only, since edges
* have already been remapped from group children to group parents.
*
* Traversal order is deterministic: outgoing edges are visited in sorted
* target-ID order, and unreachable subgraphs are entered in sorted source-ID
* order, so layout is stable across renders.
*/
function findBackEdges(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Question: findBackEdges uses recursive DFS via a closure — fine for normal graphs, but does it hold up for workflows with sub-workflows / type: workflow agents? My read is that each sub-workflow renders its own isolated graph in the dashboard so recursion depth is bounded by the longest path within a single workflow (not by MAX_SUBWORKFLOW_DEPTH nesting), but wanted to confirm you've thought about it — happy to be wrong.

flowNodes: Node<GraphNodeData>[],
flowEdges: Edge[],
startId: string,
): Set<string> {
const topLevelIds = new Set(flowNodes.filter((n) => !n.parentId).map((n) => n.id));

// Build adjacency from top-level edges. Sort targets for stability.
const adj = new Map<string, { target: string; edgeId: string }[]>();
for (const e of flowEdges) {
if (!topLevelIds.has(e.source) || !topLevelIds.has(e.target)) continue;
if (!adj.has(e.source)) adj.set(e.source, []);
adj.get(e.source)!.push({ target: e.target, edgeId: e.id });
}
for (const list of adj.values()) {
list.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0));
}

const backEdges = new Set<string>();
const onStack = new Set<string>();
const visited = new Set<string>();

const dfs = (node: string): void => {
visited.add(node);
onStack.add(node);
for (const { target, edgeId } of adj.get(node) ?? []) {
if (onStack.has(target)) {
backEdges.add(edgeId);
} else if (!visited.has(target)) {
dfs(target);
}
}
onStack.delete(node);
};

if (topLevelIds.has(startId)) dfs(startId);

// Also DFS from any unvisited nodes that have outgoing edges, so back-edges
// in unreachable subgraphs are still classified deterministically.
for (const id of [...adj.keys()].sort()) {
if (!visited.has(id)) dfs(id);
}

return backEdges;
}

function applyDagreLayout(
flowNodes: Node<GraphNodeData>[],
flowEdges: Edge[],
backEdgeIds: Set<string>,
): void {
// Use a NON-compound dagre graph — compound mode causes crashes
// when edges cross compound boundaries
const g = new dagre.graphlib.Graph();
Expand All @@ -253,10 +323,14 @@ function applyDagreLayout(flowNodes: Node<GraphNodeData>[], flowEdges: Edge[]):
g.setNode(node.id, { width: w, height: h });
}

// Add edges (dagre needs valid source/target nodes)
// Add edges (dagre needs valid source/target nodes). Back-edges are passed
// in REVERSED direction so dagre ranks the underlying DAG correctly; the
// visible flowEdges entries keep their original direction.
for (const edge of flowEdges) {
// Only add edge if both source and target are in dagre graph
if (g.hasNode(edge.source) && g.hasNode(edge.target)) {
if (!g.hasNode(edge.source) || !g.hasNode(edge.target)) continue;
if (backEdgeIds.has(edge.id)) {
g.setEdge(edge.target, edge.source);
} else {
g.setEdge(edge.source, edge.target);
}
}
Expand Down
Loading
Loading