Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion src/app/collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -682,14 +682,20 @@ impl Collection {
.map(|(project, rows)| ProjectRows { project, rows }),
)
.collect();
let joined = &join::resolve(
let mut joined = join::resolve(
&rows,
Listed {
panes,
out_of_reach,
},
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()
Expand Down
62 changes: 60 additions & 2 deletions src/model/anomaly.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Utc>,
) -> Vec<Anomaly> {
Expand All @@ -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(),
});
Expand Down Expand Up @@ -134,6 +141,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -151,6 +159,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -165,6 +174,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -183,6 +193,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -197,6 +208,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -219,6 +231,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -233,6 +246,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -247,6 +261,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -269,6 +284,7 @@ mod tests {
None,
ProviderState::Answering,
true,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -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(
Expand All @@ -299,6 +346,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -313,6 +361,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -324,6 +373,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -341,6 +391,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&wide,
now(),
);
Expand All @@ -355,6 +406,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&narrow,
now(),
);
Expand All @@ -372,6 +424,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -388,6 +441,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -402,6 +456,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -418,6 +473,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -432,6 +488,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand All @@ -446,6 +503,7 @@ mod tests {
None,
ProviderState::Answering,
false,
false,
&Anomalies::default(),
now(),
);
Expand Down
34 changes: 34 additions & 0 deletions src/model/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<BeadKey>,
/// 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<BeadKey>,
pub conflicts: Vec<Conflict>,
}

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.
Expand Down Expand Up @@ -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,
}
}
Expand Down
Loading