M4b review, and follow-up tasks 16 to 18

The review of tasks 01 to 15 goes in the implementer log, with the
device check against the owner's Mattermost. Three follow-ups:

- 16: the refusal without a runner says why.
- 17: the texts that say who asked.
- 18: approvals from non-approvers in direct or group messages go to
  an approver.

Task 14's given serve_approvals.rs becomes serve_approvals.rs-task14,
since task 18 hands over a new version. Tip T31 is added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 11:35:24 -07:00
co-authored by Claude Opus 5.5
parent d2e814161f
commit f6dd7f1741
12 changed files with 1202 additions and 19 deletions
+274
View File
@@ -0,0 +1,274 @@
# M4b task 18: an approval a non-approver asked for goes to an approver
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: approvals asked by non-approvers in direct messages go to an approver`
## Goal
M4b spec, section 6, as changed after the review of 2026-09-24. Today an approval always goes
into the thread whose turn asked for it. When that turn was asked by someone who may talk to the
bot but not approve (in `[allow] users`, not in `[approvals] users`), in a **direct or group
message**, no approver can answer it there: it could only be answered with `bxctl`. Now:
1. **Redirected.** Such an approval goes to the first approver's direct messages as a new thread.
Its first post is task 17's `asked_post`: who asked (`@username`), where, and their message.
Then come the approval's own posts, in that thread, and the asking thread gets one line,
`sent_to_approver`. The approver answers in the new thread.
2. **Stays.** An approval asked by an approver, or asked in a channel (`O` or `P`: an approver
can reply in the thread there), stays in its thread, as today.
3. **Not followed.** An approval whose session has no turn in flight here already goes to the
first approver's direct messages; now its first post is task 17's `unfollowed_post`, and the
approval's posts follow in that thread.
To know who asked, the serve loop keeps two maps in memory, not in the state file (after a
restart the turn is interrupted anyway): `authors`, the author and channel type of the last
message queued for each session, and `askers`, for each turn running, its author, channel type
and text. `start` fills `askers` from `authors`; `finished` removes both.
## Files
- Copy: `crates/gatewayd/tests/serve_redirect.rs`, and a **new version** of the given
`crates/gatewayd/tests/serve_approvals.rs` (an unfollowed session's approval now has a first
post: 3 posts, not 2)
- Modify: `crates/gatewayd/src/serve/approvals.rs`, `crates/gatewayd/src/serve/mod.rs`,
`crates/gatewayd/src/serve/handle.rs`, `docs/implementer-log.md`
## The changes, exactly
### `serve/mod.rs`
In `struct Gateway`, after the field `open`, add:
```rust
/// The author and channel type of the last message queued in each session.
authors: HashMap<SessionId, (String, String)>,
/// Who asked for each turn running, by session id.
askers: HashMap<String, approvals::Asker>,
```
and in `Gateway::new`, after `open: HashMap::new(),`, add `authors: HashMap::new(),` and
`askers: HashMap::new(),`. `HashMap` and `SessionId` are already imported there.
### `serve/approvals.rs`
1. The import `use crate::approvals::text::{self, Ended};` becomes
`use crate::approvals::text::{self, Asked, Ended};`.
2. Insert these two types just before the doc comment of `pub(super) struct Open`:
```rust
/// Who asked for the turn now running in a session: the author of the last message queued, the
/// channel's type, and the turn's text. Kept in memory only, and forgotten when the turn ends.
#[derive(Debug, Clone)]
pub(super) struct Asker {
pub(super) user: String,
pub(super) channel_type: String,
pub(super) prompt: String,
}
/// Where an approval goes, and what is posted besides it.
struct Place {
channel: String,
root: String,
/// A post before the approval's own: who asked and what, or whose session.
first: Option<String>,
/// The asking thread, told where the approval went.
note: Option<(String, String)>,
}
```
3. Replace the whole function `place`, from its doc comment (`/// Where an approval goes: its
session's turn in flight, …`) to its closing `}`, including the numbered comment inside it,
by these two functions:
```rust
/// Where an approval goes (M4b spec, section 6): its turn's thread; or, when a non-approver
/// asked in a direct or group message, the first approver's direct messages with who asked
/// and what, and a note in the asking thread; or, for a session not followed here, the first
/// approver's direct messages with a line saying so.
fn place(&self, info: &PendingApproval) -> Result<Place, String> {
let id = info.approval;
let session = info.session.as_str();
let Some(turn) = self.state.turn_of(session) else {
return Ok(Place {
channel: self.approver_dm()?,
root: String::new(),
first: Some(text::unfollowed_post(id, session)),
note: None,
});
};
let redirect = self.askers.get(session).filter(|a| {
matches!(a.channel_type.as_str(), "D" | "G")
&& !self.config.approvers().contains(&a.user)
});
let Some(asker) = redirect else {
return Ok(Place {
channel: turn.channel,
root: turn.root,
first: None,
note: None,
});
};
let by = match self.client.username(&asker.user) {
Ok(name) => format!("@{name}"),
Err(_) => asker.user.clone(),
};
let asked = Asked {
by,
channel_type: asker.channel_type.clone(),
prompt: asker.prompt.clone(),
};
Ok(Place {
channel: self.approver_dm()?,
root: String::new(),
first: Some(text::asked_post(id, &asked)),
note: Some((turn.channel, turn.root)),
})
}
/// The first approver's direct channel with the bot.
fn approver_dm(&self) -> Result<String, String> {
let first = self
.config
.first_approver()
.ok_or("no approver is configured")?;
self.client
.direct_channel(&self.me.id, first)
.map_err(|e| format!("no direct channel with the first approver: {e}"))
}
```
`place` checks in this order: no turn in flight → not followed; an asker who is not an
approver, in a `D` or `G` channel → redirected; anything else → its thread. A user name that
cannot be looked up is shown as the user id, as it is elsewhere.
4. In `show`:
- Its doc comment (two lines, `/// Post an approval's parts in order, …`) becomes:
```rust
/// Post an approval's first post, if any, and its parts in order, record them, and register
/// it for answers. An `Err` inside is why it could not be posted; the outer one stops
/// `gatewayd` (the state file).
```
- These lines
```rust
let (channel, mut root) = match self.place(info) {
Ok(place) => place,
Err(why) => return Ok(Err(why)),
};
let shown = text::render(info);
```
become
```rust
let place = match self.place(info) {
Ok(place) => place,
Err(why) => return Ok(Err(why)),
};
let (channel, mut root) = (place.channel, place.root);
let shown = text::render(info);
// A first post saying who asked, or whose session, then the approval's own posts.
let bodies: Vec<String> = place
.first
.into_iter()
.chain(shown.posts.iter().cloned())
.collect();
```
- Everywhere else in `show`, `shown.posts` becomes `bodies`. There are three:
`for body in &shown.posts {` → `for body in &bodies {`;
`shown.posts.get(posts.len().wrapping_sub(1))` → `bodies.get(posts.len().wrapping_sub(1))`;
`(posts.last(), shown.posts.last())` → `(posts.last(), bodies.last())`.
`shown.answerable` stays: it is not a post.
- Just before the last line, `Ok(Ok(posts))`, add:
```rust
if let Some((note_channel, note_root)) = place.note {
self.post(&note_channel, &note_root, &text::sent_to_approver(id));
}
```
The note is posted only after the approval was posted and registered; a failure to post it is
only logged (that is what `self.post` does).
### `serve/handle.rs`
1. Add the import `use crate::serve::approvals::Asker;` just before
`use crate::serve::{Gateway, Stop};`.
2. In `handle_post`, the arm `Route::Queue(message) => {` gets, as its first statement:
```rust
self.authors.insert(
message.session.clone(),
(post.user_id.clone(), channel_type.to_string()),
);
```
3. In `start`, just before the line
`let (registry, wait) = (Arc::clone(&self.registry), self.notice);`, add:
```rust
if let Some((user, channel_type)) = self.authors.get(&batch.session) {
let asker = Asker {
user: user.clone(),
channel_type: channel_type.clone(),
prompt: batch.text.clone(),
};
self.askers
.insert(batch.session.as_str().to_string(), asker);
}
```
It must come before that line: the thread spawned below takes `batch` by `move`.
4. Replace `finished`, from its doc comment to its closing `}`, by:
```rust
/// 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> {
while let Ok(session) = self.done_rx.try_recv() {
self.state.end_turn(session.as_str())?;
self.askers.remove(session.as_str());
match self.queues.finish(&session) {
Some(batch) => self.start(batch)?,
None => {
self.authors.remove(&session);
}
}
}
Ok(())
}
```
(The old numbered comment inside it goes: it described the loop before this change.)
## Steps
- [ ] **1. Copy.** `git switch m4b`, then
`cp docs/plans/M4b/files/crates/gatewayd/tests/serve_redirect.rs docs/plans/M4b/files/crates/gatewayd/tests/serve_approvals.rs crates/gatewayd/tests/`
- [ ] **2. See it fail.** `cargo test --no-fail-fast -p gatewayd --test serve_redirect --test serve_approvals`.
Expected: in `serve_redirect`, 3 fail and 1 passes (`in_a_channel_it_stays_in_the_thread`); in
`serve_approvals`, 1 fails (`without_a_turn_in_flight_…`) and 4 pass.
- [ ] **3. Make the changes**, one file at a time, in the order above, with
`cargo check -p gatewayd` after each. Run `cargo fmt --all`.
- [ ] **4. See it pass.**
`cargo test -p gatewayd --test serve_redirect --test serve_approvals --test serve_answers --test serve_restart`,
three times: 4, 5, 4 and 7 passed. Then `cargo test -p gatewayd`: every suite passes.
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`, with 4 more tests than
before this task (about 854). No file over 500 lines: `serve/approvals.rs` is about 260.
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
This is the last task of M4b. Stop after the commit.
## Done when
- `cargo test -p gatewayd` passes; `make gate` prints `gate: ok`.
## Stop and report if
- A given test still fails after every change above is made exactly. Do not edit the tests.
- A test passes only sometimes, or takes more than a few seconds.
- The compiler needs a change in a file not listed here.