fix(alarms): atomic InactiveUnacknowledged + flagged Clear/Acknowledge first sight for OpcUaClient (#657)

After an OpcUaClient respawn an upstream inactive+unacked condition went out as
Retrigger+Clear (duplicate Activated+Cleared rows, Active flicker), and a
first-sight inactive+acked one as Unspecified (an Activated row for an inactive
alarm; a retained-Active host condition never cleared).

- New AlarmTransitionKind.InactiveUnacknowledged: one projection, rows only for
  what changed (Activated+Cleared from acked, Cleared from active+unacked, none
  when already inactive+unacked).
- OpcUaClient uses it for moves into inactive+unacked; first sight of an
  inactive condition is InactiveUnacknowledged or Clear+Acknowledge, flagged.
- Projector: a flagged Acknowledge on an already-acked condition writes no row.
- E2E tests through DriverHostActor.ForwardNativeAlarm (red-proven).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 06:08:39 -04:00
parent 98c00904e7
commit 48a80b5962
10 changed files with 354 additions and 27 deletions
@@ -1885,7 +1885,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// decides WHICH rows the transition warrants: none for a re-statement (Gitea #647 review: a Raise on an
// already-active condition from a respawned driver; #656: a flagged snapshot/first-sight Retrigger or
// Clear that changes nothing the condition shows), and up to two for an ActiveAcknowledged (#656:
// "Activated" only if it was not already active, "Acknowledged" only if it was not already acked).
// "Activated" only if it was not already active, "Acknowledged" only if it was not already acked) or an
// InactiveUnacknowledged (#657: "Activated" + "Cleared" only if it was acked, "Cleared" if it was active).
if (!serviceAlertsAsPrimary) continue;
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
@@ -1911,8 +1912,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
{
// #656: an ActiveAcknowledged expands to "Activated" + "Acknowledged". The operator comment/user belong to
// the acknowledgement, and the activation happened at the original raise time when the source knows it.
var expandedActivation = args.Kind == AlarmTransitionKind.ActiveAcknowledged
&& historyKind != AlarmTransitionKind.Acknowledge;
// #657: an InactiveUnacknowledged's "Activated" row is likewise the occurrence, at its raise time when known.
var expandedActivation = (args.Kind == AlarmTransitionKind.ActiveAcknowledged
&& historyKind != AlarmTransitionKind.Acknowledge)
|| (args.Kind == AlarmTransitionKind.InactiveUnacknowledged && historyKind == AlarmTransitionKind.Raise);
var comment = expandedActivation ? null : args.OperatorComment;
var timestamp = expandedActivation ? args.OriginalRaiseTimestampUtc ?? args.SourceTimestampUtc : args.SourceTimestampUtc;
var alertEvent = new AlarmTransitionEvent(
@@ -1957,6 +1960,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
AlarmTransitionKind.Acknowledge => "Acknowledged",
// #656: never reached in practice — the projector expands ActiveAcknowledged into Raise + Acknowledge rows.
AlarmTransitionKind.ActiveAcknowledged => "Acknowledged",
// #657: likewise never reached — expanded into Raise + Clear (or Clear alone) rows.
AlarmTransitionKind.InactiveUnacknowledged => "Cleared",
_ => "Activated",
};