Files
boxmaker/docs/plans/M4b/15-gatewayd-answers.md
kyleandClaude Opus 5.5 af7137dafe M4b task 15: fix task 14's dropped refusal reason, as a listed edit
Task 15's session found the defect in serve/approvals.rs, a file it did not list, and stopped.
The comment in task 14 used one name for two values; task 14's tests could not reach it. Lesson T30.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 08:46:33 -07:00

180 lines
7.8 KiB
Markdown

# M4b task 15: commands answer approvals; typing pauses; the notice
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: approvals answered from Mattermost; typing paused; the notice`
## Goal
The last three pieces (M4b spec, section 7):
1. **Commands.** `Route::Approval` (task 08) goes to the registry's `judge` (task 11): an accepted
answer goes to `brokerd`; anything else is answered in the thread with its reason. M4a's notice
`M4B_COMMAND` goes away.
2. **Typing.** A turn waiting for its approval is not working: no `user_typing` for its thread
while the registry holds an approval of its session.
3. **The notice.** On `approval_pending`, the turn waits up to 10 s for `brokerd` to bring that
approval here (`Registry::was_posted`); only if it never does, it posts `waiting for approval
<id>: brokerd has not sent it here; answer it with bxctl approvals`.
## Files
- Copy: `crates/gatewayd/tests/serve_answers.rs`, `crates/gatewayd/tests/deliver_notice.rs`, and
**new versions** of three given files: `crates/gatewayd/tests/deliver.rs` (passes a `Notice`;
the new notice text), `crates/gatewayd/tests/serve.rs` (`!approve 3` is now "approval 3 is not
pending here"), `crates/gatewayd/tests/support/gateway.rs` (`Tuning` has `notice`)
- Modify: `crates/gatewayd/src/approvals/mod.rs`, `crates/gatewayd/src/deliver.rs`,
`crates/gatewayd/src/serve/mod.rs`, `crates/gatewayd/src/serve/handle.rs`,
`crates/gatewayd/src/serve/approvals.rs` (one fix, below), `crates/gatewayd/src/sessions.rs`,
`docs/implementer-log.md`
## The changes, exactly
**`approvals/mod.rs`**: every approval ever posted, for the notice. `Registry` gains a field
`ever: Mutex<HashSet<u64>>` (doc comment: "Every approval ever posted here, answered or not: for
the notice."; import `HashSet` beside `HashMap`); `add` also inserts `posted.approval` into it
(`self.ever.lock().unwrap_or_else(|p| p.into_inner()).insert(…)`, before inserting into `posted`);
and a new method:
```rust
/// Was approval `id` ever posted here?
pub fn was_posted(&self, id: u64) -> bool {
self.ever
.lock()
.unwrap_or_else(|p| p.into_inner())
.contains(&id)
}
```
**`deliver.rs`**:
- `approval_text` returns
`format!("waiting for approval {approval}: brokerd has not sent it here; answer it with bxctl approvals")`.
- After it, and `use std::time::{Duration, Instant};` at the top:
```rust
/// How long a turn waits for `brokerd` to bring its approval here before posting the notice.
pub const NOTICE_WAIT: Duration = Duration::from_secs(10);
/// Whether an approval was brought here (and so is posted already), and how long to wait for it.
pub struct Notice<'a> {
pub posted: &'a dyn Fn(u64) -> bool,
pub wait: Duration,
}
```
and write `impl Notice<'_> { fn arrives(&self, id: u64) -> bool }`: loop: `(self.posted)(id)`
→ true; past `Instant::now() + self.wait` (taken once, before the loop) → false; otherwise sleep
`Duration::from_millis(50).min(<the time left>)` (`saturating_duration_since`).
- `deliver` gains a last parameter `notice: &Notice`, and its `ApprovalPending` branch posts the
notice only when it does not arrive:
```rust
// The approval post itself comes through `gateway.sock`; the notice only when it does not.
if let TurnEvent::ApprovalPending { approval, .. } = event
&& !notice.arrives(*approval)
{
post(poster, &batch.thread, approval_text(*approval), log);
}
```
**`serve/mod.rs`**: `Tuning` gains `pub notice: Duration,` (doc: "How long a turn waits for its
approval to come through `gateway.sock` before the notice."), `crate::deliver::NOTICE_WAIT` in its
`Default`; `Gateway` gains a field `notice: Duration` (doc "`Tuning::notice`."), set by a new last
parameter of `Gateway::new`, which `run` passes as `tuning.notice`.
**`serve/handle.rs`**:
- The `Route::Approval` arm becomes:
```rust
Route::Approval {
thread,
command,
user,
post: command_post,
at,
} => {
let approvers = self.config.approvers();
let judged = self.registry.judge(
&command,
&user,
&thread.root,
&command_post,
at,
approvers,
);
if let Judged::Reply(text) = judged {
self.post(&thread.channel, &thread.root, &text);
}
}
```
- In `start`, the thread gets the registry and the wait, and passes a `Notice`:
```rust
let (registry, wait) = (Arc::clone(&self.registry), self.notice);
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);
let _ = done.send(batch.session);
});
```
- In `typing`, before the loop: `let waiting = self.registry.waiting_sessions();`, and at the top
of the loop body: `if waiting.contains(&format!("mm-{}", thread.root)) { continue; }` (with the
comment "A turn waiting for its approval is not working: no typing in its thread.").
- Imports: `use crate::approvals::Judged;`; `Notice` beside `deliver`; `M4B_COMMAND` removed.
**`sessions.rs`**: delete `pub const M4B_COMMAND`.
**`serve/approvals.rs`, a fix to task 14's `ended`.** `ended` has two different values that were
both called `reason`: the refusal's text, in `Ending::Answered { reason, .. }`, and the
`DenyReason` in `GatewayOutcome::Denied { reason }`. Task 14's comment did not say which went
where, and its tests could not send a refusal, so the refusal's text was dropped (`reason: _`, and
`Ended::Refused { by, reason: None }`). This task's `refused_with_a_reason_the_post_says_so` shows
it. Name the refusal's text `refusal`:
```rust
Ending::Answered {
by,
reason: refusal,
outcome,
} => {
```
and in the `match outcome` inside it, the `Refused` arm becomes:
```rust
GatewayOutcome::Refused {} => Ended::Refused {
by,
reason: refusal,
},
```
The `Denied { reason }` arm stays as it is: that `reason` is the `DenyReason`.
## Steps
- [ ] **1. Copy.** `git switch m4b`, then
`cp docs/plans/M4b/files/crates/gatewayd/tests/serve_answers.rs docs/plans/M4b/files/crates/gatewayd/tests/deliver_notice.rs docs/plans/M4b/files/crates/gatewayd/tests/deliver.rs docs/plans/M4b/files/crates/gatewayd/tests/serve.rs crates/gatewayd/tests/ && cp docs/plans/M4b/files/crates/gatewayd/tests/support/gateway.rs crates/gatewayd/tests/support/`
- [ ] **2. See it fail.** `cargo test -p gatewayd --test serve_answers --test deliver_notice`.
Expected: they do not compile (`deliver::Notice` and `Tuning::notice` do not exist yet).
- [ ] **3. Make the changes**, one file at a time, `cargo check -p gatewayd` after each. Run
`cargo fmt --all`.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test serve_answers --test deliver_notice --test deliver --test serve`,
three times: 4, 4, 9 and 6 passed. Then `cargo test -p gatewayd`: every suite passes.
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`, with about 845 tests.
- [ ] **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 test passes only sometimes, or waits much longer than stated.