Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
70 KiB
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:
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"):
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~<Class>" 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 — addResetFailed, see Step 3 note)
Step 1: Write the failing test
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Gitea #36: <see cref="HandshakeResult"/> lets a script tell WHICH leg of a reset-aware
/// handshake failed without naming the <see cref="HandshakePhase"/> enum (script bodies
/// import only System/System.Collections.Generic/System.Linq/System.Threading.Tasks).
/// </summary>
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
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
/// <summary>
/// Which leg of a reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> handshake a
/// <see cref="HandshakeResult"/> describes (Gitea #36, design 2026-09-10 §4).
/// </summary>
public enum HandshakePhase
{
/// <summary>The reset write + reset-echo wait that precedes the batch and trigger.</summary>
Reset,
/// <summary>The batch + trigger write and the response wait that follow an observed reset echo.</summary>
Response,
}
/// <summary>
/// Result of the reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> overload (Gitea #36):
/// like <see cref="WaitResult"/>, plus the <see cref="Phase"/> 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.
/// </summary>
/// <param name="Matched">
/// <see langword="true"/> when the response attribute reached its target within the timeout
/// (which implies the reset echo was observed first).
/// </param>
/// <param name="Value">The matched value of the phase's attribute; <see langword="null"/> on timeout / error.</param>
/// <param name="Quality">The attribute quality at match time; <see langword="null"/> on the non-match paths.</param>
/// <param name="TimedOut"><see langword="true"/> when the phase's wait timed out before a match.</param>
/// <param name="Phase">The leg this result describes — <see cref="HandshakePhase.Reset"/> only when that leg failed.</param>
/// <param name="ErrorMessage">Why the phase did not match, when known; <see langword="null"/> on success.</param>
public readonly record struct HandshakeResult(
bool Matched,
object? Value,
string? Quality,
bool TimedOut,
HandshakePhase Phase,
string? ErrorMessage)
{
/// <summary>
/// <see langword="true"/> 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 <see cref="HandshakePhase"/> type name, which
/// is not in the script imports.
/// </summary>
public bool ResetFailed => !Matched && Phase == HandshakePhase.Reset;
}
Also append to design doc §4, after the HandshakeResult code block:
`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
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.
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;
/// <summary>
/// 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.
/// </summary>
public class WriteBatchResetAndWaitTests : TestKit
{
private const string Instance = "Plant.Reactor1";
private static readonly IReadOnlyDictionary<string, string?> Values =
new Dictionary<string, string?> { ["BatchID"] = "28950" };
private ScriptRuntimeContext CreateContext(IActorRef instanceActor) =>
new(instanceActor,
new SharedScriptLibrary(
new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
NullLogger<SharedScriptLibrary>.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<SetStaticAttributeCommand>(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<WaitForAttributeRequest>(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<WriteAttributeBatchRequest>(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<WaitForAttributeRequest>(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<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(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<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false));
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
var respWait = probe.ExpectMsg<WaitForAttributeRequest>(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<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", false, "device rejected", DateTimeOffset.UtcNow));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => call);
Assert.Contains("device rejected", ex.Message);
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>The bool overload is untouched: batch first, then the wait, no reset leg.</summary>
[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<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
var wait = probe.ExpectMsg<WaitForAttributeRequest>(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:
public async Task<bool> WriteBatchAndWait(
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
string responseAttr, string? responseEncoded, TimeSpan timeout)
=> (await WriteBatchAndWaitCore(encodedValues, triggerAttr, triggerEncoded, responseAttr, responseEncoded, timeout)
.ConfigureAwait(false)).Matched;
/// <summary>
/// Reset-aware handshake (Gitea #36, design 2026-09-10 §5) backing the nine-argument
/// <c>Attributes.WriteBatchAndWaitAsync</c> overload. Legs, in a FIXED order:
/// (1) write <paramref name="resetAttr"/>=<paramref name="resetEncoded"/> through
/// <see cref="SetAttribute"/> (throws on a device-write failure, like the batch leg);
/// (2) wait event-driven for the device to ECHO the reset, bounded by
/// <paramref name="resetTimeout"/> — not matched ⇒ return a <see cref="HandshakePhase.Reset"/>
/// result and write NOTHING else; (3) the existing batch + trigger write; (4) the existing
/// response wait bounded by <paramref name="timeout"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="encodedValues">Scope-resolved attribute name → codec-encoded value to batch-write.</param>
/// <param name="triggerAttr">Scope-resolved trigger attribute written AFTER the batch.</param>
/// <param name="triggerEncoded">Codec-encoded trigger value.</param>
/// <param name="responseAttr">Scope-resolved attribute to wait on after the trigger.</param>
/// <param name="responseEncoded">Codec-encoded response target (null ⇒ any change).</param>
/// <param name="timeout">Bound for the response wait.</param>
/// <param name="resetAttr">Scope-resolved attribute written FIRST (usually the response attribute).</param>
/// <param name="resetEncoded">Codec-encoded reset value the device must echo before anything else is written.</param>
/// <param name="resetTimeout">Bound for the reset-echo wait.</param>
/// <returns>A phase-aware result; never throws on either timeout.</returns>
public async Task<HandshakeResult> WriteBatchResetAndWait(
IReadOnlyDictionary<string, string?> 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"));
}
/// <summary>
/// Shared body of <see cref="WriteBatchAndWait"/> / <see cref="WriteBatchResetAndWait"/>:
/// the batch + trigger Ask (throws on failure) followed by the response wait, returning
/// the full <see cref="WaitForAttributeResponse"/> so the reset-aware caller can surface
/// value/quality/timeout.
/// </summary>
private async Task<WaitForAttributeResponse> WriteBatchAndWaitCore(
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
string responseAttr, string? responseEncoded, TimeSpan timeout)
{
// <existing body of the old WriteBatchAndWait, verbatim, from `var cid = …` through the
// `if (!batchResp.Success) throw …;` — then replace the last line with:>
return await WaitInternal(responseAttr, responseEncoded, null, timeout, requireGoodQuality: false)
.ConfigureAwait(false);
}
Move the old method's XML <summary> 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
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.
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;
/// <summary>
/// Gitea #36 end-to-end over the REAL <see cref="InstanceActor"/> waiter and DCL ingest path:
/// the cache still holds the previous run's <c>CompleteFlag = True</c>, 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 <c>True</c>.
/// </summary>
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<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.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<InstanceActor>.Instance, dcl.Ref)));
dcl.ExpectMsg<SubscribeTagsRequest>(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<string, string?> Values(int batchId)
=> new Dictionary<string, string?> { ["MoveOutBatchID"] = AttributeValueCodec.Encode(batchId) };
/// <summary>The defect, as documentation: the legacy overload answers from the stale cache.</summary>
[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<WriteTagBatchRequest>(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<WriteTagRequest>(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<WriteTagBatchRequest>(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<WriteTagRequest>(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
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(afterWriteBatchAndWaitAsync) - 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:
/// <summary>
/// Reset-aware handshake (Gitea #36): writes <paramref name="resetKey"/>=<paramref name="resetValue"/>,
/// waits (event-driven, bounded by <paramref name="resetTimeout"/>, default = <paramref name="timeout"/>)
/// for the device to ECHO that reset, and only then performs the ordinary
/// <see cref="WriteBatchAndWaitAsync(IReadOnlyDictionary{string, object?}, string, object?, string, object?, TimeSpan)"/>
/// legs (batch → trigger → response wait). Returns a <see cref="HandshakeResult"/> so a
/// script can tell a reset-echo timeout (<see cref="HandshakeResult.ResetFailed"/>; 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
/// <see cref="System.InvalidOperationException"/> if the reset write or the batch/trigger
/// write itself fails; never throws on either timeout.
/// </summary>
/// <param name="values">Attribute key → value to batch-write (data-sourced, one connection).</param>
/// <param name="flagKey">Trigger attribute key written AFTER the batch.</param>
/// <param name="flagValue">Value to write to the trigger.</param>
/// <param name="responseKey">Attribute key to wait on after the trigger.</param>
/// <param name="responseValue">Target value to wait for (<c>null</c> ⇒ any change).</param>
/// <param name="timeout">How long to wait for the response before giving up.</param>
/// <param name="resetKey">Attribute written FIRST (usually <paramref name="responseKey"/>); static or data-sourced.</param>
/// <param name="resetValue">Value the device must echo back before the batch is written.</param>
/// <param name="resetTimeout">How long to wait for the reset echo; <c>null</c> ⇒ <paramref name="timeout"/>.</param>
/// <returns>The phase-aware handshake outcome.</returns>
public Task<HandshakeResult> WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object?> values, string flagKey, object? flagValue,
string responseKey, object? responseValue, TimeSpan timeout,
string resetKey, object? resetValue, TimeSpan? resetTimeout = null)
{
var encoded = new Dictionary<string, string?>(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:
/// <summary>Mirrors the reset-aware <c>AttributeAccessor.WriteBatchAndWaitAsync</c> overload (Gitea #36).</summary>
/// <param name="values">The set of attribute values to write as a batch.</param>
/// <param name="flagKey">The name of the flag attribute signaling the write.</param>
/// <param name="flagValue">The value written to the flag attribute.</param>
/// <param name="responseKey">The name of the attribute to observe for a response.</param>
/// <param name="responseValue">The value to wait for on the response attribute.</param>
/// <param name="timeout">The maximum time to wait for the response.</param>
/// <param name="resetKey">The attribute written first and waited on before the batch.</param>
/// <param name="resetValue">The reset value the device must echo before the batch is written.</param>
/// <param name="resetTimeout">The maximum time to wait for the reset echo; <c>null</c> means <paramref name="timeout"/>.</param>
/// <returns>The phase-aware handshake outcome.</returns>
public Task<HandshakeResult> WriteBatchAndWaitAsync(IReadOnlyDictionary<string, object?> 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.
[Fact]
public void WriteBatchAndWaitAsync_ResetAware_ResolvesScopeAndDefaultsResetTimeout()
{
var probe = CreateTestProbe();
// <construct the composition-scoped AttributeAccessor for prefix "RightMESReceiver"
// exactly as the neighbouring WriteBatchAndWaitAsync test does>
var call = accessor.WriteBatchAndWaitAsync(
new Dictionary<string, object?> { ["MoveOutBatchID"] = 28950 },
"MoveOutFlag", true, "MoveOutCompleteFlag", true, TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", false);
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(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<WaitForAttributeRequest>(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
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:
"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:
/// <summary>
/// Sandbox stand-in for the reset-aware <c>AttributeAccessor.WriteBatchAndWaitAsync</c>
/// overload (Gitea #36): present for editor/compile parity, throws
/// <see cref="ScriptSandboxException"/> when run in Test Run (no device batch-write transport).
/// </summary>
/// <param name="values">The attribute values to write as a batch.</param>
/// <param name="flagKey">The flag attribute key to set to signal completion.</param>
/// <param name="flagValue">The value to set on the flag attribute.</param>
/// <param name="responseKey">The attribute key to observe for the device's response.</param>
/// <param name="responseValue">The response value to wait for.</param>
/// <param name="timeout">The maximum time to wait for the response.</param>
/// <param name="resetKey">The attribute written first and waited on before the batch.</param>
/// <param name="resetValue">The reset value the device must echo before the batch is written.</param>
/// <param name="resetTimeout">The maximum time to wait for the reset echo; <c>null</c> means <paramref name="timeout"/>.</param>
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
public Task<HandshakeResult> WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object?> 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
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
/// <summary>Gitea #36: the reset-aware overload predicts the reset write + echo wait ahead of the batch.</summary>
[Fact]
public void ResetAwareWriteBatchAndWait_PredictsResetWriteAndEchoWaitBeforeTheBatch()
{
const string body = """
var data = new System.Collections.Generic.Dictionary<string, object?> { { "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);
}
/// <summary>Named reset arguments resolve by name, not position.</summary>
[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:
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:
/// <summary>An argument by name (<c>name: expr</c>) or, failing that, by position among the unnamed arguments.</summary>
private static ArgumentSyntax? ArgAt(SeparatedSyntaxList<ArgumentSyntax> 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<ArgumentSyntax> — see line 317.) Update the <summary> 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
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 afterNestedStepScopes_IgnoreTraceDiagnostics_AndUnwind)
Step 1: Write the test
/// <summary>
/// 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).
/// </summary>
[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<string, string?> { ["Recipe"] = "R-17" },
"Trigger", "1", "Ack", "1", TimeSpan.FromSeconds(3),
"Ack", "0", TimeSpan.FromSeconds(2));
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
Assert.True(reset.Trace);
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, InstanceName, "Ack", true, null, DateTimeOffset.UtcNow));
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, "0", "Good", TimedOut: false));
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
var respWait = probe.ExpectMsg<WaitForAttributeRequest>(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
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:
- **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:
> **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.:
4a. **Handshake ordering (Gitea #36, 2026-09-10):** the reset of `<X>CompleteFlag` must be
ECHOED by the Galaxy object before `<X>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:
# * 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:
| 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 `<X>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, "<X>Flag", true, "<X>CompleteFlag", true, 25 s, "<X>CompleteFlag", false, 10 s); if (!hs.Matched) return new { WasSuccessful=false, ErrorText = hs.ResetFailed ? "Timeout waiting for <X>CompleteFlag to reset" : "Timeout waiting for <X> 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:
- **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
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.
#!/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 <id> && scadabridge template delete --id <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<string, object?> { ["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
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
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
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
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
AttributeWaitstep 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 Booleanto plant a staletrue, then run the script once more: stillPhase=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.mdwith 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):
tea comment 36 "Runtime fix landed on main (<sha>): 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.