feat(driver-calc): B2-WP7 Calculation driver — IDependencyConsumer + triggers + deploy cycle gate

Adds the Calculation pseudo-driver (tags computed by C# scripts over other tags' live
values) plus the host seam that feeds it:

- IDependencyConsumer capability interface (Core.Abstractions) + DriverTypeNames.Calculation.
- CalculationDriver : IDriver, ISubscribable, IReadable, IDependencyConsumer — change-gate +
  dedupe (VirtualTagActor parity), timer-group scheduler, Bad-transition + script-log emission,
  Good recovery; reuses the T0-4 CalculationEvaluator.
- DriverHostActor spawns a DependencyConsumerMuxAdapter per dependency-consuming driver,
  registering its refs on the per-node DependencyMuxActor and forwarding DependencyValueChanged
  → OnDependencyValue; re-registers on every apply, torn down with the driver.
- Factory + probe registered in DriverFactoryBootstrap (keeps the T0-3 guard green); guard test
  csproj references the driver so the bin-scan discovers the factory.
- DeploymentArtifact injects the resolved scriptSource (scriptId → SourceCode) into a calc tag's
  delivered TagConfig so the host-blind driver can compile it.
- DraftValidator deploy gates: CalculationScriptMissing / CalculationScriptNotFound (scriptId
  existence) + CalculationDependencyCycle (DependencyGraph.DetectCycles over calc→calc edges).
- Tests: driver units (dep-ref extraction, change-gate, dedupe, Bad+script-log+recovery, timer,
  read), tag-config parsing, DraftValidator gates (self/2-cycle/cross-driver-terminal), and a
  Runtime integration test proving values FLOW mux → adapter → driver → calc-of-calc end-to-end.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 04:29:37 -04:00
parent ae9f906f52
commit 53d222e0f7
24 changed files with 1558 additions and 3 deletions
@@ -98,6 +98,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private readonly Dictionary<string, ChildEntry> _children = new(StringComparer.Ordinal);
/// <summary>Per-driver <see cref="DependencyConsumerMuxAdapter"/> children — one for each spawned driver
/// that implements <see cref="IDependencyConsumer"/> (e.g. the Calculation pseudo-driver). Each registers
/// the driver's declared dependency refs on the per-node <see cref="_dependencyMux"/> and forwards value
/// changes into the driver. Spawned in <see cref="SpawnChild"/>, torn down with the driver in
/// <see cref="StopDependencyAdapter"/>, and re-registered on every apply (refs change when tags/scripts
/// change). Empty when no mux is wired (the dev/None path) or no dependency-consuming driver is hosted.</summary>
private readonly Dictionary<string, IActorRef> _depConsumerAdapters = new(StringComparer.Ordinal);
// Monotonic counter feeding the child actor-name suffix (see ActorNameFor / SpawnChild). Single-
// threaded actor, so a plain increment is safe; it only ever grows, guaranteeing a unique name per
// spawn so a restart's respawn never collides with the still-terminating old child.
@@ -1372,6 +1380,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
foreach (var id in plan.ToStop) StopChild(id);
foreach (var spec in plan.ToApplyDelta) ApplyChildDelta(spec);
foreach (var spec in plan.ToSpawn) SpawnChild(spec);
// v3 (WP7): re-register every dependency-consumer adapter's interest on each apply — a driver's
// declared dependency refs change when its tags/scripts change. Newly-spawned adapters already
// registered in PreStart; this refreshes the ones an ApplyDelta kept in place. A Tell (no blocking).
foreach (var adapter in _depConsumerAdapters.Values)
adapter.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags.DependencyConsumerMuxAdapter.ReRegister());
}
/// <summary>
@@ -1736,10 +1750,33 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
}
_children[spec.DriverInstanceId] = new ChildEntry(child, spec, stub);
// v3 (WP7): if the (real, non-stub) driver consumes other tags' live values, spawn a mux-adapter
// child that registers its dependency refs on the per-node dependency mux and forwards changes
// into the driver. Requires a wired mux (dev/None deployments have none — then calc inputs simply
// can't flow, matching the VirtualTag dev path).
if (!stub && driver is IDependencyConsumer consumer && _dependencyMux is not null)
{
var adapter = Context.ActorOf(
ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags.DependencyConsumerMuxAdapter.Props(consumer, _dependencyMux),
"depmux-" + actorName);
_depConsumerAdapters[spec.DriverInstanceId] = adapter;
_log.Info("DriverHost {Node}: wired dependency-consumer mux adapter for {Id} ({Count} ref(s))",
_localNode, spec.DriverInstanceId, consumer.DependencyRefs.Count);
}
_log.Info("DriverHost {Node}: spawned {Type} driver {Id} (stub={Stub})",
_localNode, spec.DriverType, spec.DriverInstanceId, stub);
}
/// <summary>Stops + forgets the dependency-consumer mux adapter for a driver (if any). Called wherever a
/// driver child is stopped/respawned so a stale adapter never keeps a dead driver's refs registered.</summary>
private void StopDependencyAdapter(string driverInstanceId)
{
if (_depConsumerAdapters.Remove(driverInstanceId, out var adapter))
Context.Stop(adapter);
}
private void ApplyChildDelta(DriverInstanceSpec spec)
{
if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return;
@@ -1752,6 +1789,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private void StopChild(string driverInstanceId)
{
if (!_children.TryGetValue(driverInstanceId, out var entry)) return;
StopDependencyAdapter(driverInstanceId);
Context.Stop(entry.Actor);
_children.Remove(driverInstanceId);
_log.Info("DriverHost {Node}: stopped driver child {Id}", _localNode, driverInstanceId);
@@ -1805,6 +1843,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_localNode, msg.DriverInstanceId, msg.ActorByUserName);
// Stop the existing child actor — DriverInstanceActor.PostStop calls ShutdownAsync.
StopDependencyAdapter(msg.DriverInstanceId);
Context.Stop(entry.Actor);
_children.Remove(msg.DriverInstanceId);