diff --git a/docs/plans/2026-09-10-handshake-reset-echo.md b/docs/plans/2026-09-10-handshake-reset-echo.md new file mode 100644 index 00000000..e4a8ef73 --- /dev/null +++ b/docs/plans/2026-09-10-handshake-reset-echo.md @@ -0,0 +1,1292 @@ +# Reset-Aware Handshake (`WriteBatchAndWaitAsync` overload) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Close Gitea #36 in the repo — a `WriteBatchAndWaitAsync` overload that writes a reset value, waits for the device to echo it, and only then writes the batch + trigger and waits for the response, returning a phase-aware `HandshakeResult`; plus mirrors, predictor support, tests, docs and a rig live gate. + +**Architecture:** The overload is a *composition* inside `ScriptRuntimeContext` of the four existing legs (`SetAttribute` → `WaitInternal` → batch Ask → `WaitInternal`); the `InstanceActor` waiter and every wire contract are unchanged. `AttributeAccessor` exposes it to scripts; `ScriptCompileSurface` (deploy gate), `SandboxScriptHost` (Test Run editor) and `FlowShapePredictor` (Flow Debugger prediction) mirror it. Design: `docs/plans/2026-09-10-handshake-reset-echo-design.md`. + +**Tech Stack:** .NET 10 / C#, Akka.NET (TestKit + `TestProbe` in tests), Roslyn syntax walker (predictor), xunit, bash + ScadaBridge CLI for the rig seed. + +**Working copy:** memory says never work in the shared primary checkout. Create a dedicated worktree once, before Task 1: + +```bash +cd /Users/dohertj2/Desktop/ScadaBridge +git worktree add -b issue-36-handshake-reset ../ScadaBridge-issue36 main +``` + +Every `dotnet`/`git` command below runs with `cd /Users/dohertj2/Desktop/ScadaBridge-issue36` first (Bash cwd drifts — always `cd` back). Do **not** use `git stash` (memory: `refs/stash` is shared across worktrees). The rig deploy (Task 10) happens from the primary checkout after a fast-forward merge — `docker/deploy.sh` refuses to run from a worktree path. + +**Test commands (targeted, per memory "targeted tests, not full suite"):** + +```bash +SLN=ZB.MOM.WW.ScadaBridge.slnx +dotnet build $SLN 2>&1 | tail -3 # full build only at Task 10 +dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~" 2>&1 | tail -5 +``` + +--- + +### Task 1: Commons `HandshakePhase` + `HandshakeResult` + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** none (everything depends on it) + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/HandshakeResult.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/HandshakeResultTests.cs` +- Modify: `docs/plans/2026-09-10-handshake-reset-echo-design.md` (§4 — add `ResetFailed`, see Step 3 note) + +**Step 1: Write the failing test** + +```csharp +using ZB.MOM.WW.ScadaBridge.Commons.Types; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; + +/// +/// Gitea #36: lets a script tell WHICH leg of a reset-aware +/// handshake failed without naming the enum (script bodies +/// import only System/System.Collections.Generic/System.Linq/System.Threading.Tasks). +/// +public class HandshakeResultTests +{ + [Fact] + public void ResetFailed_IsTrue_OnlyForAnUnmatchedResetPhase() + { + var resetTimeout = new HandshakeResult(false, null, null, true, HandshakePhase.Reset, "reset timed out"); + var responseTimeout = new HandshakeResult(false, null, null, true, HandshakePhase.Response, "response timed out"); + var matched = new HandshakeResult(true, true, "Good", false, HandshakePhase.Response, null); + + Assert.True(resetTimeout.ResetFailed); + Assert.False(responseTimeout.ResetFailed); + Assert.False(matched.ResetFailed); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~HandshakeResultTests" 2>&1 | tail -5` +Expected: build error `The type or namespace name 'HandshakeResult' could not be found`. + +**Step 3: Write the types** + +```csharp +namespace ZB.MOM.WW.ScadaBridge.Commons.Types; + +/// +/// Which leg of a reset-aware Attributes.WriteBatchAndWaitAsync handshake a +/// describes (Gitea #36, design 2026-09-10 §4). +/// +public enum HandshakePhase +{ + /// The reset write + reset-echo wait that precedes the batch and trigger. + Reset, + + /// The batch + trigger write and the response wait that follow an observed reset echo. + Response, +} + +/// +/// Result of the reset-aware Attributes.WriteBatchAndWaitAsync overload (Gitea #36): +/// like , plus the the outcome belongs to so a +/// script can report "timed out waiting for the reset echo" separately from "timed out +/// waiting for the device to complete". A reset that is never echoed means the batch and +/// trigger were NEVER written. +/// +/// +/// when the response attribute reached its target within the timeout +/// (which implies the reset echo was observed first). +/// +/// The matched value of the phase's attribute; on timeout / error. +/// The attribute quality at match time; on the non-match paths. +/// when the phase's wait timed out before a match. +/// The leg this result describes — only when that leg failed. +/// Why the phase did not match, when known; on success. +public readonly record struct HandshakeResult( + bool Matched, + object? Value, + string? Quality, + bool TimedOut, + HandshakePhase Phase, + string? ErrorMessage) +{ + /// + /// when the reset leg did not match (timeout or error), i.e. the + /// device never echoed the reset value and nothing else was written. Provided so a + /// script can branch without spelling the type name, which + /// is not in the script imports. + /// + public bool ResetFailed => !Matched && Phase == HandshakePhase.Reset; +} +``` + +Also append to design doc §4, after the `HandshakeResult` code block: + +```markdown +`ResetFailed` (`!Matched && Phase == Reset`) is a computed convenience: script bodies import only +`System`, `System.Collections.Generic`, `System.Linq` and `System.Threading.Tasks`, so a script +branching on the enum would have to spell `ZB.MOM.WW.ScadaBridge.Commons.Types.HandshakePhase`. +``` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~HandshakeResultTests" 2>&1 | tail -5` +Expected: `Passed! - Failed: 0, Passed: 1` + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/HandshakeResult.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/HandshakeResultTests.cs docs/plans/2026-09-10-handshake-reset-echo-design.md +git commit -m "feat(commons): HandshakePhase + HandshakeResult for the reset-aware handshake (#36)" +``` + +--- + +### Task 2: `ScriptRuntimeContext.WriteBatchResetAndWait` (probe-level tests) + +**Classification:** high-risk (script runtime hot path; ordering is the whole fix) +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs:964-1007` (`WriteBatchAndWait`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/WriteBatchResetAndWaitTests.cs` (create) + +**Step 1: Write the failing tests** + +The probe plays the InstanceActor so message ORDER is observable. `ExpectNoMsg` after a failed reset is the assertion that matters most: the batch must never be sent. + +```csharp +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; + +/// +/// Gitea #36 / design 2026-09-10 §5: the reset-aware handshake runs its legs in a FIXED +/// order — reset write, reset-echo wait, batch + trigger, response wait — and never writes +/// the batch unless the reset echo was observed. The probe stands in for the InstanceActor +/// so the order and the "nothing after a failed reset" property are directly observable. +/// +public class WriteBatchResetAndWaitTests : TestKit +{ + private const string Instance = "Plant.Reactor1"; + private static readonly IReadOnlyDictionary Values = + new Dictionary { ["BatchID"] = "28950" }; + + private ScriptRuntimeContext CreateContext(IActorRef instanceActor) => + new(instanceActor, + new SharedScriptLibrary( + new ScriptCompilationService(NullLogger.Instance), + NullLogger.Instance), + currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5), + instanceName: Instance, logger: NullLogger.Instance); + + [Fact] + public async Task Legs_RunInOrder_AndReportTheResponsePhase() + { + var probe = CreateTestProbe(); + var ctx = CreateContext(probe.Ref); + + var call = ctx.WriteBatchResetAndWait( + Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25), + "MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10)); + + // Leg 1: the reset write, and nothing else yet. + var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("MoveOutCompleteFlag", reset.AttributeName); + Assert.Equal("False", reset.Value); + probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow)); + + // Leg 2: the reset-echo wait targets the RESET value and the RESET timeout. + var resetWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("MoveOutCompleteFlag", resetWait.AttributeName); + Assert.Equal("False", resetWait.TargetValueEncoded); + Assert.Equal(TimeSpan.FromSeconds(10), resetWait.Timeout); + Assert.False(resetWait.RequireGoodQuality); + probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false)); + + // Leg 3: only now the batch + trigger. + var batch = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("MoveOutFlag", batch.TriggerAttribute); + Assert.Equal("True", batch.TriggerEncodedValue); + Assert.Equal("28950", batch.AttributeEncodedValues["BatchID"]); + probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null)); + + // Leg 4: the response wait with the RESPONSE timeout. + var respWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("MoveOutCompleteFlag", respWait.AttributeName); + Assert.Equal("True", respWait.TargetValueEncoded); + Assert.Equal(TimeSpan.FromSeconds(25), respWait.Timeout); + probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: true, true, "Good", TimedOut: false)); + + var result = await call; + Assert.True(result.Matched); + Assert.Equal(HandshakePhase.Response, result.Phase); + Assert.False(result.TimedOut); + Assert.False(result.ResetFailed); + Assert.Equal(true, result.Value); + Assert.Equal("Good", result.Quality); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public async Task ResetEchoTimeout_ReportsResetPhase_AndNeverWritesTheBatch() + { + var probe = CreateTestProbe(); + var ctx = CreateContext(probe.Ref); + + var call = ctx.WriteBatchResetAndWait( + Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25), + "MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10)); + + var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow)); + var resetWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: false, null, null, TimedOut: true)); + + var result = await call; + Assert.False(result.Matched); + Assert.True(result.TimedOut); + Assert.Equal(HandshakePhase.Reset, result.Phase); + Assert.True(result.ResetFailed); + Assert.Contains("MoveOutCompleteFlag", result.ErrorMessage); + + // The property that fixes #36: no batch, no trigger, after a reset that was never echoed. + probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + + [Fact] + public async Task ResponseTimeout_ReportsResponsePhase() + { + var probe = CreateTestProbe(); + var ctx = CreateContext(probe.Ref); + + var call = ctx.WriteBatchResetAndWait( + Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25), + "MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10)); + + var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow)); + var resetWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false)); + var batch = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null)); + var respWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: false, null, null, TimedOut: true)); + + var result = await call; + Assert.False(result.Matched); + Assert.True(result.TimedOut); + Assert.Equal(HandshakePhase.Response, result.Phase); + Assert.False(result.ResetFailed); + } + + [Fact] + public async Task ResetWriteFailure_Throws_LikeTheBatchLeg() + { + var probe = CreateTestProbe(); + var ctx = CreateContext(probe.Ref); + + var call = ctx.WriteBatchResetAndWait( + Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25), + "MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10)); + + var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", false, "device rejected", DateTimeOffset.UtcNow)); + + var ex = await Assert.ThrowsAsync(() => call); + Assert.Contains("device rejected", ex.Message); + probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + } + + /// The bool overload is untouched: batch first, then the wait, no reset leg. + [Fact] + public async Task LegacyOverload_StillWritesTheBatchFirst() + { + var probe = CreateTestProbe(); + var ctx = CreateContext(probe.Ref); + + var call = ctx.WriteBatchAndWait(Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25)); + + var batch = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null)); + var wait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WaitForAttributeResponse(wait.CorrelationId, Matched: true, true, "Good", TimedOut: false)); + + Assert.True(await call); + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~WriteBatchResetAndWaitTests" 2>&1 | tail -5` +Expected: build error `'ScriptRuntimeContext' does not contain a definition for 'WriteBatchResetAndWait'`. + +**Step 3: Implement** + +In `ScriptRuntimeContext.cs`, refactor the existing `WriteBatchAndWait` into a core that returns the full wait response, and add the reset-aware composition. Replace the method starting at line 964 (keep its XML doc, it stays accurate) with: + +```csharp + public async Task WriteBatchAndWait( + IReadOnlyDictionary encodedValues, string triggerAttr, string? triggerEncoded, + string responseAttr, string? responseEncoded, TimeSpan timeout) + => (await WriteBatchAndWaitCore(encodedValues, triggerAttr, triggerEncoded, responseAttr, responseEncoded, timeout) + .ConfigureAwait(false)).Matched; + + /// + /// Reset-aware handshake (Gitea #36, design 2026-09-10 §5) backing the nine-argument + /// Attributes.WriteBatchAndWaitAsync overload. Legs, in a FIXED order: + /// (1) write = through + /// (throws on a device-write failure, like the batch leg); + /// (2) wait event-driven for the device to ECHO the reset, bounded by + /// — not matched ⇒ return a + /// result and write NOTHING else; (3) the existing batch + trigger write; (4) the existing + /// response wait bounded by . + /// + /// + /// Why the reset must be OBSERVED before the trigger is written: a subscription reports a + /// change from the last reported value, so a device that flips reset→done inside one + /// sampling interval never reports the reset — the cached previous-run "done" value would + /// then satisfy the response wait instantly (the #36 race), and an edge-triggered wait + /// would never resolve at all. Holding the trigger until the echo lands makes the existing + /// level-triggered fast-path correct again. Each leg is already a traced Flow Debugger + /// step, so a traced run shows all four. + /// + /// Scope-resolved attribute name → codec-encoded value to batch-write. + /// Scope-resolved trigger attribute written AFTER the batch. + /// Codec-encoded trigger value. + /// Scope-resolved attribute to wait on after the trigger. + /// Codec-encoded response target (null ⇒ any change). + /// Bound for the response wait. + /// Scope-resolved attribute written FIRST (usually the response attribute). + /// Codec-encoded reset value the device must echo before anything else is written. + /// Bound for the reset-echo wait. + /// A phase-aware result; never throws on either timeout. + public async Task WriteBatchResetAndWait( + IReadOnlyDictionary encodedValues, string triggerAttr, string? triggerEncoded, + string responseAttr, string? responseEncoded, TimeSpan timeout, + string resetAttr, string? resetEncoded, TimeSpan resetTimeout) + { + // Leg 1 — reset write. A static attribute updates the cache synchronously (leg 2 then + // fast-paths); a data-sourced one goes to the device and the cache waits for the echo. + await SetAttribute(resetAttr, resetEncoded ?? string.Empty).ConfigureAwait(false); + + // Leg 2 — reset echo. Quality-agnostic, like the response wait. + var reset = await WaitInternal(resetAttr, resetEncoded, null, resetTimeout, requireGoodQuality: false) + .ConfigureAwait(false); + if (!reset.Matched) + { + return new HandshakeResult( + Matched: false, reset.Value, reset.Quality, reset.TimedOut, HandshakePhase.Reset, + reset.ErrorMessage + ?? (reset.TimedOut + ? $"Timed out after {resetTimeout.TotalSeconds}s waiting for '{resetAttr}' to echo the reset value" + : $"Reset wait for '{resetAttr}' did not match")); + } + + // Legs 3 + 4 — unchanged batch/trigger write and response wait. + var response = await WriteBatchAndWaitCore( + encodedValues, triggerAttr, triggerEncoded, responseAttr, responseEncoded, timeout).ConfigureAwait(false); + return new HandshakeResult( + response.Matched, response.Value, response.Quality, response.TimedOut, HandshakePhase.Response, + response.Matched + ? null + : response.ErrorMessage + ?? (response.TimedOut + ? $"Timed out after {timeout.TotalSeconds}s waiting for '{responseAttr}'" + : $"Response wait for '{responseAttr}' did not match")); + } + + /// + /// Shared body of / : + /// the batch + trigger Ask (throws on failure) followed by the response wait, returning + /// the full so the reset-aware caller can surface + /// value/quality/timeout. + /// + private async Task WriteBatchAndWaitCore( + IReadOnlyDictionary encodedValues, string triggerAttr, string? triggerEncoded, + string responseAttr, string? responseEncoded, TimeSpan timeout) + { + // + return await WaitInternal(responseAttr, responseEncoded, null, timeout, requireGoodQuality: false) + .ConfigureAwait(false); + } +``` + +Move the old method's XML `` onto the public bool method (unchanged text) and keep the existing body inside `WriteBatchAndWaitCore` byte-for-byte except for the final line. `HandshakeResult`/`HandshakePhase` live in `ZB.MOM.WW.ScadaBridge.Commons.Types`, already imported by this file (it uses `WaitResult`). + +**Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~WriteBatchResetAndWaitTests|FullyQualifiedName~ScriptRuntimeContextTraceEmissionTests" 2>&1 | tail -5` +Expected: all pass (the trace-emission suite proves the refactored legacy path is unchanged). + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/WriteBatchResetAndWaitTests.cs +git commit -m "feat(site-runtime): WriteBatchResetAndWait — reset write + echo wait gate the batch/trigger (#36)" +``` + +--- + +### Task 3: End-to-end regression test over the real `InstanceActor` (issue item 3) + +**Classification:** high-risk (pins the actual #36 race against the real waiter + DCL ingest path) +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 4, Task 5, Task 6 (test-only file; needs Task 2) + +**Files:** +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs` (create) + +**Step 1: Write the tests** + +Model on `InstanceActorWaitForAttributeTests` (fixture) + `InstanceActorSetAttributeTests` (DCL probe answering `WriteTagRequest`). Two data-sourced Booleans on one connection, cache driven to the codec-`True` a previous run leaves behind, then the handshake through a real `ScriptRuntimeContext`. + +```csharp +using Akka.Actor; +using Akka.TestKit; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging.Abstractions; +using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.TestSupport; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; + +/// +/// Gitea #36 end-to-end over the REAL waiter and DCL ingest path: +/// the cache still holds the previous run's CompleteFlag = True, the script's reset +/// write has NOT echoed back yet. The legacy overload fast-paths on the stale value (the +/// defect, pinned as documentation); the reset-aware overload holds the batch + trigger until +/// the reset echo lands, then matches only on the device's fresh True. +/// +public class HandshakeResetEchoTests : TestKit, IDisposable +{ + private const string Conn = "PLC"; + private const string TriggerTag = "ns=3;s=MoveOutFlag"; + private const string CompleteTag = "ns=3;s=MoveOutCompleteFlag"; + private const string BatchTag = "ns=3;s=MoveOutBatchID"; + + private readonly SiteStorageService _storage; + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly TestLocalDb _localDb; + + public HandshakeResetEchoTests() + { + _localDb = TestLocalDb.CreateTemp("handshake-reset-echo"); + _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); + _storage.InitializeAsync().GetAwaiter().GetResult(); + _compilationService = new ScriptCompilationService(NullLogger.Instance); + _sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger.Instance); + } + + void IDisposable.Dispose() + { + Shutdown(); + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } + + private static FlattenedConfiguration Config() => new() + { + InstanceUniqueName = "Z28061", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "MoveOutFlag", Value = "false", DataType = "Boolean", DataSourceReference = TriggerTag, BoundDataConnectionName = Conn }, + new ResolvedAttribute { CanonicalName = "MoveOutCompleteFlag", Value = "false", DataType = "Boolean", DataSourceReference = CompleteTag, BoundDataConnectionName = Conn }, + new ResolvedAttribute { CanonicalName = "MoveOutBatchID", Value = "0", DataType = "Int32", DataSourceReference = BatchTag, BoundDataConnectionName = Conn }, + ] + }; + + private (IActorRef Actor, TestProbe Dcl, ScriptRuntimeContext Ctx) Boot() + { + var dcl = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => new InstanceActor( + "Z28061", JsonSerializer.Serialize(Config()), _storage, _compilationService, _sharedScriptLibrary, + null, new SiteRuntimeOptions(), NullLogger.Instance, dcl.Ref))); + dcl.ExpectMsg(TimeSpan.FromSeconds(5)); + + // The previous run's completion is still in the cache: codec "True", exactly what the + // response wait targets. + actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow)); + + var ctx = new ScriptRuntimeContext( + actor, _sharedScriptLibrary, currentCallDepth: 0, maxCallDepth: 10, + askTimeout: TimeSpan.FromSeconds(5), instanceName: "Z28061", logger: NullLogger.Instance); + return (actor, dcl, ctx); + } + + private static IReadOnlyDictionary Values(int batchId) + => new Dictionary { ["MoveOutBatchID"] = AttributeValueCodec.Encode(batchId) }; + + /// The defect, as documentation: the legacy overload answers from the stale cache. + [Fact] + public async Task LegacyOverload_FastPathsOnThePreviousRunsCachedTrue() + { + var (_, dcl, ctx) = Boot(); + + var call = ctx.WriteBatchAndWait( + Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true), + "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(5)); + + var batch = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); + dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow)); + + // No device update at all — and the wait still "matches". + Assert.True(await call); + } + + [Fact] + public async Task ResetAwareOverload_HoldsTheBatchUntilTheResetEchoes_ThenMatchesTheFreshTrue() + { + var (actor, dcl, ctx) = Boot(); + + var call = ctx.WriteBatchResetAndWait( + Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true), + "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10), + "MoveOutCompleteFlag", AttributeValueCodec.Encode(false), TimeSpan.FromSeconds(10)); + + // Leg 1 reaches the device as a plain tag write; the DCL acks it but the device has + // not echoed anything yet. + var reset = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(CompleteTag, reset.TagPath); + dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow)); + + // The cache still says True → the reset wait must NOT fast-path, and no batch may go out. + dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + Assert.False(call.IsCompleted); + + // The device echoes the reset. + actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow)); + + // Only now the batch + trigger. + var batch = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(TriggerTag, batch.TriggerTagPath); + Assert.Equal(true, batch.TriggerValue is bool b ? b : bool.Parse(batch.TriggerValue!.ToString()!)); + Assert.True(batch.Values.ContainsKey(BatchTag)); + dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow)); + + // Still pending: the cache now holds False. + Assert.False(call.IsCompleted); + + // The device completes THIS request. + actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow)); + + var result = await call; + Assert.True(result.Matched); + Assert.Equal(HandshakePhase.Response, result.Phase); + Assert.Equal(true, result.Value); + Assert.Equal("Good", result.Quality); + } + + [Fact] + public async Task ResetAwareOverload_NoEcho_TimesOutInResetPhase_AndNeverWritesTheBatch() + { + var (_, dcl, ctx) = Boot(); + + var call = ctx.WriteBatchResetAndWait( + Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true), + "MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10), + "MoveOutCompleteFlag", AttributeValueCodec.Encode(false), TimeSpan.FromMilliseconds(700)); + + var reset = dcl.ExpectMsg(TimeSpan.FromSeconds(5)); + dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow)); + + var result = await call; + Assert.False(result.Matched); + Assert.True(result.TimedOut); + Assert.True(result.ResetFailed); + dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + } +} +``` + +If `batch.TriggerValue` arrives as the encoded string rather than a bool (the batch handler passes `request.TriggerEncodedValue` through for non-list attributes), simplify that assertion to `Assert.Equal(AttributeValueCodec.Encode(true), batch.TriggerValue?.ToString())` — check the first run's failure message and adjust; do not weaken the ordering assertions. + +**Step 2: Run tests** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~HandshakeResetEchoTests" 2>&1 | tail -8` +Expected: 3 passed. (These are green-on-arrival because Task 2 is in; their value is the pin. If `LegacyOverload_FastPaths…` FAILS, the cache seeding is wrong — the codec form of the seeded value must equal `AttributeValueCodec.Encode(true)`.) + +**Step 3: Commit** + +```bash +git add tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs +git commit -m "test(site-runtime): pin #36 — reset-aware handshake holds the batch until the reset echo lands" +``` + +--- + +### Task 4: `AttributeAccessor` overload + `ScriptCompileSurface` mirror + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 3, Task 5, Task 6 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs:99-112` (after `WriteBatchAndWaitAsync`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs:326-334` (after the batch mirror) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs` (existing; no edit) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs` (add one test) + +**Step 1: Add the accessor overload first, then run parity — it must FAIL** + +In `ScopeAccessors.cs`, directly after the existing `WriteBatchAndWaitAsync`: + +```csharp + /// + /// Reset-aware handshake (Gitea #36): writes =, + /// waits (event-driven, bounded by , default = ) + /// for the device to ECHO that reset, and only then performs the ordinary + /// + /// legs (batch → trigger → response wait). Returns a so a + /// script can tell a reset-echo timeout (; nothing + /// else was written) from a completion timeout. Use this for every level-triggered + /// flag handshake: without an observed reset the plain overload can be satisfied + /// instantly by the previous run's cached response value. Throws + /// if the reset write or the batch/trigger + /// write itself fails; never throws on either timeout. + /// + /// Attribute key → value to batch-write (data-sourced, one connection). + /// Trigger attribute key written AFTER the batch. + /// Value to write to the trigger. + /// Attribute key to wait on after the trigger. + /// Target value to wait for (null ⇒ any change). + /// How long to wait for the response before giving up. + /// Attribute written FIRST (usually ); static or data-sourced. + /// Value the device must echo back before the batch is written. + /// How long to wait for the reset echo; null ⇒ . + /// The phase-aware handshake outcome. + public Task WriteBatchAndWaitAsync( + IReadOnlyDictionary values, string flagKey, object? flagValue, + string responseKey, object? responseValue, TimeSpan timeout, + string resetKey, object? resetValue, TimeSpan? resetTimeout = null) + { + var encoded = new Dictionary(values.Count); + foreach (var kv in values) + encoded[Resolve(kv.Key)] = AttributeValueCodec.Encode(kv.Value); + + return _ctx.WriteBatchResetAndWait( + encoded, + Resolve(flagKey), AttributeValueCodec.Encode(flagValue), + Resolve(responseKey), AttributeValueCodec.Encode(responseValue), + timeout, + Resolve(resetKey), AttributeValueCodec.Encode(resetValue), + resetTimeout ?? timeout); + } +``` + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~CompileSurfaceParityTests" 2>&1 | tail -8` +Expected: FAIL — the arity test reports `WriteBatchAndWaitAsync` with 9 parameters missing from `CompileAttributeAccessor`. (If it passes, the parity test is weaker than documented — stop and report.) + +**Step 2: Add the compile-surface mirror** + +In `ScriptCompileSurface.cs`, after the existing `WriteBatchAndWaitAsync` mirror inside `CompileAttributeAccessor`: + +```csharp + /// Mirrors the reset-aware AttributeAccessor.WriteBatchAndWaitAsync overload (Gitea #36). + /// The set of attribute values to write as a batch. + /// The name of the flag attribute signaling the write. + /// The value written to the flag attribute. + /// The name of the attribute to observe for a response. + /// The value to wait for on the response attribute. + /// The maximum time to wait for the response. + /// The attribute written first and waited on before the batch. + /// The reset value the device must echo before the batch is written. + /// The maximum time to wait for the reset echo; null means . + /// The phase-aware handshake outcome. + public Task WriteBatchAndWaitAsync(IReadOnlyDictionary values, string flagKey, object? flagValue, string responseKey, object? responseValue, TimeSpan timeout, string resetKey, object? resetValue, TimeSpan? resetTimeout = null) => throw new NotSupportedException(CompileOnly); +``` + +**Step 3: Add a scope-resolution test to `ScopeAccessorTests`** + +Follow the file's existing probe pattern (see its other `WriteBatchAndWaitAsync`/`WaitAsync` tests around lines 170–300 for the accessor construction). The assertion that matters: keys are scope-resolved and `resetTimeout` defaults to `timeout`. + +```csharp + [Fact] + public void WriteBatchAndWaitAsync_ResetAware_ResolvesScopeAndDefaultsResetTimeout() + { + var probe = CreateTestProbe(); + // + var call = accessor.WriteBatchAndWaitAsync( + new Dictionary { ["MoveOutBatchID"] = 28950 }, + "MoveOutFlag", true, "MoveOutCompleteFlag", true, TimeSpan.FromSeconds(25), + "MoveOutCompleteFlag", false); + + var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("RightMESReceiver.MoveOutCompleteFlag", reset.AttributeName); + Assert.Equal(AttributeValueCodec.Encode(false), reset.Value); + probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, "X", reset.AttributeName, true, null, DateTimeOffset.UtcNow)); + + var resetWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("RightMESReceiver.MoveOutCompleteFlag", resetWait.AttributeName); + Assert.Equal(TimeSpan.FromSeconds(25), resetWait.Timeout); // defaulted to `timeout` + probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: false, null, null, TimedOut: true)); + + var result = call.GetAwaiter().GetResult(); + Assert.True(result.ResetFailed); + } +``` + +**Step 4: Run tests** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~CompileSurfaceParityTests|FullyQualifiedName~ScopeAccessorTests" 2>&1 | tail -5` +Expected: all pass. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs +git commit -m "feat(scripts): reset-aware Attributes.WriteBatchAndWaitAsync overload + deploy-gate mirror (#36)" +``` + +--- + +### Task 5: `SandboxScriptHost` mirror + Central UI compile-clean test + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 3, Task 4, Task 6 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs:462-465` (after the batch stand-in) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs:752-770` (`InstanceScript_BatchAndWaitHelpers_DiagnoseClean`) + +**Step 1: Extend the failing test** + +In `InstanceScript_BatchAndWaitHelpers_DiagnoseClean`, add the nine-argument form to `code` and use its result: + +```csharp + "var hs = await Attributes.WriteBatchAndWaitAsync(data, \"Flag\", true, \"Done\", true, System.TimeSpan.FromSeconds(5), \"Done\", false, System.TimeSpan.FromSeconds(2));\n" + + "var hs2 = await Attributes.WriteBatchAndWaitAsync(data, \"Flag\", true, \"Done\", true, System.TimeSpan.FromSeconds(5), \"Done\", false);\n" + + "return done && hit && pred && res.Matched && hs.Matched && !hs2.ResetFailed;"; +``` + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~InstanceScript_BatchAndWaitHelpers_DiagnoseClean" 2>&1 | tail -5` +Expected: FAIL with a `CS1501` (no overload takes 8/9 arguments) marker. + +**Step 2: Add the sandbox stand-in** + +After the existing `WriteBatchAndWaitAsync` in `SandboxScriptHost.cs`: + +```csharp + /// + /// Sandbox stand-in for the reset-aware AttributeAccessor.WriteBatchAndWaitAsync + /// overload (Gitea #36): present for editor/compile parity, throws + /// when run in Test Run (no device batch-write transport). + /// + /// The attribute values to write as a batch. + /// The flag attribute key to set to signal completion. + /// The value to set on the flag attribute. + /// The attribute key to observe for the device's response. + /// The response value to wait for. + /// The maximum time to wait for the response. + /// The attribute written first and waited on before the batch. + /// The reset value the device must echo before the batch is written. + /// The maximum time to wait for the reset echo; null means . + /// Never returns; always throws in the sandbox. + public Task WriteBatchAndWaitAsync( + IReadOnlyDictionary values, string flagKey, object? flagValue, + string responseKey, object? responseValue, TimeSpan timeout, + string resetKey, object? resetValue, TimeSpan? resetTimeout = null) + => throw NotInSandbox(nameof(WriteBatchAndWaitAsync)); +``` + +`HandshakeResult` is in `ZB.MOM.WW.ScadaBridge.Commons.Types`; add the `using` if the file lacks it (it references `WaitResult`, so it is likely present). + +**Step 3: Run test** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~ScriptAnalysisServiceTests" 2>&1 | tail -5` +Expected: all pass. + +**Step 4: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs +git commit -m "feat(central-ui): Test Run sandbox mirrors the reset-aware WriteBatchAndWaitAsync (#36)" +``` + +--- + +### Task 6: `FlowShapePredictor` — nine-argument form predicts the reset legs + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 3, Task 4, Task 5 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs:370-378` (`case "WriteBatchAndWaitAsync"`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs` + +**Step 1: Write the failing test** + +```csharp + /// Gitea #36: the reset-aware overload predicts the reset write + echo wait ahead of the batch. + [Fact] + public void ResetAwareWriteBatchAndWait_PredictsResetWriteAndEchoWaitBeforeTheBatch() + { + const string body = """ + var data = new System.Collections.Generic.Dictionary { { "MoveOutBatchID", 1 } }; + var hs = await Children["RightMESReceiver"].Attributes.WriteBatchAndWaitAsync( + data, "MoveOutFlag", true, "MoveOutCompleteFlag", true, System.TimeSpan.FromSeconds(25), + "MoveOutCompleteFlag", false, System.TimeSpan.FromSeconds(10)); + if (hs.ResetFailed) return new { WasSuccessful = false }; + """; + + var flow = FlowShapePredictor.PredictSiteScript("MesMoveOut", body); + + Assert.Equal( + [ + (FlowStepKind.ScriptRun, FlowLane.Site, "MesMoveOut"), + (FlowStepKind.AttributeWrite, FlowLane.Tags, "MoveOutCompleteFlag"), + (FlowStepKind.AttributeWait, FlowLane.Site, "MoveOutCompleteFlag"), + (FlowStepKind.AttributeWrite, FlowLane.Tags, "batch + MoveOutFlag"), + (FlowStepKind.AttributeWait, FlowLane.Site, "MoveOutCompleteFlag"), + ], flow.Nodes.Select(n => (n.Kind, n.Lane, n.Title)).ToList()); + Assert.Equal("reset", flow.Nodes[1].Note); + Assert.Equal("reset echo", flow.Nodes[2].Note); + } + + /// Named reset arguments resolve by name, not position. + [Fact] + public void ResetAwareWriteBatchAndWait_NamedArguments_ResolveTheResetKey() + { + const string body = """ + var hs = await Attributes.WriteBatchAndWaitAsync(data, "Trig", true, "Done", true, t, + resetKey: "Done", resetValue: false); + """; + + var flow = FlowShapePredictor.PredictSiteScript("S", body); + + Assert.Equal("Done", flow.Nodes[1].Title); + Assert.Equal(FlowStepKind.AttributeWrite, flow.Nodes[1].Kind); + Assert.Equal(5, flow.Nodes.Count); + } +``` + +Check the `PredictedFlow`/node type: if `Note` is not a public property on the node record, assert on the count + titles only and drop the two `Note` asserts (look at `FlowStepKind`/`PredictedNode` in `Commons/Types/Debugging`). + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests --filter "FullyQualifiedName~ResetAwareWriteBatchAndWait" 2>&1 | tail -5` +Expected: FAIL — 3 nodes instead of 5. + +**Step 2: Implement** + +Replace the `case "WriteBatchAndWaitAsync":` block: + +```csharp + case "WriteBatchAndWaitAsync": + { + // (batch, triggerAttr, triggerValue, waitAttr, waitValue, timeout + // [, resetAttr, resetValue, resetTimeout]) — Gitea #36: the reset-aware + // overload writes + waits on the reset attribute BEFORE the batch. + var (trigger, triggerDependent) = LiteralOrExpr(args.ElementAtOrDefault(1)?.Expression); + var (wait, waitDependent) = LiteralOrExpr(args.ElementAtOrDefault(3)?.Expression); + var resetArg = ArgAt(args, 6, "resetKey"); + if (resetArg is not null) + { + var (reset, resetDependent) = LiteralOrExpr(resetArg.Expression); + _b.AddNode(FlowStepKind.AttributeWrite, FlowLane.Tags, Qualify(reset), resetDependent, "reset"); + _b.AddNode(FlowStepKind.AttributeWait, FlowLane.Site, Qualify(reset), resetDependent, "reset echo"); + } + _b.AddNode(FlowStepKind.AttributeWrite, FlowLane.Tags, Qualify($"batch + {trigger}"), triggerDependent, null); + _b.AddNode(FlowStepKind.AttributeWait, FlowLane.Site, Qualify(wait), waitDependent, null); + return true; + } +``` + +and add beside `LiteralOrExpr`: + +```csharp + /// An argument by name (name: expr) or, failing that, by position among the unnamed arguments. + private static ArgumentSyntax? ArgAt(SeparatedSyntaxList args, int index, string name) + { + var named = args.FirstOrDefault(a => a.NameColon?.Name.Identifier.ValueText == name); + if (named is not null) + return named; + var positional = args.ElementAtOrDefault(index); + return positional is { NameColon: null } ? positional : null; + } +``` + +(`args` is `node.ArgumentList.Arguments`, a `SeparatedSyntaxList` — see line 317.) Update the `` list in the class doc (line ~46) to mention the reset legs. + +**Step 3: Run tests** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests --filter "FullyQualifiedName~FlowShapePredictorTests" 2>&1 | tail -5` +Expected: all pass, including the unchanged Delmia six-argument expectation. + +**Step 4: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs +git commit -m "feat(flow-debugger): predict the reset write + echo wait of the reset-aware handshake (#36)" +``` + +--- + +### Task 7: Trace emission — a traced reset-aware run emits four ordered step rows + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 8, Task 9 (needs Task 2) + +**Files:** +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRuntimeContextTraceEmissionTests.cs` (append after `NestedStepScopes_IgnoreTraceDiagnostics_AndUnwind`) + +**Step 1: Write the test** + +```csharp + /// + /// Gitea #36: the reset-aware handshake is four traced steps in leg order — reset write, + /// reset-echo wait, batch write, response wait — each with its own row id, with no new + /// audit kinds (the Flow Debugger projection needs no change). + /// + [Fact] + public async Task WriteBatchResetAndWait_WhenTraced_EmitsFourStepRowsInLegOrder() + { + var probe = CreateTestProbe(); + var collector = new FlowTraceCollector(); + var ctx = CreateContext(probe.Ref, new CapturingAuditWriter(), traced: true, collector); + + var call = ctx.WriteBatchResetAndWait( + new Dictionary { ["Recipe"] = "R-17" }, + "Trigger", "1", "Ack", "1", TimeSpan.FromSeconds(3), + "Ack", "0", TimeSpan.FromSeconds(2)); + + var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(reset.Trace); + probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, InstanceName, "Ack", true, null, DateTimeOffset.UtcNow)); + var resetWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, "0", "Good", TimedOut: false)); + var batch = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null)); + var respWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: true, "1", "Good", TimedOut: false)); + Assert.True((await call).Matched); + + var rows = collector.Snapshot().Select(Row).ToList(); + Assert.Equal( + [ + (AuditKind.AttributeWrite, "Ack"), + (AuditKind.AttributeWait, "Ack"), + (AuditKind.AttributeWrite, "Trigger"), + (AuditKind.AttributeWait, "Ack"), + ], rows.Select(r => (r.Kind, r.Target)).ToList()); + Assert.Equal(4, collector.Snapshot().Select(e => e.EventId).Distinct().Count()); + } +``` + +`Target` is whatever `AuditRowValues` calls the attribute-name column that the existing tests assert on (`extra.WaitAttribute` / the write row's target) — match the neighbouring tests' property name. + +**Step 2: Run** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~WriteBatchResetAndWait_WhenTraced" 2>&1 | tail -5` +Expected: PASS (green-on-arrival pin; if the order differs, Task 2's leg order is wrong — fix Task 2, not the test). + +**Step 3: Commit** + +```bash +git add tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRuntimeContextTraceEmissionTests.cs +git commit -m "test(site-runtime): traced reset-aware handshake emits four ordered step rows (#36)" +``` + +--- + +### Task 8: Docs, register row, rig comment, CLAUDE.md + +**Classification:** small (doc-only, several files) +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 7, Task 9 + +**Files:** +- Modify: `docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md` (§5, before line 228 `## 6`) +- Modify: `docs/requirements/Component-ScriptAnalysis.md:182,231` +- Modify: `docs/api-specs/Delmia-Recipe-Download-API.md` (§4, after line 140's code block) +- Modify: `docs/plans/2026-06-16-ipsen-mes-movein-design.md:67` (after item 4) +- Modify: `docker/seed-delmia-sim.sh:10-12` (header comment) +- Modify: `docs/plans/2026-07-08-deferred-work-register.md` (append row 119) +- Modify: `CLAUDE.md:74` (after the "Explicit supervision strategies" bullet) + +**Step 1: Spec §5 addendum** — append as the last bullet of §5: + +```markdown +- **Handshake reset ordering (Gitea #36, design `2026-09-10-handshake-reset-echo-design.md`).** + The fast-path above is correct ONLY once the caller's reset has been observed. A script that + writes `CompleteFlag=false` and immediately calls the batch helper races the device echo: the + cache still holds the previous run's `true`, the wait fast-paths in 0 ms, and the script reads + stale results. An edge-triggered wait is NOT the fix (a subscription can swallow the reset when + the device flips inside one sampling interval); the reset must be echoed BEFORE the trigger is + written. Use the reset-aware `Attributes.WriteBatchAndWaitAsync(…, resetKey, resetValue, + resetTimeout)` overload, which returns a `HandshakeResult` (`ResetFailed` ⇒ nothing else was + written). +``` + +**Step 2: Component-ScriptAnalysis.md** — in line 182's parenthetical list add "including the reset-aware nine-argument overload" after `Attributes.WriteBatchAndWaitAsync`; in line 231 after `…Attributes.WriteBatchAndWaitAsync` add "(the reset-aware overload additionally yields a reset `AttributeWrite` + `AttributeWait` pair ahead of the batch)". + +**Step 3: Delmia API spec** — after the §4 script code block (line ~145) add: + +```markdown +> **Known race (Gitea #36, 2026-09-10).** Step 2's `RecipeProcessedFlag = false` is a device +> write whose echo has not arrived when step 3's wait registers, so the wait can be satisfied by +> the PREVIOUS download's cached `true`. The runtime fix is the reset-aware overload — +> `WriteBatchAndWaitAsync(data, "RecipeDownloadFlag", true, "RecipeProcessedFlag", true, 25 s, +> "RecipeProcessedFlag", false, 10 s)` returning a `HandshakeResult` — which replaces steps 2–3. +> The deployed prod/QA script bodies are NOT yet ported (deferred-work register row 119). +``` + +**Step 4: Ipsen design** — after item 4 (line 67 block) add item `4a.`: + +```markdown +4a. **Handshake ordering (Gitea #36, 2026-09-10):** the reset of `CompleteFlag` must be + ECHOED by the Galaxy object before `Flag` is raised, otherwise the completion wait + fast-paths on the previous request's cached `true`. Port the receiver scripts onto the + reset-aware `WriteBatchAndWaitAsync` overload (`docs/plans/2026-09-10-handshake-reset-echo-design.md` + §4) and drop the `Task.Delay(1000)`. +``` + +**Step 5: seed-delmia-sim.sh** — extend the header comment lines 10–12: + +```bash +# * ProcessRecipeDownload (Call) — prod's handshake ported to per-attribute +# SetAsync/WaitAsync (WriteBatchAndWaitAsync rejects static attributes). The #36 +# reset-echo race cannot occur here: a STATIC write updates the cache synchronously, +# so the reset is "echoed" before the trigger is written. The data-sourced proof of +# the reset-aware overload is docker/seed-handshake-demo.sh. +``` + +**Step 6: Register row 119** — append to the table: + +```markdown +| 119 | **Port the QA/prod MES receiver scripts onto the reset-aware `WriteBatchAndWaitAsync` overload (Gitea #36)** *(Medium)*. Template scripts CvdReactor MesMoveIn (13) / MesMoveOut (14) / MesSampleData (36) and base MESReceiver MoveIn/MoveOut (15, 16, 25, 26, 37, 38) still reset `CompleteFlag` with a plain write and rely on `Task.Delay(1000)`; QA carries a config-only `WaitAsync(false, 10 s)` patch on 13/14 (deployment record 110). Rewrite: `var hs = await rx.Attributes.WriteBatchAndWaitAsync(values, "Flag", true, "CompleteFlag", true, 25 s, "CompleteFlag", false, 10 s); if (!hs.Matched) return new { WasSuccessful=false, ErrorText = hs.ResetFailed ? "Timeout waiting for CompleteFlag to reset" : "Timeout waiting for to complete", BatchID=0 };` and delete the delay. Requires the #36 build on the target central + sites first (the overload does not exist on older sites → deploy-gate compile failure, by design). | Gitea #36, design `2026-09-10-handshake-reset-echo-design.md` §8 | Repo-only scope was the user's decision for the #36 session; the script bodies live only in the QA/prod databases. | Next QA deployment of a build ≥ this change (write a deployment record; then prod). | +``` + +**Step 7: CLAUDE.md** — after line 74 insert: + +```markdown +- **Level-triggered flag handshakes must observe the reset echo before raising the trigger (Gitea #36, 2026-09-10).** `Attributes.WriteBatchAndWaitAsync` has a reset-aware overload — `(values, flagKey, flagValue, responseKey, responseValue, timeout, resetKey, resetValue, resetTimeout?)` → `HandshakeResult` (`Matched`/`TimedOut`/`Phase`/`ResetFailed`) — that writes the reset, waits event-driven for the device to echo it, and only then writes the batch + trigger and waits for the response. The six-argument bool overload is unchanged but **unsafe for a repeated handshake**: the waiter's current-value fast-path answers from the previous run's cached response value while the script's reset write is still in flight (QA measured 0 ms matches and stale `BatchID`s). An edge-triggered wait was rejected — a subscription reports changes from the last *reported* value, so a device flipping reset→done inside one sampling interval never reports the reset and such a wait never resolves. Composition only: `ScriptRuntimeContext.WriteBatchResetAndWait` chains the existing `SetAttribute` → waiter → batch → waiter legs, so no actor/message/proto change, and a traced run shows all four steps. Mirrored on `ScriptCompileSurface`, `SandboxScriptHost` and `FlowShapePredictor`. The QA/prod MES script port is register row 119. Design: `docs/plans/2026-09-10-handshake-reset-echo-design.md`. +``` + +**Step 8: Commit** + +```bash +git add docs CLAUDE.md docker/seed-delmia-sim.sh +git commit -m "docs: reset-aware handshake — spec note, component doc, Delmia/Ipsen notes, register row 119, CLAUDE.md (#36)" +``` + +--- + +### Task 9: Rig live gate — `docker/seed-handshake-demo.sh` + +**Classification:** standard +**Estimated implement time:** ~5 min (authoring; the run itself is Task 10) +**Parallelizable with:** Task 7, Task 8 + +**Files:** +- Create: `docker/seed-handshake-demo.sh` + +**Step 1: Write the seed script** + +Modelled on `docker/seed-live-probe.sh` (CLI path/args conventions from `docker/seed-delmia-sim.sh`). Two writable, non-simulated Boolean OPC UA nodes (`ns=3;s=Motor.Running`, `ns=3;s=Pump.Running` — both `CurrentReadOrWrite`; see `docs/test_infra/test_infra_opcua.md`) plus one Double payload node, on site-a connection id 1. + +```bash +#!/bin/bash +set -euo pipefail + +# Seed a DATA-SOURCED flag handshake on site-a to prove the reset-aware +# Attributes.WriteBatchAndWaitAsync overload (Gitea #36) against a real OPC UA +# subscription — the reset echo must round-trip through the device before the +# trigger is written, and a SECOND run must not fast-path on the first run's +# cached Complete=true. +# +# What it creates (site-a / site id 1, connection id 1 "OPC PLC Simulator"): +# - Template HandshakeDemo +# * Trigger (Boolean) <- ns=3;s=Motor.Running writable, NOT simulated (holds what is written) +# * Complete (Boolean) <- ns=3;s=Pump.Running writable, NOT simulated +# * Payload (Double) <- ns=3;s=Motor.Speed writable, NOT simulated +# * Handshake (Call) — the reset-aware overload; returns the HandshakeResult fields +# * Device (ValueChange on Trigger) — plays the PLC: on Trigger==true sets Complete=true +# and clears Trigger (so the next run's trigger is a fresh edge) +# - Instance handshake-demo-1, bound + deployed +# +# Not idempotent (names must be unique). To re-seed: +# scadabridge instance delete --id && scadabridge template delete --id +# Prerequisites: infra (scadabridge-opcua) up, docker/deploy.sh, docker/seed-sites.sh. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge" +A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}") +SITE_ID="${SITE_ID:-1}" +CONNECTION_ID="${CONNECTION_ID:-1}" +INSTANCE_NAME="${INSTANCE_NAME:-handshake-demo-1}" + +json_id() { python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'; } + +echo "1/5 Creating template HandshakeDemo..." +TEMPLATE_ID=$("$CLI" template create "${A[@]}" --name HandshakeDemo \ + --description "Gitea #36 live gate: reset-aware WriteBatchAndWaitAsync over real OPC UA flags" | json_id) +echo " id=$TEMPLATE_ID" + +echo "2/5 Adding data-sourced attributes..." +"$CLI" template attribute add "${A[@]}" --template-id "$TEMPLATE_ID" --name Trigger --data-type Boolean --data-source "ns=3;s=Motor.Running" --description "Handshake trigger (writable, not simulated)" > /dev/null +"$CLI" template attribute add "${A[@]}" --template-id "$TEMPLATE_ID" --name Complete --data-type Boolean --data-source "ns=3;s=Pump.Running" --description "Handshake completion flag (writable, not simulated)" > /dev/null +"$CLI" template attribute add "${A[@]}" --template-id "$TEMPLATE_ID" --name Payload --data-type Double --data-source "ns=3;s=Motor.Speed" --description "Batch payload" > /dev/null + +echo "3/5 Adding the Handshake (Call) script — reset-aware overload..." +read -r -d '' HANDSHAKE_CODE <<'CS' || true +// Gitea #36: reset Complete=false, WAIT for the device to echo it, then batch+trigger, then wait +// for Complete=true. Run this twice: the second run is the one the plain overload got wrong. +var values = new Dictionary { ["Payload"] = 42.5 }; +var hs = await Attributes.WriteBatchAndWaitAsync( + values, "Trigger", true, "Complete", true, TimeSpan.FromSeconds(20), + "Complete", false, TimeSpan.FromSeconds(10)); +Log.Info($"handshake matched={hs.Matched} phase={hs.Phase} timedOut={hs.TimedOut} resetFailed={hs.ResetFailed}"); +return new { Matched = hs.Matched, Phase = hs.Phase.ToString(), TimedOut = hs.TimedOut, ResetFailed = hs.ResetFailed, Error = hs.ErrorMessage ?? "" }; +CS +"$CLI" template script add "${A[@]}" --template-id "$TEMPLATE_ID" --name Handshake --trigger-type Call \ + --return-def '{"type":"object","properties":{"Matched":{"type":"boolean"},"Phase":{"type":"string"},"TimedOut":{"type":"boolean"},"ResetFailed":{"type":"boolean"},"Error":{"type":"string"}},"required":["Matched","Phase"]}' \ + --code "$HANDSHAKE_CODE" > /dev/null + +echo "4/5 Adding the Device (ValueChange on Trigger) script — plays the PLC..." +read -r -d '' DEVICE_CODE <<'CS' || true +// Plays the device: on the rising edge of Trigger, complete the request and clear the trigger +// so the NEXT run's Trigger=true is a fresh change on the subscription. Its own Trigger=false +// write re-triggers this script, which returns at the guard. +var t = await Attributes.GetAsync("Trigger"); +var ts = t?.ToString(); +if (!((t is bool b && b) || ts == "1" || string.Equals(ts, "true", StringComparison.OrdinalIgnoreCase))) return null; +await Attributes.SetAsync("Complete", true); +await Attributes.SetAsync("Trigger", false); +return null; +CS +"$CLI" template script add "${A[@]}" --template-id "$TEMPLATE_ID" --name Device --trigger-type ValueChange \ + --trigger-config '{"attributeName":"Trigger"}' --code "$DEVICE_CODE" > /dev/null + +echo "5/5 Creating, binding and deploying instance $INSTANCE_NAME..." +INSTANCE_ID=$("$CLI" instance create "${A[@]}" --name "$INSTANCE_NAME" --template-id "$TEMPLATE_ID" --site-id "$SITE_ID" | json_id) +"$CLI" instance set-bindings "${A[@]}" --id "$INSTANCE_ID" \ + --bindings "[[\"Trigger\",$CONNECTION_ID],[\"Complete\",$CONNECTION_ID],[\"Payload\",$CONNECTION_ID]]" > /dev/null +"$CLI" instance deploy "${A[@]}" --id "$INSTANCE_ID" > /dev/null +echo " instance id=$INSTANCE_ID" + +echo "" +echo "=== Done. Live gate (run it TWICE — the second run is the #36 case): ===" +echo " $CLI flow-debug run-script --instance-id $INSTANCE_ID --script Handshake --show-logs ${A[*]}" +echo "Expect on BOTH runs: Matched=true, Phase=Response; the trace shows Complete write →" +echo "Complete wait (reset echo, >0 ms on the 2nd run) → batch+Trigger write → Complete wait." +``` + +`chmod +x docker/seed-handshake-demo.sh`. Verify the `template script add` flag names against `docker/seed-delmia-sim.sh` (they are the same verbs) and that the Boolean `--value` is not required for data-sourced attributes (the live-probe seed omits it). + +**Step 2: Shell-check** + +Run: `bash -n docker/seed-handshake-demo.sh && echo OK` +Expected: `OK` + +**Step 3: Commit** + +```bash +git add docker/seed-handshake-demo.sh +git commit -m "docker: seed-handshake-demo.sh — live gate for the reset-aware handshake over real OPC UA flags (#36)" +``` + +--- + +### Task 10: Integration — full build, suites, merge, rig deploy + live gate, Gitea comment + +**Classification:** high-risk (deploys the rig; merges to main) +**Estimated implement time:** ~5 min of commands + rig rebuild wall-time +**Parallelizable with:** none (last) + +**Files:** none new (verification + merge) + +**Step 1: Full build + the touched suites, in the worktree** + +```bash +cd /Users/dohertj2/Desktop/ScadaBridge-issue36 +dotnet build ZB.MOM.WW.ScadaBridge.slnx 2>&1 | grep -E "error|Warn|Build succeeded" | tail -5 +dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests 2>&1 | tail -3 +dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests 2>&1 | tail -3 +dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests 2>&1 | tail -3 +dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~ScriptAnalysis" 2>&1 | tail -3 +``` + +Expected: `0 Error(s)`; every suite `Failed: 0`. Known pre-existing flakes (memory): none in these suites except register row 118 (a CentralUI modal test outside the filter). + +**Step 2: Review the whole diff, then fast-forward main** + +```bash +git diff main...HEAD --stat +cd /Users/dohertj2/Desktop/ScadaBridge && git status --short # must be clean +git merge --ff-only issue-36-handshake-reset +git log --oneline -12 +``` + +**Step 3: Rebuild the rig from the primary checkout and run the live gate** + +```bash +cd /Users/dohertj2/Desktop/ScadaBridge +bash docker/deploy.sh 2>&1 | tail -5 +curl -sf http://localhost:9000/health/ready && echo READY +dotnet build src/ZB.MOM.WW.ScadaBridge.CLI 2>&1 | tail -1 # the seed uses the Debug CLI binary +bash docker/seed-handshake-demo.sh +``` + +Then run the printed `flow-debug run-script` command **twice** and record both outputs. Pass criteria: + +- Run 1 and run 2 both return `Matched=true`, `Phase=Response`, `ResetFailed=false`. +- Run 2's trace shows the reset-echo `AttributeWait` step with a non-zero duration (the device's echo, through the subscription), followed by the batch write and a satisfied response wait. +- Negative control: temporarily undeploy nothing — instead run `python infra/tools/opcua_tool.py write --node "ns=3;s=Pump.Running" --value true --type Boolean` to plant a stale `true`, then run the script once more: still `Phase=Response`, `Matched=true` (the reset leg clears it first). If the OPC UA tool is not available, skip and say so. + +If the seed fails on a CLI flag, fix the seed script (commit on main) and re-run; if the handshake fails, STOP — do not tune the script; report the trace. + +**Step 4: Record the gate and close out** + +- Append a short *Live gate 2026-09-10* section to `docs/plans/2026-09-10-handshake-reset-echo-design.md` with the two run results (durations of the four steps on run 2). +- Commit: `git commit -am "docs(#36): rig live gate results for the reset-aware handshake"`. +- Clean up: `git worktree remove ../ScadaBridge-issue36 && git branch -d issue-36-handshake-reset`. +- Comment on the Gitea issue (do NOT close — the QA/prod script port, register row 119, is still open): + +```bash +tea comment 36 "Runtime fix landed on main (): reset-aware Attributes.WriteBatchAndWaitAsync overload (values, flagKey, flagValue, responseKey, responseValue, timeout, resetKey, resetValue, resetTimeout?) → HandshakeResult; the reset is written and its echo awaited BEFORE the batch/trigger. Edge-triggered wait rejected (design §2). Mirrors + predictor + tests (HandshakeResetEchoTests pins the stale-cache case over the real InstanceActor) + rig live gate docker/seed-handshake-demo.sh (two consecutive runs Phase=Response). Remaining: port the QA/prod MES scripts 13/14/15/16/25/26/36/37/38 — deferred-work register row 119. Design: docs/plans/2026-09-10-handshake-reset-echo-design.md." +``` + +- Do **not** push; report to the user with the merge SHA, the gate results, and the push/QA-deploy options (memory: QA/prod deployments are the user's call). + +--- + +## Task dependency summary + +| Task | Depends on | Parallel group | +|---|---|---| +| 1 Commons types | — | — | +| 2 Context composition | 1 | — | +| 3 Real-actor regression test | 2 | A (with 4, 5, 6) | +| 4 Accessor + compile-surface mirror | 2 | A | +| 5 Sandbox mirror + UI test | 1 | A | +| 6 Predictor | — (1 for the test's `ResetFailed` reference) | A | +| 7 Trace-emission test | 2 | B (with 8, 9) | +| 8 Docs / register / CLAUDE.md | — | B | +| 9 Rig seed script | 4 (script uses the overload) | B | +| 10 Integration + rig + Gitea | all | — | + +Groups A and B are file-disjoint; if executed by parallel subagents, pre-create one worktree per task (memory: `isolation:worktree` agents share an index) and cherry-pick into `issue-36-handshake-reset` in task order. diff --git a/docs/plans/2026-09-10-handshake-reset-echo.md.tasks.json b/docs/plans/2026-09-10-handshake-reset-echo.md.tasks.json new file mode 100644 index 00000000..bb013c0b --- /dev/null +++ b/docs/plans/2026-09-10-handshake-reset-echo.md.tasks.json @@ -0,0 +1,16 @@ +{ + "planPath": "docs/plans/2026-09-10-handshake-reset-echo.md", + "tasks": [ + {"id": 1, "subject": "Task 1: Commons HandshakePhase + HandshakeResult", "status": "pending"}, + {"id": 2, "subject": "Task 2: ScriptRuntimeContext.WriteBatchResetAndWait (probe-level tests)", "status": "pending", "blockedBy": [1]}, + {"id": 3, "subject": "Task 3: End-to-end regression test over the real InstanceActor", "status": "pending", "blockedBy": [2]}, + {"id": 4, "subject": "Task 4: AttributeAccessor overload + ScriptCompileSurface mirror", "status": "pending", "blockedBy": [2]}, + {"id": 5, "subject": "Task 5: SandboxScriptHost mirror + Central UI compile-clean test", "status": "pending", "blockedBy": [1]}, + {"id": 6, "subject": "Task 6: FlowShapePredictor nine-argument form", "status": "pending", "blockedBy": [1]}, + {"id": 7, "subject": "Task 7: Trace emission test for the four ordered step rows", "status": "pending", "blockedBy": [2]}, + {"id": 8, "subject": "Task 8: Docs, register row 119, rig comment, CLAUDE.md", "status": "pending"}, + {"id": 9, "subject": "Task 9: Rig live gate seed docker/seed-handshake-demo.sh", "status": "pending", "blockedBy": [4]}, + {"id": 10, "subject": "Task 10: Integration — build, suites, merge, rig deploy + live gate, Gitea comment", "status": "pending", "blockedBy": [1,2,3,4,5,6,7,8,9]} + ], + "lastUpdated": "2026-09-10T00:00:00Z" +}