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
@@ -98,8 +98,15 @@ public sealed class FocasNativeAlarmEndToEndTests : RuntimeActorTestBase
/// sends Raise; the host must treat that as a re-statement: the operator's acknowledgement survives and no
/// second "Activated" alerts row is written. Driven through the real host with a FRESH driver instance.
/// </summary>
[Fact]
public async Task A_respawned_driver_re_reading_an_acknowledged_active_alarm_keeps_the_ack()
/// <para>
/// Re-review R1 — the other half: a DIFFERENT alarm that appeared while the driver was down (here a second
/// servo alarm, so the condition's displayed set changed) is a new statement even though the fresh driver
/// can only say Raise. It must un-acknowledge and write its alerts row.
/// </para>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task A_respawned_driver_re_reading_an_acknowledged_active_alarm_keeps_the_ack(bool newAlarmDuringRespawn)
{
var db = NewInMemoryDbFactory();
var blob = BuildArtifact();
@@ -130,14 +137,27 @@ public sealed class FocasNativeAlarmEndToEndTests : RuntimeActorTestBase
coordinator.FishForMessage<ApplyAck>(a => a.Outcome == ApplyAckOutcome.Applied, TimeSpan.FromSeconds(5));
publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, TimeSpan.FromSeconds(5));
if (newAlarmDuringRespawn)
cnc.SetAlarms(
new FocasActiveAlarm(401, FocasAlarmType.Servo, 1, "SERVO V-READY OFF"),
new FocasActiveAlarm(410, FocasAlarmType.Servo, 2, "SERVO EXCESS ERROR"));
var respawned = await StartDriverAsync(rawTags, cnc, host);
try
{
var restated = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
restated.AlarmNodeId.ShouldBe(ExpectedRawPath);
restated.State.Active.ShouldBeTrue();
restated.State.Acknowledged.ShouldBeTrue("a respawned driver's re-statement must not un-acknowledge");
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
if (newAlarmDuringRespawn)
{
restated.State.Acknowledged.ShouldBeFalse("an alarm the operator has not seen must not inherit the old ack");
alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
}
else
{
restated.State.Acknowledged.ShouldBeTrue("a respawned driver's re-statement must not un-acknowledge");
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
}
finally
{
@@ -237,7 +237,7 @@ public class NativeAlarmProjectorTests
first.ShouldBeFalse("inactive → active is a real transition");
sut.MarkAcknowledged("n1");
var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "again"), out var restatement);
var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var restatement);
restatement.ShouldBeTrue();
restated.Active.ShouldBeTrue();
restated.Acknowledged.ShouldBeTrue("a re-statement must not un-acknowledge");
@@ -247,6 +247,27 @@ public class NativeAlarmProjectorTests
retriggered.Acknowledged.ShouldBeFalse("a Retrigger is a new statement and un-acks");
}
/// <summary>
/// Gitea #647 re-review R1 — a respawned driver cannot tell "the same alarm" from "a different one that
/// appeared while it was down"; its Raise is a re-statement only when its message matches what the condition
/// shows. A different message is a new statement: it un-acks and is not flagged (so the alerts row is written).
/// </summary>
[Fact]
public void A_Raise_on_an_active_condition_with_a_different_message_is_a_new_statement()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"));
sut.MarkAcknowledged("n1");
var same = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"), out var r1);
r1.ShouldBeTrue();
same.Acknowledged.ShouldBeTrue();
var other = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "506 OVER TRAVEL (type 2 Overtravel, axis 2)"), out var r2);
r2.ShouldBeFalse("a different alarm is not a re-statement");
other.Acknowledged.ShouldBeFalse("the operator has not seen this alarm");
}
private static AlarmEventArgs Evt(
AlarmTransitionKind kind,
AlarmSeverity sev = AlarmSeverity.High,