using System.Diagnostics; using Akka.Actor; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; using ZB.MOM.WW.ScadaBridge.SiteEventLogging; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.StoreAndForward; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; /// /// WP3.1: launches one script or alarm on-trigger run directly from its owning coordinator /// ( / ), replacing the short-lived /// ScriptExecutionActor and AlarmExecutionActor. /// /// Those actors were already inert shells: neither declared a single Receive /// handler (they executed from their constructor), neither had a PostStop, state, or /// stash, and their IActorRef was never a message target — the whole lifecycle lived /// inside a detached the actor never observed. What they cost was a real /// actor cell, mailbox, and name registration per run, plus a per-spawn expression-tree /// Props.Create. Removing them changes no semantics; the run body below is the former /// ExecuteScript/ExecuteAlarmScript body, unified. /// /// Two behaviours DID change, both deliberately: /// /// The deadline is now armed by the CALLER, /// before the body is queued to the , so queue wait /// consumes the script's own budget. A body that dequeues past its deadline skips execution /// entirely and takes the timeout path — a saturated pool sheds stale work instead of /// running it late. /// The stuck-script watchdog now DETACHES and replaces the worker thread a wedged /// script is holding () instead of /// only naming it, so the pool recovers its capacity. /// /// /// Ordering, supervision, telemetry, DI scoping, Ask replies, completion messages, and /// the audit ExecutionId/ParentExecutionId threading are all unchanged. The /// launch call itself can throw only if the target scheduler is unusable (e.g. disposed); the /// caller wraps it so that failure replies to the Ask caller and decrements the in-flight /// counter rather than escalating to the coordinator's supervisor. /// internal static class ScriptRunLauncher { /// /// Launches an instance script run. Returns as soon as the body is queued; the run reports /// completion to . /// /// Name of the script being executed. /// Name of the instance that owns the script. /// Compiled Roslyn script to execute. /// Optional named parameter values for the script. /// Current call-nesting depth (used to enforce the max-depth limit). /// Instance actor reference for attribute access. /// Library of shared scripts available during execution. /// Site runtime options applied during execution. /// Actor reference that receives the script result; Nobody for fire-and-forget. /// Application-level correlation id threaded through the execution. /// The owning , which receives the completion message. /// Site communication actor (resolved on the actor thread) for Notify.Status. /// Logger for script execution events. /// Script scope controlling which APIs are available. /// Per-script run counter, used only in log messages (replaces the former per-run actor name). /// Optional health collector for recording execution metrics. /// Optional DI service provider for script execution services. /// ExecutionId of the spawning execution for audit correlation; null for root runs. /// Per-script execution timeout in seconds. Null or non-positive falls back to the global value. /// Script-execution scheduler seam (#18); null selects the process-wide shared instance. /// Flow Debugger trace flag (design 2026-09-05 §5.2): when true the run emits ScriptTrace rows and returns them inline on the . /// Flow Debugger: trigger detail for a traced value-change run (stamped into the ScriptRun row's Extra); null for calls. public static void LaunchScript( string scriptName, string instanceName, Script compiledScript, IReadOnlyDictionary? parameters, int callDepth, IActorRef instanceActor, SharedScriptLibrary sharedScriptLibrary, SiteRuntimeOptions options, IActorRef replyTo, string correlationId, IActorRef completionTarget, ICanTell? siteCommunicationActor, ILogger logger, ScriptScope scope, long runId, ISiteHealthCollector? healthCollector, IServiceProvider? serviceProvider, Guid? parentExecutionId, int? executionTimeoutSeconds, ScriptExecutionScheduler? scheduler, bool trace = false, ScriptTraceExtra? triggerExtra = null) { var timeout = ResolveTimeout(executionTimeoutSeconds, options); var executionScheduler = scheduler ?? ScriptExecutionScheduler.Shared(options); // Armed HERE, on the caller's (actor) thread, not inside the queued body: queue wait // must consume the script's own budget, otherwise a saturated pool silently grants // every backlogged run a fresh full timeout. var cts = new CancellationTokenSource(timeout); try { _ = Task.Factory.StartNew( () => RunScriptAsync( scriptName, instanceName, compiledScript, parameters, callDepth, instanceActor, sharedScriptLibrary, options, replyTo, correlationId, completionTarget, siteCommunicationActor, logger, scope, runId, healthCollector, serviceProvider, parentExecutionId, timeout, executionScheduler, cts, trace, triggerExtra), CancellationToken.None, TaskCreationOptions.DenyChildAttach, executionScheduler) .Unwrap(); } catch { // The body never got queued, so nothing will ever dispose the CTS. cts.Dispose(); throw; } } /// /// Launches an alarm on-trigger run. Same contract as , with the /// firing alarm's level/priority/message exposed to the body through the Alarm global. /// /// The canonical name of the alarm that triggered. /// The name of the owning instance. /// The alarm severity level at the time of triggering. /// The alarm priority value. /// The alarm message to pass to the script. /// The pre-compiled on-trigger script to execute. /// Reference to the instance actor for attribute/script calls. /// Shared script library providing common utilities. /// Site runtime configuration options, including the execution timeout. /// The owning , which receives the completion message. /// Logger for execution diagnostics. /// /// Optional health collector for recording execution metrics (arch-review 2026-08-19 /// P1-3): the alarm on-trigger run shares the script-error counter with /// so a wedged/failed alarm body is visible the same way a /// wedged/failed instance script is. /// /// /// Optional DI service provider (arch-review 2026-08-19 P1-3): resolves the singleton /// (stuck-watchdog reporting) and /// (aggregate run counters), exactly like the /// instance-script path. /// /// Per-alarm run counter, used only in log messages. /// The on-trigger script's per-script timeout in seconds. Null or non-positive falls back to the global value. /// /// ParentExecutionId tag-cascade: the ExecutionId of the execution whose attribute /// write fired this alarm. Null when the firing value came from the Data Connection Layer /// (external data has no spawning execution) — that on-trigger run is a tree ROOT. /// /// Script-execution scheduler seam (#18); null selects the process-wide shared instance. public static void LaunchAlarmScript( string alarmName, string instanceName, AlarmLevel level, int priority, string message, Script compiledScript, IActorRef instanceActor, SharedScriptLibrary sharedScriptLibrary, SiteRuntimeOptions options, IActorRef completionTarget, ILogger logger, ISiteHealthCollector? healthCollector, IServiceProvider? serviceProvider, long runId, int? executionTimeoutSeconds, Guid? parentExecutionId, ScriptExecutionScheduler? scheduler) { var timeout = ResolveTimeout(executionTimeoutSeconds, options); var executionScheduler = scheduler ?? ScriptExecutionScheduler.Shared(options); // Enqueue-anchored deadline; see LaunchScript. var cts = new CancellationTokenSource(timeout); try { _ = Task.Factory.StartNew( () => RunAlarmScriptAsync( alarmName, instanceName, level, priority, message, compiledScript, instanceActor, sharedScriptLibrary, options, completionTarget, logger, healthCollector, serviceProvider, runId, parentExecutionId, timeout, executionScheduler, cts), CancellationToken.None, TaskCreationOptions.DenyChildAttach, executionScheduler) .Unwrap(); } catch { cts.Dispose(); throw; } } /// /// Per-script timeout overrides the global default. A null or non-positive per-script /// value (≤ 0) falls back to the global. /// private static TimeSpan ResolveTimeout(int? executionTimeoutSeconds, SiteRuntimeOptions options) => TimeSpan.FromSeconds( executionTimeoutSeconds is { } perScript && perScript > 0 ? perScript : options.ScriptExecutionTimeoutSeconds); private static async Task RunScriptAsync( string scriptName, string instanceName, Script compiledScript, IReadOnlyDictionary? parameters, int callDepth, IActorRef instanceActor, SharedScriptLibrary sharedScriptLibrary, SiteRuntimeOptions options, IActorRef replyTo, string correlationId, IActorRef completionTarget, ICanTell? siteCommunicationActor, ILogger logger, ScriptScope scope, long runId, ISiteHealthCollector? healthCollector, IServiceProvider? serviceProvider, Guid? parentExecutionId, TimeSpan timeout, ScriptExecutionScheduler executionScheduler, CancellationTokenSource cts, bool trace = false, ScriptTraceExtra? triggerExtra = null) { IServiceScope? serviceScope = null; // Flow Debugger (design §5.4): the inline sink exists only for a traced run that has a // caller to reply to — a trace-armed trigger run (replyTo == Nobody) writes every row to the // audit writer for PullTraceEvents and would only keep a never-read list alive. The // ScriptRun row itself is emitted below on every exit path of a traced run. // Arch-review F1: the inline copy is redacted at the collector, so the collector carries // the host's IAuditRedactor. That is a SINGLETON (AddAuditLog), resolved here from the // ROOT provider for the same reason siteEventLogger below is — the traced ScriptRun row // is emitted from the catch blocks too, including the shed-at-dequeue path that throws // before the DI scope exists. Null in tests / hosts without AddAuditLog: raw, still capped. var collector = trace && !replyTo.IsNobody() ? new FlowTraceCollector( redactor: serviceProvider?.GetService()) : null; var startedUtc = DateTime.UtcNow; ScriptRuntimeContext? context = null; IAuditWriter? auditWriter = null; var siteId = string.Empty; string? sourceNode = null; // ISiteEventLogger is a singleton; resolve from the root provider so // it is available to the catch blocks regardless of scope state. var siteEventLogger = serviceProvider?.GetService(); // WP3.2 (site_events volume policy): both are singletons, resolved from the root // provider up front so they too are available to the catch blocks. optionsMonitor is // null in tests that pass no serviceProvider (SingleServiceProvider et al.) — the // constructor-passed `options` is the fallback, exactly like siteEventLogger's null // path degrades to "no site event log". Reading CurrentValue HERE (once per run, at // the top) rather than re-reading it at each call site is what "hot-togglable" means // in practice: an operator's live edit to PerRunScriptEvents/PerRunScriptEventScripts // takes effect on the NEXT run, without a restart. var summaryRecorder = serviceProvider?.GetService(); var optionsMonitor = serviceProvider?.GetService>(); var effectiveOptions = optionsMonitor?.CurrentValue ?? options; var emitPerRunEvents = ScriptRunEventPolicy.ShouldEmitPerRun(effectiveOptions, instanceName, scriptName); var runStopwatch = Stopwatch.StartNew(); // WP3.1: identify the worker this body is holding, so the watchdog can detach and // replace it if the body wedges. Read in the FIRST synchronous segment — a script // that later hops threads on an await no longer holds this worker, and the run-stamp // guard in TryDetachWorker will (correctly) refuse to detach it. var workerSlot = ScriptExecutionScheduler.CurrentWorkerSlot; var workerRunStamp = workerSlot is { } slot ? executionScheduler.CurrentRunStamp(slot) : 0L; var completed = 0; ArmStuckScriptWatchdog( cts, options, logger, siteEventLogger, executionScheduler, workerSlot, workerRunStamp, timeout, () => Volatile.Read(ref completed) != 0, $"Script '{scriptName}' on instance '{instanceName}'", instanceName, $"ScriptActor:{scriptName}"); try { // WP3.1 shed-at-dequeue: the deadline was armed at ENQUEUE, so an already-cancelled // token here means this run spent its entire budget queueing. Running it now would // burn a scarce script thread on work that is already stale — throw straight into // the existing timeout path instead, BEFORE the "started" event and before the body. cts.Token.ThrowIfCancellationRequested(); // Resolve integration services from DI (scoped lifetime) IExternalSystemClient? externalSystemClient = null; IDatabaseGateway? databaseGateway = null; // Notification Outbox: the S&F engine is a singleton; the site identity // provider supplies the site id stamped on enqueued notifications. StoreAndForwardService? storeAndForward = null; // The writer is a singleton (FallbackAuditWriter // composes the SQLite hot-path + drop-oldest ring); null in tests / hosts // that haven't called AddAuditLog, which the helper handles as a no-op. // (siteId / auditWriter / sourceNode are declared above the try so the // traced ScriptRun emission in the catch blocks can reach them.) // Site-local tracking store // backing Tracking.Status(id). Singleton; null in tests / hosts // that haven't wired the store, which the helper handles by // throwing on access. IOperationTrackingStore? operationTrackingStore = null; // Site-side cached-call // telemetry forwarder. Singleton bound to the AuditLog // composition root; null in tests / hosts that haven't called // AddAuditLog, in which case the cached-call helpers degrade // to the no-emission path (the underlying S&F handoff still // happens and a TrackedOperationId is still returned). ICachedCallTelemetryForwarder? cachedForwarder = null; // SourceNode-stamping: the local node name // resolved from INodeIdentityProvider — node-a/node-b on site // hosts. Null in tests / hosts that haven't registered the // provider, in which case NotificationSubmit.SourceNode and // SiteCallOperational.SourceNode stay null and central // persists the rows with SourceNode NULL. if (serviceProvider != null) { serviceScope = serviceProvider.CreateScope(); externalSystemClient = serviceScope.ServiceProvider.GetService(); databaseGateway = serviceScope.ServiceProvider.GetService(); storeAndForward = serviceScope.ServiceProvider.GetService(); siteId = serviceScope.ServiceProvider.GetService()?.SiteId ?? string.Empty; auditWriter = serviceScope.ServiceProvider.GetService(); operationTrackingStore = serviceScope.ServiceProvider.GetService(); cachedForwarder = serviceScope.ServiceProvider.GetService(); sourceNode = serviceScope.ServiceProvider.GetService()?.NodeName; } context = new ScriptRuntimeContext( instanceActor, sharedScriptLibrary, callDepth, options.MaxScriptCallDepth, timeout, instanceName, logger, externalSystemClient, databaseGateway, storeAndForward, siteCommunicationActor, siteId, // Notification Outbox (FU3): stamp the executing script onto outbound // notifications using the Site Event Logging "Source" convention. sourceScript: $"ScriptActor:{scriptName}", // Emit one ApiOutbound/ApiCall row per // ExternalSystem.Call. Writer is best-effort; failures are logged // and swallowed inside the helper so the script's call path is // never aborted by an audit failure. auditWriter: auditWriter, // Site-local tracking store // backing Tracking.Status(id). Authoritative source of truth for // cached-call status — read directly by the script API. operationTrackingStore: operationTrackingStore, // Cached-call telemetry // forwarder for ExternalSystem.CachedCall / Database.CachedWrite // CachedSubmit emission + the immediate-success terminal-row // emission. Best-effort: null degrades the helpers to a // no-emission path; the S&F handoff and TrackedOperationId // return are unaffected. cachedForwarder: cachedForwarder, // The spawning execution's // id for an inbound-API-routed call. The routed script still // mints its own fresh ExecutionId — this records the spawner. // Null for normal (tag-change / timer) runs. parentExecutionId: parentExecutionId, // SourceNode-stamping: the local node name // (node-a/node-b on a site) — threaded down so Notify.Send // and the four cached-call telemetry constructors can stamp // it onto NotificationSubmit.SourceNode and // SiteCallOperational.SourceNode respectively. sourceNode: sourceNode, // Thread the singleton site event logger so // recursion-limit violations at CallScript/CallShared emit a // script Error site event in addition to ILogger.LogError. siteEventLogger: siteEventLogger, // WaitForAttribute (spec §4.3/§4.4): thread the per-script // execution-timeout token so Attributes.WaitAsync's Ask is // bounded by the script's own ExecutionTimeoutSeconds — a // shorter script deadline wins over the wait's own timeout. scriptTimeoutToken: cts.Token, // Flow Debugger (design §5.2/§5.4): the trace flag and the inline sink. isTraced: trace, traceCollector: collector); var globals = new ScriptGlobals { Instance = context, Parameters = new ScriptParameters(parameters ?? new Dictionary()), CancellationToken = cts.Token, Scope = scope }; // WP3.2: the aggregate ALWAYS records this run (that's the point of the interval // summary), but the per-run Info event itself is gated by ScriptRunEventPolicy — // off by default, on globally via PerRunScriptEvents, or on for named scripts via // PerRunScriptEventScripts. Fire-and-forget (the `_ =` discards the task) so the // event log can never block or fault the script's own run; mirrors the existing // Error-path emit, which stays unconditional regardless of this policy. summaryRecorder?.RecordStarted(instanceName, scriptName); if (emitPerRunEvents) { _ = siteEventLogger?.LogEventAsync( "script", "Info", instanceName, $"ScriptActor:{scriptName}", $"Script '{scriptName}' on instance '{instanceName}' started"); } var state = await compiledScript.RunAsync(globals, cts.Token); // Flow Debugger: the ScriptRun row goes into the collector BEFORE the reply so the // inline list the caller receives includes the run itself. Complete() (not Snapshot) // closes the sink at the same instant — outbound rows finishing after this point // (plan 2026-09-06 D5) go to the audit writer only. EmitScriptRun(trace, collector, auditWriter, logger, scriptName, instanceName, siteId, sourceNode, context.ExecutionId, parentExecutionId, startedUtc, runStopwatch.ElapsedMilliseconds, parameters, state.ReturnValue, error: null, triggerExtra); // Send result to requester if this was an Ask-based call if (!replyTo.IsNobody()) { replyTo.Tell(new ScriptCallResult( correlationId, true, state.ReturnValue, null, context.ExecutionId, collector?.Complete())); } // Operational `script` event — execution completed successfully. Same gating as // the started event above. summaryRecorder?.RecordCompleted(instanceName, scriptName, runStopwatch.ElapsedMilliseconds); if (emitPerRunEvents) { _ = siteEventLogger?.LogEventAsync( "script", "Info", instanceName, $"ScriptActor:{scriptName}", $"Script '{scriptName}' on instance '{instanceName}' completed"); } // Notify the owning ScriptActor of completion (also releases its in-flight slot). completionTarget.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, true, null)); } catch (OperationCanceledException) { healthCollector?.IncrementScriptError(); summaryRecorder?.RecordTimedOut(instanceName, scriptName, runStopwatch.ElapsedMilliseconds); var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' timed out after {timeout.TotalSeconds}s"; logger.LogWarning("{Message} (run {RunId})", errorMsg, runId); // Failures recorded to site event log; script NOT disabled after failure. WP3.2: // Error-level emissions are unconditional — never gated by ScriptRunEventPolicy. _ = siteEventLogger?.LogEventAsync( "script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg); EmitScriptRun(trace, collector, auditWriter, logger, scriptName, instanceName, siteId, sourceNode, context?.ExecutionId, parentExecutionId, startedUtc, runStopwatch.ElapsedMilliseconds, parameters, null, errorMsg, triggerExtra); if (!replyTo.IsNobody()) { replyTo.Tell(new ScriptCallResult( correlationId, false, null, errorMsg, context?.ExecutionId, collector?.Complete())); } completionTarget.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg)); } catch (Exception ex) { healthCollector?.IncrementScriptError(); summaryRecorder?.RecordFailed(instanceName, scriptName, runStopwatch.ElapsedMilliseconds); // Failures recorded to site event log; script NOT disabled after failure. WP3.2: // Error-level emissions are unconditional — never gated by ScriptRunEventPolicy. var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' failed: {ex.Message}"; logger.LogError(ex, "Script execution failed: {Script} on {Instance} (run {RunId})", scriptName, instanceName, runId); _ = siteEventLogger?.LogEventAsync( "script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg, ex.ToString()); EmitScriptRun(trace, collector, auditWriter, logger, scriptName, instanceName, siteId, sourceNode, context?.ExecutionId, parentExecutionId, startedUtc, runStopwatch.ElapsedMilliseconds, parameters, null, errorMsg, triggerExtra); if (!replyTo.IsNobody()) { replyTo.Tell(new ScriptCallResult( correlationId, false, null, errorMsg, context?.ExecutionId, collector?.Complete())); } completionTarget.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg)); } finally { // Mark the body finished so the stuck-script watchdog (registered on // cts.Token above) treats a timely cancellation as NOT stuck. Interlocked.Exchange(ref completed, 1); // Dispose the DI scope (and scoped services) after script execution completes serviceScope?.Dispose(); cts.Dispose(); } } /// /// Arch-review F7: a traced run that never launched (shed at the in-flight cap, or /// suppressed by MinTimeBetweenRuns) still leaves a Failed /// ScriptTrace.ScriptRun row, so the Flow Debugger explains the gap with the guard /// that refused the run instead of reporting NO_TRIGGER_WITHIN_WINDOW — which reads /// as "no value-change trigger on this attribute, or no device echo" and sends the operator /// to check the wrong things. /// /// There is no collector: a shed run has no reply to ride on (an Ask caller gets the /// shed ScriptCallResult from ScriptActor.ShedRun, not a trace list), so the /// row goes to the audit writer only and reaches the debugger via PullTraceEvents / /// Replay. The writer and identity providers come from a fresh scope because this runs on /// the ACTOR thread, outside any launch scope. elapsedMs is 0 — the run never ran. /// Best-effort and never throws: a no-op when there is no provider or no writer. /// 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(); if (writer is null) return; var siteId = scope.ServiceProvider.GetService()?.SiteId ?? string.Empty; var sourceNode = scope.ServiceProvider.GetService()?.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); } } /// /// Flow Debugger (design 2026-09-05 §5.1): emits the ScriptTrace.ScriptRun row for a /// traced run — collector first (so the inline reply carries it), then the best-effort /// audit writer. OccurredAtUtc is the run's START so the row sorts before the /// writes/waits the run made. No-op when untraced; never throws. /// private static void EmitScriptRun( bool trace, FlowTraceCollector? collector, IAuditWriter? auditWriter, ILogger logger, string scriptName, string instanceName, string siteId, string? sourceNode, Guid? executionId, Guid? parentExecutionId, DateTime startedUtc, long elapsedMs, IReadOnlyDictionary? parameters, object? returnValue, string? error, ScriptTraceExtra? triggerExtra) { if (!trace) return; try { var sourceScript = $"ScriptActor:{scriptName}"; var evt = ScadaBridgeAuditEventFactory.Create( AuditChannel.ScriptTrace, AuditKind.ScriptRun, error is null ? AuditStatus.Delivered : AuditStatus.Failed, occurredAtUtc: DateTime.SpecifyKind(startedUtc, DateTimeKind.Utc), actor: sourceScript, target: scriptName, sourceNode: sourceNode, executionId: executionId, parentExecutionId: parentExecutionId, sourceSiteId: string.IsNullOrEmpty(siteId) ? null : siteId, sourceInstanceId: instanceName, sourceScript: sourceScript, durationMs: (int)Math.Min(elapsedMs, int.MaxValue), errorMessage: error, requestSummary: TraceJson(parameters), responseSummary: TraceJson(returnValue), extra: (triggerExtra ?? new ScriptTraceExtra { TriggerKind = "call" }).ToJson()); collector?.Add(evt); if (auditWriter is null) return; var t = auditWriter.WriteAsync(evt, CancellationToken.None); if (!t.IsCompletedSuccessfully) { _ = t.ContinueWith( x => logger.LogWarning(x.Exception, "Flow trace ScriptRun write failed for {Script}", scriptName), CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } } catch (Exception ex) { logger.LogWarning(ex, "Flow trace ScriptRun emission failed for {Script}", scriptName); } } private static string? TraceJson(object? value) { if (value is null) return null; try { return JsonSerializer.Serialize(value, ScriptTraceExtra.JsonOptions); } catch (Exception) { return null; } } private static async Task RunAlarmScriptAsync( string alarmName, string instanceName, AlarmLevel level, int priority, string message, Script compiledScript, IActorRef instanceActor, SharedScriptLibrary sharedScriptLibrary, SiteRuntimeOptions options, IActorRef completionTarget, ILogger logger, ISiteHealthCollector? healthCollector, IServiceProvider? serviceProvider, long runId, Guid? parentExecutionId, TimeSpan timeout, ScriptExecutionScheduler executionScheduler, CancellationTokenSource cts) { // arch-review 2026-08-19 P1-3: singletons resolved from the root provider up front, // exactly like RunScriptAsync, so they are available to the catch blocks regardless // of scope state. Null in tests that pass no serviceProvider — degrades the same way // the instance-script path does (no site event log / no aggregate counters). var siteEventLogger = serviceProvider?.GetService(); var summaryRecorder = serviceProvider?.GetService(); // The on-trigger run has no per-script name of its own in the site-event/summary // sense — key it under the alarm, mirroring the "AlarmActor:{name}" source // convention already used for this alarm's other site events. var scriptName = $"AlarmActor:{alarmName}"; var runStopwatch = Stopwatch.StartNew(); // arch-review 2026-08-19 P1-3: identify the worker this body is holding, so the // stuck-script watchdog can detach and replace it if the body wedges — same // mechanism, same gauge, as the instance-script path. Read in the FIRST synchronous // segment, before any await. var workerSlot = ScriptExecutionScheduler.CurrentWorkerSlot; var workerRunStamp = workerSlot is { } slot ? executionScheduler.CurrentRunStamp(slot) : 0L; var completed = 0; ArmStuckScriptWatchdog( cts, options, logger, siteEventLogger, executionScheduler, workerSlot, workerRunStamp, timeout, () => Volatile.Read(ref completed) != 0, $"Alarm on-trigger script for '{alarmName}' on instance '{instanceName}'", instanceName, scriptName); try { // Enqueue-anchored deadline: an already-cancelled token means this on-trigger run // spent its whole budget queueing behind other script bodies. Shed it rather than // run it late — the alarm it would react to is already stale. cts.Token.ThrowIfCancellationRequested(); // An alarm on-trigger run can call Instance.CallScript() // via the ScriptRuntimeContext injected into globals var context = new ScriptRuntimeContext( instanceActor, sharedScriptLibrary, currentCallDepth: 0, options.MaxScriptCallDepth, timeout, instanceName, logger, // ParentExecutionId tag-cascade: the // alarm on-trigger run mints its own fresh ExecutionId (the // ctor's `?? NewGuid()` fallback) and records the firing // execution's id as its ParentExecutionId — null (a root) // only when the firing value came from the DCL. parentExecutionId: parentExecutionId, // WaitForAttribute (spec §4.4): thread the alarm on-trigger // script's per-script execution-timeout token so a // Attributes.WaitAsync inside an on-trigger script is bounded // by the same script deadline. scriptTimeoutToken: cts.Token); var globals = new ScriptGlobals { Instance = context, Parameters = new ScriptParameters(), CancellationToken = cts.Token, Alarm = new AlarmContext { Name = alarmName, Level = level, Priority = priority, Message = message } }; summaryRecorder?.RecordStarted(instanceName, scriptName); await compiledScript.RunAsync(globals, cts.Token); summaryRecorder?.RecordCompleted(instanceName, scriptName, runStopwatch.ElapsedMilliseconds); completionTarget.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, true)); } catch (OperationCanceledException) { healthCollector?.IncrementScriptError(); summaryRecorder?.RecordTimedOut(instanceName, scriptName, runStopwatch.ElapsedMilliseconds); logger.LogWarning( "Alarm on-trigger script for {Alarm} on {Instance} timed out (run {RunId})", alarmName, instanceName, runId); completionTarget.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, false)); } catch (Exception ex) { healthCollector?.IncrementScriptError(); summaryRecorder?.RecordFailed(instanceName, scriptName, runStopwatch.ElapsedMilliseconds); // Failures logged, alarm continues logger.LogError(ex, "Alarm on-trigger script for {Alarm} on {Instance} failed (run {RunId})", alarmName, instanceName, runId); completionTarget.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, false)); } finally { // Mark the body finished so the stuck-script watchdog (registered on // cts.Token above) treats a timely cancellation as NOT stuck. Interlocked.Exchange(ref completed, 1); cts.Dispose(); } } /// /// Arms the stuck-script watchdog (S2, extended by WP3.1). /// /// The CTS firing only REQUESTS cooperative cancellation; it does NOT free a thread /// blocked in synchronous I/O. When the deadline elapses, this waits /// and, if the body still has not /// returned, (a) names the script loudly on the site event log and (b) DETACHES the worker /// thread it is holding and starts a replacement — so a wedged script costs the pool one /// thread only until the watchdog fires, not forever. At the detach cap it logs an Error /// instead of growing threads without bound. /// /// The Task.Run/Task.Delay run on the shared thread pool, never on the /// (possibly saturated) script scheduler — deliberate, and the whole point of the watchdog. /// Register fires on cancellation only; a normally-completing run disposes its CTS /// with no callback. A run cancelled while still QUEUED flips its completed flag in the /// body's finally long before the grace elapses, so it is correctly not reported. /// private static void ArmStuckScriptWatchdog( CancellationTokenSource cts, SiteRuntimeOptions options, ILogger logger, ISiteEventLogger? siteEventLogger, ScriptExecutionScheduler scheduler, int? workerSlot, long workerRunStamp, TimeSpan timeout, Func isCompleted, string subject, string instanceName, string source) { var graceMs = options.StuckScriptGraceMs; cts.Token.Register(() => _ = Task.Run(async () => { await Task.Delay(graceMs); if (isCompleted()) return; var stuckMsg = $"{subject} exceeded its {timeout.TotalSeconds:F0}s timeout and is STILL EXECUTING — " + "its dedicated script-execution thread is blocked (cooperative cancellation not observed)."; var outcome = workerSlot is { } slot ? scheduler.TryDetachWorker(slot, workerRunStamp) : WorkerDetachOutcome.NotRunning; switch (outcome) { case WorkerDetachOutcome.Detached: stuckMsg += " The worker thread has been DETACHED and replaced; it will exit when the body returns."; break; case WorkerDetachOutcome.AtCap: stuckMsg += $" The script-execution pool already holds {scheduler.DetachedThreadCount} detached " + "stuck threads (at cap); NOT replacing this one."; break; case WorkerDetachOutcome.NotRunning: // The body is not on a worker thread (it hopped threads on an await), so no // dedicated thread is lost — report it, but there is nothing to replace. break; } logger.LogError(stuckMsg); _ = siteEventLogger?.LogEventAsync("script", "Error", instanceName, source, stuckMsg); })); } }