gatewayd: a turn's speaker, the username of the one person who wrote its burst

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-28 10:51:17 -07:00
co-authored by ornith-1.5-35b-a3b
parent 4866353ac9
commit fae12c3fe2
12 changed files with 503 additions and 15 deletions
+18 -9
View File
@@ -151,6 +151,7 @@ fn one_turn(
socket: &Path,
batch: &Batch,
resume: bool,
speaker: Option<&str>,
on_event: &mut dyn FnMut(&TurnEvent),
) -> Outcome {
// 1. Connect to the socket (else LoopDown("cannot connect to <path>: <e>")).
@@ -167,8 +168,8 @@ fn one_turn(
session: batch.session.clone(),
content: batch.text.clone(),
resume,
// Set in M5c-1 task 03. Until then no turn has a speaker, and none is synced.
speaker: None,
// On the turn only, never in its text: `memoryd` reads it from the log, the model never.
speaker: speaker.map(str::to_string),
};
if let Err(e) = write_frame(
&mut stream,
@@ -207,14 +208,20 @@ fn one_turn(
}
}
/// A turn for a batch. A reply in a thread `loopd` does not know creates the session, as
/// `bxctl chat --session` does.
pub fn run_turn(socket: &Path, batch: &Batch, on_event: &mut dyn FnMut(&TurnEvent)) -> Outcome {
/// A turn for a batch, spoken by `speaker` (a Mattermost username) when one person wrote all of
/// it. A reply in a thread `loopd` does not know creates the session, as `bxctl chat --session`
/// does.
pub fn run_turn(
socket: &Path,
batch: &Batch,
speaker: Option<&str>,
on_event: &mut dyn FnMut(&TurnEvent),
) -> Outcome {
// `one_turn` with batch.resume. When that is Refused with NoSuchSession and batch.resume was
// true, one more `one_turn` with resume false. Otherwise the first outcome.
match one_turn(socket, batch, batch.resume, on_event) {
match one_turn(socket, batch, batch.resume, speaker, on_event) {
Outcome::Refused(err) if err.code == ErrorCode::NoSuchSession && batch.resume => {
one_turn(socket, batch, false, on_event)
one_turn(socket, batch, false, speaker, on_event)
}
outcome => outcome,
}
@@ -230,11 +237,13 @@ fn post(poster: &dyn Poster, thread: &Thread, text: String, log: &dyn Fn(&str))
}
}
/// Run a batch's turn and post what comes of it in its thread. A post that fails is logged.
/// Run a batch's turn, spoken by `speaker`, and post what comes of it in its thread. A post that
/// fails is logged.
pub fn deliver(
poster: &dyn Poster,
socket: &Path,
batch: &Batch,
speaker: Option<&str>,
log: &dyn Fn(&str),
notice: &Notice,
) {
@@ -244,7 +253,7 @@ pub fn deliver(
// LoopDown(why) -> log "gatewayd: <session>: <why>" and post LOOP_DOWN. A post that fails is
// logged, exactly "gatewayd: cannot post in <channel> (thread <root>): <error>", and the rest
// goes on.
let outcome = run_turn(socket, batch, &mut |event| {
let outcome = run_turn(socket, batch, speaker, &mut |event| {
if let TurnEvent::ApprovalPending { approval, .. } = event
&& !notice.arrives(*approval)
{
+32 -1
View File
@@ -158,13 +158,21 @@ impl Gateway {
self.done_tx.clone(),
);
let (session, thread) = (batch.session.clone(), batch.thread.clone());
let speaker = self.speaker(&batch);
let spawned = std::thread::Builder::new().spawn(move || {
let posted = |id: u64| registry.was_posted(id);
let notice = Notice {
posted: &posted,
wait,
};
deliver(client.as_ref(), &socket, &batch, &|line| log(line), &notice);
deliver(
client.as_ref(),
&socket,
&batch,
speaker.as_deref(),
&|line| log(line),
&notice,
);
let _ = done.send(batch.session);
});
if let Err(e) = spawned {
@@ -178,6 +186,29 @@ impl Gateway {
Ok(())
}
/// The turn's speaker: the username of the one person who wrote all of the batch, as
/// Mattermost gives it (M5c spec, section 11). `None` when two or more people did, or when
/// the name cannot be found: never the user id, never a guess. A name found is kept; a
/// failure is logged and not kept, so the next turn looks again.
fn speaker(&mut self, batch: &Batch) -> Option<String> {
let user = batch.sender()?;
if let Some(name) = self.usernames.get(user) {
return Some(name.clone());
}
match self.client.username(user) {
Ok(name) => {
self.usernames.insert(user.to_string(), name.clone());
Some(name)
}
Err(e) => {
(self.log)(&format!(
"gatewayd: no username for {user}: {e}; the turn has no speaker"
));
None
}
}
}
/// Turns that ended: out of flight, who asked forgotten, and the next batch of each session
/// started. Never blocks.
pub(super) fn finished(&mut self) -> Result<(), Stop> {
+3
View File
@@ -121,6 +121,8 @@ pub(crate) struct Gateway {
askers: HashMap<String, approvals::Asker>,
/// How long a turn waits for its approval to come through `gateway.sock` before the notice.
notice: Duration,
/// Mattermost usernames by user id, each looked up once (M5c spec, section 11).
usernames: HashMap<String, String>,
}
/// The wait before attempt `n` (from 0) after a loss.
@@ -271,6 +273,7 @@ impl Gateway {
authors: HashMap::new(),
askers: HashMap::new(),
notice,
usernames: HashMap::new(),
}
}
+26
View File
@@ -39,6 +39,8 @@ pub struct Message {
pub text: String,
/// A thread in a channel or group message that this Boxmaker now takes part in.
pub joins_thread: bool,
/// Who wrote it: the post's `user_id`, a Mattermost user id.
pub user: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -245,6 +247,7 @@ impl Router {
resume: !post.root_id.is_empty(),
text: format!("!{rest}"),
joins_thread: channel_type != "D",
user: post.user_id.clone(),
});
}
if let Some(command) = approval_command(message) {
@@ -269,6 +272,7 @@ impl Router {
resume: !post.root_id.is_empty(),
text: message.to_string(),
joins_thread: channel_type != "D",
user: post.user_id.clone(),
})
}
}
@@ -280,6 +284,22 @@ pub struct Batch {
pub thread: Thread,
pub resume: bool,
pub text: String,
/// Who wrote each message of `text`, in order: Mattermost user ids.
pub senders: Vec<String>,
}
impl Batch {
/// The one user who wrote every message of the batch. `None` when two or more people did, or
/// when it names nobody: a burst joined from several people has no speaker (M5c spec,
/// section 4).
pub fn sender(&self) -> Option<&str> {
let first = self.senders.first()?;
if self.senders.iter().all(|user| user == first) {
Some(first.as_str())
} else {
None
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -295,6 +315,8 @@ pub enum Pushed {
struct Pending {
thread: Thread,
waiting: Vec<String>,
/// The author of each message in `waiting`, in the same order.
senders: Vec<String>,
}
/// The sessions with a turn running, and the messages waiting for each.
@@ -323,6 +345,7 @@ impl Queues {
return Pushed::Full(pending.thread.clone());
}
pending.waiting.push(message.text.clone());
pending.senders.push(message.user.clone());
return Pushed::Waiting;
}
let batch = Batch {
@@ -330,12 +353,14 @@ impl Queues {
thread: message.thread.clone(),
resume: message.resume,
text: message.text.clone(),
senders: vec![message.user.clone()],
};
self.running.insert(
message.session,
Pending {
thread: message.thread.clone(),
waiting: Vec::new(),
senders: Vec::new(),
},
);
Pushed::Start(batch)
@@ -354,6 +379,7 @@ impl Queues {
thread: pending.thread.clone(),
resume: true,
text: pending.waiting.join("\n\n"),
senders: std::mem::take(&mut pending.senders),
};
pending.waiting.clear();
self.running.insert(session.clone(), pending);