diff --git a/docs/coordinator.md b/docs/coordinator.md index 6911a49e..fadfd605 100644 --- a/docs/coordinator.md +++ b/docs/coordinator.md @@ -96,6 +96,11 @@ each entry finishes. When a dep entry transitions to terminal, the loop re-evalu the queue immediately, so downstream entries are unblocked with no extra wakeup. No additional `notify_one()` call is needed. +**Circular-dep caveat**: if A depends on B and B depends on A, neither entry ever +becomes runnable — the worker skips both indefinitely with no error. Callers must +ensure acyclic dep graphs. Cycle detection is deferred to a future iteration (when +parallel workers make a stuck queue more visible). + --- ## Container view diff --git a/hive-c0re/src/rebuild_queue.rs b/hive-c0re/src/rebuild_queue.rs index 72293e1b..e299b243 100644 --- a/hive-c0re/src/rebuild_queue.rs +++ b/hive-c0re/src/rebuild_queue.rs @@ -468,9 +468,10 @@ impl RebuildQueue { .filter(|e| e.state.is_terminal()) .map(|e| e.id) .collect(); - // All queued ids — used to distinguish "not yet terminal" from - // "evicted (= resolved)". - let queued_ids: std::collections::HashSet = inner + // Active (non-terminal) ids: Queued + Running. Named `active_ids` + // rather than `queued_ids` because Running entries are included; + // used to distinguish "still in flight" from "evicted (= resolved)". + let active_ids: std::collections::HashSet = inner .entries .iter() .filter(|e| !e.state.is_terminal()) @@ -480,7 +481,11 @@ impl RebuildQueue { e.state == QueueState::Queued && e.depends_on.iter().all(|dep_id| { // Resolved if terminal in queue OR not in queue at all. - terminal_ids.contains(dep_id) || !queued_ids.contains(dep_id) + // Note: circular deps (A depends on B, B depends on A) + // silently deadlock — neither entry ever becomes runnable. + // Not a problem in v1 (no callers yet), but callers must + // ensure acyclic dep graphs. + terminal_ids.contains(dep_id) || !active_ids.contains(dep_id) }) })?; let entry = &mut inner.entries[pos];