diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index 9c77bfe..b687a85 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -961,12 +961,12 @@ impl Conversation { } } -/// The conversation a reply is told in: the one conversation its briefs' origins name (no brief is the public one), -/// or none when they name two. A reply of two conversations is told in no session, so no member's words +/// The conversation a reply is told in: the one conversation its briefs' origins name, or none when they name two or +/// it answers no brief (a result the Self could not attribute). Such a reply is told in no session, so no member's words /// reach another's room, and it stays in force. fn reply_conversation(answers: &[Option]) -> Option { let mut conversations = answers.iter().map(|origin| Conversation::of_origin(origin.as_ref())); - let Some(first) = conversations.next() else { return Some(Conversation::Public) }; + let first = conversations.next()?; conversations.all(|conversation| conversation == first).then_some(first) } diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs index 5aadc30..31e7361 100644 --- a/crates/mind-body/src/self_run.rs +++ b/crates/mind-body/src/self_run.rs @@ -517,11 +517,17 @@ fn withdraw_misconfigured(mind: &dyn MindPort, clock: &dyn Clock, run: &Pipeline /// error, and the launch that opens the next run closes the run left open. pub fn carry(mind: &dyn MindPort, clock: &dyn Clock, lender: &dyn Lender, spawner: &dyn Spawner, run_cap_s: u64, out: &mut dyn Write) -> Result { let Some((run, claims)) = her_run(mind)? else { return Ok(SelfRunOutcome::NoRun) }; - let mut task = task_section(mind, &claims)?; let open = inbox::derive(mind, &HashSet::new())?; // One Self turn answers one conversation: the start set is the claimed briefs and the open briefs of the first claimed brief's // conversation (with no claimed brief, of the oldest open brief's). The rest are derived by poll, one conversation at a time. let conversation = claims.iter().find_map(|claim| open.iter().find(|brief| brief.id == claim.id.0)).or(open.first()).map(|brief| brief.conversation.clone()); + // A claim of another conversation, or one no longer open beside open briefs of a conversation, is not rendered or carried: + // the start line holds one conversation only. + let claims: Vec = claims + .into_iter() + .filter(|claim| claim.kind != PipelineKind::FollowUp || open.iter().find(|brief| brief.id == claim.id.0).map_or(conversation.is_none(), |brief| Some(&brief.conversation) == conversation.as_ref())) + .collect(); + let mut task = task_section(mind, &claims)?; let mut told: Vec = open.into_iter().filter(|brief| claims.iter().any(|claim| claim.id.0 == brief.id) || Some(&brief.conversation) == conversation.as_ref()).collect(); let also: Vec<&Brief> = told.iter().filter(|brief| !claims.iter().any(|claim| claim.id.0 == brief.id)).collect(); if !also.is_empty() { @@ -582,8 +588,14 @@ struct SelfInbox<'a> { /// The conversation of the briefs carried or echoed and not yet answered by a reply: while it is set, only that conversation's briefs /// are told, so what she says ending a turn reaches her Persona in one conversation. None when every brief told has been answered. conversation: Option, - /// The briefs the last reply answered: a result that took in no brief (a task notification, a flush) continues that turn, so it answers them. + /// The briefs the last reply answered. A result that took in no brief (a task notification) continues that turn, so it answers them, + /// only while every brief this run told is of one conversation: the actuator cannot tell whose work a notification is, so once two + /// conversations were told it answers nothing, is told in no session and stays in force. last_answers: Vec, + /// The flush was written and the CLI has not yet taken it. + flush_written: bool, + /// The CLI took the flush and its result has not come: that result is the session's note to its successor, never a reply to a brief. + flushing: bool, /// Replies made so far; the next is `-r`. replies: u32, /// Replies the mind did not take: process memory, retried at the next result and once at the end. The journal keeps their text either way. @@ -600,7 +612,7 @@ impl<'a> SelfInbox<'a> { carried.push(brief.id.clone()); } } - Self { mind, start, plan: render_plan(plan_quota, now_ms), told, waiting: Vec::new(), heard, run: run.clone(), carried, echoed: Vec::new(), conversation, last_answers: Vec::new(), replies: 0, unadmitted: Vec::new() } + Self { mind, start, plan: render_plan(plan_quota, now_ms), told, waiting: Vec::new(), heard, run: run.clone(), carried, echoed: Vec::new(), conversation, last_answers: Vec::new(), flush_written: false, flushing: false, replies: 0, unadmitted: Vec::new() } } /// Admits each reply the mind has not taken, in order; one try each. A refusal or an unreachable mind keeps the reply and never ends the session. @@ -624,10 +636,19 @@ impl<'a> SelfInbox<'a> { if ok && !text.trim().is_empty() { self.replies += 1; let run_label = self.run.id.0.rsplit(':').next().unwrap_or(&self.run.id.0); - let taken = std::mem::take(&mut self.echoed); - let answered: Vec = if taken.is_empty() { self.last_answers.clone() } else { taken.into_iter().take(64).collect() }; + let flush = std::mem::take(&mut self.flushing); + let taken = if flush { Vec::new() } else { std::mem::take(&mut self.echoed) }; + let answered: Vec = if !taken.is_empty() { + taken.into_iter().take(64).collect() + } else if !flush && self.one_conversation_told() { + self.last_answers.clone() + } else { + Vec::new() + }; let answers = answered.iter().map(|id| PipelineRef { kind: PipelineKind::FollowUp, id: id.as_str().into() }).collect(); - self.last_answers = answered; + if !answered.is_empty() { + self.last_answers = answered; + } self.unadmitted.push(PipelineReply { instance: self.mind.instance().clone(), label: format!("{run_label}-r{}", self.replies).as_str().into(), @@ -640,6 +661,11 @@ impl<'a> SelfInbox<'a> { self.conversation = self.carried.first().or(self.echoed.first()).and_then(|id| self.conversation_of(id)); } + /// Whether every brief this run told is of one conversation. + fn one_conversation_told(&self) -> bool { + self.told.iter().all(|brief| brief.conversation == self.told[0].conversation) + } + /// The conversation of a brief told in this run. fn conversation_of(&self, id: &str) -> Option { self.told.iter().find(|brief| brief.id == id).map(|brief| brief.conversation.clone()) @@ -679,6 +705,7 @@ impl SessionInbox for SelfInbox<'_> { } fn flush_text(&mut self, why: Why) -> String { + self.flush_written = true; flush_text(why) } @@ -688,6 +715,7 @@ impl SessionInbox for SelfInbox<'_> { fn echoed(&mut self) { self.echoed.append(&mut self.carried); + self.flushing |= std::mem::take(&mut self.flush_written); } fn result(&mut self, line: &str, ok: bool, blocked: bool) -> Result<(bool, bool)> { diff --git a/crates/mind-body/src/self_run/tests/conversation.rs b/crates/mind-body/src/self_run/tests/conversation.rs index e375a29..379a979 100644 --- a/crates/mind-body/src/self_run/tests/conversation.rs +++ b/crates/mind-body/src/self_run/tests/conversation.rs @@ -116,7 +116,7 @@ fn the_same_conversations_briefs_are_still_told_while_its_turn_is_unanswered() { assert_eq!(answered(&mind, 1), vec![id_of("a1"), id_of("a2")]); } -/// A task notification or a flush is a turn that took in no brief: it continues the turn before, so its words are that conversation's. +/// A task notification is a turn that took in no brief: while the run has told one conversation only, it continues the turn before. #[test] fn a_result_that_took_in_no_brief_answers_the_previous_replys_briefs() { let mind = live_mind(); @@ -127,3 +127,98 @@ fn a_result_that_took_in_no_brief_answers_the_previous_replys_briefs() { assert_eq!(answered(&mind, 2), vec![id_of("a1")]); assert_eq!(answered(&mind, 3), vec![id_of("a1")], "continues the turn before"); } + +/// The actuator cannot tell whose work a notification is, so once the run has told two conversations a result that took in no brief +/// answers nothing (her Persona tells such a reply in no session). +#[test] +fn a_result_that_took_in_no_brief_answers_nothing_once_two_conversations_were_told() { + let mind = live_mind(); + let steps = vec![ + Out(echo()), + Out(saying("nothing yet")), + briefed(&mind, 31, vec![aqua("a1")]), + Out(echo()), + Out(saying("aqua turn: started a background task")), + briefed(&mind, 31, vec![libby("l1")]), + Out(echo()), + Out(saying("libby turn")), + Out(saying("the background task for aqua finished")), + ]; + let ran = mind_run(&mind, steps); + assert!(ran.outcome.is_ok()); + assert_eq!(answered(&mind, 2), vec![id_of("a1")]); + assert_eq!(answered(&mind, 3), vec![id_of("l1")]); + assert!(answered(&mind, 4).is_empty(), "the notification is told in neither conversation"); +} + +/// The flush result is the session's note to its successor: it answers no brief, though one conversation was told and a notification would continue it. +#[test] +fn the_result_of_the_flush_answers_no_brief() { + let mind = live_mind(); + persona_commit(&mind, vec![aqua("a1")]); + let steps = vec![Out(echo()), Out(at("m1", CEILING_TOKENS)), Out(saying("aqua work")), Out(echo()), Out(saying("note to my successor"))]; + let ran = mind_run(&mind, steps); + assert!(ran.outcome.is_ok()); + assert_eq!(answered(&mind, 1), vec![id_of("a1")]); + assert!(answered(&mind, 2).is_empty(), "the flush is never told as a reply"); +} + +/// A restart whose claimed brief was resolved while another conversation's brief is open: the start line holds the open brief only. +#[test] +fn a_restart_with_a_resolved_claim_puts_one_conversation_on_the_start_line() { + let mind = TestMind::seeded(); + mind.committed(vec![spec("x"), run("persona", RunTurn::PersonaTurn, RunOperator::Mind, &[])]); + persona_commit(&mind, vec![aqua("a1")]); + persona_commit(&mind, vec![libby("l1")]); + mind.committed(vec![run("selfrun-a", RunTurn::SelfRun, RunOperator::Mind, &[brief_ref("a1")])]); + mind.committed(vec![close(brief_ref("a1"), recorded())]); + let ran = carry_with(&Seen::of(&mind), vec![Out(echo()), Out(saying("libby done"))], Some(0), false); + assert!(ran.outcome.is_ok()); + let start = &ran.written()[0]; + assert!(start.contains(&id_of("l1")) && !start.contains(&id_of("a1")), "{start}"); + assert_eq!(answered(&mind, 1), vec![id_of("l1")], "told in the right conversation, none stranded"); +} + +/// The claim is the first open brief and another conversation's brief is next: the start set follows the claim. +#[test] +fn the_start_set_follows_the_claim_not_the_next_open_brief() { + let mind = TestMind::seeded(); + mind.committed(vec![spec("x"), run("persona", RunTurn::PersonaTurn, RunOperator::Mind, &[])]); + persona_commit(&mind, vec![aqua("a1")]); + persona_commit(&mind, vec![libby("l1")]); + mind.committed(vec![run("selfrun-a", RunTurn::SelfRun, RunOperator::Mind, &[brief_ref("a1")])]); + let ran = carry_with(&Seen::of(&mind), vec![Out(echo()), Out(saying("aqua done"))], Some(0), false); + assert!(ran.outcome.is_ok()); + let start = &ran.written()[0]; + assert!(start.contains(&id_of("a1")) && !start.contains(&id_of("l1")), "{start}"); +} + +/// A result arrives while a line of its conversation is written and unechoed, after another conversation's brief was told first: +/// the gate holds, so the other conversation's next brief waits for the reply. +#[test] +fn the_gate_holds_across_a_result_while_a_line_of_its_conversation_is_unechoed() { + let mind = live_mind(); + let steps = vec![ + Out(echo()), + Out(saying("nothing yet")), + briefed(&mind, 31, vec![libby("l1")]), + Out(echo()), + Out(saying("l1 done")), + briefed(&mind, 31, vec![aqua("a1")]), + Out(echo()), + briefed(&mind, 31, vec![aqua("a2")]), + Out(saying("a1 done")), + briefed(&mind, 31, vec![libby("l2")]), + Out(echo()), + Idle(31), + Out(saying("a2 done")), + Idle(31), + Out(echo()), + Out(saying("l2 done")), + ]; + let ran = mind_run(&mind, steps); + assert!(ran.outcome.is_ok()); + let told = tells(&ran); + assert!(told.iter().all(|line| u8::from(line.contains(&id_of("l2"))) + u8::from(line.contains(&id_of("a2"))) <= 1), "{told:?}"); + assert_eq!((answered(&mind, 2), answered(&mind, 3), answered(&mind, 4), answered(&mind, 5)), (vec![id_of("l1")], vec![id_of("a1")], vec![id_of("a2")], vec![id_of("l2")])); +}