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
@@ -476,6 +476,69 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
captured.Value.StatusCode.ShouldBe(0x80020000u); // BadInternalError — dormant-engine parity
}
/// <summary>
/// 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.
/// </summary>
[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<DependencyMuxActor.RegisterInterest>();
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<OpcUaPublishActor.AttributeValueUpdate>();
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<OpcUaPublishActor.AttributeValueUpdate>();
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));
}
/// <summary>Evaluator that always returns <c>Ok(value)</c> — 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.</summary>
private sealed class ConstOkEvaluator : IVirtualTagEvaluator
{
private readonly object _value;
/// <summary>Creates an evaluator that always returns <paramref name="value"/>.</summary>
/// <param name="value">The constant value to return.</param>
public ConstOkEvaluator(object value) => _value = value;
/// <summary>Returns <c>Ok(value)</c> regardless of inputs.</summary>
/// <param name="id">The tag identifier.</param>
/// <param name="expr">The expression string.</param>
/// <param name="deps">The dependency values.</param>
/// <returns>A successful result carrying the fixed value.</returns>
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
=> VirtualTagEvalResult.Ok(_value);
}
/// <summary>Evaluator that returns <c>Ok(42.0)</c> on its first call and <c>Failure</c> thereafter —
/// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's
/// evaluation thread.</summary>