diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeLogTrackerHealthTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeLogTrackerHealthTests.cs
index ae53602f..77a62555 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeLogTrackerHealthTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Ime/ImeLogTrackerHealthTests.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
@@ -158,6 +159,130 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
Assert.False(health.HasSkippedWork);
}
+ [Fact]
+ public async Task Starved_timeout_is_retried_once_and_the_recovered_match_is_not_skipped_work()
+ {
+ // Session b9f1d134: a CPU-starved Hyper-V guest tripped the 1 s wall-clock regex
+ // timeout on a line the pattern genuinely matches in 5.8 µs. A timeout whose attempt
+ // consumed almost no match cost is starvation — retried once, recovered, no warning.
+ using var h = new Harness();
+ var degraded = 0;
+ h.Tracker.OnTrackerDegraded = (_, __, ___) => degraded++;
+ h.Tracker.MatchCostReader = () => 0; // attempt cost 0 < gate
+ var attempts = 0;
+ h.Tracker.MatchInvoker = (id, regex, input) =>
+ {
+ if (id == "T-MARK" && ++attempts == 1) throw new RegexMatchTimeoutException();
+ return regex.Match(input);
+ };
+
+ h.Append(Entry("marker 1") + "\n");
+ await h.Pass();
+
+ var health = h.Tracker.GetHealthSnapshot();
+ Assert.Equal(2, attempts);
+ Assert.Equal(1, health.PatternHits["T-MARK"]); // recovered
+ Assert.Equal(0, health.RegexTimeouts);
+ Assert.Equal(1, health.RegexTimeoutRetries);
+ Assert.False(health.HasSkippedWork);
+ Assert.Equal(0, degraded);
+ }
+
+ [Fact]
+ public async Task Timeout_that_burned_real_cpu_is_skipped_without_a_retry()
+ {
+ // The gate exists so a genuinely spinning pattern never gets a second helping of
+ // CPU: an attempt at or over the gate is a real timeout, counted and skipped. The
+ // wall-clock fallback lands here by construction (a timed-out attempt always
+ // measures >= the full timeout > gate).
+ using var h = new Harness();
+ var degraded = 0;
+ h.Tracker.OnTrackerDegraded = (_, __, ___) => degraded++;
+ h.Tracker.LineMatchBudget = long.MaxValue; // isolate the gate from the budget
+ long cost = 0;
+ h.Tracker.MatchCostReader = () => cost += h.Tracker.TimeoutRetryGate; // every delta >= gate
+ var attempts = 0;
+ h.Tracker.MatchInvoker = (id, regex, input) =>
+ {
+ if (id == "T-MARK") { attempts++; throw new RegexMatchTimeoutException(); }
+ return regex.Match(input);
+ };
+
+ h.Append(Entry("marker 1 loose 1") + "\n");
+ await h.Pass();
+
+ var health = h.Tracker.GetHealthSnapshot();
+ Assert.Equal(1, attempts); // no retry
+ Assert.Equal(0, health.PatternHits["T-MARK"]);
+ Assert.Equal(1, health.PatternHits["T-LOOSE"]); // later patterns still ran
+ Assert.Equal(1, health.RegexTimeouts);
+ Assert.Equal(0, health.RegexTimeoutRetries);
+ Assert.True(health.HasSkippedWork);
+ Assert.Equal(1, degraded);
+ }
+
+ [Fact]
+ public async Task Retry_that_times_out_again_is_counted_once_and_not_retried_further()
+ {
+ using var h = new Harness();
+ h.Tracker.MatchCostReader = () => 0; // every attempt looks starved
+ var attempts = 0;
+ h.Tracker.MatchInvoker = (id, regex, input) =>
+ {
+ if (id == "T-MARK") { attempts++; throw new RegexMatchTimeoutException(); }
+ return regex.Match(input);
+ };
+
+ h.Append(Entry("marker 1") + "\n");
+ await h.Pass();
+
+ var health = h.Tracker.GetHealthSnapshot();
+ Assert.Equal(2, attempts); // exactly one retry
+ Assert.Equal(1, health.RegexTimeouts);
+ Assert.Equal(1, health.RegexTimeoutRetries);
+ Assert.True(health.HasSkippedWork);
+ }
+
+ [Fact]
+ public async Task Budget_break_skips_the_remaining_patterns_and_names_the_first_skipped()
+ {
+ using var h = new Harness();
+ string? firstSkipped = null;
+ h.Tracker.OnTrackerDegraded = (_, __, pattern) => firstSkipped = pattern;
+ h.Tracker.LineMatchBudget = 500;
+ long cost = 0;
+ h.Tracker.MatchCostReader = () => cost += 1000; // every Match charges 1000 units
+
+ h.Append(Entry("marker 1 loose 1") + "\n");
+ await h.Pass();
+
+ var health = h.Tracker.GetHealthSnapshot();
+ Assert.Equal("T-NEVER", firstSkipped); // pattern order: T-MARK, T-NEVER, T-LOOSE
+ Assert.Equal(1, health.PatternHits["T-MARK"]); // matched before the budget was spent
+ Assert.Equal(0, health.PatternHits["T-LOOSE"]); // skipped by the break
+ Assert.Equal(1, health.BudgetBreaks);
+ Assert.True(health.HasSkippedWork);
+ }
+
+ [Fact]
+ public async Task Retry_counter_survives_a_restart()
+ {
+ using var h = new Harness();
+ h.Tracker.MatchCostReader = () => 0;
+ var attempts = 0;
+ h.Tracker.MatchInvoker = (id, regex, input) =>
+ {
+ if (id == "T-MARK" && ++attempts == 1) throw new RegexMatchTimeoutException();
+ return regex.Match(input);
+ };
+ h.Append(Entry("marker 1") + "\n");
+ await h.Pass();
+ h.Tracker.SaveStateForTest();
+
+ h.Restart();
+ Assert.Equal(1, h.Tracker.GetHealthSnapshot().RegexTimeoutRetries);
+ }
+
[Fact]
public void Ime_agent_version_is_captured_from_the_pattern_action()
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs
index 675c1f1f..6fe0378b 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs
@@ -331,6 +331,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private sealed class PassStats
{
public int RegexTimeouts;
+ public int RegexTimeoutRetries;
public int BudgetBreaks;
public int OversizedLines;
public string FirstSkippedPatternId;
@@ -342,23 +343,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// remaining patterns are skipped for this line (counted, first skipped ID recorded) —
/// matches already handled on this line stand.
///
- /// The budget is the SUM of the time spent inside Regex.Match on this line — the
- /// cost the budget exists to bound (hostile input × unanchored custom patterns). It is
- /// deliberately not the wall-clock from the first to the last pattern: that window also
- /// contains the match handlers (signal ingress, match-log append, state writes) and
- /// whatever the scheduler does to this thread in between. Session 946ccbd6 (2026-08-30)
- /// recorded two budget breaks on a 1 MB `AppWorkload.log` with no line over 5 KB — the
- /// Hyper-V guest had stalled the whole process for 5–10 s mid-line — and each break
- /// silently skipped the remaining patterns of a genuine line.
+ /// The budget is the SUM of the COST spent inside Regex.Match on this line — the
+ /// resource the budget exists to bound (hostile input × unanchored custom patterns).
+ /// Cost is thread CPU cycles when , wall ticks
+ /// otherwise. Two production sessions forced the cycle move (both Hyper-V, 2026-08-30):
+ /// 946ccbd6 — a 5–10 s guest stall BETWEEN two patterns broke the then wall-clock-window
+ /// budget on a genuine line (fixed by charging only Match time); b9f1d134 — a stall
+ /// INSIDE Match calls (CPU pinned at 100 % during the DeviceSetup script burst) broke
+ /// the budget six times and tripped the 1 s regex timeout on a line PS-AGENT-OUTPUT
+ /// genuinely matches in 5.8 µs. Descheduled time accrues no cycles, so only the second
+ /// failure mode needed the cost change; the timeout itself is wall-clock inside .NET
+ /// and cannot be moved — hence the gated single retry below.
+ ///
+ ///
+ /// A whose attempt consumed less than
+ /// of cost was starvation, not matching — that attempt
+ /// is retried exactly once (counted as a retry, not a timeout; a recovered match is
+ /// handled normally and is NOT skipped work). An attempt at or over the gate burned
+ /// real CPU: counted as a timeout and skipped, never granted a second helping. In the
+ /// wall-clock fallback the gate is unreachable (a timed-out attempt always measures
+ /// ≥ the full timeout), so the fallback keeps the old skip-on-first-timeout behaviour.
///
///
private void MatchLine(string filePath, string line, string messageToMatch, CmTraceLogEntry entry, PassStats stats)
{
- long matchTicks = 0;
+ long matchCost = 0;
for (var i = 0; i < _activePatterns.Count; i++)
{
var pattern = _activePatterns[i];
- if (matchTicks > LineMatchBudgetTicks)
+ if (matchCost > LineMatchBudget)
{
stats.BudgetBreaks++;
_budgetBreaks++;
@@ -366,22 +379,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
return;
}
- Match match;
- var matchStarted = Stopwatch.GetTimestamp();
- try
+ Match match = null;
+ var retried = false;
+ while (true)
{
- match = pattern.Regex.Match(messageToMatch);
+ var costBefore = MatchCostReader();
+ try
+ {
+ match = MatchInvoker(pattern.PatternId, pattern.Regex, messageToMatch);
+ matchCost += MatchCostReader() - costBefore;
+ break;
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ var attemptCost = MatchCostReader() - costBefore;
+ matchCost += attemptCost;
+ if (!retried && attemptCost < TimeoutRetryGate)
+ {
+ retried = true;
+ stats.RegexTimeoutRetries++;
+ _regexTimeoutRetries++;
+ _logger.Debug($"ImeLogTracker: regex timeout for pattern '{pattern.PatternId}' consumed only {attemptCost} match-cost units (< gate {TimeoutRetryGate}) — starved thread, retrying once");
+ continue;
+ }
+ stats.RegexTimeouts++;
+ _regexTimeouts++;
+ if (_timeoutLoggedPatternIds.Add(pattern.PatternId))
+ _logger.Debug($"ImeLogTracker: regex timeout for pattern '{pattern.PatternId}' (message length {messageToMatch.Length}, attempt cost {attemptCost}) — skipped; further timeouts of this pattern are only counted");
+ break;
+ }
}
- catch (RegexMatchTimeoutException)
- {
- matchTicks += Stopwatch.GetTimestamp() - matchStarted;
- stats.RegexTimeouts++;
- _regexTimeouts++;
- if (_timeoutLoggedPatternIds.Add(pattern.PatternId))
- _logger.Debug($"ImeLogTracker: regex timeout for pattern '{pattern.PatternId}' (message length {messageToMatch.Length}) — skipped; further timeouts of this pattern are only counted");
- continue;
- }
- matchTicks += Stopwatch.GetTimestamp() - matchStarted;
+ if (match == null) continue;
if (match.Success)
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs
index 11d70cc4..f7228102 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs
@@ -241,12 +241,55 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// (IME writes an entry in one call) is far smaller.
internal const int HeldTailMaxBytes = 1024 * 1024;
- // Aggregate matching budget per line across ALL active patterns. The 1 s per-regex
- // timeout bounds one Match call; with ~80 active patterns and tenant-authored custom
- // patterns (which are not anchored) a crafted line could still serialize ~80 s. After
- // this budget the remaining patterns are skipped for that line and the pass summary
- // says so. No genuine IME line comes anywhere near it.
- private static readonly long LineMatchBudgetTicks = Stopwatch.Frequency * 2;
+ // Per-Match wall-clock timeout baked into every compiled Regex. .NET checks it against
+ // the wall clock internally, so it CANNOT be moved to CPU time — the starvation retry
+ // below exists precisely because of that.
+ internal static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(1);
+
+ // Whether per-thread CPU cycle accounting is available (probed once). When it is, the
+ // line budget and the timeout-retry gate are charged in cycles the matching thread
+ // actually executed; when not, both fall back to wall-clock Stopwatch ticks — exactly
+ // the pre-cycle behaviour, including "no retry ever" (see TimeoutRetryGate).
+ internal static readonly bool ThreadCycleTimeAvailable = Interop.ThreadNativeMethods.Probe();
+
+ // Converts "seconds of hostile CPU" into a cycle budget. Nominal frequency is unknowable
+ // in advance and precision is irrelevant: genuine lines cost microseconds (session
+ // b9f1d134: PS-AGENT-OUTPUT on its worst real line = 5.8 µs), the budget bounds seconds.
+ // A 1 GHz machine gets an effective 6 s budget, a 5 GHz machine 1.2 s — both fine.
+ private const long AssumedCyclesPerSecond = 3_000_000_000;
+
+ // Aggregate matching budget per line across ALL active patterns, charged in match COST
+ // (thread CPU cycles when available, wall ticks otherwise). The per-regex timeout bounds
+ // one Match call; with ~80 active patterns and tenant-authored custom patterns (which
+ // are not anchored) a crafted line could still serialize ~80 s of CPU. After this budget
+ // the remaining patterns are skipped for that line and the pass summary says so. No
+ // genuine IME line comes anywhere near it — but a starved thread no longer trips it,
+ // because descheduled time accrues no cycles (sessions 946ccbd6 / b9f1d134, 2026-08-30).
+ // Instance field as a test seam; initialised from the probed static default.
+ internal long LineMatchBudget =
+ ThreadCycleTimeAvailable ? AssumedCyclesPerSecond * 2 : Stopwatch.Frequency * 2;
+
+ // Gate for the ONE retry after a RegexMatchTimeoutException: retry only when the
+ // timed-out attempt consumed less than ~0.5 s of match cost — then the 1 s wall-clock
+ // timeout was starvation, and the retry costs what the match really costs (µs). A
+ // genuinely spinning pattern burns ~1 s of CPU into the attempt, exceeds the gate and
+ // is skipped without a second helping. In the wall-clock fallback the gate is
+ // unreachable by construction (a timed-out attempt always measures >= 1 s wall), so no
+ // retry ever fires there — starvation and spinning are indistinguishable without
+ // cycle accounting, and retrying a genuine spinner would double the hostile bound.
+ internal long TimeoutRetryGate =
+ ThreadCycleTimeAvailable ? AssumedCyclesPerSecond / 2 : Stopwatch.Frequency / 2;
+
+ // Monotonic per-thread match-cost counter (cycles or wall ticks; deltas taken around
+ // each Regex.Match on the poll thread only). Test seam.
+ internal Func MatchCostReader = ThreadCycleTimeAvailable
+ ? (Func)Interop.ThreadNativeMethods.GetCurrentThreadCycles
+ : Stopwatch.GetTimestamp;
+
+ // Test seam for MatchLine: lets tests throw RegexMatchTimeoutException deterministically
+ // for a chosen pattern instead of constructing a genuinely catastrophic regex.
+ internal Func MatchInvoker =
+ (patternId, regex, input) => regex.Match(input);
// An unterminated tail at EOF (a physical line without '\n', or a multiline entry
// without its ']LOG]!>' close) is normally the writer mid-write. It is held back —
@@ -279,6 +322,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private long _entriesMatched;
private long _oversizedLines;
private long _regexTimeouts;
+ private long _regexTimeoutRetries;
private long _budgetBreaks;
private long _heldTailCount;
private int _unanchoredPatterns;
@@ -306,6 +350,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
EntriesMatched = _entriesMatched,
OversizedLines = _oversizedLines,
RegexTimeouts = _regexTimeouts,
+ RegexTimeoutRetries = _regexTimeoutRetries,
BudgetBreaks = _budgetBreaks,
HeldTails = _heldTailCount,
HealthScriptResultParseFailures = HealthScriptResultParseFailures,
@@ -733,7 +778,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
var regexStr = pattern.Pattern.Replace("{GUID}", GuidPattern);
// Singleline: make '.' match newlines too — required because multiline CMTrace
// entries are reassembled with '\n' chars (e.g. DO TEL JSON in IME >= 1.101)
- var regex = new Regex(regexStr, RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline, TimeSpan.FromSeconds(1));
+ var regex = new Regex(regexStr, RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline, RegexMatchTimeout);
var compiled = new CompiledPattern
{
@@ -1000,6 +1045,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_entriesMatched = state.Health.EntriesMatched;
_oversizedLines = state.Health.OversizedLines;
_regexTimeouts = state.Health.RegexTimeouts;
+ _regexTimeoutRetries = state.Health.RegexTimeoutRetries;
_budgetBreaks = state.Health.BudgetBreaks;
_heldTailCount = state.Health.HeldTails;
HealthScriptResultParseFailures = state.Health.HealthScriptResultParseFailures;
@@ -1068,6 +1114,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
EntriesMatched = _entriesMatched,
OversizedLines = _oversizedLines,
RegexTimeouts = _regexTimeouts,
+ RegexTimeoutRetries = _regexTimeoutRetries,
BudgetBreaks = _budgetBreaks,
HeldTails = _heldTailCount,
HealthScriptResultParseFailures = HealthScriptResultParseFailures,
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerHealth.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerHealth.cs
index 599e8b7c..523a6f4a 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerHealth.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerHealth.cs
@@ -21,6 +21,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public long EntriesMatched { get; set; }
public long OversizedLines { get; set; }
public long RegexTimeouts { get; set; }
+
+ ///
+ /// Regex timeouts absorbed by the one gated retry: the timed-out attempt consumed
+ /// almost no CPU (a starved thread, not a spinning pattern) and the retry recovered
+ /// the match. Expected nonzero on CPU-saturated VMs; NOT skipped work — only a
+ /// timeout that survives (or is denied) the retry counts into .
+ ///
+ public long RegexTimeoutRetries { get; set; }
+
public long BudgetBreaks { get; set; }
public long HeldTails { get; set; }
public int HealthScriptResultParseFailures { get; set; }
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs
index 693aaa17..029535b0 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs
@@ -159,6 +159,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public long EntriesMatched { get; set; }
public long OversizedLines { get; set; }
public long RegexTimeouts { get; set; }
+ public long RegexTimeoutRetries { get; set; }
public long BudgetBreaks { get; set; }
public long HeldTails { get; set; }
public int HealthScriptResultParseFailures { get; set; }
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ThreadNativeMethods.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ThreadNativeMethods.cs
new file mode 100644
index 00000000..96c208be
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ThreadNativeMethods.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
+{
+ ///
+ /// Win32 P/Invoke for per-thread CPU accounting. Used by ImeLogTracker to charge the
+ /// per-line match budget with cycles the matching thread actually executed instead of
+ /// wall-clock time: on a CPU-saturated guest the wall clock keeps running while the
+ /// thread is descheduled, so a wall-based budget measures the scheduler, not the regex
+ /// (sessions 946ccbd6 / b9f1d134, 2026-08-30).
+ ///
+ internal static class ThreadNativeMethods
+ {
+ // Pseudo handle (-2): always refers to the calling thread, never needs closing.
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr GetCurrentThread();
+
+ // Cycles the thread has executed, accumulated by the kernel at context switches.
+ // Excludes time the thread spent descheduled by the GUEST scheduler; hypervisor-level
+ // vCPU preemption is invisible to the guest kernel and still gets charged — the
+ // measurement is a strict improvement over wall clock, not perfect steal accounting.
+ [DllImport("kernel32.dll")]
+ private static extern bool QueryThreadCycleTime(IntPtr threadHandle, out ulong cycleTime);
+
+ ///
+ /// Cycle count of the calling thread, or 0 if the call fails. A 0 makes the caller's
+ /// delta accounting charge nothing for the enclosed work — the safe direction (no
+ /// spurious skips); persistent unavailability is ruled out by .
+ ///
+ public static long GetCurrentThreadCycles()
+ {
+ try
+ {
+ ulong cycles;
+ return QueryThreadCycleTime(GetCurrentThread(), out cycles) ? unchecked((long)cycles) : 0L;
+ }
+ catch (Exception)
+ {
+ return 0L;
+ }
+ }
+
+ /// One-shot availability check (missing export, denied call).
+ public static bool Probe()
+ {
+ try
+ {
+ ulong cycles;
+ return QueryThreadCycleTime(GetCurrentThread(), out cycles);
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs
index 8a93997a..7ce5baaa 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs
@@ -201,6 +201,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
data["ime_entries_matched"] = health.EntriesMatched;
data["ime_oversized_lines"] = health.OversizedLines;
data["ime_regex_timeouts"] = health.RegexTimeouts;
+ data["ime_regex_timeout_retries"] = health.RegexTimeoutRetries;
data["ime_line_budget_breaks"] = health.BudgetBreaks;
data["ime_held_tails"] = health.HeldTails;
data["ime_unanchored_patterns"] = health.UnanchoredPatterns;
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs
index 385e90ec..f621efec 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs
@@ -322,6 +322,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
["file"] = fileName ?? string.Empty,
["oversizedLines"] = health.OversizedLines,
["regexTimeouts"] = health.RegexTimeouts,
+ ["regexTimeoutRetries"] = health.RegexTimeoutRetries,
["lineBudgetBreaks"] = health.BudgetBreaks,
["heldTails"] = health.HeldTails,
["linesRead"] = health.LinesRead,
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs
index 430ecd4a..c969fcb5 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs
@@ -579,6 +579,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
["entriesMatched"] = health.EntriesMatched,
["oversizedLines"] = health.OversizedLines,
["regexTimeouts"] = health.RegexTimeouts,
+ ["regexTimeoutRetries"] = health.RegexTimeoutRetries,
["lineBudgetBreaks"] = health.BudgetBreaks,
["heldTails"] = health.HeldTails,
["unanchoredPatterns"] = health.UnanchoredPatterns,