feat(site-runtime): WriteBatchResetAndWait — reset write + echo wait gate the batch/trigger (#36)

Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
This commit is contained in:
Joseph Doherty
2026-09-10 15:22:29 -04:00
parent e5c536c6b7
commit f7504a9499
2 changed files with 249 additions and 1 deletions
@@ -964,6 +964,89 @@ public class ScriptRuntimeContext
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>
/// <param name="encodedValues">Scope-resolved attribute name → codec-encoded value to batch-write.</param>
/// <param name="triggerAttr">Scope-resolved trigger attribute name written AFTER the batch.</param>
/// <param name="triggerEncoded">Codec-encoded value for the trigger.</param>
/// <param name="responseAttr">Scope-resolved attribute to wait on.</param>
/// <param name="responseEncoded">Codec-encoded target value (null ⇒ any change).</param>
/// <param name="timeout">How long to wait for the response before returning a timed-out result.</param>
/// <returns>The full response wait result — never throws on a wait timeout.</returns>
private async Task<WaitForAttributeResponse> WriteBatchAndWaitCore(
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
string responseAttr, string? responseEncoded, TimeSpan timeout)
{
var cid = Guid.NewGuid().ToString();
var batchReq = new WriteAttributeBatchRequest(
@@ -1003,7 +1086,8 @@ public class ScriptRuntimeContext
throw new InvalidOperationException(
$"WriteBatchAndWait write failed: {batchResp.ErrorMessage}");
return await WaitAttribute(responseAttr, responseEncoded, null, timeout).ConfigureAwait(false);
return await WaitInternal(responseAttr, responseEncoded, null, timeout, requireGoodQuality: false)
.ConfigureAwait(false);
}
/// <summary>