Files
boxmaker/docs/plans/M4b/15-gatewayd-answers.md
T
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

7.8 KiB

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:

    /// 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:
/// 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:
        // 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:
            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:
        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:

            Ending::Answered {
                by,
                reason: refusal,
                outcome,
            } => {

and in the match outcome inside it, the Refused arm becomes:

                    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.