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

1293 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Reset-Aware Handshake (`WriteBatchAndWaitAsync` overload) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Close Gitea #36 in the repo — a `WriteBatchAndWaitAsync` overload that writes a reset value, waits for the device to echo it, and only then writes the batch + trigger and waits for the response, returning a phase-aware `HandshakeResult`; plus mirrors, predictor support, tests, docs and a rig live gate.
**Architecture:** The overload is a *composition* inside `ScriptRuntimeContext` of the four existing legs (`SetAttribute` → `WaitInternal` → batch Ask → `WaitInternal`); the `InstanceActor` waiter and every wire contract are unchanged. `AttributeAccessor` exposes it to scripts; `ScriptCompileSurface` (deploy gate), `SandboxScriptHost` (Test Run editor) and `FlowShapePredictor` (Flow Debugger prediction) mirror it. Design: `docs/plans/2026-09-10-handshake-reset-echo-design.md`.
**Tech Stack:** .NET 10 / C#, Akka.NET (TestKit + `TestProbe` in tests), Roslyn syntax walker (predictor), xunit, bash + ScadaBridge CLI for the rig seed.
**Working copy:** memory says never work in the shared primary checkout. Create a dedicated worktree once, before Task 1:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge
git worktree add -b issue-36-handshake-reset ../ScadaBridge-issue36 main
```
Every `dotnet`/`git` command below runs with `cd /Users/dohertj2/Desktop/ScadaBridge-issue36` first (Bash cwd drifts — always `cd` back). Do **not** use `git stash` (memory: `refs/stash` is shared across worktrees). The rig deploy (Task 10) happens from the primary checkout after a fast-forward merge — `docker/deploy.sh` refuses to run from a worktree path.
**Test commands (targeted, per memory "targeted tests, not full suite"):**
```bash
SLN=ZB.MOM.WW.ScadaBridge.slnx
dotnet build $SLN 2>&1 | tail -3 # full build only at Task 10
dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~<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**
```csharp
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**
```csharp
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:
```markdown
`ResetFailed` (`!Matched && Phase == Reset`) is a computed convenience: script bodies import only
`System`, `System.Collections.Generic`, `System.Linq` and `System.Threading.Tasks`, so a script
branching on the enum would have to spell `ZB.MOM.WW.ScadaBridge.Commons.Types.HandshakePhase`.
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~HandshakeResultTests" 2>&1 | tail -5`
Expected: `Passed! - Failed: 0, Passed: 1`
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/HandshakeResult.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/HandshakeResultTests.cs docs/plans/2026-09-10-handshake-reset-echo-design.md
git commit -m "feat(commons): HandshakePhase + HandshakeResult for the reset-aware handshake (#36)"
```
---
### Task 2: `ScriptRuntimeContext.WriteBatchResetAndWait` (probe-level tests)
**Classification:** high-risk (script runtime hot path; ordering is the whole fix)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs:964-1007` (`WriteBatchAndWait`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/WriteBatchResetAndWaitTests.cs` (create)
**Step 1: Write the failing tests**
The probe plays the InstanceActor so message ORDER is observable. `ExpectNoMsg` after a failed reset is the assertion that matters most: the batch must never be sent.
```csharp
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <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:
```csharp
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**
```bash
git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/WriteBatchResetAndWaitTests.cs
git commit -m "feat(site-runtime): WriteBatchResetAndWait — reset write + echo wait gate the batch/trigger (#36)"
```
---
### Task 3: End-to-end regression test over the real `InstanceActor` (issue item 3)
**Classification:** high-risk (pins the actual #36 race against the real waiter + DCL ingest path)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 4, Task 5, Task 6 (test-only file; needs Task 2)
**Files:**
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs` (create)
**Step 1: Write the tests**
Model on `InstanceActorWaitForAttributeTests` (fixture) + `InstanceActorSetAttributeTests` (DCL probe answering `WriteTagRequest`). Two data-sourced Booleans on one connection, cache driven to the codec-`True` a previous run leaves behind, then the handshake through a real `ScriptRuntimeContext`.
```csharp
using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <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**
```bash
git add tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs
git commit -m "test(site-runtime): pin #36 — reset-aware handshake holds the batch until the reset echo lands"
```
---
### Task 4: `AttributeAccessor` overload + `ScriptCompileSurface` mirror
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 3, Task 5, Task 6
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs:99-112` (after `WriteBatchAndWaitAsync`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs:326-334` (after the batch mirror)
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs` (existing; no edit)
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs` (add one test)
**Step 1: Add the accessor overload first, then run parity — it must FAIL**
In `ScopeAccessors.cs`, directly after the existing `WriteBatchAndWaitAsync`:
```csharp
/// <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`:
```csharp
/// <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`.
```csharp
[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**
```bash
git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs
git commit -m "feat(scripts): reset-aware Attributes.WriteBatchAndWaitAsync overload + deploy-gate mirror (#36)"
```
---
### Task 5: `SandboxScriptHost` mirror + Central UI compile-clean test
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 3, Task 4, Task 6
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs:462-465` (after the batch stand-in)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs:752-770` (`InstanceScript_BatchAndWaitHelpers_DiagnoseClean`)
**Step 1: Extend the failing test**
In `InstanceScript_BatchAndWaitHelpers_DiagnoseClean`, add the nine-argument form to `code` and use its result:
```csharp
"var hs = await Attributes.WriteBatchAndWaitAsync(data, \"Flag\", true, \"Done\", true, System.TimeSpan.FromSeconds(5), \"Done\", false, System.TimeSpan.FromSeconds(2));\n" +
"var hs2 = await Attributes.WriteBatchAndWaitAsync(data, \"Flag\", true, \"Done\", true, System.TimeSpan.FromSeconds(5), \"Done\", false);\n" +
"return done && hit && pred && res.Matched && hs.Matched && !hs2.ResetFailed;";
```
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~InstanceScript_BatchAndWaitHelpers_DiagnoseClean" 2>&1 | tail -5`
Expected: FAIL with a `CS1501` (no overload takes 8/9 arguments) marker.
**Step 2: Add the sandbox stand-in**
After the existing `WriteBatchAndWaitAsync` in `SandboxScriptHost.cs`:
```csharp
/// <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**
```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs
git commit -m "feat(central-ui): Test Run sandbox mirrors the reset-aware WriteBatchAndWaitAsync (#36)"
```
---
### Task 6: `FlowShapePredictor` — nine-argument form predicts the reset legs
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 3, Task 4, Task 5
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs:370-378` (`case "WriteBatchAndWaitAsync"`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs`
**Step 1: Write the failing test**
```csharp
/// <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:
```csharp
case "WriteBatchAndWaitAsync":
{
// (batch, triggerAttr, triggerValue, waitAttr, waitValue, timeout
// [, resetAttr, resetValue, resetTimeout]) — Gitea #36: the reset-aware
// overload writes + waits on the reset attribute BEFORE the batch.
var (trigger, triggerDependent) = LiteralOrExpr(args.ElementAtOrDefault(1)?.Expression);
var (wait, waitDependent) = LiteralOrExpr(args.ElementAtOrDefault(3)?.Expression);
var resetArg = ArgAt(args, 6, "resetKey");
if (resetArg is not null)
{
var (reset, resetDependent) = LiteralOrExpr(resetArg.Expression);
_b.AddNode(FlowStepKind.AttributeWrite, FlowLane.Tags, Qualify(reset), resetDependent, "reset");
_b.AddNode(FlowStepKind.AttributeWait, FlowLane.Site, Qualify(reset), resetDependent, "reset echo");
}
_b.AddNode(FlowStepKind.AttributeWrite, FlowLane.Tags, Qualify($"batch + {trigger}"), triggerDependent, null);
_b.AddNode(FlowStepKind.AttributeWait, FlowLane.Site, Qualify(wait), waitDependent, null);
return true;
}
```
and add beside `LiteralOrExpr`:
```csharp
/// <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**
```bash
git add src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs
git commit -m "feat(flow-debugger): predict the reset write + echo wait of the reset-aware handshake (#36)"
```
---
### Task 7: Trace emission — a traced reset-aware run emits four ordered step rows
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 8, Task 9 (needs Task 2)
**Files:**
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRuntimeContextTraceEmissionTests.cs` (append after `NestedStepScopes_IgnoreTraceDiagnostics_AndUnwind`)
**Step 1: Write the test**
```csharp
/// <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**
```bash
git add tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRuntimeContextTraceEmissionTests.cs
git commit -m "test(site-runtime): traced reset-aware handshake emits four ordered step rows (#36)"
```
---
### Task 8: Docs, register row, rig comment, CLAUDE.md
**Classification:** small (doc-only, several files)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 7, Task 9
**Files:**
- Modify: `docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md` (§5, before line 228 `## 6`)
- Modify: `docs/requirements/Component-ScriptAnalysis.md:182,231`
- Modify: `docs/api-specs/Delmia-Recipe-Download-API.md` (§4, after line 140's code block)
- Modify: `docs/plans/2026-06-16-ipsen-mes-movein-design.md:67` (after item 4)
- Modify: `docker/seed-delmia-sim.sh:10-12` (header comment)
- Modify: `docs/plans/2026-07-08-deferred-work-register.md` (append row 119)
- Modify: `CLAUDE.md:74` (after the "Explicit supervision strategies" bullet)
**Step 1: Spec §5 addendum** — append as the last bullet of §5:
```markdown
- **Handshake reset ordering (Gitea #36, design `2026-09-10-handshake-reset-echo-design.md`).**
The fast-path above is correct ONLY once the caller's reset has been observed. A script that
writes `CompleteFlag=false` and immediately calls the batch helper races the device echo: the
cache still holds the previous run's `true`, the wait fast-paths in 0 ms, and the script reads
stale results. An edge-triggered wait is NOT the fix (a subscription can swallow the reset when
the device flips inside one sampling interval); the reset must be echoed BEFORE the trigger is
written. Use the reset-aware `Attributes.WriteBatchAndWaitAsync(…, resetKey, resetValue,
resetTimeout)` overload, which returns a `HandshakeResult` (`ResetFailed` ⇒ nothing else was
written).
```
**Step 2: Component-ScriptAnalysis.md** — in line 182's parenthetical list add "including the reset-aware nine-argument overload" after `Attributes.WriteBatchAndWaitAsync`; in line 231 after `…Attributes.WriteBatchAndWaitAsync` add "(the reset-aware overload additionally yields a reset `AttributeWrite` + `AttributeWait` pair ahead of the batch)".
**Step 3: Delmia API spec** — after the §4 script code block (line ~145) add:
```markdown
> **Known race (Gitea #36, 2026-09-10).** Step 2's `RecipeProcessedFlag = false` is a device
> write whose echo has not arrived when step 3's wait registers, so the wait can be satisfied by
> the PREVIOUS download's cached `true`. The runtime fix is the reset-aware overload —
> `WriteBatchAndWaitAsync(data, "RecipeDownloadFlag", true, "RecipeProcessedFlag", true, 25 s,
> "RecipeProcessedFlag", false, 10 s)` returning a `HandshakeResult` — which replaces steps 2–3.
> The deployed prod/QA script bodies are NOT yet ported (deferred-work register row 119).
```
**Step 4: Ipsen design** — after item 4 (line 67 block) add item `4a.`:
```markdown
4a. **Handshake ordering (Gitea #36, 2026-09-10):** the reset of `<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:
```bash
# * ProcessRecipeDownload (Call) — prod's handshake ported to per-attribute
# SetAsync/WaitAsync (WriteBatchAndWaitAsync rejects static attributes). The #36
# reset-echo race cannot occur here: a STATIC write updates the cache synchronously,
# so the reset is "echoed" before the trigger is written. The data-sourced proof of
# the reset-aware overload is docker/seed-handshake-demo.sh.
```
**Step 6: Register row 119** — append to the table:
```markdown
| 119 | **Port the QA/prod MES receiver scripts onto the reset-aware `WriteBatchAndWaitAsync` overload (Gitea #36)** *(Medium)*. Template scripts CvdReactor MesMoveIn (13) / MesMoveOut (14) / MesSampleData (36) and base MESReceiver MoveIn/MoveOut (15, 16, 25, 26, 37, 38) still reset `<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:
```markdown
- **Level-triggered flag handshakes must observe the reset echo before raising the trigger (Gitea #36, 2026-09-10).** `Attributes.WriteBatchAndWaitAsync` has a reset-aware overload — `(values, flagKey, flagValue, responseKey, responseValue, timeout, resetKey, resetValue, resetTimeout?)` → `HandshakeResult` (`Matched`/`TimedOut`/`Phase`/`ResetFailed`) — that writes the reset, waits event-driven for the device to echo it, and only then writes the batch + trigger and waits for the response. The six-argument bool overload is unchanged but **unsafe for a repeated handshake**: the waiter's current-value fast-path answers from the previous run's cached response value while the script's reset write is still in flight (QA measured 0 ms matches and stale `BatchID`s). An edge-triggered wait was rejected — a subscription reports changes from the last *reported* value, so a device flipping reset→done inside one sampling interval never reports the reset and such a wait never resolves. Composition only: `ScriptRuntimeContext.WriteBatchResetAndWait` chains the existing `SetAttribute` → waiter → batch → waiter legs, so no actor/message/proto change, and a traced run shows all four steps. Mirrored on `ScriptCompileSurface`, `SandboxScriptHost` and `FlowShapePredictor`. The QA/prod MES script port is register row 119. Design: `docs/plans/2026-09-10-handshake-reset-echo-design.md`.
```
**Step 8: Commit**
```bash
git add docs CLAUDE.md docker/seed-delmia-sim.sh
git commit -m "docs: reset-aware handshake — spec note, component doc, Delmia/Ipsen notes, register row 119, CLAUDE.md (#36)"
```
---
### Task 9: Rig live gate — `docker/seed-handshake-demo.sh`
**Classification:** standard
**Estimated implement time:** ~5 min (authoring; the run itself is Task 10)
**Parallelizable with:** Task 7, Task 8
**Files:**
- Create: `docker/seed-handshake-demo.sh`
**Step 1: Write the seed script**
Modelled on `docker/seed-live-probe.sh` (CLI path/args conventions from `docker/seed-delmia-sim.sh`). Two writable, non-simulated Boolean OPC UA nodes (`ns=3;s=Motor.Running`, `ns=3;s=Pump.Running` — both `CurrentReadOrWrite`; see `docs/test_infra/test_infra_opcua.md`) plus one Double payload node, on site-a connection id 1.
```bash
#!/bin/bash
set -euo pipefail
# Seed a DATA-SOURCED flag handshake on site-a to prove the reset-aware
# Attributes.WriteBatchAndWaitAsync overload (Gitea #36) against a real OPC UA
# subscription — the reset echo must round-trip through the device before the
# trigger is written, and a SECOND run must not fast-path on the first run's
# cached Complete=true.
#
# What it creates (site-a / site id 1, connection id 1 "OPC PLC Simulator"):
# - Template HandshakeDemo
# * Trigger (Boolean) <- ns=3;s=Motor.Running writable, NOT simulated (holds what is written)
# * Complete (Boolean) <- ns=3;s=Pump.Running writable, NOT simulated
# * Payload (Double) <- ns=3;s=Motor.Speed writable, NOT simulated
# * Handshake (Call) — the reset-aware overload; returns the HandshakeResult fields
# * Device (ValueChange on Trigger) — plays the PLC: on Trigger==true sets Complete=true
# and clears Trigger (so the next run's trigger is a fresh edge)
# - Instance handshake-demo-1, bound + deployed
#
# Not idempotent (names must be unique). To re-seed:
# scadabridge instance delete --id <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**
```bash
git add docker/seed-handshake-demo.sh
git commit -m "docker: seed-handshake-demo.sh — live gate for the reset-aware handshake over real OPC UA flags (#36)"
```
---
### Task 10: Integration — full build, suites, merge, rig deploy + live gate, Gitea comment
**Classification:** high-risk (deploys the rig; merges to main)
**Estimated implement time:** ~5 min of commands + rig rebuild wall-time
**Parallelizable with:** none (last)
**Files:** none new (verification + merge)
**Step 1: Full build + the touched suites, in the worktree**
```bash
cd /Users/dohertj2/Desktop/ScadaBridge-issue36
dotnet build ZB.MOM.WW.ScadaBridge.slnx 2>&1 | grep -E "error|Warn|Build succeeded" | tail -5
dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests 2>&1 | tail -3
dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests 2>&1 | tail -3
dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests 2>&1 | tail -3
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~ScriptAnalysis" 2>&1 | tail -3
```
Expected: `0 Error(s)`; every suite `Failed: 0`. Known pre-existing flakes (memory): none in these suites except register row 118 (a CentralUI modal test outside the filter).
**Step 2: Review the whole diff, then fast-forward main**
```bash
git diff main...HEAD --stat
cd /Users/dohertj2/Desktop/ScadaBridge && git status --short # must be clean
git merge --ff-only issue-36-handshake-reset
git log --oneline -12
```
**Step 3: Rebuild the rig from the primary checkout and run the live gate**
```bash
cd /Users/dohertj2/Desktop/ScadaBridge
bash docker/deploy.sh 2>&1 | tail -5
curl -sf http://localhost:9000/health/ready && echo READY
dotnet build src/ZB.MOM.WW.ScadaBridge.CLI 2>&1 | tail -1 # the seed uses the Debug CLI binary
bash docker/seed-handshake-demo.sh
```
Then run the printed `flow-debug run-script` command **twice** and record both outputs. Pass criteria:
- Run 1 and run 2 both return `Matched=true`, `Phase=Response`, `ResetFailed=false`.
- Run 2's trace shows the reset-echo `AttributeWait` step with a non-zero duration (the device's echo, through the subscription), followed by the batch write and a satisfied response wait.
- Negative control: temporarily undeploy nothing — instead run `python infra/tools/opcua_tool.py write --node "ns=3;s=Pump.Running" --value true --type Boolean` to plant a stale `true`, then run the script once more: still `Phase=Response`, `Matched=true` (the reset leg clears it first). If the OPC UA tool is not available, skip and say so.
If the seed fails on a CLI flag, fix the seed script (commit on main) and re-run; if the handshake fails, STOP — do not tune the script; report the trace.
**Step 4: Record the gate and close out**
- Append a short *Live gate 2026-09-10* section to `docs/plans/2026-09-10-handshake-reset-echo-design.md` with the two run results (durations of the four steps on run 2).
- Commit: `git commit -am "docs(#36): rig live gate results for the reset-aware handshake"`.
- Clean up: `git worktree remove ../ScadaBridge-issue36 && git branch -d issue-36-handshake-reset`.
- Comment on the Gitea issue (do NOT close — the QA/prod script port, register row 119, is still open):
```bash
tea comment 36 "Runtime fix landed on main (<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.