feat(discord): binding reload without a restart, and a per-user channel allowlist (#1509)

`reload` validates the binding file and sends SIGHUP to the live owner;
the running connector re-reads it and swaps guildName, channels, users
and limits in place. name, seat, guildId, botUserId, tokenFile, engine
and context are fixed for the life of the process; a change there, an
invalid file or a channel outside the guild refuses the reload and keeps
the old binding. Every attempt is one line in reloads.jsonl. The service
unit maps `systemctl --user reload` to the same signal.

A user entry may carry `channels`, an allowlist of listed channel ids;
absent means every listed channel. Outside the list the message is
dropped as channel-not-for-user; threads count as their parent.

Suite 41/41, 101 node tests. QUEUE rows 19 and 20 opened.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-13 18:59:31 -05:00
co-authored by Claude Fable 5.1
parent d9745a4510
commit caaef941e6
16 changed files with 326 additions and 21 deletions
+23 -3
View File
@@ -18,6 +18,7 @@ scripts/discord.sh run <binding> [--supervised]
scripts/discord.sh stop <binding>
scripts/discord.sh unlock <binding>
scripts/discord.sh recover <binding>
scripts/discord.sh reload <binding>
scripts/discord-service.sh render | install | uninstall | status <binding>
```
@@ -63,6 +64,22 @@ scripts/discord-service.sh render | install | uninstall | status <binding>
line it wrote itself; a brake an operator wrote at any point, even during
the recovery, stays and the start is refused. Nothing automatic ever
removes an operator's `STOP`.
- `reload` applies an edited binding to the running connector without a
restart. It validates the file first (an invalid file exits 2 and nothing
is signaled), then sends SIGHUP to the live owner in `run.lock` (no live
owner exits 1; the file applies at the next start). The process re-reads
the file and swaps `guildName`, `channels`, `users` and `limits` in place;
a channel that is new to the binding is read over REST and must be in the
bound guild. `name`, `seat`, `guildId`, `botUserId`, `tokenFile`, `engine`
and `context` are fixed for the life of the process, because the engine
and its prompt are launched once and the token is read once; a change
there, an invalid file or a failed channel lookup refuses the reload and
keeps the old binding. The turn in flight finishes under the limits it
started with; the next admission uses the new binding. Every attempt is
one line in `reloads.jsonl`, `applied` with the differences by id or
`refused` with the reason, and one line in the log. Nothing is sent to
Discord. Under the service unit, `systemctl --user reload
mosaic-discord@<binding>` sends the same signal.
Exit codes: 0 ok, 1 operation failed, 2 invalid data or configuration, 3
refused by a brake (`STOP` present or the binding held; a supervisor must
@@ -114,13 +131,15 @@ is `src/binding.mjs`.
| `guildId`, `guildName`, `botUserId` | the one server and the bot identity `check` confirms |
| `tokenFile` | absolute path to the bot token, 0600, read into memory at start, never printed or journaled |
| `channels[]` | `{id, name, mode}`; `open` answers every message, `mention` only when the bot is mentioned; threads inherit the parent's mode |
| `users[]` | `{id, name}`; the only authors that get a turn |
| `users[]` | `{id, name, channels?}`; the only authors that get a turn. `channels` is an optional allowlist of listed channel ids; absent means every listed channel, present means those and their threads only, everything else is dropped as `channel-not-for-user` |
| `engine` | `provider`, `model`, `thinking` for pi |
| `limits` | `turnsPerDay` (200), `turnTimeoutSeconds` (180), `replyChunkChars` (1900), `inboundMaxChars` (4000) |
| `context.files[]` | files appended to pi's system prompt in order, repository-relative and inside the repository (no absolute paths, `..` or symlinks); the Discord block is added after them |
Unknown keys, missing fields, wrong types, empty allowlists and a bot listed
as a user all refuse with exit 2.
Unknown keys, missing fields, wrong types, empty allowlists, a user channel
that is not listed and a bot listed as a user all refuse with exit 2. A
running connector picks up an edit through `reload`; the fields it will not
take in place are listed under that command.
## What happens to a message
@@ -172,6 +191,7 @@ start and names the nonces.
<dataRoot>/discord/<binding>/launches/ context snapshot and sha256 per run
<dataRoot>/discord/<binding>/STOP stop switch
<dataRoot>/discord/<binding>/notices.jsonl once-per-day fixed lines already attempted
<dataRoot>/discord/<binding>/reloads.jsonl one line per reload attempt, applied or refused
<dataRoot>/discord/<binding>/run.lock/ ownership directory (atomic mkdir) with owner.json {pid, start, boot}; stale ones need `unlock`
<dataRoot>/sessions/discord-<binding>/ the pi session, continued across runs
```