diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index 9730780..a7e0342 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.Host;
using ZB.MOM.WW.OtOpcUa.Host.Health;
+using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Security;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
@@ -43,10 +44,8 @@ builder.Services.AddAkka("otopcua", (ab, _) =>
{
if (hasAdmin)
ab.WithOtOpcUaControlPlaneSingletons();
- // Driver-role startup (DriverHostActor spawn + child probes) is wired in F19 once a
- // RuntimeStartup contract is added — the actor itself exists (Phase 6), the registration
- // extension does not yet. Without it, driver-role nodes still join the cluster and serve
- // health/redundancy traffic but won't auto-spawn DriverHostActor.
+ if (hasDriver)
+ ab.WithOtOpcUaRuntimeActors();
});
if (hasAdmin)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..bf410b8
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
@@ -0,0 +1,57 @@
+using Akka.Actor;
+using Akka.Hosting;
+using Microsoft.EntityFrameworkCore;
+using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
+using ZB.MOM.WW.OtOpcUa.Runtime.Health;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime;
+
+public static class ServiceCollectionExtensions
+{
+ public const string DriverRole = "driver";
+
+ public const string DriverHostActorName = "driver-host";
+ public const string DbHealthProbeActorName = "db-health";
+
+ ///
+ /// Spawns the per-node driver-role actors on the host's :
+ /// (one per node) and
+ /// (consumed by the health endpoint + redundancy calc).
+ ///
+ /// Mirror of WithOtOpcUaControlPlaneSingletons for the driver role. Both must
+ /// be registered on the same as the cluster
+ /// bootstrap so the actors share the host's ActorSystem.
+ ///
+ /// Wire from the fused Host's Program.cs when the node carries the driver role:
+ ///
+ /// if (hasDriver)
+ /// ab.WithOtOpcUaRuntimeActors();
+ ///
+ ///
+ public static AkkaConfigurationBuilder WithOtOpcUaRuntimeActors(this AkkaConfigurationBuilder builder)
+ {
+ builder.WithActors((system, registry, resolver) =>
+ {
+ var dbFactory = resolver.GetService>();
+ var roleInfo = resolver.GetService();
+
+ var dbHealth = system.ActorOf(
+ DbHealthProbeActor.Props(dbFactory),
+ DbHealthProbeActorName);
+ registry.Register(dbHealth);
+
+ var driverHost = system.ActorOf(
+ DriverHostActor.Props(dbFactory, roleInfo.LocalNode),
+ DriverHostActorName);
+ registry.Register(driverHost);
+ });
+
+ return builder;
+ }
+}
+
+/// Marker key types used by Akka.Hosting to resolve runtime actors from the registry.
+public sealed class DriverHostActorKey { }
+public sealed class DbHealthProbeActorKey { }
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000..33907eb
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs
@@ -0,0 +1,85 @@
+using Akka.Hosting;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests;
+
+///
+/// Verifies WithOtOpcUaRuntimeActors spawns DriverHostActor + DbHealthProbeActor
+/// on the host's ActorSystem and registers both under their marker keys. This is the
+/// driver-role mirror of the admin-role WithOtOpcUaControlPlaneSingletons bootstrap.
+///
+public sealed class ServiceCollectionExtensionsTests
+{
+ [Fact]
+ public async Task WithOtOpcUaRuntimeActors_spawns_driver_host_and_db_health_probe()
+ {
+ using var host = Host.CreateDefaultBuilder()
+ .ConfigureServices((_, services) =>
+ {
+ services.AddSingleton>(
+ new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N")));
+ services.AddSingleton(new FakeClusterRoleInfo());
+
+ services.AddAkka("otopcua-test", (ab, _) =>
+ {
+ ab.AddHocon(@"
+ akka.actor.provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster""
+ akka.remote.dot-netty.tcp.hostname = ""127.0.0.1""
+ akka.remote.dot-netty.tcp.port = 0
+ akka.cluster.seed-nodes = []
+ akka.cluster.roles = [""driver""]
+ ", HoconAddMode.Prepend);
+ ab.WithOtOpcUaRuntimeActors();
+ });
+ })
+ .Build();
+
+ await host.StartAsync();
+ try
+ {
+ var driverHost = host.Services.GetRequiredService>();
+ var dbHealth = host.Services.GetRequiredService>();
+
+ driverHost.ActorRef.ShouldNotBeNull();
+ dbHealth.ActorRef.ShouldNotBeNull();
+ driverHost.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DriverHostActorName);
+ dbHealth.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DbHealthProbeActorName);
+ }
+ finally
+ {
+ await host.StopAsync();
+ }
+ }
+
+ private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory
+ {
+ public OtOpcUaConfigDbContext CreateDbContext()
+ {
+ var opts = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(dbName)
+ .Options;
+ return new OtOpcUaConfigDbContext(opts);
+ }
+ }
+
+ private sealed class FakeClusterRoleInfo : IClusterRoleInfo
+ {
+ public NodeId LocalNode { get; } = NodeId.Parse("test-node");
+ public IReadOnlySet LocalRoles { get; } = new HashSet(["driver"]);
+ public bool HasRole(string role) => LocalRoles.Contains(role);
+ public IReadOnlyList MembersWithRole(string role) => Array.Empty();
+ public NodeId? RoleLeader(string role) => null;
+ public event EventHandler? RoleLeaderChanged
+ {
+ add { _ = value; }
+ remove { _ = value; }
+ }
+ }
+}