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
21 changes: 21 additions & 0 deletions fleet-view/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6477,6 +6477,27 @@ mod tests {
(app, action)
}

/// cb-4yj4: only a give-back the board took is handed to the start ledger as the view's own
/// write; one answered any other way leaves nothing that could swallow a later return.
#[test]
fn only_a_returned_give_back_is_noted_as_returned() {
use crate::lifecycle::ReleaseOutcome;
let (app, _) = ended_release(ReleaseOutcome::Returned { text: String::new() });
assert_eq!(app.returned, vec![("Rogue".to_string(), "cb-x".to_string())]);
for outcome in [
ReleaseOutcome::Elsewhere,
ReleaseOutcome::Closed,
ReleaseOutcome::Kept { reason: "its work is not on main".into() },
ReleaseOutcome::Retry,
ReleaseOutcome::Running,
ReleaseOutcome::Failed { text: "no".into() },
] {
let label = format!("{outcome:?}");
let (app, _) = ended_release(outcome);
assert!(app.returned.is_empty(), "{label} is not a give-back the board took");
}
}

#[test]
fn a_kept_claim_is_remembered_and_says_nothing() {
let (app, action) = ended_release(crate::lifecycle::ReleaseOutcome::Kept {
Expand Down
20 changes: 20 additions & 0 deletions fleet-view/src/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1776,6 +1776,26 @@ mod tests {
crate::model::partition_beads(beads)
}

/// cb-4yj4: a give-back is settled against the first board that no longer shows the bead
/// held by its name - in either bucket a holder lives in.
#[test]
fn still_held_reads_the_holder_from_either_bucket() {
let planning = holding("Xavier");
assert_eq!(planning.being_planned.len(), 1, "the fixture is being planned");
assert!(still_held(&planning, "Xavier", "cb-x"), "held while being planned");
let building = claimed(&[("cb-x", "Xavier")]);
assert_eq!(building.claimed.len(), 1, "the fixture is claimed");
assert!(still_held(&building, "Xavier", "cb-x"), "held while claimed");

assert!(!still_held(&planning, "Rogue", "cb-x"), "another name holds it");
assert!(!still_held(&building, "Rogue", "cb-x"), "another name claimed it");
assert!(!still_held(&building, "Xavier", "cb-y"), "NAME holds a different bead");
assert!(
!still_held(&crate::model::WorkBuckets::default(), "Xavier", "cb-x"),
"the board after the give-back"
);
}

#[test]
fn a_gone_implementers_claim_is_found_once_its_owner_has_left_it() {
let buckets = claimed(&[("cb-x", "Rogue"), ("cb-z", "Storm")]);
Expand Down
89 changes: 5 additions & 84 deletions fleet-view/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5726,6 +5726,11 @@ mod main_tests {
/// which unassigns it and so moves its `updated_at`. That revision is the view's own write,
/// not a return from a later stage, and must not break the unchanged-work guard: the row is
/// not started again on the same bead until something real changes.
///
/// The wiring only: a `Returned` release reaches `app.returned`, `start_due` notes and settles
/// it against `still_held`. Each branch is pinned where it is decided (cb-pkd4):
/// `app::tests::only_a_returned_give_back_is_noted_as_returned`, `triggers::tests::give_back`
/// and `lifecycle::tests::still_held_reads_the_holder_from_either_bucket`.
#[test]
fn the_views_own_give_back_does_not_restart_the_ux_agent_on_the_same_bead() {
let dir = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -5836,90 +5841,6 @@ mod main_tests {
settle_gone(&mut host, "Xavier");
}

/// cb-4yj4 review finding 1: a give-back that did not put the bead back on the queue (here the
/// pass agreed it, and `release-bead` answered `elsewhere`) leaves nothing behind, so the bead's
/// later return from a producer still starts the UX row.
#[test]
fn a_give_back_answered_elsewhere_does_not_swallow_a_later_return() {
let dir = tempfile::tempdir().unwrap();
let paths = scratch(dir.path(), "sleep 5");
let now = Utc::now();
let found_at = now - chrono::Duration::seconds(60);
let started = now - chrono::Duration::seconds(50);
let ended = now - chrono::Duration::seconds(20);
let mut host = SessionHost::default();
let roster = vec![
RosterEntry { name: "Xavier".into(), role: "ux".into(), kind: AgentKind::Interactive },
RosterEntry { name: "Cyclops".into(), role: "producer".into(), kind: AgentKind::Implementer },
];
let ux_bead = |updated_at, assignee: Option<&str>, labels: &[&str]| cerebro_tui::model::Bead {
id: "cb-ux".into(),
title: "undesigned work".into(),
status: "open".into(),
issue_type: "task".into(),
labels: labels.iter().map(|l| l.to_string()).collect(),
priority: Some(2),
updated_at: Some(updated_at),
assignee: assignee.map(str::to_string),
metadata: serde_json::Value::Null,
external_ref: None,
};
let board = |bead: cerebro_tui::model::Bead, listed: bool| {
let mut work = cerebro_tui::model::partition_beads(vec![bead]);
work.candidates = [(
"ux".to_string(),
if listed {
vec![cerebro_tui::model::Candidate { id: "cb-ux".into(), priority: Some(2) }]
} else {
Vec::new()
},
)]
.into_iter()
.collect();
work
};
let found = TriggerFacts::derive(
&board(ux_bead(found_at, None, &[]), true),
&roster,
&std::collections::BTreeSet::new(),
|_| false,
triggers::GhAnswer::Unanswered,
1,
);
let mut ledger = StartLedger::default();
ledger.note_started("Xavier", started, triggers::fingerprint("ux", &found));
ledger.note_ended("Xavier", ended);

// A stale board still shows cb-ux held, though the pass agreed it before it ended.
let mut app = standby_app(
supervising(),
vec![staged_row("Xavier", "ux", RowState::Dead)],
Some(board(ux_bead(started, Some("Xavier"), &[]), false)),
ended,
);
give_back(&mut app, &host, &mut test_logger(), &paths, &roster, ended);
let request = app.take_outbox().pop().expect("the stale board's bead is given back");
app.begin_write(&request, std::path::Path::new("bd"));
start_due(&mut app, &mut host, &mut ledger, &mut test_logger(), &paths, &BTreeMap::new(), 1, &BTreeMap::new(), &roster, ended);
app.finish_write(app::WriteAnswer::Release {
name: "Xavier".into(),
bead: "cb-ux".into(),
cause: lifecycle::GiveBack::Ended,
outcome: lifecycle::ReleaseOutcome::Elsewhere,
});
app.finish_work_refresh(Ok(board(ux_bead(ended, None, &["ux:agreed"]), false)), now);
start_due(&mut app, &mut host, &mut ledger, &mut test_logger(), &paths, &BTreeMap::new(), 1, &BTreeMap::new(), &roster, now);
assert!(!host.is_live("Xavier"), "nothing to design");

// A producer parks it back to UX: the same queue the pass was started on, a newer revision.
let later = now + chrono::Duration::seconds(30);
app.finish_work_refresh(Ok(board(ux_bead(later, None, &[]), true)), later);
start_due(&mut app, &mut host, &mut ledger, &mut test_logger(), &paths, &BTreeMap::new(), 1, &BTreeMap::new(), &roster, later);
assert!(host.is_live("Xavier"), "a return from a later stage starts the UX row");
host.kill(&paths, "Xavier");
settle_gone(&mut host, "Xavier");
}

fn implementer_row(name: &str, state: cerebro_tui::model::RowState) -> cerebro_tui::model::FleetRow {
cerebro_tui::model::FleetRow {
role: "producer".into(),
Expand Down
129 changes: 129 additions & 0 deletions fleet-view/src/triggers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1836,6 +1836,135 @@ mod tests {
}
}

/// The cb-4yj4 give-back guard, over plain data. Xavier's pass was started on cb-ux (and
/// cb-other) at `started`; the view then gave cb-ux back, which moved its revision.
mod give_back {
use super::*;

fn at(seconds: i64) -> DateTime<Utc> {
DateTime::from_timestamp(1_800_000_000 + seconds, 0).unwrap()
}

fn ux_print(cb_ux: i64) -> Fingerprint {
Fingerprint::Ux {
ux_agreed: 0,
implementers: 1,
candidates: vec![("cb-ux".into(), Some(2)), ("cb-other".into(), Some(2))],
undesigned_revisions: vec![
("cb-ux".into(), Some(at(cb_ux))),
("cb-other".into(), Some(at(0))),
],
}
}

/// The board's UX queue: each bead listed with its revision.
fn listing(beads: &[(&str, i64)]) -> TriggerFacts {
TriggerFacts {
undesigned_revisions: beads
.iter()
.map(|(id, revision)| ((*id).to_string(), Some(at(*revision))))
.collect(),
..empty_facts()
}
}

fn given_back_from_xavier() -> StartLedger {
let mut ledger = StartLedger::default();
ledger.note_started("Xavier", at(0), Some(ux_print(0)));
ledger.note_given_back("Xavier", "cb-ux");
ledger
}

#[test]
fn a_give_back_still_held_on_the_board_is_kept_and_absorbs_nothing() {
let mut ledger = given_back_from_xavier();
// A board read before the give-back landed, though it lists a newer revision.
ledger.settle_given_back(&listing(&[("cb-ux", 30)]), |name, bead| {
name == "Xavier" && bead == "cb-ux"
});
assert_eq!(ledger.fingerprint("Xavier"), Some(&ux_print(0)), "nothing absorbed");
assert_eq!(ledger.given_back.get("Xavier").map(String::as_str), Some("cb-ux"), "kept");

// The first board after it settles the note that was kept.
ledger.settle_given_back(&listing(&[("cb-ux", 30)]), |_, _| false);
assert_eq!(ledger.fingerprint("Xavier"), Some(&ux_print(30)));
}

#[test]
fn a_settled_give_back_absorbs_the_listed_revision_into_the_ux_fingerprint() {
let mut ledger = given_back_from_xavier();
ledger.settle_given_back(&listing(&[("cb-ux", 30), ("cb-other", 40)]), |_, _| false);
assert_eq!(
ledger.fingerprint("Xavier"),
Some(&ux_print(30)),
"only the given-back bead takes the board's revision; cb-other keeps its own"
);
assert!(ledger.given_back.is_empty(), "settled once, then gone");

// A later write moves the revision again, and nothing absorbs it.
ledger.settle_given_back(&listing(&[("cb-ux", 60)]), |_, _| false);
assert_eq!(ledger.fingerprint("Xavier"), Some(&ux_print(30)));
}

#[test]
fn a_give_back_not_listed_is_dropped_and_cannot_swallow_a_later_return() {
let mut ledger = given_back_from_xavier();
// The release answered as if returned, but the bead is not on the UX queue.
ledger.settle_given_back(&listing(&[("cb-other", 40)]), |_, _| false);
assert_eq!(ledger.fingerprint("Xavier"), Some(&ux_print(0)), "nothing to absorb");
assert!(ledger.given_back.is_empty(), "the note goes all the same");

// A producer parks it back to UX later: that return is not absorbed.
ledger.settle_given_back(&listing(&[("cb-ux", 90)]), |_, _| false);
assert_eq!(ledger.fingerprint("Xavier"), Some(&ux_print(0)));
}

#[test]
fn a_give_back_with_no_ux_fingerprint_is_dropped() {
let producer = Fingerprint::Producer { assignable_ids: vec!["cb-ux".into()] };
let mut ledger = StartLedger::default();
ledger.note_started("Cyclops", at(0), Some(producer.clone()));
ledger.note_given_back("Cyclops", "cb-ux");
ledger.note_given_back("Rogue", "cb-ux"); // never started: no fingerprint at all
ledger.settle_given_back(&listing(&[("cb-ux", 30)]), |_, _| false);
assert_eq!(ledger.fingerprint("Cyclops"), Some(&producer), "not a UX print: untouched");
assert_eq!(ledger.fingerprint("Rogue"), None, "no print is invented");
assert!(ledger.given_back.is_empty());
}

#[test]
fn a_second_give_back_replaces_the_first() {
let mut ledger = given_back_from_xavier();
ledger.note_given_back("Xavier", "cb-other");
ledger.settle_given_back(&listing(&[("cb-ux", 30), ("cb-other", 40)]), |_, _| false);
assert_eq!(
ledger.fingerprint("Xavier"),
Some(&Fingerprint::Ux {
ux_agreed: 0,
implementers: 1,
candidates: vec![("cb-ux".into(), Some(2)), ("cb-other".into(), Some(2))],
undesigned_revisions: vec![
("cb-ux".into(), Some(at(0))),
("cb-other".into(), Some(at(40))),
],
}),
"one note per name: the later give-back is the one absorbed"
);
}

#[test]
fn a_new_start_forgets_the_give_back_note() {
let mut ledger = given_back_from_xavier();
ledger.note_started("Xavier", at(10), Some(ux_print(10)));
ledger.settle_given_back(&listing(&[("cb-ux", 30)]), |_, _| false);
assert_eq!(
ledger.fingerprint("Xavier"),
Some(&ux_print(10)),
"the new pass's print is not rewritten by the old pass's give-back"
);
}
}

fn agent(role: &'static str) -> AgentFacts<'static> {
AgentFacts { role, ended_at: None, started_at: None, last_fingerprint: None }
}
Expand Down
Loading