diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs
index c5641b81..6e29f09e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs
@@ -31,6 +31,13 @@ public sealed class VirtualTagActor : ReceiveActor
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
+ /// Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
+ /// Sent by on every apply (deploy) to a surviving child: a deploy
+ /// re-materialises the child's published UNS node to BadWaitingForInitialData, but an
+ /// unchanged-plan child keeps its dedup state, so a static-dependency recompute is suppressed and the
+ /// node would stay Bad forever. Re-asserting the last state recovers it. Singleton — carries no data.
+ public sealed class ReassertValue { public static readonly ReassertValue Instance = new(); private ReassertValue() { } }
+
/// Result emitted to the parent (the host bridge). carries the
/// OPC UA quality the node should read: for a fresh computed value,
/// when the script failed/timed out (02/S13) — in which case
@@ -105,6 +112,7 @@ public sealed class VirtualTagActor : ReceiveActor
_mux = mux;
Receive(OnDependencyChanged);
+ Receive(_ => OnReassertValue());
}
///
@@ -122,6 +130,21 @@ public sealed class VirtualTagActor : ReceiveActor
_mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self));
}
+ /// Re-emit the last emitted value/quality to the parent bridge, bypassing the value dedup, so a
+ /// deploy that re-materialised our published UNS node to BadWaitingForInitialData recovers even when
+ /// the recomputed value is unchanged (permanent Bad otherwise, with a static dependency). No-op until the
+ /// child has emitted at least once — the first dependency arrival publishes the initial value normally.
+ /// Ordering is safe: DriverHostActor enqueues the address-space RebuildAddressSpace (which
+ /// re-materialises the node) to the single-threaded publish actor BEFORE the ApplyVirtualTags that
+ /// triggers this re-assert, so the re-publish lands on the freshly-materialised node.
+ private void OnReassertValue()
+ {
+ if (!_hasLastValue && !_lastPublishedBad) return;
+ var quality = _lastPublishedBad ? OpcUaQuality.Bad : OpcUaQuality.Good;
+ Context.Parent.Tell(new EvaluationResult(
+ _virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), quality));
+ }
+
private void OnDependencyChanged(DependencyValueChanged msg)
{
_dependencies[msg.TagId] = msg.Value;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs
index 4229d68d..7326b74d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs
@@ -150,6 +150,9 @@ public sealed class VirtualTagHostActor : ReceiveActor
// recreated here from the new plan, adopting the edited Expression/DependencyRefs. Children
// whose plan was unchanged still have a live entry and are skipped, keeping their mux
// subscriptions and last-value dedup state.
+ // Track the just-spawned vtags so the re-assert pass below skips them: a fresh child has no last
+ // value to re-assert and publishes its initial value on the first dependency arrival anyway.
+ var newlySpawned = new HashSet(StringComparer.Ordinal);
foreach (var p in msg.Plans)
{
if (_children.ContainsKey(p.VirtualTagId)) continue;
@@ -166,6 +169,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
mux: _mux));
Context.Watch(child);
_children[p.VirtualTagId] = child;
+ newlySpawned.Add(p.VirtualTagId);
_log.Debug("VirtualTagHost: spawned child for vtag {VirtualTagId}", p.VirtualTagId);
}
@@ -177,6 +181,20 @@ public sealed class VirtualTagHostActor : ReceiveActor
_planByVtag[p.VirtualTagId] = p;
}
+ // Re-assert last values on surviving (not just-spawned) children. Every apply coincides with a
+ // deploy, and DriverHostActor enqueues the address-space RebuildAddressSpace — which re-materialises
+ // each VirtualTag's UNS node to BadWaitingForInitialData — to the single-threaded publish actor
+ // BEFORE this ApplyVirtualTags. A surviving child keeps its value-dedup state, so an unchanged
+ // recompute is suppressed and its freshly-reset node would stay Bad forever (permanent with a static
+ // dependency). Telling each survivor to ReassertValue re-publishes its last state through the same
+ // publish actor AFTER the materialise, so the node recovers. Fresh children are skipped (nothing to
+ // re-assert; their first dependency arrival publishes normally).
+ foreach (var (vtagId, child) in _children)
+ {
+ if (newlySpawned.Contains(vtagId)) continue;
+ child.Tell(VirtualTagActor.ReassertValue.Instance);
+ }
+
_log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})",
desired.Count, _children.Count);
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs
index e71a290a..cf86cf86 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs
@@ -476,6 +476,69 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
captured.Value.StatusCode.ShouldBe(0x80020000u); // BadInternalError — dormant-engine parity
}
+ ///
+ /// v3 Batch 4 live-gate regression (RED on the pre-fix tree): after a deploy re-materialises a
+ /// VirtualTag's UNS node to BadWaitingForInitialData, a surviving (unchanged-plan) child must
+ /// re-assert its last value so the node recovers — otherwise a static-dependency VirtualTag stays Bad
+ /// forever because its unchanged recompute is dedup-suppressed. Drives the REAL host + real child:
+ /// apply a plan, capture the spawned child via the mux probe, feed one dependency change to publish a
+ /// Good value (which the child then dedups), then re-apply the SAME plan (the redeploy). The fix makes
+ /// the survivor re-publish its last value; on the unfixed tree the second ExpectMsg times out.
+ ///
+ [Fact]
+ public void Unchanged_redeploy_reasserts_last_value_so_a_reset_uns_node_recovers()
+ {
+ var publish = CreateTestProbe();
+ var mux = CreateTestProbe();
+ var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new ConstOkEvaluator(7.0)));
+
+ var plan = new[] { Plan("vt-1", "eq-1", "speed-rpm") };
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
+
+ // Capture the spawned child from the RegisterInterest sender so we can drive a dependency change
+ // through the real evaluate → parent-Tell → bridge path.
+ mux.ExpectMsg();
+ var child = mux.LastSender;
+
+ // First dependency change → Ok(7) → Good publish; the child now holds 7 as its dedup'd last value.
+ child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
+ var first = publish.ExpectMsg();
+ first.Value.ShouldBe(7.0);
+ first.Quality.ShouldBe(OpcUaQuality.Good);
+
+ // The redeploy re-materialises the node to BadWaitingForInitialData and re-applies the UNCHANGED
+ // plan — the child survives with its dedup state. The fix re-asserts the last value; without it the
+ // node would stay Bad (an unchanged recompute of 7 is dedup-suppressed) and this ExpectMsg times out.
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
+ var reasserted = publish.ExpectMsg();
+ reasserted.NodeId.ShouldBe("eq-1/speed-rpm");
+ reasserted.Value.ShouldBe(7.0);
+ reasserted.Quality.ShouldBe(OpcUaQuality.Good);
+
+ // No child churn on the unchanged redeploy — the re-assert must not respawn or re-register.
+ mux.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
+ }
+
+ /// Evaluator that always returns Ok(value) — a static-output VirtualTag whose recompute
+ /// is dedup-suppressed after the first publish, so the re-assert path is the only way its reset node
+ /// recovers.
+ private sealed class ConstOkEvaluator : IVirtualTagEvaluator
+ {
+ private readonly object _value;
+
+ /// Creates an evaluator that always returns .
+ /// The constant value to return.
+ public ConstOkEvaluator(object value) => _value = value;
+
+ /// Returns Ok(value) regardless of inputs.
+ /// The tag identifier.
+ /// The expression string.
+ /// The dependency values.
+ /// A successful result carrying the fixed value.
+ public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary deps)
+ => VirtualTagEvalResult.Ok(_value);
+ }
+
/// Evaluator that returns Ok(42.0) on its first call and Failure thereafter —
/// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's
/// evaluation thread.