feat(cli): S4 follow-up, refusal backoff and tracker boot (row 45, #1527)

Rocko's round 2 candidate, packet agents/rocko/work/s4-follow-up/
(build.patch c8cec070, candidate manifest 5b067a9d, 8/8 OK).

- Definite DM refusals wait the full 30-minute cap, counted from the
  journal's last refusal, so five refusals span about two hours before
  gave-up (lead decision 73). Unknown outcomes keep doubling.
- Broker close sends at host.mjs:144/150/184/188 pass a callback, which
  closes the EPIPE window both reviewers found in round 1.
- README documents manual recovery for an open decision.
- trackers-boot test, X9, X14, and Darkwing's round 1 notes 1-4.
- The append type check stays out; Rocko's reason holds (both reviewers
  agree).

Reviews: Darkwing approve (comment 26884), Filbert approve (26886).
Landing gate on b13fef4c plus the patch: every node suite and every
scripts/test-*.sh green, test-task 98/0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-09 09:27:34 -05:00
co-authored by Claude Opus 5.5
parent f4714aa03b
commit 9cdb6d82e3
8 changed files with 635 additions and 56 deletions
+30 -9
View File
@@ -145,9 +145,10 @@ inbox through the reader capability and does two things:
- **Digest.** It sends one digest a day at 08:00 America/Chicago. The hour
comes from the IANA zone, so daylight saving time is handled. If the host
starts after 08:00 and the day has no digest yet, the digest goes at once.
The digest lists the inbox and marks each blocking decision as DM sent or
DM pending. An empty inbox gets one line. Each message stays within
Discord's 2000 characters.
The digest lists the inbox. It marks each blocking decision with one of
three states: "DM sent", "DM pending" or "DM refused, not retried". An
empty inbox gets one line. Each message stays within Discord's 2000
characters.
The notifier only reads the bus. Its memory is the journal
`<dataRoot>/notify/<business>/sent.jsonl` (directory 0700, file 0600), with
@@ -160,21 +161,41 @@ one line per send attempt:
- A decision, or a day's digest, counts as sent once it has a `confirmed`
line.
- A `refused` or `unknown` send is retried. The wait starts at 30 s and
doubles up to 30 min. A duplicate costs less than a miss. Every retry
- A `refused` or `unknown` send is retried. After an `unknown` outcome the
wait starts at 30 s and doubles up to 30 min. A duplicate costs less than
a miss. Every retry
carries the same Discord nonce, so a retry inside Discord's dedupe window
returns the first message. A DM's nonce comes from the decision id. A
digest's comes from the business and the day.
- A definite refusal is a DM refused with an HTTP 4xx other than 429. The
next attempt waits the full 30 min, counted from the refusal's `at` in
the journal, so a restart does not shorten it. After 5 of them for one
decision, at least 2 hours apart end to end, the notifier appends one
`gave-up` line, logs it once and never sends that DM again. The digest
marks the decision "DM refused, not retried". The count comes from the
journal, so a restart keeps it. An `unknown` outcome (network, 5xx or
429) retries without a limit (lead decisions 72 and 73).
- Recovery after a give-up is manual. Fixing the binding does not resend
the DM, which stays given up. The decision stays open in `mosaic inbox`,
the digest lists it, and the operator decides it with `mosaic decide`.
- On open, a final line without its newline is a torn write. The notifier
copies those bytes to `torn-<UTC stamp>.bin` in the same directory (0600,
a new file, synced), then truncates `sent.jsonl` to its last newline and
syncs it. It logs both steps. A crash between the two leaves the torn
tail in place, and the next open repairs it with a second copy. The next
append therefore starts on a line of its own.
- A malformed complete line refuses with exit 3 and changes nothing.
- The journal refuses with exit 3 when its directory is looser than 0700 or
not yours, when `sent.jsonl` is a symlink, or when the file is not a
regular 0600 file you own.
- A malformed complete line refuses with exit 3 and changes nothing. Each
line is type-checked: `at` an ISO timestamp, `kind` `dm` or `digest`,
`decision` a string (required for `dm`, null for `digest`), `day` a real
`YYYY-MM-DD` date (required for `digest`), `outcome` one of `confirmed`,
`refused`, `unknown` or `gave-up` (`gave-up` only for `dm`), `messageId`
a string (required when confirmed) or null, `status` an integer when
present.
- The journal refuses with exit 3 when its directory is looser than 0700,
not yours, a symlink (dangling or not) or not writable, or cannot be
created (for example, a parent is a file). It also refuses when
`sent.jsonl` is a symlink, a directory or not writable, or is not a
regular 0600 file you own. The message names the path.
- No Discord channel or user id goes in the journal, a log line or an
error.