fix(alarms): #647 re-review — re-statement needs the same message; CNC refusal is not comms loss; worst-of health

R1: NativeAlarmProjector treats Raise-on-Active as a re-statement only when the message matches what
the condition shows; a different alarm appearing during a respawn un-acks and writes its alerts row.
FOCAS condition messages are a deterministic function of the matched set (tested across fresh driver
instances and reordered lists); Runtime E2E now covers both the same alarm and a new one during a
respawn. Residual (source message determinism) documented in AlarmTracking.md.

R2: a non-OK cnc_rdalmmsg2 return is thrown as FocasWireException{Rc} and classified as a CNC
refusal: conditions get a quality-only BadNotSupported once, one Warning, polling continues, and
driver health is untouched (no 90 s watchdog reconnect loop on a production control). Only transport
faults degrade (falsification control in the test).

R3: alarm-path health is tracked apart from the value path and combined in GetHealth: worst state,
later LastSuccessfulRead. A good value read can no longer mask a failing alarm read (no per-poll
/hosts flip) and a good alarm read cannot mask a value Degraded.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 20:45:32 -04:00
parent 89961af6e6
commit bac93744ce
11 changed files with 308 additions and 57 deletions
+9 -3
View File
@@ -57,9 +57,15 @@ same condition is wired as an **event notifier of every referencing equipment fo
- **Routing is an exact `ConditionId == RawPath` match** (`DriverHostActor.ForwardNativeAlarm`). A driver that
decorates the id has every transition dropped as "no alarm condition" — AbCip did (`"{ref}#active"`, fixed
under #641), and so did FOCAS (`"{host}#{Type}:{Number}"`, fixed under #647 — one condition per `ALARM:` tag, see
`drivers/FOCAS.md`). A `Raise` on a condition already projected Active is a **re-statement** (#647 review): the
ack is kept and no second "Activated" row is published — a respawned driver re-reading a still-active alarm sends
exactly that. Only `Retrigger` (or inactive → active) un-acks. A native source must also set `Kind`: an
`drivers/FOCAS.md`). A `Raise` on a condition already projected Active **with the same message** is a
**re-statement** (#647 review): the ack is kept and no second "Activated" row is published — a respawned driver
re-reading a still-active alarm sends exactly that. A Raise with a DIFFERENT message is a new statement and un-acks:
a respawned driver cannot tell "the same alarm" from "a different one that appeared while it was down", so the
host compares what the condition shows. Only `Retrigger` (or inactive → active, or a changed-message Raise) un-acks.
⚠️ **Residual:** the comparison is only as good as the source's message determinism. FOCAS and MTConnect messages
are deterministic for an unchanged statement; a source whose message carries volatile text (a timestamp, a
counter) would un-ack on every respawn — conservative, never hiding an alarm — and a source whose message omits
what changed (a new alarm under identical text) would keep a stale ack across a respawn. A native source must also set `Kind`: an
`Unspecified` kind projects no state change.
### Native condition state survives a full rebuild (Gitea #641)
+15 -3
View File
@@ -405,16 +405,28 @@ it, then author `ALARM:T<code>`. Number ranges are series-independent.
- **…but an unreachable source says so.** The first failed read of an outage annotates every subscribed condition
on that device `BadNoCommunication` through the #626 quality-only path (no state change, no `/alerts` row) and
logs ONE Warning naming `cnc_rdalmmsg2`; the next successful read lifts it to Good (before any transition it
also finds, e.g. Good then Clear for an alarm that cleared during the outage). Failed alarm reads also reach
also finds, e.g. Good then Clear for an alarm that cleared during the outage). Transport failures also reach
driver health: `Degraded` with `LastSuccessfulRead` frozen (only a successful read advances it), so a driver
whose only tags are `ALARM:` tags trips the comms-loss watchdog (#527) and the host's driver-wide #477
annotation like any other.
- **A CNC refusal is not comms loss.** A non-OK FOCAS return from `cnc_rdalmmsg2` (the CNC answered and declined,
e.g. `EW_FUNC`) marks the conditions `BadNotSupported` (quality-only, once), logs one Warning, and **leaves driver
health alone** — polling continues at the normal cadence. Treating it as comms loss would make the #527 watchdog
reconnect a production control every 90 s forever. Only a transport fault (session down, wire fault, malformed
reply) degrades.
- **Health is the worst of the value path and the alarm path, computed together** (`GetHealth`). A failing alarm
read keeps a mixed driver `Degraded` however many value reads succeed (so `/hosts` does not flip per poll), and a
good alarm read cannot mask a value-path `Degraded`. `LastSuccessfulRead` is the later of the two paths' last
successes, so the watchdog fires only when both have stopped.
- **Diffed against what the server was TOLD**, recorded only while a handler is attached. The record lives on the
driver instance: a reconnect (same-set re-subscribe) and an in-place re-initialise (Force Reconnect, #630) re-raise
nothing. **A deploy that changes the driver's config respawns it** (the planner stops + recreates the driver, a new
instance with an empty record), and its first poll sends `Raise` for every still-active alarm. The host treats a
`Raise` on a condition it already shows Active as a **re-statement**: the acknowledgement is kept and no second
"Activated" `/alerts` row is written (`NativeAlarmProjector`, #647 review). Only `Retrigger` — or inactive →
`Raise` on a condition it already shows Active **with the same message** as a **re-statement**: the
acknowledgement is kept and no second "Activated" `/alerts` row is written (`NativeAlarmProjector`, #647 review).
The condition message is a deterministic function of the matched set (winner, type code, axes, `[+N more]`), so a
true re-statement matches exactly, while an alarm set that changed during the respawn — including one that only
LOST a member — reads as a new statement and un-acks (the conservative direction). Only `Retrigger` — or inactive →
active — un-acknowledges, which is why a genuinely new CNC alarm in a matched set is sent as `Retrigger`.
- **Polling:** one `cnc_rdalmmsg2` per `AlarmProjection.PollInterval` (default 2 s) for each device that owns a
**subscribed** alarm tag; devices without one are never polled for alarms.