29 KiB
FixedTree-injection follow-ups — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (or executing-plans) to implement this plan task-by-task.
Goal: Implement the five approved follow-ups to the FixedTree-under-Equipment dynamic-injection feature: (A) injectable discovery timeout, (B) per-driver re-discovery policy gate, (C) re-trigger discovery on a config-unchanged rebind, (D) de-dup the double SetDesiredSubscriptions, and (E) lift the ≥1-authored-tag requirement + support multi-device-per-driver.
Architecture: Akka.NET actor pipeline. DriverInstanceActor runs post-connect discovery and publishes DiscoveredNodesReady; DriverHostActor resolves the bound equipment, maps discovered nodes via DiscoveredNodeMapper, caches a plan, materialises via OpcUaPublishActor, and merges subscription refs. Composition is built by AddressSpaceComposer.Compose (pure, from entities) and mirrored by DeploymentArtifact (decode, from the sealed JSON artifact) — the two MUST stay byte-parity-equal. The deployment artifact already serialises full Equipment + Device entities, so E needs no DB migration and no artifact wire-format change — only decode/projection reads.
Tech Stack: .NET 10, C# (default interface members, collection expressions), Akka.NET, xUnit. Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx (TreatWarningsAsErrors). Test (macOS — run filtered, NOT full-solution; the net48 Wonderware testhost can't run on macOS):
dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~Runtime.Tests"dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~OpcUaServer.Tests"dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~FOCAS"
Design: 2026-06-26-otopcua-fixedtree-followups-design.md. Branch: feat/focas-fixedtree-equipment-injection (continue on it; commit per task; do NOT push/merge — standing rule).
Out of scope (locked): discovered-alarm injection; writable discovered nodes.
Execution order & parallelism
Two files are each touched by multiple tasks and MUST be edited serially:
DriverInstanceActor.cs: Task 1 → Task 3 → Task 4DriverHostActor.cs: Task 6 → Task 7 → Task 8 → Task 9
Independent file sets that can run concurrently with the above: Task 2 (ITagDiscovery + 5 driver files) and Task 5 (AddressSpaceComposer.cs + DeploymentArtifact.cs).
Dependency summary: T3 ⟵{T1,T2}; T4 ⟵T3; T6 ⟵T5; T7 ⟵{T4,T6}; T8 ⟵T7; T9 ⟵{T5,T8}; T10 ⟵T9; T11 ⟵{T2,T4,T9,T10}.
Task 1: Injectable discovery timeout (follow-up A)
Classification: small Estimated implement time: ~3 min Parallelizable with: Task 2, Task 5
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs(ctor ~244-259,Props~195-210, fields ~133-137,HandleRediscoverAsync~765) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs
Context: HandleRediscoverAsync hardcodes using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); (line 765). The rediscover interval + attempt-cap are already ctor params (_rediscoverInterval, _rediscoverMaxAttempts). Add a sibling param for the per-pass discovery timeout, default-preserving.
Step 1 — Failing test: add a test asserting that when constructed with a very short discovery timeout and an ITagDiscovery whose DiscoverAsync blocks, the pass cancels by the injected timeout (e.g. DiscoveredNodesReady carries an empty set within the short window) rather than waiting 30 s. Reuse the existing fake ITagDiscovery driver in this test file (search it for the existing discovery-actor fake; mirror that pattern). If a fully deterministic timeout test is too flaky, instead assert the wiring: a new public DefaultRediscoverDiscoverTimeout constant exists and equals 30 s, and the ctor/Props accept the param.
Step 2 — Verify it fails: dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~DriverInstanceActorDiscoveryTests" → fails to compile / fails assertion.
Step 3 — Implement:
- Add
public static readonly TimeSpan DefaultRediscoverDiscoverTimeout = TimeSpan.FromSeconds(30);next to the other discovery defaults (~line 36-39). - Add a field
private readonly TimeSpan _rediscoverDiscoverTimeout;(~133-137). - Add ctor param
TimeSpan? rediscoverDiscoverTimeout = null(afterrediscoverMaxAttempts); assign_rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;. - Add the matching optional param to
Propsand forward it. - In
HandleRediscoverAsync, replaceTimeSpan.FromSeconds(30)with_rediscoverDiscoverTimeout.
Step 4 — Verify: test passes; dotnet build ZB.MOM.WW.OtOpcUa.slnx → 0 warnings.
Step 5 — Commit: git commit -m "feat(otopcua): make FixedTree re-discovery per-pass timeout injectable (follow-up A)"
Task 2: Re-discovery policy enum + ITagDiscovery member + driver overrides (follow-up B, part 1)
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1, Task 5
Files:
- Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs(or a small new test next to the FOCAS driver tests assertingFocasDriverreportsUntilStable)
Context: ITagDiscovery (Core.Abstractions) currently has only DiscoverAsync. Add a policy the actor (Task 3) honors. Default = today's behavior so any non-overriding driver is unchanged.
Step 1 — Failing test: assert new FocasDriver(...).RediscoverPolicy == DiscoveryRediscoverPolicy.UntilStable and that one network driver (e.g. OpcUaClientDriver) reports Once. (Construct via the simplest available ctor/fake; if drivers are hard to construct standalone, assert the enum + default member exist and compile, plus a focused test on FOCAS.)
Step 2 — Verify it fails: compile failure (enum/member absent).
Step 3 — Implement:
- In
ITagDiscovery.cs, add the enum + a default-implemented member:
/// <summary>How aggressively the host re-runs post-connect discovery for this driver.</summary>
public enum DiscoveryRediscoverPolicy
{
/// <summary>Retry every interval up to the cap or until the captured set is non-empty and stable
/// (for drivers whose discovered shape fills in asynchronously after connect, e.g. FOCAS FixedTree).</summary>
UntilStable,
/// <summary>Run exactly one discovery pass on connect (drivers that discover synchronously in DiscoverAsync).</summary>
Once,
/// <summary>Never run post-connect discovery.</summary>
Never,
}
public interface ITagDiscovery
{
/// <summary>Post-connect re-discovery policy. Default preserves the original retry-until-stable behavior.</summary>
DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable;
Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
}
FocasDriver: addpublic DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable;(explicit — it genuinely needs the retry loop).OpcUaClientDriver,TwinCATDriver,AbCipDriver,AbLegacyDriver: addpublic DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;— these discover synchronously insideDiscoverAsync, so one pass on connect suffices; the 15× retry was wasted (potentially heavy) work. Before settingOnce, confirm each driver'sDiscoverAsyncreturns its complete set synchronously (read eachDiscoverAsync); if any populates a cache asynchronously after connect like FOCAS, leave itUntilStableand note why in a comment.
Step 4 — Verify: test passes; build 0 warnings.
Step 5 — Commit: git commit -m "feat(otopcua): add ITagDiscovery.RediscoverPolicy + per-driver assignments (follow-up B)"
Task 3: DriverInstanceActor honors RediscoverPolicy (follow-up B, part 2)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (serial after Task 1 on the same file; needs Task 2's enum)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs(StartDiscovery~736-740,HandleRediscoverAsync~754-795) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs
Context: StartDiscovery() currently kicks the loop for every ITagDiscovery driver. HandleRediscoverAsync schedules the next tick unless stable/capped. Gate both on the driver's RediscoverPolicy.
Step 1 — Failing tests (3):
- A fake
ITagDiscoverydriver reportingNever→ noDiscoveredNodesReadyis ever published after connect. - A fake reporting
Oncewhose captured set would keep GROWING across passes → exactly ONEDiscoveredNodesReadyand no further tick scheduled. - A fake reporting
UntilStable→ existing behavior (retries until stable/cap) — keep/extend the current passing test.
Step 2 — Verify they fail: the Never/Once tests fail (today everything retries-until-stable).
Step 3 — Implement:
- In
StartDiscovery(): after theif (_driver is not ITagDiscovery discovery) return;guard, read the policy;if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Never) return;before scheduling the firstRediscoverTick. - In
HandleRediscoverAsync: after publishingDiscoveredNodesReady, when the policy isOnce, do NOT schedule another tick (log Debug "policy=Once, single pass" and return). WhenUntilStable, keep today's stop-on-stable + cap logic. (Read the live policy via((ITagDiscovery)_driver).RediscoverPolicy.) - Keep the generation guard intact.
Step 4 — Verify: the 3 tests pass; the full DriverInstanceActorDiscoveryTests + Runtime.Tests suite stays green; build 0 warnings.
Step 5 — Commit: git commit -m "feat(otopcua): DriverInstanceActor honors RediscoverPolicy (Never/Once/UntilStable) (follow-up B)"
Task 4: TriggerRediscovery message + handler (follow-up C, part 1)
Classification: standard Estimated implement time: ~4 min Parallelizable with: none (serial after Task 3 on the same file)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs(message decls nearRediscoverTick~110-115; add aConnected-state receive) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs
Context: Task 7 (DriverHostActor) will Tell a driver child to re-run discovery after a rebind. The child must accept that message and only act when Connected.
Step 1 — Failing tests (2):
- Send
TriggerRediscoveryto an actor whose driver isConnected→ it runs a discovery pass and publishesDiscoveredNodesReady(respecting policy: aNeverdriver does NOT). - Send
TriggerRediscoverybefore connect / while notConnected→ noDiscoveredNodesReady, no crash (no-op).
Step 2 — Verify they fail: message type doesn't exist.
Step 3 — Implement:
- Add
public sealed record TriggerRediscovery();near the other public messages. - In the
Connectedstate, add a receive forTriggerRediscoverythat callsStartDiscovery()(which already honors policy + theITagDiscoveryguard, and uses the current_initGeneration). - In other states, either don't register the receive (so it's unhandled = no-op) or register a no-op. Prefer registering only in
Connectedso a non-connected child silently ignores it (verify the actor's state-machine style — match how other state-scoped messages are handled). Ensure noUnhandled-logging noise; if the actor logs unhandled messages, add an explicit ignore in the relevant states.
Step 4 — Verify: both tests pass; suite green; build 0 warnings.
Step 5 — Commit: git commit -m "feat(otopcua): DriverInstanceActor.TriggerRediscovery message (follow-up C)"
Task 5: EquipmentNode carries DriverInstanceId/DeviceId/DeviceHost (follow-up E, projection)
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 1, Task 2
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs(EquipmentNoderecord line 61; projection ~326-332;Composesignatures ~281-312) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs(ReadEquipmentNode~810-820; the equipment decode call ~204;Empty()~362-367; add aDevices-array →DeviceId→host map) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/(composer projection test) and the existing artifact-decode/parity test forEquipmentNode(searchtests/forReadEquipmentNode/EquipmentNodes/DeploymentArtifactcoverage; if a Compose-vs-decode parity test exists, extend it)
Context: The artifact already serialises full Equipment rows (incl. nullable DriverInstanceId, DeviceId) and a full Devices array (each Device has DeviceId + schemaless DeviceConfig JSON containing FOCAS's HostAddress). Compose (pure) and DeploymentArtifact (decode) MUST produce identical EquipmentNodes. _lastComposition (used by the resolver) always comes from decode, but parity is still required by tests.
Step 1 — Failing tests:
- Composer: given an
EquipmentwithDriverInstanceId="d1",DeviceId="dev1", and aDevice{DeviceId="dev1", DeviceConfig={"HostAddress":"10.0.0.5:8193"}},Compose(...)yieldsEquipmentNodewithDriverInstanceId=="d1",DeviceId=="dev1",DeviceHost=="10.0.0.5:8193"; with no device assigned → all three null. - Decode: an artifact JSON whose
Equipmentelement has those fields + a matchingDeviceselement decodes to the sameEquipmentNode.
Step 2 — Verify they fail: EquipmentNode has no such fields.
Step 3 — Implement:
- Extend the record (defaulted params keep all existing call sites compiling):
public sealed record EquipmentNode(
string EquipmentId,
string DisplayName,
string UnsLineId,
string? DriverInstanceId = null,
string? DeviceId = null,
string? DeviceHost = null);
- Add a shared host-extraction helper usable by BOTH sides (place it where both can call it without a new project dependency — e.g. a
public static string? TryExtractDeviceHost(string? deviceConfigJson)onAddressSpaceComposer, parsing the top-level"HostAddress"string from theDeviceConfigJSON; return null if absent/unparseable). Add a normalization step (trim; lower-case host) and DOCUMENT that the discovered device-host folder segment must be normalized the same way in Task 9. Compose: add an optionalIReadOnlyList<Device>? devices = nullparam to BOTH overloads (forward from the 5-arg overload as empty). BuilddeviceHostById = devices.ToDictionary(d => d.DeviceId, d => TryExtractDeviceHost(d.DeviceConfig)). In the equipment projection, setDriverInstanceId: e.DriverInstanceId,DeviceId: e.DeviceId,DeviceHost: e.DeviceId is null ? null : deviceHostById.GetValueOrDefault(e.DeviceId).DeploymentArtifact: read theDevicesarray (decodeDeviceId+DeviceConfig) into aDeviceId→host map using the SAMETryExtractDeviceHosthelper; thread it intoReadEquipmentNode(change its signature to accept the map, or do a post-pass) so it readsDriverInstanceId/DeviceIdfrom the element and resolvesDeviceHostfrom the map. UpdateEmpty()only if its arity changed (it won't — record params are defaulted).- Parity: ensure the decode-side host normalization is byte-identical to
Compose's (same helper). If a Compose-vs-decode parity test exists, pass the sameDevicestoComposein that test.
Step 4 — Verify: new tests pass; OpcUaServer.Tests + Runtime.Tests green; build 0 warnings. Run the existing artifact-parity test — it MUST stay green.
Step 5 — Commit: git commit -m "feat(otopcua): EquipmentNode carries DriverInstanceId/DeviceId/DeviceHost (follow-up E projection)"
Task 6: DriverHostActor — cache-as-dict + driver-level equipment resolution (follow-up E, part 1)
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (serial: first DriverHostActor task; needs Task 5)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs(_discoveredByDriverfield ~168;HandleDiscoveredNodes~580-639;ApplyDiscoveredPlan~658-701;RoutingEquals; redeploy re-inject tail ~1247-1290) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
Context: Today _discoveredByDriver is Dictionary<string, DiscoveredInjectionPlan> (one plan per driver) and equipment is resolved ONLY from authored EquipmentTags. This task (1) changes the cache value to a per-equipment map so Task 9 can add multiple equipments, and (2) makes resolution also use the equipment-level driver link so a driver with an assigned equipment but ZERO authored tags still grafts. Still requires exactly one resolved equipment here (multi-device is Task 9) — >1 keeps the current warn+skip.
Step 1 — Failing tests:
- Tag-less graft: composition has an
EquipmentNode{DriverInstanceId="d1"}with NO authoredEquipmentTagsford1;DiscoveredNodesReady("d1", nodes)→ nodes graft under that equipment (today: skipped with "no equipment/authored tags"). - Regression: the existing single-equipment-with-authored-tags test still grafts identically (collapse retained).
Step 2 — Verify it fails: tag-less case is skipped today.
Step 3 — Implement:
- Change
_discoveredByDrivertoDictionary<string, IReadOnlyDictionary<string, DiscoveredInjectionPlan>>(driverId → (equipmentId → plan)). Update ALL readers:HandleDiscoveredNodesshort-circuit,ApplyDiscoveredPlan, and the redeploy re-inject tail must iterate the inner map. - New resolution in
HandleDiscoveredNodes: candidate equipments =_lastComposition.EquipmentNodes.Where(e => e.DriverInstanceId == driverId).Select(e => e.EquipmentId)∪ the existing authored-tag-derived set. Distinct.- 0 → log Info, skip (unchanged message).
- 1 → resolve
equipmentId; authoredRefs for that driver as today;DiscoveredNodeMapper.Map(equipmentId, nodes, authoredRefs); cache as a 1-entry inner map; apply. -
1 → for THIS task, keep
_log.Warning(... "multi-equipment-per-driver is handled in the multi-device path")+ skip. (Task 9 replaces this branch.)
ApplyDiscoveredPlan: keep applying a single(equipmentId, plan); callers now iterate the inner map and call it per entry. The subscription-merge union must include ALL discovered routing keys across the driver's plans (so a multi-plan driver subscribes every device's refs). Keep the authored value/alarm ref computation.RoutingEqualsshort-circuit: compare the FULL new inner-map routing against the cached inner-map routing (skip re-apply only when every equipment's routing is unchanged).- Redeploy re-inject tail: iterate
_discoveredByDriver; for each driver, re-resolve candidates from the CURRENT composition; per cached(equipmentId, plan)entry, keep the existing drop rules (equipment no longer resolves / plan NodeIds not scoped toequipmentId) but applied per-entry; re-apply surviving entries. (Task 7 will add the re-trigger on drop; Task 8 the de-dup.)
Step 4 — Verify: new + existing DriverHostActorDiscoveryTests green; Runtime.Tests green; build 0 warnings.
Step 5 — Commit: git commit -m "feat(otopcua): driver-level equipment resolution + per-equipment discovered-plan cache (follow-up E)"
Task 7: DriverHostActor — re-trigger discovery on rebind drop (follow-up C, part 2)
Classification: high-risk Estimated implement time: ~4 min Parallelizable with: none (serial after Task 6; needs Task 4's message)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs(redeploy re-inject tail drop branches ~1264-1288; update the deliberate-won't-fixcomment) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
Context: When the re-inject tail DROPS a cached plan because the equipment rebound/no-longer-resolves, the FixedTree stays absent under the new equipment until the driver's next natural reconnect. Re-trigger discovery so it re-grafts promptly.
Step 1 — Failing test: simulate a redeploy where a driver's equipment changed (cached plan scoped to old EQ-1, new composition binds the driver to EQ-2). Assert the driver child receives DriverInstanceActor.TriggerRediscovery after the drop. (Use the test harness's child-probe/TestProbe pattern already used in this file for asserting messages to driver children.)
Step 2 — Verify it fails: no re-trigger today.
Step 3 — Implement: in each drop branch (the two Remove sites), after removing the entry, Tell that driver's child actor new DriverInstanceActor.TriggerRediscovery() (guard: only if the child exists in _children). Update the inline comment: the previous "we deliberately do NOT add re-trigger logic" note becomes a description of the new re-trigger (discovery-only, idempotent, child no-ops if not Connected). If a driver maps to MULTIPLE cached equipment entries and only one drops, still send a single TriggerRediscovery (discovery re-resolves all of them) — de-dupe so a driver is told at most once per re-inject pass.
Step 4 — Verify: test passes; suite green; build 0 warnings.
Step 5 — Commit: git commit -m "feat(otopcua): re-trigger discovery on config-unchanged rebind (follow-up C)"
Task 8: DriverHostActor — single SetDesiredSubscriptions per redeploy (follow-up D)
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (serial after Task 7)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs(PushDesiredSubscriptionsbulk loop ~1204; re-inject tail interaction) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
Context: During an in-process redeploy a cached driver gets the bulk authored-only SetDesiredSubscriptions (line 1204) AND then the union from ApplyDiscoveredPlan (line 697) — one extra unsub/resub blip. Make it exactly one send per driver.
Step 1 — Failing test: redeploy with one driver that has a cached discovered plan; assert the driver child receives SetDesiredSubscriptions EXACTLY ONCE during the redeploy, and that the single payload is the authored∪discovered UNION. Add a second test: a driver whose cached plan is DROPPED in the re-inject tail (rebind) still receives exactly one SetDesiredSubscriptions carrying the AUTHORED-ONLY set (fallback) — its authored subscriptions must not be lost.
Step 2 — Verify it fails: today the cached-driver case sends twice.
Step 3 — Implement:
- In the bulk loop, SKIP the send for any
driverIdpresent in_discoveredByDriver(capture the key set BEFORE the re-inject tail runs). - Re-inject tail: when a cached plan is APPLIED,
ApplyDiscoveredPlanalready sends the union (covers authored). When a cached plan is DROPPED (all entries for the driver removed → the driver no longer has any cached plan), send the authored-onlySetDesiredSubscriptionsfor that driver as a fallback (mirror the bulk-loop payload: authored value refs + alarm refs,SubscriptionPublishingInterval). - Ensure the invariant holds for drivers WITHOUT a cached plan (unchanged: single bulk send) and drivers added/removed by the reconcile.
Step 4 — Verify: both tests pass; the existing redeploy/restore tests stay green (watch for any test asserting the old double-send count); build 0 warnings.
Step 5 — Commit: git commit -m "perf(otopcua): one SetDesiredSubscriptions per driver per redeploy (follow-up D)"
Task 9: DriverHostActor — multi-device-per-driver partition (follow-up E, part 2)
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (serial after Task 8; needs Task 5's DeviceHost)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs(HandleDiscoveredNodes>1-candidate branch from Task 6) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
Context: Replace Task 6's ">1 candidate → warn+skip" with a real partition. Each candidate equipment has EquipmentNode.DeviceHost (from Task 5). The discovered nodes carry a device-host folder segment at FolderPathSegments[1] (FOCAS uses device.HostAddress). Partition nodes by that segment, normalize it the SAME way Task 5 normalized DeviceHost, and map each device's subset under the matching equipment via the existing DiscoveredNodeMapper.Map (a single-device subset → collapse kicks in per equipment → clean EQ-n/FOCAS/Identity/...).
Step 1 — Failing tests:
- Multi-device: driver
d1resolves toEQ-A{DeviceHost=h1}andEQ-B{DeviceHost=h2}; discovered nodes split across folder segmentsh1/h2; asserth1's subtree grafts underEQ-Aandh2's underEQ-B, each routing-keyed correctly, and_discoveredByDriver["d1"]has two entries. - Unmatched device-host → warn-skip: a discovered segment
h3with no matching equipment is NOT grafted (logged Warning), whileh1/h2still graft. - Degenerate: >1 candidate but NO
DeviceHostdata anywhere → falls back to warn+skip (no crash, no mis-graft).
Step 2 — Verify it fails: Task 6 left this as warn+skip.
Step 3 — Implement: in the >1-candidate branch, build hostToEquipment = candidates.Where(e => e.DeviceHost != null).ToDictionary(Normalize(e.DeviceHost), e.EquipmentId) (guard duplicate hosts → warn+skip the ambiguous host). Partition nodes by Normalize(FolderPathSegments.Count >= 2 ? FolderPathSegments[1] : null). For each partition with a matching equipment: compute that equipment's authoredRefs, Map(equipmentId, partitionNodes, authoredRefs), collect into the inner (equipmentId → plan) map. Unmatched partitions → _log.Warning + skip. Cache the multi-entry inner map and apply every entry (Task 6 made apply per-entry). Use the SAME normalization helper from Task 5 (factor it so both call it).
Step 4 — Verify: all three tests pass; single-device + tag-less tests from Task 6 still green; Runtime.Tests + OpcUaServer.Tests + FOCAS suites green; build 0 warnings.
Step 5 — Commit: git commit -m "feat(otopcua): multi-device-per-driver FixedTree partition (follow-up E)"
Task 10: Docs — update follow-up notes + design statuses
Classification: trivial Estimated implement time: ~3 min Parallelizable with: none (after Task 9)
Files:
- Modify:
docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md(the "Follow-ups surfaced during the review chain" section + the decisions-table multi-device row — mark A–E DONE, note the rebind re-trigger now exists) - Modify:
docs/plans/2026-06-26-otopcua-fixedtree-followups-design.md(Status → Implemented) - Modify:
docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-RESUME.md(§3 — strike the now-closed follow-ups)
Steps: update the prose to reflect what shipped (each follow-up + the fact that E required no migration / no artifact change; the rebind re-trigger reversed the earlier won't-fix, cleanly). Commit: git commit -m "docs(otopcua): record FixedTree follow-ups A-E as implemented"
Task 11: Build + full offline suite + regression gate
Classification: standard Estimated implement time: ~4 min (mostly test wall-time) Parallelizable with: none (final; after Tasks 2, 4, 9, 10)
Files: none (verification only)
Steps:
dotnet build ZB.MOM.WW.OtOpcUa.slnx→ 0 errors, 0 warnings.dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~Runtime.Tests"→ all green.dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~OpcUaServer.Tests"→ all green.dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~FOCAS"→ all green (live-wire integration tests skip without the CNC — expected).- Confirm the validated single-device FOCAS injection path is unchanged (the relevant
DriverHostActorDiscoveryTests/end-to-end test passes untouched). Report counts. Do NOT run a full-solutiondotnet test(net48 Wonderware testhost can't run on macOS).
No commit (verification). Live wonder re-validation is optional + user-gated.