diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
index 04fd3cb..5b238c3 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
@@ -839,15 +839,17 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
new AlarmFeedMessage { Transition = TransitionFromSnapshot(incoming.Value, AlarmTransitionKind.Raise) },
incoming.Key);
}
- else if (existing.CurrentState != incoming.Value.CurrentState
- && incoming.Value.CurrentState == AlarmConditionState.ActiveAcked)
+ else if (existing.CurrentState != incoming.Value.CurrentState)
{
- // The alarm was already known but the worker now reports it acknowledged: the
- // live Acknowledge transition never reached the feed. Without this the acked
- // state is absorbed silently by the snapshot replace below and subscribers show
- // the alarm unacked until it clears.
+ // The alarm was already known but the worker now reports a different state. An
+ // ActiveAcked snapshot repairs a missed Acknowledge. Any other incoming active
+ // state repairs a missed Raise for the incoming instance (including an
+ // acknowledged alarm reference that was cleared and re-raised between passes).
+ AlarmTransitionKind kind = incoming.Value.CurrentState == AlarmConditionState.ActiveAcked
+ ? AlarmTransitionKind.Acknowledge
+ : AlarmTransitionKind.Raise;
Broadcast(
- new AlarmFeedMessage { Transition = TransitionFromSnapshot(incoming.Value, AlarmTransitionKind.Acknowledge) },
+ new AlarmFeedMessage { Transition = TransitionFromSnapshot(incoming.Value, kind) },
incoming.Key);
}
}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs
index 4bfbb58..f319ae2 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs
@@ -155,6 +155,66 @@ public sealed class GatewayAlarmMonitorAttachOrderTests
await monitor.StopAsync(CancellationToken.None);
}
+ ///
+ /// GWC-35: an acknowledged cached alarm followed by an active snapshot with a newer
+ /// original-raise timestamp is a new alarm instance, not an acknowledgement reversal. The
+ /// reconcile repair must broadcast a Raise carrying the incoming instance identity.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task ApplyReconcile_WhenAcknowledgedAlarmHasNewActiveInstance_BroadcastsRaise()
+ {
+ using GatewayMetrics metrics = new();
+ await using FakeSessionManager sessions = new();
+ using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
+
+ using CancellationTokenSource cts = new();
+ await monitor.StartAsync(cts.Token);
+ await sessions.WaitForSubscribeStartAsync(WaitTimeout);
+
+ Timestamp oldRaise = Timestamp.FromDateTimeOffset(
+ new DateTimeOffset(2026, 8, 10, 13, 0, 0, TimeSpan.Zero));
+ sessions.SetReconcileSnapshot(SnapshotAt(AlarmConditionState.ActiveAcked, oldRaise, oldRaise));
+ sessions.EmitEvent(ProviderModeProbe(1));
+ await WaitUntilAsync(
+ () => monitor.CurrentAlarms.Any(alarm => alarm.AlarmFullReference == AlarmReference
+ && alarm.CurrentState == AlarmConditionState.ActiveAcked),
+ WaitTimeout);
+
+ List received = [];
+ TaskCompletionSource snapshotComplete = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ using CancellationTokenSource streamCts = new();
+ Task reader = ReadFeedAsync(monitor, received, snapshotComplete, streamCts.Token, untilSnapshotComplete: true);
+ await snapshotComplete.Task.WaitAsync(WaitTimeout);
+
+ Timestamp newRaise = Timestamp.FromDateTimeOffset(
+ new DateTimeOffset(2026, 8, 10, 13, 0, 20, TimeSpan.Zero));
+ sessions.SetReconcileSnapshot(SnapshotAt(AlarmConditionState.Active, newRaise, newRaise));
+ sessions.EmitEvent(ProviderModeProbe(2));
+
+ AlarmFeedMessage raise = await WaitForAsync(
+ received,
+ m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.Transition
+ && m.Transition.TransitionKind == AlarmTransitionKind.Raise,
+ WaitTimeout);
+ Assert.Equal(AlarmReference, raise.Transition.AlarmFullReference);
+ Assert.Equal(newRaise, raise.Transition.OriginalRaiseTimestamp);
+ Assert.Equal(newRaise, raise.Transition.TransitionTimestamp);
+
+ lock (received)
+ {
+ AlarmFeedMessage[] transitions = received
+ .Where(m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.Transition)
+ .ToArray();
+ Assert.Single(transitions);
+ }
+
+ await streamCts.CancelAsync();
+ await reader;
+ await cts.CancelAsync();
+ await monitor.StopAsync(CancellationToken.None);
+ }
+
///
/// NEXT-03. A reconcile Raise repair applied while the matching live Raise is still
/// buffered must not double-broadcast: the live transition carrying the same worker