feat(graph): run map branches as per-item subgraph chains

A map item no longer runs a single branch node; it walks the subgraph
reachable from `branch` over `next`/`fallback` edges (branch_subgraph),
one chain per item, holding one semaphore permit for the whole chain.
Chain steps go through the shared `step()` so llm retries, fallbacks,
script `_next` and agent timeouts keep their existing semantics. A node
without `next` ends the chain (StepContext.branch_mode), a route outside
the subgraph, a fan-out, an end node or a loop past
max_loop_iterations fails the item, and every item error carries
`map node 'X': sub-branch [i] failed at node 'Y' (step n)`.

`output_key` is removed from each item's fork before the chain runs, so
the collected value must be written by the chain rather than inherited
from the parent.

Teammate identities are now per item for the whole chain: provisioned
when any flagged agent is in the subgraph, armed only before
`teammates: true` agent steps, and retired at chain exit. mark_finished
moves out of run_agent_for_graph to the two provisioning sites (frontier
task and chain exit), so timed-out and early-failing agents are also
marked finished.
This commit is contained in:
Dark-Alex-17 committed 2026-09-11 10:56:26 -06:00
1 parent 2049316364
commit f1cf9a6ae7
5 files changed
+427 -179

No files matched your search

+38
View File
@@ -862,6 +862,44 @@ fn find_reachable_nodes(graph: &Graph) -> HashSet<String> {
reachable
}
/// Nodes reachable from `entry` over the edges a chain can follow at run
/// time: `next` targets and script/llm `fallback`s. Approval routes and a
/// nested map's `branch` are not followed — neither may appear inside a
/// branch, and the validator reports them separately.
pub(super) fn branch_subgraph(graph: &Graph, entry: &str) -> HashSet<String> {
let mut reachable: HashSet<String> = HashSet::new();
let mut queue: VecDeque<String> = VecDeque::new();
if !graph.has_node(entry) {
return reachable;
}
reachable.insert(entry.to_string());
queue.push_back(entry.to_string());
while let Some(id) = queue.pop_front() {
let Some(node) = graph.get_node(&id) else {
continue;
};
let mut edges: Vec<&String> = node
.next
.as_ref()
.map(|t| t.as_slice().iter().collect())
.unwrap_or_default();
match &node.node_type {
NodeType::Script(s) => edges.extend(s.fallback.as_ref()),
NodeType::Llm(l) => edges.extend(l.fallback.as_ref()),
_ => {}
}
for next in edges {
if graph.has_node(next) && reachable.insert(next.clone()) {
queue.push_back(next.clone());
}
}
}
reachable
}
// v1 parallel-group detection: only the immediate `next` targets of a fan-out node count as a parallel group. Map
// branches are handled separately by `validate_map_branches` (the branch's self-parallelism is checked via strict-mode
// rules on the branch node itself, not via group membership).