fix(site-runtime): seed republished overrides into Script Actors without firing scripts; alarms still evaluate (review 2026-10-21 01/SR-U1, decision M1)

HandleOverridesLoaded now sends children a site-local AttributeValueSeed
(stream still gets the AttributeValueChanged). ScriptActor updates its
snapshot, Conditional truth value and Expression last result (seed-only eval,
never fires); AlarmActor evaluates it as a normal change.
This commit is contained in:
Joseph Doherty
2026-10-22 09:03:10 -04:00
parent 999653a6b9
commit 3c11510c52
6 changed files with 174 additions and 14 deletions
@@ -117,6 +117,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
// against the latest snapshot. Both flags are touched only on the actor thread.
private bool _evalInFlight;
private bool _evalPending;
// 01/SR-U1: true when the pending evaluation was requested only by seeds (never fires).
private bool _evalPendingSeedOnly;
// WhileTrue trigger state: the most recent truth value of a Conditional
// trigger's comparison, used to detect false->true / true->false edges.
@@ -205,6 +207,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
// Handle attribute value changes for value-change and conditional triggers
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
// Review 2026-10-21 01/SR-U1: a value seeded at instance start updates trigger state only.
Receive<Messages.AttributeValueSeed>(HandleAttributeValueSeed);
// Handle interval tick
Receive<IntervalTick>(_ => TrySpawnExecution(null));
@@ -338,6 +342,31 @@ public class ScriptActor : ReceiveActor, IWithTimers
}
}
/// <summary>
/// Review 2026-10-21 01/SR-U1 (user decision M1): adopts a value the Instance Actor seeded at
/// start (a persisted static override loaded after this actor was created with the deploy
/// default) WITHOUT launching a run. The snapshot and the trigger's edge state are brought up
/// to date — a Conditional's truth value, an Expression's last result (evaluated, never fired) —
/// so the NEXT real change is judged against the real value: no spurious edge from the replay,
/// and no missed edge afterwards. A WhileTrue trigger whose condition already holds is seeded
/// true without starting its re-fire timer; it fires on its next false→true edge.
/// </summary>
private void HandleAttributeValueSeed(Messages.AttributeValueSeed seed)
{
var changed = seed.Change;
_attributeSnapshot[changed.AttributeName] = changed.Value;
if (_triggerConfig is ConditionalTriggerConfig conditional &&
conditional.AttributeName == changed.AttributeName)
{
_conditionState = EvaluateCondition(conditional, changed.Value);
}
else if (_triggerConfig is ExpressionTriggerConfig)
{
StartExpressionEvaluation(seedOnly: true);
}
}
/// <summary>
/// Starts an off-dispatcher evaluation of the compiled trigger expression (P1).
/// The expression previously ran synchronously on the actor's dispatcher thread
@@ -359,10 +388,20 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// the work is queued — so gate-wait time burns the same budget and a saturated gate
/// yields a timely false instead of an unbounded stall.</para>
/// </summary>
private void StartExpressionEvaluation()
/// <param name="seedOnly">
/// 01/SR-U1: the result only seeds <see cref="_lastExpressionResult"/> and never fires. A
/// coalesced pending evaluation stays seed-only unless a real change also asked for it.
/// </param>
private void StartExpressionEvaluation(bool seedOnly = false)
{
if (_compiledTriggerExpression == null || _triggerConfig is not ExpressionTriggerConfig) return;
if (_evalInFlight) { _evalPending = true; return; } // coalesce bursts: one in flight, one pending
if (_evalInFlight)
{
// coalesce bursts: one in flight, one pending (seed-only only if every request was)
_evalPendingSeedOnly = _evalPending ? _evalPendingSeedOnly && seedOnly : seedOnly;
_evalPending = true;
return;
}
_evalInFlight = true;
var snapshot = new Dictionary<string, object?>(_attributeSnapshot); // point-in-time copy, actor thread
@@ -407,7 +446,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
cts.Dispose();
}
}).PipeTo(self,
success: r => new ExpressionEvalResult(r),
success: r => new ExpressionEvalResult(r, seedOnly),
failure: ex => new ExpressionEvalFailed(ex));
}
@@ -422,13 +461,21 @@ public class ScriptActor : ReceiveActor, IWithTimers
_evalInFlight = false;
if (_triggerConfig is ExpressionTriggerConfig exprConfig)
{
if (exprConfig.Mode == TriggerMode.WhileTrue)
if (msg.SeedOnly)
{
// 01/SR-U1: a seed adopts the truth value and never fires.
}
else if (exprConfig.Mode == TriggerMode.WhileTrue)
HandleWhileTrueTransition(msg.Result, _lastExpressionResult);
else if (msg.Result && !_lastExpressionResult)
TrySpawnExecution(null);
_lastExpressionResult = msg.Result;
}
if (_evalPending) { _evalPending = false; StartExpressionEvaluation(); }
if (_evalPending)
{
_evalPending = false;
StartExpressionEvaluation(_evalPendingSeedOnly);
}
}
/// <summary>
@@ -873,7 +920,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// carries the boolean truth value so the OnTrue/WhileTrue edge logic runs on
/// the actor thread.
/// </summary>
private sealed record ExpressionEvalResult(bool Result);
private sealed record ExpressionEvalResult(bool Result, bool SeedOnly = false);
/// <summary>
/// Piped back to self when the off-dispatcher evaluation TASK itself faults