persona: a second refused reply merges into the pending retell, and a refusal bound belongs to the batch (s2 retell-overwrite-loses-batch, ack-frozen-by-refused-retell)

The single retell slot becomes one merged Retell (every refused delivery not yet told again, in seq order, with the refusals of the most-refused one), so the ack rule's lowest unanswered seq never loses a batch. The refusal count travels with the batch through its Written line and echo instead of resetting on any answer; a batch's third refusal ends the session by the existing failure path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-11-01 20:20:34 +01:00
commit b23a01fa8b

View file

@ -80,8 +80,8 @@ pub const PERSONA_POLL_S: i64 = 5;
pub const SANDBOX_DIR: &str = "sandbox";
/// The file inside the unit's runtime directory that holds one process's system prompt.
const INSTRUCTIONS_FILE: &str = "persona-instructions.md";
/// A refused result is told back; this many in a row end the session.
const REFUSALS_IN_A_ROW: u32 = 3;
/// A refused batch is told back; its third refusal ends the session, however many answers lie between.
const REFUSALS_PER_BATCH: u32 = 3;
/// The environment variables the unit's `persona.env` sets for the persona flags.
pub const ENV_ROOMS: &str = "MIND_PERSONA_ROOMS";
pub const ENV_OPERATOR: &str = "MIND_PERSONA_OPERATOR";
@ -840,8 +840,8 @@ fn one_process(
written: None,
echoed: Vec::new(),
shown: Shown::default(),
echoed_refusals: 0,
retell: None,
refusals: 0,
};
drive(organs.clock, child, &mut inbox, remaining_s, &mut std::io::stdout())?;
Ok(())
@ -919,10 +919,20 @@ impl Shown {
}
}
/// A line written whose echo has not come: the deliveries it carries and what it showed.
/// A line written whose echo has not come: the deliveries it carries, what it showed, and how many
/// times its batch was already refused.
struct Written {
batch: Vec<ChatDelivery>,
shown: Shown,
refusals: u32,
}
/// Every refused batch not yet told again, merged into one, with the refusal to tell and the most
/// refusals any of its deliveries has had.
struct Retell {
batch: Vec<ChatDelivery>,
why: Failure,
refusals: u32,
}
/// The Persona's side of the session: her chat, in one conversation, told to the live session
@ -952,10 +962,11 @@ struct PersonaInbox<'a, C: ControlSource, S: ControlSource> {
written: Option<Written>,
/// Deliveries the CLI took in since the last result: the next result's batch.
echoed: Vec<ChatDelivery>,
/// The most refusals any line echoed since the last result had already had.
echoed_refusals: u32,
shown: Shown,
/// A refused result's batch, to be told again with the refusal.
retell: Option<(Vec<ChatDelivery>, Failure)>,
refusals: u32,
/// What refused results left to be told again.
retell: Option<Retell>,
}
impl<C: ControlSource, S: ControlSource> PersonaInbox<'_, C, S> {
@ -977,12 +988,12 @@ impl<C: ControlSource, S: ControlSource> PersonaInbox<'_, C, S> {
}
/// Writes `batch` as one line: `text` with the lines before it, and notes what it carries.
fn write(&mut self, batch: Vec<ChatDelivery>, prefix: Option<String>) -> Result<String> {
fn write(&mut self, batch: Vec<ChatDelivery>, prefix: Option<String>, refusals: u32) -> Result<String> {
let (input, shown) = self.render(&batch)?;
if let Some(through) = batch.iter().map(|delivery| delivery.seq).max() {
self.told_through = self.told_through.max(through);
}
self.written = Some(Written { batch, shown });
self.written = Some(Written { batch, shown, refusals });
Ok(match prefix {
Some(prefix) => format!("{prefix}\n\n{input}"),
None => input,
@ -1000,7 +1011,7 @@ impl<C: ControlSource, S: ControlSource> PersonaInbox<'_, C, S> {
impl<C: ControlSource, S: ControlSource> SessionInbox for PersonaInbox<'_, C, S> {
fn start_line(&mut self) -> Result<String> {
let batch = std::mem::take(&mut self.start);
self.write(batch, None)
self.write(batch, None, 0)
}
fn poll_every_s(&self) -> i64 {
@ -1027,11 +1038,11 @@ impl<C: ControlSource, S: ControlSource> SessionInbox for PersonaInbox<'_, C, S>
}
fn tell(&mut self) -> Result<String> {
if let Some((batch, why)) = self.retell.take() {
return self.write(batch, Some(format!("Your last reply was refused ({why}). Answer the same messages again, as one reply.")));
if let Some(Retell { batch, why, refusals }) = self.retell.take() {
return self.write(batch, Some(format!("Your last reply was refused ({why}). Answer the same messages again, as one reply.")), refusals);
}
let batch = std::mem::take(&mut self.waiting);
self.write(batch, None)
self.write(batch, None, 0)
}
fn flush_text(&self, why: Why) -> String {
@ -1044,18 +1055,21 @@ impl<C: ControlSource, S: ControlSource> SessionInbox for PersonaInbox<'_, C, S>
}
fn echoed(&mut self) {
if let Some(Written { batch, shown }) = self.written.take() {
if let Some(Written { batch, shown, refusals }) = self.written.take() {
self.echoed_refusals = self.echoed_refusals.max(refusals);
self.echoed.extend(batch);
self.shown.add(shown);
}
}
/// The result answers the deliveries echoed since the last one. A reply that cannot be decoded
/// is told back; the third in a row, a CLI error or any failure of the start ends the session
/// (decide sees a blocked result and closes stdin).
/// is told back, with every other batch refused and not yet told again; a batch's third refusal,
/// a CLI error or any failure of the start ends the session (decide sees a blocked result and
/// closes stdin).
fn result(&mut self, line: &str, ok: bool, blocked: bool) -> Result<(bool, bool)> {
let batch = std::mem::take(&mut self.echoed);
let shown = std::mem::take(&mut self.shown);
let refused_before = std::mem::take(&mut self.echoed_refusals);
if blocked || !ok {
self.progress.failure.get_or_insert(Failure::Session);
return Ok((false, true));
@ -1076,7 +1090,7 @@ impl<C: ControlSource, S: ControlSource> SessionInbox for PersonaInbox<'_, C, S>
// told in seq order, so while one is unanswered below an answered seq, everything
// answered lies above it and nothing is acked; the ack follows its answer.
self.answered_through = self.answered_through.max(batch.iter().map(|delivery| delivery.seq).max().unwrap_or(0));
let unanswered = self.retell.iter().flat_map(|(retold, _)| retold).chain(self.written.iter().flat_map(|written| &written.batch)).map(|delivery| delivery.seq).min();
let unanswered = self.retell.iter().flat_map(|retell| &retell.batch).chain(self.written.iter().flat_map(|written| &written.batch)).map(|delivery| delivery.seq).min();
let past_unanswered = unanswered.is_some_and(|lowest| lowest <= self.answered_through);
if !past_unanswered && self.answered_through > self.progress.acked {
if self.organs.chat.ack(self.answered_through).is_err() {
@ -1085,7 +1099,6 @@ impl<C: ControlSource, S: ControlSource> SessionInbox for PersonaInbox<'_, C, S>
}
self.progress.acked = self.answered_through;
}
self.refusals = 0;
if started.is_some() {
self.progress.self_run = started;
}
@ -1093,12 +1106,20 @@ impl<C: ControlSource, S: ControlSource> SessionInbox for PersonaInbox<'_, C, S>
Ok((true, false))
}
Step::Refused(why) => {
self.refusals += 1;
if self.refusals >= REFUSALS_IN_A_ROW {
let refusals = refused_before + 1;
if refusals >= REFUSALS_PER_BATCH {
self.progress.failure.get_or_insert(why);
return Ok((false, true));
}
self.retell = Some((batch, why));
let mut merged = batch;
let mut most = refusals;
if let Some(waiting) = self.retell.take() {
most = most.max(waiting.refusals);
merged.extend(waiting.batch);
}
merged.sort_by_key(|delivery| delivery.seq);
merged.dedup_by_key(|delivery| delivery.seq);
self.retell = Some(Retell { batch: merged, why, refusals: most });
Ok((true, false))
}
Step::Failed(why) => {