diff --git a/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs b/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs
index 5f40d9e02e..0d822335bf 100644
--- a/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs
+++ b/Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs
@@ -117,6 +117,81 @@ await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =
}
}
+ ///
+ /// #4966: the same 50 heavy rows, read over a window the store does NOT cover (24 hours back, 49 minutes held), so the three
+ /// window-floor keys and the 177-character note ride on the payload. The covered case is 30,223 bytes; this
+ /// one pins that the cut window's extra keys do not push the default call over it.
+ ///
+ [Fact]
+ public async Task GetActiveQueries_Default_WithACutWindow_StaysUnderResponseBudget_WithFiftyRealisticRows()
+ {
+ var cs = ConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live active-queries budget test.");
+
+ var ct = TestContext.Current.CancellationToken;
+ const string cutServerName = "darling-mcp-active-queries-budget-cut-e2e";
+ var cutServerId = ServerIdHelper.GetDeterministicHashCode(cutServerName);
+ using var connection = new NpgsqlConnection(cs);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DeleteRowsAsync(connection, cutServerId, ct);
+ await using var postgres = NpgsqlDataSource.Create(cs!);
+
+ var bodySucceeded = false;
+ try
+ {
+ await DarlingMcpTestData.RegisterServerAsync(connection, cutServerId, cutServerName, ct);
+ var baseTime = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow).AddMinutes(-49);
+ var wideText = BuildQueryText(seed: 999, approxLength: 2_800);
+ await SeedFiftyHeavyRowsAsync(connection, cutServerId, cutServerName, wideText, baseTime, ct);
+
+ var cutJson = await DarlingMcpSessionTools.GetActiveQueries(postgres, cutServerName, hours_back: 24);
+ using var cutParsed = JsonDocument.Parse(cutJson);
+ Assert.True(cutParsed.RootElement.GetProperty("window_truncated").GetBoolean(), "the 24-hour window must read as cut.");
+ Assert.False(string.IsNullOrEmpty(cutParsed.RootElement.GetProperty("effective_start").GetString()));
+ var note = cutParsed.RootElement.GetProperty("truncation_note").GetString()!;
+ Assert.InRange(note.Length, 150, 250);
+ Assert.Equal(25, cutParsed.RootElement.GetProperty("snapshots_returned").GetInt32());
+
+ var cutBytes = Encoding.UTF8.GetByteCount(cutJson);
+ _output.WriteLine($"get_active_queries default call, cut window: {cutBytes:N0} bytes (budget {McpResponseBudget.DefaultBytes:N0}), note {note.Length} characters.");
+ Assert.True(cutBytes < McpResponseBudget.DefaultBytes,
+ $"get_active_queries' default call over a cut window is {cutBytes:N0} bytes, at or over the {McpResponseBudget.DefaultBytes:N0}-byte budget.");
+
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =>
+ await DeleteRowsAsync(cleanup, cutServerId, cleanupCt));
+ }
+ }
+
+ /// The same 50 heavy rows the covered case plants (ten near 2,800 characters, the rest near 700), for the cut-window case below.
+ private static async Task SeedFiftyHeavyRowsAsync(NpgsqlConnection connection, int serverId, string serverName, string wideText, DateTime baseTime, System.Threading.CancellationToken ct)
+ {
+ for (var i = 0; i < 50; i++)
+ {
+ var t = DarlingMcpTestData.Naive(baseTime.AddMinutes(i));
+ var db = i % 3 == 0 ? "StackOverflow" : i % 3 == 1 ? "AdventureWorks" : "ReportingDW";
+ var isWide = i >= 40;
+ var text = isWide ? wideText : BuildQueryText(seed: i, approxLength: 700);
+ var hasWait = i % 4 == 0;
+
+ await DarlingMcpTestData.ExecAsync(connection, ct,
+ @"INSERT INTO query_snapshots (collection_id, collection_time, server_id, server_name, session_id, database_name, elapsed_time_formatted, query_text, status, blocking_session_id, wait_type, wait_time_ms, cpu_time_ms, total_elapsed_time_ms, reads, writes, logical_reads, granted_query_memory_gb, transaction_isolation_level, dop, parallel_worker_count, login_name, host_name, program_name, open_transaction_count, request_id, wait_resource, percent_complete, query_hash)
+VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29)",
+ CollectionIdGenerator.Next(), t, serverId, serverName, 100 + i, db,
+ "00 00:00:05.125", text, i % 5 == 0 ? "suspended" : "running", 0,
+ hasWait ? "PAGEIOLATCH_SH" : null, hasWait ? 250L + i : 0L, 1000L + (i * 37), 1500L + (i * 37),
+ 10_000L + (i * 123), 50L + i, i % 7, i % 6 == 0 ? 0.75m : 0m,
+ i % 2 == 0 ? "Read Committed" : "Repeatable Read", i % 6 == 0 ? 8 : 1, i % 6 == 0 ? 4 : 0,
+ i % 2 == 0 ? "app_svc_prod" : @"CONTOSO\svc_reporting", $"APPSRV{i % 5:D2}",
+ i % 2 == 0 ? ".Net SqlClient Data Provider" : "MyOrderService.Worker", i % 3 == 0 ? 1 : 0, 0,
+ "KEY: 5:72057594043432960 (a1b2c3d4e5f6)", 12.5m + i, "0x" + (0x1A2B3C4D5E6F7080L + i).ToString("X16"));
+ }
+ }
+
/// Builds ASCII SQL text (a big literal IN-list, a realistic cause of an outsized capture) near
/// characters, so its length in .NET UTF-16 chars and its size in UTF-8
/// bytes stay close.
@@ -136,10 +211,12 @@ private static string BuildQueryText(int seed, int approxLength)
return sb.ToString();
}
- private static async Task DeleteRowsAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct)
+ private static Task DeleteRowsAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct) => DeleteRowsAsync(connection, ServerId, ct);
+
+ private static async Task DeleteRowsAsync(NpgsqlConnection connection, int serverId, System.Threading.CancellationToken ct)
{
using var cleanup = new NpgsqlCommand(
- $"DELETE FROM query_snapshots WHERE server_id = {ServerId}; DELETE FROM servers WHERE server_id = {ServerId};",
+ $"DELETE FROM query_snapshots WHERE server_id = {serverId}; DELETE FROM servers WHERE server_id = {serverId};",
connection);
await cleanup.ExecuteNonQueryAsync(ct);
}
diff --git a/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs b/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs
index bfc2c3c5e7..0c37e627af 100644
--- a/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs
+++ b/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs
@@ -17,6 +17,7 @@
using Npgsql;
using PerformanceMonitor.Collectors;
using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Service;
using PerformanceMonitor.Darling.Service.Mcp;
using PerformanceMonitor.Darling.Storage;
using Xunit;
@@ -263,6 +264,300 @@ await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =
}
}
+
+ /* #4966 window-floor cases. The coverage probe measures the purge edge (the schedule's seven days) from the store's own
+ clock, so a window anchored months back would read as lying wholly before the coverage: the anchor is the current minute,
+ taken once per test, and every seeded instant is an offset from it. */
+ private static DateTime AnchorNow()
+ {
+ var now = DateTime.UtcNow;
+ return new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);
+ }
+
+ private static readonly string[] WindowTools = ["get_active_queries", "get_waiting_tasks"];
+
+ private static string TableOf(string tool) => tool == "get_active_queries" ? "query_snapshots" : "waiting_tasks";
+
+ private static Task CallAsync(NpgsqlDataSource postgres, string tool, string server, int hours, DateTime end) =>
+ tool == "get_active_queries"
+ ? DarlingMcpSessionTools.GetActiveQueries(postgres, server, hours, as_of: WebDataStartNote.FormatWindowEnd(end))
+ : DarlingMcpSessionTools.GetWaitingTasks(postgres, server, hours, as_of: WebDataStartNote.FormatWindowEnd(end));
+
+ private static string WindowServerName(string tool, string window) => "darling-mcp-session-window-" + window + "-" + TableOf(tool);
+
+ /// A server registered at , its runs of both collectors logged every
+ /// minutes from to (none when null),
+ /// and three rows of 's table an hour apart from (none when null).
+ private static async Task SeedWindowServerAsync(
+ NpgsqlConnection connection, string tool, string name, DateTime created, DateTime? runsFrom, int stepMinutes, DateTime? firstRow, DateTime end, System.Threading.CancellationToken ct)
+ {
+ var serverId = ServerIdHelper.GetDeterministicHashCode(name);
+ await DeleteWindowServerAsync(connection, name, ct);
+ await DarlingMcpTestData.RegisterServerAsync(connection, serverId, name, ct);
+ await DarlingMcpTestData.ExecAsync(connection, ct, "UPDATE servers SET created_date = $2 WHERE server_id = $1", serverId, DarlingMcpTestData.Naive(created));
+
+ if (runsFrom is DateTime from)
+ {
+ await DarlingMcpTestData.ExecAsync(connection, ct,
+ @"INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, duration_ms, status, rows_collected)
+SELECT row_number() OVER (), $1, $2, $3, t, 12, 'SUCCESS', 0
+FROM generate_series($4::timestamp, $5::timestamp, make_interval(mins => $6)) AS t",
+ serverId, name, TableOf(tool), DarlingMcpTestData.Naive(from), DarlingMcpTestData.Naive(end), stepMinutes);
+ }
+
+ if (firstRow is DateTime first)
+ {
+ for (var i = 0; i < 3; i++)
+ {
+ var at = DarlingMcpTestData.Naive(first.AddHours(i));
+ if (tool == "get_active_queries")
+ {
+ await DarlingMcpTestData.ExecAsync(connection, ct,
+ @"INSERT INTO query_snapshots (collection_id, collection_time, server_id, server_name, session_id, database_name, elapsed_time_formatted, query_text, status, blocking_session_id, wait_type, wait_time_ms, cpu_time_ms, total_elapsed_time_ms, reads, writes, logical_reads, granted_query_memory_gb, transaction_isolation_level, dop, parallel_worker_count, login_name, host_name, program_name, open_transaction_count, request_id)
+VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26)",
+ CollectionIdGenerator.Next(), at, serverId, name, 55, Db, "00 00:00:05.000", "SELECT 1", "running", 0, "CXPACKET", 500L, 5000L, 5000L, 100L, 0L, 2000L, 0.5m, "Read Committed", 4, 3, "sa", "APP01", "SSMS", 1, 0);
+ }
+ else
+ {
+ await DarlingMcpTestData.ExecAsync(connection, ct,
+ @"INSERT INTO waiting_tasks (collection_id, collection_time, server_id, server_name, session_id, wait_type, wait_duration_ms, blocking_session_id, resource_description, database_name)
+VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)",
+ CollectionIdGenerator.Next(), at, serverId, name, 55, "LCK_M_X", 3000L, 60, null, Db);
+ }
+ }
+ }
+ }
+
+ private static async Task DeleteWindowServerAsync(NpgsqlConnection connection, string name, System.Threading.CancellationToken ct)
+ {
+ var serverId = ServerIdHelper.GetDeterministicHashCode(name);
+ await DarlingMcpTestData.ExecAsync(connection, ct,
+ "DELETE FROM config_collector_schedules WHERE server_id = $1 AND collector_name IN ('query_snapshots', 'waiting_tasks')", serverId);
+ foreach (var table in new[] { "query_snapshots", "waiting_tasks", "collection_log", "servers" })
+ {
+ await DarlingMcpTestData.ExecAsync(connection, ct, $"DELETE FROM {table} WHERE server_id = $1", serverId);
+ }
+ }
+
+ /// Runs for each window tool against its own seeded server, then removes what it seeded.
+ private async Task ForEachWindowToolAsync(
+ string window, Func seedAndAssert)
+ {
+ var cs = ConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live session-tools test.");
+
+ var ct = TestContext.Current.CancellationToken;
+ using var connection = new NpgsqlConnection(cs);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await using var postgres = NpgsqlDataSource.Create(cs!);
+
+ /* The coverage probe measures the purge edge from the collector's retention, and a fleet-wide override row in
+ config_collector_schedules for either collector (another test's leftover on a shared store) moves that edge off the
+ seven days these cases assume. The cases are about the default, so the fleet rows for the two collectors are set aside and put back in the finally. */
+ var savedFleetRows = new List<(string Name, int? Frequency, int? Retention, bool Enabled, string[]? Databases)>();
+ await using (var save = new NpgsqlCommand(
+ "SELECT collector_name, frequency_minutes, retention_days, enabled, databases FROM config_collector_schedules WHERE server_id IS NULL AND lower(collector_name) IN ('query_snapshots', 'waiting_tasks')", connection))
+ await using (var saved = await save.ExecuteReaderAsync(ct))
+ {
+ while (await saved.ReadAsync(ct))
+ {
+ savedFleetRows.Add((saved.GetString(0),
+ saved.IsDBNull(1) ? null : saved.GetInt32(1),
+ saved.IsDBNull(2) ? null : saved.GetInt32(2),
+ saved.GetBoolean(3),
+ saved.IsDBNull(4) ? null : (string[])saved.GetValue(4)));
+ }
+ }
+
+ await DarlingMcpTestData.ExecAsync(connection, ct,
+ "DELETE FROM config_collector_schedules WHERE server_id IS NULL AND lower(collector_name) IN ('query_snapshots', 'waiting_tasks')");
+
+ var bodySucceeded = false;
+ try
+ {
+ foreach (var tool in WindowTools)
+ {
+ await seedAndAssert(connection, postgres, tool, WindowServerName(tool, window), AnchorNow());
+ }
+
+ bodySucceeded = true;
+ }
+ finally
+ {
+ foreach (var row in savedFleetRows)
+ {
+ await DarlingMcpTestData.ExecAsync(connection, System.Threading.CancellationToken.None,
+ "INSERT INTO config_collector_schedules (server_id, collector_name, frequency_minutes, retention_days, enabled, databases) VALUES (NULL, $1, $2, $3, $4, $5) ON CONFLICT DO NOTHING",
+ row.Name, (object?)row.Frequency ?? DBNull.Value, (object?)row.Retention ?? DBNull.Value, row.Enabled, (object?)row.Databases ?? DBNull.Value);
+ }
+
+ await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =>
+ {
+ foreach (var tool in WindowTools)
+ {
+ await DeleteWindowServerAsync(cleanup, WindowServerName(tool, window), cleanupCt);
+ }
+ });
+ }
+ }
+
+ private static System.Text.Json.JsonElement Parse(string json) => System.Text.Json.JsonDocument.Parse(json).RootElement.Clone();
+
+ /// (a) The server was registered two days ago and read over 168 hours: the data starts at its first collection, and the keys sit right after hours_back.
+ [Fact]
+ public async Task ARowsAnswer_ForAServerAddedTwoDaysAgo_NamesWhereCoverageStarts_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await ForEachWindowToolAsync("added", async (connection, postgres, tool, name, end) =>
+ {
+ var added = end.AddDays(-2);
+ await SeedWindowServerAsync(connection, tool, name, added, added, 30, end.AddDays(-1), end, ct);
+
+ var json = await CallAsync(postgres, tool, name, 168, end);
+
+ var root = Parse(json);
+ Assert.True(root.GetProperty("window_truncated").GetBoolean(), tool);
+ Assert.Equal(McpHelpers.FormatEffectiveStart(added), root.GetProperty("effective_start").GetString());
+ Assert.EndsWith("Z", root.GetProperty("effective_start").GetString()!, StringComparison.Ordinal);
+ Assert.Equal(
+ DarlingMcpWindowNotice.Build(added, end.AddHours(-168), TableOf(tool)).TruncationNote,
+ root.GetProperty("truncation_note").GetString());
+ Assert.Contains("raw " + TableOf(tool) + " retains", root.GetProperty("truncation_note").GetString(), StringComparison.Ordinal);
+ Assert.False(root.TryGetProperty("effective_hours_back", out _), tool);
+
+ /* Written right after hours_back, in the contract's order. */
+ var names = root.EnumerateObject().Select(p => p.Name).ToList();
+ var at = names.IndexOf("hours_back");
+ Assert.Equal(["effective_start", "window_truncated", "truncation_note"], names.Skip(at + 1).Take(3));
+ });
+ }
+
+ ///
+ /// #4966: a coverage probe that throws costs the notice, never the rows. The seam stands in for the probe on a window that
+ /// needs one (168 hours): the rows come back WITHOUT the three keys, and an empty answer WITHOUT hints.
+ ///
+ [Fact]
+ public async Task AFailedProbe_CostsTheNotice_NeverTheRows_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await ForEachWindowToolAsync("probefail", async (connection, postgres, tool, name, end) =>
+ {
+ await SeedWindowServerAsync(connection, tool, name, end.AddDays(-2), end.AddDays(-2), 30, end.AddDays(-1), end, ct);
+
+ var bodySucceeded = false;
+ DarlingMcpWindowNotice.TestOnlyProbe = () => throw new TimeoutException("the probe's deadline passed");
+ try
+ {
+ var root = Parse(await CallAsync(postgres, tool, name, 168, end));
+
+ Assert.False(root.TryGetProperty("status", out _), tool);
+ Assert.False(root.TryGetProperty("error", out _), tool);
+ Assert.Equal(3, root.GetProperty(tool == "get_active_queries" ? "queries" : "tasks").GetArrayLength());
+ Assert.False(root.TryGetProperty("effective_start", out _), tool);
+ Assert.False(root.TryGetProperty("window_truncated", out _), tool);
+ Assert.False(root.TryGetProperty("truncation_note", out _), tool);
+
+ /* An empty answer: the same failed probe leaves the status without its hints. */
+ var empty = Parse(await CallAsync(postgres, tool, name, 1, end.AddDays(-5)));
+ Assert.Equal("empty", empty.GetProperty("status").GetString());
+ Assert.False(empty.TryGetProperty("hints", out _), tool);
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunOwnedAsync(bodySucceeded, () =>
+ {
+ DarlingMcpWindowNotice.TestOnlyProbe = null;
+ return Task.CompletedTask;
+ });
+ }
+ });
+ }
+
+ /// (b) A quiet start: registered a month ago, its first row two days into the window. The store covered the whole window, so false and the asked start.
+ [Fact]
+ public async Task ARowsAnswer_WhoseFirstRowComesLate_IsCovered_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await ForEachWindowToolAsync("quiet", async (connection, postgres, tool, name, end) =>
+ {
+ await SeedWindowServerAsync(connection, tool, name, end.AddDays(-30), end.AddDays(-8), 60, end.AddDays(-5), end, ct);
+
+ var root = Parse(await CallAsync(postgres, tool, name, 168, end));
+
+ if (tool == "get_active_queries")
+ {
+ /* The house omit-false rule: a covered default call stays inside the response budget, so the three keys
+ are written only when the window was cut. get_waiting_tasks keeps them always, like the other raw-tier tools. */
+ Assert.False(root.TryGetProperty("window_truncated", out _), tool);
+ Assert.False(root.TryGetProperty("effective_start", out _), tool);
+ Assert.False(root.TryGetProperty("truncation_note", out _), tool);
+ return;
+ }
+
+ Assert.False(root.GetProperty("window_truncated").GetBoolean(), tool);
+ Assert.Equal(System.Text.Json.JsonValueKind.Null, root.GetProperty("truncation_note").ValueKind);
+ /* The asked start: an anchor within the client-clock allowance of now reads as now, so it is the call's own clock
+ a few seconds past the seeded minute, never earlier than the window and never later than the first row. */
+ var effectiveStart = DateTime.Parse(root.GetProperty("effective_start").GetString()!, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind);
+ Assert.InRange((effectiveStart - end.AddHours(-168)).TotalSeconds, 0, 120);
+ });
+ }
+
+ /// (c) An empty one-hour window with no run in it: the store holds no collection, so the hints say NOT covered, with no start to name.
+ [Fact]
+ public async Task AnEmptyHour_WithNoRun_SaysTheStoreHoldsNoCollection_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await ForEachWindowToolAsync("norun", async (connection, postgres, tool, name, end) =>
+ {
+ await SeedWindowServerAsync(connection, tool, name, end.AddDays(-30), null, 30, null, end, ct);
+
+ var root = Parse(await CallAsync(postgres, tool, name, 1, end));
+
+ Assert.Equal("empty", root.GetProperty("status").GetString());
+ var hints = root.GetProperty("hints");
+ Assert.True(hints.GetProperty("window_truncated").GetBoolean(), tool);
+ Assert.Equal(System.Text.Json.JsonValueKind.Null, hints.GetProperty("effective_start").ValueKind);
+ Assert.Equal(
+ DarlingMcpWindowNotice.Build(null, end.AddHours(-1), TableOf(tool), emptyAnswer: true).TruncationNote,
+ hints.GetProperty("truncation_note").GetString());
+ Assert.Contains("no collection of " + TableOf(tool), hints.GetProperty("truncation_note").GetString(), StringComparison.Ordinal);
+ });
+ }
+
+ /// (d) An empty one-hour window with a run 35 minutes in: the store ran for it, so covered (false, null) at the asked start.
+ [Fact]
+ public async Task AnEmptyHour_WithARunInIt_IsCovered_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await ForEachWindowToolAsync("runin", async (connection, postgres, tool, name, end) =>
+ {
+ var start = end.AddHours(-1);
+ /* One run, 35 minutes after the window's start: the generate_series step is longer than the window. */
+ await SeedWindowServerAsync(connection, tool, name, end.AddDays(-30), start.AddMinutes(35), 600, null, start.AddMinutes(35), ct);
+
+ var root = Parse(await CallAsync(postgres, tool, name, 1, end));
+
+ Assert.Equal("empty", root.GetProperty("status").GetString());
+ var hints = root.GetProperty("hints");
+ Assert.False(hints.GetProperty("window_truncated").GetBoolean(), tool);
+ Assert.Equal(System.Text.Json.JsonValueKind.Null, hints.GetProperty("truncation_note").ValueKind);
+ Assert.Equal(McpHelpers.FormatEffectiveStart(start), hints.GetProperty("effective_start").GetString());
+ });
+ }
+
+ /// The empty answers carry the hints and the not_collected answer stays bare: the hints ride only on the empty status.
+ [Fact]
+ public void TheEmptyAnswers_CarryTheHints_AndNotCollectedStaysBare_InTheToolSource()
+ {
+ var source = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpSessionTools.cs").ReplaceLineEndings("\n");
+ Assert.Contains("?? McpHelpers.Status(\"empty\", \"No active query snapshots found in the requested time range.\", notice.AsHints());", source, StringComparison.Ordinal);
+ Assert.Contains("?? McpHelpers.Status(\"empty\", \"No waiting tasks captured in the specified time range.\", notice.AsHints());", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, \"query_snapshots\", cancellationToken, notice", source, StringComparison.Ordinal);
+ }
+
private static async Task DeleteRowsAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct, bool keepServer = false)
{
var sql = string.Join(" ", new[] { "session_stats", "query_snapshots", "waiting_tasks" }
diff --git a/Darling/Darling.Tests/DarlingQueryHeatmapTests.cs b/Darling/Darling.Tests/DarlingQueryHeatmapTests.cs
index 17e4e36e27..72109f34d2 100644
--- a/Darling/Darling.Tests/DarlingQueryHeatmapTests.cs
+++ b/Darling/Darling.Tests/DarlingQueryHeatmapTests.cs
@@ -615,6 +615,160 @@ private static async Task DeleteRowsAsync(NpgsqlConnection connection, Cancellat
private const string ParityServerName = "query-heatmap-parity";
private const string ParityDb = "AppDb";
+ /// Runs against a registered, empty server and removes what it seeded. The seeded
+ /// instants are offsets from the current second, never a fixed date: the data-start probe reads the clock for the
+ /// purge edge, so a fixed past anchor would fall wholly outside the store's coverage.
+ private async Task WithServerAsync(Func body)
+ {
+ var cs = ConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(cs),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live heatmap test.");
+
+ var ct = TestContext.Current.CancellationToken;
+ using var connection = new NpgsqlConnection(cs);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DeleteRowsAsync(connection, ct);
+
+ await using var postgres = NpgsqlDataSource.Create(cs!);
+ var bodySucceeded = false;
+ try
+ {
+ await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct);
+ await body(connection, postgres, DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow), ct);
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =>
+ await DeleteRowsAsync(cleanup, cleanupCt));
+ }
+ }
+
+ ///
+ /// #4966: the first row of the table comes three hours into a 24-hour window, so the grid's left edge is not the
+ /// window's. The three keys sit right after hours_back, effective_start is the first row's time (exact:
+ /// the table is raw, so its coverage starts at its oldest row), and the note names the table.
+ ///
+ [Fact]
+ public async Task ARealGrid_WhoseRowsStartInsideTheRange_SaysWhereTheDataStarts_AgainstDevPostgres()
+ {
+ await WithServerAsync(async (connection, postgres, baseNow, ct) =>
+ {
+ var t1 = FloorToHour(baseNow.AddHours(-3));
+ await SeedAsync(connection, ct, t1, "0xHOT", deltaExec: 5, deltaElapsed: 250_000);
+ await SeedAsync(connection, ct, t1.AddMinutes(35), "0xNEXT", deltaExec: 3, deltaElapsed: 90_000);
+
+ var grid = Root(await DarlingMcpQueryHeatmapTools.GetQueryHeatmap(postgres, ServerName, 24));
+
+ Assert.True(grid.GetProperty("window_truncated").GetBoolean());
+ Assert.Equal(McpHelpers.FormatEffectiveStart(t1), grid.GetProperty("effective_start").GetString());
+ Assert.Equal(
+ DarlingMcpWindowNotice.Build(t1, ParseUtc(grid.GetProperty("window_start").GetString()!), "query_stats").TruncationNote,
+ grid.GetProperty("truncation_note").GetString());
+ Assert.Contains("raw query_stats retains", grid.GetProperty("truncation_note").GetString(), StringComparison.Ordinal);
+ Assert.False(grid.TryGetProperty("effective_hours_back", out _));
+
+ var names = grid.EnumerateObject().Select(p => p.Name).ToList();
+ Assert.Equal(["effective_start", "window_truncated", "truncation_note"], names.Skip(names.IndexOf("hours_back") + 1).Take(3));
+ });
+ }
+
+ ///
+ /// #4966: a quiet start. The table holds a row from before the window, so the window is covered whenever its own rows
+ /// begin: false, no note, and effective_start is the window's start (the one the payload names, not a row's time).
+ ///
+ [Fact]
+ public async Task ARealGrid_WhoseTableReachesBeforeTheRange_SaysNothing_AgainstDevPostgres()
+ {
+ await WithServerAsync(async (connection, postgres, baseNow, ct) =>
+ {
+ await SeedAsync(connection, ct, baseNow.AddHours(-40), "0xOLD", deltaExec: 4, deltaElapsed: 200_000);
+ await SeedAsync(connection, ct, FloorToHour(baseNow.AddHours(-3)), "0xHOT", deltaExec: 5, deltaElapsed: 250_000);
+
+ var grid = Root(await DarlingMcpQueryHeatmapTools.GetQueryHeatmap(postgres, ServerName, 24));
+
+ Assert.False(grid.GetProperty("window_truncated").GetBoolean());
+ Assert.Equal(JsonValueKind.Null, grid.GetProperty("truncation_note").ValueKind);
+ Assert.Equal(grid.GetProperty("window_start").GetString(), grid.GetProperty("effective_start").GetString());
+ });
+ }
+
+ ///
+ /// #4966: a failed data-start probe costs the notice, never the answer. The grid comes back with the three keys absent,
+ /// and an empty answer carries no hints.
+ ///
+ [Fact]
+ public async Task AFailedProbe_CostsTheNotice_NeverTheGrid_AgainstDevPostgres()
+ {
+ await WithServerAsync(async (connection, postgres, baseNow, ct) =>
+ {
+ var t1 = FloorToHour(baseNow.AddHours(-3));
+ await SeedAsync(connection, ct, t1, "0xHOT", deltaExec: 5, deltaElapsed: 250_000);
+
+ var bodySucceeded = false;
+ DarlingMcpWindowNotice.TestOnlyProbe = () => throw new TimeoutException("the probe's deadline passed");
+ try
+ {
+ var grid = Root(await DarlingMcpQueryHeatmapTools.GetQueryHeatmap(postgres, ServerName, 24));
+
+ Assert.False(grid.TryGetProperty("status", out _));
+ Assert.False(grid.TryGetProperty("error", out _));
+ Assert.True(grid.TryGetProperty("window_start", out _));
+ Assert.False(grid.TryGetProperty("effective_start", out _));
+ Assert.False(grid.TryGetProperty("window_truncated", out _));
+ Assert.False(grid.TryGetProperty("truncation_note", out _));
+
+ var empty = Root(await DarlingMcpQueryHeatmapTools.GetQueryHeatmap(postgres, ServerName, 1));
+ Assert.Equal("empty", empty.GetProperty("status").GetString());
+ Assert.False(empty.TryGetProperty("hints", out _));
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunOwnedAsync(bodySucceeded, () =>
+ {
+ DarlingMcpWindowNotice.TestOnlyProbe = null;
+ return Task.CompletedTask;
+ });
+ }
+ });
+ }
+
+ ///
+ /// #4966: every empty answer carries the three keys under hints, and unavailable carries none.
+ /// Rows only outside the window: the store holds no collection of the window, so the hints say NOT covered with no start
+ /// to name. Then a zero-execution capture inside it: the store did look, so covered, at the window's start.
+ ///
+ [Fact]
+ public async Task TheEmptyAnswers_CarryTheDataStartHints_AndTheUnavailableOneStaysBare_AgainstDevPostgres()
+ {
+ await WithServerAsync(async (connection, postgres, baseNow, ct) =>
+ {
+ var never = Root(await DarlingMcpQueryHeatmapTools.GetQueryHeatmap(postgres, ServerName));
+ Assert.Equal("unavailable", never.GetProperty("status").GetString());
+ Assert.False(never.TryGetProperty("hints", out _));
+
+ await SeedAsync(connection, ct, baseNow.AddHours(-40), "0xOLD", deltaExec: 4, deltaElapsed: 200_000);
+
+ var outside = Root(await DarlingMcpQueryHeatmapTools.GetQueryHeatmap(postgres, ServerName, 2));
+ Assert.Equal("empty", outside.GetProperty("status").GetString());
+ var outsideHints = outside.GetProperty("hints");
+ Assert.True(outsideHints.GetProperty("window_truncated").GetBoolean());
+ Assert.Equal(JsonValueKind.Null, outsideHints.GetProperty("effective_start").ValueKind);
+ Assert.Contains("no collection of query_stats", outsideHints.GetProperty("truncation_note").GetString(), StringComparison.Ordinal);
+
+ await SeedAsync(connection, ct, baseNow.AddMinutes(-30), "0xIDLE", deltaExec: 0, deltaElapsed: 999_000);
+
+ var idle = Root(await DarlingMcpQueryHeatmapTools.GetQueryHeatmap(postgres, ServerName, 24));
+ Assert.Equal("empty", idle.GetProperty("status").GetString());
+ var idleHints = idle.GetProperty("hints");
+ Assert.False(idleHints.GetProperty("window_truncated").GetBoolean());
+ Assert.Equal(JsonValueKind.Null, idleHints.GetProperty("truncation_note").ValueKind);
+ Assert.EndsWith("Z", idleHints.GetProperty("effective_start").GetString()!, StringComparison.Ordinal);
+ });
+ }
+
///
/// #4233 ruling item 4's live pin: the pre-#4233 SQL (fetched via git show origin/dev:... before
/// this PR's rewrite landed, pinned below as so it cannot silently rot)
diff --git a/Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs b/Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs
index dcf5d92772..ec06588a44 100644
--- a/Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs
+++ b/Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs
@@ -18,6 +18,7 @@
using ModelContextProtocol.Server;
using Npgsql;
using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Common;
using PerformanceMonitor.Darling.Service.Mcp;
using PerformanceMonitor.Darling.Storage;
using Xunit;
@@ -392,6 +393,174 @@ await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =
}
}
+ /// Runs against a registered, empty server and removes what it seeded. The seeded
+ /// instants are offsets from the current second, never a fixed date: the data-start probe reads the clock for the
+ /// purge edge, so a fixed past anchor would fall wholly outside the store's coverage.
+ private async Task WithServerAsync(Func body)
+ {
+ var cs = ConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(cs),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live regressions test.");
+
+ var ct = TestContext.Current.CancellationToken;
+ using var connection = new NpgsqlConnection(cs);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DeleteRowsAsync(connection, ct);
+
+ await using var postgres = NpgsqlDataSource.Create(cs!);
+ var bodySucceeded = false;
+ try
+ {
+ await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct);
+ await body(connection, postgres, DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow), ct);
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =>
+ await DeleteRowsAsync(cleanup, cleanupCt));
+ }
+ }
+
+ /// The truncated-baseline sentence Lite adds to its note, which this tool copies.
+ private const string BaselineTail = "Here the window starts at baseline_start, so the baseline holds only the part from effective_start to baseline_end.";
+
+ ///
+ /// #4966: the store's first Query Store row comes 40 hours back, inside the 7-day baseline, so the baseline holds only part
+ /// of what baseline_start names. The answer says so: truncated, effective_start is that first row (exact: the
+ /// table is raw, so its coverage starts at its oldest row), the note names the table and ends with the baseline sentence.
+ ///
+ [Fact]
+ public async Task ARegression_WhoseBaselineStartsInsideTheRange_SaysWhereTheDataStarts_AgainstDevPostgres()
+ {
+ await WithServerAsync(async (connection, postgres, baseNow, ct) =>
+ {
+ await SeedAsync(connection, ct, baseNow.AddHours(-40), 90, avgDurationUs: 1000, avgCpuUs: 1000, intervalId: 3, queryId: 7);
+ await SeedAsync(connection, ct, baseNow.AddMinutes(-30), 200, avgDurationUs: 4000, avgCpuUs: 4000, intervalId: 4, queryId: 7);
+
+ var hit = Root(await DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(postgres, ServerName, 24));
+
+ Assert.Equal(1, hit.GetProperty("regression_count").GetInt32());
+ Assert.True(hit.GetProperty("window_truncated").GetBoolean());
+ Assert.Equal(McpHelpers.FormatEffectiveStart(baseNow.AddHours(-40)), hit.GetProperty("effective_start").GetString());
+ Assert.NotEqual(hit.GetProperty("baseline_start").GetString(), hit.GetProperty("effective_start").GetString());
+ var note = hit.GetProperty("truncation_note").GetString()!;
+ Assert.Contains("raw query_store_stats retains", note, StringComparison.Ordinal);
+ Assert.EndsWith(BaselineTail, note, StringComparison.Ordinal);
+ Assert.False(hit.TryGetProperty("effective_hours_back", out _));
+
+ var names = hit.EnumerateObject().Select(p => p.Name).ToList();
+ Assert.Equal(["effective_start", "window_truncated", "truncation_note"], names.Skip(names.IndexOf("hours_back") + 1).Take(3));
+ });
+ }
+
+ ///
+ /// #4966: a quiet start. The store holds a row from before the baseline begins (nine days back, for a baseline that
+ /// starts eight days back), so the baseline is covered whenever the rows inside it begin: false, no note, and
+ /// effective_start is exactly baseline_start.
+ ///
+ [Fact]
+ public async Task ARegression_WhoseTableReachesBeforeTheBaseline_SaysNothing_AgainstDevPostgres()
+ {
+ await WithServerAsync(async (connection, postgres, baseNow, ct) =>
+ {
+ await SeedAsync(connection, ct, baseNow.AddDays(-9), 10, avgDurationUs: 1000, avgCpuUs: 1000, intervalId: 2, queryId: 9);
+ await SeedAsync(connection, ct, baseNow.AddHours(-40), 90, avgDurationUs: 1000, avgCpuUs: 1000, intervalId: 3, queryId: 7);
+ await SeedAsync(connection, ct, baseNow.AddMinutes(-30), 200, avgDurationUs: 4000, avgCpuUs: 4000, intervalId: 4, queryId: 7);
+
+ var hit = Root(await DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(postgres, ServerName, 24));
+
+ Assert.Equal(1, hit.GetProperty("regression_count").GetInt32());
+ Assert.False(hit.GetProperty("window_truncated").GetBoolean());
+ Assert.Equal(JsonValueKind.Null, hit.GetProperty("truncation_note").ValueKind);
+ Assert.Equal(hit.GetProperty("baseline_start").GetString(), hit.GetProperty("effective_start").GetString());
+ });
+ }
+
+ ///
+ /// #4966: a failed data-start probe costs the notice, never the answer. The regression rows come back with the three keys
+ /// absent, and an empty answer carries no hints.
+ ///
+ [Fact]
+ public async Task AFailedProbe_CostsTheNotice_NeverTheRows_AgainstDevPostgres()
+ {
+ await WithServerAsync(async (connection, postgres, baseNow, ct) =>
+ {
+ await SeedAsync(connection, ct, baseNow.AddHours(-40), 90, avgDurationUs: 1000, avgCpuUs: 1000, intervalId: 3, queryId: 7);
+ await SeedAsync(connection, ct, baseNow.AddMinutes(-30), 200, avgDurationUs: 4000, avgCpuUs: 4000, intervalId: 4, queryId: 7);
+
+ var bodySucceeded = false;
+ DarlingMcpWindowNotice.TestOnlyProbe = () => throw new TimeoutException("the probe's deadline passed");
+ try
+ {
+ var hit = Root(await DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(postgres, ServerName, 24));
+
+ Assert.False(hit.TryGetProperty("status", out _));
+ Assert.False(hit.TryGetProperty("error", out _));
+ Assert.Equal(1, hit.GetProperty("regression_count").GetInt32());
+ Assert.False(hit.TryGetProperty("effective_start", out _));
+ Assert.False(hit.TryGetProperty("window_truncated", out _));
+ Assert.False(hit.TryGetProperty("truncation_note", out _));
+
+ /* Take the recent row away: the recent side is missing, so the answer is empty. */
+ await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM query_store_stats WHERE server_id = $1 AND collection_time > $2", ServerId, DarlingMcpTestData.Naive(baseNow.AddHours(-2)));
+
+ var empty = Root(await DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(postgres, ServerName, 2));
+ Assert.Equal("empty", empty.GetProperty("status").GetString());
+ Assert.False(empty.TryGetProperty("hints", out _));
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunOwnedAsync(bodySucceeded, () =>
+ {
+ DarlingMcpWindowNotice.TestOnlyProbe = null;
+ return Task.CompletedTask;
+ });
+ }
+ });
+ }
+
+ ///
+ /// #4966: every empty answer carries the three keys under hints, the all-clear included (a clean comparison
+ /// over a baseline the store only partly holds is not a true negative), and every unavailable answer carries none.
+ ///
+ [Fact]
+ public async Task TheEmptyAnswers_CarryTheDataStartHints_AndTheUnavailableOnesStayBare_AgainstDevPostgres()
+ {
+ await WithServerAsync(async (connection, postgres, baseNow, ct) =>
+ {
+ var never = Root(await DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(postgres, ServerName));
+ Assert.Equal("unavailable", never.GetProperty("status").GetString());
+ Assert.False(never.TryGetProperty("hints", out _));
+
+ await SeedAsync(connection, ct, baseNow.AddHours(-40), 100, avgDurationUs: 1000, avgCpuUs: 1000, intervalId: 2);
+
+ /* Nothing in the last two hours: the recent side is missing. The baseline still starts 40 hours back, inside it. */
+ var noRecent = Root(await DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(postgres, ServerName, 2));
+ Assert.Equal("empty", noRecent.GetProperty("status").GetString());
+ var noRecentHints = noRecent.GetProperty("hints");
+ Assert.True(noRecentHints.GetProperty("window_truncated").GetBoolean());
+ Assert.Equal(McpHelpers.FormatEffectiveStart(baseNow.AddHours(-40)), noRecentHints.GetProperty("effective_start").GetString());
+ Assert.EndsWith(BaselineTail, noRecentHints.GetProperty("truncation_note").GetString()!, StringComparison.Ordinal);
+
+ await SeedAsync(connection, ct, baseNow.AddMinutes(-30), 100, avgDurationUs: 1000, avgCpuUs: 1000, intervalId: 1);
+
+ var clear = Root(await DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(postgres, ServerName, 24));
+ Assert.Equal("empty", clear.GetProperty("status").GetString());
+ Assert.Contains("all-clear", clear.GetProperty("message").GetString(), StringComparison.Ordinal);
+ var clearHints = clear.GetProperty("hints");
+ Assert.True(clearHints.GetProperty("window_truncated").GetBoolean());
+ Assert.Equal(McpHelpers.FormatEffectiveStart(baseNow.AddHours(-40)), clearHints.GetProperty("effective_start").GetString());
+
+ /* No baseline: the whole history sits inside the window. The unavailable answer stays bare. */
+ var noBaseline = Root(await DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(postgres, ServerName, 48));
+ Assert.Equal("unavailable", noBaseline.GetProperty("status").GetString());
+ Assert.False(noBaseline.TryGetProperty("hints", out _));
+ });
+ }
+
///
/// #5015: a database_name filter that matches nothing in the baseline or the window says the filter matched nothing, not
/// "this IS the all-clear" (the server-wide probe saw both sides collected). A filter that matches, and finds nothing
diff --git a/Darling/Darling.Tests/DarlingWebEndpointsTests.cs b/Darling/Darling.Tests/DarlingWebEndpointsTests.cs
index d01a8a8209..d60b9cac80 100644
--- a/Darling/Darling.Tests/DarlingWebEndpointsTests.cs
+++ b/Darling/Darling.Tests/DarlingWebEndpointsTests.cs
@@ -179,15 +179,15 @@ public void ReadEndpoints_IncludeTheAgHealthTool()
[Fact]
public void ReadEndpoints_ActiveQueries_KeepsTheTwoThousandCharacterWebPreview()
{
- /* #4198 lane W2: the MCP default fell to a 500-char query_text preview (QueryTextPreviewLength), but
+ /* #4198 lane W2: the MCP default fell to a 500-char (now 400) query_text preview (QueryTextPreviewLength), but
the web viewer isn't that budget's caller — its /api/read row calls the internal budget-taking
overload with an explicit 2000, the pre-#4198 McpHelpers.Truncate budget every caller got, so the
Active Queries tab doesn't shrink under it. A regression here (dropping the overload, or the literal
- 2000) silently starves that tab's query text down to 500 characters. Source-text pin rather than a
+ 2000) silently starves that tab's query text down to the MCP preview (400 characters). Source-text pin rather than a
live call: no rig in this lane. */
var source = RepoFile.ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingWebEndpoints.cs");
Assert.Contains(
- "[\"get_active_queries\"] = (c, pg, an) => DarlingMcpSessionTools.GetActiveQueries(pg, Server(c), Hours(c, 1), Str(c, \"database_name\"), QueryBool(c, \"blocking_only\", false), Rows(c, \"limit\", 50), 2000, AsOf(c), c.RequestAborted),",
+ "[\"get_active_queries\"] = (c, pg, an) => DarlingMcpSessionTools.GetActiveQueries(pg, Server(c), Hours(c, 1), Str(c, \"database_name\"), QueryBool(c, \"blocking_only\", false), Rows(c, \"limit\", 50), 2000, AsOf(c), logger, c.RequestAborted),",
source, StringComparison.Ordinal);
}
diff --git a/Darling/Darling.Tests/EffectiveStartUtcTests.cs b/Darling/Darling.Tests/EffectiveStartUtcTests.cs
index bd9a4e06a3..8ad2d916e8 100644
--- a/Darling/Darling.Tests/EffectiveStartUtcTests.cs
+++ b/Darling/Darling.Tests/EffectiveStartUtcTests.cs
@@ -45,6 +45,9 @@ public void FormatEffectiveStart_NamesTheInstantAsUtc_WhicheverKindItCameWith()
[Theory]
[InlineData("DarlingMcpDataTools.cs", 5)]
[InlineData("DarlingMcpQueryStoreClutterTools.cs", 1)]
+ [InlineData("DarlingMcpSessionTools.cs", 2)]
+ [InlineData("DarlingMcpQueryHeatmapTools.cs", 1)]
+ [InlineData("DarlingMcpQueryStoreRegressionTools.cs", 1)]
public void EveryWindowFloorWrite_RoutesThroughTheSharedFormatter(string file, int minimumRouted)
{
var source = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", file);
@@ -54,13 +57,30 @@ public void EveryWindowFloorWrite_RoutesThroughTheSharedFormatter(string file, i
.Where(line => line.StartsWith("effective_start = ", StringComparison.Ordinal))
.ToList();
- var routed = writes.Count(line => line.StartsWith("effective_start = McpHelpers.FormatEffectiveStart(", StringComparison.Ordinal));
- var others = writes.Where(line => !line.StartsWith("effective_start = McpHelpers.FormatEffectiveStart(", StringComparison.Ordinal)).ToList();
+ /* #4966: a tool that writes the shared notice (DarlingMcpWindowNotice) writes the value the helper formatted, and the
+ helper's own formatting is pinned below. */
+ bool IsRouted(string line) =>
+ line.StartsWith("effective_start = McpHelpers.FormatEffectiveStart(", StringComparison.Ordinal)
+ || line == "effective_start = notice.EffectiveStart,";
+ var routed = writes.Count(IsRouted);
+ var others = writes.Where(line => !IsRouted(line)).ToList();
Assert.True(routed >= minimumRouted, $"{file} writes effective_start through McpHelpers.FormatEffectiveStart {routed} time(s); expected at least {minimumRouted}.");
Assert.All(others, line => Assert.Equal("effective_start = (string?)null,", line));
}
+ ///
+ /// #4966: the shared notice formats its effective_start through , so every
+ /// tool that writes notice.EffectiveStart prints UTC with the Z.
+ ///
+ [Fact]
+ public void TheSharedNotice_FormatsItsEffectiveStart_ThroughTheSharedFormatter()
+ {
+ var source = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpWindowNotice.cs");
+ Assert.Contains("McpHelpers.FormatEffectiveStart(RawWindowFloor.EffectiveStart(floor, requestedStart))", source, StringComparison.Ordinal);
+ Assert.DoesNotContain(".ToString(\"o\"", source, StringComparison.Ordinal);
+ }
+
///
/// #4966: the five Lite event-list tools write effective_start from the shared notice
/// (McpQueryTools.EventWindowNoticeAsync -> WindowNotice, which prints through
@@ -86,6 +106,35 @@ public void EveryLiteEventListWindowFloorWrite_ComesFromTheSharedNotice(string f
Assert.Equal(expectedTools, Regex.Matches(source, @"emptyAnswer: true\)\)\.AsHints\(\)").Count);
}
+ ///
+ /// #4966: the six Lite config and log tools (three config-change tools, the one-server get_collection_log,
+ /// get_plan_corrections and get_memory_pressure_events) write effective_start from the shared notice
+ /// (McpQueryTools.WindowNoticeAsync -> WindowNotice, which prints through
+ /// ), never from a bare ToString("o") of the store's naive floor.
+ /// Each data answer writes the key once, from notice.EffectiveStart; counts the
+ /// empty answers that carry the same notice under hints (get_collection_log has two: the filtered one and the quiet window).
+ /// None of them uses the event-time form: their probes read the column the rows are stamped on.
+ ///
+ [Theory]
+ [InlineData("McpConfigHistoryTools.cs", 3, 3)]
+ [InlineData("McpHealthTools.cs", 1, 2)]
+ [InlineData("McpPlanCorrectionTools.cs", 1, 1)]
+ [InlineData("McpMemoryTools.cs", 1, 1)]
+ public void EveryLiteConfigAndLogWindowFloorWrite_ComesFromTheSharedNotice(string file, int expectedWrites, int expectedHints)
+ {
+ var source = RepoFile.ReadRepoFile("Lite", "Mcp", file);
+ var writes = source
+ .Split('\n')
+ .Select(line => line.Trim())
+ .Where(line => line.StartsWith("effective_start = ", StringComparison.Ordinal))
+ .ToList();
+
+ Assert.Equal(expectedWrites, writes.Count);
+ Assert.All(writes, line => Assert.Equal("effective_start = notice.EffectiveStart,", line));
+ Assert.Equal(expectedHints, Regex.Matches(source, @"emptyAnswer: true\)\)\.AsHints\(\)").Count);
+ Assert.DoesNotContain("EventWindowNoticeAsync", source, StringComparison.Ordinal);
+ }
+
///
/// #4966, #5015: where a page's rows stop and start (oldest_returned_* and newest_returned_*: collection,
/// event, deadlock and alert time) describe the window the page covers, so every tool that carries them prints them the
diff --git a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs
index 060a968150..7b0a1282a3 100644
--- a/Darling/Darling.Tests/McpPayloadContractCensusTests.cs
+++ b/Darling/Darling.Tests/McpPayloadContractCensusTests.cs
@@ -1540,16 +1540,16 @@ public static readonly (string Key, string[] Files, string WhatWasCut)[] SecondB
"the dedup_key fingerprint scan's ceiling (FingerprintScanCeiling), observed off a ceiling + 1 fetch, beside the page's own truncated — two bounds in one payload, the second spelled _truncated"),
("processes_truncated", ["DarlingMcpBlockingTools.cs"],
"get_deadlock_detail's processes[] is cut by two bounds, the per-deadlock cap DarlingDeadlockProcessRows.MaxProcessesPerDeadlock (12) and the default page's shared row budget (DefaultPageRowBudget, 24); the rows cut from one deadlock are counted beside it. Unlike the boolean _truncated keys around it, processes_truncated is a COUNT (0 when nothing was cut), kept under this name because it is the per-deadlock twin of the page's truncated and a reader looks for it beside processes"),
- ("window_truncated", ["DarlingMcpDataTools.cs", "DarlingMcpJobTools.cs", "DarlingMcpQueryStoreClutterTools.cs", "DarlingMcpTrendTools.cs", "McpBlockingTools.cs", "McpLongQueryTools.cs", "McpQueryTools.cs", "McpSessionTools.cs", "McpWaitTools.cs"],
- "the #2364 / #2353 WINDOW floor (#3653 item 17): the tier that answered did not hold the whole requested window, so the served series begins later than asked — beside effective_start / effective_hours_back, observed off the served head against the shared ninety-minute TruncationSlack, no cap involved; get_query_trend and get_query_store_top write it in their initializers, the duration-trend trio through TrendDisclosure.WriteTo (Darling) and WriteDisclosure (Lite); get_query_store_clutter (#3797) writes it in its initializer for its plan-churn and wait arms, which read the same raw tier get_query_store_top does, off the same window-floor read and the same ninety-minute slack; Lite's get_active_queries and get_waiting_tasks (QuerySnapshots / WaitingTasks, read by coverage), get_query_store_regressions (checked against the baseline's start, the earlier of its two windows) and get_query_heatmap (#4966) write it in their initializers through McpQueryTools.WindowNotice, beside a page cut of their own (truncated), so they publish effective_start without effective_hours_back, which the window-floor rule below holds apart for the window floor"),
+ ("window_truncated", ["DarlingMcpDataTools.cs", "DarlingMcpJobTools.cs", "DarlingMcpQueryHeatmapTools.cs", "DarlingMcpQueryStoreClutterTools.cs", "DarlingMcpQueryStoreRegressionTools.cs", "DarlingMcpSessionTools.cs", "DarlingMcpTrendTools.cs", "McpBlockingTools.cs", "McpConfigHistoryTools.cs", "McpHealthTools.cs", "McpLongQueryTools.cs", "McpMemoryTools.cs", "McpPlanCorrectionTools.cs", "McpQueryTools.cs", "McpSessionTools.cs", "McpWaitTools.cs"],
+ "the #2364 / #2353 WINDOW floor (#3653 item 17): the tier that answered did not hold the whole requested window, so the served series begins later than asked — beside effective_start / effective_hours_back, observed off the served head against the shared ninety-minute TruncationSlack, no cap involved; get_query_trend and get_query_store_top write it in their initializers, the duration-trend trio through TrendDisclosure.WriteTo (Darling) and WriteDisclosure (Lite); get_query_store_clutter (#3797) writes it in its initializer for its plan-churn and wait arms, which read the same raw tier get_query_store_top does, off the same window-floor read and the same ninety-minute slack; Lite's get_active_queries and get_waiting_tasks (QuerySnapshots / WaitingTasks, read by coverage), get_query_store_regressions (checked against the baseline's start, the earlier of its two windows) and get_query_heatmap (#4966) write it in their initializers through McpQueryTools.WindowNotice, beside a page cut of their own (truncated), so they publish effective_start without effective_hours_back, which the window-floor rule below holds apart for the window floor; Darling's get_active_queries, get_waiting_tasks, get_query_heatmap and get_query_store_regressions (#4966) write the same three keys after hours_back through DarlingMcpWindowNotice, with the same wording and the same absence of effective_hours_back (the regressions tool checks its baseline's start, as Lite's does); Darling's get_active_queries (DarlingMcpSessionTools.cs) writes them only when the window was cut, so a covered default call stays inside the response budget and a missing key there means covered (Lite's writes them always), and each of the four leaves them off when its coverage probe failed, since a failed probe costs the notice and never the rows; and Lite's config-change, collection-log, plan-correction and memory-pressure tools (#4966) write it as well"),
("findings_truncated", ["DarlingMcpTools.cs", "McpAnalysisTools.cs"],
"#4198: get_analysis_findings' GROUP PAGE cut — limit caps the collapsed per-chain groups returned (default 18), independent of the pre-existing truncated above (the raw WindowCoveringLimit occurrence read, beside truncation_note): truncated warns occurrence stats may under-report, findings_truncated warns other diagnostic chains exist but are not on this page at all"),
];
public static readonly (string Key, string[] Files, string WhatItExplains)[] CutNoteKeys =
[
- ("truncation_note", ["DarlingMcpDataTools.cs", "DarlingMcpQueryStoreClutterTools.cs", "DarlingMcpTools.cs", "McpAnalysisTools.cs", "McpBlockingTools.cs", "McpLongQueryTools.cs", "McpQueryTools.cs", "McpSessionTools.cs", "McpWaitTools.cs"],
- "the prose beside the flag: on get_analysis_findings (both SKUs) beside truncated, the WindowCoveringLimit read cap observed off a cap + 1 fetch; on get_query_store_top and get_query_store_clutter beside window_truncated, the #2364 window floor — the store's raw retention did not reach the whole requested window (on the clutter view, for the two arms that read the raw tier); on Lite's get_top_queries_by_cpu / get_top_procedures_by_cpu / get_query_store_top (#4231), the same window-floor note, from LocalDataService.GetQueryWindowFloorAsync; and on Lite's get_active_queries, get_waiting_tasks, get_query_store_regressions and get_query_heatmap (#4966), the same note beside window_truncated, built by McpQueryTools.WindowNotice with the table each one read"),
+ ("truncation_note", ["DarlingMcpDataTools.cs", "DarlingMcpQueryHeatmapTools.cs", "DarlingMcpQueryStoreClutterTools.cs", "DarlingMcpQueryStoreRegressionTools.cs", "DarlingMcpSessionTools.cs", "DarlingMcpTools.cs", "McpAnalysisTools.cs", "McpBlockingTools.cs", "McpConfigHistoryTools.cs", "McpHealthTools.cs", "McpLongQueryTools.cs", "McpMemoryTools.cs", "McpPlanCorrectionTools.cs", "McpQueryTools.cs", "McpSessionTools.cs", "McpWaitTools.cs"],
+ "the prose beside the flag: on get_analysis_findings (both SKUs) beside truncated, the WindowCoveringLimit read cap observed off a cap + 1 fetch; on get_query_store_top and get_query_store_clutter beside window_truncated, the #2364 window floor — the store's raw retention did not reach the whole requested window (on the clutter view, for the two arms that read the raw tier); on Lite's get_top_queries_by_cpu / get_top_procedures_by_cpu / get_query_store_top (#4231), the same window-floor note, from LocalDataService.GetQueryWindowFloorAsync; and on Lite's get_active_queries, get_waiting_tasks, get_query_store_regressions and get_query_heatmap (#4966), the same note beside window_truncated, built by McpQueryTools.WindowNotice with the table each one read; and on Darling's get_active_queries, get_waiting_tasks, get_query_heatmap and get_query_store_regressions (#4966), the same note beside window_truncated, built by DarlingMcpWindowNotice with the table each one read (query_snapshots, waiting_tasks, query_stats, query_store_stats); and Lite's config-change, collection-log, plan-correction and memory-pressure tools (#4966), the same note from the coverage probe"),
("findings_truncated_note", ["DarlingMcpTools.cs", "McpAnalysisTools.cs"],
"#4198: the prose beside findings_truncated — how many diagnostic chains were active in the window and that raising limit or narrowing hours_back would show more of them"),
];
@@ -1580,7 +1580,7 @@ public static readonly (string Key, string[] Files, string WhatWasCut)[] FieldPr
("deadlock_graph_xml_truncated", ["DarlingMcpBlockingTools.cs", "McpBlockingTools.cs"],
"#4198: get_deadlock_detail's own wide field — deadlock_graph_xml is a 2000-character preview by default (a busy production store measured 120,454 bytes for 3 graphs), full_graph or a dedup_key call gets the whole XML"),
("query_text_truncated", ["DarlingMcpDataTools.cs", "DarlingMcpPlanCorrectionTools.cs", "DarlingMcpQueryStoreRegressionTools.cs", "DarlingMcpSessionTools.cs", "McpPlanCorrectionTools.cs", "McpQueryTools.cs", "McpSessionTools.cs"],
- "#4198: query_text is previewed at read time by four tools: get_active_queries at 500 chars (full_text gets the whole text; a synthetic 50-row page measured 81,489 bytes), get_query_store_regressions at 240 chars (full_text opts back in; a busy production store measured 211 KB at default arguments), get_plan_corrections at 150 chars (full_text gets the whole text; the full text IS in the store, not collector-capped), get_query_store_top at 400 chars (full_text gets the whole text; query_store_stats.query_text is not collector-capped either). get_query_store_top's MCP signature forwards to an internal previewLength overload so the web viewer can keep the OLD 2000-char cap that field already had, rather than switching to full text the way get_deadlock_detail's never-capped field does"),
+ "#4198: query_text is previewed at read time by four tools: get_active_queries at 400 chars (500 until #4966's window-floor keys needed the room; full_text gets the whole text; a synthetic 50-row page measured 81,489 bytes), get_query_store_regressions at 240 chars (full_text opts back in; a busy production store measured 211 KB at default arguments), get_plan_corrections at 150 chars (full_text gets the whole text; the full text IS in the store, not collector-capped), get_query_store_top at 400 chars (full_text gets the whole text; query_store_stats.query_text is not collector-capped either). get_query_store_top's MCP signature forwards to an internal previewLength overload so the web viewer can keep the OLD 2000-char cap that field already had, rather than switching to full text the way get_deadlock_detail's never-capped field does"),
("error_message_truncated", ["DarlingMcpDataTools.cs", "McpHealthTools.cs"],
"#4198: get_collection_log's own wide field — error_message is a 500-character preview by default (a seeded store measured 90,514 bytes for 200 rows at the old 200-row default), full_text opts back into the whole (up to 4000-character, DarlingObservability.LogCollectionAsync's own write-time ceiling) field"),
("last_error_truncated", ["DarlingMcpDataTools.cs", "McpHealthTools.cs"],
diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt
index b84a88435a..7cfd6ed856 100644
--- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt
+++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt
@@ -11,7 +11,7 @@ param get_active_queries.server_name 28
tool get_session_stats 391
param get_session_stats.server_name 28
-tool get_waiting_tasks 751
+tool get_waiting_tasks 364
param get_waiting_tasks.as_of 167
param get_waiting_tasks.hours_back 28
param get_waiting_tasks.limit 141
diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs
index d6fc097e84..c407f5c93d 100644
--- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs
+++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs
@@ -118,7 +118,7 @@ so neither counts here. */
41,669 bytes (measured, every field on every collector) to 20,707 bytes, under the shared 32 KB budget. */
/* #4198 (lane TH): +193 bytes for get_active_queries' new full_query_text opt-in parameter and its
- limit description's #4198 note (query_text, the wide field, is now a 500-char preview by default;
+ limit description's #4198 note (query_text, the wide field, is now a 500-char (now 400) preview by default;
limit is 25, down from 50); the preview note itself moved after <> to stay under the head's
own 620-char target, so the served head is unchanged at 616. */
/* #4198 (lane W2): -6 bytes for renaming get_active_queries' full_query_text opt-in to full_text — the
diff --git a/Darling/Darling.Tests/RawWindowFloorQuietStartTests.cs b/Darling/Darling.Tests/RawWindowFloorQuietStartTests.cs
index 9585441269..cc73df6be8 100644
--- a/Darling/Darling.Tests/RawWindowFloorQuietStartTests.cs
+++ b/Darling/Darling.Tests/RawWindowFloorQuietStartTests.cs
@@ -10,7 +10,10 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
using Npgsql;
+using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Service.Mcp;
using PerformanceMonitor.Darling.Storage;
using Xunit;
using static Darling.Tests.RepoFile;
@@ -181,6 +184,174 @@ public void AFloorAfterTheStart_IsWhereTheWindowWasServedFrom() =>
public void NoFloor_ReportsTheRequestedStart() =>
Assert.Equal(s_start, RawWindowFloor.EffectiveStart(null, s_start));
+ private const string NotCoveredHead =
+ "The store holds no collection of query_snapshots for this server in this window, so nothing was read, and this empty answer is not a report that nothing happened. ";
+
+ private const string NotCoveredTail =
+ "The window may reach further back than the store retains, this server may have been monitored for less time than that, or collection may have stopped; get_collection_health shows which.";
+
+ private const string TruncatedSentence =
+ "The window reaches further back than this server's raw query_snapshots retains (or this server has been monitored for less time than that), so the older part of it was not read.";
+
+ private static async Task<(McpWindowNotice Notice, int Probes)> ReadAsync(
+ TimeSpan window, DateTime? floor, bool emptyAnswer, string? tail = null)
+ {
+ var probes = 0;
+ var notice = await DarlingMcpWindowNotice.ReadAsync(
+ () => { probes++; return Task.FromResult(floor); }, s_start, s_start + window, "query_snapshots", tail, emptyAnswer);
+ return (notice, probes);
+ }
+
+ /// #4966: a probe that throws costs the notice, never the answer: no verdict, no hints, and a Warning.
+ [Fact]
+ public async Task AProbeThatThrows_AnswersNoNotice_AndLogsAWarning()
+ {
+ var logger = new CapturingLogger();
+ var notice = await DarlingMcpWindowNotice.ReadAsync(
+ () => throw new TimeoutException("deadline"), s_start, s_start + TimeSpan.FromDays(7), "query_snapshots", logger: logger);
+
+ Assert.True(notice.IsUnavailable);
+ Assert.False(notice.WindowTruncated);
+ Assert.Null(notice.EffectiveStart);
+ Assert.Null(notice.AsHints());
+ Assert.Equal([LogLevel.Warning], logger.Levels);
+
+ /* An empty answer fails the same way. */
+ Assert.True((await DarlingMcpWindowNotice.ReadAsync(
+ () => throw new TimeoutException("deadline"), s_start, s_start + TimeSpan.FromHours(1), "query_snapshots", emptyAnswer: true)).IsUnavailable);
+ }
+
+ /// #4966: only the CALLER's cancellation goes through; the probe's own deadline is a failed probe.
+ [Fact]
+ public async Task ACancelledCaller_StillCancels_ButAProbeDeadlineDoesNot()
+ {
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+ await Assert.ThrowsAnyAsync(() => DarlingMcpWindowNotice.ReadAsync(
+ () => throw new OperationCanceledException(cts.Token), s_start, s_start + TimeSpan.FromDays(7), "query_snapshots", cancellationToken: cts.Token));
+
+ var notice = await DarlingMcpWindowNotice.ReadAsync(
+ () => throw new OperationCanceledException("the probe's own deadline"), s_start, s_start + TimeSpan.FromDays(7), "query_snapshots",
+ cancellationToken: CancellationToken.None);
+ Assert.True(notice.IsUnavailable);
+ }
+
+ private sealed class CapturingLogger : ILogger
+ {
+ public List Levels { get; } = [];
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) =>
+ Levels.Add(logLevel);
+ }
+
+ /// #4966: an answer with rows over a window of 90 minutes or less never asks the store, and is covered at the start that was asked for.
+ [Theory]
+ [InlineData(60)]
+ [InlineData(90)]
+ public async Task ARowsAnswer_OverNinetyMinutesOrLess_MakesNoProbe_AndIsCoveredAtTheAskedStart(int minutes)
+ {
+ var (notice, probes) = await ReadAsync(TimeSpan.FromMinutes(minutes), s_start.AddMinutes(80), emptyAnswer: false);
+
+ Assert.Equal(0, probes);
+ Assert.Equal(McpHelpers.FormatEffectiveStart(s_start), notice.EffectiveStart);
+ Assert.False(notice.WindowTruncated);
+ Assert.Null(notice.TruncationNote);
+ }
+
+ [Fact]
+ public async Task ARowsAnswer_PastNinetyMinutes_IsProbed_AndAFloorWithinTheSlackIsStillCovered()
+ {
+ var (notice, probes) = await ReadAsync(TimeSpan.FromMinutes(91), s_start.AddMinutes(90), emptyAnswer: false);
+
+ Assert.Equal(1, probes);
+ Assert.False(notice.WindowTruncated);
+ Assert.Null(notice.TruncationNote);
+ Assert.Equal(McpHelpers.FormatEffectiveStart(s_start.AddMinutes(90)), notice.EffectiveStart);
+ }
+
+ /// An empty answer is probed at any length, and a probe that finds nothing says NOT covered, with the sentence Lite says.
+ [Fact]
+ public async Task AnEmptyAnswer_AtSixtyMinutes_IsProbed_AndANullFloorIsNotCovered()
+ {
+ var (notice, probes) = await ReadAsync(TimeSpan.FromMinutes(60), floor: null, emptyAnswer: true);
+
+ Assert.Equal(1, probes);
+ Assert.Null(notice.EffectiveStart);
+ Assert.True(notice.WindowTruncated);
+ Assert.Equal(NotCoveredHead + NotCoveredTail, notice.TruncationNote);
+ }
+
+ [Fact]
+ public async Task AnEmptyAnswer_WhoseProbeFindsCoverage_IsCoveredAtTheFloor_OrTheStartWhenTheFloorIsEarlier()
+ {
+ var (inside, _) = await ReadAsync(TimeSpan.FromMinutes(60), s_start.AddMinutes(35), emptyAnswer: true);
+ Assert.Equal(McpHelpers.FormatEffectiveStart(s_start.AddMinutes(35)), inside.EffectiveStart);
+ Assert.False(inside.WindowTruncated);
+ Assert.Null(inside.TruncationNote);
+
+ var (before, _) = await ReadAsync(TimeSpan.FromMinutes(60), s_start.AddDays(-3), emptyAnswer: true);
+ Assert.Equal(McpHelpers.FormatEffectiveStart(s_start), before.EffectiveStart);
+ Assert.False(before.WindowTruncated);
+ }
+
+ [Fact]
+ public async Task AFloorPastTheSlack_IsTruncated_NamesTheTable_AndCarriesTheTail()
+ {
+ var floor = s_start.AddDays(2);
+ var (notice, probes) = await ReadAsync(TimeSpan.FromDays(7), floor, emptyAnswer: false, tail: "More.");
+
+ Assert.Equal(1, probes);
+ Assert.True(notice.WindowTruncated);
+ Assert.Equal(McpHelpers.FormatEffectiveStart(floor), notice.EffectiveStart);
+ Assert.EndsWith("Z", notice.EffectiveStart, StringComparison.Ordinal);
+ Assert.Equal(TruncatedSentence + " More.", notice.TruncationNote);
+ Assert.Equal(TruncatedSentence, (await ReadAsync(TimeSpan.FromDays(7), floor, emptyAnswer: false)).Notice.TruncationNote);
+ }
+
+ [Fact]
+ public void TheHints_CarryTheThreeKeys_WithTheSameValues()
+ {
+ var notice = DarlingMcpWindowNotice.Build(null, s_start, "waiting_tasks", emptyAnswer: true);
+ var json = System.Text.Json.JsonSerializer.SerializeToElement(notice.AsHints());
+
+ Assert.Equal(System.Text.Json.JsonValueKind.Null, json.GetProperty("effective_start").ValueKind);
+ Assert.True(json.GetProperty("window_truncated").GetBoolean());
+ Assert.Equal(notice.TruncationNote, json.GetProperty("truncation_note").GetString());
+ }
+
+ ///
+ /// The two sentences are copies of Lite's (#4966), so a client reads one wording from either app. Lite's source is
+ /// read here and must hold each sentence as a string literal, and Darling's notice must be exactly those literals
+ /// with the table put in. A reword on one side fails until the other follows.
+ ///
+ [Fact]
+ public void TheNoticeSentences_EqualLitesText()
+ {
+ var lite = ReadRepoFile("Lite", "Mcp", "McpQueryTools.cs").ReplaceLineEndings("\n");
+
+ const string head = "The store holds no collection of {table} for this server in this window, so nothing was read, and this empty answer is not a report that nothing happened. ";
+ Assert.Contains("$\"" + head + "\"", lite, StringComparison.Ordinal);
+ Assert.Contains("\"" + NotCoveredTail + "\"", lite, StringComparison.Ordinal);
+ Assert.Equal(NotCoveredHead + NotCoveredTail, DarlingMcpWindowNotice.Build(null, s_start, "query_snapshots", emptyAnswer: true).TruncationNote);
+
+ const string truncated = "The window reaches further back than this server's raw {table} retains (or this server has been monitored for less time than that), so the older part of it was not read.";
+ Assert.Contains("$\"" + truncated + "\"", lite, StringComparison.Ordinal);
+ Assert.Equal(TruncatedSentence, DarlingMcpWindowNotice.Build(s_start.AddDays(1), s_start, "query_snapshots").TruncationNote);
+ }
+
+ /// The helper probes with GetAsync: GetForServerAsync answers null for a window of 90 minutes or less, which an empty answer would read as "not covered".
+ [Fact]
+ public void TheHelperProbes_WithGetAsync_NeverGetForServerAsync()
+ {
+ var source = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpWindowNotice.cs");
+ Assert.Contains("DataWindowFloor.GetAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("GetForServerAsync(", source.Replace("", ""), StringComparison.Ordinal);
+ }
+
/// The floor walks each table's index in time order, so the walk stops at the first row it meets.
[Theory]
[InlineData("query_stats")]
diff --git a/Darling/Darling.Tests/WebDataStartNoteLiveTests.cs b/Darling/Darling.Tests/WebDataStartNoteLiveTests.cs
index 33e94fdecc..6281a00d0e 100644
--- a/Darling/Darling.Tests/WebDataStartNoteLiveTests.cs
+++ b/Darling/Darling.Tests/WebDataStartNoteLiveTests.cs
@@ -96,10 +96,14 @@ public async Task AWindowTheStoreCovered_GetsNoNote_WhenTheFirstRowComesLate_Aga
var threeDays = await store.AskAsync(QuietServerName, hours: 72, ct);
Assert.False(week["truncated"]?.GetValue());
- Assert.Null(week["window_truncated"]);
+ /* The tool's own keys (#4966) say covered: false and no note. The web adds nothing of its own, and the page draws
+ a note only for true. */
+ Assert.NotEqual(true, week["window_truncated"]?.GetValue());
Assert.Null(week["truncation_note"]);
+ Assert.Null(week["data_start_utc"]);
Assert.NotNull(week["tasks"]);
- Assert.Null(threeDays["window_truncated"]);
+ Assert.NotEqual(true, threeDays["window_truncated"]?.GetValue());
+ Assert.Null(threeDays["data_start_utc"]);
}
/// A read that hit its cap lists the newest rows only, so the note names the oldest row it returned, not
@@ -406,7 +410,7 @@ FROM information_schema.columns
/// What the web mirror answers for the grid: the tool's own payload, then the data-start note.
public async Task AskAsync(string server, int hours, CancellationToken ct)
{
- var payload = await DarlingMcpSessionTools.GetWaitingTasks(DataSource, server, hours, PageCap, null, ct);
+ var payload = await DarlingMcpSessionTools.GetWaitingTasks(DataSource, server, hours, PageCap, null, cancellationToken: ct);
var answered = await WebDataStartNote.AddAsync(DataSource, "get_waiting_tasks", server, hours, null, payload, null, ct);
return Assert.IsType(JsonNode.Parse(answered));
}
diff --git a/Darling/Darling.Tests/WebDataStartNoteTests.cs b/Darling/Darling.Tests/WebDataStartNoteTests.cs
index ed81a7bbfc..10055ea387 100644
--- a/Darling/Darling.Tests/WebDataStartNoteTests.cs
+++ b/Darling/Darling.Tests/WebDataStartNoteTests.cs
@@ -15,6 +15,7 @@
using System.Threading.Tasks;
using Npgsql;
using PerformanceMonitor.Darling.Service;
+using PerformanceMonitor.Darling.Service.Mcp;
using PerformanceMonitor.Darling.Storage;
using Xunit;
using static Darling.Tests.RepoFile;
@@ -113,7 +114,7 @@ async Task Run(string tool, string? server, int? hours, string payload)
Assert.Same(Rows, await Run("get_waiting_tasks", "sql01", 0, Rows));
// An envelope that keeps its own message (unavailable, not_collected), an error, text that is not an object, and a tool that
- // already reports its own floor. The empty envelope is not here any more (#4966): it says the read looked and found nothing,
+ // already reports its own floor (not on the listed reads any more: see below). The empty envelope is not here any more (#4966): it says the read looked and found nothing,
// so it reaches the store like rows do (WebDataStartNoteConfigAndLogTests holds that, read by read).
const string Empty = "{\"status\":\"unavailable\",\"message\":\"No waiting tasks in this window.\"}";
const string NotCollected = "{\"status\":\"not_collected\",\"message\":\"This engine has no waiting tasks.\"}";
@@ -126,7 +127,11 @@ await Assert.ThrowsAnyAsync(
Assert.Same(Failed, await Run("get_waiting_tasks", "sql01", 168, Failed));
Assert.Same("[1,2]", await Run("get_waiting_tasks", "sql01", 168, "[1,2]"));
Assert.Same("not json", await Run("get_waiting_tasks", "sql01", 168, "not json"));
- Assert.Same(Own, await Run("get_waiting_tasks", "sql01", 168, Own));
+ /* A tool's own window-floor keys on a LISTED read no longer end the web's decision (#4966: get_waiting_tasks writes them
+ itself): they are stripped and the note is decided as if they were absent, so this answer reaches the store like rows do.
+ A read outside the list is untouched whatever it carries. */
+ await Assert.ThrowsAnyAsync(() => Run("get_waiting_tasks", "sql01", 168, Own));
+ Assert.Same(Own, await Run("get_query_store_top", "sql01", 168, Own));
}
///
@@ -163,6 +168,101 @@ public async Task ACappedWaitingTasksRead_NamesTheOldestRowItReturned_WithoutAsk
Assert.Single(answer["tasks"]!.AsArray());
}
+ ///
+ /// #4966: the tool writes its own effective_start, window_truncated and truncation_note after
+ /// hours_back (the MCP dialect, in UTC). On a listed read the web still decides its note itself: a capped
+ /// get_waiting_tasks payload that carries the tool's window_truncated: false gets the web's capped
+ /// note, byte for byte what the same payload without the tool's keys gets.
+ ///
+ [Fact]
+ public async Task ACappedWaitingTasksRead_ThatCarriesTheToolsOwnWindowKeys_StillGetsTheWebsCappedNote()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ var withKeys = CappedTasks.Replace(
+ "\"hours_back\":48,",
+ "\"hours_back\":48,\"effective_start\":\"2026-01-01T00:00:00.0000000Z\",\"window_truncated\":false,\"truncation_note\":null,",
+ StringComparison.Ordinal);
+ Assert.NotEqual(CappedTasks, withKeys);
+
+ var plain = await WebDataStartNote.AddAsync(null!, "get_waiting_tasks", "sql01", 48, WindowEnd, CappedTasks, null, ct);
+ var answered = await WebDataStartNote.AddAsync(null!, "get_waiting_tasks", "sql01", 48, WindowEnd, withKeys, null, ct);
+
+ var answer = Assert.IsType(JsonNode.Parse(answered));
+ Assert.True(answer["window_truncated"]?.GetValue());
+ Assert.Equal("2026-01-02T12:30:00.0000000", answer["effective_start"]?.GetValue());
+ Assert.StartsWith("partial window: this grid shows only the newest rows, back to 2026-01-02 12:30 UTC", answer["truncation_note"]?.GetValue(), StringComparison.Ordinal);
+ Assert.Equal("2026-01-02T12:30:00.0000000Z", answer["oldest_shown_utc"]?.GetValue());
+ Assert.Equal(1, answer.Count(p => p.Key == "window_truncated"));
+
+ /* The same fields the plain payload got, with the same values (key order aside: the tool's keys sit earlier). */
+ var plainAnswer = Assert.IsType(JsonNode.Parse(plain));
+ foreach (var (key, value) in plainAnswer)
+ {
+ Assert.Equal(value?.ToJsonString(), answer[key]?.ToJsonString());
+ }
+ }
+
+ ///
+ /// #4966: the active-queries panel is not in , so the page draws the tool's
+ /// own truncation_note as it comes, through windowFloorStrip (a grid only, and only when
+ /// window_truncated is true). That is safe without a page change: the note names no instant, so the page
+ /// has no UTC time to convert and nothing in it mixes two clocks; windowNoteText hands a sentence with no
+ /// matching instant back as sent.
+ ///
+ [Fact]
+ public void AToolsOwnTruncationNote_OnAPanelOutsideTheList_NamesNoInstant_AndThePageDrawsItOnlyForATruncatedGrid()
+ {
+ Assert.DoesNotContain("get_active_queries", WebDataStartNote.TableByRead.Keys);
+ var notices = new[]
+ {
+ DarlingMcpWindowNotice.Build(null, Utc(2026, 1, 1), "query_snapshots", emptyAnswer: true).TruncationNote!,
+ DarlingMcpWindowNotice.Build(Utc(2026, 1, 3), Utc(2026, 1, 1), "query_snapshots").TruncationNote!,
+ };
+ foreach (var note in notices)
+ {
+ Assert.DoesNotMatch(@"\d{4}-\d{2}-\d{2}|\d{1,2}:\d{2}", note);
+ }
+
+ var util = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "util.js").ReplaceLineEndings("\n");
+ Assert.Contains("if (!desc || desc.windowNote === false || (desc.viz !== \"table\" && desc.viz !== \"stat\")) return null;", util, StringComparison.Ordinal);
+ Assert.Contains("if (!source || source.window_truncated !== true) return null;", util, StringComparison.Ordinal);
+ Assert.Contains("return sent;\n}", util, StringComparison.Ordinal);
+ }
+
+ ///
+ /// #4966: the query heatmap and the Query Store regressions panels are grids (table() descriptors, no
+ /// noteKey) and neither read is in , so the page draws the tool's own
+ /// truncation_note above each, through windowFloorStrip, when the answer says window_truncated: true.
+ /// It is the MCP sentence as sent: it names no instant (the regressions tail says "effective_start" and "baseline_end"
+ /// as field names), so nothing in it mixes the server's UTC with the browser's zone. An empty envelope keeps the
+ /// note under hints, which the page does not read, so it draws none there. No page change is needed.
+ ///
+ [Theory]
+ [InlineData("get_query_heatmap", "Query Heatmap", "query_stats", false)]
+ [InlineData("get_query_store_regressions", "Query Store Regressions", "query_store_stats", true)]
+ public void TheHeatmapAndRegressionsPanels_DrawTheToolsOwnTruncationNote_AsSent(string read, string title, string table, bool baselineTail)
+ {
+ Assert.DoesNotContain(read, WebDataStartNote.TableByRead.Keys);
+
+ var tabs = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server-tabs.js").ReplaceLineEndings("\n");
+ var open = tabs.IndexOf("table(\n \"" + title + "\",\n \"" + read + "\",", StringComparison.Ordinal);
+ Assert.True(open >= 0, read + " is no longer a table() panel on a server tab");
+ var call = tabs[open..tabs.IndexOf("\n ),", open, StringComparison.Ordinal)];
+ Assert.DoesNotContain("truncation_note", call, StringComparison.Ordinal);
+
+ var note = DarlingMcpWindowNotice.Build(
+ Utc(2026, 1, 3), Utc(2026, 1, 1), table,
+ baselineTail ? "Here the window starts at baseline_start, so the baseline holds only the part from effective_start to baseline_end." : null).TruncationNote!;
+ Assert.DoesNotMatch(@"\d{4}-\d{2}-\d{2}|\d{1,2}:\d{2}", note);
+ Assert.Contains("this server's raw " + table + " retains", note, StringComparison.Ordinal);
+ Assert.Equal(baselineTail, note.EndsWith("baseline_end.", StringComparison.Ordinal));
+
+ var util = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "util.js").ReplaceLineEndings("\n");
+ Assert.Contains("if (!desc || desc.windowNote === false || (desc.viz !== \"table\" && desc.viz !== \"stat\")) return null;", util, StringComparison.Ordinal);
+ Assert.Contains("if (!source || source.window_truncated !== true) return null;", util, StringComparison.Ordinal);
+ Assert.Contains("return sent;\n}", util, StringComparison.Ordinal);
+ }
+
///
/// The capped rule is for the one list ordered by time. A read that did not hit its cap, a capped answer with no
/// usable oldest row, and every other listed read, whose cap keeps the top rows of an aggregate over the whole
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
index e340647a70..ccd3fd936d 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
@@ -4341,9 +4341,9 @@ internal static IReadOnlyDictionary BuildReadDispatch(I
["get_analysis_findings"] = (c, pg, an) => DarlingMcpTools.GetAnalysisFindings(an, pg, Server(c), Hours(c, 24), Rows(c, "limit", MaxRowLimit), QueryBool(c, "include_drilldown", false), QueryBool(c, "full_text", true), as_of: AsOf(c), logger: logger, cancellationToken: c.RequestAborted),
/* ── sessions ── */
- ["get_active_queries"] = (c, pg, an) => DarlingMcpSessionTools.GetActiveQueries(pg, Server(c), Hours(c, 1), Str(c, "database_name"), QueryBool(c, "blocking_only", false), Rows(c, "limit", 50), 2000, AsOf(c), c.RequestAborted),
+ ["get_active_queries"] = (c, pg, an) => DarlingMcpSessionTools.GetActiveQueries(pg, Server(c), Hours(c, 1), Str(c, "database_name"), QueryBool(c, "blocking_only", false), Rows(c, "limit", 50), 2000, AsOf(c), logger, c.RequestAborted),
["get_session_stats"] = (c, pg, an) => DarlingMcpSessionTools.GetSessionStats(pg, Server(c), c.RequestAborted),
- ["get_waiting_tasks"] = (c, pg, an) => DarlingMcpSessionTools.GetWaitingTasks(pg, Server(c), Hours(c, 1), Rows(c, "limit", 30), as_of: AsOf(c), cancellationToken: c.RequestAborted),
+ ["get_waiting_tasks"] = (c, pg, an) => DarlingMcpSessionTools.GetWaitingTasks(pg, Server(c), Hours(c, 1), Rows(c, "limit", 30), as_of: AsOf(c), logger: logger, cancellationToken: c.RequestAborted),
/* ── alerts / mute rules ── */
["get_alert_history"] = (c, pg, an) => DarlingMcpAlertTools.GetAlertHistory(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), as_of: AsOf(c), include_dismissed: QueryBool(c, "include_dismissed", false), cancellationToken: c.RequestAborted),
@@ -4426,12 +4426,12 @@ web viewer keeps that behavior (an operator reading the Collection Log grid gets
["get_memory_clerks"] = (c, pg, an) => DarlingMcpDataTools.GetMemoryClerks(pg, Server(c), c.RequestAborted),
["get_memory_stats"] = (c, pg, an) => DarlingMcpDataTools.GetMemoryStats(pg, Server(c), c.RequestAborted),
["get_perfmon_stats"] = (c, pg, an) => DarlingMcpDataTools.GetPerfmonStats(pg, Server(c), Str(c, "counter_name"), Str(c, "instance_name"), c.RequestAborted),
- ["get_query_heatmap"] = (c, pg, an) => DarlingMcpQueryHeatmapTools.GetQueryHeatmap(pg, Server(c), Hours(c, 24), Str(c, "metric"), Str(c, "database_name"), QueryInt(c, "bucket_minutes", null, 5), Rows(c, "limit", 500), as_of: AsOf(c), cancellationToken: c.RequestAborted),
+ ["get_query_heatmap"] = (c, pg, an) => DarlingMcpQueryHeatmapTools.GetQueryHeatmap(pg, Server(c), Hours(c, 24), Str(c, "metric"), Str(c, "database_name"), QueryInt(c, "bucket_minutes", null, 5), Rows(c, "limit", 500), as_of: AsOf(c), logger: logger, cancellationToken: c.RequestAborted),
/* #4198: full_text defaults false on the MCP signature (a 240-character preview keeps a busy
production store's default call under the shared response budget), but the web viewer has
always shown the whole query text. The row pins its OWN default to true so the MCP-side
budget cut does not silently shrink what the viewer renders. */
- ["get_query_store_regressions"] = (c, pg, an) => DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(pg, Server(c), Hours(c, 24), Str(c, "database_name"), Rows(c, "limit", 50), full_text: QueryBool(c, "full_text", true), as_of: AsOf(c), cancellationToken: c.RequestAborted),
+ ["get_query_store_regressions"] = (c, pg, an) => DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(pg, Server(c), Hours(c, 24), Str(c, "database_name"), Rows(c, "limit", 50), full_text: QueryBool(c, "full_text", true), as_of: AsOf(c), logger: logger, cancellationToken: c.RequestAborted),
["get_query_store_clutter"] = (c, pg, an) => DarlingMcpQueryStoreClutterTools.GetQueryStoreClutter(pg, Server(c), Hours(c, 24), Rows(c, "limit", DarlingMcpQueryStoreClutterTools.DefaultLimit), QueryBool(c, "include_fleet_median", false), AsOf(c), c.RequestAborted),
/* #4198: query_text already had a 2000-character cap before this tool had a full_text opt-in at
all, so the viewer keeps that exact number through the previewLength overload -- QueryBool
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs
index 9b29daec2d..48e2c34aff 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs
@@ -13,6 +13,7 @@
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
using Npgsql;
using PerformanceMonitor.Common;
@@ -67,6 +68,7 @@ public static async Task GetQueryHeatmap(
[Description("Maximum CELLS to return, most recent bins first. Default 100. A full day of 5-minute bins can reach 2,016 cells on a busy server; raise bucket_minutes rather than the cap to see the whole window.")] int limit = DefaultCellLimit,
[Description(McpHelpers.AsOfDescription)] string? as_of = null,
[Description("Return each cell's top query at full length instead of an 80-character preview. Default false.")] bool full_text = false,
+ ILogger? logger = null,
CancellationToken cancellationToken = default)
{
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken);
@@ -109,8 +111,16 @@ cell cap.
var rows = await DarlingQueryHeatmapReader.GetQueryHeatmapAsync(
postgres, resolved.ServerId, parsedMetric, start, end, database_name, bucket_minutes, limit + 1, previewLength, cancellationToken);
+ /* #4966: where this server's query_stats start, read ahead of the empty answers, which carry it too: an empty grid
+ over a window the table does not reach back to is not a report that nothing ran. A window of 90 minutes or
+ less that answered cells is covered without a probe. Unfiltered by database_name (the floor is a property of
+ the table). */
+ var notice = await DarlingMcpWindowNotice.ReadAsync(
+ () => DarlingMcpWindowNotice.Probe(postgres, "query_stats", resolved.ServerName, start, end, cancellationToken),
+ start, end, "query_stats", emptyAnswer: rows.Count == 0, logger: logger, cancellationToken: cancellationToken);
+
if (rows.Count == 0)
- return await EmptyAsync(postgres, resolved.ServerName, resolved.ServerId, start, end, hours_back, cancellationToken);
+ return await EmptyAsync(postgres, resolved.ServerName, resolved.ServerId, start, end, hours_back, notice, cancellationToken);
var truncated = rows.Count > limit;
var cells = rows.Take(limit).ToList();
@@ -141,10 +151,15 @@ because the SQL sorts time DESC and bucket ASC. The DESC exists only so the cap
var labels = DarlingQueryHeatmapReader.BucketLabels[parsedMetric];
- return JsonSerializer.Serialize(new
+ var json = JsonSerializer.Serialize(new
{
server = resolved.ServerName,
hours_back,
+ /* The window floor, always present (false and null when the table covered the window). No effective_hours_back:
+ this payload carries a page `truncated`, and the census holds that key apart for the window floor. */
+ effective_start = notice.EffectiveStart,
+ window_truncated = notice.WindowTruncated,
+ truncation_note = notice.TruncationNote,
metric = DarlingQueryHeatmapReader.MetricName(parsedMetric),
metric_unit = DarlingQueryHeatmapReader.MetricUnit(parsedMetric),
database_name,
@@ -185,6 +200,9 @@ way to see how much of the window is missing. */
top_query_text_truncated = c.TopQueryTextTruncated,
}),
}, McpHelpers.JsonOptions);
+
+ /* A failed probe costs the notice, never the cells (see DarlingMcpWindowNotice.ReadAsync). */
+ return notice.IsUnavailable ? DarlingMcpWindowNotice.WithoutKeys(json) : json;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -205,7 +223,7 @@ way to see how much of the window is missing. */
///
private static async Task EmptyAsync(
NpgsqlDataSource postgres, string serverName, int serverId, DateTime start, DateTime end, int hours_back,
- CancellationToken cancellationToken)
+ McpWindowNotice notice, CancellationToken cancellationToken)
{
var (hasAny, hasInWindow) = await DarlingQueryHeatmapReader.GetCoverageAsync(postgres, serverId, start, end, cancellationToken);
@@ -221,11 +239,13 @@ private static async Task EmptyAsync(
{
return McpHelpers.Status(
"empty",
- $"{serverName} has query stats from outside this window but nothing collected IN the last {hours_back} hour(s), so the grid has no columns rather than no hot cells. Widen hours_back, or check get_collection_health — a collector that stopped looks exactly like this.");
+ $"{serverName} has query stats from outside this window but nothing collected IN the last {hours_back} hour(s), so the grid has no columns rather than no hot cells. Widen hours_back, or check get_collection_health — a collector that stopped looks exactly like this.",
+ notice.AsHints());
}
return McpHelpers.Status(
"empty",
- $"Query stats WERE collected for {serverName} in the last {hours_back} hour(s), but no capture recorded an execution: every row carried a zero execution delta, so nothing lands on the grid. A server that is up and idle looks exactly like this, and so does a database_name filter matching nothing collected. Delta-based collection also needs a SECOND cycle before the first non-zero row exists.");
+ $"Query stats WERE collected for {serverName} in the last {hours_back} hour(s), but no capture recorded an execution: every row carried a zero execution delta, so nothing lands on the grid. A server that is up and idle looks exactly like this, and so does a database_name filter matching nothing collected. Delta-based collection also needs a SECOND cycle before the first non-zero row exists.",
+ notice.AsHints());
}
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs
index 34283b16d4..b19e4a7a31 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs
@@ -13,6 +13,7 @@
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
using Npgsql;
using PerformanceMonitor.Common;
@@ -53,6 +54,7 @@ public static async Task GetQueryStoreRegressions(
[Description("Maximum rows to return, worst first. Default 30, sized to keep a default call under the shared response budget. Read truncated to know whether the window held more.")] int limit = 30,
[Description("Return each row's full query text instead of a 240-character preview. Default false.")] bool full_text = false,
[Description(McpHelpers.AsOfDescription)] string? as_of = null,
+ ILogger? logger = null,
CancellationToken cancellationToken = default)
{
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken);
@@ -77,16 +79,33 @@ public static async Task GetQueryStoreRegressions(
var rows = await DarlingQueryStoreRegressionReader.GetQueryStoreRegressionsAsync(
postgres, resolved.ServerId, start, end, database_name, limit + 1, baselineStart, cancellationToken);
+ /* #4966: where this server's query_store_stats start, checked against the EARLIER of the two windows this tool
+ compares: the baseline's start, not the recent window's. A store that began inside the baseline compares
+ against a shorter baseline than baseline_start names, and nothing else on the payload says so. Read ahead of the
+ empty answers, which carry it too: "no query regressed" over a baseline the store only partly holds is not a true
+ negative. Unfiltered by database_name (the floor is a property of the table). */
+ var notice = await DarlingMcpWindowNotice.ReadAsync(
+ () => DarlingMcpWindowNotice.Probe(postgres, "query_store_stats", resolved.ServerName, baselineStart, end, cancellationToken),
+ baselineStart, end, "query_store_stats",
+ "Here the window starts at baseline_start, so the baseline holds only the part from effective_start to baseline_end.",
+ emptyAnswer: rows.Count == 0, logger: logger, cancellationToken: cancellationToken);
+
if (rows.Count == 0)
- return await EmptyAsync(postgres, resolved.ServerName, resolved.ServerId, start, end, baselineStart, hours_back, database_name, cancellationToken);
+ return await EmptyAsync(postgres, resolved.ServerName, resolved.ServerId, start, end, baselineStart, hours_back, database_name, notice, cancellationToken);
var truncated = rows.Count > limit;
var shown = rows.Take(limit);
- return JsonSerializer.Serialize(new
+ var json = JsonSerializer.Serialize(new
{
server = resolved.ServerName,
hours_back,
+ /* The window floor, always present (false and null when the store covered the baseline). No effective_hours_back:
+ this payload carries a page `truncated`, and the census holds that key apart for the window floor, so the reach
+ is the instant. */
+ effective_start = notice.EffectiveStart,
+ window_truncated = notice.WindowTruncated,
+ truncation_note = notice.TruncationNote,
database_name,
/*
Named so the caller cannot mistake which side is which. "baseline" is NOT a fixed
@@ -138,6 +157,9 @@ 5 ms regression executed a million times outranks a 5-second one executed twice.
query_text_truncated = !full_text && r.QueryTextSample.Length > QueryTextPreviewLength,
}),
}, McpHelpers.JsonOptions);
+
+ /* A failed probe costs the notice, never the rows (see DarlingMcpWindowNotice.ReadAsync). */
+ return notice.IsUnavailable ? DarlingMcpWindowNotice.WithoutKeys(json) : json;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -190,7 +212,7 @@ void Note(string field, string baseline, string recent)
///
private static async Task EmptyAsync(
NpgsqlDataSource postgres, string serverName, int serverId, DateTime start, DateTime end, DateTime baselineStart, int hours_back,
- string? databaseName, CancellationToken cancellationToken)
+ string? databaseName, McpWindowNotice notice, CancellationToken cancellationToken)
{
var (hasBaseline, hasRecent) = await DarlingQueryStoreRegressionReader.GetCoverageAsync(
postgres, serverId, start, end, baselineStart, cancellationToken: cancellationToken);
@@ -214,7 +236,8 @@ private static async Task EmptyAsync(
{
return McpHelpers.Status(
"empty",
- $"{serverName} has Query Store history from before this window but nothing collected IN the last {hours_back} hour(s), so there is a recent side missing rather than nothing to report. Widen hours_back, or check get_collection_health — a collector that stopped looks exactly like this.");
+ $"{serverName} has Query Store history from before this window but nothing collected IN the last {hours_back} hour(s), so there is a recent side missing rather than nothing to report. Widen hours_back, or check get_collection_health — a collector that stopped looks exactly like this.",
+ notice.AsHints());
}
/* #5015: the probe above is the SERVER's, so it answers a database_name filter the same whether the database was
@@ -232,7 +255,8 @@ it the same. */
{
return McpHelpers.Status(
"empty",
- $"No Query Store capture on {serverName} in the last {hours_back} hour(s) or in the {DarlingQueryStoreRegressionReader.BaselineLookbackDays}-day baseline before it matched database_name '{databaseName}', so the filter matched nothing and this is NOT the all-clear: no query in that database was compared. Check the database name, or drop the filter to read every database.");
+ $"No Query Store capture on {serverName} in the last {hours_back} hour(s) or in the {DarlingQueryStoreRegressionReader.BaselineLookbackDays}-day baseline before it matched database_name '{databaseName}', so the filter matched nothing and this is NOT the all-clear: no query in that database was compared. Check the database name, or drop the filter to read every database.",
+ notice.AsHints());
}
if (!filteredBaseline)
@@ -246,12 +270,14 @@ it the same. */
{
return McpHelpers.Status(
"empty",
- $"{serverName} has Query Store history of database_name '{databaseName}' from before this window but nothing of it collected IN the last {hours_back} hour(s), so that database's recent side is missing rather than nothing to report, and this is NOT the all-clear: no query in that database was compared. Widen hours_back, or check get_collection_health — a collector that stopped looks exactly like this.");
+ $"{serverName} has Query Store history of database_name '{databaseName}' from before this window but nothing of it collected IN the last {hours_back} hour(s), so that database's recent side is missing rather than nothing to report, and this is NOT the all-clear: no query in that database was compared. Widen hours_back, or check get_collection_health — a collector that stopped looks exactly like this.",
+ notice.AsHints());
}
}
return McpHelpers.Status(
"empty",
- $"No query on {serverName} regressed in the last {hours_back} hour(s). Both a baseline and this window were collected and no query's average CPU is more than 25% worse than its baseline — this IS the all-clear for this read.");
+ $"No query on {serverName} regressed in the last {hours_back} hour(s). Both a baseline and this window were collected and no query's average CPU is more than 25% worse than its baseline — this IS the all-clear for this read.",
+ notice.AsHints());
}
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs
index ae9e06bf8e..ce813e729a 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs
@@ -13,6 +13,7 @@
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
using Npgsql;
using PerformanceMonitor.Common;
@@ -108,15 +109,15 @@ public static async Task GetSessionStats(
/// the shape those already use). truncated / total_snapshots
/// already tell a caller who wants more to raise limit or narrow hours_back. The web viewer is not this
/// budget's caller (#4198 lane W2): its /api/read mirror keeps the pre-#4198 2,000-character
- /// preview every caller got before this tool's default fell to 500, through the internal overload below
+ /// preview every caller got before this tool's default fell to 500 and then, with the window-floor keys (#4966), to 400, through the internal overload below
/// (the split #3897's trend tools use — one MCP-facing method, one budget-taking overload).
///
- private const int QueryTextPreviewLength = 500;
+ private const int QueryTextPreviewLength = 400;
///
private const int DefaultLimit = 25;
- [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> query_text is a preview by default (query_text_truncated: true) — pass full_text for the whole text. Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocking has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")]
+ [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> query_text is a preview by default (query_text_truncated: true) — pass full_text for the whole text. Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocking has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit). When the store did not cover the whole window, effective_start, window_truncated and truncation_note say where its data starts.")]
public static Task GetActiveQueries(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
@@ -124,11 +125,12 @@ public static Task GetActiveQueries(
[Description("Filter to a specific database. Applied in SQL; a head blocker in ANOTHER database is then not on the page, and its victims say blocker_not_shown = filtered.")] string? database_name = null,
[Description("Show only queries involved in blocking: rows with blocking_session_id > 0, plus the head blockers those rows name in the same capture.")] bool blocking_only = false,
[Description("Maximum number of rows to return. Default 25 (#4198, down from 50 — sized to fit the response budget). The page is bounded by limit, not hours_back — truncated says whether the window held more.")] int limit = DefaultLimit,
- [Description("Return each row's full query text instead of a 500-character preview. Default false.")] bool full_text = false,
+ [Description("Return each row's full query text instead of a 400-character preview. Default false.")] bool full_text = false,
[Description(McpHelpers.AsOfDescription)] string? as_of = null,
+ ILogger? logger = null,
CancellationToken cancellationToken = default) =>
GetActiveQueries(postgres, server_name, hours_back, database_name, blocking_only, limit,
- full_text ? null : QueryTextPreviewLength, as_of, cancellationToken);
+ full_text ? null : QueryTextPreviewLength, as_of, logger, cancellationToken);
///
/// get_active_queries under an explicit query-text preview length (#4198 lane W2): the MCP tool above
@@ -141,7 +143,7 @@ public static Task GetActiveQueries(
///
internal static async Task GetActiveQueries(
NpgsqlDataSource postgres, string? server_name, int hours_back, string? database_name, bool blocking_only,
- int limit, int? queryTextPreviewLength, string? as_of, CancellationToken cancellationToken = default)
+ int limit, int? queryTextPreviewLength, string? as_of, ILogger? logger = null, CancellationToken cancellationToken = default)
{
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken);
if (error != null) return error;
@@ -164,6 +166,13 @@ row past the cap so truncation is OBSERVED on the filtered population rather tha
postgres, resolved.ServerId, now.AddHours(-hours_back), now, limit + 1, filter, blocking_only, cancellationToken);
var rows = page.Rows;
+ /* #4966: where the store's coverage of the window starts, in the three keys every window-floor tool writes. An answer
+ with rows over a window of 90 minutes or less needs no probe; an empty one is always probed. */
+ var windowStart = now.AddHours(-hours_back);
+ var notice = await DarlingMcpWindowNotice.ReadAsync(
+ () => DarlingMcpWindowNotice.Probe(postgres, "query_snapshots", resolved.ServerName, windowStart, now, cancellationToken),
+ windowStart, now, "query_snapshots", emptyAnswer: rows.Count == 0, logger: logger, cancellationToken: cancellationToken);
+
if (rows.Count == 0)
{
/* A filtered miss is not a collection miss: with the filters in the query, an empty page under
@@ -174,11 +183,12 @@ database_name or blocking_only means the window held no snapshot matching them.
"empty",
$"No active query snapshots on {resolved.ServerName} in the last {hours_back} hour(s) matched "
+ DescribeActiveQueryFilters(filter, blocking_only)
- + ". The filters were applied in SQL over the whole window, so unfiltered snapshots may well exist — drop them to see what the window holds.");
+ + ". The filters were applied in SQL over the whole window, so unfiltered snapshots may well exist — drop them to see what the window holds.",
+ notice.AsHints());
}
return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "query_snapshots", cancellationToken)
- ?? McpHelpers.Status("empty", "No active query snapshots found in the requested time range.");
+ ?? McpHelpers.Status("empty", "No active query snapshots found in the requested time range.", notice.AsHints());
}
var truncated = rows.Count > limit;
@@ -221,10 +231,16 @@ database_name or blocking_only means the window held no snapshot matching them.
query_text_truncated = queryTextPreviewLength.HasValue && (r.QueryText?.Length ?? 0) > queryTextPreviewLength.Value
}).ToList();
- return JsonSerializer.Serialize(new
+ var payload = new
{
server = resolved.ServerName,
hours_back,
+ /* #4966: the window floor. Written only when the store did NOT cover the window (see below). No
+ effective_hours_back: this payload carries a page `truncated`, and the census holds that key apart for the window
+ floor (McpPayloadContractCensusTests), so the reach is the instant. */
+ effective_start = notice.EffectiveStart,
+ window_truncated = notice.WindowTruncated,
+ truncation_note = notice.TruncationNote,
filters_applied = new
{
database_name = filter,
@@ -240,7 +256,20 @@ database_name or blocking_only means the window held no snapshot matching them.
oldest_returned_collection_time = McpHelpers.FormatEffectiveStart(shown[^1].CollectionTime),
newest_returned_collection_time = McpHelpers.FormatEffectiveStart(shown[0].CollectionTime),
queries = result
- }, McpHelpers.JsonOptions);
+ };
+
+ /* The house omit-false rule: a covered window carries none of the three keys, so a default call stays inside
+ McpResponseBudget.DefaultBytes (they cost ~110 bytes). A cut window carries all three, in place. A failed probe
+ has no verdict (WindowTruncated is false), so it carries none either: the rows go out without the notice. */
+ var node = JsonSerializer.SerializeToNode(payload, McpHelpers.JsonOptions)!.AsObject();
+ if (!notice.WindowTruncated)
+ {
+ node.Remove("effective_start");
+ node.Remove("window_truncated");
+ node.Remove("truncation_note");
+ }
+
+ return node.ToJsonString(McpHelpers.JsonOptions);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -275,13 +304,14 @@ private static string DescribeActiveQueryFilters(string? databaseName, bool bloc
return "blocking_only";
}
- [McpServerTool(Name = "get_waiting_tasks"), Description("Gets recently captured waiting tasks — queries that were actively waiting on a resource at collection time — NEWEST CAPTURE FIRST, longest wait first within a capture. Shows session ID, wait type, duration, blocking session, and database. Complements get_wait_stats by showing individual waiting queries rather than aggregated stats. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: tasks_returned is how many rows you got, truncated says the window held more than limit, and oldest_returned_collection_time / newest_returned_collection_time bound the page — under newest-first ordering the oldest stamp IS how far back this read reached, and one busy capture can fill the whole page by itself. Raise limit or narrow hours_back when truncated is true.")]
+ [McpServerTool(Name = "get_waiting_tasks"), Description("Gets recently captured waiting tasks — queries that were actively waiting on a resource at collection time — NEWEST CAPTURE FIRST, longest wait first within a capture. Shows session ID, wait type, duration, blocking session, and database. Complements get_wait_stats by showing individual waiting queries rather than aggregated stats. <> THE PAGE IS BOUNDED BY limit, NOT BY hours_back: tasks_returned is how many rows you got, truncated says the window held more than limit, and oldest_returned_collection_time / newest_returned_collection_time bound the page — under newest-first ordering the oldest stamp IS how far back this read reached, and one busy capture can fill the whole page by itself. Raise limit or narrow hours_back when truncated is true. When the store did not cover the whole window, effective_start, window_truncated and truncation_note say where its data starts.")]
public static async Task GetWaitingTasks(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
[Description("Hours of history. Default 1.")] int hours_back = 1,
[Description("Maximum rows to return, newest capture first. Default 30. This is what bounds the page — read truncated to know whether the window held more.")] int limit = 30,
[Description(McpHelpers.AsOfDescription)] string? as_of = null,
+ ILogger? logger = null,
CancellationToken cancellationToken = default)
{
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken);
@@ -299,9 +329,15 @@ public static async Task GetWaitingTasks(
signal. The reader's LIMIT 500 was invisible, and the envelope stated no bound at all. */
var rows = await DarlingSessionReader.GetWaitingTasksAsync(
postgres, resolved.ServerId, now.AddHours(-hours_back), now, limit + 1, cancellationToken);
+
+ /* #4966: the window floor, as get_active_queries writes it (see there). */
+ var windowStart = now.AddHours(-hours_back);
+ var notice = await DarlingMcpWindowNotice.ReadAsync(
+ () => DarlingMcpWindowNotice.Probe(postgres, "waiting_tasks", resolved.ServerName, windowStart, now, cancellationToken),
+ windowStart, now, "waiting_tasks", emptyAnswer: rows.Count == 0, logger: logger, cancellationToken: cancellationToken);
if (rows.Count == 0)
return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "waiting_tasks", cancellationToken)
- ?? McpHelpers.Status("empty", "No waiting tasks captured in the specified time range.");
+ ?? McpHelpers.Status("empty", "No waiting tasks captured in the specified time range.", notice.AsHints());
var truncated = rows.Count > limit;
var page = truncated ? rows.Take(limit).ToList() : rows;
@@ -317,12 +353,16 @@ public static async Task GetWaitingTasks(
collection_time = r.CollectionTime.ToString("o")
});
- return JsonSerializer.Serialize(new
+ var json = JsonSerializer.Serialize(new
{
server = resolved.ServerName,
/* #3541 A3: the envelope was bare — server and rows, no window, no count, no bound. Now the
span requested, the page described as a page, and the span the page covers. */
hours_back,
+ /* #4966: the window floor, always present on a data answer; no effective_hours_back, for the reason given on get_active_queries. */
+ effective_start = notice.EffectiveStart,
+ window_truncated = notice.WindowTruncated,
+ truncation_note = notice.TruncationNote,
tasks_returned = page.Count,
truncated,
/* #4966: where the page's rows stop describes the window the page covers, so it prints like
@@ -332,6 +372,9 @@ public static async Task GetWaitingTasks(
order = "collection_time_desc",
tasks = result
}, McpHelpers.JsonOptions);
+
+ /* A failed probe costs the notice, never the rows (see DarlingMcpWindowNotice.ReadAsync). */
+ return notice.IsUnavailable ? DarlingMcpWindowNotice.WithoutKeys(json) : json;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpWindowNotice.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpWindowNotice.cs
new file mode 100644
index 0000000000..63de0bcfa3
--- /dev/null
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpWindowNotice.cs
@@ -0,0 +1,156 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Npgsql;
+using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Storage;
+
+namespace PerformanceMonitor.Darling.Service.Mcp;
+
+///
+/// The three window-floor keys answers, as the values a tool writes into its
+/// payload (#4966). is null only for an empty answer over a window the store holds
+/// nothing in: there is no start to name.
+///
+internal readonly record struct McpWindowNotice(string? EffectiveStart, bool WindowTruncated, string? TruncationNote)
+{
+ ///
+ /// The notice a failed coverage probe leaves (#4966): there is no verdict on the window, so a tool answers without the
+ /// three keys (a data answer) or without hints (an empty one). A failed probe costs the notice, never the rows, the rule
+ /// the web's own note follows. Not , so a tool that writes its keys only for a cut window
+ /// writes none.
+ ///
+ internal static McpWindowNotice Unavailable { get; } = new(null, false, null) { IsUnavailable = true };
+
+ /// True for : the probe failed, so no key and no hint may be written.
+ internal bool IsUnavailable { get; init; }
+
+ ///
+ /// The same three keys, with the same values and wording, for an empty status, which carries them under
+ /// hints: an empty answer over a window the store does not reach back to is not a true negative, and without
+ /// them it read as one. A status that says the collector never ran (not_collected) does not carry them.
+ /// A notice from a failed probe has no hints (null), which leaves out.
+ ///
+ internal object? AsHints() => IsUnavailable ? null : new { effective_start = EffectiveStart, window_truncated = WindowTruncated, truncation_note = TruncationNote };
+}
+
+///
+/// The window-floor notice a time-ranged MCP tool writes beside hours_back (#4966): effective_start,
+/// window_truncated and truncation_note, the shape and the wording Lite's tools carry
+/// (McpQueryTools.WindowNotice), so a client reads one dialect from either app. The two sentences are copied
+/// from Lite and held equal to it by a test.
+///
+/// The coverage probe is , never :
+/// the latter answers null for any window of 90 minutes or less, and on an empty answer that null reads as "not
+/// covered", so every default one-hour call that found nothing would say the store holds no collection.
+///
+internal static class DarlingMcpWindowNotice
+{
+ ///
+ /// The notice for a probe's (where coverage starts, null for none). On an empty answer
+ /// () a null floor means the store holds no collection for the server in the window,
+ /// and the notice says NOT covered: window_truncated true, no effective_start, and a note that says
+ /// so. On an answer WITH rows a null floor means covered, at the start that was asked for.
+ /// is one more sentence a truncated note carries.
+ ///
+ internal static McpWindowNotice Build(
+ DateTime? floor, DateTime requestedStart, string table, string? tail = null, bool emptyAnswer = false)
+ {
+ if (floor is null && emptyAnswer)
+ {
+ return new McpWindowNotice(
+ null,
+ true,
+ $"The store holds no collection of {table} for this server in this window, so nothing was read, and this empty answer is not a report that nothing happened. "
+ + "The window may reach further back than the store retains, this server may have been monitored for less time than that, or collection may have stopped; get_collection_health shows which.");
+ }
+
+ var truncated = RawWindowFloor.IsTruncated(floor, requestedStart);
+ return new McpWindowNotice(
+ McpHelpers.FormatEffectiveStart(RawWindowFloor.EffectiveStart(floor, requestedStart)),
+ truncated,
+ truncated
+ ? $"The window reaches further back than this server's raw {table} retains (or this server has been monitored for less time than that), so the older part of it was not read."
+ + (tail is null ? "" : " " + tail)
+ : null);
+ }
+
+ ///
+ /// with its coverage probe, which a window no longer than
+ /// that answered rows never needs: the probe cannot find a floor
+ /// later than the start by more than the slack, so the answer is covered whatever it would read. The probe is a
+ /// delegate and is not started for such a window. An EMPTY answer is always probed, whatever the window's length:
+ /// nothing was read, so nothing else says the store held the window at all.
+ ///
+ /// A probe that throws, other than because the CALLER cancelled, is logged at Warning and answers
+ /// : the probe is a second read on a shared store, and it must not turn a page
+ /// of rows that was already read into an error.
+ ///
+ internal static async Task ReadAsync(
+ Func> probe, DateTime requestedStart, DateTime windowEnd, string table, string? tail = null, bool emptyAnswer = false,
+ ILogger? logger = null, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(probe);
+ try
+ {
+ var floor = emptyAnswer || windowEnd - requestedStart > DurationTrendRouting.TruncationSlack ? await probe() : null;
+ return Build(floor, requestedStart, table, tail, emptyAnswer);
+ }
+ catch (Exception ex) when (!(ex is OperationCanceledException && cancellationToken.IsCancellationRequested))
+ {
+ logger?.LogWarning(ex, "The coverage probe of {Table} failed; the answer goes without its window-floor notice.", table);
+ return McpWindowNotice.Unavailable;
+ }
+ }
+
+ ///
+ /// A payload already serialized with the three window-floor keys, without them: what a tool answers when its probe failed
+ /// (). Only top-level keys are touched, and the order of the rest is kept.
+ ///
+ internal static string WithoutKeys(string json)
+ {
+ var node = JsonNode.Parse(json)!.AsObject();
+ node.Remove("effective_start");
+ node.Remove("window_truncated");
+ node.Remove("truncation_note");
+ return node.ToJsonString(McpHelpers.JsonOptions);
+ }
+
+ /// A test's stand-in for the coverage probe, per async flow (null: the store is asked).
+ private static readonly AsyncLocal>?> s_testOnlyProbe = new();
+
+ /// Test seam: while set, runs this instead of the store read.
+ internal static Func>? TestOnlyProbe
+ {
+ get => s_testOnlyProbe.Value;
+ set => s_testOnlyProbe.Value = value;
+ }
+
+ /// The coverage probe for one collector table and one server over [, ].
+ internal static Task Probe(
+ NpgsqlDataSource postgres, string table, string serverName, DateTime start, DateTime end, CancellationToken cancellationToken) =>
+ Probe(postgres, DataWindowFloor.Source.ForCollectorTable(table), serverName, start, end, cancellationToken);
+
+ /// The coverage probe over a source the caller built (the collection log, one collector's runs, a stitched read).
+ internal static Task Probe(
+ NpgsqlDataSource postgres, DataWindowFloor.Source source, string serverName, DateTime start, DateTime end, CancellationToken cancellationToken)
+ {
+ var stub = TestOnlyProbe;
+ if (stub != null)
+ {
+ return stub();
+ }
+
+ return DataWindowFloor.GetAsync(postgres, [source], [serverName], start, end, StorageCommandDeadlines.McpReadSeconds, cancellationToken);
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Service/WebDataStartNote.cs b/Darling/PerformanceMonitor.Darling.Service/WebDataStartNote.cs
index 0e80a3ed2c..9c79746c03 100644
--- a/Darling/PerformanceMonitor.Darling.Service/WebDataStartNote.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/WebDataStartNote.cs
@@ -204,12 +204,43 @@ internal static string FormatWindowEnd(DateTime endUtc) =>
"get_plan_corrections",
};
+ ///
+ /// The three window-floor keys a listed read's tool writes itself at the top level of a data answer (#4966).
+ ///
+ private static readonly string[] ToolWindowFloorKeys = ["effective_start", "window_truncated", "truncation_note"];
+
+ ///
+ /// Removes the tool's own window-floor keys from a data answer (an empty status keeps its copy under
+ /// hints, which the page does not read). The guard below then sees the payload as it was before the tool
+ /// wrote them, so a tool's window_truncated: false can neither hide the web's capped note nor stand in for it.
+ ///
+ private static void StripToolWindowFloor(JsonObject? payload)
+ {
+ if (payload is null)
+ {
+ return;
+ }
+
+ foreach (var key in ToolWindowFloorKeys)
+ {
+ payload.Remove(key);
+ }
+ }
+
///
/// with the notice fields added when is a listed grid read whose
/// window starts before its table's coverage, or is a newest-first list () that
/// hit its row cap; otherwise itself, untouched.
/// is the window the page asked for (null or below 1: none was asked), and
/// the request's window anchor.
+ /// A listed read's tool may write the same three keys itself (#4966: effective_start,
+ /// window_truncated, truncation_note, the MCP dialect, in UTC). The page draws its own note from the
+ /// fields this method adds, in the browser's zone, so the tool's three are removed first and the note is decided as
+ /// it was before the tool wrote them: the page gets the same answer, capped or coverage. When a note is added, the
+ /// payload that carries it is the stripped one, so the tool's three keys are gone from it. When no note is added
+ /// (every early return: covered, a short window, a failed probe, a capped page that reaches the start), the string
+ /// goes back as the tool wrote it, and still holds the tool's keys. That is harmless today: the tool and this method run
+ /// the same probe over the same window, so their verdicts agree.
///
internal static async Task AddAsync(
NpgsqlDataSource postgres, string tool, string? server, int? hoursBack, string? asOf, string result,
@@ -234,6 +265,9 @@ internal static async Task AddAsync(
return result;
}
+ /* Strips the parsed copy only: the early returns below send `result` itself, keys and all. */
+ StripToolWindowFloor(payload);
+
/* Rows, or the answer that says the read looked and found nothing (#4966, NothingFoundStatusByRead): an empty span
over a short history reads as "nothing happened" when it is only short, so that answer gets the note too. The
probe counts a server only when it holds a row or logged a run in the window, so a server that did neither is
diff --git a/Lite.Tests/McpToolsListBudget/McpWaitTools.txt b/Lite.Tests/McpToolsListBudget/McpWaitTools.txt
index 3fbb238a2a..8c47595e2d 100644
--- a/Lite.Tests/McpToolsListBudget/McpWaitTools.txt
+++ b/Lite.Tests/McpToolsListBudget/McpWaitTools.txt
@@ -17,7 +17,7 @@ param get_wait_types.as_of 167
param get_wait_types.hours_back 29
param get_wait_types.server_name 28
-tool get_waiting_tasks 751
+tool get_waiting_tasks 364
param get_waiting_tasks.as_of 167
param get_waiting_tasks.hours_back 28
param get_waiting_tasks.limit 141
diff --git a/Lite.Tests/McpWindowNoticeConfigAndLogToolTests.cs b/Lite.Tests/McpWindowNoticeConfigAndLogToolTests.cs
new file mode 100644
index 0000000000..fcd767f421
--- /dev/null
+++ b/Lite.Tests/McpWindowNoticeConfigAndLogToolTests.cs
@@ -0,0 +1,620 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text.Json;
+using System.Threading.Tasks;
+using DuckDB.NET.Data;
+using PerformanceMonitorLite.Database;
+using PerformanceMonitorLite.Mcp;
+using PerformanceMonitorLite.Models;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #4966: where the data starts, on six Lite tools: the three config-change tools (get_server_config_changes,
+/// get_database_config_changes, get_trace_flag_changes), the one-server form of get_collection_log,
+/// get_plan_corrections and get_memory_pressure_events. Each publishes effective_start,
+/// window_truncated and truncation_note beside hours_back on a data answer, and the same three keys
+/// under hints on an empty one, from the coverage probe ()
+/// the other window-floor tools use. None of them uses the event-time form (McpQueryTools.EventWindowNoticeAsync): a
+/// config change is stamped with the capture time of the snapshot that showed it, a plan-correction row and a log run with the
+/// run's own time, and the memory pressure probe reads sample_time, the column the list is windowed on, so the oldest row
+/// shown can never be older than the probe's floor. Every window is anchored at a fixed as_of, never the clock. Own
+/// per test, like .
+///
+public sealed class McpWindowNoticeConfigAndLogToolTests : IDisposable
+{
+ private const string ServerName = "ConfigLogNoticeServer";
+ private const string AsOf = "2026-09-10T12:00:00Z";
+ private const int HoursBack = 168;
+ private static readonly DateTime Anchor = new(2026, 9, 10, 12, 0, 0, DateTimeKind.Utc);
+ private static readonly DateTime WindowStart = Anchor.AddHours(-HoursBack);
+
+ private readonly int _serverId;
+ private readonly string _tempDir;
+ private readonly DuckDbInitializer _duckDb;
+ private readonly ServerManager _serverManager;
+ private long _nextId = 1;
+
+ /// The tools under test. The collection log is the one-server form.
+ public enum Tool
+ {
+ ServerConfig,
+ DatabaseConfig,
+ TraceFlags,
+ CollectionLog,
+ PlanCorrections,
+ MemoryPressure
+ }
+
+ public McpWindowNoticeConfigAndLogToolTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "McpWindowNoticeConfigLog_" + Guid.NewGuid().ToString("N")[..8]);
+ Directory.CreateDirectory(Path.Combine(_tempDir, "config"));
+ _duckDb = new DuckDbInitializer(Path.Combine(_tempDir, "test.duckdb"));
+
+ _serverManager = new ServerManager(Path.Combine(_tempDir, "config"));
+ var server = new ServerConnection { ServerName = ServerName, DisplayName = ServerName };
+ _serverManager.AddServer(server);
+ _serverId = RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(server));
+ }
+
+ public void Dispose()
+ {
+ try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); }
+ catch { /* best-effort cleanup */ }
+ }
+
+ private static DateTime Naive(DateTime instant) => DateTime.SpecifyKind(instant, DateTimeKind.Unspecified);
+
+ private static DateTime ParseUtc(string text) =>
+ DateTime.Parse(text, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
+
+ private static JsonElement Root(string json) => JsonDocument.Parse(json).RootElement;
+
+ private LocalDataService Service() => new(_duckDb);
+
+ /* ───────────────────────── per-tool wiring ───────────────────────── */
+
+ private Task CallAsync(Tool tool, int hoursBack = HoursBack, int? limit = null) => tool switch
+ {
+ Tool.ServerConfig => McpConfigHistoryTools.GetServerConfigChanges(Service(), _serverManager, ServerName, hoursBack, as_of: AsOf),
+ Tool.DatabaseConfig => McpConfigHistoryTools.GetDatabaseConfigChanges(Service(), _serverManager, ServerName, hoursBack, as_of: AsOf),
+ Tool.TraceFlags => McpConfigHistoryTools.GetTraceFlagChanges(Service(), _serverManager, ServerName, hoursBack, as_of: AsOf),
+ Tool.CollectionLog => McpHealthTools.GetCollectionLog(Service(), _serverManager, ServerName, hoursBack, limit: limit, as_of: AsOf),
+ Tool.PlanCorrections => McpPlanCorrectionTools.GetPlanCorrections(Service(), _serverManager, ServerName, hoursBack, limit: limit ?? 25, as_of: AsOf),
+ Tool.MemoryPressure => McpMemoryTools.GetMemoryPressureEvents(Service(), _serverManager, ServerName, hoursBack, as_of: AsOf),
+ _ => throw new ArgumentOutOfRangeException(nameof(tool))
+ };
+
+ /// The collector whose logged runs the tool's coverage is read from. The collection log is the run log itself: its
+ /// own rows are the coverage, and the collector it is seeded under is any one.
+ private static string CollectorOf(Tool tool) => tool switch
+ {
+ Tool.ServerConfig => "server_config",
+ Tool.DatabaseConfig => "database_config",
+ Tool.TraceFlags => "trace_flags",
+ Tool.PlanCorrections => "plan_correction",
+ Tool.MemoryPressure => "memory_pressure_events",
+ _ => "wait_stats"
+ };
+
+ /// The table name the truncation note names.
+ private static string TableOf(Tool tool) => tool switch
+ {
+ Tool.ServerConfig => "server_config",
+ Tool.DatabaseConfig => "database_config",
+ Tool.TraceFlags => "trace_flags",
+ Tool.CollectionLog => "collection_log",
+ Tool.PlanCorrections => "plan_correction",
+ _ => "memory_pressure_events"
+ };
+
+ ///
+ /// Data for the tool at , which is what its answer lists. A config tool needs a CHANGE, so this seeds
+ /// the baseline snapshot at and the changed one at (the change is stamped
+ /// with the later capture time). The others seed one row stamped .
+ ///
+ private async Task SeedDataAsync(Tool tool, DateTime at, DateTime? baselineAt = null)
+ {
+ var baseline = baselineAt ?? at.AddMinutes(-20);
+ switch (tool)
+ {
+ case Tool.ServerConfig:
+ await SeedServerConfigAsync(baseline, 0);
+ await SeedServerConfigAsync(at, 4);
+ break;
+ case Tool.DatabaseConfig:
+ await SeedDatabaseConfigAsync(baseline, "FULL");
+ await SeedDatabaseConfigAsync(at, "SIMPLE");
+ break;
+ case Tool.TraceFlags:
+ await SeedTraceFlagAsync(baseline, 1117);
+ await SeedTraceFlagAsync(at, 1117);
+ await SeedTraceFlagAsync(at, 3226);
+ break;
+ case Tool.CollectionLog:
+ await SeedLogRunsAsync("wait_stats", at, at, everyMinutes: 30);
+ break;
+ case Tool.PlanCorrections:
+ await SeedPlanCorrectionAsync(at, recommendation: "PR_1");
+ break;
+ default:
+ await SeedMemoryPressureAsync(sampleTime: at, collectedAt: at);
+ break;
+ }
+ }
+
+ ///
+ /// Two stretches of data: the first at (the data's start) and a later one a day before the anchor. A config
+ /// tool's first stretch is its baseline snapshot, so the later change is the one the answer lists.
+ ///
+ private async Task SeedFirstAndLaterAsync(Tool tool, DateTime first)
+ {
+ if (tool is Tool.ServerConfig or Tool.DatabaseConfig or Tool.TraceFlags)
+ {
+ await SeedDataAsync(tool, Anchor.AddDays(-1), baselineAt: first);
+ }
+ else
+ {
+ await SeedDataAsync(tool, first);
+ await SeedDataAsync(tool, Anchor.AddDays(-1));
+ }
+ }
+
+ /// The collector's runs, for every tool but the collection log (whose log rows ARE the data).
+ private Task SeedCoverageAsync(Tool tool, DateTime first, DateTime last) =>
+ tool == Tool.CollectionLog ? Task.CompletedTask : SeedLogRunsAsync(CollectorOf(tool), first, last, everyMinutes: 30);
+
+ /* ───────────────────────── a data answer ───────────────────────── */
+
+ [Theory]
+ [InlineData(Tool.ServerConfig)]
+ [InlineData(Tool.DatabaseConfig)]
+ [InlineData(Tool.TraceFlags)]
+ [InlineData(Tool.CollectionLog)]
+ [InlineData(Tool.PlanCorrections)]
+ [InlineData(Tool.MemoryPressure)]
+ public async Task DataAndRunsStartInsideTheWindow_ReportTheFloor_AndTheNote(Tool tool)
+ {
+ await _duckDb.InitializeAsync();
+ var floor = Anchor.AddDays(-2);
+ await SeedCoverageAsync(tool, floor, Anchor);
+ await SeedFirstAndLaterAsync(tool, floor);
+
+ var root = Root(await CallAsync(tool));
+
+ AssertTruncatedAt(root, floor, TableOf(tool));
+ }
+
+ [Theory]
+ [InlineData(Tool.ServerConfig)]
+ [InlineData(Tool.DatabaseConfig)]
+ [InlineData(Tool.TraceFlags)]
+ [InlineData(Tool.CollectionLog)]
+ [InlineData(Tool.PlanCorrections)]
+ [InlineData(Tool.MemoryPressure)]
+ public async Task AQuietStart_CollectedFromBeforeTheWindow_FirstDataTwoDaysIn_IsCovered(Tool tool)
+ {
+ await _duckDb.InitializeAsync();
+ /* The store holds this tool's data from before the window began, and nothing was stored until two days in: a server that
+ is quiet for days looks exactly like this, and a probe that read only the window's rows would call it truncated. A config
+ tool's older evidence is its baseline snapshot; the collection log's is an older run. */
+ await SeedCoverageAsync(tool, WindowStart.AddHours(-1), Anchor);
+ if (tool == Tool.CollectionLog)
+ {
+ await SeedLogRunsAsync("wait_stats", WindowStart.AddHours(-1), WindowStart.AddHours(-1), everyMinutes: 30);
+ }
+
+ await SeedDataAsync(tool, Anchor.AddDays(-2), baselineAt: WindowStart.AddHours(-1));
+
+ var root = Root(await CallAsync(tool));
+
+ AssertCovered(root, WindowStart);
+ }
+
+ /// The three keys sit right after hours_back, in this order, on every tool.
+ [Theory]
+ [InlineData(Tool.ServerConfig)]
+ [InlineData(Tool.DatabaseConfig)]
+ [InlineData(Tool.TraceFlags)]
+ [InlineData(Tool.CollectionLog)]
+ [InlineData(Tool.PlanCorrections)]
+ [InlineData(Tool.MemoryPressure)]
+ public async Task ADataAnswer_CarriesTheKeysRightAfterHoursBack(Tool tool)
+ {
+ await _duckDb.InitializeAsync();
+ await SeedCoverageAsync(tool, Anchor.AddDays(-2), Anchor);
+ await SeedFirstAndLaterAsync(tool, Anchor.AddDays(-2));
+
+ var names = new List();
+ foreach (var property in Root(await CallAsync(tool)).EnumerateObject())
+ {
+ names.Add(property.Name);
+ }
+
+ var hoursBack = names.IndexOf("hours_back");
+ Assert.True(hoursBack >= 0);
+ Assert.Equal(new[] { "effective_start", "window_truncated", "truncation_note" }, names.GetRange(hoursBack + 1, 3));
+ }
+
+ /// A window of 90 minutes or less with rows starts no probe: the answer is covered at the requested start, not at the data.
+ [Theory]
+ [InlineData(Tool.ServerConfig)]
+ [InlineData(Tool.DatabaseConfig)]
+ [InlineData(Tool.TraceFlags)]
+ [InlineData(Tool.CollectionLog)]
+ [InlineData(Tool.PlanCorrections)]
+ [InlineData(Tool.MemoryPressure)]
+ public async Task AShortWindowWithRows_StartsNoProbe_AndIsCoveredAtTheRequestedStart(Tool tool)
+ {
+ await _duckDb.InitializeAsync();
+ await SeedDataAsync(tool, Anchor.AddMinutes(-30), baselineAt: Anchor.AddMinutes(-50));
+
+ var root = Root(await CallAsync(tool, hoursBack: 1));
+
+ AssertCovered(root, Anchor.AddHours(-1));
+ }
+
+ /// A capped page (limit: 1 of two rows) sits next to a window floor: the page cut does not change the notice. These two tools page; the others return the whole window.
+ [Theory]
+ [InlineData(Tool.CollectionLog)]
+ [InlineData(Tool.PlanCorrections)]
+ public async Task ACappedPage_NextToAWindowFloor_LeavesTheNoticeUnchanged(Tool tool)
+ {
+ await _duckDb.InitializeAsync();
+ var floor = Anchor.AddDays(-2);
+ await SeedCoverageAsync(tool, floor, Anchor);
+ await SeedFirstAndLaterAsync(tool, floor);
+
+ var root = Root(await CallAsync(tool, limit: 1));
+
+ Assert.True(root.GetProperty("truncated").GetBoolean());
+ AssertTruncatedAt(root, floor, TableOf(tool));
+ }
+
+ /* ───────────────────────── what each probe reads ───────────────────────── */
+
+ ///
+ /// A config read keeps the snapshot BEFORE the window as the diff's baseline and the collectors capture on connect, so a
+ /// server that has stayed connected holds its last snapshot days before the window and none inside it. That is a window the
+ /// store covers, with no change in it; the plain probe finds nothing inside the window and would say the store holds no
+ /// collection of it. The empty answer's hints say covered.
+ ///
+ [Theory]
+ [InlineData(Tool.ServerConfig)]
+ [InlineData(Tool.DatabaseConfig)]
+ [InlineData(Tool.TraceFlags)]
+ public async Task AConfigWindow_HoldsNoSnapshotButOneBeforeIt_IsCoveredAndEmpty(Tool tool)
+ {
+ await _duckDb.InitializeAsync();
+ await SeedDataAsync(tool, WindowStart.AddDays(-9), baselineAt: WindowStart.AddDays(-10));
+ await SeedLogRunsAsync("wait_stats", Anchor.AddDays(-3), Anchor, everyMinutes: 60);
+
+ var root = Root(await CallAsync(tool));
+
+ Assert.Equal("empty", root.GetProperty("status").GetString());
+ AssertCovered(root.GetProperty("hints"), WindowStart);
+ }
+
+ ///
+ /// The memory pressure list is windowed on sample_time, the ring-buffer event's own time, which can sit before the
+ /// first run that stored it; the probe reads that same column beside the collector's runs. So a first-run backfill needs no
+ /// event-time form: the collector's first run is 100 minutes into the window (past the 90-minute slack), it stored an event
+ /// from 30 minutes in, and the probe alone already names the event and calls the window covered.
+ ///
+ [Fact]
+ public async Task MemoryPressure_AnEventOlderThanItsRun_FirstRunBackfill_NamesTheEvent_AndIsNotACut()
+ {
+ await _duckDb.InitializeAsync();
+ var firstRun = WindowStart.AddMinutes(100);
+ var backfilled = WindowStart.AddMinutes(30);
+ await SeedLogRunsAsync("memory_pressure_events", firstRun, Anchor, everyMinutes: 30);
+ await SeedMemoryPressureAsync(sampleTime: backfilled, collectedAt: firstRun);
+
+ var root = Root(await CallAsync(Tool.MemoryPressure));
+
+ Assert.False(root.GetProperty("window_truncated").GetBoolean());
+ Assert.Equal(JsonValueKind.Null, root.GetProperty("truncation_note").ValueKind);
+ var text = root.GetProperty("effective_start").GetString()!;
+ Assert.EndsWith("Z", text, StringComparison.Ordinal);
+ Assert.True(Math.Abs((ParseUtc(text) - backfilled).TotalSeconds) < 5, $"effective_start {text} should be the event {backfilled:o}");
+ Assert.False(root.TryGetProperty("effective_hours_back", out _));
+ }
+
+ ///
+ /// A plan-correction answer can hold only the automatic-tuning snapshot (a database the engine has nothing to recommend for):
+ /// the recommendation list is empty but the answer is a data answer, so it carries the keys at the top level and is probed
+ /// whatever the window's length.
+ ///
+ [Fact]
+ public async Task PlanCorrections_AnAutomaticTuningOnlyAnswer_CarriesTheKeysAtTheTopLevel_AndIsProbed()
+ {
+ await _duckDb.InitializeAsync();
+ await SeedLogRunsAsync("plan_correction", Anchor.AddDays(-2), Anchor, everyMinutes: 30);
+ await SeedPlanCorrectionAsync(Anchor.AddDays(-1), recommendation: null);
+
+ var root = Root(await CallAsync(Tool.PlanCorrections));
+
+ Assert.False(root.TryGetProperty("status", out _));
+ Assert.Equal(0, root.GetProperty("recommendations_returned").GetInt32());
+ AssertTruncatedAt(root, Anchor.AddDays(-2), "plan_correction");
+ }
+
+ /// A one-hour, tuning-only answer is probed too (short window, empty page): effective_start is the probe's floor, not the asked start.
+ [Fact]
+ public async Task PlanCorrections_AOneHourTuningOnlyAnswer_NamesTheProbesFloor_NotTheAskedStart()
+ {
+ await _duckDb.InitializeAsync();
+ var floor = Anchor.AddMinutes(-20);
+ await SeedLogRunsAsync("plan_correction", floor, Anchor, everyMinutes: 5);
+ await SeedPlanCorrectionAsync(floor, recommendation: null);
+
+ var root = Root(await CallAsync(Tool.PlanCorrections, hoursBack: 1));
+
+ Assert.False(root.TryGetProperty("status", out _));
+ var text = root.GetProperty("effective_start").GetString()!;
+ Assert.True(Math.Abs((ParseUtc(text) - floor).TotalSeconds) < 5,
+ $"effective_start {text} should be the probe's floor {floor:o}, not the asked start {Anchor.AddHours(-1):o}");
+ }
+
+ /// A status filter that matches nothing, over a window with runs, is a filtered-empty answer, not a quiet window.
+ [Fact]
+ public async Task CollectionLog_AStatusFilterThatMatchesNothing_TakesTheFilteredEmptyBranch()
+ {
+ await _duckDb.InitializeAsync();
+ await SeedLogRunsAsync("wait_stats", Anchor.AddHours(-20), Anchor, everyMinutes: 30);
+
+ var root = Root(await McpHealthTools.GetCollectionLog(
+ Service(), _serverManager, ServerName, 24, as_of: AsOf, status: "ERROR"));
+
+ Assert.Equal("empty", root.GetProperty("status").GetString());
+ var message = root.GetProperty("message").GetString()!;
+ Assert.DoesNotContain("genuinely quiet", message, StringComparison.Ordinal);
+ Assert.Contains("matched", message, StringComparison.Ordinal);
+ }
+
+ /// A snapshot before the window with NO run in the window proves nothing was read: not covered.
+ [Theory]
+ [InlineData(Tool.ServerConfig)]
+ [InlineData(Tool.DatabaseConfig)]
+ [InlineData(Tool.TraceFlags)]
+ public async Task AConfigWindow_OneSnapshotBeforeIt_ButNoRunInIt_SaysNothingWasRead(Tool tool)
+ {
+ await _duckDb.InitializeAsync();
+ await SeedDataAsync(tool, WindowStart.AddDays(-9), baselineAt: WindowStart.AddDays(-10));
+ await SeedLogRunsAsync("wait_stats", WindowStart.AddDays(-9), WindowStart.AddDays(-8), everyMinutes: 60);
+
+ var root = Root(await CallAsync(tool));
+
+ Assert.Equal("empty", root.GetProperty("status").GetString());
+ AssertNothingHeld(root.GetProperty("hints"), TableOf(tool));
+ }
+
+ /// The collection log's probe is the log itself, unfiltered: a collector filter that matches nothing still says where the log starts.
+ [Fact]
+ public async Task CollectionLog_AFilterThatMatchesNothing_StillSaysWhereTheLogStarts_UnderHints()
+ {
+ await _duckDb.InitializeAsync();
+ await SeedLogRunsAsync("wait_stats", Anchor.AddDays(-2), Anchor, everyMinutes: 30);
+
+ var root = Root(await McpHealthTools.GetCollectionLog(
+ Service(), _serverManager, ServerName, HoursBack, as_of: AsOf, collector_name: "no_such_collector"));
+
+ Assert.Equal("empty", root.GetProperty("status").GetString());
+ AssertTruncatedAt(root.GetProperty("hints"), Anchor.AddDays(-2), "collection_log");
+ }
+
+ /// A server that has NEVER logged a run is a fault, not an empty window: unavailable keeps its shape and carries no keys.
+ [Fact]
+ public async Task CollectionLog_ANeverCollectedServer_IsUnavailable_AndStaysBare()
+ {
+ await _duckDb.InitializeAsync();
+
+ var root = Root(await CallAsync(Tool.CollectionLog));
+
+ Assert.Equal("unavailable", root.GetProperty("status").GetString());
+ Assert.False(root.TryGetProperty("hints", out _));
+ Assert.False(root.TryGetProperty("window_truncated", out _));
+ }
+
+ /// The fleet form names no server, so there is no one server's start to give: it carries none of the three keys.
+ [Fact]
+ public async Task CollectionLog_TheFleetForm_CarriesNoWindowFloorKeys()
+ {
+ await _duckDb.InitializeAsync();
+ await SeedLogRunsAsync("wait_stats", Anchor.AddDays(-2), Anchor, everyMinutes: 30);
+
+ var json = await McpHealthTools.GetCollectionLog(Service(), _serverManager, server_name: null, hours_back: HoursBack, as_of: AsOf);
+
+ var root = Root(json);
+ Assert.True(root.TryGetProperty("run_count", out _));
+ Assert.False(root.TryGetProperty("effective_start", out _));
+ Assert.False(root.TryGetProperty("window_truncated", out _));
+ Assert.False(root.TryGetProperty("truncation_note", out _));
+ }
+
+ /* ───────────────────────── an empty answer says where the data starts too ───────────────────────── */
+
+ [Theory]
+ [InlineData(Tool.ServerConfig)]
+ [InlineData(Tool.DatabaseConfig)]
+ [InlineData(Tool.TraceFlags)]
+ [InlineData(Tool.PlanCorrections)]
+ [InlineData(Tool.MemoryPressure)]
+ public async Task AnEmptyWindow_PastCoverage_CarriesTheFloorAndTheNote_UnderHints(Tool tool)
+ {
+ await _duckDb.InitializeAsync();
+ /* The collector ran for two days of a 7-day ask and stored nothing a change or a row would show: only the keys say the
+ older five were never read. A config tool holds one snapshot, which is no change. */
+ await SeedCoverageAsync(tool, Anchor.AddDays(-2), Anchor);
+ if (tool is Tool.ServerConfig)
+ {
+ await SeedServerConfigAsync(Anchor.AddDays(-2), 0);
+ }
+ else if (tool is Tool.DatabaseConfig)
+ {
+ await SeedDatabaseConfigAsync(Anchor.AddDays(-2), "FULL");
+ }
+ else if (tool is Tool.TraceFlags)
+ {
+ await SeedTraceFlagAsync(Anchor.AddDays(-2), 1117);
+ }
+
+ var root = Root(await CallAsync(tool));
+
+ Assert.Equal("empty", root.GetProperty("status").GetString());
+ AssertTruncatedAt(root.GetProperty("hints"), Anchor.AddDays(-2), TableOf(tool));
+ }
+
+ [Theory]
+ [InlineData(Tool.ServerConfig)]
+ [InlineData(Tool.DatabaseConfig)]
+ [InlineData(Tool.TraceFlags)]
+ [InlineData(Tool.PlanCorrections)]
+ [InlineData(Tool.MemoryPressure)]
+ public async Task AnEmptyWindow_TheStoreHoldsNothingIn_SaysNothingWasRead(Tool tool)
+ {
+ await _duckDb.InitializeAsync();
+
+ var root = Root(await CallAsync(tool));
+
+ Assert.Equal("empty", root.GetProperty("status").GetString());
+ AssertNothingHeld(root.GetProperty("hints"), TableOf(tool));
+ }
+
+ [Theory]
+ [InlineData(Tool.PlanCorrections)]
+ [InlineData(Tool.MemoryPressure)]
+ public async Task AnEmptyWindow_TheCollectorCoveredFromBeforeIt_SaysCovered(Tool tool)
+ {
+ await _duckDb.InitializeAsync();
+ await SeedCoverageAsync(tool, Anchor.AddDays(-9), Anchor);
+
+ var root = Root(await CallAsync(tool));
+
+ Assert.Equal("empty", root.GetProperty("status").GetString());
+ AssertCovered(root.GetProperty("hints"), WindowStart);
+ }
+
+ /// The collection log's one empty window with older runs: this server HAS collected, but nothing in the window, so the hints say the store holds no collection of the window.
+ [Fact]
+ public async Task CollectionLog_AQuietWindowWithOlderRuns_SaysNothingInTheWindowWasRead()
+ {
+ await _duckDb.InitializeAsync();
+ await SeedLogRunsAsync("wait_stats", Anchor.AddDays(-20), Anchor.AddDays(-19), everyMinutes: 30);
+
+ var root = Root(await CallAsync(Tool.CollectionLog, hoursBack: 24));
+
+ Assert.Equal("empty", root.GetProperty("status").GetString());
+ AssertNothingHeld(root.GetProperty("hints"), "collection_log");
+ }
+
+ /* ───────────────────────── assertions ───────────────────────── */
+
+ /// The store's data starts at , later than the window asked for.
+ private static void AssertTruncatedAt(JsonElement root, DateTime floor, string table)
+ {
+ Assert.True(root.GetProperty("window_truncated").GetBoolean());
+ var text = root.GetProperty("effective_start").GetString()!;
+ Assert.EndsWith("Z", text, StringComparison.Ordinal);
+ Assert.True(Math.Abs((ParseUtc(text) - floor).TotalSeconds) < 5,
+ $"effective_start {text} should be the seeded floor {floor:o}");
+ var note = root.GetProperty("truncation_note").GetString();
+ Assert.NotNull(note);
+ Assert.Contains($"raw {table} retains", note, StringComparison.Ordinal);
+ AssertNoReachKey(root);
+ }
+
+ /// An empty answer over a window the store holds no collection in: not covered, and no start to name.
+ private static void AssertNothingHeld(JsonElement root, string table)
+ {
+ Assert.True(root.GetProperty("window_truncated").GetBoolean());
+ Assert.Equal(JsonValueKind.Null, root.GetProperty("effective_start").ValueKind);
+ var note = root.GetProperty("truncation_note").GetString();
+ Assert.NotNull(note);
+ Assert.Contains($"holds no collection of {table}", note, StringComparison.Ordinal);
+ AssertNoReachKey(root);
+ }
+
+ /// The store held the whole window: the keys are present, false and null, at the requested start.
+ private static void AssertCovered(JsonElement root, DateTime requestedStart)
+ {
+ Assert.False(root.GetProperty("window_truncated").GetBoolean());
+ Assert.Equal(JsonValueKind.Null, root.GetProperty("truncation_note").ValueKind);
+ var text = root.GetProperty("effective_start").GetString()!;
+ Assert.EndsWith("Z", text, StringComparison.Ordinal);
+ Assert.True(Math.Abs((ParseUtc(text) - requestedStart).TotalMinutes) < 2,
+ $"effective_start {text} should be the requested start {requestedStart:o}");
+ AssertNoReachKey(root);
+ }
+
+ /// None of the six writes a reach: the instant alone, as on the other window-floor payloads.
+ private static void AssertNoReachKey(JsonElement root) =>
+ Assert.False(root.TryGetProperty("effective_hours_back", out _));
+
+ /* ───────────────────────── seeding ───────────────────────── */
+
+ private async Task ExecuteAsync(string sql, params object?[] values)
+ {
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+ using var readLock = _duckDb.AcquireReadLock();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = sql;
+ foreach (var value in values)
+ {
+ cmd.Parameters.Add(new DuckDBParameter { Value = value ?? DBNull.Value });
+ }
+
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ private Task SeedServerConfigAsync(DateTime captureTime, long value) => ExecuteAsync(@"
+INSERT INTO server_config (config_id, capture_time, server_id, server_name, configuration_name, value_configured, value_in_use, is_dynamic, is_advanced)
+VALUES ($1, $2, $3, $4, 'max degree of parallelism', $5, $5, TRUE, TRUE)",
+ _nextId++, Naive(captureTime), _serverId, ServerName, value);
+
+ private Task SeedDatabaseConfigAsync(DateTime captureTime, string recoveryModel) => ExecuteAsync(@"
+INSERT INTO database_config (config_id, capture_time, server_id, server_name, database_name, recovery_model)
+VALUES ($1, $2, $3, $4, 'Db', $5)",
+ _nextId++, Naive(captureTime), _serverId, ServerName, recoveryModel);
+
+ private Task SeedTraceFlagAsync(DateTime captureTime, int traceFlag) => ExecuteAsync(@"
+INSERT INTO trace_flags (config_id, capture_time, server_id, server_name, trace_flag, status, is_global, is_session)
+VALUES ($1, $2, $3, $4, $5, TRUE, TRUE, FALSE)",
+ _nextId++, Naive(captureTime), _serverId, ServerName, traceFlag);
+
+ private Task SeedPlanCorrectionAsync(DateTime collectedAt, string? recommendation) => ExecuteAsync(@"
+INSERT INTO plan_correction (collection_id, collection_time, server_id, server_name, database_name, recommendation_name, recommendation_state, score,
+ force_last_good_plan_desired_state, force_last_good_plan_actual_state)
+VALUES ($1, $2, $3, $4, 'Db', $5, 'Active', 50, 'Enabled', 'Enabled')",
+ _nextId++, Naive(collectedAt), _serverId, ServerName, recommendation);
+
+ private Task SeedMemoryPressureAsync(DateTime sampleTime, DateTime collectedAt) => ExecuteAsync(@"
+INSERT INTO memory_pressure_events (collection_id, collection_time, server_id, server_name, sample_time, memory_notification, memory_indicators_process, memory_indicators_system)
+VALUES ($1, $2, $3, $4, $5, 'RESOURCE_MEMPHYSICAL_LOW', 1, 1)",
+ _nextId++, Naive(collectedAt), _serverId, ServerName, Naive(sampleTime));
+
+ /// The collector's runs in collection_log, every minutes, whether or not anything happened.
+ private async Task SeedLogRunsAsync(string collector, DateTime firstUtc, DateTime lastUtc, int everyMinutes)
+ {
+ await ExecuteAsync($@"
+INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, duration_ms, status, rows_collected)
+SELECT $1 + row_number() OVER (), $2, $3, $4, g.t, 12, 'SUCCESS', 0
+FROM generate_series($5::TIMESTAMP, $6::TIMESTAMP, INTERVAL {everyMinutes} MINUTE) AS g(t)",
+ _nextId, _serverId, ServerName, collector, Naive(firstUtc), Naive(lastUtc));
+ _nextId += 100_000;
+ }
+}
diff --git a/Lite/Mcp/McpConfigHistoryTools.cs b/Lite/Mcp/McpConfigHistoryTools.cs
index 2e0c081d3c..44bd1db705 100644
--- a/Lite/Mcp/McpConfigHistoryTools.cs
+++ b/Lite/Mcp/McpConfigHistoryTools.cs
@@ -13,10 +13,32 @@ namespace PerformanceMonitorLite.Mcp;
/// STORED reads, no live monitored-server hit. Config is captured ON CONNECT (not on a fixed schedule), so
/// change granularity equals the connect/restart cadence and at least two snapshots are needed before a
/// change can be detected — the empty result explains this rather than silently returning nothing.
+///
+/// #4966: each tool says where its data starts (effective_start, window_truncated,
+/// truncation_note), from the coverage probe () over
+/// the snapshot view the diff reads (v_server_config, v_database_config, v_trace_flags, on capture_time),
+/// which also counts the snapshot before the window that the diff keeps as its baseline.
+/// A change is stamped with the capture time of the snapshot that showed it, so a first run cannot store a change from
+/// before itself: the oldest change can never be older than the probe's floor, and the plain notice
+/// () is the right one, not the event-time form.
///
[McpServerToolType]
public sealed class McpConfigHistoryTools
{
+ ///
+ /// #4966: the window notice of one config-change tool. The window is the one the read took
+ /// (windowEnd minus hours_back, which is what GetTimeRange gives for an as_of anchor).
+ /// A data answer over a window of 90 minutes or less starts no probe; an empty one always does.
+ ///
+ private static Task WindowNoticeAsync(
+ LocalDataService dataService, QueryWindowRelation relation, int serverId, int hoursBack, DateTime windowEnd, string table, bool emptyAnswer = false)
+ {
+ var requestedStart = windowEnd.AddHours(-hoursBack);
+ return McpQueryTools.WindowNoticeAsync(
+ () => dataService.GetConfigSnapshotCoverageFloorAsync(relation, serverId, requestedStart, windowEnd),
+ requestedStart, windowEnd, table, emptyAnswer: emptyAnswer);
+ }
+
[McpServerTool(Name = "get_server_config_changes"), Description("Gets server configuration change history by diffing sp_configure (sys.configurations) snapshots. Shows which settings changed, their old vs new configured/in-use values, whether the change requires a restart, and dynamic/advanced flags. NOTE: config is captured on server connect (not on a fixed schedule), so changes are detected between connect snapshots and need at least two.")]
public static async Task GetServerConfigChanges(
LocalDataService dataService,
@@ -37,12 +59,20 @@ public static async Task GetServerConfigChanges(
if (rows.Count == 0)
return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "server_config")
?? McpHelpers.Status("empty",
- $"No server configuration changes detected in the last {hours_back}h. Config is captured on connect, so at least two snapshots are needed to detect a change.");
+ $"No server configuration changes detected in the last {hours_back}h. Config is captured on connect, so at least two snapshots are needed to detect a change.",
+ (await WindowNoticeAsync(dataService, QueryWindowRelation.ServerConfig, resolved.ServerId, hours_back, windowEnd, "server_config", emptyAnswer: true)).AsHints());
+
+ var notice = await WindowNoticeAsync(dataService, QueryWindowRelation.ServerConfig, resolved.ServerId, hours_back, windowEnd, "server_config");
return JsonSerializer.Serialize(new
{
server = resolved.ServerName,
hours_back,
+ /* #4966: where this server's config snapshots start for the window, always present (false and null when
+ the store covered it). No effective_hours_back, as on the other window-floor payloads. */
+ effective_start = notice.EffectiveStart,
+ window_truncated = notice.WindowTruncated,
+ truncation_note = notice.TruncationNote,
change_count = rows.Count,
changes = rows.Select(r => new
{
@@ -85,12 +115,20 @@ public static async Task GetDatabaseConfigChanges(
if (rows.Count == 0)
return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "database_config")
?? McpHelpers.Status("empty",
- $"No database configuration changes detected in the last {hours_back}h. Config is captured on connect, so at least two snapshots are needed to detect a change.");
+ $"No database configuration changes detected in the last {hours_back}h. Config is captured on connect, so at least two snapshots are needed to detect a change.",
+ (await WindowNoticeAsync(dataService, QueryWindowRelation.DatabaseConfig, resolved.ServerId, hours_back, windowEnd, "database_config", emptyAnswer: true)).AsHints());
+
+ var notice = await WindowNoticeAsync(dataService, QueryWindowRelation.DatabaseConfig, resolved.ServerId, hours_back, windowEnd, "database_config");
return JsonSerializer.Serialize(new
{
server = resolved.ServerName,
hours_back,
+ /* #4966: where this server's config snapshots start for the window, always present (false and null when
+ the store covered it). No effective_hours_back, as on the other window-floor payloads. */
+ effective_start = notice.EffectiveStart,
+ window_truncated = notice.WindowTruncated,
+ truncation_note = notice.TruncationNote,
change_count = rows.Count,
changes = rows.Select(r => new
{
@@ -129,12 +167,20 @@ public static async Task GetTraceFlagChanges(
if (rows.Count == 0)
return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "trace_flags")
?? McpHelpers.Status("empty",
- $"No trace flag changes detected in the last {hours_back}h. Config is captured on connect, so at least two snapshots are needed to detect a change.");
+ $"No trace flag changes detected in the last {hours_back}h. Config is captured on connect, so at least two snapshots are needed to detect a change.",
+ (await WindowNoticeAsync(dataService, QueryWindowRelation.TraceFlags, resolved.ServerId, hours_back, windowEnd, "trace_flags", emptyAnswer: true)).AsHints());
+
+ var notice = await WindowNoticeAsync(dataService, QueryWindowRelation.TraceFlags, resolved.ServerId, hours_back, windowEnd, "trace_flags");
return JsonSerializer.Serialize(new
{
server = resolved.ServerName,
hours_back,
+ /* #4966: where this server's config snapshots start for the window, always present (false and null when
+ the store covered it). No effective_hours_back, as on the other window-floor payloads. */
+ effective_start = notice.EffectiveStart,
+ window_truncated = notice.WindowTruncated,
+ truncation_note = notice.TruncationNote,
change_count = rows.Count,
changes = rows.Select(r => new
{
diff --git a/Lite/Mcp/McpHealthTools.cs b/Lite/Mcp/McpHealthTools.cs
index 211bad2829..8d23e065e6 100644
--- a/Lite/Mcp/McpHealthTools.cs
+++ b/Lite/Mcp/McpHealthTools.cs
@@ -961,7 +961,20 @@ other read carries and take reach away from exactly the read whose premise is lo
var truncated = rows.Count > effectiveLimit;
if (truncated) rows = rows.Take(effectiveLimit).ToList();
- var filtered = !string.IsNullOrWhiteSpace(collector_name) || min_duration_ms is not null;
+ var filtered = !string.IsNullOrWhiteSpace(collector_name) || min_duration_ms is not null
+ || !string.IsNullOrWhiteSpace(status);
+
+ /* #4966: where this server's run log starts for the window. The probe reads the log itself (v_collection_log, on
+ collection_time; the relation names no collector, so it is the row-only probe) over the window the read took, and it
+ ignores the collector, duration and status filters on purpose: the notice says where the LOG starts, not where one
+ collector's or one status's runs do. A run is stamped with its own time, so no run can be older than the probe's
+ floor: the plain notice fits, not the event-time form. The window is uncapped, so one past the log's retention is
+ exactly what this reports. The fleet form (server_name omitted or "*") names no server and carries no notice: there
+ is no one server's start to give (see GetCollectionLogFleetAsync). */
+ var requestedStart = windowEnd.AddHours(-hours);
+ Task NoticeAsync(bool emptyAnswer) => McpQueryTools.WindowNoticeAsync(
+ () => dataService.GetQueryWindowFloorAsync(QueryWindowRelation.CollectionLog, resolved.ServerId, requestedStart, windowEnd),
+ requestedStart, windowEnd, "collection_log", emptyAnswer: emptyAnswer);
if (rows.Count == 0)
{
@@ -1000,14 +1013,18 @@ collected. Same order as Darling's twin.
{
return McpHelpers.Status(
"empty",
- $"No collector runs on {resolved.ServerName} in the last {hours} hour(s) matched {McpHelpers.DescribeCollectionLogFilters(collector_name, min_duration_ms, status)}. This says nothing about the window as a whole — the filters were applied, so unfiltered runs may well exist. Drop them to see what the window holds, and check collector_name against the names get_collection_health lists, since it is matched exactly.");
+ $"No collector runs on {resolved.ServerName} in the last {hours} hour(s) matched {McpHelpers.DescribeCollectionLogFilters(collector_name, min_duration_ms, status)}. This says nothing about the window as a whole — the filters were applied, so unfiltered runs may well exist. Drop them to see what the window holds, and check collector_name against the names get_collection_health lists, since it is matched exactly.",
+ (await NoticeAsync(emptyAnswer: true)).AsHints());
}
return McpHelpers.Status(
"empty",
- $"No collector runs recorded for {resolved.ServerName} in the last {hours} hour(s). This server HAS collected before, so this window is genuinely quiet rather than broken — widen hours_back to find the most recent runs.");
+ $"No collector runs recorded for {resolved.ServerName} in the last {hours} hour(s). This server HAS collected before, so this window is genuinely quiet rather than broken — widen hours_back to find the most recent runs.",
+ (await NoticeAsync(emptyAnswer: true)).AsHints());
}
+ var notice = await NoticeAsync(emptyAnswer: false);
+
var result = rows.Select(r => new
{
collector = r.CollectorName,
@@ -1035,6 +1052,11 @@ the whole field. */
server = resolved.ServerName,
/* The span REQUESTED, and no longer the only span reported -- see the two timestamps. */
hours_back = hours,
+ /* #4966: where the run log starts, always present (false and null when the log covered the window). No
+ effective_hours_back: this payload carries a page `truncated`, and the census holds that key apart for the window floor. */
+ effective_start = notice.EffectiveStart,
+ window_truncated = notice.WindowTruncated,
+ truncation_note = notice.TruncationNote,
run_count = rows.Count,
truncated,
/*
@@ -1089,6 +1111,8 @@ key meaning two unrelated things by branch is the collision the name avoids. Eac
/// The FLEET-WIDE form of (#4199), Darling's twin exactly: same
/// validations, same filters, same truncated/ordering contract, merged across every ENABLED server
/// instead of scoped to one, each row carrying which server it came from.
+ /// #4966: no window-floor notice here. The one-server form says where THAT server's run log starts; this form
+ /// names no server and merges every enabled server's runs, so there is no single start to give.
///
private static async Task GetCollectionLogFleetAsync(
LocalDataService dataService,
diff --git a/Lite/Mcp/McpMemoryTools.cs b/Lite/Mcp/McpMemoryTools.cs
index 2992d1fbf6..e3c1caf82c 100644
--- a/Lite/Mcp/McpMemoryTools.cs
+++ b/Lite/Mcp/McpMemoryTools.cs
@@ -219,13 +219,29 @@ public static async Task GetMemoryPressureEvents(
if (rows.Count == 0)
{
return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "memory_pressure_events")
- ?? McpHelpers.Status("empty", "No memory pressure events found in the requested time range.");
+ ?? McpHelpers.Status("empty", "No memory pressure events found in the requested time range.",
+ (await McpQueryTools.WindowNoticeAsync(
+ () => dataService.GetQueryWindowFloorAsync(QueryWindowRelation.MemoryPressureEvents, resolved.ServerId, windowEnd.AddHours(-hours_back), windowEnd),
+ windowEnd.AddHours(-hours_back), windowEnd, "memory_pressure_events", emptyAnswer: true)).AsHints());
}
+ /* #4966: where this server's memory_pressure_events data starts for the window. The list is windowed on sample_time,
+ the ring-buffer event's own time, which can sit before the first run that stored it, and the probe reads that same
+ column beside the collector's runs (QueryWindowRelationTimeColumn), so the oldest event shown can never be older
+ than the probe's floor: the plain notice, not the event-time form. */
+ var requestedStart = windowEnd.AddHours(-hours_back);
+ var notice = await McpQueryTools.WindowNoticeAsync(
+ () => dataService.GetQueryWindowFloorAsync(QueryWindowRelation.MemoryPressureEvents, resolved.ServerId, requestedStart, windowEnd),
+ requestedStart, windowEnd, "memory_pressure_events");
+
return JsonSerializer.Serialize(new
{
server = resolved.ServerName,
hours_back,
+ /* #4966: always present (false and null when the store covered the window). No effective_hours_back, as on the other window-floor payloads. */
+ effective_start = notice.EffectiveStart,
+ window_truncated = notice.WindowTruncated,
+ truncation_note = notice.TruncationNote,
events = rows.Select(r => new
{
sample_time = r.SampleTime.ToString("o"),
diff --git a/Lite/Mcp/McpPlanCorrectionTools.cs b/Lite/Mcp/McpPlanCorrectionTools.cs
index 855623e7b0..26729e43ca 100644
--- a/Lite/Mcp/McpPlanCorrectionTools.cs
+++ b/Lite/Mcp/McpPlanCorrectionTools.cs
@@ -67,9 +67,22 @@ signal. The reader's LIMIT 200 over per-cycle re-captures gave every window the
?? McpHelpers.Status("empty",
"No plan correction data collected for this server. The collector runs against SQL Server 2017+ " +
"(sys.dm_db_tuning_recommendations); a server that has never produced a row here either predates " +
- "that or has no databases with Query Store on.");
+ "that or has no databases with Query Store on.",
+ (await McpQueryTools.WindowNoticeAsync(
+ () => dataService.GetQueryWindowFloorAsync(QueryWindowRelation.PlanCorrection, resolved.ServerId, windowEnd.AddHours(-hours_back), windowEnd),
+ windowEnd.AddHours(-hours_back), windowEnd, "plan_correction", emptyAnswer: true)).AsHints());
}
+ /* #4966: where this server's plan_correction data starts for the window. The rows are re-captures stamped with the
+ run's own collection_time, which is the column the probe reads, so the plain notice fits (no event-time form). A
+ page of recommendations that is empty beside an automatic-tuning snapshot is still an empty list: it is probed
+ whatever the window's length, so it cannot be read as "nothing was recommended" over a window nothing covered. The
+ page cut (limit, newest first) is `truncated`; this is the separate window floor. */
+ var requestedStart = windowEnd.AddHours(-hours_back);
+ var notice = await McpQueryTools.WindowNoticeAsync(
+ () => dataService.GetQueryWindowFloorAsync(QueryWindowRelation.PlanCorrection, resolved.ServerId, requestedStart, windowEnd),
+ requestedStart, windowEnd, "plan_correction", emptyAnswer: rows.Count == 0);
+
var recommendations = page.Select(r => new
{
collection_time = r.CollectionTime.ToString("o"),
@@ -109,6 +122,11 @@ beside it. */
{
server = resolved.ServerName,
hours_back,
+ /* #4966: always present (false and null when the store covered the window). No effective_hours_back: this payload
+ carries a page `truncated`, and the census holds that key apart for the window floor. */
+ effective_start = notice.EffectiveStart,
+ window_truncated = notice.WindowTruncated,
+ truncation_note = notice.TruncationNote,
automatic_tuning = tuning.Select(t => new
{
database_name = t.DatabaseName,
diff --git a/Lite/Mcp/McpSessionTools.cs b/Lite/Mcp/McpSessionTools.cs
index 78c1da12e2..b085e991fc 100644
--- a/Lite/Mcp/McpSessionTools.cs
+++ b/Lite/Mcp/McpSessionTools.cs
@@ -11,12 +11,12 @@ public sealed class McpSessionTools
{
/// #4198: see Darling's DarlingMcpSessionTools.QueryTextPreviewLength twin for the
/// measured bytes both defaults come down from.
- private const int QueryTextPreviewLength = 500;
+ private const int QueryTextPreviewLength = 400;
///
private const int DefaultLimit = 25;
- [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> query_text is a preview by default (query_text_truncated: true) — pass full_text for the whole text. Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocked_process_reports has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit).")]
+ [McpServerTool(Name = "get_active_queries"), Description("Active query snapshots (sys.dm_exec_requests) in a window ending at as_of: query text, wait, CPU/elapsed ms, blocking, DOP, memory grant GB. database_name/blocking_only filter IN SQL: total_snapshots is the filtered count; truncated means over limit. wait_time_ms, dop, granted_query_memory_gb, open_transaction_count: null = zero or not applicable. Head blockers are never stripped (is_head_blocker); a victim's blocker_not_shown is not_captured, filtered, or past_page. Empty: no snapshot in the window, or none matching filters; not_collected (unfiltered only): engine can't run it. <> query_text is a preview by default (query_text_truncated: true) — pass full_text for the whole text. Gets active query snapshots captured from sys.dm_exec_requests. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event. EVERY FILTER IS PART OF THE QUERY: database_name and blocking_only are applied in SQL before the page is cut, total_snapshots is the count of snapshot rows in the window that pass your filters, snapshots_returned is how many you got, and truncated says the filtered population held more than limit — raise limit or narrow hours_back when it is true (NEWEST CAPTURE FIRST, highest CPU first within a capture; oldest_returned_collection_time / newest_returned_collection_time bound the page). Applied in SQL, so total_snapshots counts the blocking population and truncated is measured against it. HEAD BLOCKERS ARE NEVER STRIPPED: a session another row in the same capture names as its blocker is on the page whatever its text (including a WAITFOR shell holding locks), flagged is_head_blocker. A victim whose blocker is NOT on the page says why in blocker_not_shown: not_captured (the blocker held no running request at that capture — an idle open transaction is the classic case; get_blocked_process_reports has its input buffer from the blocked-process report), filtered (your database_name filter excluded it), or past_page (it is in the filtered population but beyond limit). When the store did not cover the whole window, effective_start, window_truncated and truncation_note say where its data starts.")]
public static async Task GetActiveQueries(
LocalDataService dataService,
ServerManager serverManager,
@@ -25,7 +25,7 @@ public static async Task GetActiveQueries(
[Description("Filter to a specific database. Applied in SQL; a head blocker in ANOTHER database is then not on the page, and its victims say blocker_not_shown = filtered.")] string? database_name = null,
[Description("Show only queries involved in blocking: rows with blocking_session_id > 0, plus the head blockers those rows name in the same capture.")] bool blocking_only = false,
[Description("Maximum number of rows to return. Default 25 (#4198, down from 50 — sized to fit the response budget). The page is bounded by limit, not hours_back — truncated says whether the window held more.")] int limit = DefaultLimit,
- [Description("Return each row's full query text instead of a 500-character preview. Default false.")] bool full_text = false,
+ [Description("Return each row's full query text instead of a 400-character preview. Default false.")] bool full_text = false,
[Description(McpHelpers.AsOfDescription)] string? as_of = null)
{
var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name);
diff --git a/Lite/Mcp/McpWaitTools.cs b/Lite/Mcp/McpWaitTools.cs
index e6751fbe04..c72772591d 100644
--- a/Lite/Mcp/McpWaitTools.cs
+++ b/Lite/Mcp/McpWaitTools.cs
@@ -192,7 +192,7 @@ every trend tool of both SKUs — see BaselineDiscontinuities. */
}
}
- [McpServerTool(Name = "get_waiting_tasks"), Description("Gets recently captured waiting tasks — queries that were actively waiting on a resource at collection time — NEWEST CAPTURE FIRST, longest wait first within a capture. Shows session ID, wait type, duration, blocking session, and database. Complements get_wait_stats by showing individual waiting queries rather than aggregated stats. THE PAGE IS BOUNDED BY limit, NOT BY hours_back: tasks_returned is how many rows you got, truncated says the window held more than limit, and oldest_returned_collection_time / newest_returned_collection_time bound the page — under newest-first ordering the oldest stamp IS how far back this read reached, and one busy capture can fill the whole page by itself. Raise limit or narrow hours_back when truncated is true.")]
+ [McpServerTool(Name = "get_waiting_tasks"), Description("Gets recently captured waiting tasks — queries that were actively waiting on a resource at collection time — NEWEST CAPTURE FIRST, longest wait first within a capture. Shows session ID, wait type, duration, blocking session, and database. Complements get_wait_stats by showing individual waiting queries rather than aggregated stats. <> THE PAGE IS BOUNDED BY limit, NOT BY hours_back: tasks_returned is how many rows you got, truncated says the window held more than limit, and oldest_returned_collection_time / newest_returned_collection_time bound the page — under newest-first ordering the oldest stamp IS how far back this read reached, and one busy capture can fill the whole page by itself. Raise limit or narrow hours_back when truncated is true. When the store did not cover the whole window, effective_start, window_truncated and truncation_note say where its data starts.")]
public static async Task GetWaitingTasks(
LocalDataService dataService,
ServerManager serverManager,
diff --git a/Lite/Services/LocalDataService.ConfigChanges.cs b/Lite/Services/LocalDataService.ConfigChanges.cs
index 13c39a169e..7f477585a6 100644
--- a/Lite/Services/LocalDataService.ConfigChanges.cs
+++ b/Lite/Services/LocalDataService.ConfigChanges.cs
@@ -142,6 +142,38 @@ public async Task> GetTraceFlagChangesAsync(
.ToList();
}
+ ///
+ /// #4966: the coverage probe of a config-change tool (,
+ /// , ). The config reads keep the
+ /// snapshot BEFORE the window as the diff baseline, and the collectors capture on connect, so a server that has stayed
+ /// connected holds its last snapshot days before the window and none inside it:
+ /// answers null there (nothing in the window), which an empty answer would read as "the store holds no collection of this
+ /// in the window". A snapshot at or before the window's start does cover the window, so this answers the start then and
+ /// otherwise what answers.
+ ///
+ internal async Task GetConfigSnapshotCoverageFloorAsync(QueryWindowRelation relation, int serverId, DateTime startUtc, DateTime endUtc)
+ {
+ var floor = await GetQueryWindowFloorAsync(relation, serverId, startUtc, endUtc);
+ if (floor is not null)
+ {
+ return floor;
+ }
+
+ /* A snapshot before the window only proves coverage if the server was monitored during it: at least one run in the
+ window. With none, nothing was read, and the null answer says so. */
+ var view = QueryWindowRelationView(relation);
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+ command.CommandText = $@"
+SELECT 1
+WHERE EXISTS (SELECT 1 FROM {view} WHERE server_id = $1 AND capture_time < $2)
+AND EXISTS (SELECT 1 FROM v_collection_log WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3)";
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = startUtc });
+ command.Parameters.Add(new DuckDBParameter { Value = endUtc });
+ return await command.ExecuteScalarAsync() is not null ? startUtc : null;
+ }
+
private async Task> ReadServerConfigSnapshotsAsync(int serverId, DateTime endUtc)
{
using var connection = await OpenConnectionAsync();