tinyhumansai/openhuman · error · TeamError

dependency cycle detected

Error message

dependency cycle detected

What it means

Thrown by agent_teams::ops::validate_dependencies when has_task_cycle — a Kahn's-algorithm topological sort over the existing tasks plus the candidate new task — cannot exhaust the graph, i.e. the combined dependency graph contains a cycle. It wraps TeamError::CyclicDependency and fires after self-dep and unknown-dep checks pass.

Source

Thrown at src/openhuman/agent/orchestration/agent_teams/ops.rs:416

    existing: &[AgentTeamTask],
) -> Result<()> {
    let known: HashSet<&str> = existing.iter().map(|t| t.id.as_str()).collect();

    for dep in depends_on {
        if dep == new_task_id {
            return Err(anyhow!(TeamError::SelfDependency {
                task_id: new_task_id.to_string(),
            }));
        }
        if !known.contains(dep.as_str()) {
            return Err(anyhow!(TeamError::UnknownDependency {
                depends_on: dep.clone(),
            }));
        }
    }

    if has_task_cycle(new_task_id, depends_on, existing) {
        return Err(anyhow!(TeamError::CyclicDependency));
    }

    Ok(())
}

/// Kahn's-algorithm cycle check over the task dependency graph (existing tasks
/// plus the candidate new task). Edge `dep -> task` means `task` depends on
/// `dep`. Edges pointing at unknown ids are ignored here (rejected separately).
fn has_task_cycle(
    new_task_id: &str,
    new_depends_on: &[String],
    existing: &[AgentTeamTask],
) -> bool {
    // Node set: every existing task id plus the new one.
    let mut nodes: HashSet<&str> = existing.iter().map(|t| t.id.as_str()).collect();
    nodes.insert(new_task_id);

    let mut indegree: HashMap<&str, usize> = nodes.iter().map(|&n| (n, 0)).collect();

View on GitHub (pinned to a221052e0d)

Solutions

  1. Map the existing team's task graph (list tasks + their deps) and remove the back-edge before assigning
  2. Break the loop by making one direction a separate follow-up task with no reverse dep
  3. If the existing graph itself is cyclic (data corruption), rebuild the team's tasks

Example fix

// before
// taskB already depends on taskA
agent_teams::ops::assign_task(&config, &team, "taskA-v2", member, &["taskB".into()])?; // closes cycle A->B->A

// after — cut the back-edge before adding the new node
let deps: Vec<String> = depends_on
    .into_iter()
    .filter(|d| !task_transitively_depends_on(&config, &team, d, &new_task_id))
    .collect();
agent_teams::ops::assign_task(&config, &team, &new_task_id, member, &deps)?;
Defensive patterns

Strategy: validation

Validate before calling

// Client-side Kahn check mirroring has_task_cycle: build edges dep -> task over
// existing tasks plus the candidate, then ensure a full topological order exists.
fn would_cycle(new_id: &str, new_deps: &[String], existing: &[AgentTeamTask]) -> bool {
    let mut indeg: HashMap<&str, usize> = HashMap::new();
    let mut edges: HashMap<&str, Vec<&str>> = HashMap::new();
    let ids = existing.iter().map(|t| t.id.as_str()).chain([new_id]);
    for id in ids { indeg.entry(id).or_insert(0); }
    for t in existing { for d in &t.depends_on { edges.entry(d.as_str()).or_default().push(t.id.as_str()); *indeg.get_mut(t.id.as_str()).unwrap() += 1; } }
    for d in new_deps { edges.entry(d.as_str()).or_default().push(new_id); *indeg.get_mut(new_id).unwrap() += 1; }
    let mut q: Vec<&str> = indeg.iter().filter(|(_, &d)| d == 0).map(|(k, _)| *k).collect();
    let mut seen = 0;
    while let Some(n) = q.pop() { seen += 1; for &m in edges.get(n).into_iter().flatten() { if let Some(d) = indeg.get_mut(m) { *d -= 1; if *d == 0 { q.push(m); } } } }
    seen != indeg.len()
}

Try / catch

match agent_teams::ops::assign_task(&config, &team_id, &task_id, member, &depends_on) {
    Ok(_) => Ok(()),
    Err(e) if e.to_string().contains("dependency cycle") => {
        // drop the back-edge and re-submit, or split the mutually-dependent work
        assign_without_back_edge(&config, &team_id, &task_id, member, &depends_on)
    }
    Err(e) => Err(e),
}

Prevention

When it happens

Trigger: Assigning a task whose depends_on closes a loop: the new task depends on an existing task that (transitively) depends back on it, or the stored existing graph already contains a cycle that the new edges complete/expose. Requires the dep ids to all be known and non-self, otherwise the earlier checks fire instead.

Common situations: LLM-generated plans adding back-references; two tasks authored to wait on each other; a previously corrupted ledger where tasks were inserted with cyclic deps bypassing validation.

Related errors


AI-assisted analysis of tinyhumansai/openhuman@a221052e0d (2026-08-16). Data as JSON: /api/errors/240bdb16da62a322. Report an issue: GitHub.