diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/WhiteGloveClassifierTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/WhiteGloveClassifierTests.cs index a5ca440a..2e926d09 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/WhiteGloveClassifierTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/WhiteGloveClassifierTests.cs @@ -212,6 +212,32 @@ namespace AutopilotMonitor.Agent.Core.Tests.Tracking Assert.False(after.ShouldRouteToWhiteGlovePart1); } + // ===================================================================== + // Session 304620a8 regression — WhiteGlove Part 2 re-entry after resume + // ===================================================================== + + [Fact] + public void Classify_Part2ResumeWithAllPositivesAndAadJoin_IsWeakNotStrong() + { + // Reproduziert den Signalstand beim 2. ESP-Exit in Session 304620a8-...: + // Part 1 ist sauber durchgelaufen (Shell-Core + SaveWg + Event509 + FooUser), + // Agent hat nach Part 1 rebootet, in Part 2 hat der reale User sich + // eingeloggt (AadJoinedWithUser=true). Der 2. ESP-Exit darf nicht erneut + // zu whiteglove_complete routen — Hard-Excluder muss das Routing kippen. + var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + ShellCoreWhiteGloveSuccess = true, // +80 + HasSaveWhiteGloveSuccessResult = true, // +10 + IsWhiteGloveStartDetected = true, // +15 + IsFooUserDetected = true, // +20 + AgentRestartedAfterEspExit = true, // +10 + AadJoinedWithUser = true, // -100 hard excluder + }); + + Assert.False(result.ShouldRouteToWhiteGlovePart1); + Assert.NotEqual(WhiteGloveConfidence.Strong, result.Confidence); + } + // ===================================================================== // ShouldRouteToWhiteGlovePart1 only fires on Strong // ===================================================================== diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs index 3a8b839e..9b1e4ec3 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs @@ -714,25 +714,32 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment } else if (skipUser == true) { - // WhiteGlove guard: Only SaveWhiteGloveSuccessResult=succeeded is reliable. - // EventID 509 is a soft signal — fires on hybrid-join too, must not trigger WG. - bool wgStarted = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false; - bool wgSuccessResult = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false; + // WhiteGlove guard — canonical decision via WhiteGloveClassifier, identical to + // the other three WG decision sites. HasSaveWhiteGloveSuccessResult alone is + // not sufficient: after WhiteGlove Part 1 the registry flag remains set and a + // subsequent user-enrollment ESP exit (Part 2) would falsely re-route to WG. + // The classifier's AadJoinedWithUser hard-excluder blocks that path correctly. + var wgSignals = SnapshotWhiteGloveSignals(); + var wgClassification = WhiteGloveClassifier.Classify(wgSignals); + EmitWhiteGloveClassification(wgClassification, "EspExiting_SkipUserTrue", wgSignals); - if (wgSuccessResult) + if (wgClassification.ShouldRouteToWhiteGlovePart1) { - _logger.Info($"EnrollmentTracker: ESP exiting with SkipUserStatusPage=true and WhiteGlove confirmed " + - $"(whiteGloveStarted={wgStarted}, saveWhiteGloveSuccessResult={wgSuccessResult}) — " + - "routing to WhiteGlove completion instead of device_only_esp_registry"); + _logger.Info($"EnrollmentTracker: ESP exiting with SkipUserStatusPage=true and WG classifier Strong " + + $"({wgClassification.Reason}) — routing to WhiteGlove completion instead of device_only_esp_registry"); RecordSignal("whiteglove_guard_activated"); EmitTraceEvent("whiteglove_guard_esp_exiting", - "ESP exiting with SaveWhiteGloveSuccessResult confirmed — routing to WhiteGlove path", + "ESP exiting with WG classifier Strong — routing to WhiteGlove path", new Dictionary { - { "whiteGloveStartDetected", wgStarted }, - { "saveWhiteGloveSuccessResult", wgSuccessResult }, - { "fooUserDetected", _fooUserDetected }, + { "wgScore", wgClassification.Score }, + { "wgConfidence", wgClassification.Confidence.ToString() }, + { "wgFactors", wgClassification.ContributingFactors }, + { "whiteGloveStartDetected", wgSignals.IsWhiteGloveStartDetected }, + { "saveWhiteGloveSuccessResult", wgSignals.HasSaveWhiteGloveSuccessResult }, + { "fooUserDetected", wgSignals.IsFooUserDetected }, + { "aadJoinedWithUser", wgSignals.AadJoinedWithUser }, { "previousPhase", lastPhase ?? "unknown" } }); @@ -1161,8 +1168,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment } // GUARD 4: WhiteGlove Part 1 redirect — canonical classifier decision. - // All three WG decision sites go through WhiteGloveClassifier, so this guard - // uses the same rule as OnDeviceSetupProvisioningComplete and the StateMachine. + // All four WG decision sites go through WhiteGloveClassifier (this guard, + // OnDeviceSetupProvisioningComplete, OnFinalizingSetupPhaseTriggered esp_exiting, + // and the StateMachine), so decisions converge on the same rule. // ShouldRouteToWhiteGlovePart1==true only on Strong confidence; Weak is // observable telemetry but falls through to the regular completion path. var wgSignals = SnapshotWhiteGloveSignals(); diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 0ff3e5b1..b633d3d9 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1033 +1036 diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx index 4cfc3938..3fbb79db 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx @@ -2,7 +2,7 @@ import React from "react"; import { EnrollmentEvent } from "@/types"; -import { V1_PHASES, V1_SKIP_USER_PHASES, V2_PHASES } from "../utils/phaseConstants"; +import { resolvePhaseLayout } from "../utils/phaseConstants"; interface PhaseTimelineProps { currentPhase: number; @@ -16,9 +16,7 @@ interface PhaseTimelineProps { } export default function PhaseTimeline({ currentPhase, completedPhases, events = [], sessionStatus, enrollmentType, isPreProvisioned, isSkipUserStatusPage, onPhaseClick }: PhaseTimelineProps) { - const phases = enrollmentType === "v2" - ? V2_PHASES - : isSkipUserStatusPage ? V1_SKIP_USER_PHASES : V1_PHASES; + const { phases, useSkipLayout } = resolvePhaseLayout({ enrollmentType, isSkipUserStatusPage, isPreProvisioned }); // Derive current activity for the active phase from events const getCurrentActivity = (phaseId: number): string | null => { @@ -149,7 +147,7 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events = let end: number | null = null; - if (isSkipUserStatusPage) { + if (useSkipLayout) { // V1 SkipUser: Device Setup(2) → FinalizingSetup(6) → AppsUser(5) → Complete(7) // No AccountSetup(4). AppsUser is a top-level phase (not sub-phase). switch (phaseId) { @@ -207,7 +205,7 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events = // since there is no Account Setup parent phase. const isSubPhase = (phaseId: number): boolean => { if (enrollmentType === "v2") return false; - if (isSkipUserStatusPage) return phaseId === 3; + if (useSkipLayout) return phaseId === 3; return phaseId === 3 || phaseId === 5; }; diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/phaseConstants.ts b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/phaseConstants.ts index f13f527f..774a8bbd 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/phaseConstants.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/phaseConstants.ts @@ -32,6 +32,26 @@ export const V2_PHASES = [ export const V1_PHASE_ORDER = ["Start", "Device Preparation", "Device Setup", "Apps (Device)", "Account Setup", "Apps (User)", "Finalizing Setup", "Complete", "Failed"]; export const V2_PHASE_ORDER = ["Start", "Device Preparation", "App Installation", "Finalizing Setup", "Complete", "Failed"]; +export type PhaseDescriptor = { id: number; name: string; shortName: string }; + +// WhiteGlove (IsPreProvisioned=true) runs the full user enrollment in Part 2 even when the +// admin set SkipUserStatusPage=true — the collapsed layout only applies to pure device-only +// deployments. V2 always uses its simplified phase set. +export function resolvePhaseLayout(params: { + enrollmentType?: string; + isSkipUserStatusPage?: boolean; + isPreProvisioned?: boolean; +}): { phases: PhaseDescriptor[]; useSkipLayout: boolean } { + if (params.enrollmentType === "v2") { + return { phases: V2_PHASES, useSkipLayout: false }; + } + const useSkipLayout = !!params.isSkipUserStatusPage && !params.isPreProvisioned; + return { + phases: useSkipLayout ? V1_SKIP_USER_PHASES : V1_PHASES, + useSkipLayout, + }; +} + // Lookup by phase number — Phase 3 has different names per enrollment type export const V1_PHASE_NAMES: Record = { [-1]: "Unknown", 0: "Start", 1: "Device Preparation", 2: "Device Setup", 3: "Apps (Device)", 4: "Account Setup", 5: "Apps (User)", 6: "Finalizing Setup", 7: "Complete", 99: "Failed" }; export const V2_PHASE_NAMES: Record = { [-1]: "Unknown", 0: "Start", 1: "Device Preparation", 2: "Device Setup", 3: "App Installation", 4: "Account Setup", 5: "Apps (User)", 6: "Finalizing Setup", 7: "Complete", 99: "Failed" }; diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/phaseLayout.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/phaseLayout.test.ts new file mode 100644 index 00000000..581ce29d --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/phaseLayout.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { + resolvePhaseLayout, + V1_PHASES, + V1_SKIP_USER_PHASES, + V2_PHASES, +} from "../../app/sessions/[sessionId]/utils/phaseConstants"; + +describe("resolvePhaseLayout", () => { + it("returns full V1 layout for WhiteGlove + SkipUserStatusPage=true (user enrollment runs in Part 2)", () => { + const { phases, useSkipLayout } = resolvePhaseLayout({ + enrollmentType: "v1", + isSkipUserStatusPage: true, + isPreProvisioned: true, + }); + expect(useSkipLayout).toBe(false); + expect(phases).toBe(V1_PHASES); + expect(phases.map((p) => p.id)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); + expect(phases.find((p) => p.id === 4)?.name).toBe("Account Setup"); + }); + + it("returns collapsed V1_SKIP_USER layout for Non-WhiteGlove + SkipUserStatusPage=true (pure device-only)", () => { + const { phases, useSkipLayout } = resolvePhaseLayout({ + enrollmentType: "v1", + isSkipUserStatusPage: true, + isPreProvisioned: false, + }); + expect(useSkipLayout).toBe(true); + expect(phases).toBe(V1_SKIP_USER_PHASES); + // No Account Setup (id=4), Finalizing (id=6) comes before Apps (User) (id=5) + expect(phases.map((p) => p.id)).toEqual([0, 1, 2, 3, 6, 5, 7]); + expect(phases.find((p) => p.id === 4)).toBeUndefined(); + }); + + it("returns full V1 layout for standard V1 without SkipUserStatusPage", () => { + const { phases, useSkipLayout } = resolvePhaseLayout({ + enrollmentType: "v1", + isSkipUserStatusPage: false, + isPreProvisioned: false, + }); + expect(useSkipLayout).toBe(false); + expect(phases).toBe(V1_PHASES); + }); + + it("returns V2 layout for V2 enrollment regardless of SkipUserStatusPage/IsPreProvisioned", () => { + for (const flags of [ + { isSkipUserStatusPage: false, isPreProvisioned: false }, + { isSkipUserStatusPage: true, isPreProvisioned: false }, + { isSkipUserStatusPage: false, isPreProvisioned: true }, + { isSkipUserStatusPage: true, isPreProvisioned: true }, + ]) { + const { phases, useSkipLayout } = resolvePhaseLayout({ enrollmentType: "v2", ...flags }); + expect(useSkipLayout).toBe(false); + expect(phases).toBe(V2_PHASES); + } + }); + + it("treats undefined/missing SkipUserStatusPage as not-skip", () => { + const { phases, useSkipLayout } = resolvePhaseLayout({ + enrollmentType: "v1", + isPreProvisioned: false, + }); + expect(useSkipLayout).toBe(false); + expect(phases).toBe(V1_PHASES); + }); +}); diff --git a/src/Web/autopilot-monitor-web/utils/sessionExportUtils.ts b/src/Web/autopilot-monitor-web/utils/sessionExportUtils.ts index 289d152a..ba6dd3f7 100644 --- a/src/Web/autopilot-monitor-web/utils/sessionExportUtils.ts +++ b/src/Web/autopilot-monitor-web/utils/sessionExportUtils.ts @@ -276,7 +276,7 @@ export function generateUiExport( if (!isWhiteGlove) { // Standard single timeline - const grouped = groupEventsByPhase(sorted, phaseNames, phaseOrder); + const grouped = groupEventsByPhase(sorted, phaseNames, phaseOrder, { preventPhaseRegression: true }); renderPhaseBlocks(lines, grouped, phaseOrder, severityLabel); } else { // WhiteGlove session: two-part timeline, matching the UI split logic. @@ -347,7 +347,7 @@ export function generateUiExport( lines.push("#".repeat(43)); lines.push(` PRE-PROVISIONING PART [WhiteGlove]`); lines.push("#".repeat(43)); - const preProvGrouped = groupEventsByPhase(preProvEvents, phaseNames, phaseOrder); + const preProvGrouped = groupEventsByPhase(preProvEvents, phaseNames, phaseOrder, { preventPhaseRegression: true }); renderPhaseBlocks(lines, preProvGrouped, phaseOrder, severityLabel); // --- User Enrollment Part or Awaiting Banner --- diff --git a/tasks/todo.md b/tasks/todo.md index 97fd47d5..3097d143 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -12,3 +12,13 @@ Pflicht-Checks nach Deploy. Siehe [feedback_post_deploy_in_todo.md](C:/Users/Oli - [ ] **+7d** — Gewichts-Review: brauchen `ShellCoreWhiteGloveSuccess (+80)` oder `HasSaveWhiteGloveSuccessResult (+10)` Anpassung? Falls ja Follow-up-Ticket - [ ] **+7d** — `shadow_discrepancy`-Rate vs. Baseline vor Deploy (siehe [project_shadow_sm_cutover.md](C:/Users/OliverKieselbach/.claude/projects/c--Code-GitHubRepos-Autopilot-Monitor/memory/project_shadow_sm_cutover.md)): sollte fallen, nicht steigen - [ ] Ergebnisse inline dokumentieren (z.B. `✓ 2026-04-20: 142 Strong / 18 Weak / 3400 None`) + +## Post-Deploy: WhiteGlove Guard-Site 4 (`esp_exiting` Classifier) (Deploy-Datum: _YYYY-MM-DD eintragen bei Rollout_) + +Plan: `C:\Users\OliverKieselbach\.claude\plans\304620a8-3f18-4927-9bed-324bb1997303-bei-rustling-cake.md` +Referenz-Session die den Bug ausgelöst hat: `304620a8-3f18-4927-9bed-324bb1997303` (Status=Pending nach 2x whiteglove_complete). + +- [ ] **+24h** — `whiteglove_classification` Events mit `callSite=EspExiting_SkipUserTrue`: `query_table(table="Events", filter="EventType eq 'whiteglove_classification' and Timestamp ge <24h-ago>")` → Client-Filter `data.callSite=="EspExiting_SkipUserTrue"` → Verteilung Strong/Weak/None protokollieren. Erwartung: die allermeisten echten Part-1-ESP-Exits bleiben Strong; Part-2-Resume-Sessions mit AADJ fallen auf Weak/None. +- [ ] **+24h** — Rate `decision_process_completion` mit `outcome=pending_part1` muss gegenüber 7d-Baseline vor Deploy sinken. `query_table(table="Events", filter="EventType eq 'decision_process_completion'")` + Client-Filter `data.outcome=="pending_part1"`. +- [ ] **+24h** — Suche nach Sessions mit `whiteglove_complete` **und** `aadJoinedWithUser=true` **und** `Status=Pending`: sollte nach Deploy ≈ 0 sein. Identifiziert Sessions die weiterhin falsch klassifiziert werden. +- [ ] **+7d** — `agent_trace` mit `decision=whiteglove_guard_esp_exiting` prüfen: DataJson muss `wgScore`/`wgConfidence`/`wgFactors` enthalten (Classifier-basiert), keine alten Einträge ohne Score.