From 2f04e13ea5feac85c6eb8280e300cc97b591e1d1 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 07:13:07 +0000 Subject: [PATCH] Address-only wake: a DM is hers whatever the basis; the quiet ack reads her projects' names from her mind; no quiet ack while a stale send waits; a refused ack in a poll is retried Soul findings F1, F2, F3 and F6 on PR 43. Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/address.rs | 6 +-- crates/mind-body/src/address/tests.rs | 9 ++++ crates/mind-body/src/heartbeat.rs | 2 +- crates/mind-body/src/persona.rs | 38 ++++++++------- crates/mind-body/src/persona/tests.rs | 66 +++++++++++++++++++++++---- 5 files changed, 92 insertions(+), 29 deletions(-) diff --git a/crates/mind-body/src/address.rs b/crates/mind-body/src/address.rs index a92ea55..d2a9ee0 100644 --- a/crates/mind-body/src/address.rs +++ b/crates/mind-body/src/address.rs @@ -53,10 +53,10 @@ pub(crate) fn addresses(who: &Addressee, delivery: &ChatDelivery) -> bool { delivery.form == Form::Message && (delivery.mentions.contains(&who.matrix_id) || who.names.iter().any(|name| names_whole_word(&delivery.text, &name.text, name.exact))) } -/// Whether a delivery the mouth gave `own` is for her: Matrix's word that it is her DM, a tag of hers or a reply to her, or what it -/// says (`addresses`). The basis `Room` (`@room`) is for no one, and no basis is unproven, not "not for her": the names still decide. +/// Whether a delivery the mouth gave `own` is for her: her DM (`is_direct`, which every mouth sends, so an old mouth's DM is hers too), +/// Matrix's word that it is a tag of hers or a reply to her, or what it says (`addresses`). The basis `Room` (`@room`) is for no one, and no basis is unproven, not "not for her": the names still decide. pub(crate) fn is_for(own: &Addressee, delivery: &ChatDelivery) -> bool { - (delivery.form == Form::Message && matches!(delivery.basis, Some(Basis::Direct | Basis::Tag | Basis::Reply))) || addresses(own, delivery) + (delivery.form == Form::Message && (delivery.is_direct || matches!(delivery.basis, Some(Basis::Direct | Basis::Tag | Basis::Reply)))) || addresses(own, delivery) } /// The chat names of one stewarded repo. Listed names are exactly those, each matched exactly as the steward wrote it (capitalization diff --git a/crates/mind-body/src/address/tests.rs b/crates/mind-body/src/address/tests.rs index 4605e89..08abac3 100644 --- a/crates/mind-body/src/address/tests.rs +++ b/crates/mind-body/src/address/tests.rs @@ -128,3 +128,12 @@ fn only_a_message_can_address_and_the_mouths_basis_stands_for_the_polling_mind_a assert!(is_for(&nibu, &with(None, Form::Message, "nibu, look", &[])), "unproven is not 'not for her': her name decides"); assert!(is_for(&nibu, &with(Some(Basis::Room), Form::Message, "nibu, look", &[])), "an @room that also names her is named"); } + +#[test] +fn a_direct_message_is_for_her_whatever_the_basis() { + let nibu = steward("GameCult/Thing", &[]); + let dm = ChatDelivery { is_direct: true, ..said("nothing named", &[]) }; + assert!(is_for(&nibu, &dm), "an old mouth sends no basis; the DM is hers"); + assert!(!is_for(&nibu, &ChatDelivery { form: Form::Notice, ..dm.clone() })); + assert!(!is_for(&nibu, &ChatDelivery { form: Form::Edit, ..dm })); +} diff --git a/crates/mind-body/src/heartbeat.rs b/crates/mind-body/src/heartbeat.rs index c7991e8..1d281e3 100644 --- a/crates/mind-body/src/heartbeat.rs +++ b/crates/mind-body/src/heartbeat.rs @@ -503,7 +503,7 @@ fn newest(mind: &dyn MindPort, selection: Selection) -> Result Result> { +pub(crate) fn project_names_of(mind: &dyn MindPort) -> Result> { let mut names = Vec::new(); for header in headers(mind, &in_force(of_kind(PipelineKind::Stewardship)))? { if let PipelineFacts::Stewardship { repo, names: listed, .. } = &header.facts { diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index c781aa2..8ab67ca 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -89,7 +89,7 @@ use crate::chat_image::{self, Image, MAX_PER_LINE}; use crate::chat_client::{Chat, ChatDelivery, MouthRefused, ChatOpenDirectRequest, ChatRead, DirectOutcome, ChatSendRequest, ContextLine, Refusal, SendBasis}; use crate::connector::render_plan; use crate::control::{ControlSource, Effective, HeldReason, derive}; -use crate::heartbeat::{HeartbeatView, PollTrace}; +use crate::heartbeat::{HeartbeatView, PollTrace, project_names_of}; use crate::lease_client::{Lease, LeaseRequest, Lender, Lent}; use crate::launch::{Clock, LaunchOutcome, LaunchRequest, Launcher, Ports, holders, open_and_launch}; use crate::queue::{MindPort, any_of, citing, headers, in_force, live_claims, of_kind}; @@ -894,8 +894,11 @@ pub fn carry(organs: &Organs, control: &impl ControlSource, swarm: &impl Control let routes = reply_routes(organs.mind, &BTreeSet::new()).unwrap_or_default(); let addressing = addressing_of(config, organs.heartbeat, &own_addressee(config)); pending.sort_by_key(|delivery| delivery.seq); - let own = addressing.iter().find(|mind| mind.matrix_id() == config.own_sender).cloned().unwrap_or_else(|| own_addressee(config)); - if let Some(through) = quiet_through(&own, &pending, 0) { + // Her names as the heartbeat's table has them, and her projects' names as her mind holds them now: the table is saved after the + // heartbeat launches her, so it may not yet hold a project a message names. + let table = addressing.iter().find(|mind| mind.matrix_id() == config.own_sender).cloned().unwrap_or_else(|| own_addressee(config)); + let fresh = own_addressee(config).with_projects(project_names_of(organs.mind).unwrap_or_default()); + if let Some(through) = quiet_through(&[&table, &fresh], &pending, 0) { if organs.chat.ack(through).is_err() { progress.failure = Some(Failure::Ack); break; @@ -1608,12 +1611,14 @@ impl SessionInbox for PersonaInbox<'_, C, S> if !pending.is_empty() { self.addressing = addressing_of(self.config, self.organs.heartbeat, &self.addressee); } - let own = self.addressing.iter().find(|mind| mind.matrix_id() == self.config.own_sender).cloned().unwrap_or_else(|| self.addressee.clone()); - if let Some(through) = quiet_through(&own, &pending, self.told_through) { - if self.organs.chat.ack(through).is_err() { - self.progress.failure.get_or_insert(Failure::Ack); - return Ok(Polled { pending: false, other: false, other_first: false, yield_now: true }); - } + let row = self.addressing.iter().find(|mind| mind.matrix_id() == self.config.own_sender).cloned().unwrap_or_else(|| self.addressee.clone()); + let own = [&row, &self.addressee]; + // Not while a stale send waits for the messages that interrupted it: they are what the revision line shows her. A failed ack is + // tried again at the next poll; it is not a reason to end the session. + if self.revision.is_none() + && let Some(through) = quiet_through(&own, &pending, self.told_through) + && self.organs.chat.ack(through).is_ok() + { self.progress.acked = through; pending.retain(|delivery| delivery.seq > through); } @@ -1635,7 +1640,7 @@ impl SessionInbox for PersonaInbox<'_, C, S> self.first_seen.entry(delivery.seq).or_insert(now); } let any_untold = !untold.is_empty(); - let due = revision_ready || untold.iter().any(|delivery| is_for(&own, delivery) && now >= self.first_seen[&delivery.seq] + stagger_ms(self.config, &self.addressing, delivery)); + let due = revision_ready || untold.iter().any(|delivery| own.iter().any(|who| is_for(who, delivery)) && now >= self.first_seen[&delivery.seq] + stagger_ms(self.config, &self.addressing, delivery)); self.waiting = if due { one_line_of(untold, |batch| message_lines(self.config, &self.addressee, &self.addressing, batch, &self.earlier(batch)).len()) } else { Vec::new() }; Ok(self.polled(!self.waiting.is_empty() || self.retell.is_some() || revision_ready || !self.notices.is_empty() || !self.waiting_replies.is_empty(), any_untold)) } @@ -1993,12 +1998,13 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch Ok(Step::Answered(started, reply.intent, opened, notice)) } -/// The seq an ack may pass because nothing in it is for her: the leading run of `pending` (oldest first) that is not addressed to -/// `own` and lies above `told_through`, so no delivery she was told and has not answered is passed. None when the front is addressed, -/// or already told. Such a delivery wakes and tells no one; it is acked in sequence so the mouth's position moves (it stops reading -/// at 500 unacked) and is not lost: Matrix keeps it, and the `context` of the next addressed delivery shows it. -fn quiet_through(own: &Addressee, pending: &[ChatDelivery], told_through: u64) -> Option { - let run: Vec<&ChatDelivery> = pending.iter().take_while(|delivery| delivery.seq > told_through && !is_for(own, delivery)).collect(); +/// The seq an ack may pass because nothing in it is for her: the leading run of `pending` (oldest first) that is not addressed to any of +/// `own` (her rows as the heartbeat's table, her projects' names and her session know them) and lies above `told_through`, so no +/// delivery she was told and has not answered is passed. None when the front is addressed, or already told. Such a delivery wakes and +/// tells no one; it is acked in sequence so the mouth's position moves (it stops reading at 500 unacked) and is not lost: Matrix keeps +/// it, and the `context` of the next addressed delivery shows it. +fn quiet_through(own: &[&Addressee], pending: &[ChatDelivery], told_through: u64) -> Option { + let run: Vec<&ChatDelivery> = pending.iter().take_while(|delivery| delivery.seq > told_through && !own.iter().any(|who| is_for(who, delivery))).collect(); run.last().map(|last| last.seq) } diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index 78b99dd..f56bad3 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -76,6 +76,8 @@ struct FakeChat { speaker: RefCell, /// Acks are recorded and the deliveries stay, where several sessions share one pending. keep_on_ack: Cell, + /// The mouth refuses every ack. + fail_ack: Cell, /// The opens asked of the mouth, in order. opens: RefCell>, /// How the fake mouth answers an open; none is a transport error. @@ -169,6 +171,9 @@ impl Chat for FakeChat { } fn ack(&self, through_seq: u64) -> Result<()> { + if self.fail_ack.get() { + return Err(anyhow!("the mouth is down")); + } self.log.borrow_mut().push("ack".into()); self.acked.borrow_mut().push(through_seq); if !self.keep_on_ack.get() { @@ -459,6 +464,7 @@ impl Rig { landed: RefCell::default(), speaker: RefCell::new("@eureka:chat".into()), keep_on_ack: Cell::new(false), + fail_ack: Cell::new(false), opens: RefCell::default(), open_outcome: Cell::new(Some(DirectOutcome::Opened)), open_unserved: Cell::new(false), @@ -7947,13 +7953,13 @@ fn a_leading_run_of_what_is_not_hers_is_quiet_and_an_ack_never_passes_what_is_to let own = own_addressee(&config()); let room = |seq| heard(seq, "anyone around", Some(Basis::Room), Form::Message); let unproven = |seq| heard(seq, "anyone around", None, Form::Message); - assert_eq!(quiet_through(&own, &[unproven(1), room(2), unproven(3)], 0), Some(3), "a nil basis and an @room are not hers"); + assert_eq!(quiet_through(&[&own], &[unproven(1), room(2), unproven(3)], 0), Some(3), "a nil basis and an @room are not hers"); let hers = heard(4, "anyone around", Some(Basis::Reply), Form::Message); - assert_eq!(quiet_through(&own, &[unproven(1), hers.clone(), unproven(5)], 0), Some(1), "the run stops at the first that is hers"); - assert_eq!(quiet_through(&own, &[hers, unproven(5)], 0), None); - assert_eq!(quiet_through(&own, &[unproven(1), unproven(2)], 1), None, "the front was told and is not answered: nothing passes it"); - assert_eq!(quiet_through(&own, &[unproven(5), unproven(6)], 5), None); - assert_eq!(quiet_through(&own, &[], 0), None); + assert_eq!(quiet_through(&[&own], &[unproven(1), hers.clone(), unproven(5)], 0), Some(1), "the run stops at the first that is hers"); + assert_eq!(quiet_through(&[&own], &[hers, unproven(5)], 0), None); + assert_eq!(quiet_through(&[&own], &[unproven(1), unproven(2)], 1), None, "the front was told and is not answered: nothing passes it"); + assert_eq!(quiet_through(&[&own], &[unproven(5), unproven(6)], 5), None); + assert_eq!(quiet_through(&[&own], &[], 0), None); } /// A notice or an edit never addresses her, whatever else it says; a nil basis does not mean "not for her": her name still does. @@ -7964,10 +7970,10 @@ fn a_notice_and_an_edit_are_quiet_whatever_they_say_and_a_nil_basis_with_her_nam for (basis, form) in [(Some(Basis::Direct), Form::Notice), (Some(Basis::Tag), Form::Edit), (Some(Basis::Reply), Form::Notice), (None, Form::Edit)] { let mut line = heard(1, "eureka, look", basis, form); line.mentions = vec!["@eureka:chat".into()]; - assert_eq!(quiet_through(&own, &[line], 0), Some(1), "{basis:?} {form:?}"); + assert_eq!(quiet_through(&[&own], &[line], 0), Some(1), "{basis:?} {form:?}"); } - assert_eq!(quiet_through(&own, &[heard(1, "Eureka, look", None, Form::Message)], 0), None, "an old mouth's delivery, named in the text"); - assert_eq!(quiet_through(&own, &[heard(1, "look", Some(Basis::Direct), Form::Message)], 0), None); + assert_eq!(quiet_through(&[&own], &[heard(1, "Eureka, look", None, Form::Message)], 0), None, "an old mouth's delivery, named in the text"); + assert_eq!(quiet_through(&[&own], &[heard(1, "look", Some(Basis::Direct), Form::Message)], 0), None); } /// Two unaddressed lines ahead of an addressed one are acked at the start, in one ack, and the turn is told the addressed one alone. @@ -8011,3 +8017,45 @@ fn a_v1_mouths_deliveries_still_wake_her_by_name_and_by_tag() { assert_eq!(acked, [2]); assert!(first_line(&writes).contains("Eureka, look")); } + +/// An old mouth sends no basis, but every mouth sends `is_direct`: a DM that names no one is hers, is told, and is not acked as quiet. +#[test] +fn a_direct_message_from_an_old_mouth_is_hers_with_no_basis() { + use crate::chat_client::Form; + let dm = ChatDelivery { is_direct: true, ..heard(1, "can you look at this", None, Form::Message) }; + assert_eq!(quiet_through(&[&own_addressee(&config())], &[dm.clone()], 0), None); + let rig = Rig::new(vec![dm], &[says(&[("operator", "ok")])]); + rig.clock.step.set(0); + let (child, writes) = answering(&rig, reply_saying("ok")); + let (_, acked) = carry_children(&rig, vec![child]); + assert!(first_line(&writes).contains("can you look at this")); + assert_eq!(acked, [1]); +} + +/// A message naming only a project she stewards is hers by her mind's records, though no heartbeat table holds the name yet: it is told, not +/// acked as quiet at the start. +#[test] +fn a_message_naming_only_her_project_is_told_though_no_table_holds_the_name() { + use crate::chat_client::Form; + let rig = Rig::new(vec![heard(1, "is the bad day simulator green?", None, Form::Message)], &[says(&[])]); + rig.mind.committed(vec![steward_naming("GameCult/BadDaySimulator", &[])]); + rig.clock.step.set(0); + let (child, writes) = answering(&rig, reply_saying("ok")); + let (_, acked) = carry_children(&rig, vec![child]); + assert!(first_line(&writes).contains("bad day simulator"), "{}", first_line(&writes)); + assert_eq!(acked, [1], "acked once, after it was answered"); +} + +/// A refused ack at the start ends the start with the ack failure, and nothing is told. +#[test] +fn a_refused_quiet_ack_ends_the_start_and_tells_nothing() { + use crate::chat_client::Form; + let rig = Rig::new(vec![heard(1, "chatter", None, Form::Message), heard(2, "more chatter", None, Form::Message)], &[]); + rig.chat.fail_ack.set(true); + rig.clock.step.set(0); + let (child, writes) = answering(&rig, empty_reply()); + let (outcome, acked) = carry_children(&rig, vec![child]); + assert_eq!(failure_of(outcome), Some(Failure::Ack)); + assert!(acked.is_empty()); + assert!(writes.borrow().is_empty()); +}