diff --git a/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json b/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json
index 0bba16d4..f6048cd0 100644
--- a/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json
+++ b/archreview/plans/R2-10-resilience-observability-plan.md.tasks.json
@@ -3,7 +3,7 @@
"lastUpdated": "2026-07-12",
"tasks": [
{ "id": "T1", "subject": "Tracker: LastBreakerClosedUtc + IsBreakerOpen + RecordBreakerClose (Core, TDD in DriverResilienceStatusTrackerTests)", "status": "completed", "blockedBy": [] },
- { "id": "T2", "subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)", "status": "pending", "blockedBy": ["T1"] },
+ { "id": "T2", "subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)", "status": "completed", "blockedBy": ["T1"] },
{ "id": "T3", "subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)", "status": "pending", "blockedBy": ["T1"] },
{ "id": "T4", "subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName", "status": "pending", "blockedBy": [] },
{ "id": "T5", "subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)", "status": "pending", "blockedBy": ["T1", "T4"] },
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs
index 2a6b3952..fe5b6cff 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs
@@ -38,14 +38,16 @@ public sealed class DriverResiliencePipelineBuilder
/// Clock source for pipeline timeouts + breaker sampling. Defaults to system.
/// When non-null, every built pipeline wires Polly telemetry into
/// the tracker — retries increment ConsecutiveFailures, breaker-open stamps
- /// LastBreakerOpenUtc, breaker-close resets failures. Feeds Admin /hosts +
+ /// LastBreakerOpenUtc, breaker-close resets failures and stamps
+ /// LastBreakerClosedUtc (so IsBreakerOpen reads false again). Feeds the AdminUI
+ /// resilience panel via the driver-resilience-status DPS publisher +
/// the in-flight-call-depth column. Absent tracker means no telemetry (unit tests +
/// deployments that don't care about resilience observability).
/// When non-null, retry / circuit-breaker-open / breaker-close events are
- /// logged (instance + host + capability + attempt/exception). This is the operator-facing
- /// observability surface for the resilience pipeline until the Admin /hosts reader
- /// (Phase 6.1 Stream E.2/E.3) ships — without it the pipeline runs silently. Absent logger =
- /// no resilience logging (unit tests).
+ /// logged (instance + host + capability + attempt/exception). This is the structured-log
+ /// drill-down for the resilience pipeline, complementing the AdminUI resilience panel fed by
+ /// the tracker — without it the pipeline runs silently. Absent logger = no resilience logging
+ /// (unit tests).
public DriverResiliencePipelineBuilder(
TimeProvider? timeProvider = null,
DriverResilienceStatusTracker? statusTracker = null,
@@ -186,8 +188,11 @@ public sealed class DriverResiliencePipelineBuilder
breakerOptions.OnClosed = args =>
{
// Closing the breaker means the target recovered — reset the consecutive-
- // failure counter so Admin UI stops flashing red for this host.
- tracker?.RecordSuccess(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime);
+ // failure counter AND stamp the close time so IsBreakerOpen reads false again
+ // (the resilience panel stops showing "Breaker OPEN" for this host).
+ var closedUtc = timeProvider.GetUtcNow().UtcDateTime;
+ tracker?.RecordSuccess(driverInstanceId, hostName, closedUtc);
+ tracker?.RecordBreakerClose(driverInstanceId, hostName, closedUtc);
logger?.LogInformation(
"Driver resilience circuit-breaker closed (recovered): instance={DriverInstanceId} host={HostName} capability={Capability}",
driverInstanceId, hostName, capability);
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs
index b9bebed5..44642c28 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs
@@ -277,6 +277,44 @@ public sealed class DriverResiliencePipelineBuilderTests
snap!.LastBreakerOpenUtc.ShouldNotBeNull();
}
+ ///
+ /// R2-10 truthfulness fix: after the breaker opens and later recovers (a successful probe
+ /// past BreakDuration), the pipeline builder's OnClosed hook must record the
+ /// breaker close on the tracker — so
+ /// reads false again and is stamped.
+ /// Uses a controllable so the 15 s break duration elapses deterministically.
+ ///
+ [Fact]
+ public async Task Tracker_RecordsBreakerClose_OnRecovery()
+ {
+ var clock = new ManualTimeProvider(new DateTime(2026, 7, 13, 0, 0, 0, DateTimeKind.Utc));
+ var tracker = new DriverResilienceStatusTracker();
+ var builder = new DriverResiliencePipelineBuilder(timeProvider: clock, statusTracker: tracker);
+ var pipeline = builder.GetOrCreate("drv-trk", "host-c", DriverCapability.Write, TierAOptions);
+
+ // Drive the breaker open with the tier's throughput of failures.
+ var threshold = TierAOptions.Resolve(DriverCapability.Write).BreakerFailureThreshold;
+ for (var i = 0; i < threshold; i++)
+ {
+ await Should.ThrowAsync(async () =>
+ await pipeline.ExecuteAsync(async _ =>
+ {
+ await Task.Yield();
+ throw new InvalidOperationException("boom");
+ }));
+ }
+
+ tracker.TryGet("drv-trk", "host-c")!.IsBreakerOpen.ShouldBeTrue("breaker must be open after the failure threshold");
+
+ // Elapse the break duration (15 s) so the breaker half-opens, then let a probe succeed → OnClosed.
+ clock.Advance(TimeSpan.FromSeconds(16));
+ await pipeline.ExecuteAsync(async _ => await Task.Yield());
+
+ var snap = tracker.TryGet("drv-trk", "host-c")!;
+ snap.LastBreakerClosedUtc.ShouldNotBeNull("OnClosed must stamp the close time on recovery");
+ snap.IsBreakerOpen.ShouldBeFalse("the breaker must read closed after a successful recovery probe");
+ }
+
/// Verifies that the tracker isolates counters per host.
[Fact]
public async Task Tracker_IsolatesCounters_PerHost()
@@ -404,6 +442,21 @@ public sealed class DriverResiliencePipelineBuilderTests
attempts.ShouldBe(1, "the new generation must honor optsB (retryCount 0), not the stale optsA re-cache");
}
+ ///
+ /// A controllable whose clock advances only on .
+ /// Drives both wall-clock () and monotonic ()
+ /// reads off the same tick counter so Polly's circuit-breaker break-duration timing is deterministic.
+ ///
+ private sealed class ManualTimeProvider : TimeProvider
+ {
+ private long _ticks;
+ public ManualTimeProvider(DateTime start) => _ticks = start.Ticks;
+ public void Advance(TimeSpan by) => _ticks += by.Ticks;
+ public override DateTimeOffset GetUtcNow() => new(_ticks, TimeSpan.Zero);
+ public override long GetTimestamp() => _ticks;
+ public override long TimestampFrequency => TimeSpan.TicksPerSecond;
+ }
+
/// Minimal in-memory capturing (level, formatted-message) pairs so the
/// resilience logging contract can be asserted without a real logging provider.
private sealed class CapturingLogger : ILogger