self: a result that took in no brief answers nothing once two conversations were told; the flush answers no brief; the start line holds one conversation

A reply to no brief is told in no session and stays in force (ruling briefless-result-routing). A restart drops a claim that is not of the start set conversation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Eureka Hands 2026-11-07 00:20:09 +00:00
commit f8403165e5
3 changed files with 133 additions and 10 deletions

View file

@ -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<MemoryOrigin>]) -> Option<Conversation> {
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)
}

View file

@ -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<SelfRunOutcome> {
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<PipelineRef> = 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<Brief> = 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<Conversation>,
/// 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<String>,
/// 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 `<run label>-r<n+1>`.
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<String> = 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<String> = 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<Conversation> {
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)> {

View file

@ -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")]));
}