fix WhiteGloveClassifier and Completion logic, fix Phase Timeline

This commit is contained in:
Oliver Kieselbach
2026-04-17 12:59:38 +02:00
parent 6efae2c709
commit 6719464f34
8 changed files with 151 additions and 23 deletions
@@ -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
// =====================================================================
@@ -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<string, object>
{
{ "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();
@@ -1 +1 @@
1033
1036
@@ -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;
};
@@ -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<number, string> = { [-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<number, string> = { [-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" };
@@ -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);
});
});
@@ -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 ---
+10
View File
@@ -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.