From 31535b07e09583c3fcee4643baf9de0682a9df8c Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 7 Sep 2026 21:27:07 +0200 Subject: [PATCH] web+backend: a warming MCP health card says only "Starting instance... (attempt n of 12)" and re-probes for ~60s before it warns (D-220) The card composed its line from the backend's explanation ("did not answer within 3s - it scales to zero when idle ... ~20-30s"): probe mechanics nobody waiting on a starting instance needs. It now writes its own one-liner and ignores check.message, so the server's "warming" and a client-side probe timeout read identically and the wording does not depend on a backend deploy. The backend message is shortened but still carries the budget for direct API callers. The re-poll budget of 8 x 5s (~40s) expired on a real cold start - Container Apps activation has no upper bound we control - so it is now 12 x 5s (~60s), with a test pinning that the stated seconds come from cadence x attempts. --- internal | 2 +- .../Services/HealthCheckService.cs | 4 +++- .../app/health-check/page.tsx | 4 +++- .../lib/__tests__/healthCheckView.test.ts | 16 +++++++++++--- .../lib/healthCheckView.ts | 21 ++++++++++++------- 5 files changed, 34 insertions(+), 13 deletions(-) diff --git a/internal b/internal index 488d17b4..41d3f151 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 488d17b4ea1080dc565c6c72d3d7456c6368231c +Subproject commit 41d3f15126df53b5ca497336bf1c93e1f5718743 diff --git a/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs b/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs index 869dca30..405a9e0c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs @@ -327,7 +327,9 @@ public class HealthCheckService // fault — and with minReplicas=0 it is the expected path for an idle server, // which is why it logs at Information and must not colour any aggregate. check.Status = "warming"; - check.Message = $"MCP server did not answer within {timeoutSeconds}s — it scales to zero when idle and is likely still starting (a cold start takes ~20-30s)."; + // Short on purpose: the portal card renders its own one-line warming text and + // ignores this message, so it only has to be honest for a direct API caller. + check.Message = $"MCP server is starting (no answer within {timeoutSeconds}s; it scales to zero when idle)"; _logger.LogInformation(ex, "MCP server health probe found a cold server (no answer within {TimeoutSeconds}s); activation continues in the background", timeoutSeconds); } catch (HttpRequestException ex) diff --git a/src/Web/autopilot-monitor-web/app/health-check/page.tsx b/src/Web/autopilot-monitor-web/app/health-check/page.tsx index c5b729aa..77c6ecc5 100644 --- a/src/Web/autopilot-monitor-web/app/health-check/page.tsx +++ b/src/Web/autopilot-monitor-web/app/health-check/page.tsx @@ -156,7 +156,9 @@ export default function HealthCheckPage() { description: 'AI query interface availability', status: clientTimedOut ? 'warming' : 'warning', message: clientTimedOut - ? 'MCP status check timed out — the server is likely still starting.' + // Never rendered — resolveMcpCardState writes the warming line itself — but it + // keeps this object honest for anything that reads the raw check. + ? 'MCP server is starting (probe timed out)' : error instanceof Error ? error.message : 'MCP status check could not complete', }); } finally { diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/healthCheckView.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/healthCheckView.test.ts index 9a870065..4e506634 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/healthCheckView.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/healthCheckView.test.ts @@ -6,6 +6,7 @@ import { visibleHealthDetails, resolveMcpCardState, MCP_WARMING_MAX_ATTEMPTS, + MCP_WARMING_POLL_MS, } from "../healthCheckView"; const checks: HealthCheck[] = [ @@ -98,7 +99,7 @@ describe("visibleHealthDetails", () => { }); describe("resolveMcpCardState", () => { - const warming = (message = "MCP server did not answer within 3s — it scales to zero when idle."): HealthCheck => ({ + const warming = (message = "MCP server is starting (no answer within 3s; it scales to zero when idle)"): HealthCheck => ({ name: "MCP Server", description: "AI query interface availability", status: "warming", @@ -124,13 +125,18 @@ describe("resolveMcpCardState", () => { expect(s.display.status).toBe("warming"); expect(s.ratedStatus).toBeNull(); expect(s.shouldPoll).toBe(true); - expect(s.display.message).toContain("attempt 1 of 8"); + expect(s.display.message).toBe(`Starting instance… (attempt 1 of ${MCP_WARMING_MAX_ATTEMPTS})`); + }); + + it("does not carry the server's explanation into the card", () => { + const s = resolveMcpCardState({ check: warming(), loading: false, attempts: 0 }); + expect(s.display.message).not.toContain("scales to zero"); }); it("keeps showing warming while the next probe runs, so the card cannot flicker", () => { const s = resolveMcpCardState({ check: warming(), loading: true, attempts: 3 }); expect(s.display.status).toBe("warming"); - expect(s.display.message).toContain("attempt 4 of 8"); + expect(s.display.message).toContain(`attempt 4 of ${MCP_WARMING_MAX_ATTEMPTS}`); expect(s.shouldPoll).toBe(true); }); @@ -139,6 +145,10 @@ describe("resolveMcpCardState", () => { expect(s.display.status).toBe("warning"); expect(s.ratedStatus).toBe("warning"); expect(s.shouldPoll).toBe(false); + // The stated budget must be the one actually spent, not a number that drifted. + expect(s.display.message).toContain( + `~${Math.round((MCP_WARMING_MAX_ATTEMPTS * MCP_WARMING_POLL_MS) / 1000)}s`, + ); }); it("rates a healthy server and stops polling", () => { diff --git a/src/Web/autopilot-monitor-web/lib/healthCheckView.ts b/src/Web/autopilot-monitor-web/lib/healthCheckView.ts index 7d033236..799a87b2 100644 --- a/src/Web/autopilot-monitor-web/lib/healthCheckView.ts +++ b/src/Web/autopilot-monitor-web/lib/healthCheckView.ts @@ -36,13 +36,15 @@ export function visibleHealthChecks(checks: readonly HealthCheck[], operatorView * (see HealthCheckService.CheckMcpServerAsync) rather than holding the request. The card * then re-polls on its own until the replica is up. * - * 5s x 8 attempts ≈ 46s of coverage against a measured 13-25s activation (worst observed - * first byte 31.5s). Shorter intervals buy nothing — every attempt costs the backend a 3s - * held request — and more attempts would turn a genuinely broken container into a silent - * forever-spinner instead of an honest warning. + * 5s x 12 attempts ≈ 60s of coverage. The measured activation is 13-25s (worst observed + * first byte 31.5s), but a 40s budget was seen to expire on a real cold start — Container + * Apps activation has no upper bound we control — so the budget is 60s. Shorter intervals + * buy nothing (every attempt costs the backend a 3s held request), and stretching it much + * further would turn a genuinely broken container into a silent forever-spinner instead of + * an honest warning. */ export const MCP_WARMING_POLL_MS = 5_000; -export const MCP_WARMING_MAX_ATTEMPTS = 8; +export const MCP_WARMING_MAX_ATTEMPTS = 12; /** * Client-side budget for one probe. Comfortably above the backend's ~3s answer, and @@ -88,7 +90,7 @@ export function resolveMcpCardState(args: { display: { ...MCP_CARD_BASE, status: "warning", - message: `MCP server did not come up within ~${Math.round((MCP_WARMING_MAX_ATTEMPTS * MCP_WARMING_POLL_MS) / 1000)}s. Use Re-check, or the container may be failing to start.`, + message: `Instance did not start within ~${Math.round((MCP_WARMING_MAX_ATTEMPTS * MCP_WARMING_POLL_MS) / 1000)}s — use Re-check.`, }, ratedStatus: "warning", shouldPoll: false, @@ -96,11 +98,16 @@ export function resolveMcpCardState(args: { } // Deliberately keeps showing "warming" while the next probe is in flight: switching to // "checking" every 5s would make the card flicker for the whole cold start. + // + // The card owns this wording and ignores check.message on purpose: a scale-from-zero is + // routine, nobody watching the page needs the probe budget or the mechanics of it (they + // stay in the backend log and in the API message), and the two warming sources — the + // server's own "warming" and a client-side probe timeout — must read identically. return { display: { ...MCP_CARD_BASE, status: "warming", - message: `${check.message} Re-checking automatically (attempt ${attempts + 1} of ${MCP_WARMING_MAX_ATTEMPTS})…`, + message: `Starting instance… (attempt ${attempts + 1} of ${MCP_WARMING_MAX_ATTEMPTS})`, details: check.details, }, ratedStatus: null,