Files
ScadaBridge/docs/plans/2026-09-10-handshake-reset-echo.md

70 KiB
Raw Permalink Blame History

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 — add ResetFailed, 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 (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:

    /// <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 after NestedStepScopes_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 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):
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.