Watchdog notice reader: pin timeout, spawn-error, 5xx/429 and guard behaviour; say what non-2xx is

Tests only, plus wording. No change to what the reader or bridge does.

- A spawn timeout and a spawn error (ENOBUFS from a bridge that floods
  stdout) record unknown, are never re-posted, and block the closure.
  WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS overrides the 60 s timeout so the test
  reaches that branch; the default is unchanged.
- A 500/502/503/429 answer to the message POST exits 75 from the bridge, and
  through the reader is retried with the same nonce until it completes.
- allowed_mentions {parse: []} is asserted in the posted body; status exits
  non-zero while an entry is unknown and zero when none is.
- The bridge comment and docs/watchdog-notice.md no longer call a non-2xx
  answer "definite": it is retried with the same nonce, relying on
  enforce_nonce inside Discord's window, and may double-send if Discord acted
  on a 5xx outside it.

Builds. Two tests fail on Windows (SIGKILL status), as before; they pass on
Yggdrasil.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 22:56:27 +02:00
commit ff087a554b
4 changed files with 77 additions and 18 deletions

View file

@ -471,9 +471,12 @@ async function postDiscordMessage(options) {
}
// discord-dm exits NOT_SENT_EXIT only when it knows no message was delivered:
// anything before the message POST, or a definite non-2xx answer to it. A
// failure after the POST was sent (network error, receipt completion, output)
// keeps exit 1, so the caller treats the outcome as unknown.
// anything before the message POST, or a non-2xx answer to it. A 5xx or 429
// is not proof of non-delivery (Discord may have created the message before
// answering): the caller retries with the same nonce and relies on
// enforce_nonce inside Discord's window. A failure after the POST was sent
// (network error, receipt completion, output) keeps exit 1, so the caller
// treats the outcome as unknown.
const NOT_SENT_EXIT = 75;
class NotSentError extends Error {}
class DiscordRejection extends Error {}

View file

@ -28,15 +28,17 @@ const INCIDENT_SCHEMA = "idunn.operator_incident.v1";
const JOURNAL_TYPE = "bifrost.watchdog_notice_execution";
const JOURNAL_SCHEMA = "bifrost.watchdog_notice_execution.v1";
const MAX_ATTEMPTS = 5;
const BRIDGE_TIMEOUT_MS = 60_000;
// The override exists so a test can reach the timeout branch without a 60 s wait.
const BRIDGE_TIMEOUT_MS = Number(process.env.WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS) || 60_000;
const STATUSES = ["running", "completed", "failed", "unknown"];
const NAME = /^[a-z][a-z-]{0,63}$/;
// Idunn's require_id (GameCult/Idunn src/control_plane.rs:9122-9131 at 7f7528b):
// 1-256 bytes of [A-Za-z0-9-_.:/]. The notice puts the subject in inline code.
const SUBJECT = /^[A-Za-z0-9_.:/-]{1,256}$/;
const MAX_DATE_MS = 8.64e15;
// bifrost-bridge.mjs discord-dm exits 75 (EX_TEMPFAIL) only when it knows the
// message was not sent. Every other non-success leaves the outcome unknown.
// bifrost-bridge.mjs discord-dm exits 75 (EX_TEMPFAIL) for failures before
// the message POST and for any non-2xx answer to it: retried with the same nonce.
// Every other non-success leaves the outcome unknown.
const BRIDGE_NOT_SENT = 75;
class NoticeError extends Error {}
@ -175,7 +177,7 @@ function noticeText(record, notice) {
}
// The one send primitive. The outcome is completed with a message id; failed
// only when the bridge reports exit 75 (known not sent); otherwise unknown. The
// only when the bridge reports exit 75 (retryable, same nonce); otherwise unknown. The
// error is a fixed code: bridge stdout and stderr never reach the journal.
async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content }) {
const dir = await mkdtemp(resolve(tmpdir(), "watchdog-notice-"));