mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
fix WhiteGloveClassifier and Completion logic, fix Phase Timeline
This commit is contained in:
@@ -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
|
||||
// =====================================================================
|
||||
|
||||
+22
-14
@@ -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 ---
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user