From 49099d0139b76b38e8f8076e6458d54a56e50be5 Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Tue, 10 Nov 2026 17:39:15 +0100 Subject: [PATCH 1/4] test(cb-pkd4): still_held is pinned over plain buckets Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCDKf2BPq4snBsCf41FST1 --- fleet-view/src/lifecycle.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/fleet-view/src/lifecycle.rs b/fleet-view/src/lifecycle.rs index fb483a9d..d920ea9b 100644 --- a/fleet-view/src/lifecycle.rs +++ b/fleet-view/src/lifecycle.rs @@ -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")]); From d91a61fa92f10a9760696366a0232a0c1b4cc7c4 Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Tue, 10 Nov 2026 17:40:07 +0100 Subject: [PATCH 2/4] test(cb-pkd4): every branch of the give-back ledger is pinned in triggers.rs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCDKf2BPq4snBsCf41FST1 --- fleet-view/src/triggers.rs | 129 +++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/fleet-view/src/triggers.rs b/fleet-view/src/triggers.rs index 6fc71dae..bf67b630 100644 --- a/fleet-view/src/triggers.rs +++ b/fleet-view/src/triggers.rs @@ -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 { + 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 } } From a95cd407562135ea29f555ceda7405aa67db642e Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Tue, 10 Nov 2026 17:40:25 +0100 Subject: [PATCH 3/4] test(cb-pkd4): only a give-back the board took reaches the ledger, pinned in app.rs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCDKf2BPq4snBsCf41FST1 --- fleet-view/src/app.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/fleet-view/src/app.rs b/fleet-view/src/app.rs index bb5ca1a1..d377484f 100644 --- a/fleet-view/src/app.rs +++ b/fleet-view/src/app.rs @@ -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 { From c1a7e0d0acfbc70ceeb32158e882c2247e6ecca8 Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Tue, 10 Nov 2026 17:40:35 +0100 Subject: [PATCH 4/4] test(cb-pkd4): main.rs keeps one wiring test of the give-back guard The elsewhere case's branches now live in app.rs and triggers.rs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCDKf2BPq4snBsCf41FST1 --- fleet-view/src/main.rs | 89 +++--------------------------------------- 1 file changed, 5 insertions(+), 84 deletions(-) diff --git a/fleet-view/src/main.rs b/fleet-view/src/main.rs index f16d89ca..45dd7c7e 100644 --- a/fleet-view/src/main.rs +++ b/fleet-view/src/main.rs @@ -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(); @@ -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(),