From a7f683bba07b5a24b83ab2dc0be17334ed7d97d7 Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:53:15 +0100 Subject: [PATCH 1/3] feat(model): flag a claim only when no seat sits on or above it A seat names its pane on the one bead covering its work and sets the beads beneath it in progress without a pane of their own. An in-progress bead now fires orphan-claim only when neither it nor any bead above it, by either nesting edge, has a pane. A pane out of the run's reach covers the beads beneath it, as it excuses its own bead. The age rule is unchanged. --- docs/design.md | 10 +- src/model/anomaly.rs | 62 ++++++++- src/model/snapshot/build.rs | 224 ++++++++++++++++++++++++++++++-- src/view/forest/tests/copies.rs | 7 +- 4 files changed, 291 insertions(+), 12 deletions(-) diff --git a/docs/design.md b/docs/design.md index 342912de..2fd18553 100644 --- a/docs/design.md +++ b/docs/design.md @@ -790,7 +790,7 @@ All computed in the pure model. The first needs bd alone; the rest need herdr. | rule | condition | reading | |---|---|---| | `stale-claim` | `in_progress`, not updated in N days | beads' own `bd stale`, narrowed to claims | -| `orphan-claim` | `in_progress`, no pane resolves for it | the agent died mid-claim — or the bead named a live pane the join refused it, in which case the refusal travels with the rule and the row says which | +| `orphan-claim` | `in_progress`, no pane resolves for it or for any bead above it in the tree | the agent died mid-claim — or the bead named a live pane the join refused it, in which case the refusal travels with the rule and the row says which | | `stale-pane` | bead is closed, its pane is alive | agent finished and did not exit | | `unattributed` | pane alive in a configured project, no bead resolves | a pane nobody can account for | | `unconfigured` | pane alive in a directory no `[[projects]]` entry covers | a project `bdi` was never told about; the fix is a config entry | @@ -811,6 +811,14 @@ on a node nothing fired on — never absent, never null. a live pane is not an anomaly — an agent parked on it is a normal state, and firing on it would report every waiting agent as dead. +A seat that works several beads names its pane on the one covering them all, and +the beads beneath it are `in_progress` with no pane of their own. So a pane on a +bead covers every bead beneath it, by either nesting edge and at any depth, and +a bead under several beads is covered by a pane on any one of them. A pane on a +bead says nothing for the beads above it. A bead above whose pane is out of +reach covers too, for the same reason the bead's own would: nothing says the seat +is gone. `stale-claim` reads bd alone, so a covered claim still goes stale. + A `hooked` bead is a claim too, and neither `orphan-claim` nor `stale-claim` fires on it. `bd stale` leaves it out, and a hook can outlive the session of the agent it belongs to. diff --git a/src/model/anomaly.rs b/src/model/anomaly.rs index 34592bfc..18e57224 100644 --- a/src/model/anomaly.rs +++ b/src/model/anomaly.rs @@ -13,7 +13,7 @@ use crate::model::types::{Bead, Status}; pub enum Anomaly { /// `in_progress` and untouched for longer than the configured window. StaleClaim { days: i64 }, - /// `in_progress` with no pane behind it. + /// `in_progress` with no pane behind it or behind any bead above it. /// /// Where the bead named a live pane the join would not award it, that /// refusal is the reason and travels with the rule; where it named @@ -42,12 +42,19 @@ pub enum Anomaly { /// did answer are still worth reading — a run that fell silent about every /// claim because one session hiccuped would throw away what it does know to /// avoid saying what it does not. +/// +/// `seat_above` says a seat sits on a bead above this one in the tree, or on +/// one whose pane the run could not ask about. A seat names its pane on the +/// lowest bead covering its work and sets the beads beneath to `in_progress` +/// without naming it on them, so a claim under a seat has its agent. +#[allow(clippy::too_many_arguments)] pub fn detect( bead: &Bead, agent: Option<&AgentRef>, refused: Option<&Conflict>, agents: ProviderState, pane_out_of_reach: bool, + seat_above: bool, cfg: &Anomalies, now: DateTime, ) -> Vec { @@ -62,7 +69,7 @@ pub fn detect( let mut fired = Vec::new(); - if agents.answered() && agent.is_none() && !pane_out_of_reach { + if agents.answered() && agent.is_none() && !pane_out_of_reach && !seat_above { fired.push(Anomaly::OrphanClaim { refused: refused.cloned(), }); @@ -134,6 +141,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -151,6 +159,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -165,6 +174,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -183,6 +193,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -197,6 +208,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -219,6 +231,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -233,6 +246,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -247,6 +261,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -269,6 +284,7 @@ mod tests { None, ProviderState::Answering, true, + false, &Anomalies::default(), now(), ); @@ -285,12 +301,43 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); assert_eq!(got, vec![orphan(), Anomaly::StaleClaim { days: 60 }]); } + /// The seat convention: the pane is named on the bead covering the work, + /// and the steps beneath it are in progress with nothing of their own to + /// name. The age rule reads bd alone, so a covered claim still goes stale. + #[test] + fn a_claim_beneath_a_seat_is_not_orphaned_and_is_still_aged() { + let covered = detect( + &bead("in_progress", YESTERDAY), + None, + None, + ProviderState::Answering, + false, + true, + &Anomalies::default(), + now(), + ); + assert_eq!(covered, Vec::new()); + + let neglected = detect( + &bead("in_progress", SIXTY_DAYS_AGO), + None, + None, + ProviderState::Answering, + false, + true, + &Anomalies::default(), + now(), + ); + assert_eq!(neglected, vec![Anomaly::StaleClaim { days: 60 }]); + } + #[test] fn a_recent_claim_with_a_pane_is_clean() { let got = detect( @@ -299,6 +346,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -313,6 +361,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -324,6 +373,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -341,6 +391,7 @@ mod tests { None, ProviderState::Answering, false, + false, &wide, now(), ); @@ -355,6 +406,7 @@ mod tests { None, ProviderState::Answering, false, + false, &narrow, now(), ); @@ -372,6 +424,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -388,6 +441,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -402,6 +456,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -418,6 +473,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -432,6 +488,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); @@ -446,6 +503,7 @@ mod tests { None, ProviderState::Answering, false, + false, &Anomalies::default(), now(), ); diff --git a/src/model/snapshot/build.rs b/src/model/snapshot/build.rs index 8d491e64..385c43dd 100644 --- a/src/model/snapshot/build.rs +++ b/src/model/snapshot/build.rs @@ -11,7 +11,7 @@ use crate::model::anomaly; use crate::model::badges; use crate::model::edges::{Related, Relations}; use crate::model::join::{self, BeadKey, Conflict, Joined}; -use crate::model::tree::Assembled; +use crate::model::tree::{self, Assembled}; use crate::model::types::{Edge, Pane, PaneKey}; use super::filter::{in_flight_first, partition}; @@ -69,16 +69,21 @@ pub fn build_tree( .map(|project| (project, cfg.badges_for_project(project))) .collect(); let project_of = |at: usize| assembled.external.get(&at).map_or(project, String::as_str); + let key_of = |at: usize| BeadKey { + project: project_of(at).to_string(), + id: assembled.beads[at].id.clone(), + }; + let under_a_seat = under_a_seat(assembled, |at| { + let key = key_of(at); + joined.agents.contains_key(&key) || joined.out_of_reach.contains(&key) + }); let beads: Vec = assembled .beads .iter() .enumerate() .map(|(at, bead)| { let own = project_of(at); - let key = BeadKey { - project: own.to_string(), - id: bead.id.clone(), - }; + let key = key_of(at); let said = said.get(own); let readiness = said.map(|said| said.readiness); let agent = joined.agents.get(&key).cloned(); @@ -119,6 +124,7 @@ pub fn build_tree( refused, agents, out_of_reach, + under_a_seat[at], &cfg.anomalies, now, ), @@ -154,6 +160,18 @@ pub fn build_tree( } } +/// Whether each bead sits beneath a bead `seated` holds, by whatever edge the +/// tree nests it: a seat covers every bead beneath it, and none above. +fn under_a_seat(assembled: &Assembled, seated: impl Fn(usize) -> bool) -> Vec { + let mut covered = vec![false; assembled.beads.len()]; + for seat in (0..covered.len()).filter(|at| seated(*at)) { + for below in tree::beneath(&assembled.children, seat, &[]) { + covered[below] = true; + } + } + covered +} + /// The ids of the blockers outside the bead at `at`'s own answer that may /// still block it: each bead in another project it waits on that is not /// finished, by bd's own rule, and each blocker no answer holds that may be @@ -450,23 +468,213 @@ mod tests { } #[test] - fn a_bead_firing_two_rules_is_counted_once() { + fn a_claim_beneath_a_seat_is_held_to_its_age_alone() { let t = tree(); assert_eq!( node(&t, "dun-7.3").anomalies, + vec![Anomaly::StaleClaim { days: 60 }], + "dun-7.3 is under dun-7's seat, so only its age is held against it" + ); + assert_eq!(node(&t, "dun-7.2").anomalies, vec![Anomaly::StalePane]); + assert_eq!(t.counts.anomalies, 2); + } + + /// One pane, working on the work in `dun-1`'s tree. + const SEAT_PANES: &str = r#"{"result":{"agents":[ + {"pane_id":"w:p1","cwd":"/srv/work/dunwich","agent_status":"working"} + ]}}"#; + + /// A bead in flight, parented where `parent` says, and blocked by each of + /// `blockers`. `pane` is the one thing a seat writes onto its bead. + fn in_flight(id: &str, parent: Option<&str>, blockers: &[&str], pane: bool) -> String { + let deps: Vec = parent + .map(|p| format!(r#"{{"depends_on_id":"{p}","type":"parent-child"}}"#)) + .into_iter() + .chain( + blockers + .iter() + .map(|b| format!(r#"{{"depends_on_id":"{b}","type":"blocks"}}"#)), + ) + .collect(); + let metadata = if pane { + r#"{"agent_pane":"w:p1"}"# + } else { + "{}" + }; + format!( + r#"{{"id":"{id}","title":"{id}","status":"in_progress", + "updated_at":"2026-08-29T12:00:00Z","metadata":{metadata}, + "dependencies":[{}]}}"#, + deps.join(",") + ) + } + + /// The tree under `root`, read with `out_of_reach` as the beads whose + /// panes the run could not ask about. + fn tree_under(root: &str, beads: &[String], out_of_reach: &[&str]) -> Tree { + let beads = parse_shared_beads(&format!("[{}]", beads.join(","))).expect("rows parse"); + let assembled = crate::model::tree::Nesting::of(&beads) + .assemble(root) + .expect("the rows assemble"); + let mut joined = joined(&assembled.beads, &panes(SEAT_PANES)); + joined.out_of_reach = out_of_reach + .iter() + .map(|id| BeadKey { + project: "dunwich".to_string(), + id: id.to_string(), + }) + .collect(); + let relations = relations(&assembled.beads); + build_tree( + "dunwich", + &assembled, + &joined, + &crate::model::snapshot::said_by("dunwich", &readiness(), &relations), + ProviderState::Answering, + &cfg(), + now(), + ) + } + + /// Every anomaly fired in the tree under `root`, by the bead it fired on. + fn fired_under( + root: &str, + beads: &[String], + out_of_reach: &[&str], + ) -> BTreeMap> { + tree_under(root, beads, out_of_reach) + .beads + .into_iter() + .map(|n| (n.id, n.anomalies)) + .filter(|(_, fired)| !fired.is_empty()) + .collect() + } + + #[test] + fn a_bead_firing_two_rules_is_counted_once() { + let old_claim = r#"{"id":"dun-8","title":"dun-8","status":"in_progress", + "updated_at":"2026-07-01T12:00:00Z"}"#; + let t = tree_under("dun-8", &[old_claim.to_string()], &[]); + + assert_eq!( + node(&t, "dun-8").anomalies, vec![ Anomaly::OrphanClaim { refused: None }, Anomaly::StaleClaim { days: 60 } ], "an old claim whose agent died is both" ); - assert_eq!(node(&t, "dun-7.2").anomalies, vec![Anomaly::StalePane]); assert_eq!( - t.counts.anomalies, 2, + t.counts.anomalies, 1, "the count is beads to look at, not rules that fired" ); } + fn orphan() -> Vec { + vec![Anomaly::OrphanClaim { refused: None }] + } + + /// The seat convention: the pane is on the bead covering the work, and the + /// beads beneath it, however deep, are in progress without one. + #[test] + fn a_step_in_progress_beneath_a_seated_bead_is_not_orphaned() { + let fired = fired_under( + "dun-1", + &[ + in_flight("dun-1", None, &[], true), + in_flight("dun-1.1", Some("dun-1"), &[], false), + in_flight("dun-1.1.1", Some("dun-1.1"), &[], false), + ], + &[], + ); + assert_eq!(fired, BTreeMap::new()); + } + + /// A molecule's steps hang beneath the bead waiting on its outcome step + /// by a blocks edge, which nests them just as a parent-child edge does. + #[test] + fn a_blocker_in_progress_beneath_a_seated_bead_is_not_orphaned() { + let fired = fired_under( + "dun-2", + &[ + in_flight("dun-2", None, &["dun-2.1"], true), + in_flight("dun-2.1", None, &[], false), + ], + &[], + ); + assert_eq!(fired, BTreeMap::new()); + } + + #[test] + fn a_claim_with_no_seat_on_it_or_above_it_is_still_orphaned() { + let fired = fired_under( + "dun-3", + &[ + in_flight("dun-3", None, &[], false), + in_flight("dun-3.1", Some("dun-3"), &[], false), + ], + &[], + ); + assert_eq!( + fired, + BTreeMap::from([ + ("dun-3".to_string(), orphan()), + ("dun-3.1".to_string(), orphan()) + ]) + ); + } + + #[test] + fn a_seat_beneath_a_claim_does_not_cover_it() { + let fired = fired_under( + "dun-4", + &[ + in_flight("dun-4", None, &[], false), + in_flight("dun-4.1", Some("dun-4"), &[], true), + ], + &[], + ); + assert_eq!(fired, BTreeMap::from([("dun-4".to_string(), orphan())])); + } + + /// `dun-5.3` is a step of two beads, only one of which has a seat. + #[test] + fn a_seat_on_any_one_of_a_beads_parents_covers_it() { + let fired = fired_under( + "dun-5", + &[ + in_flight("dun-5", None, &[], false), + in_flight("dun-5.1", Some("dun-5"), &["dun-5.3"], true), + in_flight("dun-5.2", Some("dun-5"), &["dun-5.3"], false), + in_flight("dun-5.3", None, &[], false), + ], + &[], + ); + assert_eq!( + fired, + BTreeMap::from([ + ("dun-5".to_string(), orphan()), + ("dun-5.2".to_string(), orphan()), + ]), + "dun-5.3 is covered by dun-5.1, and the unseated beads are not" + ); + } + + /// A seat whose pane the run could not ask about may be alive, so a claim + /// beneath it is not known to be orphaned. + #[test] + fn a_claim_beneath_a_seat_out_of_reach_is_not_orphaned() { + let fired = fired_under( + "dun-6", + &[ + in_flight("dun-6", None, &[], false), + in_flight("dun-6.1", Some("dun-6"), &[], false), + ], + &["dun-6"], + ); + assert_eq!(fired, BTreeMap::new()); + } + /// The same forest read on a machine with no agent provider: nothing /// answers for panes, so the join is handed none. fn read_without_a_provider(state: ProviderState) -> Snapshot { diff --git a/src/view/forest/tests/copies.rs b/src/view/forest/tests/copies.rs index cd266bb1..cb5e05f0 100644 --- a/src/view/forest/tests/copies.rs +++ b/src/view/forest/tests/copies.rs @@ -63,7 +63,12 @@ fn assert_held_exactly(forest: &Forest, trees: &[Arc]) { /// they had never been looking at. #[test] fn folding_one_copy_of_a_bead_drawn_twice_leaves_the_other_open() { - let mut forest = flatten(overlapping(&panes_on(&["qua-1.2", "wha-2.1"]))); + let mut forest = flatten(overlapping(&panes_on(&[ + "qua-1.2", + "qua-1.2.1", + "wha-2.1", + "wha-2.2", + ]))); let [upper, lower] = copies_of(&forest, "qua-1.2"); step_onto(&mut forest, lower); From 7aab2ee68205ca07bbc4b6f085482faaac3de932 Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:03:08 +0100 Subject: [PATCH 2/3] test(tests): hold the json contract to the seat rule dun-7.1 and dun-7.3 sit under dun-7's pane, so they fire no orphan-claim. The two-rules example now uses an unseated claim. --- tests/snapshot_json.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/snapshot_json.rs b/tests/snapshot_json.rs index 95e46ba2..19ca83fd 100644 --- a/tests/snapshot_json.rs +++ b/tests/snapshot_json.rs @@ -285,7 +285,7 @@ fn the_json_carries_the_contract_fields() { assert_eq!(tree["cycles"], json!([])); assert_eq!( tree["counts"], - json!({"total": 5, "finished": 1, "live_agents": 2, "anomalies": 3}) + json!({"total": 5, "finished": 1, "live_agents": 2, "anomalies": 2}) ); } @@ -349,7 +349,7 @@ fn a_node_carries_every_field_the_contract_names() { "drawn_on": "own", }], "agent": null, - "anomalies": [{"rule": "orphan-claim"}], + "anomalies": [], "orphaned_dependencies": [], }) ); @@ -389,13 +389,24 @@ fn a_node_carries_every_anomaly_that_fires_on_it() { assert_eq!( node(tree, "dun-7.3")["anomalies"], - json!([{"rule": "orphan-claim"}, {"rule": "stale-claim", "days": 60}]) + json!([{"rule": "stale-claim", "days": 60}]), + "under dun-7's pane, so only its age is held against it" ); assert_eq!( node(tree, "dun-7.2")["anomalies"], json!([{"rule": "stale-pane"}]) ); assert_eq!(node(tree, "dun-7")["anomalies"], json!([])); + + let unseated = dunwich_with(Fake::holding(beads( + r#"[{"id":"dun-8","title":"survey the quay","status":"in_progress", + "updated_at":"2026-07-01T09:00:00Z"}]"#, + ))); + let emitted = emit(&panes(), &unseated, Filter::All); + assert_eq!( + node(&emitted["trees"][0], "dun-8")["anomalies"], + json!([{"rule": "orphan-claim"}, {"rule": "stale-claim", "days": 60}]) + ); } #[test] From 12e259eb015c2a90ccd8e95b9d1c8d7e0eb19110 Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:19:17 +0100 Subject: [PATCH 3/3] fix(model): cover a claim by a seat in any tree that draws it Seat coverage was read from one tree at a time, so a claim drawn under two roots was covered in the seated root's tree and orphaned in the other. Coverage is now gathered across every tree before any tree is built. --- docs/design.md | 3 +- src/app/collection.rs | 8 ++- src/model/join.rs | 34 ++++++++++ src/model/snapshot/build.rs | 108 ++++++++++++++++++++------------ src/model/snapshot/mod.rs | 3 +- src/view/forest/tests/groups.rs | 1 + 6 files changed, 114 insertions(+), 43 deletions(-) diff --git a/docs/design.md b/docs/design.md index 2fd18553..6648d120 100644 --- a/docs/design.md +++ b/docs/design.md @@ -814,7 +814,8 @@ firing on it would report every waiting agent as dead. A seat that works several beads names its pane on the one covering them all, and the beads beneath it are `in_progress` with no pane of their own. So a pane on a bead covers every bead beneath it, by either nesting edge and at any depth, and -a bead under several beads is covered by a pane on any one of them. A pane on a +a bead under several beads is covered by a pane on any one of them, in whichever +tree it is drawn. A pane on a bead says nothing for the beads above it. A bead above whose pane is out of reach covers too, for the same reason the bead's own would: nothing says the seat is gone. `stale-claim` reads bd alone, so a covered claim still goes stale. diff --git a/src/app/collection.rs b/src/app/collection.rs index 702725ac..3c52b90c 100644 --- a/src/app/collection.rs +++ b/src/app/collection.rs @@ -575,7 +575,7 @@ impl Collection { .map(|(project, rows)| ProjectRows { project, rows }), ) .collect(); - let joined = &join::resolve( + let mut joined = join::resolve( &rows, Listed { panes, @@ -583,6 +583,12 @@ impl Collection { }, rooted, ); + for (project, _, read) in &drawn { + if let Ok(assembled) = read { + joined.cover(project, assembled); + } + } + let joined = &joined; let said: BTreeMap<&str, Said> = answered .iter() diff --git a/src/model/join.rs b/src/model/join.rs index a51bf148..1b89daf7 100644 --- a/src/model/join.rs +++ b/src/model/join.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use serde::Serialize; use crate::config::{Config, Project}; +use crate::model::tree::{self, Assembled}; use crate::model::types::Bead; use crate::model::types::{Pane, PaneKey, PaneStatus}; @@ -167,9 +168,41 @@ pub struct Joined { /// nothing it holds says whether the seat behind the claim is alive. A /// rule reading that pane's absence has no absence to read. pub out_of_reach: BTreeSet, + /// Beads sitting beneath one that holds an agent, or one whose pane is out + /// of reach, in any tree the run drew. Filled by `cover`, once for every + /// tree, before any tree is built from this. + pub under_a_seat: BTreeSet, pub conflicts: Vec, } +impl Joined { + /// Note every bead beneath a seat in `assembled`, a tree of `project`'s. + /// + /// A tree holds everything beneath its root, so every bead above another + /// is in a tree that holds both. A bead drawn in several trees is covered + /// in all of them by a seat in any one. + pub fn cover(&mut self, project: &str, assembled: &Assembled) { + let key_of = |at: usize| BeadKey { + project: assembled + .external + .get(&at) + .map_or(project, String::as_str) + .to_string(), + id: assembled.beads[at].id.clone(), + }; + for seat in 0..assembled.beads.len() { + let key = key_of(seat); + if self.agents.contains_key(&key) || self.out_of_reach.contains(&key) { + self.under_a_seat.extend( + tree::beneath(&assembled.children, seat, &[]) + .into_iter() + .map(key_of), + ); + } + } + } +} + /// The project a pane sits in: the one holding its directory, or, where none /// does, the one holding the same place in the main working tree of the /// repository the directory is in. `None` when no project's does. @@ -403,6 +436,7 @@ pub fn resolve(trees: &[ProjectRows<'_>], listed: Listed<'_>, cfg: &Config) -> J agents, refused, out_of_reach, + under_a_seat: BTreeSet::new(), conflicts, } } diff --git a/src/model/snapshot/build.rs b/src/model/snapshot/build.rs index 385c43dd..f096e5c3 100644 --- a/src/model/snapshot/build.rs +++ b/src/model/snapshot/build.rs @@ -11,7 +11,7 @@ use crate::model::anomaly; use crate::model::badges; use crate::model::edges::{Related, Relations}; use crate::model::join::{self, BeadKey, Conflict, Joined}; -use crate::model::tree::{self, Assembled}; +use crate::model::tree::Assembled; use crate::model::types::{Edge, Pane, PaneKey}; use super::filter::{in_flight_first, partition}; @@ -69,21 +69,16 @@ pub fn build_tree( .map(|project| (project, cfg.badges_for_project(project))) .collect(); let project_of = |at: usize| assembled.external.get(&at).map_or(project, String::as_str); - let key_of = |at: usize| BeadKey { - project: project_of(at).to_string(), - id: assembled.beads[at].id.clone(), - }; - let under_a_seat = under_a_seat(assembled, |at| { - let key = key_of(at); - joined.agents.contains_key(&key) || joined.out_of_reach.contains(&key) - }); let beads: Vec = assembled .beads .iter() .enumerate() .map(|(at, bead)| { let own = project_of(at); - let key = key_of(at); + let key = BeadKey { + project: own.to_string(), + id: bead.id.clone(), + }; let said = said.get(own); let readiness = said.map(|said| said.readiness); let agent = joined.agents.get(&key).cloned(); @@ -124,7 +119,7 @@ pub fn build_tree( refused, agents, out_of_reach, - under_a_seat[at], + joined.under_a_seat.contains(&key), &cfg.anomalies, now, ), @@ -160,18 +155,6 @@ pub fn build_tree( } } -/// Whether each bead sits beneath a bead `seated` holds, by whatever edge the -/// tree nests it: a seat covers every bead beneath it, and none above. -fn under_a_seat(assembled: &Assembled, seated: impl Fn(usize) -> bool) -> Vec { - let mut covered = vec![false; assembled.beads.len()]; - for seat in (0..covered.len()).filter(|at| seated(*at)) { - for below in tree::beneath(&assembled.children, seat, &[]) { - covered[below] = true; - } - } - covered -} - /// The ids of the blockers outside the bead at `at`'s own answer that may /// still block it: each bead in another project it waits on that is not /// finished, by bd's own rule, and each blocker no answer holds that may be @@ -509,14 +492,16 @@ mod tests { ) } - /// The tree under `root`, read with `out_of_reach` as the beads whose - /// panes the run could not ask about. - fn tree_under(root: &str, beads: &[String], out_of_reach: &[&str]) -> Tree { + /// The trees under each of `roots`, all read together with `out_of_reach` + /// as the beads whose panes the run could not ask about. + fn trees_under(roots: &[&str], beads: &[String], out_of_reach: &[&str]) -> Vec { let beads = parse_shared_beads(&format!("[{}]", beads.join(","))).expect("rows parse"); - let assembled = crate::model::tree::Nesting::of(&beads) - .assemble(root) - .expect("the rows assemble"); - let mut joined = joined(&assembled.beads, &panes(SEAT_PANES)); + let nesting = crate::model::tree::Nesting::of(&beads); + let assembled: Vec = roots + .iter() + .map(|root| nesting.assemble(root).expect("the rows assemble")) + .collect(); + let mut joined = joined(&beads, &panes(SEAT_PANES)); joined.out_of_reach = out_of_reach .iter() .map(|id| BeadKey { @@ -524,16 +509,28 @@ mod tests { id: id.to_string(), }) .collect(); - let relations = relations(&assembled.beads); - build_tree( - "dunwich", - &assembled, - &joined, - &crate::model::snapshot::said_by("dunwich", &readiness(), &relations), - ProviderState::Answering, - &cfg(), - now(), - ) + for tree in &assembled { + joined.cover("dunwich", tree); + } + let relations = relations(&beads); + assembled + .iter() + .map(|tree| { + build_tree( + "dunwich", + tree, + &joined, + &crate::model::snapshot::said_by("dunwich", &readiness(), &relations), + ProviderState::Answering, + &cfg(), + now(), + ) + }) + .collect() + } + + fn tree_under(root: &str, beads: &[String], out_of_reach: &[&str]) -> Tree { + trees_under(&[root], beads, out_of_reach).remove(0) } /// Every anomaly fired in the tree under `root`, by the bead it fired on. @@ -660,6 +657,37 @@ mod tests { ); } + /// `dun-9.1` is drawn in both trees, and the seat is in only one of them. + /// A claim is covered or it is not, whichever tree it is read in. + #[test] + fn a_seat_in_one_tree_covers_a_claim_drawn_in_another() { + let trees = trees_under( + &["dun-9", "dun-10"], + &[ + in_flight("dun-9", None, &["dun-9.1"], true), + in_flight("dun-10", None, &["dun-9.1"], false), + in_flight("dun-9.1", None, &[], false), + ], + &[], + ); + + let fired: Vec> = trees + .iter() + .map(|tree| { + tree.beads + .iter() + .map(|n| (n.id.as_str(), n.anomalies.as_slice())) + .filter(|(_, fired)| !fired.is_empty()) + .collect() + }) + .collect(); + assert_eq!( + fired, + vec![vec![], vec![("dun-10", orphan().as_slice())]], + "dun-10 has no seat on it or above it, and dun-9.1 is under dun-9's" + ); + } + /// A seat whose pane the run could not ask about may be alive, so a claim /// beneath it is not known to be orphaned. #[test] diff --git a/src/model/snapshot/mod.rs b/src/model/snapshot/mod.rs index e4eb7c00..c310ab7c 100644 --- a/src/model/snapshot/mod.rs +++ b/src/model/snapshot/mod.rs @@ -882,7 +882,8 @@ render = "⏸ waiting" pub(super) fn tree() -> Tree { let assembled = assembled(BEADS); let panes = panes(PANES); - let joined = joined(&assembled.beads, &panes); + let mut joined = joined(&assembled.beads, &panes); + joined.cover("dunwich", &assembled); let relations = edges::relations(&assembled.beads); build_tree( "dunwich", diff --git a/src/view/forest/tests/groups.rs b/src/view/forest/tests/groups.rs index c2514d31..3e19ec72 100644 --- a/src/view/forest/tests/groups.rs +++ b/src/view/forest/tests/groups.rs @@ -64,6 +64,7 @@ fn an_empty_group_draws_nothing() { agents: joined.agents, refused: BTreeMap::new(), out_of_reach: BTreeSet::new(), + under_a_seat: BTreeSet::new(), conflicts: Vec::new(), }, &cfg(),