mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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.
This commit is contained in:
+1
-1
Submodule internal updated: 488d17b4ea...41d3f15126
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user