fix(v3-batch4): Equipment VirtualTag runtime dependency/publish resolution (live-gate)

Equipment VirtualTags published Bad at runtime whenever their dependency
value was static. Root cause: a deploy re-materialises each VirtualTag's
UNS node to BadWaitingForInitialData (OpcUaPublishActor.HandleRebuild), but
a surviving unchanged-plan VirtualTagActor keeps its value-dedup state, so
its unchanged recompute is suppressed (VirtualTagActor.OnDependencyChanged
value dedup) and the freshly-reset node stays Bad forever. Only masked when
the dependency value changes every poll.

Fix: VirtualTagHostActor tells each surviving (not just-spawned) child to
ReassertValue on every apply; the child re-emits its last value/quality,
bypassing dedup. Ordering is safe — DriverHostActor enqueues the
RebuildAddressSpace (materialise) to the single-threaded publish actor
before the ApplyVirtualTags that triggers the re-assert, so the re-publish
lands on the freshly-materialised node.

Regression test: VirtualTagHostActorTests
.Unchanged_redeploy_reasserts_last_value_so_a_reset_uns_node_recovers
(fails before, passes after). Live-verified on docker-dev: GateVt reads
Good via both the absolute and the {{equip}}/MainPressure script forms.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 14:09:43 -04:00
parent 1badc10445
commit b95efb0b28
3 changed files with 104 additions and 0 deletions
@@ -31,6 +31,13 @@ public sealed class VirtualTagActor : ReceiveActor
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
/// <summary>Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
/// Sent by <see cref="VirtualTagHostActor"/> on every apply (deploy) to a surviving child: a deploy
/// re-materialises the child's published UNS node to <c>BadWaitingForInitialData</c>, 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.</summary>
public sealed class ReassertValue { public static readonly ReassertValue Instance = new(); private ReassertValue() { } }
/// <summary>Result emitted to the parent (the host bridge). <paramref name="Quality"/> carries the
/// OPC UA quality the node should read: <see cref="OpcUaQuality.Good"/> for a fresh computed value,
/// <see cref="OpcUaQuality.Bad"/> when the script failed/timed out (02/S13) — in which case
@@ -105,6 +112,7 @@ public sealed class VirtualTagActor : ReceiveActor
_mux = mux;
Receive<DependencyValueChanged>(OnDependencyChanged);
Receive<ReassertValue>(_ => OnReassertValue());
}
/// <inheritdoc />
@@ -122,6 +130,21 @@ public sealed class VirtualTagActor : ReceiveActor
_mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self));
}
/// <summary>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 <c>BadWaitingForInitialData</c> 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: <c>DriverHostActor</c> enqueues the address-space <c>RebuildAddressSpace</c> (which
/// re-materialises the node) to the single-threaded publish actor BEFORE the <c>ApplyVirtualTags</c> that
/// triggers this re-assert, so the re-publish lands on the freshly-materialised node.</summary>
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;
@@ -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<string>(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);
}