Merge branch 'worktree-agent-a6faa2ddb571f8394' into fix/flow-debugger-arch-review

This commit is contained in:
Joseph Doherty
2026-09-06 14:22:32 -04:00
3 changed files with 227 additions and 2 deletions
@@ -532,6 +532,17 @@ public class ScriptActor : ReceiveActor, IWithTimers
_logger.LogDebug(
"Script {Script} on {Instance}: skipping execution, min time between runs not elapsed ({Elapsed} < {Min})",
_scriptName, _instanceName, elapsed, _minTimeBetweenRuns.Value);
// Arch-review F7: a TRACED run refused here is otherwise invisible to the Flow
// Debugger, which then blames a missing trigger. Leave a Failed ScriptRun row
// naming the guard instead.
if (trace)
{
ScriptRunLauncher.EmitSuppressedRun(
_serviceProvider, _logger, _scriptName, _instanceName, parentExecutionId,
$"suppressed: MinTimeBetweenRuns ({_minTimeBetweenRuns.Value}) not elapsed " +
$"since the previous run ({elapsed} ago).",
triggerExtra);
}
return;
}
}
@@ -574,7 +585,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
{
if (callDepth == 0 && _runsInFlight >= _options.MaxConcurrentRunsPerScript)
{
ShedRun(replyTo, correlationId);
ShedRun(replyTo, correlationId, parentExecutionId, trace, triggerExtra);
return;
}
@@ -649,8 +660,18 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// an unrelated script's run) gets an explicit error so the caller fails fast rather than
/// hanging to its Ask timeout. Nested (<c>callDepth &gt; 0</c>) launches never reach here —
/// see <see cref="SpawnExecution"/>.
///
/// <para>Arch-review F7: a traced run shed here also leaves a Failed <c>ScriptRun</c> audit
/// row, so the Flow Debugger names the cap rather than reporting a missing trigger. The
/// trigger detail (<paramref name="triggerExtra"/>) rides along so the row still says which
/// attribute change would have fired the run.</para>
/// </summary>
private void ShedRun(IActorRef replyTo, string correlationId)
private void ShedRun(
IActorRef replyTo,
string correlationId,
Guid? parentExecutionId = null,
bool trace = false,
ScriptTraceExtra? triggerExtra = null)
{
_healthCollector?.IncrementScriptRunShed();
@@ -675,6 +696,15 @@ public class ScriptActor : ReceiveActor, IWithTimers
correlationId, false, null,
$"shed: {_runsInFlight} runs already in flight"));
}
if (trace)
{
ScriptRunLauncher.EmitSuppressedRun(
_serviceProvider, _logger, _scriptName, _instanceName, parentExecutionId,
$"shed: MaxConcurrentRunsPerScript reached — {_runsInFlight} runs already in " +
$"flight (cap {_options.MaxConcurrentRunsPerScript}).",
triggerExtra);
}
}
private void HandleExecutionCompleted(ScriptExecutionCompleted msg)
@@ -515,6 +515,50 @@ internal static class ScriptRunLauncher
}
}
/// <summary>
/// Arch-review F7: a traced run that never launched (shed at the in-flight cap, or
/// suppressed by <c>MinTimeBetweenRuns</c>) still leaves a <c>Failed</c>
/// <c>ScriptTrace.ScriptRun</c> row, so the Flow Debugger explains the gap with the guard
/// that refused the run instead of reporting <c>NO_TRIGGER_WITHIN_WINDOW</c> — which reads
/// as "no value-change trigger on this attribute, or no device echo" and sends the operator
/// to check the wrong things.
///
/// <para>There is no collector: a shed run has no reply to ride on (an Ask caller gets the
/// shed <c>ScriptCallResult</c> from <c>ScriptActor.ShedRun</c>, not a trace list), so the
/// row goes to the audit writer only and reaches the debugger via <c>PullTraceEvents</c> /
/// Replay. The writer and identity providers come from a fresh scope because this runs on
/// the ACTOR thread, outside any launch scope. <c>elapsedMs</c> is 0 — the run never ran.
/// Best-effort and never throws: a no-op when there is no provider or no writer.</para>
/// </summary>
internal static void EmitSuppressedRun(
IServiceProvider? serviceProvider,
ILogger logger,
string scriptName,
string instanceName,
Guid? parentExecutionId,
string reason,
ScriptTraceExtra? triggerExtra)
{
if (serviceProvider is null) return;
try
{
using var scope = serviceProvider.CreateScope();
var writer = scope.ServiceProvider.GetService<IAuditWriter>();
if (writer is null) return;
var siteId = scope.ServiceProvider.GetService<ISiteIdentityProvider>()?.SiteId ?? string.Empty;
var sourceNode = scope.ServiceProvider.GetService<INodeIdentityProvider>()?.NodeName;
EmitScriptRun(
trace: true, collector: null, writer, logger, scriptName, instanceName, siteId, sourceNode,
executionId: Guid.NewGuid(), parentExecutionId, DateTime.UtcNow, elapsedMs: 0,
parameters: null, returnValue: null, error: reason, triggerExtra);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Flow trace suppressed-run emission failed for {Script}", scriptName);
}
}
/// <summary>
/// Flow Debugger (design 2026-09-05 §5.1): emits the <c>ScriptTrace.ScriptRun</c> row for a
/// traced run — collector first (so the inline reply carries it), then the best-effort