mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
3318467857
The maintenance sweep graduated a stalled session to terminal Failed via the repo (MaintenanceService Stage 2, SessionTimeoutHours=5h default) but wrote no event into the session stream and never enqueued analyze. The analyze pipeline fires on stream events, so a server-timed-out session analyzed to totalIssues:0 — unlike the agent max-lifetime path, which emits a real enrollment_failed and is correctly analyzed. - New server-authored event type Constants.EventTypes.SessionTimeout = "session_timeout" (+ MCP termination_events catalog; drift test green). - MaintenanceService Stage 2 now captures the UpdateSessionStatusAsync result and, only on the real first transition (idempotent — the sweep never re-collects a Failed session), emits a session_timeout event (new pure static BuildSessionTimeoutEvent: Sequence = max(session events)+1 so it sorts last, Phase=Unknown, Source=System. Maintenance, Severity=Error) and enqueues auto-analyze with ReasonEnrollmentFailed. Best-effort try/catch — never blocks the timeout transition. Producer injected. - RuleEngine: EvaluatePrecondition gains a pure event-type-presence gate (no-dataField exists/not_exists) — additive (no rule used dataField-less preconditions), enables a session-level suppression with no shared join field. - New rule ANALYZE-ENRL-002 "Session Timed Out": fires on session_timeout, suppressed when enrollment_complete OR enrollment_failed exists (5h<6h late-completion race + no double-report with ANALYZE-ENRL-001). dist regenerated via combine.js. - Web: session_timeout treated as terminal for stale-status refetch (the sweep sends no SignalR delta). Tests: +3 RuleEngine pure-type precondition, SessionTimeoutRuleTests (real shipped rule fire/suppress), MaintenanceServiceSessionTimeoutEventTests (shape/sequence). Backend 2168 pass, web 434 pass, MCP 177 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
81 lines
3.2 KiB
C#
81 lines
3.2 KiB
C#
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared.Models;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Pins <see cref="MaintenanceService.BuildSessionTimeoutEvent"/> — the server-authored
|
|
/// <c>session_timeout</c> event the 5h maintenance sweep injects into the stream so the analyze
|
|
/// pipeline (ANALYZE-ENRL-002) can fire on an otherwise event-silent terminal timeout. A full
|
|
/// service smoke test would need ~17 deps; the load-bearing logic here is the field shape and the
|
|
/// Sequence assignment (must sort AFTER the session's last event so it isn't interleaved into the
|
|
/// canonical Sequence order), so that lives behind a pure static helper (analog to DecideAutoAction).
|
|
/// </summary>
|
|
public class MaintenanceServiceSessionTimeoutEventTests
|
|
{
|
|
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
|
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
|
|
|
private static SessionSummary Session() => new()
|
|
{
|
|
TenantId = TenantId,
|
|
SessionId = SessionId,
|
|
StartedAt = new DateTime(2026, 5, 31, 8, 0, 0, DateTimeKind.Utc),
|
|
};
|
|
|
|
[Fact]
|
|
public void Sequence_IsOnePastTheSessionMax()
|
|
{
|
|
var existing = new List<EnrollmentEvent>
|
|
{
|
|
new() { Sequence = 3 },
|
|
new() { Sequence = 41 }, // max — even though out of array order
|
|
new() { Sequence = 17 },
|
|
};
|
|
var now = new DateTime(2026, 5, 31, 13, 0, 0, DateTimeKind.Utc);
|
|
|
|
var evt = MaintenanceService.BuildSessionTimeoutEvent(Session(), 5, existing, now);
|
|
|
|
Assert.Equal(42, evt.Sequence);
|
|
}
|
|
|
|
[Fact]
|
|
public void Sequence_IsOne_WhenNoPriorEvents()
|
|
{
|
|
var now = new DateTime(2026, 5, 31, 13, 0, 0, DateTimeKind.Utc);
|
|
|
|
Assert.Equal(1, MaintenanceService.BuildSessionTimeoutEvent(Session(), 5, new List<EnrollmentEvent>(), now).Sequence);
|
|
Assert.Equal(1, MaintenanceService.BuildSessionTimeoutEvent(Session(), 5, null!, now).Sequence);
|
|
}
|
|
|
|
[Fact]
|
|
public void Shape_IsTerminalServerAuthoredAndPhaseUnknown()
|
|
{
|
|
var now = new DateTime(2026, 5, 31, 13, 0, 0, DateTimeKind.Utc);
|
|
|
|
var evt = MaintenanceService.BuildSessionTimeoutEvent(Session(), 5, new List<EnrollmentEvent>(), now);
|
|
|
|
Assert.Equal("session_timeout", evt.EventType);
|
|
Assert.Equal(AutopilotMonitor.Shared.Constants.EventTypes.SessionTimeout, evt.EventType);
|
|
Assert.Equal("System.Maintenance", evt.Source);
|
|
Assert.Equal(EventSeverity.Error, evt.Severity);
|
|
// Non-phase event: must stay Unknown so the UI timeline doesn't render a phantom phase boundary.
|
|
Assert.Equal(EnrollmentPhase.Unknown, evt.Phase);
|
|
Assert.Equal(now, evt.Timestamp);
|
|
Assert.Equal(TenantId, evt.TenantId);
|
|
Assert.Equal(SessionId, evt.SessionId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Data_CarriesTimeoutAttribution()
|
|
{
|
|
var now = new DateTime(2026, 5, 31, 13, 0, 0, DateTimeKind.Utc);
|
|
|
|
var evt = MaintenanceService.BuildSessionTimeoutEvent(Session(), 5, new List<EnrollmentEvent>(), now);
|
|
|
|
Assert.Equal(5, evt.Data["timeoutHours"]);
|
|
Assert.Equal("maintenance_sweep", evt.Data["source"]);
|
|
Assert.Contains("2026-05-31", evt.Data["startedAt"].ToString());
|
|
}
|
|
}
|