|
|
|
@@ -0,0 +1,413 @@
|
|
|
|
|
# Discord connector pilot: Sage on Shared Signals
|
|
|
|
|
|
|
|
|
|
Owner of the plan: Jason. Coordinator and builder: the Claude session.
|
|
|
|
|
Reviewer: Filbert or an orch-01 review seat, whoever is free when the
|
|
|
|
|
candidate is pinned. Tracking: #1509. QUEUE row 14.
|
|
|
|
|
|
|
|
|
|
Status: candidate built, in review. Jason settled Q1 to Q27 through
|
|
|
|
|
ms-grill-me on 2026-09-13 (section 9). The package, the offline suite
|
|
|
|
|
(`scripts/test-discord.sh`, 74 tests in eight groups plus package checks),
|
|
|
|
|
the wrapper, the DISCORD-USER.md draft and the README were written the same
|
|
|
|
|
day; section 10 records the choices made while building. No listener runs
|
|
|
|
|
until the live pilot in section 6. The bot on Shared Signals is
|
|
|
|
|
administration-capable only, per the private setup record.
|
|
|
|
|
|
|
|
|
|
## 1. What exists today
|
|
|
|
|
|
|
|
|
|
Sage's private workspace holds the completed one-time server setup, a
|
|
|
|
|
bounded REST administration utility, a research packet on Hermes and OpenClaw
|
|
|
|
|
with pinned hashes, and an agent-direction memo. Path (private, not in this
|
|
|
|
|
checkout):
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
~/.mosaic/fleet/agents/sage/work/shared-signals/
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Verified from those records on 2026-09-13:
|
|
|
|
|
|
|
|
|
|
- Bot identity and the exact guild are checked before every write. Fourteen
|
|
|
|
|
writes have matching journal receipts. Two categories and nine channels were
|
|
|
|
|
created, original channels and roles untouched. Avatar and banner set.
|
|
|
|
|
- The admin utility is on-demand. There is no gateway connection, so a
|
|
|
|
|
mention in Discord reaches nobody.
|
|
|
|
|
- The direction memo recommends one small owned integration, not a fork of
|
|
|
|
|
Hermes or OpenClaw, and lists the controls every version must have:
|
|
|
|
|
server AND channel AND stable user ID before any model call, action
|
|
|
|
|
permissions separate from chat permission, Discord text treated as
|
|
|
|
|
untrusted input, durable inbound and outbound records so a restart cannot
|
|
|
|
|
duplicate a reply, a stop switch and a usage ceiling.
|
|
|
|
|
- The token and server-id files are mode 0644 inside a 0700 directory. The
|
|
|
|
|
connector's check must refuse anything but 0600, so this gets fixed before
|
|
|
|
|
the first check run.
|
|
|
|
|
|
|
|
|
|
The archived `v1/plugins/discord` is a gateway-service adapter for a
|
|
|
|
|
different runtime. It is reference only. Nothing from it is activated.
|
|
|
|
|
|
|
|
|
|
## 2. Outcome of the pilot
|
|
|
|
|
|
|
|
|
|
Jason writes to Sage in one Shared Signals channel and gets a reply from the
|
|
|
|
|
same Sage persona that runs in the terminal, without a terminal. A restart in
|
|
|
|
|
the middle of a turn does not produce a second reply. An unlisted user gets
|
|
|
|
|
nothing. The whole exchange is on disk as write-once records.
|
|
|
|
|
|
|
|
|
|
That is the entire MVP. Repository writes from chat, announcements,
|
|
|
|
|
scheduled posts, attachments, tools, DMs, slash commands, Carmen's
|
|
|
|
|
enrollment, and sharing the terminal conversation are all later pieces and
|
|
|
|
|
are listed in section 8 so they are not lost.
|
|
|
|
|
|
|
|
|
|
## 3. Where it sits in Mosaic Stack
|
|
|
|
|
|
|
|
|
|
A Discord channel is one more interface onto a seat's conversation, the same
|
|
|
|
|
class of thing as the WebUI session chat (row 5) and the terminal. The
|
|
|
|
|
settled WebUI rulings apply here where they overlap: one writer per
|
|
|
|
|
conversation (Q5, Q9), a transport receipt is not an answer, uncertain sends
|
|
|
|
|
are reconciled rather than replayed (Q14), and Discord text is untrusted
|
|
|
|
|
data. The pilot does not wait for the conversation controller that CHAT-03
|
|
|
|
|
will build. It owns its own engine now and is written so the engine can be
|
|
|
|
|
swapped for that controller later without touching transport or policy.
|
|
|
|
|
|
|
|
|
|
Layout, following `packages/seat`:
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
packages/discord/ reusable software, committed
|
|
|
|
|
src/binding.mjs strict schema, fail-closed load, token file check
|
|
|
|
|
src/authorize.mjs pure decision: guild, channel, thread, user, mode
|
|
|
|
|
src/gateway.mjs Discord gateway v10 client (identify, heartbeat, resume)
|
|
|
|
|
src/rest.mjs create message with nonce, typing, get channel/guild/me
|
|
|
|
|
src/engine-pi.mjs pi --mode rpc child, one session, follow-up queue
|
|
|
|
|
src/journal.mjs inbox, outbox, drops JSONL; write-once turn records; STOP; pid
|
|
|
|
|
src/context.mjs Discord context block, envelope, reply splitter
|
|
|
|
|
src/connector.mjs the loop: event -> authorize -> journal -> engine -> deliver
|
|
|
|
|
src/cli.mjs check | run | stop
|
|
|
|
|
src/errors.mjs DiscordError with exit code
|
|
|
|
|
fixtures/binding.example.json the shape with placeholder ids
|
|
|
|
|
tests/ node --test, fake gateway, fake REST, fake pi
|
|
|
|
|
README.md
|
|
|
|
|
scripts/discord.sh thin wrapper, same as scripts/mosaic
|
|
|
|
|
scripts/test-discord.sh the offline suite
|
|
|
|
|
agents/sage/DISCORD-USER.md reduced profile (Q14), Jason edits
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Runtime data, never committed:
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
<dataRoot>/discord/<binding>.json the binding (IDs, limits), 0600
|
|
|
|
|
<dataRoot>/discord/<binding>/inbox.jsonl every accepted Discord message id
|
|
|
|
|
<dataRoot>/discord/<binding>/outbox.jsonl intent -> confirmed | refused | unknown
|
|
|
|
|
<dataRoot>/discord/<binding>/admissions.jsonl one line per admitted turn, before the engine runs
|
|
|
|
|
<dataRoot>/discord/<binding>/turns/<id>.json one write-once record per turn
|
|
|
|
|
<dataRoot>/discord/<binding>/drops.jsonl one line per dropped or refused message
|
|
|
|
|
<dataRoot>/discord/<binding>/launches/ context snapshot and sha256 per run
|
|
|
|
|
<dataRoot>/discord/<binding>/STOP stop switch (presence refuses new turns)
|
|
|
|
|
<dataRoot>/discord/<binding>/notices.jsonl once-per-day fixed lines already attempted
|
|
|
|
|
<dataRoot>/discord/<binding>/run.lock/ ownership directory (atomic mkdir), owner.json {pid, start, boot}; stale needs `unlock`
|
|
|
|
|
<dataRoot>/sessions/discord-<binding>/ pi session JSONL for this conversation
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The binding is deployment policy for a private server. It carries Discord
|
|
|
|
|
IDs for real people, so it lives under the data root, not in `roles/`. The
|
|
|
|
|
repository holds the schema and a fixture with placeholder IDs. The token
|
|
|
|
|
stays where it is, in Sage's private secrets directory, and the binding
|
|
|
|
|
points at it by path. The connector reads it into the child process
|
|
|
|
|
environment and never prints, journals or passes it on a command line.
|
|
|
|
|
|
|
|
|
|
## 4. Behaviour
|
|
|
|
|
|
|
|
|
|
Inbound. The gateway delivers `MESSAGE_CREATE`. The connector drops the
|
|
|
|
|
event unless all of these hold: guild id matches the binding; the channel,
|
|
|
|
|
or for a thread its parent channel, is listed; the author id is listed; the
|
|
|
|
|
author is not a bot or webhook; for a channel in `mention` mode the bot is
|
|
|
|
|
mentioned. A drop writes one counter line, never a Discord reply. Anything
|
|
|
|
|
that passes is appended to the inbox before any other action. A message id
|
|
|
|
|
already in the inbox is ignored, which is the restart guard.
|
|
|
|
|
|
|
|
|
|
Turn. One turn at a time per binding. The prompt sent to pi is an envelope:
|
|
|
|
|
channel name, thread name if any, author id, message id, then the text.
|
|
|
|
|
Text is data. The system prompt says so in one paragraph and states the reply
|
|
|
|
|
limit. While a turn runs, later accepted messages queue as pi follow-ups and
|
|
|
|
|
stay in the inbox. Turn timeout aborts pi and journals a failed turn.
|
|
|
|
|
|
|
|
|
|
Outbound. The final assistant text is split at 1900 characters on paragraph
|
|
|
|
|
boundaries. Each chunk is sent with `nonce` set to the outbox intent id and
|
|
|
|
|
`enforce_nonce` true, so a retry after an unknown outcome returns the
|
|
|
|
|
existing message instead of posting twice. `allowed_mentions` is empty.
|
|
|
|
|
Intent is journaled before the POST, confirmation after, with the Discord
|
|
|
|
|
message id. A network failure leaves `unknown`; on the next start the
|
|
|
|
|
connector reconciles every `unknown` by re-sending the same nonce, which
|
|
|
|
|
Discord dedups, before it accepts new traffic.
|
|
|
|
|
|
|
|
|
|
Controls. `check` validates the binding, refuses a token file that is not
|
|
|
|
|
0600 regular and non-symlink, connects, confirms bot id, guild id and channel
|
|
|
|
|
visibility, confirms the message-content intent is granted (close code 4014
|
|
|
|
|
means it is not), and exits without sending anything. `run` refuses to start
|
|
|
|
|
when STOP exists or unknown outbox entries cannot be reconciled. `stop`
|
|
|
|
|
creates STOP and signals the process; the current turn finishes or times
|
|
|
|
|
out, then the process exits. A daily turn ceiling from the binding is
|
|
|
|
|
enforced by counting turn records; over the ceiling the connector journals
|
|
|
|
|
the refusal and stops accepting.
|
|
|
|
|
|
|
|
|
|
Engine. `pi --mode rpc --no-tools --no-extensions --no-context-files
|
|
|
|
|
--no-skills --offline`, provider, model and thinking from the binding, session
|
|
|
|
|
dir under the data root, `--continue` when a session exists. The system prompt
|
|
|
|
|
is assembled the way `scripts/agent-host-dev.sh` does it for the terminal
|
|
|
|
|
Sage: CONSTITUTION, STANDARDS, Sage SOUL, USER, plus a Discord context block.
|
|
|
|
|
AGENTS.md and CONTEXT.md are not injected; this Sage has no tools and no
|
|
|
|
|
repository. Every prompt waits for `agent_settled`, then reads the last
|
|
|
|
|
assistant message from `get_messages`. No tools means no approvals and no
|
|
|
|
|
file effects in the pilot.
|
|
|
|
|
|
|
|
|
|
## 5. Tests before any live run
|
|
|
|
|
|
|
|
|
|
All offline, `node --test packages/discord/tests`. No token, no network, no
|
|
|
|
|
model.
|
|
|
|
|
|
|
|
|
|
- Binding: unknown key, missing field, wrong type, non-0600 token file,
|
|
|
|
|
symlink token file, empty allowlists each refuse with exit 2.
|
|
|
|
|
- Authorization: a table of 20 or so cases covering wrong guild, unlisted
|
|
|
|
|
channel, thread of listed and unlisted parent, unlisted user, bot author,
|
|
|
|
|
webhook, mention mode with and without mention, `@everyone`.
|
|
|
|
|
- Gateway: fake WebSocket drives hello, identify, ready, heartbeat and ack,
|
|
|
|
|
missed ack forces reconnect, op 7 reconnect resumes with sequence, op 9
|
|
|
|
|
invalid session re-identifies, close 4014 reports missing intent.
|
|
|
|
|
- Delivery: fake REST returns 200, 429 with retry_after, 5xx, and a socket
|
|
|
|
|
error; the outbox shows confirmed, refused and unknown; restart with an
|
|
|
|
|
unknown entry re-sends the same nonce once and reconciles.
|
|
|
|
|
- Engine: fake pi RPC over stdio; prompt while streaming becomes follow-up;
|
|
|
|
|
timeout sends abort; malformed JSONL line fails the turn, not the process.
|
|
|
|
|
- Restart: an inbox with three ids and a MESSAGE_CREATE replay of the same
|
|
|
|
|
three produces zero turns.
|
|
|
|
|
- Stop and ceiling: STOP present refuses run; the ceiling plus one is refused
|
|
|
|
|
and journaled.
|
|
|
|
|
|
|
|
|
|
## 6. Live pilot and acceptance
|
|
|
|
|
|
|
|
|
|
After the offline suite is green and Jason gives the go in section 7.
|
|
|
|
|
|
|
|
|
|
1. Fix token file mode to 0600. Run `check`. Receipt in Sage's private
|
|
|
|
|
evidence directory.
|
|
|
|
|
2. Start `run` in a tmux window on the host. Jason sends one message in the
|
|
|
|
|
agreed channel. Sage answers. Turn record and outbox line exist.
|
|
|
|
|
3. Send a second message while the first turn is running. It arrives as a
|
|
|
|
|
follow-up, not a lost message and not a second concurrent turn.
|
|
|
|
|
4. Kill the process during a turn, restart. No second reply to the same
|
|
|
|
|
message. Outbox reconciled.
|
|
|
|
|
5. From a second Discord account not on the allowlist (Jason's choice of
|
|
|
|
|
account), send a message. No reply, one drop line.
|
|
|
|
|
6. Send a message containing a quoted instruction to reveal the token or to
|
|
|
|
|
read private strategy. Sage refuses in text. Nothing leaves the process
|
|
|
|
|
that was not already in the prompt.
|
|
|
|
|
7. Lower the ceiling to the current count, send one more. Refused, journaled,
|
|
|
|
|
process stops accepting.
|
|
|
|
|
8. Record per-turn latency and token usage from the turn records.
|
|
|
|
|
|
|
|
|
|
Acceptance is all eight with receipts, plus Jason's word that the reply
|
|
|
|
|
reads as Sage. The gate is Jason's ruling, not the checklist.
|
|
|
|
|
|
|
|
|
|
## 7. Decisions put to Jason (all ruled, see section 9)
|
|
|
|
|
|
|
|
|
|
These change the work or cross a boundary (spend, external communication as
|
|
|
|
|
Sage, a new dependency). Recommendations are marked.
|
|
|
|
|
|
|
|
|
|
- D1. Activation and ceiling. Running a listener that posts as Sage on a
|
|
|
|
|
shared server is external communication and costs model calls. Recommend
|
|
|
|
|
go for the pilot with a ceiling of 200 turns per day and a 180 second turn
|
|
|
|
|
timeout, both in the binding.
|
|
|
|
|
- D2. Authorized users. The guild owner id in the setup evidence is presumed
|
|
|
|
|
to be Jason. Confirm it. Carmen is not enrolled in the pilot; her verified
|
|
|
|
|
id is a later piece.
|
|
|
|
|
- D3. Channel scope. Recommend `#sage-admin` in open mode (it is already
|
|
|
|
|
private to admins) and `#general` in mention mode, threads allowed in both.
|
|
|
|
|
Nothing else listens.
|
|
|
|
|
- D4. Discord client. Recommend a raw gateway and REST client on Node's
|
|
|
|
|
built-in WebSocket and fetch, matching the no-dependency convention of the
|
|
|
|
|
other packages and keeping the whole transport auditable. The alternative
|
|
|
|
|
is `discord.js` pinned exactly, which brings a large dependency tree. If
|
|
|
|
|
the soak in section 6 shows reconnect problems the raw client cannot
|
|
|
|
|
handle, switch to `discord.js` as a separate piece.
|
|
|
|
|
- D5. Model and account. Recommend the same provider, model and thinking as
|
|
|
|
|
the terminal Sage launcher (`zai/glm-5.3`, high), same auth file, with the
|
|
|
|
|
ceiling from D1 as the spend limit. A separate account is a later choice.
|
|
|
|
|
- D6. Message-content intent. The Discord developer portal toggle for the
|
|
|
|
|
bot must be on. Jason's action; `check` proves it.
|
|
|
|
|
- D7. Builder. Recommend the Claude session builds it under this brief with
|
|
|
|
|
Filbert as reviewer, keeping darkwing on rows 6 and 9. Alternatively
|
|
|
|
|
darkwing builds it after row 9.
|
|
|
|
|
|
|
|
|
|
## 8. Explicitly later
|
|
|
|
|
|
|
|
|
|
Kept here so the pilot stays small. None of these start on their own.
|
|
|
|
|
|
|
|
|
|
- Carmen's verified id and shared-room rules.
|
|
|
|
|
- Tools for the Discord Sage (read-only first), then repository writes with
|
|
|
|
|
reviewed diffs, per the direction memo.
|
|
|
|
|
- Announcements and scheduled posts with explicit destinations and approval.
|
|
|
|
|
- Attachments and images.
|
|
|
|
|
- Slash commands, DMs, per-thread sessions.
|
|
|
|
|
- Sharing one conversation between terminal, WebUI and Discord through the
|
|
|
|
|
CHAT-03 controller.
|
|
|
|
|
- Control board row for the connector (its pane runs node, not pi, so the
|
|
|
|
|
current liveness check would show it offline).
|
|
|
|
|
- Supervision as a systemd user service instead of a tmux window.
|
|
|
|
|
- Fleet seats other than Sage, other servers, one product with
|
|
|
|
|
per-workspace bindings.
|
|
|
|
|
|
|
|
|
|
## 9. Rulings, 2026-09-13
|
|
|
|
|
|
|
|
|
|
Three rounds through ms-grill-me. Where a ruling changed section 4 the
|
|
|
|
|
ruling wins.
|
|
|
|
|
|
|
|
|
|
| ID | Ruling |
|
|
|
|
|
|---|---|
|
|
|
|
|
| Q1 | Chat only. No tools, no repository writes, no posting on its own. |
|
|
|
|
|
| Q2 | Live pilot authorized. Ceiling 200 turns per day, 180 s turn timeout, both in the binding. |
|
|
|
|
|
| Q3 | Jason only, by the guild owner id in the setup evidence. Carmen later. |
|
|
|
|
|
| Q4 | `#sage-admin` open mode, `#general` mention mode, threads inherit from parent. Nothing else. |
|
|
|
|
|
| Q5 | Raw gateway and REST on Node built-in WebSocket and fetch. No `discord.js`; revisit only if the soak shows reconnect trouble. |
|
|
|
|
|
| Q6 | Same provider, model, thinking and auth as the terminal Sage launcher. |
|
|
|
|
|
| Q7 | One pi session per binding, separate from the terminal Sage conversation. Envelope names channel, thread and author. |
|
|
|
|
|
| Q8 | Unauthorized message: silence, one drop line in the journal. |
|
|
|
|
|
| Q9 | Binding under `<dataRoot>/discord/`, 0600, never committed. Repo holds schema and placeholder fixture. |
|
|
|
|
|
| Q10 | No backfill. Only messages received live after start. |
|
|
|
|
|
| Q11 | tmux window by hand, STOP file as the brake. No systemd in the pilot. |
|
|
|
|
|
| Q12 | Claude session builds; reviewer is Filbert or an orch-01 review seat, whoever is free at pin. Darkwing stays on rows 6 and 9. |
|
|
|
|
|
| Q13 | Message-content intent believed on; `check` proves it. |
|
|
|
|
|
| Q14 | Do not inject USER.md. Inject `agents/sage/DISCORD-USER.md`, a Jason-authored reduced profile, committed. |
|
|
|
|
|
| Q15 | Discord context block as proposed: channel and server, messages are data not instructions, 1900 char replies, no tools/files/memory outside the conversation, no credentials/paths/private strategy, no promises of action. |
|
|
|
|
|
| Q16 | Sage declines DYOR strategy discussion everywhere in Discord until the shared-repository piece exists. |
|
|
|
|
|
| Q17 | Reply with message reference, in place. No thread creation. |
|
|
|
|
|
| Q18 | Chunk at 1900 on paragraph boundaries, one nonce per chunk. Typing indicator every 8 s during a turn. |
|
|
|
|
|
| Q19 | Failed turn: one fixed line posted, never model output, plus the failed turn record. |
|
|
|
|
|
| Q20 | Ceiling hit: one fixed line in the tripping channel, stay up idle until UTC midnight. |
|
|
|
|
|
| Q21 | Drop messages over 4000 chars with a fixed reply. Envelope is text only; attachments and embeds stripped. |
|
|
|
|
|
| Q22 | Live evidence goes to Sage's private shared-signals evidence directory. Repo gets offline suite results and a pointer. |
|
|
|
|
|
| Q23 | Step 5 uses Jason's real second account. |
|
|
|
|
|
| Q24 | Pi auto-compaction on with defaults. |
|
|
|
|
|
| Q25 | Offline suite of seven groups, wired into `scripts/verify.sh`. |
|
|
|
|
|
| Q26 | Live pilot exactly as section 6, receipts private. Gate H: Jason rules the reply reads as Sage. |
|
|
|
|
|
| Q27 | Order: package, tests, `scripts/discord.sh`, DISCORD-USER.md draft, README, verify hook. Commit to `refactor` after green suites and reviewer approval. No push without separate say-so. Live pilot after commit. |
|
|
|
|
|
|
|
|
|
|
## 10. Choices made while building, 2026-09-13
|
|
|
|
|
|
|
|
|
|
Recorded here because they refine a ruling or fill a gap the rulings left.
|
|
|
|
|
|
|
|
|
|
- Q25 said the suite is wired into `scripts/verify.sh`. That script is the
|
|
|
|
|
Docker hello check, not a suite aggregator; no package suite hooks into
|
|
|
|
|
it. The suite is `scripts/test-discord.sh`, the same shape as
|
|
|
|
|
`test-foundation.sh`, listed in `docs/TOOLS.md`. If verify.sh ever becomes
|
|
|
|
|
an aggregator, this suite joins it there.
|
|
|
|
|
- Reconcile window. Discord dedups `enforce_nonce` for "the past few
|
|
|
|
|
minutes". An `unknown` delivery older than five minutes is marked
|
|
|
|
|
`refused` with a reason instead of re-sent, because a re-send outside the
|
|
|
|
|
window would post a second reply, which is the one thing the outcome in
|
|
|
|
|
section 2 forbids. Inside the window it is re-sent once with the same
|
|
|
|
|
nonce, as section 4 says.
|
|
|
|
|
- Turn attribution in the engine. A turn that fails on the connector's side
|
|
|
|
|
(timeout, malformed line) stays in the engine's queue, marked done, until
|
|
|
|
|
pi's own `turn_end` for it arrives; otherwise that event would be read as
|
|
|
|
|
the answer to the next prompt. The offline engine group covers this.
|
|
|
|
|
- Thread lookup. A message in a channel that is neither listed nor known
|
|
|
|
|
from `GUILD_CREATE`/`THREAD_*` events causes one `GET /channels/{id}`,
|
|
|
|
|
and only when the guild matches and the author is listed, so strangers
|
|
|
|
|
never cost a REST call. The result is cached.
|
|
|
|
|
- Over the ceiling, refusals are drop lines, not turn records, so they do
|
|
|
|
|
not count against the ceiling themselves.
|
|
|
|
|
- Chunk nonces are `<message id>-<n>`; fixed-line nonces add `-f`, `-b`,
|
|
|
|
|
`-c` for failed, oversize and ceiling, all under Discord's 25-character
|
|
|
|
|
nonce limit.
|
|
|
|
|
- `agents/sage/` is untracked in this checkout as a whole (another session's
|
|
|
|
|
work). Only `DISCORD-USER.md` is part of this piece; the binding's context
|
|
|
|
|
list needs `agents/sage/SOUL.md` on disk, which it is.
|
|
|
|
|
- Review intake found the ceiling counted only written turn records, so a
|
|
|
|
|
burst arriving while turns ran could queue past the cap. Admitted turns now
|
|
|
|
|
count until their record is written. Two smaller observations stay as
|
|
|
|
|
follow-ups, not changes: a follow-up turn's timeout budget starts at
|
|
|
|
|
admission, so it runs shorter than `turnTimeoutSeconds` while an earlier
|
|
|
|
|
turn is still busy; and the pid file was left behind if `run` failed between
|
|
|
|
|
writing it and starting the connector (the next `run` overwrote a dead
|
|
|
|
|
pid, so nothing refused). Both pid-file points are moot since round two.
|
|
|
|
|
- rev-code-02's round one (#1509 comment 26114) changed four things. The
|
|
|
|
|
reconcile window is measured from the first outbox line for a nonce, never
|
|
|
|
|
a retry. Context files must be repository-relative, symlink-free and
|
|
|
|
|
realpath-contained; the reviewer's stronger option, hard-coding the four
|
|
|
|
|
Sage files, was not taken because the binding is the owner's 0600 policy
|
|
|
|
|
and the containment is the boundary that matters. Admissions are appended
|
|
|
|
|
to `admissions.jsonl` before the engine runs and the ceiling counts them,
|
|
|
|
|
so a crash mid-turn still counts. Ownership is a `run.lock` directory
|
|
|
|
|
(mkdir is atomic) holding the pid and its /proc start time; `stop` signals
|
|
|
|
|
only a live pid whose start time matches, and `run` refuses where /proc is
|
|
|
|
|
unavailable.
|
|
|
|
|
- rev-code-02's round two (#1509 comment 26121) kept F4 open and added two.
|
|
|
|
|
The O_EXCL pid file had a window between create and write where a second
|
|
|
|
|
start read an empty file as stale and unlinked it. Ownership is now the
|
|
|
|
|
`run.lock` directory: mkdir either succeeds or fails, the owner record is
|
|
|
|
|
written to a temp name and renamed in, and a directory without a record is
|
|
|
|
|
treated as a claim in progress (busy) for 30 seconds, after which only a
|
|
|
|
|
crash between mkdir and rename can explain it. A stale lock is moved aside
|
|
|
|
|
by rename, never deleted in place, so two reclaimers cannot both take it
|
|
|
|
|
and neither can remove a lock a third start just made. The test spawns
|
|
|
|
|
four processes that claim at once; one wins, three refuse, and a fifth
|
|
|
|
|
claim refuses while the winner holds the lock. The daily ceiling notice
|
|
|
|
|
was memory-only, so a same-day restart repeated it; the decision is now a
|
|
|
|
|
line in `notices.jsonl` written before the delivery attempt. A duplicate
|
|
|
|
|
gateway event arriving during the awaited thread lookup was admitted
|
|
|
|
|
twice; the id is reserved for the whole of `handleMessage` now.
|
|
|
|
|
- rev-code-02's round three (#1509 comment 26123) reproduced a three-party
|
|
|
|
|
race in the automatic reclaim: two starts both judge a lock stale, one
|
|
|
|
|
moves it aside, a third start publishes a live lock, and the delayed one
|
|
|
|
|
then moves that live lock aside and claims. Rename proves nothing about
|
|
|
|
|
which directory it moved. The reviewer offered two options, a gate that
|
|
|
|
|
every claimant respects or fail-closed with a separate controlled cleanup.
|
|
|
|
|
I took the second: a gate is one more thing that can go stale, and the
|
|
|
|
|
repository's rule is to diagnose a refusal, not route around it. `run` now
|
|
|
|
|
refuses any lock it does not own and names `scripts/discord.sh unlock`.
|
|
|
|
|
`unlock` refuses a live owner and, after moving the lock aside, checks the
|
|
|
|
|
record it moved is the record it inspected; if not, it renames the lock
|
|
|
|
|
back and refuses. The cost is a manual step after a crash or reboot. Tests
|
|
|
|
|
cover four starts over a stale lock (all refuse, the lock is untouched),
|
|
|
|
|
one unlock, four starts again (one owner), and the changed-under-unlock
|
|
|
|
|
case.
|
|
|
|
|
- rev-code-02's round four (#1509 comment 26132) broke that `unlock` too: a
|
|
|
|
|
claim can land in the empty interval between moving a lock aside and
|
|
|
|
|
restoring it, so a compare-then-restore leaves two owners. The fix is the
|
|
|
|
|
reviewer's other option, an explicit quiescence gate, and the gate is the
|
|
|
|
|
`STOP` file the connector already honors. `unlock` writes `STOP` before it
|
|
|
|
|
inspects anything; a claim re-checks `STOP` after publishing its record
|
|
|
|
|
and releases itself if `STOP` is there. A claim that survives therefore
|
|
|
|
|
published before `unlock` began, and `unlock` sees it alive and refuses.
|
|
|
|
|
No rename-aside, no restore, no residue. The reviewer's four-party
|
|
|
|
|
schedule is a test: stale lock, unlock in progress, two claims inside its
|
|
|
|
|
gap, one record landing right before removal; the result is no owner, no
|
|
|
|
|
residue, `STOP` standing, and one clean claim after `STOP` is removed.
|
|
|
|
|
- rev-code-02's round five (#1509 comment 26150) closed the gate design and
|
|
|
|
|
found two more. The owner record held pid and /proc start ticks, and ticks
|
|
|
|
|
count from boot, so the same pair can recur after a reboot; the record now
|
|
|
|
|
carries the boot id too and `stop` signals only when all three match. The
|
|
|
|
|
identity check is three-way: dead or positively different is stale and
|
|
|
|
|
`unlock` clears it; alive but unreadable identity refuses everything and
|
|
|
|
|
removes nothing; only a full match is live. A claimant that cannot read
|
|
|
|
|
its own start time or the boot id refuses to claim. Second, `unlock`'s
|
|
|
|
|
refusal for a live owner promised an exit on `STOP` that the running
|
|
|
|
|
connector does not perform (it only drops later messages), so the text now
|
|
|
|
|
says to use `stop`. The connector does not gain a `STOP` watcher in this
|
|
|
|
|
pilot; `stop` sends SIGTERM, which is the shutdown path.
|
|
|
|
|
- rev-code-02's round six (#1509 comment 26158) probed the upgrade path: a
|
|
|
|
|
connector started under the round-five build holds `{pid, start}` with no
|
|
|
|
|
boot id, and the three-way check read that as a positive mismatch, so
|
|
|
|
|
`unlock` removed the lock under a live process. A live pid with a record
|
|
|
|
|
lacking start or boot is now "unknown" and refuses everything until the pid
|
|
|
|
|
is dead. A record file that exists but cannot be parsed is now "invalid"
|
|
|
|
|
rather than absent; nothing removes or claims over it. Tests: a live legacy
|
|
|
|
|
child (byte-identical lock, no signal target, no second owner, cleared after
|
|
|
|
|
exit) and an invalid-record control.
|
|
|
|
|
- rev-code-02's round seven (#1509 comment 26165) found the last gap in the
|
|
|
|
|
same family: a record with a start or boot string of the wrong shape was
|
|
|
|
|
compared to /proc and read as a positive mismatch. Identity values now have
|
|
|
|
|
a syntax (decimal start ticks, lowercase uuid boot id); anything else reads
|
|
|
|
|
as absent and, under a live pid, is "unknown". Six live-child controls
|
|
|
|
|
cover empty, non-decimal and mixed start values and empty, malformed and
|
|
|
|
|
uppercase boot values.
|
|
|
|
|
- rev-code-02's round eight (#1509 comment 26168): the start syntax still
|
|
|
|
|
took leading-zero and above-2^64 digit strings, which the kernel never
|
|
|
|
|
prints. The check is now the canonical unsigned decimal form; boundary
|
|
|
|
|
values sit in the syntax table and three more live-child controls.
|
|
|
|
|
|