- Docs
- Implementation notes, by version
v0.5.4: persistent alerts
An opt-in extension of Cobalt Guard's owner alerts. The policy stays in hooks/alerts.ts (pure functions over the kept records); hooks/register.tsx owns the timer, the host calls and the commands. With guardRepeat and guardSound off, behaviour is that of 0.5.3.
Lifecycle#
| State | How it is reached | What follows |
|---|---|---|
| told | a due condition's first notification was written (sent) | reminders may follow |
| reminded | still open after guardRepeatSec, inside every bound | record carries nth |
| acknowledged | /cockpit guard ack or silence, typed by a person | no more reminders for that occurrence |
| silenced | /cockpit guard silence | nothing is sent this session until unsilence |
| resolved | the condition no longer holds, or its task is gone | recorded once, no notification; a return is a new occurrence |
| spent | guardMaxReminders reached, or the session budget | nothing more for that occurrence |
Everything is derived from the alert records in the Run Ledger (episodeOf, remind, closedOf, hasReminderLeft), so a reload or restart resumes a count. GuardState gained one optional field, ackAt (when the person last acknowledged): a notice that was still being written at that moment is recorded acknowledged, so an acknowledgment cannot be lost to timing.
The 32-record cap gives up held-back records first, then the records of occurrences already resolved; an open condition's alert, reminder count and acknowledgment are the last to go.
Bounds#
A reminder is sent only when all hold: persistence on; not silenced; the occurrence has a written first alert and no acknowledgment; fewer than guardMaxReminders reminders (failed ones count); the session has more than one alert left (guardMaxAlerts; the last is reserved for a condition not yet told); guardRepeatSec since the occurrence's last notice; guardCooldownSec since any alert. A clock that goes backwards waits.
Timer#
One $.clock.every timer (30 s) exists only while hasReminderLeft is true. armReminders is the single place that creates or cancels it: after every raiseAlerts, after ack/silence/unsilence, at session.start (which is how a reload resumes) and session.end. A tick calls the same raiseAlerts as every other caller, behind the same re-entry guard, so a tick during a raise does nothing. A reminder is also skipped while the shared state marks a notice in flight, and nothing re-arms the timer after session.end.
Delivery and what is claimed#
- Notification: the host's
$.ui.notify.sentmeans the host reported the channel it wrote to. ATTEMPTED and SENT are recorded; DELIVERED or seen is never claimed. - Sound (
guardSound): the existingsound()cue player and its player chain, whose last entry is the terminal bell; it honourssoundsand/cockpit mute.cue: sound|bellrecords that a player exited 0. The player is started and not waited for, so a slow or missing player never delays the tool call or stop the alert came from; the record is marked afterwards. - Desktop: no desktop notifier is started. Terminals that implement the host's notification channel show their own popup. From a remote machine, whether that reaches the operator's desktop depends on the terminal and connection. This is unverified and is listed as interactive acceptance.
Rejected alternatives#
- A
notify-send(or other) desktop process: a new program to run, and on a remote machine it would notify the remote session, not the operator. - A second, separate reminder state atom: the records already hold everything, and a second source could disagree after a reload.
- Repeating without an acknowledgment check on the timer path only: an acknowledgment must end repeats on every path, so the rule lives in
remind.
Tests#
tests/alerts-persistent.test.ts: configuration clamps and opt-in; the reminder rule (interval, cap, failed reminders, acknowledgment, silence, cooldown, reserved budget, backwards clock, new occurrence after resolution); timer-only repetition; no verdict change; acknowledgment; silence and unsilence; person-only unsilence; resolution and return; reset and new task; session end; failing notifications; budget and cooldown spacing; closed message text; sound, bell fallback and mute; reload resuming with one timer; acknowledged reload; stale stored record; stale in-flight mark; malformed stored state; an acknowledgment during a notification in flight; a hanging player; a late call after session end; record-cap eviction.
Independent review#
A separate reviewer approved the change with four non-blocking findings, all corrected before merge: the cue was awaited on the tool-call path; the record cap could evict an open condition's alert or acknowledgment behind resolved records; an acknowledgment typed while the first notification was in flight was lost; and a reminder did not check the shared in-flight mark.
Remaining human acceptance#
On the operator's own terminal: enable guardAlerts, guardRepeat (and guardSound), block a task, and observe the first notification, a reminder after the interval, silence after /cockpit guard ack, and the sound. Repeat from the remote Boat machine to learn whether the notification and bell cross the connection.