diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs
index e7ba30c2..85479ae8 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs
@@ -825,6 +825,11 @@ public class AlarmActor : ReceiveActor
// Context.Parent.Tell from the execution actor.
Self,
_logger,
+ // arch-review 2026-08-19 P1-3: same health collector + service provider as
+ // the instance-script path, so the alarm on-trigger run gets the same
+ // stuck-script watchdog, script-error counting, and run-summary threading.
+ _healthCollector,
+ _serviceProvider,
runId,
// Per-script timeout from the on-trigger script (null = global).
_onTriggerExecutionTimeoutSeconds,
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRunLauncher.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRunLauncher.cs
index 5a3c99ea..95395553 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRunLauncher.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRunLauncher.cs
@@ -138,6 +138,18 @@ internal static class ScriptRunLauncher
/// 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.
///
@@ -158,6 +170,8 @@ internal static class ScriptRunLauncher
SiteRuntimeOptions options,
IActorRef completionTarget,
ILogger logger,
+ ISiteHealthCollector? healthCollector,
+ IServiceProvider? serviceProvider,
long runId,
int? executionTimeoutSeconds,
Guid? parentExecutionId,
@@ -174,7 +188,8 @@ internal static class ScriptRunLauncher
() => RunAlarmScriptAsync(
alarmName, instanceName, level, priority, message, compiledScript,
instanceActor, sharedScriptLibrary, options, completionTarget, logger,
- runId, parentExecutionId, timeout, cts),
+ healthCollector, serviceProvider, runId, parentExecutionId, timeout,
+ executionScheduler, cts),
CancellationToken.None, TaskCreationOptions.DenyChildAttach, executionScheduler)
.Unwrap();
}
@@ -463,11 +478,42 @@ internal static class ScriptRunLauncher
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
@@ -511,12 +557,17 @@ internal static class ScriptRunLauncher
}
};
+ 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);
@@ -524,6 +575,8 @@ internal static class ScriptRunLauncher
}
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})",
@@ -532,6 +585,9 @@ internal static class ScriptRunLauncher
}
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();
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmScriptWatchdogTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmScriptWatchdogTests.cs
new file mode 100644
index 00000000..c38de82a
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmScriptWatchdogTests.cs
@@ -0,0 +1,237 @@
+using Akka.Actor;
+using Akka.TestKit;
+using Akka.TestKit.Xunit2;
+using Microsoft.CodeAnalysis.CSharp.Scripting;
+using Microsoft.CodeAnalysis.Scripting;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
+using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
+
+///
+/// arch-review 2026-08-19 P1-3 — alarm on-trigger runs used to bypass the stuck-script
+/// watchdog, the script-error health counter, and
+/// entirely: ScriptRunLauncher.RunAlarmScriptAsync carried none of the machinery its
+/// sibling RunScriptAsync (the instance-script path) carries, on the SAME bounded
+/// pool. A wedged alarm body cost a pool thread
+/// forever with no site event, no detach/replace, and DetachedScriptThreads stayed 0;
+/// a timed-out or failed on-trigger run never touched the health collector or the run-summary
+/// counters either.
+///
+/// These tests mirror and
+/// exactly, but drive an
+/// on-trigger run (via a ValueMatch raise) instead of a
+/// Call.
+///
+public class AlarmScriptWatchdogTests : TestKit, IDisposable
+{
+ private readonly SharedScriptLibrary _sharedLibrary;
+
+ public AlarmScriptWatchdogTests()
+ {
+ var compilationService = new ScriptCompilationService(
+ NullLogger.Instance);
+ _sharedLibrary = new SharedScriptLibrary(
+ compilationService, NullLogger.Instance);
+ AlarmWatchdogHooks.Gate = new SemaphoreSlim(0);
+ }
+
+ void IDisposable.Dispose()
+ {
+ // Free any still-wedged worker before tearing down, so no test leaves a blocked thread.
+ AlarmWatchdogHooks.Gate.Release(8);
+ Shutdown();
+ }
+
+ private static Script