diff --git a/docs/design.md b/docs/design.md index 342912d..6648d12 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,15 @@ 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, 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. + 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/app/collection.rs b/src/app/collection.rs index bf42b98..2d9bd42 100644 --- a/src/app/collection.rs +++ b/src/app/collection.rs @@ -682,7 +682,7 @@ impl Collection { .map(|(project, rows)| ProjectRows { project, rows }), ) .collect(); - let joined = &join::resolve( + let mut joined = join::resolve( &rows, Listed { panes, @@ -690,6 +690,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/anomaly.rs b/src/model/anomaly.rs index 34592bf..18e5722 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/join.rs b/src/model/join.rs index a51bf14..1b89daf 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 8d491e6..f096e5c 100644 --- a/src/model/snapshot/build.rs +++ b/src/model/snapshot/build.rs @@ -119,6 +119,7 @@ pub fn build_tree( refused, agents, out_of_reach, + joined.under_a_seat.contains(&key), &cfg.anomalies, now, ), @@ -450,23 +451,258 @@ 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 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 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 { + project: "dunwich".to_string(), + id: id.to_string(), + }) + .collect(); + 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. + 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" + ); + } + + /// `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] + 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/model/snapshot/mod.rs b/src/model/snapshot/mod.rs index e4eb7c0..c310ab7 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/copies.rs b/src/view/forest/tests/copies.rs index cd266bb..cb5e05f 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); diff --git a/src/view/forest/tests/groups.rs b/src/view/forest/tests/groups.rs index c2514d3..3e19ec7 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(), diff --git a/tests/snapshot_json.rs b/tests/snapshot_json.rs index 95e46ba..19ca83f 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]