feat(v2): WG Part-2 architectural cleanup + install-mode handoff fix

Bundled change covering three orthogonal pieces of work that all landed in
the same working tree before the next PR-style commit could be split out.

## WG Part-2 architectural cleanup (PR-A through PR-E)

Plan: ~/.claude/plans/wg-part2-architectural-cleanup.md.

The V2 agent carried a full separate "Part-2 apparatus" (4 special signal
kinds, 2 special stages, a special outcome, a special classifier, a 24h
safety deadline, a dedicated reducer file, adapter `part2Mode` switches,
a SessionRecovered bridge). This did not match OS reality: post-reseal
Part 2 is just a normal user-driven OOBE running through the Classic
flow. The apparatus also carried a real risk (24h false-positive
enrollment_failed mid-success) and was not even active in practice
(observed in session bdb3cf9d-541b-4c44-aebe-5b9e72743b0a, 2026-05-04).

V2 is now V1-symmetric:

  PR-A — Orchestrator Resume Mechanic. EnrollmentOrchestrator detects a
  persisted Stage=WhiteGloveSealed snapshot at Start(), archives the
  reducer-state segments to state/.part1-<utc-ts>/, runs as a fresh
  first-boot through the normal recovery branches, and posts a single
  whiteglove_resumed InformationalEvent after the onIngressReady hook.
  New helper class StateArchiver.cs + StateArchiverTests +
  EnrollmentOrchestratorPart2EmissionTests.

  PR-B — DecisionCore Cleanup. Removed:
    - DecisionSignalKind.UserAadSignInComplete / HelloResolvedPart2 /
      DesktopArrivedPart2 / AccountSetupCompletedPart2 / SessionRecovered
    - SessionStage.WhiteGloveAwaitingUserSignIn / WhiteGloveCompletedPart2
    - SessionOutcome.WhiteGlovePart2Complete
    - DeadlineNames.WhiteGlovePart2Safety
    - DecisionState's 4 Part-2 SignalFacts (+ builder + snapshot slots)
    - WhiteGlovePart2CompletionClassifier + WhiteGlovePart2CompletionSnapshot
    - DecisionEngine.WhiteGlovePart2.cs (entire reducer file)
    - HandleSessionRecoveredV1 dispatcher + handler

  PR-C — Adapter Cleanup. Dropped the inert `part2Mode` ctor parameter
  from AadJoinWatcherAdapter, DesktopArrivalDetectorAdapter,
  HelloTrackerAdapter, EspAndHelloTrackerAdapter (and their tests).
  AadJoinHost is the only production caller using a named-arg form,
  unaffected.

  PR-D — Backend + Web. Removed `WhiteGloveCompletedPart2` cases from
  TelemetryPayloadParser.IsTerminalStage and DecisionGraphBuilder
  (IsTerminalStage + DeriveTerminalOutcome) plus their tests. Dropped
  the dead `UserAadSignInComplete` constant from Shared.Constants.
  Extracted pure helper computeWhiteGloveSplitSequence(events) in
  app/sessions/[sessionId]/utils/eventHelpers.ts; useSessionDerivedData +
  utils/sessionExportUtils now share it. New 6-case vitest covering
  splitter behaviour (no marker, primary trigger via whiteglove_resumed,
  agent_started fallback, Pending-only variants, primary-vs-fallback
  race). PhaseTimeline + eventTypes were already on the new model.

  PR-E — Tests + Fixtures Final Cleanup. Deleted the two
  whiteglove-part2-*.jsonl fixtures + EdgeScenarioTests references;
  removed dead `whiteglove_part2` case + test from SummaryDialog
  OutcomeMapper; trimmed PR-B history breadcrumbs that still embedded
  removed symbol names so the final grep is clean. Updated MEMORY
  entries (project_v2_completion_location, project_wg_part2_simple_model,
  project_wg_signal_validation_pending, project_bdb3cf9d_wg_followups,
  MEMORY.md index).

What stayed: SessionStage.WhiteGloveSealed (Part-1 marker), the
`_isWhiteGlovePart2` Hint-Bool (V1-symmetric, plan §11) wired only into
EnrollmentTerminationHandler / SoftwareInventoryAnalyzer for shutdown-
analyzer phase=2 tagging, the `whiteglove_part1_complete` event-type +
`whiteglove_part1` outcome-string, `PausedForPart2` terminal-outcome
label for the Inspector, the `whiteglove_resumed` event as UI-splitter
trigger.

Final acceptance: grep for the removed symbol set in src/ excluding V1
(Agent.Core/) is empty. V2.Core.Tests 991 / DecisionCore.Tests 282 /
Functions.Tests 862 / SummaryDialog.Tests 44 / Web vitest 227 — all
green. Solution build clean.

## Resume-path correctness fixes (Codex review of the cleanup)

Three findings on the new resume mechanic:

  P1a — AgentRuntimeHost was still skipping LifecycleEmitters
  PostEnrollmentFactsObserved + PostSessionStarted on isWhiteGloveResume.
  After PR-A's archive-and-reset the decision state is empty, so the
  Classic flow needs both anchors (otherwise ScenarioProfile stays at
  default and the Hello+Desktop AND-gate misclassifies). Fixed: post
  unconditionally for facts; SessionStarted is now only gated by an
  AdminAction, not by the resume flag. The orchestrator's
  whiteglove_resumed lands first (smaller sequence) so the Web splitter
  (`splitSequence = resumed.sequence - 1`) still puts SessionStarted
  into the Part-2 timeline block.

  P1b — StateArchiver was archiving event-sequence.json along with the
  reducer state. Part 2 reuses the same SessionId and the backend orders
  events session-wide by (SessionId, Sequence); the Web split also uses
  `splitSequence = resumed.sequence - 1`. Resetting the counter at the
  resume boundary would collide Part-1 and Part-2 sequences and break
  the splitter. event-sequence.json is now intentionally preserved; the
  three reducer-state segments still archive. StateArchiverTests +
  RecoveryPathTests asserting the new contract.

  P2 — V1 reset the local session-age clock on Part-2 resume
  (SessionPersistence.ResetSessionCreatedAt, MonitoringService.cs:530).
  V2 was clearing only the WhiteGlove marker, so a crash/reboot during
  Part-2 AccountSetup would let the next start run the emergency-break
  watchdog against the original Part-1 timestamp and trip too early.
  AgentRuntimeHost now also calls SessionIdPersistence.SaveSessionCreatedAt
  (DateTime.UtcNow) right after ClearWhiteGloveComplete. New
  ProgramGuardsTests test asserting the V1-symmetric resume contract.

## Install-mode handoff hardening

Plan reference (memory): project_v2_install_runtime_handoff.md.
Bootstrap script + Program.InstallMode + InstallModeTaskXmlTests.

  PR1 — Replace `schtasks /Run` with WMI Win32_Process.Create for the
  install-time runtime handoff. schtasks /Run reports SUCCESS as soon
  as the run is queued; the same battery / AV / OOBE-defer paths that
  motivate PR2 also queue a manual /Run (observed WinPE 2026-05-04,
  task stuck in Queued state). WMI also gives the runtime process the
  same isolation Task Scheduler uses, without the Job-Object inheritance
  risk of a direct Process.Start.

  PR2 — Register the BootTrigger Scheduled Task from a hardened XML
  definition rather than `schtasks /Create`. The CLI form inherits XML
  schema defaults DisallowStartIfOnBatteries=true and
  StopIfGoingOnBatteries=true, which queue the BootTrigger run
  indefinitely on a laptop that boots on battery in OOBE / WinPE. The
  XML disables both, sets StartWhenAvailable, and pins
  ExecutionTimeLimit=PT0S so the long-running agent is never killed at
  the schtasks 72h CLI default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-04 18:36:37 +02:00
parent c0b7b50457
commit d16b60f14c
75 changed files with 1277 additions and 1244 deletions
@@ -23,6 +23,9 @@
matches production behaviour on fresh images.
.CHANGELOG
2026-05-04 v2.0-pre-dev Verify-block message aligned with prod variant: WMI-detached
runtime launch (Program.InstallMode.cs PR1) and XML-hardened
BootTrigger fallback (PR2). Comment trimmed.
2026-04-20 v2.0-pre-dev Forked from Install-AutopilotMonitor-Dev.ps1 v1.1-dev for V2-Agent.
Default URL -> AutopilotMonitor-Agent-V2-dev.zip,
integrity file -> version-v2-dev.json,
@@ -261,11 +264,10 @@ try {
}
Write-Log "Agent install mode completed successfully"
# Verify the runtime process actually started after schtasks /Run.
# schtasks reports SUCCESS as soon as the run is queued, not once the process
# is alive. Silent launch failures (AV/EDR block, AppLocker/WDAC, scheduler
# defer during OOBE) would otherwise stay invisible until the next reboot
# triggers the task via /SC ONSTART.
# Belt-and-suspenders runtime verify. --install spawns the runtime via WMI
# (PR1, 2026-05-04), bypassing the schtasks /Run queue defer. This still
# catches AV/EDR or AppLocker/WDAC kills after spawn. On miss, the BootTrigger
# task (PR2 hardened) takes over at next boot.
$runtimeProcessName = 'AutopilotMonitor.Agent.V2'
$verifyTimeoutSec = 10
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
@@ -280,7 +282,7 @@ try {
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
} else {
Write-Log ("WARNING: Runtime process verification FAILED. schtasks /Run was reported SUCCESS by --install, but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) or Task Scheduler defer. Agent may not start until next system boot ('/SC ONSTART'). Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the WMI-detached launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
}
Write-Log "===== Bootstrap Completed Successfully (DEV) ====="
@@ -38,10 +38,11 @@
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
2026-05-04 v2.0-pre WMI-detached runtimelaunch (Program.InstallMode.cs PR1) instead
of schtasks /Run, and BootTrigger fallback via XML-hardened task (PR2).
2026-04-20 v2.0-pre Forked from Install-AutopilotMonitor.ps1 v1.1 for V2-Agent.
URL -> AutopilotMonitor-Agent-V2.zip, integrity file -> version-v2.json,
agent exe -> AutopilotMonitor.Agent.V2.exe. Same guards + install flow.
Plan: plans/REFACTOR_AGENT_V2.md section 4 M2.
2026-04-09 v1.1 Introduced explicit script version and log it on startup
(all entries above were v1.0)
2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard:
@@ -124,7 +125,6 @@ try {
# Guard 4: Bootstrap window expired (device uptime > 12h without agent)
# Guard 5: Agent binary already present from a previous run
# NOTE: OOBEInProgress is NOT used -- it is unreliable (observed =0 during active enrollment).
# NOTE: explorer.exe is NOT used -- it runs even in early OOBE right after script execution.
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
@@ -296,12 +296,9 @@ try {
}
# TenantId-wait timeout (seconds). When the registry probe finds no TenantId on
# first try, the agent registers a RegistryWatcher on the Enrollments + CloudDomainJoin
# keys and waits up to this long for AAD/MDM enrollment to populate before bailing.
# 0 = no wait (legacy fast-fail). Increase here (and re-deploy via Intune) for tenants
# where hybrid join + auto-MDM enrollment regularly needs longer than 10 min.
# Field evidence (GardnerMedia, hybrid-AAD-joined, 2026-04-30): the AAD device cert
# landed ~5 min after the agent fired, so 600 s is comfortably sufficient.
# first try, the agent watches Enrollments + CloudDomainJoin until populated.
# Hybrid-AAD-joined devices typically need ~5 min for the AAD device cert, so
# 600 s leaves headroom. 0 = no wait (legacy fast-fail).
$tenantIdWaitSec = 600
# Let the agent install/deploy itself and manage its own Scheduled Task
@@ -313,11 +310,6 @@ try {
}
Write-Log "Agent install mode completed successfully"
# Verify the runtime process actually started after schtasks /Run.
# schtasks reports SUCCESS as soon as the run is queued, not once the process
# is alive. Silent launch failures (AV/EDR block, AppLocker/WDAC, scheduler
# defer during OOBE) would otherwise stay invisible until the next reboot
# triggers the task via /SC ONSTART.
$runtimeProcessName = 'AutopilotMonitor.Agent.V2'
$verifyTimeoutSec = 10
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
@@ -332,7 +324,7 @@ try {
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
} else {
Write-Log ("WARNING: Runtime process verification FAILED. schtasks /Run was reported SUCCESS by --install, but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) or Task Scheduler defer. Agent may not start until next system boot ('/SC ONSTART'). Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the WMI-detached launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
}
Write-Log "===== Bootstrap Completed Successfully ====="
@@ -22,7 +22,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
/// <para>
/// <b>Drives the real pipeline</b>: DecisionEngine + DecisionStepProcessor + SignalIngress +
/// EffectRunner + Persistence (SignalLog/Journal/Snapshot) + TelemetryTransport + production
/// Classifiers (WhiteGloveSealingClassifier + WhiteGlovePart2CompletionClassifier).
/// Classifiers (WhiteGloveSealingClassifier).
/// </para>
/// <para>
/// <b>System-boundary fakes only</b>: HTTP upload (<see cref="FakeBackendTelemetryUploader"/>),
@@ -64,7 +64,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
var classifiers = new List<IClassifier>
{
new WhiteGloveSealingClassifier(),
new WhiteGlovePart2CompletionClassifier(),
};
Orchestrator = new EnrollmentOrchestrator(
@@ -163,45 +163,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
f.Stop();
}
// ================================================================ 9) WhiteGlove Part-2 Happy
// (PR-B 2026-05-04: Scenario 9 "WhiteGlove Part-2 Happy" removed — the V2-only
// Part-2 apparatus (Spezial-Stages / Outcomes / Classifier / Safety-Deadline) was
// deleted along with the bridge-signal mechanic. Post-reseal-reboot Part-2 now
// runs as a fresh Classic enrollment driven by the orchestrator's archive-and-reset
// path; the fixture and a replacement classic-flow scenario test land in PR-E.)
[Fact]
public void Scenario09_WhiteGlovePart2_Happy_completes_part2()
{
using var f = new EnrollmentOrchestratorFixture();
f.Start();
f.PostFixture("whiteglove-part2-happy-v1.jsonl");
var reached = f.WaitForStage(
DefaultTerminalTimeoutMs,
SessionStage.WhiteGloveCompletedPart2,
SessionStage.Completed,
SessionStage.Failed);
Assert.True(reached,
$"Part-2 happy fixture did not reach a terminal stage within {DefaultTerminalTimeoutMs}ms; " +
$"stuck at {f.Orchestrator.CurrentState.Stage} (stepIndex={f.Orchestrator.CurrentState.StepIndex}). " +
$"Last transition: {f.LastTransition()?.Trigger}.");
Assert.Equal(SessionStage.WhiteGloveCompletedPart2, f.Orchestrator.CurrentState.Stage);
Assert.Equal(SessionOutcome.WhiteGlovePart2Complete, f.Orchestrator.CurrentState.Outcome);
f.Stop();
}
// ================================================================ 10) WhiteGlove Part-2 Stuck
[Fact]
public void Scenario10_WhiteGlovePart2_Stuck_fails_on_safety_deadline()
{
using var f = new EnrollmentOrchestratorFixture();
f.Start();
f.PostFixture("whiteglove-part2-stuck-v1.jsonl");
Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Failed));
Assert.Equal(SessionOutcome.EnrollmentFailed, f.Orchestrator.CurrentState.Outcome);
f.Stop();
}
// (PR-B 2026-05-04: Scenario 10 "WhiteGlove Part-2 Stuck" removed — the 24h Part-2
// safety deadline that drove this scenario into Failed/EnrollmentFailed was deleted
// along with the rest of the WG-Part-2 apparatus. The new model treats Part-2 as a
// fresh Classic enrollment, so a stuck Part-2 sits on the existing max-lifetime
// watchdog instead of a Part-2-specific 24h deadline; coverage for that path lives
// in the orchestrator's lifetime tests.)
// ================================================================ 11) Hybrid-Reboot
@@ -76,7 +76,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
[InlineData(SessionStage.Completed)]
[InlineData(SessionStage.Failed)]
[InlineData(SessionStage.WhiteGloveSealed)]
[InlineData(SessionStage.WhiteGloveCompletedPart2)]
public void Terminal_stage_transition_fires_callback_with_terminal_state(SessionStage terminal)
{
using var rig = new Rig();
@@ -0,0 +1,177 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
#pragma warning disable xUnit1031
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
/// <summary>
/// PR-A acceptance: <see cref="EnrollmentOrchestrator"/> emits the
/// <c>whiteglove_resumed</c> lifecycle event exactly once when Start() detects a
/// persisted <see cref="SessionStage.WhiteGloveSealed"/> snapshot, and emits nothing
/// on a fresh first boot. The post is positioned AFTER the <c>onIngressReady</c>
/// hook (so caller-side <c>agent_started</c> lands first, V1-symmetric ordering)
/// and BEFORE collector hosts start (so the resumed-marker precedes any collector
/// telemetry).
/// </summary>
[Collection("SerialThreading")]
public sealed class EnrollmentOrchestratorPart2EmissionTests
{
[Fact]
public void Fresh_start_does_not_emit_whiteglove_resumed()
{
using var rig = new EnrollmentOrchestratorRig();
Directory.CreateDirectory(rig.StateDir);
using var sut = rig.Build();
sut.Start();
Assert.False(sut.IsWhiteGlovePart2);
sut.Stop();
// Stop's terminal drain ships any pending items. None of the Event-kind
// items must be a whiteglove_resumed event.
Assert.DoesNotContain(
rig.Uploader.Received.SelectMany(b => b),
item => item.Kind == AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.TelemetryItemKind.Event &&
item.PayloadJson != null &&
item.PayloadJson.Contains("\"whiteglove_resumed\""));
}
[Fact]
public void Whiteglove_sealed_snapshot_emits_whiteglove_resumed_exactly_once()
{
using var rig = new EnrollmentOrchestratorRig();
Directory.CreateDirectory(rig.StateDir);
// Seed Part-1 sealed state.
var sealedState = DecisionState.CreateInitial("S1", "T1")
.ToBuilder()
.WithStage(SessionStage.WhiteGloveSealed)
.WithStepIndex(7)
.Build();
new SnapshotPersistence(Path.Combine(rig.StateDir, "snapshot.json")).Save(sealedState);
rig.Uploader.QueueOk(20);
using var sut = rig.Build();
sut.Start();
Assert.True(sut.IsWhiteGlovePart2);
sut.Stop();
// Exactly one Event-kind item carries whiteglove_resumed across all batches.
// (The corresponding InformationalEvent signal is also persisted to the spool
// for replay, but only the Event-kind item is the wire-level enrollment event.)
var resumedEvents = rig.Uploader.Received
.SelectMany(b => b)
.Where(item =>
item.Kind == AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.TelemetryItemKind.Event &&
item.PayloadJson != null &&
item.PayloadJson.Contains("\"whiteglove_resumed\""))
.ToList();
Assert.Single(resumedEvents);
// Carries previousStage + resumedAtUtc payload markers.
var payload = resumedEvents[0].PayloadJson!;
Assert.Contains("WhiteGloveSealed", payload);
Assert.Contains("resumedAtUtc", payload);
}
[Fact]
public void Whiteglove_resumed_is_emitted_after_onIngressReady_hook_runs()
{
using var rig = new EnrollmentOrchestratorRig();
Directory.CreateDirectory(rig.StateDir);
var sealedState = DecisionState.CreateInitial("S1", "T1")
.ToBuilder()
.WithStage(SessionStage.WhiteGloveSealed)
.WithStepIndex(3)
.Build();
new SnapshotPersistence(Path.Combine(rig.StateDir, "snapshot.json")).Save(sealedState);
using var sut = rig.Build();
// While onIngressReady is running, the SignalLog must NOT yet contain the
// InformationalEvent that carries whiteglove_resumed — Plan §11 V1 ordering:
// agent_started lands first, then whiteglove_resumed.
bool? logHadResumedDuringHook = null;
sut.Start(ingress =>
{
var signalLog = GetSignalLog(sut);
logHadResumedDuringHook = signalLog.ReadAll().Any(s =>
s.Kind == DecisionSignalKind.InformationalEvent &&
s.Payload != null &&
s.Payload.TryGetValue("eventType", out var et) &&
et == "whiteglove_resumed");
});
Assert.True(sut.IsWhiteGlovePart2);
Assert.False(
logHadResumedDuringHook,
"whiteglove_resumed leaked onto the signal log BEFORE onIngressReady returned.");
// Spin until the worker thread has reduced the post into the log.
var signalLog = GetSignalLog(sut);
Assert.True(SpinWait.SpinUntil(
() => signalLog.ReadAll().Any(s =>
s.Kind == DecisionSignalKind.InformationalEvent &&
s.Payload != null &&
s.Payload.TryGetValue("eventType", out var et) &&
et == "whiteglove_resumed"),
3000),
"Expected whiteglove_resumed InformationalEvent on signal log after Start.");
sut.Stop();
}
[Fact]
public void Whiteglove_sealed_snapshot_resumes_through_archive_and_reset_only()
{
// PR-A removed the SessionRecovered post and PR-B removed the enum value +
// handler entirely; the resume path is now archive-and-reset followed by a
// fresh Classic enrollment plus a single whiteglove_resumed timeline event.
// No "session-recovered" lifecycle signal should land in the signal log.
using var rig = new EnrollmentOrchestratorRig();
Directory.CreateDirectory(rig.StateDir);
var sealedState = DecisionState.CreateInitial("S1", "T1")
.ToBuilder()
.WithStage(SessionStage.WhiteGloveSealed)
.WithStepIndex(2)
.Build();
new SnapshotPersistence(Path.Combine(rig.StateDir, "snapshot.json")).Save(sealedState);
using var sut = rig.Build();
sut.Start();
// Give the worker a moment.
Thread.Sleep(100);
var signalLog = GetSignalLog(sut);
// No signal carrying a session-recovered identifier in payload/source.
Assert.DoesNotContain(
signalLog.ReadAll(),
s => s.SourceOrigin != null && s.SourceOrigin.Contains("session_recovered"));
sut.Stop();
}
private static ISignalLogWriter GetSignalLog(EnrollmentOrchestrator sut)
{
var field = typeof(EnrollmentOrchestrator).GetField(
"_signalLog",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
return (ISignalLogWriter)field!.GetValue(sut)!;
}
}
}
@@ -64,38 +64,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
// ================================================================= Sonderfall 1: WG Part-1 Resume
[Fact]
public void Whiteglove_part1_snapshot_triggers_session_recovered_bridge_on_start()
public void Whiteglove_part1_snapshot_triggers_archive_and_reset_on_start()
{
// PR-A V1-symmetric Archive-and-Reset: a persisted WhiteGloveSealed snapshot makes
// Start() move the reducer-state segment files into a `.part1-<ts>/` subfolder, set
// the IsWhiteGlovePart2 hint, and run the recovery pipeline as a fresh first-boot.
// The event-sequence counter is intentionally NOT archived — it stays put so the
// session-wide event sequence remains monotonic across the resume boundary.
using var rig = new Rig();
Directory.CreateDirectory(rig.StateDir);
// Seed a snapshot with stage = WhiteGloveSealed (Part 1 complete pre-reboot).
// Seed a snapshot with stage = WhiteGloveSealed (Part 1 complete pre-reboot) plus
// a non-empty signal-log + journal + event-sequence counter so we can verify which
// files get archived and which stay put.
var sealedState = DecisionState.CreateInitial("S1", "T1")
.ToBuilder()
.WithStage(SessionStage.WhiteGloveSealed)
.WithStepIndex(5)
.Build();
new SnapshotPersistence(Path.Combine(rig.StateDir, "snapshot.json")).Save(sealedState);
File.WriteAllText(
Path.Combine(rig.StateDir, "signal-log.jsonl"),
"{\"part1-leftover\":true}\n",
Encoding.UTF8);
File.WriteAllText(
Path.Combine(rig.StateDir, "journal.jsonl"),
"{\"part1-journal\":true}\n",
Encoding.UTF8);
File.WriteAllText(
Path.Combine(rig.StateDir, "event-sequence.json"),
"{\"LastAssignedSequence\":17}",
Encoding.UTF8);
var sut = rig.Build();
sut.Start();
Assert.True(sut.IsWhiteGlovePart2Resume);
Assert.True(sut.IsWhiteGlovePart2);
Assert.False(sut.WasStartupQuarantine);
// After ingress start, the orchestrator posts SessionRecovered — the reducer bridges
// to WhiteGloveAwaitingUserSignIn. Wait for the signal-log to contain the signal.
var signalLog = GetSignalLog(sut);
Assert.True(SpinWait.SpinUntil(
() => signalLog.ReadAll().Any(s => s.Kind == DecisionSignalKind.SessionRecovered),
3000));
// The recovered state is fresh (StepIndex=0, Stage=SessionStarted) — no WhiteGlove
// stage on the live engine; the legacy bridge into AwaitingUserSignIn is gone.
Assert.Equal(0, sut.CurrentState.StepIndex);
Assert.NotEqual(SessionStage.WhiteGloveSealed, sut.CurrentState.Stage);
// State must have advanced past WhiteGloveSealed to WhiteGloveAwaitingUserSignIn.
Assert.True(SpinWait.SpinUntil(
() => sut.CurrentState.Stage == SessionStage.WhiteGloveAwaitingUserSignIn,
3000));
// Archive bucket exists and holds the reducer-state segments.
var part1Buckets = Directory.GetDirectories(rig.StateDir, ".part1-*");
Assert.Single(part1Buckets);
var bucket = part1Buckets[0];
Assert.True(File.Exists(Path.Combine(bucket, "snapshot.json")));
Assert.True(File.Exists(Path.Combine(bucket, "signal-log.jsonl")));
Assert.True(File.Exists(Path.Combine(bucket, "journal.jsonl")));
Assert.True(File.Exists(Path.Combine(bucket, "reason.txt")));
// event-sequence.json stayed in place with its Part-1 contents — Part-2
// emissions continue counting from there, preventing sequence collisions.
Assert.False(File.Exists(Path.Combine(bucket, "event-sequence.json")));
var preservedCounter = File.ReadAllText(Path.Combine(rig.StateDir, "event-sequence.json"));
Assert.Contains("17", preservedCounter);
// PR-B removed the SessionRecovered enum + handler entirely; nothing in the
// signal log on resume should match the now-deleted lifecycle bridge. Smoke-check:
// the only signal kinds we expect on a fresh start are the SessionStarted /
// EspConfigDetected bootstrap path — never anything carrying "session_recovered".
var signalLog = GetSignalLog(sut);
Thread.Sleep(100);
Assert.DoesNotContain(signalLog.ReadAll(),
s => s.SourceOrigin != null && s.SourceOrigin.Contains("session_recovered"));
sut.Stop();
// After Stop+drain, the uploader must have received a whiteglove_resumed
// Event-kind item — Plan §4 acceptance criterion. (The corresponding
// InformationalEvent signal is also persisted but only the Event item is
// the wire-level enrollment event.)
Assert.True(SpinWait.SpinUntil(
() => rig.Uploader.Received
.SelectMany(b => b)
.Any(item =>
item.Kind == TelemetryItemKind.Event &&
item.PayloadJson != null &&
item.PayloadJson.Contains("\"whiteglove_resumed\"")),
3000));
}
[Fact]
@@ -115,12 +164,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var sut = rig.Build();
sut.Start();
Assert.False(sut.IsWhiteGlovePart2Resume);
Assert.False(sut.IsWhiteGlovePart2);
// No SessionRecovered should have been posted.
Thread.Sleep(100);
var signalLog = GetSignalLog(sut);
Assert.DoesNotContain(signalLog.ReadAll(), s => s.Kind == DecisionSignalKind.SessionRecovered);
// No archive bucket: this is a regular non-WG snapshot, the orchestrator
// recovers in-place rather than archiving.
Assert.Empty(Directory.GetDirectories(rig.StateDir, ".part1-*"));
sut.Stop();
}
@@ -150,7 +198,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
sut.Start();
Assert.True(sut.WasStartupQuarantine);
Assert.False(sut.IsWhiteGlovePart2Resume);
Assert.False(sut.IsWhiteGlovePart2);
// Original snapshot + signal-log were moved aside.
Assert.False(File.Exists(snapshotPath));
@@ -677,17 +725,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
// Future re-arm: the rehydrated deadline is live on the scheduler (IsScheduled=true)
// but no DeadlineFired signal has been emitted because its wall-clock due time
// has not arrived.
// has not arrived. Stage AwaitingHello (a regular Classic-flow stage that doesn't
// trigger PR-A's WhiteGlove archive-and-reset path) so the deadline survives recovery.
using var rig = new Rig();
Directory.CreateDirectory(rig.StateDir);
var future = new ActiveDeadline(
name: "part2_safety",
name: "hello_safety",
dueAtUtc: DateTime.UtcNow.AddHours(2), // real wall-clock future (scheduler uses wall clock)
firesSignalKind: DecisionSignalKind.DeadlineFired);
var seed = DecisionState.CreateInitial("S1", "T1")
.ToBuilder()
.WithStage(SessionStage.WhiteGloveSealed)
.WithStage(SessionStage.AwaitingHello)
.WithStepIndex(5)
.WithLastAppliedSignalOrdinal(4)
.AddDeadline(future)
@@ -701,7 +750,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
// Scheduler must know about the re-armed deadline.
var scheduler = GetScheduler(sut);
Assert.True(scheduler.IsScheduled("part2_safety"),
Assert.True(scheduler.IsScheduled("hello_safety"),
"Future-due persisted deadline was not re-armed on the scheduler.");
// Give the ThreadPool a moment to prove we don't fire it prematurely.
@@ -0,0 +1,190 @@
using System;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
public sealed class StateArchiverTests
{
private static readonly DateTime At = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
private static AgentLogger MakeLogger(TempDirectory tmp) =>
new AgentLogger(tmp.Path, AgentLogLevel.Info);
[Fact]
public void Archive_moves_reducer_state_segments_but_preserves_event_sequence_counter()
{
// event-sequence.json must stay put: Part 2 reuses the same SessionId and the
// backend orders events by (SessionId, Sequence). Resetting the counter at the
// resume boundary would collide Part-1 and Part-2 sequence values and break the
// Inspector + Web split-detection (`splitSequence = resumed.sequence - 1`).
using var tmp = new TempDirectory();
var stateDir = tmp.Path;
File.WriteAllText(Path.Combine(stateDir, "snapshot.json"), "{\"snap\":1}");
File.WriteAllText(Path.Combine(stateDir, "signal-log.jsonl"), "{\"sig\":1}\n");
File.WriteAllText(Path.Combine(stateDir, "journal.jsonl"), "{\"jrn\":1}\n");
File.WriteAllText(Path.Combine(stateDir, "event-sequence.json"), "{\"LastAssignedSequence\":42}");
var bucket = StateArchiver.ArchiveStateFolder(
stateDir, "test-archive", () => At, MakeLogger(tmp));
Assert.NotNull(bucket);
Assert.True(Directory.Exists(bucket!));
Assert.True(File.Exists(Path.Combine(bucket!, "snapshot.json")));
Assert.True(File.Exists(Path.Combine(bucket!, "signal-log.jsonl")));
Assert.True(File.Exists(Path.Combine(bucket!, "journal.jsonl")));
Assert.True(File.Exists(Path.Combine(bucket!, "reason.txt")));
// Reducer-state originals moved out.
Assert.False(File.Exists(Path.Combine(stateDir, "snapshot.json")));
Assert.False(File.Exists(Path.Combine(stateDir, "signal-log.jsonl")));
Assert.False(File.Exists(Path.Combine(stateDir, "journal.jsonl")));
// Event-sequence counter stayed in place with its Part-1 contents.
Assert.True(File.Exists(Path.Combine(stateDir, "event-sequence.json")));
Assert.False(File.Exists(Path.Combine(bucket!, "event-sequence.json")));
var preservedJson = File.ReadAllText(Path.Combine(stateDir, "event-sequence.json"));
Assert.Contains("42", preservedJson);
}
[Fact]
public void Archive_bucket_name_is_iso8601_timestamped()
{
using var tmp = new TempDirectory();
File.WriteAllText(Path.Combine(tmp.Path, "snapshot.json"), "{}");
var bucket = StateArchiver.ArchiveStateFolder(
tmp.Path, "ts-test", () => At, MakeLogger(tmp));
Assert.NotNull(bucket);
var bucketName = Path.GetFileName(bucket!);
// Format: .part1-yyyyMMddTHHmmssfffZ
Assert.StartsWith(".part1-2026", bucketName);
Assert.EndsWith("Z", bucketName);
}
[Fact]
public void Multiple_archives_produce_distinct_buckets()
{
using var tmp = new TempDirectory();
File.WriteAllText(Path.Combine(tmp.Path, "snapshot.json"), "{\"v\":1}");
var nowIndex = 0;
DateTime[] stamps =
{
At,
At.AddSeconds(1),
};
var bucket1 = StateArchiver.ArchiveStateFolder(
tmp.Path, "first", () => stamps[nowIndex++], MakeLogger(tmp));
// Recreate a fresh snapshot.json and archive again at a later stamp.
File.WriteAllText(Path.Combine(tmp.Path, "snapshot.json"), "{\"v\":2}");
var bucket2 = StateArchiver.ArchiveStateFolder(
tmp.Path, "second", () => stamps[nowIndex++], MakeLogger(tmp));
Assert.NotNull(bucket1);
Assert.NotNull(bucket2);
Assert.NotEqual(bucket1, bucket2);
Assert.True(Directory.Exists(bucket1!));
Assert.True(Directory.Exists(bucket2!));
}
[Fact]
public void Archive_with_no_segment_files_returns_null_and_creates_no_bucket()
{
using var tmp = new TempDirectory();
// tmp.Path exists but has no segment files.
var bucket = StateArchiver.ArchiveStateFolder(
tmp.Path, "noop", () => At, MakeLogger(tmp));
Assert.Null(bucket);
Assert.Empty(Directory.GetDirectories(tmp.Path, ".part1-*"));
}
[Fact]
public void Archive_with_missing_directory_returns_null()
{
using var tmp = new TempDirectory();
var missing = Path.Combine(tmp.Path, "does-not-exist");
Assert.False(Directory.Exists(missing));
var bucket = StateArchiver.ArchiveStateFolder(
missing, "missing", () => At, MakeLogger(tmp));
Assert.Null(bucket);
}
[Fact]
public void Archive_with_only_some_segment_files_moves_what_exists()
{
using var tmp = new TempDirectory();
// Only snapshot.json + journal.jsonl present; signal-log missing.
File.WriteAllText(Path.Combine(tmp.Path, "snapshot.json"), "{}");
File.WriteAllText(Path.Combine(tmp.Path, "journal.jsonl"), "{}");
var bucket = StateArchiver.ArchiveStateFolder(
tmp.Path, "partial", () => At, MakeLogger(tmp));
Assert.NotNull(bucket);
Assert.True(File.Exists(Path.Combine(bucket!, "snapshot.json")));
Assert.True(File.Exists(Path.Combine(bucket!, "journal.jsonl")));
Assert.False(File.Exists(Path.Combine(bucket!, "signal-log.jsonl")));
Assert.False(File.Exists(Path.Combine(bucket!, "event-sequence.json")));
Assert.True(File.Exists(Path.Combine(bucket!, "reason.txt")));
}
[Fact]
public void Archive_does_not_create_a_bucket_when_only_event_sequence_counter_is_present()
{
// event-sequence.json is no longer in KnownSegmentFiles, so a state directory
// that only holds the counter (e.g. an aborted bootstrap that failed before the
// first reducer write) must NOT be considered a Part-1 sealing to archive.
using var tmp = new TempDirectory();
File.WriteAllText(Path.Combine(tmp.Path, "event-sequence.json"), "{\"LastAssignedSequence\":7}");
var bucket = StateArchiver.ArchiveStateFolder(
tmp.Path, "counter-only", () => At, MakeLogger(tmp));
Assert.Null(bucket);
Assert.Empty(Directory.GetDirectories(tmp.Path, ".part1-*"));
Assert.True(File.Exists(Path.Combine(tmp.Path, "event-sequence.json")));
}
[Fact]
public void Archive_writes_reason_text_into_bucket()
{
using var tmp = new TempDirectory();
File.WriteAllText(Path.Combine(tmp.Path, "snapshot.json"), "{}");
var bucket = StateArchiver.ArchiveStateFolder(
tmp.Path, "wg_part1_resume_archive", () => At, MakeLogger(tmp));
Assert.NotNull(bucket);
var reason = File.ReadAllText(Path.Combine(bucket!, "reason.txt"));
Assert.Equal("wg_part1_resume_archive", reason);
}
[Fact]
public void Archive_throws_on_null_state_directory()
{
using var tmp = new TempDirectory();
Assert.Throws<ArgumentException>(() =>
StateArchiver.ArchiveStateFolder(null!, "x", () => At, MakeLogger(tmp)));
}
[Fact]
public void Archive_throws_on_null_clock()
{
using var tmp = new TempDirectory();
Assert.Throws<ArgumentNullException>(() =>
StateArchiver.ArchiveStateFolder(tmp.Path, "x", null!, MakeLogger(tmp)));
}
}
}
@@ -0,0 +1,102 @@
using System.Xml;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// Pins the hardened Task Scheduler XML produced by the V2 install mode. The CLI
/// equivalent (<c>schtasks /Create /SC ONSTART /RU SYSTEM /RL HIGHEST</c>) inherits
/// XML schema defaults that queue the BootTrigger run indefinitely on a laptop
/// booting on battery in OOBE / WinPE (Event 325 'Launch request queued',
/// observed 2026-05-04). Disabling these settings silently in a future refactor
/// would re-introduce that failure mode — these tests fail loudly if that happens.
/// </summary>
public sealed class InstallModeTaskXmlTests
{
private const string SampleExePath = @"C:\ProgramData\AutopilotMonitor\Agent\AutopilotMonitor.Agent.V2.exe";
[Fact]
public void Xml_is_well_formed_and_uses_task_scheduler_1_2_namespace()
{
var xml = AutopilotMonitor.Agent.V2.Program.BuildScheduledTaskXml(SampleExePath);
var doc = new XmlDocument();
doc.LoadXml(xml);
Assert.NotNull(doc.DocumentElement);
Assert.Equal("Task", doc.DocumentElement!.LocalName);
Assert.Equal("http://schemas.microsoft.com/windows/2004/02/mit/task", doc.DocumentElement.NamespaceURI);
Assert.Equal("1.2", doc.DocumentElement.GetAttribute("version"));
}
[Fact]
public void Xml_disables_battery_conditions_so_oobe_boot_trigger_does_not_queue()
{
var xml = AutopilotMonitor.Agent.V2.Program.BuildScheduledTaskXml(SampleExePath);
// The two settings whose schema-defaults caused the WinPE 'Launch request queued' regression.
Assert.Contains("<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>", xml);
Assert.Contains("<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>", xml);
}
[Fact]
public void Xml_runs_as_system_with_highest_privileges_and_boot_trigger()
{
var xml = AutopilotMonitor.Agent.V2.Program.BuildScheduledTaskXml(SampleExePath);
// S-1-5-18 == LocalSystem; HighestAvailable matches /RL HIGHEST in the CLI form.
Assert.Contains("<UserId>S-1-5-18</UserId>", xml);
Assert.Contains("<RunLevel>HighestAvailable</RunLevel>", xml);
Assert.Contains("<BootTrigger>", xml);
}
[Fact]
public void Xml_disables_execution_time_limit_so_long_running_agent_is_not_killed_at_72h()
{
var xml = AutopilotMonitor.Agent.V2.Program.BuildScheduledTaskXml(SampleExePath);
// schtasks CLI default is PT72H; agent is meant to live for the entire enrollment session.
Assert.Contains("<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>", xml);
}
[Fact]
public void Xml_keeps_start_when_available_and_allow_start_on_demand()
{
var xml = AutopilotMonitor.Agent.V2.Program.BuildScheduledTaskXml(SampleExePath);
// StartWhenAvailable: covers a missed BootTrigger after a deferred boot.
// AllowStartOnDemand: keeps manual `schtasks /Run` available for support / dev.
Assert.Contains("<StartWhenAvailable>true</StartWhenAvailable>", xml);
Assert.Contains("<AllowStartOnDemand>true</AllowStartOnDemand>", xml);
}
[Fact]
public void Xml_embeds_exec_command_with_xml_special_chars_escaped()
{
// ProgramData is admin-set; defensive against `&` or `<` in custom paths.
const string trickyExe = @"C:\Program Files & Co\Auto<pilot>Monitor\Agent.V2.exe";
var xml = AutopilotMonitor.Agent.V2.Program.BuildScheduledTaskXml(trickyExe);
Assert.DoesNotContain(trickyExe, xml);
Assert.Contains("Program Files &amp; Co", xml);
Assert.Contains("Auto&lt;pilot&gt;Monitor", xml);
// Still parses as well-formed XML after escape.
var doc = new XmlDocument();
doc.LoadXml(xml);
var commandNode = doc.GetElementsByTagName("Command")[0];
Assert.NotNull(commandNode);
Assert.Equal(trickyExe, commandNode!.InnerText);
}
[Fact]
public void Empty_exe_path_is_rejected()
{
Assert.Throws<System.ArgumentException>(
() => AutopilotMonitor.Agent.V2.Program.BuildScheduledTaskXml(string.Empty));
Assert.Throws<System.ArgumentException>(
() => AutopilotMonitor.Agent.V2.Program.BuildScheduledTaskXml(null!));
}
}
}
@@ -188,6 +188,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.False(tripped);
}
[Fact]
public void CheckSessionAgeEmergencyBreak_does_not_trip_after_part2_resume_rebases_the_age_clock()
{
// V1-symmetric Part-2 resume contract: AgentRuntimeHost clears the
// whiteglove.complete marker AND rebases session.created to the Part-2 boot
// moment. Without the rebase, a crash/reboot during Part-2 AccountSetup
// would drop us back here with the original Part-1 timestamp and the
// emergency break would trip immediately. Verifies both halves of the
// contract by simulating: aged Part-1 session → Part-2 boot rebase →
// subsequent restart's emergency-break check.
using var tmp = new TempDirectory();
var logger = NewLogger(tmp.Path);
var persistence = new SessionIdPersistence(tmp.Path);
// Aged Part-1 session that already burned through most of the lifetime budget.
persistence.GetOrCreate();
persistence.SaveSessionCreatedAt(DateTime.UtcNow.AddHours(-47));
// Simulate AgentRuntimeHost's resume cleanup block (the marker no longer
// exists at this point — it has been cleared and the timer has been rebased).
persistence.ClearWhiteGloveComplete(logger);
persistence.SaveSessionCreatedAt(DateTime.UtcNow);
// Next restart's emergency-break check sees a fresh Part-2 timestamp.
var tripped = AutopilotMonitor.Agent.V2.Program.CheckSessionAgeEmergencyBreak(
dataDirectory: tmp.Path,
stateDirectory: Path.Combine(tmp.Path, "State"),
absoluteMaxSessionHours: 48,
selfDestructOnComplete: false,
cleanupServiceFactory: null,
logger: logger,
consoleMode: false);
Assert.False(tripped);
Assert.True(persistence.SessionExists());
}
[Fact]
public void CheckSessionAgeEmergencyBreak_initialises_missing_session_created_on_first_miss()
{
@@ -88,26 +88,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.DoesNotContain("alice@", info.Payload[SignalPayloadKeys.Message]);
}
[Fact]
public void Part2Mode_emits_UserAadSignInComplete_kind()
{
using var f = new Fixture();
using var adapter = new AadJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock, part2Mode: true);
adapter.TriggerFromTest("bob@tailspin.com", "x");
var decisionSignal = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.UserAadSignInComplete);
Assert.Contains("Post-reboot", decisionSignal.Evidence.Summary);
// Part 2 mode also dual-emits the corresponding info event.
var info = f.Ingress.Posted.Single(p =>
p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == SharedEventTypes.UserAadSignInComplete);
Assert.Equal("tailspin.com", info.Payload!["userDomain"]);
}
[Fact]
public void Empty_thumbprint_is_reflected_in_payload()
{
@@ -74,27 +74,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.Equal("DesktopArrivalDetector", info.Payload![SignalPayloadKeys.Source]);
// Fix 1 policy: desktop_arrived is a completion-gate event → flush immediately.
Assert.Equal("true", info.Payload[SignalPayloadKeys.ImmediateUpload]);
Assert.Equal("false", info.Payload["part2Mode"]);
Assert.Equal("explorer.exe process poll", info.Payload["detectionSource"]);
Assert.True(info.Payload.ContainsKey("detectedAt"));
}
[Fact]
public void Part2Mode_emits_DesktopArrivedPart2_kind_and_part2_flagged_info_event()
{
using var f = new Fixture();
using var adapter = new DesktopArrivalDetectorAdapter(f.Detector, f.Ingress, f.Clock, part2Mode: true);
adapter.TriggerFromTest();
var decision = f.DecisionSignal(DecisionSignalKind.DesktopArrivedPart2);
Assert.Contains("Part 2", decision.Evidence.Summary);
var info = f.InfoEvent(SharedEventTypes.DesktopArrived);
Assert.Equal("true", info.Payload!["part2Mode"]);
Assert.Contains("Part 2", info.Payload[SignalPayloadKeys.Message]);
}
[Fact]
public void Duplicate_trigger_is_deduplicated_on_both_rails()
{
@@ -103,11 +103,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
{
f.Coordinator.ForceMarkHelloCompleted("timeout");
using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock, part2Mode: true);
using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock);
adapter.TriggerHelloFromCoordinatorPropertyForTest();
var posted = Assert.Single(f.Ingress.Posted);
Assert.Equal(DecisionSignalKind.HelloResolvedPart2, posted.Kind);
Assert.Equal(DecisionSignalKind.HelloResolved, posted.Kind);
Assert.Equal("timeout", posted.Payload![SignalPayloadKeys.HelloOutcome]);
}
finally
@@ -57,18 +57,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.Equal("unknown", posted.Payload![SignalPayloadKeys.HelloOutcome]);
}
[Fact]
public void Part2Mode_Hello_emits_HelloResolvedPart2()
{
using var f = new Fixture();
using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock, part2Mode: true);
adapter.TriggerHelloFromTest("completed");
Assert.Equal(DecisionSignalKind.HelloResolvedPart2, f.Ingress.Posted[0].Kind);
Assert.Equal("completed", f.Ingress.Posted[0].Payload![SignalPayloadKeys.HelloOutcome]);
}
[Fact]
public void FinalizingEvent_emits_EspPhaseChanged_with_FinalizingSetup_payload()
{
@@ -60,18 +60,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.Contains("completed", posted.Evidence.Summary);
}
[Fact]
public void Part2Mode_emits_HelloResolvedPart2_kind()
{
using var f = new Fixture();
using var adapter = new HelloTrackerAdapter(f.Tracker, f.Ingress, f.Clock, part2Mode: true);
adapter.TriggerFromTest("completed");
var posted = f.Ingress.Posted.Single();
Assert.Equal(DecisionSignalKind.HelloResolvedPart2, posted.Kind);
}
[Theory]
[InlineData("completed")]
[InlineData("skipped")]
@@ -57,7 +57,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
new object[] { SharedConstants.EventTypes.DesktopArrived },
new object[] { SharedConstants.EventTypes.AadPlaceholderUserDetected },
new object[] { SharedConstants.EventTypes.AadUserJoinedObserved },
new object[] { SharedConstants.EventTypes.UserAadSignInComplete },
new object[] { SharedConstants.EventTypes.HybridLoginPending },
new object[] { SharedConstants.EventTypes.AgentShuttingDown },
new object[] { SharedConstants.EventTypes.SystemRebootDetected },
@@ -428,7 +428,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
// WhiteGlove Part 1 must NOT launch the summary dialog or emit
// enrollment_summary_shown — the device is about to be reseal-rebooted, the user
// session does not exist yet, and apps have not installed. The other terminal
// stages (Completed / Failed / WhiteGloveCompletedPart2) still launch the dialog.
// stages (Completed / Failed) still launch the dialog. Part 2 reaches the same
// Completed stage as a fresh Classic enrollment after Archive-and-Reset (PR-A).
[Fact]
public void Handle_skips_summary_dialog_on_whiteglove_part1_even_with_dialog_enabled()
@@ -448,14 +449,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
}
[Fact]
public void Handle_emits_enrollment_summary_shown_on_whiteglove_part2_completion()
public void Handle_emits_enrollment_summary_shown_on_completed_termination_with_part2_hint()
{
// PR-B: post WG-Part-2 cleanup, a Part-2 resume terminates with Stage=Completed
// (Classic flow). The orchestrator's IsWhiteGlovePart2 hint distinguishes a
// Part-2 run from a fresh first-boot Classic enrollment for the analyzer pipeline.
// From the dialog's perspective both Part-2 and a regular Completed look identical
// — final-status.json is written and enrollment_summary_shown is emitted.
using var rig = new Rig();
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveCompletedPart2 }.Build();
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
var cfg = rig.BuildConfig(showDialog: true, selfDestruct: false);
rig.Build(cfg).Handle(sender: null!,
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveCompletedPart2));
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
Assert.Contains("enrollment_summary_shown", rig.EmittedEventTypes);
Assert.True(File.Exists(Path.Combine(rig.StateDir, SummaryDialogLauncher.FinalStatusFileName)));
@@ -41,7 +41,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
[InlineData(SessionStage.Completed, EnrollmentTerminationOutcome.Succeeded, "succeeded")]
[InlineData(SessionStage.Failed, EnrollmentTerminationOutcome.Failed, "failed")]
[InlineData(SessionStage.WhiteGloveSealed, EnrollmentTerminationOutcome.Succeeded, "whiteglove_part1")]
[InlineData(SessionStage.WhiteGloveCompletedPart2, EnrollmentTerminationOutcome.Succeeded, "whiteglove_part2")]
public void Build_maps_outcome_from_terminal_stage(SessionStage stage, EnrollmentTerminationOutcome outcome, string expected)
{
var state = StateWith(stage);
@@ -280,7 +279,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
[InlineData(SessionStage.Completed, EnrollmentTerminationOutcome.Succeeded, "succeeded")]
[InlineData(SessionStage.Failed, EnrollmentTerminationOutcome.Failed, "failed")]
[InlineData(SessionStage.WhiteGloveSealed, EnrollmentTerminationOutcome.Succeeded, "whiteglove_part1")]
[InlineData(SessionStage.WhiteGloveCompletedPart2, EnrollmentTerminationOutcome.Succeeded, "whiteglove_part2")]
public void Build_outcome_carries_v2_granularity(SessionStage stage, EnrollmentTerminationOutcome outcome, string expected)
{
// Schema 2 keeps the V2-enum granularity in the wire format. The dialog reads
@@ -134,7 +134,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private string? _quarantineReason;
// Recovery flags (populated during Start()).
private bool _isWhiteGlovePart2Resume;
// V1-symmetric Part-2 hint (analog to V1 MonitoringService._isWhiteGlovePart2):
// set when Start() detects a persisted WhiteGloveSealed snapshot and archives the
// state folder before fresh-starting the recovery pipeline. Read by
// AgentRuntimeHost (for "skip facts/SessionStarted on resume" semantics) and
// shutdown analyzers (V1: runShutdownAnalyzers(part: 2)).
private bool _isWhiteGlovePart2;
private bool _wasStartupQuarantine;
public EnrollmentOrchestrator(
@@ -224,11 +229,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
public string? QuarantineReason => _quarantineReason;
/// <summary>
/// <c>true</c> wenn der Start mit einem persistierten <c>WhiteGloveSealed</c>-State
/// gelaufen ist und die Part-2-Bridge via <c>SessionRecovered</c>-Signal gezündet wurde.
/// Plan §2.7 Sonderfall 1.
/// <c>true</c> when Start() detected a persisted <see cref="SessionStage.WhiteGloveSealed"/>
/// snapshot, archived the prior state folder to <c>state/.part1-&lt;ts&gt;/</c>, and
/// began a fresh Classic enrollment flow. V1-symmetric to
/// <c>MonitoringService._isWhiteGlovePart2</c>. Plan §2.7 Sonderfall 1.
/// </summary>
public bool IsWhiteGlovePart2Resume => _isWhiteGlovePart2Resume;
public bool IsWhiteGlovePart2 => _isWhiteGlovePart2;
/// <summary>
/// <c>true</c> wenn der Start auf einen korrupten State-Segment traf und
@@ -305,11 +311,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
EnsureDirectories();
// 1) Persistenz. Writer scannen bestehende Files im Ctor (LastOrdinal / LastStepIndex).
var snapshotPath = Path.Combine(_stateDirectory, "snapshot.json");
var signalLogPath = Path.Combine(_stateDirectory, "signal-log.jsonl");
var journalPath = Path.Combine(_stateDirectory, "journal.jsonl");
var eventSequencePath = Path.Combine(_stateDirectory, "event-sequence.json");
// 0) WhiteGlove Part-2 resume detection. V1-symmetric Archive-and-Reset
// pattern: peek the persisted snapshot via the lock-free static reader; if
// the prior run sealed Part-1, move the reducer-state segment files
// (snapshot, signal-log, journal) aside to a timestamped
// <c>.part1-&lt;ts&gt;/</c> bucket BEFORE the writers below open them for
// scanning. <c>event-sequence.json</c> is intentionally preserved so the
// session-wide event sequence stays monotonic across the resume boundary
// (the backend orders events by <c>(SessionId, Sequence)</c> and the
// Web UI splits on <c>resumed.sequence - 1</c>). The orchestrator then
// runs through the normal recovery branches as a fresh first-boot
// (branch c1), re-emits a <c>whiteglove_resumed</c> lifecycle event
// after the onIngressReady hook, and lets the standard Classic
// enrollment flow drive completion.
// Anti-loop: once archived, the WhiteGloveSealed snapshot is gone, so a
// subsequent restart sees no marker and continues as a Classic session.
// The hint bool is in-memory only — also matches V1 (which clears the
// on-disk marker file before emitting <c>whiteglove_resumed</c>).
if (Directory.Exists(_stateDirectory))
{
var rawSnapshot = SnapshotPersistence.TryReadRaw(snapshotPath);
if (rawSnapshot != null && rawSnapshot.Stage == SessionStage.WhiteGloveSealed)
{
StateArchiver.ArchiveStateFolder(
stateDirectory: _stateDirectory,
reason: "wg_part1_resume_archive",
utcNow: () => _clock.UtcNow,
logger: _logger);
_isWhiteGlovePart2 = true;
_logger.Info(
"EnrollmentOrchestrator: WhiteGlove Part-1 resume detected — state archived; " +
"starting fresh Classic enrollment flow for Part-2.");
}
}
// 1) Persistenz. Writer scannen bestehende Files im Ctor (LastOrdinal / LastStepIndex).
_signalLog = new SignalLogWriter(signalLogPath);
_journal = new JournalWriter(journalPath, () => _clock.UtcNow);
_snapshot = new SnapshotPersistence(snapshotPath, () => _clock.UtcNow);
@@ -455,18 +496,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
$"signalsReplayed={signalsToReplay.Count}, " +
$"journal.LastStepIndex={_journal.LastStepIndex}.");
// Sonderfall 1 Detection: WG Part 1 -> Reboot -> Part 2 Resume.
// Self-destruct bleibt AUS (Caller ist zuständig — die Info wird via
// IsWhiteGlovePart2Resume exponiert); Session-Recovered-Signal wird gepostet
// nachdem Ingress gestartet ist.
if (initialState.Stage == SessionStage.WhiteGloveSealed)
{
_isWhiteGlovePart2Resume = true;
_logger.Info(
"EnrollmentOrchestrator: White-Glove Part-1 resume detected — SessionRecovered signal " +
"will be posted after ingress start; self-destruct handling is caller-owned.");
}
// 3) Telemetry-Transport. Batch size flows from AgentConfiguration.MaxBatchSize
// via Program.cs (P1 fix: previously the remote-config knob was merged but
// never applied, so tenants saw drainInterval/MaxBatchSize have no effect).
@@ -662,34 +691,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
// 13a) Recovery Sonderfall 1: WG Part-1 → Part-2 bridge zünden. Das Signal muss
// nach _ingress.Start laufen, aber VOR Collectors/Adapters — so sieht der
// Reducer-Worker zuerst die Bridge-Transition (WhiteGloveSealed → AwaitingUserSignIn)
// und erst danach kommende Real-Signals.
if (_isWhiteGlovePart2Resume)
{
try
{
_ingress.Post(
kind: DecisionSignalKind.SessionRecovered,
occurredAtUtc: _clock.UtcNow,
sourceOrigin: "EnrollmentOrchestrator",
evidence: new Evidence(
kind: EvidenceKind.Synthetic,
identifier: "wg_part1_resume",
summary: "White-Glove Part-1 state recovered from snapshot; Part-2 bridge triggered."),
payload: new Dictionary<string, string>(StringComparer.Ordinal)
{
["from"] = SessionStage.WhiteGloveSealed.ToString(),
["to"] = SessionStage.WhiteGloveAwaitingUserSignIn.ToString(),
});
}
catch (Exception ex)
{
_logger.Error("EnrollmentOrchestrator: failed to post SessionRecovered for WG Part-2 resume.", ex);
}
}
// 13b) Caller-owned pre-collector hook. Single-rail refactor uses this slot to post
// agent-lifecycle signals (agent_started, agent_version_check, …) so they land
// on the signal log before any collector-generated signal — fixes the seq=13
@@ -707,6 +708,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
// 13b-WG) WhiteGlove Part-2 resume marker event. V1-symmetric to
// MonitoringService.cs:518 — after Part-1 was sealed and the state
// archived in step 0, announce the resume on the session timeline so
// the backend can flip the session row from Pending → InProgress and
// downstream UI splitters can render a "User Enrollment Part 2"
// block. Posted AFTER the onIngressReady hook so agent_started lands
// first (matches V1 ordering).
if (_isWhiteGlovePart2)
{
try
{
var lifecyclePost = new InformationalEventPost(_ingress, _clock);
var resumedAtUtc = _clock.UtcNow;
lifecyclePost.Emit(
eventType: "whiteglove_resumed",
source: "Agent",
message: "WhiteGlove Part 2 resumed after reseal-reboot; Part-1 state archived.",
severity: Shared.Models.EventSeverity.Info,
immediateUpload: true,
data: new Dictionary<string, string>(StringComparer.Ordinal)
{
["previousStage"] = SessionStage.WhiteGloveSealed.ToString(),
["resumedAtUtc"] = resumedAtUtc.ToString(
"o", System.Globalization.CultureInfo.InvariantCulture),
},
occurredAtUtc: resumedAtUtc,
sourceOrigin: "EnrollmentOrchestrator",
evidenceSummary:
"WhiteGlove Part-1 state archived; resuming as fresh Classic enrollment for Part-2.");
}
catch (Exception ex)
{
_logger.Error("EnrollmentOrchestrator: failed to post whiteglove_resumed for Part-2 resume.", ex);
}
}
// 13c) Plan §6 Fix 9 bootstrap — probe the FirstSync SkipUser/SkipDevice flags
// synchronously and post EspConfigDetected BEFORE any collector host starts.
// Rationale: DeviceInfoHost.CollectAll runs fire-and-forget on the ThreadPool,
@@ -896,7 +933,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
var outcome = terminalState.Stage switch
{
SessionStage.Completed or SessionStage.WhiteGloveCompletedPart2 or SessionStage.WhiteGloveSealed
SessionStage.Completed or SessionStage.WhiteGloveSealed
=> EnrollmentTerminationOutcome.Succeeded,
SessionStage.Failed => EnrollmentTerminationOutcome.Failed,
_ => EnrollmentTerminationOutcome.TimedOut,
@@ -0,0 +1,112 @@
#nullable enable
using System;
using System.IO;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.DecisionCore.Engine;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Archives the reducer-state segment files (snapshot.json, signal-log.jsonl,
/// journal.jsonl) into a timestamped <c>.part1-&lt;ts&gt;/</c> subfolder so the
/// WhiteGlove Part-2 resume path can start from a fresh decision-engine state
/// while preserving the Part-1 segments for diagnostics.
/// <para>
/// <b>Not archived:</b> <c>event-sequence.json</c>. Part 2 reuses the same SessionId
/// as Part 1, and the backend stores events under an <c>(SessionId, Sequence)</c>
/// ordering that the Inspector + the Web WhiteGlove split-detection
/// (<c>useSessionDerivedData.ts</c>: <c>splitSequence = resumed.sequence - 1</c>)
/// treat as session-wide. Resetting the counter at Part 2 would collide Part-1 and
/// Part-2 events under identical sequence values and break the split. The counter
/// stays in place so it continues monotonically across the resume boundary.
/// </para>
/// <para>
/// Used exclusively by <see cref="EnrollmentOrchestrator"/> when it detects a
/// persisted snapshot in <c>SessionStage.WhiteGloveSealed</c>. The archive-and-reset
/// pattern is V1-symmetric to <c>SessionPersistence.ClearWhiteGloveComplete</c>:
/// V1 simply deletes the marker file, V2 moves the reducer state aside (since V2
/// owns a persistent decision-engine state that V1 did not have).
/// </para>
/// <para>
/// Failure mode: if the move throws (FS permission denied, file locked by an
/// external scanner, target directory exists), the exception propagates so the
/// orchestrator's Start() fails fast rather than continuing with a half-archived
/// mixed-state directory. The caller is responsible for surfacing the failure.
/// </para>
/// </summary>
public static class StateArchiver
{
/// <summary>
/// Reducer-state segment files moved into the <c>.part1-&lt;ts&gt;/</c> bucket on
/// archive. <c>event-sequence.json</c> is intentionally NOT in this list — see the
/// class summary.
/// </summary>
private static readonly string[] KnownSegmentFiles = new[]
{
"snapshot.json",
"signal-log.jsonl",
"journal.jsonl",
};
/// <summary>
/// Move all existing state-segment files in <paramref name="stateDirectory"/>
/// to a timestamped <c>.part1-&lt;utc-timestamp&gt;/</c> subfolder. No-op when
/// the state directory does not exist; no-op (without creating the bucket) when
/// none of the known segment files exist.
/// </summary>
/// <param name="stateDirectory">The state directory holding the four segment files.</param>
/// <param name="reason">Free-text reason recorded as <c>reason.txt</c> alongside the archived files.</param>
/// <param name="utcNow">Clock for the timestamped bucket name; injected for deterministic tests.</param>
/// <param name="logger">Optional logger for INFO-level breadcrumb after a successful archive.</param>
/// <returns>The full path of the created bucket, or <c>null</c> if there was nothing to archive.</returns>
public static string? ArchiveStateFolder(
string stateDirectory,
string reason,
Func<DateTime> utcNow,
AgentLogger? logger = null)
{
if (string.IsNullOrEmpty(stateDirectory))
throw new ArgumentException("stateDirectory is mandatory.", nameof(stateDirectory));
if (utcNow == null) throw new ArgumentNullException(nameof(utcNow));
if (!Directory.Exists(stateDirectory)) return null;
// Decide whether anything would actually move before allocating a bucket — avoids
// creating an empty .part1-<ts> directory on a fresh first-boot state folder.
var hasAny = false;
foreach (var name in KnownSegmentFiles)
{
if (File.Exists(Path.Combine(stateDirectory, name)))
{
hasAny = true;
break;
}
}
if (!hasAny) return null;
var stamp = utcNow().ToString("yyyyMMdd'T'HHmmssfff'Z'");
var bucket = Path.Combine(stateDirectory, ".part1-" + stamp);
Directory.CreateDirectory(bucket);
foreach (var name in KnownSegmentFiles)
{
var src = Path.Combine(stateDirectory, name);
if (File.Exists(src))
{
// Move semantics: throws on collision. Two archives within the same
// millisecond are vanishingly unlikely (the orchestrator only archives
// once per Start), and a collision is a real bug worth surfacing.
File.Move(src, Path.Combine(bucket, name));
}
}
File.WriteAllText(Path.Combine(bucket, "reason.txt"), reason ?? string.Empty, Encoding.UTF8);
logger?.Info(
$"StateArchiver: archived state segments to '{Path.GetFileName(bucket)}' (reason={reason}).");
return bucket;
}
}
}
@@ -14,8 +14,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
/// Adapter for <see cref="AadJoinWatcher"/>:
/// <list type="bullet">
/// <item><description><see cref="AadJoinWatcher.AadUserJoined"/> →
/// <see cref="DecisionSignalKind.AadUserJoinedLate"/> (Part 1) or
/// <see cref="DecisionSignalKind.UserAadSignInComplete"/> (Part 2). Plan §2.1a / §2.2.</description></item>
/// <see cref="DecisionSignalKind.AadUserJoinedLate"/>. Plan §2.1a / §2.2.</description></item>
/// <item><description><see cref="AadJoinWatcher.PlaceholderUserDetected"/> → emits a
/// <c>aad_placeholder_user_detected</c> informational event for backend timeline
/// visibility (Hybrid User-Driven completion-gap fix, 2026-05-01). NOT a decision signal —
@@ -32,7 +31,6 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly IClock _clock;
private readonly InformationalEventPost _post;
private readonly Action? _onRealUserJoined;
private readonly bool _part2Mode;
private bool _fired;
private bool _placeholderFired;
@@ -40,14 +38,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
AadJoinWatcher watcher,
ISignalIngressSink ingress,
IClock clock,
bool part2Mode = false,
Action? onRealUserJoined = null)
{
_watcher = watcher ?? throw new ArgumentNullException(nameof(watcher));
_ingress = ingress ?? throw new ArgumentNullException(nameof(ingress));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_post = new InformationalEventPost(ingress, clock);
_part2Mode = part2Mode;
_onRealUserJoined = onRealUserJoined;
_watcher.AadUserJoined += OnAadUserJoined;
@@ -74,8 +70,6 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
if (_fired) return;
_fired = true;
var kind = _part2Mode ? DecisionSignalKind.UserAadSignInComplete : DecisionSignalKind.AadUserJoinedLate;
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
{
// Don't log the full email in decision signals — user PII. Keep domain only
@@ -92,15 +86,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
};
_ingress.Post(
kind: kind,
kind: DecisionSignalKind.AadUserJoinedLate,
occurredAtUtc: _clock.UtcNow,
sourceOrigin: SourceLabel,
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: "aad-join-watcher-v1",
summary: _part2Mode
? "Post-reboot AAD user sign-in detected (JoinInfo registry key)"
: "AAD user join observed (JoinInfo registry key)",
summary: "AAD user join observed (JoinInfo registry key)",
derivationInputs: new Dictionary<string, string>(StringComparer.Ordinal)
{
["registryKey"] = @"HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo",
@@ -108,21 +100,15 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
payload: payload);
// Codex review 2026-05-01 — also dual-emit as informational event.
// HandleAadUserJoinedLateV1 / Part 2 handler in DecisionEngine.Classic is
// observation-only (no timeline effect), so without this dual-emission the
// backend Events table never sees the real-user join. FailureSnapshotBuilder
// (and any other event-stream consumer) needs this anchor to correctly classify
// aadJoinState=real_user. Same PII-safe payload shape as the decision signal.
var infoEventType = _part2Mode
? SharedConstants.EventTypes.UserAadSignInComplete
: SharedConstants.EventTypes.AadUserJoinedObserved;
var infoMessage = _part2Mode
? $"Post-reboot AAD user sign-in detected (domain={payload["userDomain"]})"
: $"AAD user join observed (domain={payload["userDomain"]})";
// HandleAadUserJoinedLateV1 in DecisionEngine.Classic is observation-only (no
// timeline effect), so without this dual-emission the backend Events table never
// sees the real-user join. FailureSnapshotBuilder (and any other event-stream
// consumer) needs this anchor to correctly classify aadJoinState=real_user. Same
// PII-safe payload shape as the decision signal.
_post.Emit(
eventType: infoEventType,
eventType: SharedConstants.EventTypes.AadUserJoinedObserved,
source: SourceLabel,
message: infoMessage,
message: $"AAD user join observed (domain={payload["userDomain"]})",
severity: EventSeverity.Info,
immediateUpload: true,
data: payload,
@@ -10,8 +10,7 @@ using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
/// <summary>
/// Adapter for <see cref="DesktopArrivalDetector"/> → <see cref="DecisionSignalKind.DesktopArrived"/>
/// (or <see cref="DecisionSignalKind.DesktopArrivedPart2"/> in post-reboot WhiteGlove-Part-2 mode).
/// Adapter for <see cref="DesktopArrivalDetector"/> → <see cref="DecisionSignalKind.DesktopArrived"/>.
/// Plan §2.1a / §2.2.
/// <para>
/// Fire-once by design (the detector itself guards against duplicate fires; the adapter
@@ -33,20 +32,17 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly ISignalIngressSink _ingress;
private readonly IClock _clock;
private readonly InformationalEventPost _post;
private readonly bool _part2Mode;
private bool _fired;
public DesktopArrivalDetectorAdapter(
DesktopArrivalDetector detector,
ISignalIngressSink ingress,
IClock clock,
bool part2Mode = false)
IClock clock)
{
_detector = detector ?? throw new ArgumentNullException(nameof(detector));
_ingress = ingress ?? throw new ArgumentNullException(nameof(ingress));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_post = new InformationalEventPost(ingress, clock);
_part2Mode = part2Mode;
_detector.DesktopArrived += OnDesktopArrived;
}
@@ -67,13 +63,10 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_fired = true;
var now = _clock.UtcNow;
var kind = _part2Mode ? DecisionSignalKind.DesktopArrivedPart2 : DecisionSignalKind.DesktopArrived;
var summary = _part2Mode
? "Desktop arrival observed (Part 2 — post-reboot user sign-in)"
: "Desktop arrival observed (explorer.exe under real user)";
const string summary = "Desktop arrival observed (explorer.exe under real user)";
_ingress.Post(
kind: kind,
kind: DecisionSignalKind.DesktopArrived,
occurredAtUtc: now,
sourceOrigin: SourceLabel,
evidence: new Evidence(
@@ -83,7 +76,6 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
derivationInputs: new Dictionary<string, string>(StringComparer.Ordinal)
{
["detectionSource"] = "explorer.exe process poll",
["part2Mode"] = _part2Mode ? "true" : "false",
}));
// Parity with V1: also emit a `desktop_arrived` InformationalEvent so the Events
@@ -93,7 +85,6 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
["detectedAt"] = now.ToString("o"),
["detectionSource"] = "explorer.exe process poll",
["part2Mode"] = _part2Mode ? "true" : "false",
};
_post.Emit(
@@ -21,7 +21,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
/// <para>
/// Signal-Mapping identisch zu den Sub-Tracker-Adaptern (Single-Tracker-Wiring-Szenarien):
/// <list type="bullet">
/// <item><c>HelloCompleted</c> → <see cref="DecisionSignalKind.HelloResolved"/> / <see cref="DecisionSignalKind.HelloResolvedPart2"/></item>
/// <item><c>HelloCompleted</c> → <see cref="DecisionSignalKind.HelloResolved"/></item>
/// <item><c>FinalizingSetupPhaseTriggered</c> → <see cref="DecisionSignalKind.EspPhaseChanged"/> (phase=FinalizingSetup)</item>
/// <item><c>WhiteGloveCompleted</c> → <see cref="DecisionSignalKind.WhiteGloveShellCoreSuccess"/></item>
/// <item><c>EspFailureDetected</c> → <see cref="DecisionSignalKind.EspTerminalFailure"/> (merged aus ShellCore + Provisioning)</item>
@@ -43,7 +43,6 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly EspAndHelloTracker _coordinator;
private readonly ISignalIngressSink _ingress;
private readonly IClock _clock;
private readonly bool _part2Mode;
private bool _helloPosted;
private bool _finalizingPosted;
@@ -56,13 +55,11 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
public EspAndHelloTrackerAdapter(
EspAndHelloTracker coordinator,
ISignalIngressSink ingress,
IClock clock,
bool part2Mode = false)
IClock clock)
{
_coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator));
_ingress = ingress ?? throw new ArgumentNullException(nameof(ingress));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_part2Mode = part2Mode;
_coordinator.HelloCompleted += OnHelloCompleted;
_coordinator.FinalizingSetupPhaseTriggered += OnFinalizing;
@@ -114,10 +111,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_helloPosted = true;
var outcome = string.IsNullOrEmpty(helloOutcome) ? "unknown" : helloOutcome!;
var kind = _part2Mode ? DecisionSignalKind.HelloResolvedPart2 : DecisionSignalKind.HelloResolved;
_ingress.Post(
kind: kind,
kind: DecisionSignalKind.HelloResolved,
occurredAtUtc: _clock.UtcNow,
sourceOrigin: SourceOrigin,
evidence: new Evidence(
@@ -9,8 +9,8 @@ using AutopilotMonitor.DecisionCore.Signals;
namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
/// <summary>
/// Adapter for <see cref="HelloTracker"/> → <see cref="DecisionSignalKind.HelloResolved"/>
/// (Part 1) or <see cref="DecisionSignalKind.HelloResolvedPart2"/> (Part 2). Plan §2.1a / §2.2.
/// Adapter for <see cref="HelloTracker"/> → <see cref="DecisionSignalKind.HelloResolved"/>.
/// Plan §2.1a / §2.2.
/// <para>
/// Fires genau einmal, wenn der Tracker Hello-Abschluss feststellt. Payload enthält den
/// <c>HelloOutcome</c>-String (<c>completed | skipped | timeout | not_configured | wizard_not_started</c>),
@@ -22,19 +22,16 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly HelloTracker _tracker;
private readonly ISignalIngressSink _ingress;
private readonly IClock _clock;
private readonly bool _part2Mode;
private bool _fired;
public HelloTrackerAdapter(
HelloTracker tracker,
ISignalIngressSink ingress,
IClock clock,
bool part2Mode = false)
IClock clock)
{
_tracker = tracker ?? throw new ArgumentNullException(nameof(tracker));
_ingress = ingress ?? throw new ArgumentNullException(nameof(ingress));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_part2Mode = part2Mode;
_tracker.HelloCompleted += OnHelloCompleted;
}
@@ -55,9 +52,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
var outcome = string.IsNullOrEmpty(helloOutcome) ? "unknown" : helloOutcome!;
var kind = _part2Mode ? DecisionSignalKind.HelloResolvedPart2 : DecisionSignalKind.HelloResolved;
_ingress.Post(
kind: kind,
kind: DecisionSignalKind.HelloResolved,
occurredAtUtc: _clock.UtcNow,
sourceOrigin: "HelloTracker",
evidence: new Evidence(
@@ -106,6 +106,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
// is only written by the AppDomain.ProcessExit handler, which Windows can pre-empt.
// Null = no early write (tests / parity with the original blind-exit path).
private readonly Action _writeCleanExitMarker;
// V1-symmetric Part-2 hint accessor (plan §11). A Part-2 resume runs as a fresh
// Classic enrollment after Archive-and-Reset, so the terminal Stage is
// <c>Completed</c>/<c>Failed</c> like any first-run completion. The shutdown
// analyzer pipeline still needs to know it was a Part-2 run so SoftwareInventoryAnalyzer
// can tag findings with phase=2 and the backend's vulnerability-correlation pipeline
// can filter Part-2 inventory out of the Part-1 set. Wired by AgentRuntimeHost as
// <c>() =&gt; orchestrator.IsWhiteGlovePart2</c>; null in tests = legacy (always
// treats as Part-1/null).
private readonly Func<bool> _isWhiteGlovePart2Accessor;
private int _handled;
@@ -132,7 +141,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
Func<int> ignoredCountAccessor = null,
Func<int> pendingItemCountAccessor = null,
Action writeCleanExitMarker = null,
Func<long> ingressPendingSignalCountAccessor = null)
Func<long> ingressPendingSignalCountAccessor = null,
Func<bool> isWhiteGlovePart2Accessor = null)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -161,6 +171,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
_pendingItemCountAccessor = pendingItemCountAccessor;
_writeCleanExitMarker = writeCleanExitMarker;
_ingressPendingSignalCountAccessor = ingressPendingSignalCountAccessor;
_isWhiteGlovePart2Accessor = isWhiteGlovePart2Accessor;
}
/// <summary>
@@ -197,7 +208,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
// reseal-rebooted) or fails outright; either way the enrollment_summary_shown
// event is semantically wrong and pollutes the timeline. Skip the dialog
// build + launch + final-status.json write entirely on Part 1; the regular
// Completed / Failed / WhiteGloveCompletedPart2 paths still run unchanged.
// Completed / Failed paths still run unchanged.
if (!isWhiteGlovePart1)
{
if (state == null)
@@ -323,10 +334,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
// SoftwareInventoryAnalyzer's per-phase idempotency guards make a second
// Part-1 call (when WhiteGloveInventoryTrigger fired earlier) a no-op for the
// inventory analyzer while still running LocalAdmin / IntegrityBypass.
//
// Part-2 detection comes from the orchestrator's
// <see cref="EnrollmentOrchestrator.IsWhiteGlovePart2"/> hint, not from a
// dedicated terminal stage. After Archive-and-Reset the V2 reducer drives
// Part 2 as a fresh Classic enrollment that terminates on <c>Completed</c>
// /<c>Failed</c>; the orchestrator preserves the Part-2 origin via the
// in-memory <c>_isWhiteGlovePart2</c> flag wired here. V1-symmetric:
// <c>runShutdownAnalyzers(_isWhiteGlovePart2 ? 2 : null)</c>.
int? wgPart;
if (args.StageName == SessionStage.WhiteGloveSealed.ToString())
wgPart = 1;
else if (args.StageName == SessionStage.WhiteGloveCompletedPart2.ToString())
else if (_isWhiteGlovePart2Accessor != null && _isWhiteGlovePart2Accessor())
wgPart = 2;
else
wgPart = null;
@@ -59,7 +59,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
/// <summary>
/// Schema 2 — derives a human-readable explanation rendered as the dialog's failure
/// banner. Returns <c>null</c> for successful terminal outcomes (succeeded /
/// whiteglove_part1 / whiteglove_part2) so the dialog skips the banner.
/// whiteglove_part1) so the dialog skips the banner. Part-2 reaches succeeded
/// through the Classic flow after Archive-and-Reset (PR-A).
/// <para>
/// Source priority:
/// </para>
@@ -111,10 +112,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
Add("helloResolved", state.HelloResolvedUtc);
Add("desktopArrived", state.DesktopArrivedUtc);
Add("systemReboot", state.SystemRebootUtc);
Add("part2UserAadSignIn", state.UserAadSignInCompleteUtc);
Add("part2HelloResolved", state.HelloResolvedPart2Utc);
Add("part2DesktopArrived", state.DesktopArrivedPart2Utc);
Add("part2AccountSetupCompleted", state.AccountSetupCompletedPart2Utc);
return ts.Count == 0 ? null : ts;
}
@@ -122,7 +119,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
private static string MapOutcome(EnrollmentTerminationOutcome outcome, SessionStage stage)
{
if (stage == SessionStage.WhiteGloveSealed) return "whiteglove_part1";
if (stage == SessionStage.WhiteGloveCompletedPart2) return "whiteglove_part2";
switch (outcome)
{
case EnrollmentTerminationOutcome.Succeeded: return "succeeded";
@@ -146,10 +142,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
if (obs.WhiteGloveSealingPatternSeen != null && obs.WhiteGloveSealingPatternSeen.Value)
signals.Add("whiteglove_sealing_pattern");
if (state.ImeMatchedPatternId != null) signals.Add($"ime_pattern:{state.ImeMatchedPatternId.Value}");
if (state.UserAadSignInCompleteUtc != null) signals.Add("part2_user_aad_signin");
if (state.HelloResolvedPart2Utc != null) signals.Add("part2_hello_resolved");
if (state.DesktopArrivedPart2Utc != null) signals.Add("part2_desktop_arrived");
if (state.AccountSetupCompletedPart2Utc != null) signals.Add("part2_account_setup_completed");
return signals;
}
@@ -43,6 +43,13 @@
<ItemGroup>
<Reference Include="System.Net.Http" />
<!--
System.Management is required by the WMI-detached runtime launch in
Program.InstallMode.cs (StartRuntimeDetached). WMI Win32_Process.Create
spawns the runtime outside the install-mode process tree so the agent
survives the bootstrap script + IntuneWindowsAgent unwinding.
-->
<Reference Include="System.Management" />
</ItemGroup>
<!--
@@ -2,7 +2,10 @@ using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Management;
using System.Reflection;
using System.Security;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Security;
@@ -144,31 +147,36 @@ namespace AutopilotMonitor.Agent.V2
}
var taskName = Constants.ScheduledTaskName;
var taskCommand = $"\"{targetExePath}\"";
logger.Info($"Registering Scheduled Task '{taskName}' for executable: {targetExePath}");
var createExitCode = RunProcess(
SystemPaths.Schtasks,
$"/Create /TN \"{taskName}\" /TR \"{taskCommand}\" /SC ONSTART /RU SYSTEM /RL HIGHEST /F",
logger);
if (createExitCode != 0)
throw new InvalidOperationException(
$"Failed to create/update Scheduled Task '{taskName}' (exit code {createExitCode}).");
// PR2: register the task from a hardened XML definition rather than the
// schtasks /Create CLI. The CLI defaults inherit DisallowStartIfOnBatteries
// = true and StopIfGoingOnBatteries = true, which queue the BootTrigger run
// indefinitely on a laptop that boots on battery in OOBE / WinPE (observed
// 2026-05-04, Event 325 'Launch request queued', task status = Queued).
// The XML below disables both, sets StartWhenAvailable, and pins
// ExecutionTimeLimit = PT0S so the long-running agent is never killed at
// 72h (the schtasks CLI default).
CreateScheduledTaskFromXml(taskName, targetExePath, logger);
logger.Info($"Scheduled Task '{taskName}' created/updated successfully.");
var startExitCode = RunProcess(
SystemPaths.Schtasks,
$"/Run /TN \"{taskName}\"",
logger);
if (startExitCode != 0)
throw new InvalidOperationException(
$"Failed to start Scheduled Task '{taskName}' (exit code {startExitCode}).");
logger.Info($"Scheduled Task '{taskName}' started successfully.");
// PR1: launch the runtime directly via WMI Win32_Process.Create rather
// than 'schtasks /Run'. Two reasons:
//
// 1. schtasks /Run reports SUCCESS as soon as the run is queued. The
// same battery / AV / OOBE-defer paths that motivate PR2 also queue
// a manual /Run — observed in WinPE 2026-05-04 with the task stuck
// in 'Queued' state (project_v2_install_runtime_handoff.md).
// 2. WMI spawns the process from the WBEM service context, outside the
// install-mode process tree. A direct Process.Start would inherit
// whatever Job Object IntuneWindowsAgent / the bootstrap script
// sit in, and could be killed when those unwind. WMI gives us the
// same isolation Task Scheduler uses for /Run, without the queue.
int runtimePid = StartRuntimeDetached(targetExePath, logger);
logger.Info(
$"Runtime process launched (WMI-detached). PID={runtimePid}, exe={targetExePath}");
TryWriteDeploymentMarker(logger);
@@ -330,5 +338,147 @@ namespace AutopilotMonitor.Agent.V2
logger.Warning($"Failed to write deployment registry marker: {ex.Message}");
}
}
/// <summary>
/// PR2: registers the BootTrigger Scheduled Task from a hardened XML definition
/// rather than via <c>schtasks /Create /TR ... /SC ONSTART /RU SYSTEM /RL HIGHEST</c>.
/// The CLI form inherits XML schema defaults that queue the task indefinitely on a
/// laptop booting on battery (Event 325 'Launch request queued', observed
/// 2026-05-04 in WinPE) and kill long-running tasks at 72 h. This XML disables both
/// behaviours and makes the BootTrigger run robust across the OOBE → first-login
/// power transitions.
/// </summary>
private static void CreateScheduledTaskFromXml(string taskName, string exePath, AgentLogger logger)
{
var taskXml = BuildScheduledTaskXml(exePath);
var tempXmlPath = Path.Combine(
Path.GetTempPath(),
$"AutopilotMonitor-Task-{Guid.NewGuid():N}.xml");
try
{
// schtasks /XML expects UTF-16 LE with BOM (matches Task Scheduler exports).
File.WriteAllText(tempXmlPath, taskXml, Encoding.Unicode);
var createExitCode = RunProcess(
SystemPaths.Schtasks,
$"/Create /TN \"{taskName}\" /XML \"{tempXmlPath}\" /F",
logger);
if (createExitCode != 0)
throw new InvalidOperationException(
$"Failed to create/update Scheduled Task '{taskName}' from XML " +
$"(exit code {createExitCode}, xml='{tempXmlPath}').");
}
finally
{
try
{
if (File.Exists(tempXmlPath)) File.Delete(tempXmlPath);
}
catch (Exception ex)
{
logger.Warning($"Failed to delete temp task XML '{tempXmlPath}': {ex.Message}");
}
}
}
/// <summary>
/// Pure builder for the Task Scheduler 1.2 XML used by
/// <see cref="CreateScheduledTaskFromXml"/>. Kept <c>internal static</c> so unit
/// tests can pin the hardened settings — disabling these silently in a future
/// refactor would re-introduce the OOBE battery-queue failure mode.
/// </summary>
internal static string BuildScheduledTaskXml(string exePath)
{
if (string.IsNullOrEmpty(exePath))
throw new ArgumentException("exePath must be set.", nameof(exePath));
var escapedExe = SecurityElement.Escape(exePath);
return $@"<?xml version=""1.0"" encoding=""UTF-16""?>
<Task version=""1.2"" xmlns=""http://schemas.microsoft.com/windows/2004/02/mit/task"">
<RegistrationInfo>
<Description>Autopilot Monitor enrollment observability agent.</Description>
<Author>AutopilotMonitor</Author>
</RegistrationInfo>
<Triggers>
<BootTrigger>
<Enabled>true</Enabled>
</BootTrigger>
</Triggers>
<Principals>
<Principal id=""Author"">
<UserId>S-1-5-18</UserId>
<RunLevel>HighestAvailable</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<IdleSettings>
<StopOnIdleEnd>false</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>7</Priority>
</Settings>
<Actions Context=""Author"">
<Exec>
<Command>{escapedExe}</Command>
</Exec>
</Actions>
</Task>";
}
/// <summary>
/// PR1: launches the runtime out-of-band of the install-mode process tree via
/// WMI <c>Win32_Process.Create</c>. The WBEM service spawns the new process from
/// its own service context, giving the runtime the same Job-Object isolation that
/// <c>schtasks /Run</c> would (the Task Scheduler service is the parent there) —
/// without paying the scheduler's queue defer (battery / AV / OOBE).
/// </summary>
private static int StartRuntimeDetached(string exePath, AgentLogger logger)
{
using (var processClass = new ManagementClass("Win32_Process"))
using (var inParams = processClass.GetMethodParameters("Create"))
{
inParams["CommandLine"] = $"\"{exePath}\"";
inParams["CurrentDirectory"] = Path.GetDirectoryName(exePath) ?? string.Empty;
using (var outParams = processClass.InvokeMethod("Create", inParams, null))
{
if (outParams == null)
throw new InvalidOperationException(
$"WMI Win32_Process.Create returned no out-parameters for '{exePath}'.");
var returnValue = Convert.ToUInt32(outParams["ReturnValue"]);
if (returnValue != 0u)
{
// WMI return codes: 0=ok, 2=access denied, 3=insufficient privilege,
// 8=unknown failure, 9=path not found, 21=invalid parameter, 22=invalid name
// (https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/create-method-in-class-win32-process).
throw new InvalidOperationException(
$"WMI Win32_Process.Create failed for '{exePath}': returnValue={returnValue}.");
}
var pid = Convert.ToInt32(outParams["ProcessId"]);
if (pid <= 0)
throw new InvalidOperationException(
$"WMI Win32_Process.Create reported success but no ProcessId for '{exePath}'.");
return pid;
}
}
}
}
}
@@ -71,7 +71,6 @@ namespace AutopilotMonitor.Agent.V2.Runtime
var classifiers = new IClassifier[]
{
new WhiteGloveSealingClassifier(),
new WhiteGlovePart2CompletionClassifier(),
};
var componentFactory = new DefaultComponentFactory(
@@ -300,7 +299,15 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// spool-empty. Pair with off-worker dispatch in
// EnrollmentOrchestrator.OnDecisionTerminalStage so the
// wait can actually make progress.
ingressPendingSignalCountAccessor: () => orchestrator.IngressPendingSignalCount);
ingressPendingSignalCountAccessor: () => orchestrator.IngressPendingSignalCount,
// V1-symmetric Part-2 hint accessor (plan §11). The orchestrator
// does not reach a dedicated terminal stage for Part 2; instead
// Part 2 runs as a fresh Classic enrollment after Archive-and-Reset
// and ends on Completed/Failed. The shutdown analyzer pipeline
// still needs the hint to tag findings with phase=2 so the backend
// vulnerability correlation pipeline can filter Part-2 inventory
// out of the Part-1 set.
isWhiteGlovePart2Accessor: () => orchestrator.IsWhiteGlovePart2);
// ServerActionDispatcher (plan §5.3) — constructed inside this
// hook so lifecyclePost + terminationHandler are guaranteed
@@ -346,14 +353,23 @@ namespace AutopilotMonitor.Agent.V2.Runtime
logger.Warning("WhiteGloveInventoryTrigger not wired — componentFactory.EspAndHelloHost is null after orchestrator.Start.");
}
// WhiteGlove Part-2 resume: EnrollmentOrchestrator.Start already posts
// SessionRecovered, which triggers HandleWhiteGlovePart1To2Bridge —
// that reducer effect emits whiteglove_resumed on the timeline. We
// only need to clear the persisted marker here.
// WhiteGlove Part-2 resume: EnrollmentOrchestrator.Start (PR-A) detected
// the persisted WhiteGloveSealed snapshot, archived the state folder,
// and posted whiteglove_resumed via InformationalEventPost after the
// onIngressReady hook ran. We clear the persisted marker file here so
// the next run isn't classified as a Part-2 resume too, AND rebase the
// session-age clock to the Part-2 boot moment. Without the rebase a
// crash/reboot during Part-2 AccountSetup would let the next start
// run the emergency-break watchdog against the original Part-1
// session.created and trip too early. V1 parity:
// SessionPersistence.ResetSessionCreatedAt() in
// MonitoringService.cs:530.
if (isWhiteGloveResume)
{
try { sessionPersistence.ClearWhiteGloveComplete(logger); }
catch (Exception ex) { logger.Debug($"ClearWhiteGloveComplete threw: {ex.Message}"); }
try { sessionPersistence.SaveSessionCreatedAt(DateTime.UtcNow); }
catch (Exception ex) { logger.Debug($"SaveSessionCreatedAt threw: {ex.Message}"); }
}
// Reboot mid-session: post SystemRebootObserved so the reducer
@@ -380,31 +396,30 @@ namespace AutopilotMonitor.Agent.V2.Runtime
}
// V2 race-fix follow-up (10c8e0bf review, 2026-04-27) —
// EnrollmentFactsObserved is posted unconditionally except on
// WhiteGlove Part-2 resume. The facts handler is stage-agnostic,
// idempotent, and monotonic, so it composes cleanly with whatever
// lifecycle anchor follows (SessionStarted, AdminPreemptionDetected).
// Why also for AdminPreemption: even a session that goes straight to
// EnrollmentFactsObserved is posted unconditionally. The facts handler
// is stage-agnostic, idempotent, and monotonic, so it composes cleanly
// with whatever lifecycle anchor follows (SessionStarted,
// AdminPreemptionDetected). Even a session that goes straight to
// terminal benefits from a populated ScenarioProfile (JoinMode /
// EnrollmentType) for completion-event reporting and downstream
// analytics. WhiteGlove Part-2 resume hydrates the profile from the
// persisted snapshot and the SessionRecovered signal — re-posting
// facts there would be a no-op via monotonicity but adds noise to
// the Inspector trace, so the existing exclusion stays.
if (!isWhiteGloveResume)
{
LifecycleEmitters.PostEnrollmentFactsObserved(orchestrator.IngressSink, logger);
}
// analytics. WhiteGlove Part-2 resume runs as a fresh Classic
// enrollment after PR-A's archive-and-reset, so the engine state is
// empty and needs the facts re-seeded — same as a first boot.
LifecycleEmitters.PostEnrollmentFactsObserved(orchestrator.IngressSink, logger);
// V2 parity — post SessionStarted so the reducer establishes the session
// anchor (HandleSessionStartedV1 in DecisionEngine.Shared.cs). Skipped on:
// - WhiteGlove Part-2 resume: EnrollmentOrchestrator.Start already
// posts SessionRecovered, which triggers
// HandleWhiteGlovePart1To2Bridge.
// - Admin preemption: the AdminPreemptionDetected signal below
// drives the session straight to a terminal stage; SessionStarted
// first would be noise.
if (!isWhiteGloveResume && string.IsNullOrEmpty(registrationResult.AdminAction))
// anchor (HandleSessionStartedV1 in DecisionEngine.Shared.cs). Skipped only
// when the register-session response carried an AdminAction: the
// AdminPreemptionDetected signal below drives the session straight to a
// terminal stage; SessionStarted first would be noise.
// WhiteGlove Part-2 resume is NOT excluded — after archive-and-reset the
// decision state is empty, so the Classic flow needs its anchor (V1
// emitted whiteglove_resumed AND continued through the normal start path).
// The orchestrator's whiteglove_resumed event lands first via
// onIngressReady (sequence < SessionStarted), so the Web splitter's
// `splitSequence = resumed.sequence - 1` correctly puts SessionStarted
// into the Part-2 timeline block.
if (string.IsNullOrEmpty(registrationResult.AdminAction))
{
LifecycleEmitters.PostSessionStarted(orchestrator.IngressSink, registrationResult, agentConfig, agentVersion, logger);
}
@@ -1 +1 @@
562
597
@@ -1 +1 @@
1243
1256
@@ -11,13 +11,13 @@ using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// Coverage for <see cref="DecisionAuditTrailBuilder"/> and the six DecisionEngine emission
/// Coverage for <see cref="DecisionAuditTrailBuilder"/> and the DecisionEngine emission
/// sites that previously published terminal / state-changing timeline events with empty
/// or near-empty <c>EnrollmentEvent.Data</c>: <c>whiteglove_complete</c>,
/// <c>whiteglove_resumed</c>, <c>whiteglove_part2_complete</c>, <c>enrollment_failed</c>
/// (Part-2 safety / EspTerminalFailure / EffectInfrastructureFailure). Each test exercises
/// the reducer's typed-payload contract end-to-end so a regression to "empty Data on the
/// wire" is caught at the engine boundary instead of in a UI smoke test.
/// <c>whiteglove_resumed</c>, <c>enrollment_failed</c> (EspTerminalFailure /
/// EffectInfrastructureFailure). Each test exercises the reducer's typed-payload
/// contract end-to-end so a regression to "empty Data on the wire" is caught at the
/// engine boundary instead of in a UI smoke test.
/// </summary>
public sealed class DecisionAuditTrailTests
{
@@ -302,116 +302,10 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.Equal(EnrollmentMode.WhiteGlove.ToString(), scenario["mode"]);
}
// ============================================================ whiteglove_resumed
[Fact]
public void WhiteGloveResumed_attaches_audit_trail_alongside_legacy_scalar_parameters()
{
var engine = new DecisionEngine();
// Sealed Part-1 state recovered after reboot.
var sealedState = DecisionState.CreateInitial("sess-wgr", "tenant-wgr")
.ToBuilder()
.WithStage(SessionStage.WhiteGloveSealed)
.Build();
var resumeUtc = new DateTime(2026, 4, 30, 9, 0, 0, DateTimeKind.Utc);
var step = engine.Reduce(sealedState, MakeSignal(
10, DecisionSignalKind.SessionRecovered, resumeUtc, sourceOrigin: "EnrollmentOrchestrator"));
var effect = SingleTimelineEffect(step, "whiteglove_resumed");
// Legacy scalar parameters preserved (existing assertions in ReducerTimelineEffectTests).
Assert.Equal(resumeUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture),
effect.Parameters!["resumedAtUtc"]);
Assert.Equal("EnrollmentOrchestrator", effect.Parameters["sourceOrigin"]);
// Newly-attached audit trail.
var payload = Assert.IsType<Dictionary<string, object>>(effect.TypedPayload);
Assert.Equal("SessionRecovered:WhiteGloveSealed->AwaitingUserSignIn", payload["trigger"]);
Assert.Equal(nameof(SessionStage.WhiteGloveAwaitingUserSignIn), payload["sessionStage"]);
// The bridge-specific scalars are present in the typed payload too.
Assert.Equal(resumeUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture), payload["resumedAtUtc"]);
Assert.Equal("EnrollmentOrchestrator", payload["sourceOrigin"]);
}
// ============================================================ whiteglove_part2_complete
[Fact]
public void WhiteGlovePart2Complete_carries_audit_trail_with_part2_verdict_and_facts()
{
var engine = new DecisionEngine();
// Establish Part-2 awaiting state with all four Part-2 facts in place so the
// verdict applier transitions to WhiteGloveCompletedPart2.
var state = DecisionState.CreateInitial("sess-wg2", "tenant-wg2")
.ToBuilder()
.WithStage(SessionStage.WhiteGloveAwaitingUserSignIn)
.Build();
state = engine.Reduce(state, MakeSignal(20, DecisionSignalKind.UserAadSignInComplete, T0.AddHours(2))).NewState;
state = engine.Reduce(state, MakeSignal(21, DecisionSignalKind.HelloResolvedPart2, T0.AddHours(2).AddSeconds(10))).NewState;
state = engine.Reduce(state, MakeSignal(22, DecisionSignalKind.DesktopArrivedPart2, T0.AddHours(2).AddSeconds(20))).NewState;
state = engine.Reduce(state, MakeSignal(23, DecisionSignalKind.AccountSetupCompletedPart2, T0.AddHours(2).AddSeconds(30))).NewState;
var verdictSignal = MakeSignal(24, DecisionSignalKind.ClassifierVerdictIssued, T0.AddHours(2).AddMinutes(1),
new Dictionary<string, string>
{
["classifier"] = WhiteGlovePart2CompletionClassifier.ClassifierId,
["level"] = "Confirmed",
["score"] = "100",
["reason"] = "all_part2_facts_present",
["inputHash"] = "feedface",
});
var step = engine.Reduce(state, verdictSignal);
Assert.Equal(SessionStage.WhiteGloveCompletedPart2, step.NewState.Stage);
var effect = SingleTimelineEffect(step, "whiteglove_part2_complete");
var payload = Assert.IsType<Dictionary<string, object>>(effect.TypedPayload);
Assert.Equal($"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:Confirmed", payload["trigger"]);
Assert.Equal(nameof(SessionStage.WhiteGloveCompletedPart2), payload["sessionStage"]);
var signalsSeen = Assert.IsType<List<string>>(payload["signalsSeen"]);
Assert.Contains("user_aad_sign_in_complete", signalsSeen);
Assert.Contains("hello_resolved_part2", signalsSeen);
Assert.Contains("desktop_arrived_part2", signalsSeen);
Assert.Contains("account_setup_completed_part2", signalsSeen);
var classifier = Assert.IsType<Dictionary<string, object>>(payload["classifier"]);
Assert.Equal(WhiteGlovePart2CompletionClassifier.ClassifierId, classifier["id"]);
Assert.Equal("feedface", classifier["inputHash"]);
}
// ============================================================ enrollment_failed (Part-2 safety)
[Fact]
public void Part2SafetyDeadline_failure_event_carries_audit_trail_with_part2_user_absent_reason()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("sess-p2s", "tenant-p2s")
.ToBuilder()
.WithStage(SessionStage.WhiteGloveAwaitingUserSignIn)
.AddDeadline(new ActiveDeadline(
name: DeadlineNames.WhiteGlovePart2Safety,
dueAtUtc: T0.AddHours(24),
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.WhiteGlovePart2Safety,
}))
.Build();
var step = engine.Reduce(state, MakeSignal(
30, DecisionSignalKind.DeadlineFired, T0.AddHours(24),
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.WhiteGlovePart2Safety }));
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
var effect = SingleTimelineEffect(step, "enrollment_failed");
var payload = Assert.IsType<Dictionary<string, object>>(effect.TypedPayload);
Assert.Equal($"DeadlineFired:{DeadlineNames.WhiteGlovePart2Safety}", payload["trigger"]);
Assert.Equal("part2_user_absent", payload["reason"]);
Assert.Equal(nameof(SessionStage.Failed), payload["sessionStage"]);
}
// (PR-B 2026-05-04: WG-Part-2 audit-trail tests removed — `whiteglove_resumed` is
// now an InformationalEvent emitted directly by the orchestrator (PR-A) rather than
// a reducer-emitted timeline effect, and the Part-2 classifier / completion verdict /
// 24h safety deadline were all deleted with the rest of the V2 WG-Part-2 apparatus.)
// ============================================================ enrollment_failed (ESP terminal)
@@ -95,8 +95,10 @@ namespace AutopilotMonitor.DecisionCore.Tests
var snapshot = DecisionStateSnapshotBuilder.Build(DecisionState.CreateInitial("s", "t"));
var facts = (Dictionary<string, object?>)snapshot["facts"]!;
// All 15 SignalFact slots present and null.
Assert.Equal(15, facts.Count);
// The WG-resume cleanup (2026-05-04) dropped the four V2-only post-reseal
// SignalFacts along with the rest of the dedicated Part-2 apparatus; the
// Classic Hello/Desktop/AAD facts cover both runs now, leaving 11 slots.
Assert.Equal(11, facts.Count);
Assert.All(facts.Values, v => Assert.Null(v));
}
@@ -203,10 +205,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
.OrderBy(k => k, StringComparer.Ordinal)
.ToList();
// Sanity: the plan says 15 SignalFact properties exist in DecisionState.
// If this number ever changes, both the count expectation AND the actual
// snapshot output need to evolve in lockstep.
Assert.Equal(15, expectedFactKeys.Count);
// PR-B (2026-05-04): 11 SignalFact properties on DecisionState (was 15;
// the 4 WG-Part-2 facts were removed). If this number ever changes, both
// the count expectation AND the actual snapshot output need to evolve in
// lockstep.
Assert.Equal(11, expectedFactKeys.Count);
var snapshot = DecisionStateSnapshotBuilder.Build(DecisionState.CreateInitial("s", "t"));
var facts = (Dictionary<string, object?>)snapshot["facts"]!;
@@ -21,7 +21,6 @@ namespace AutopilotMonitor.DecisionCore.Tests
[InlineData(nameof(SharedConstants.EventTypes.DesktopArrived))]
[InlineData(nameof(SharedConstants.EventTypes.AadPlaceholderUserDetected))]
[InlineData(nameof(SharedConstants.EventTypes.AadUserJoinedObserved))]
[InlineData(nameof(SharedConstants.EventTypes.UserAadSignInComplete))]
[InlineData(nameof(SharedConstants.EventTypes.HybridLoginPending))]
[InlineData(nameof(SharedConstants.EventTypes.AgentShuttingDown))]
[InlineData(nameof(SharedConstants.EventTypes.SystemRebootDetected))]
@@ -72,9 +71,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
[Fact]
public void Allowlist_count_matches_planned_anchor_count()
{
// Plan §A documents 13 anchors (12 Constants + the new prior_run_died_with_state).
// Drift from this number is a contract change that needs a paired plan update.
Assert.Equal(13, LifecycleAnchorEventTypes.Count);
// Plan §A originally documented 13 anchors; the WG-resume cleanup (2026-05-04)
// dropped the V2-only post-reseal real-user sign-in anchor — the Classic
// aad_user_joined_observed event covers both Part-1 and the post-reseal join
// now. Drift from 12 is a contract change that needs a paired plan update.
Assert.Equal(12, LifecycleAnchorEventTypes.Count);
}
}
}
@@ -101,7 +101,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
var state = DecisionState.CreateInitial("s", "t");
var step1 = engine.Reduce(state, MakeSignal(DecisionSignalKind.SessionStarted, ordinal: 0));
var step2 = engine.Reduce(step1.NewState, MakeSignal(DecisionSignalKind.SessionRecovered, ordinal: 1));
var step2 = engine.Reduce(step1.NewState, MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 1));
Assert.Equal("2.0.0.0", engine.ReducerVersion);
Assert.Equal("2.0.0.0", step1.Transition.ReducerVersion);
@@ -77,34 +77,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.False(timelineEffect.Parameters.ContainsKey("previousExitType"));
}
// ------------------------------------------------------ WhiteGlovePart1To2 bridge
[Fact]
public void WhiteGlovePart1To2Bridge_emits_whiteglove_resumed_timeline_entry()
{
var engine = new DecisionEngine();
var sealedState = DecisionState.CreateInitial("s", "t")
.ToBuilder()
.WithStage(SessionStage.WhiteGloveSealed)
.Build();
var recoverSignal = BuildSignal(
DecisionSignalKind.SessionRecovered,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
sourceOrigin: "EnrollmentOrchestrator");
var step = engine.Reduce(sealedState, recoverSignal);
Assert.Equal(SessionStage.WhiteGloveAwaitingUserSignIn, step.NewState.Stage);
var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry);
Assert.Equal("whiteglove_resumed", timelineEffect.Parameters!["eventType"]);
Assert.Equal("2026-04-22T10:00:00.0000000Z", timelineEffect.Parameters["resumedAtUtc"]);
Assert.Equal("EnrollmentOrchestrator", timelineEffect.Parameters["sourceOrigin"]);
// ScheduleDeadline remains.
Assert.Contains(step.Effects, e => e.Kind == DecisionEffectKind.ScheduleDeadline);
}
// (PR-B 2026-05-04: WhiteGlovePart1To2 bridge test removed — the SessionRecovered
// signal kind + bridge handler were deleted with the rest of the V2 WG-Part-2
// apparatus. The post-reseal-reboot flow is now driven by the orchestrator-side
// archive-and-reset + a direct `whiteglove_resumed` InformationalEvent emit;
// EnrollmentOrchestratorPart2EmissionTests covers the new path.)
// ---------------------------------------------------- AdminPreemptionDetected
@@ -20,7 +20,6 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
new Dictionary<string, IClassifier>
{
[WhiteGloveSealingClassifier.ClassifierId] = new WhiteGloveSealingClassifier(),
[WhiteGlovePart2CompletionClassifier.ClassifierId] = new WhiteGlovePart2CompletionClassifier(),
});
[Fact]
@@ -65,7 +64,7 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
// Determinism: the same signal stream through a fresh engine + fresh state
// yields the same FinalStepHash. Simulates the Orchestrator replaying a
// persisted SignalLog after an agent crash. Plan L.2 Event-Sourcing.
var signals = LoadFixture("whiteglove-part2-happy-v1.jsonl");
var signals = LoadFixture("selfdeploying-happy-v1.jsonl");
var r1 = NewHarness().Replay("session-anon-replay", "tenant-anon-replay", signals);
var r2 = NewHarness().Replay("session-anon-replay", "tenant-anon-replay", signals);
@@ -94,7 +93,7 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
}
[Fact]
public void AllSixCommittedFixtures_produceTerminalState()
public void AllCommittedFixtures_produceTerminalState()
{
// Global sanity: every committed anonymized fixture ends in a terminal stage.
// This catches regressions where a new reducer change breaks a scenario end.
@@ -108,8 +107,6 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
"whiteglove-inline-v1.jsonl",
"whiteglove-signal-correlated-v1.jsonl",
"whiteglove-false-positive-v1.jsonl",
"whiteglove-part2-happy-v1.jsonl",
"whiteglove-part2-stuck-v1.jsonl",
"hybrid-reboot-v1.jsonl",
"esp-terminal-failure-v1.jsonl",
};
@@ -121,8 +118,7 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
var isTerminal = result.FinalState.Stage == SessionStage.Completed
|| result.FinalState.Stage == SessionStage.Failed
|| result.FinalState.Stage == SessionStage.WhiteGloveSealed
|| result.FinalState.Stage == SessionStage.WhiteGloveCompletedPart2;
|| result.FinalState.Stage == SessionStage.WhiteGloveSealed;
Assert.True(
isTerminal,
@@ -1,91 +0,0 @@
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.DecisionCore.Tests.Harness;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
{
/// <summary>
/// Plan §4 M3.4 — WhiteGlove Part 2 scenarios.
/// </summary>
public sealed class WhiteGlovePart2ScenarioTests : ScenarioTestBase
{
private static ClassifierAwareReplayHarness NewHarness() =>
new ClassifierAwareReplayHarness(
new DecisionEngine(),
new Dictionary<string, IClassifier>
{
[WhiteGloveSealingClassifier.ClassifierId] = new WhiteGloveSealingClassifier(),
[WhiteGlovePart2CompletionClassifier.ClassifierId] = new WhiteGlovePart2CompletionClassifier(),
});
[Fact]
public void WhiteGlovePart2Happy_fourUserSignals_sealPart2()
{
var signals = LoadFixture("whiteglove-part2-happy-v1.jsonl");
var result = NewHarness().Replay("session-anon-0020", "tenant-anon-0020", signals);
Assert.Equal(SessionStage.WhiteGloveCompletedPart2, result.FinalState.Stage);
Assert.Equal(SessionOutcome.WhiteGlovePart2Complete, result.FinalState.Outcome);
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGlovePart2Completion.Level);
Assert.Equal(100, result.FinalState.ClassifierOutcomes.WhiteGlovePart2Completion.Score);
// All four Part-2 facts should be present.
Assert.NotNull(result.FinalState.UserAadSignInCompleteUtc);
Assert.NotNull(result.FinalState.HelloResolvedPart2Utc);
Assert.NotNull(result.FinalState.DesktopArrivedPart2Utc);
Assert.NotNull(result.FinalState.AccountSetupCompletedPart2Utc);
// Part-1 hypothesis still Confirmed (not reset by the Part-2 transition).
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
// SystemReboot fact recorded from SessionRecovered.
Assert.NotNull(result.FinalState.SystemRebootUtc);
Assert.Empty(result.FinalState.Deadlines);
}
[Fact]
public void WhiteGlovePart2Stuck_safetyDeadlineFires_failsSession()
{
var signals = LoadFixture("whiteglove-part2-stuck-v1.jsonl");
var result = NewHarness().Replay("session-anon-0021", "tenant-anon-0021", signals);
Assert.Equal(SessionStage.Failed, result.FinalState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, result.FinalState.Outcome);
Assert.Equal(HypothesisLevel.Rejected, result.FinalState.ClassifierOutcomes.WhiteGlovePart2Completion.Level);
Assert.Equal("part2_user_absent", result.FinalState.ClassifierOutcomes.WhiteGlovePart2Completion.Reason);
// No user-signal facts captured.
Assert.Null(result.FinalState.UserAadSignInCompleteUtc);
Assert.Null(result.FinalState.HelloResolvedPart2Utc);
Assert.Null(result.FinalState.DesktopArrivedPart2Utc);
Assert.Null(result.FinalState.AccountSetupCompletedPart2Utc);
// Part 1 verdict remains Confirmed even as Part 2 failed.
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
Assert.Empty(result.FinalState.Deadlines);
}
[Fact]
public void WhiteGlovePart2_bridge_armsSafetyDeadline_onRecoverFromSealed()
{
// Intermediate-state check by running a shortened fixture: after Part-1 seals and
// SessionRecovered fires, the safety deadline should be active. The happy path
// then cancels it when the classifier reaches Confirmed; here we just verify the
// bridge behaviour by loading only the first 4 signals of the happy fixture.
var full = LoadFixture("whiteglove-part2-happy-v1.jsonl");
var prefix = new List<AutopilotMonitor.DecisionCore.Signals.DecisionSignal>();
for (int i = 0; i < 4 && i < full.Count; i++) prefix.Add(full[i]);
var result = NewHarness().Replay("session-anon-0022", "tenant-anon-0022", prefix);
Assert.Equal(SessionStage.WhiteGloveAwaitingUserSignIn, result.FinalState.Stage);
Assert.Contains(
result.FinalState.Deadlines,
d => d.Name == DeadlineNames.WhiteGlovePart2Safety);
}
}
}
@@ -27,7 +27,6 @@ namespace AutopilotMonitor.DecisionCore.Tests
// ScenarioProfile (derived classification) and ClassifierOutcomes (verdict storage).
Assert.Equal(EnrollmentMode.Unknown, state.ScenarioProfile.Mode);
Assert.Equal(HypothesisLevel.Unknown, state.ClassifierOutcomes.WhiteGloveSealing.Level);
Assert.Equal(HypothesisLevel.Unknown, state.ClassifierOutcomes.WhiteGlovePart2Completion.Level);
Assert.Equal(HypothesisLevel.Unknown, state.ClassifierOutcomes.DeviceOnlyDeployment.Level);
Assert.Empty(state.Deadlines);
Assert.Equal(-1, state.LastAppliedSignalOrdinal);
@@ -11,12 +11,11 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
public sealed class ClassifierOutcomesTests
{
[Fact]
public void Empty_allThreeHypothesesAreUnknown()
public void Empty_bothHypothesesAreUnknown()
{
var o = ClassifierOutcomes.Empty;
Assert.Equal(HypothesisLevel.Unknown, o.WhiteGloveSealing.Level);
Assert.Equal(HypothesisLevel.Unknown, o.WhiteGlovePart2Completion.Level);
Assert.Equal(HypothesisLevel.Unknown, o.DeviceOnlyDeployment.Level);
}
@@ -32,7 +31,6 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
var outcomes = ClassifierOutcomes.Empty.WithWhiteGloveSealing(h);
Assert.Equal(HypothesisLevel.Confirmed, outcomes.WhiteGloveSealing.Level);
Assert.Equal(HypothesisLevel.Unknown, outcomes.WhiteGlovePart2Completion.Level);
Assert.Equal(HypothesisLevel.Unknown, outcomes.DeviceOnlyDeployment.Level);
}
@@ -53,7 +51,6 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
{
Assert.Throws<ArgumentNullException>(() => new ClassifierOutcomes(
whiteGloveSealing: null!,
whiteGlovePart2Completion: Hypothesis.UnknownInstance,
deviceOnlyDeployment: Hypothesis.UnknownInstance));
}
@@ -61,7 +58,6 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
public void With_nullValue_throws()
{
Assert.Throws<ArgumentNullException>(() => ClassifierOutcomes.Empty.WithWhiteGloveSealing(null!));
Assert.Throws<ArgumentNullException>(() => ClassifierOutcomes.Empty.WithWhiteGlovePart2Completion(null!));
Assert.Throws<ArgumentNullException>(() => ClassifierOutcomes.Empty.WithDeviceOnlyDeployment(null!));
}
}
@@ -9,13 +9,11 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
[InlineData(SessionStage.Completed, true)]
[InlineData(SessionStage.Failed, true)]
[InlineData(SessionStage.WhiteGloveSealed, true)]
[InlineData(SessionStage.WhiteGloveCompletedPart2, true)]
[InlineData(SessionStage.Unknown, false)]
[InlineData(SessionStage.SessionStarted, false)]
[InlineData(SessionStage.AwaitingHello, false)]
[InlineData(SessionStage.EspAccountSetup, false)]
[InlineData(SessionStage.WhiteGloveCandidate, false)]
[InlineData(SessionStage.WhiteGloveAwaitingUserSignIn, false)]
public void IsTerminal_classifies_stages_correctly(SessionStage stage, bool expected)
{
Assert.Equal(expected, stage.IsTerminal());
@@ -25,7 +23,6 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
[InlineData(SessionStage.WhiteGloveSealed, true)]
[InlineData(SessionStage.Completed, false)]
[InlineData(SessionStage.Failed, false)]
[InlineData(SessionStage.WhiteGloveCompletedPart2, false)]
[InlineData(SessionStage.WhiteGloveCandidate, false)]
public void IsPauseBeforePart2_only_true_for_WhiteGloveSealed(SessionStage stage, bool expected)
{
@@ -67,14 +67,13 @@ namespace AutopilotMonitor.SummaryDialog.Tests
[InlineData("succeeded", OutcomeKind.Success)]
[InlineData("Succeeded", OutcomeKind.Success)]
[InlineData("completed", OutcomeKind.Success)] // defensive: accept V1 string under V2 schema
[InlineData("whiteglove_part2", OutcomeKind.Success)]
[InlineData("whiteglove_part1", OutcomeKind.PreProvisioningComplete)]
[InlineData("timed_out", OutcomeKind.TimedOut)]
[InlineData("failed", OutcomeKind.Failure)]
[InlineData("unknown", OutcomeKind.Unknown)]
[InlineData("not_a_real_outcome", OutcomeKind.Unknown)]
[InlineData("", OutcomeKind.Unknown)]
public void Map_v2_resolves_six_kinds(string outcome, OutcomeKind expected)
public void Map_v2_resolves_five_kinds(string outcome, OutcomeKind expected)
{
var status = new FinalStatus { SchemaVersion = 2, Outcome = outcome };
Assert.Equal(expected, OutcomeMapper.Map(status));
@@ -3,10 +3,10 @@ using System;
namespace AutopilotMonitor.SummaryDialog.Models
{
/// <summary>
/// Six visual states the dialog can render. The mapping from the wire-format
/// Five visual states the dialog can render. The mapping from the wire-format
/// <see cref="FinalStatus.Outcome"/> string depends on the schema version: V1 only
/// produces "completed"/"failed", V2 also uses "succeeded"/"timed_out"/"whiteglove_part1"/
/// "whiteglove_part2"/"unknown". The renderer chooses header text, icon and banner
/// produces "completed"/"failed", V2 uses "succeeded"/"failed"/"timed_out"/
/// "whiteglove_part1"/"unknown". The renderer chooses header text, icon and banner
/// colour from this enum, not from the raw string.
/// </summary>
public enum OutcomeKind
@@ -66,13 +66,12 @@ namespace AutopilotMonitor.SummaryDialog.Models
: OutcomeKind.Failure;
}
// V2 — six explicit states. Accept "completed" too, defensively, in case a
// V2 — five explicit states. Accept "completed" too, defensively, in case a
// mixed environment ever surfaces (V1-style outcome string with V2 schema flag).
switch (outcome.ToLowerInvariant())
{
case "succeeded":
case "completed":
case "whiteglove_part2":
return OutcomeKind.Success;
case "whiteglove_part1":
@@ -75,7 +75,6 @@ public class DecisionGraphBuilderTests
[InlineData("Completed", "Succeeded")]
[InlineData("Failed", "Failed")]
[InlineData("WhiteGloveSealed", "PausedForPart2")]
[InlineData("WhiteGloveCompletedPart2", "Succeeded")]
public void Build_marks_terminal_stages_and_derives_outcome(string terminalStage, string expectedOutcome)
{
var transitions = new[]
@@ -181,7 +181,7 @@ public class TelemetryPayloadParserTests
[Fact]
public void ParseTransition_projects_IsTerminal_true_for_terminal_stages()
{
var terminalStages = new[] { "Completed", "Failed", "WhiteGloveSealed", "WhiteGloveCompletedPart2" };
var terminalStages = new[] { "Completed", "Failed", "WhiteGloveSealed" };
foreach (var stage in terminalStages)
{
@@ -246,7 +246,6 @@ public class TelemetryPayloadParserTests
[InlineData("Completed", true)]
[InlineData("Failed", true)]
[InlineData("WhiteGloveSealed", true)]
[InlineData("WhiteGloveCompletedPart2", true)]
[InlineData("EspInProgress", false)]
[InlineData("AccountSetup", false)]
[InlineData("", false)]
@@ -144,7 +144,6 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
case "Completed":
case "Failed":
case "WhiteGloveSealed":
case "WhiteGloveCompletedPart2":
return true;
default:
return false;
@@ -97,7 +97,6 @@ namespace AutopilotMonitor.Functions.Services
case "Completed":
case "Failed":
case "WhiteGloveSealed":
case "WhiteGloveCompletedPart2":
return true;
default:
return false;
@@ -114,7 +113,6 @@ namespace AutopilotMonitor.Functions.Services
switch (stage)
{
case "Completed":
case "WhiteGloveCompletedPart2":
return "Succeeded";
case "Failed":
return "Failed";
@@ -28,7 +28,6 @@ namespace AutopilotMonitor.DecisionCore.Classifiers
return classifierId switch
{
WhiteGloveSealingClassifier.ClassifierId => state.ClassifierOutcomes.WhiteGloveSealing.LastClassifierVerdictId,
WhiteGlovePart2CompletionClassifier.ClassifierId => state.ClassifierOutcomes.WhiteGlovePart2Completion.LastClassifierVerdictId,
_ => null,
};
}
@@ -10,9 +10,8 @@ namespace AutopilotMonitor.DecisionCore.Classifiers
/// a classifier whose last verdict matches the current snapshot hash.
/// </para>
/// <para>
/// First implementations shipped in M3: <c>WhiteGloveSealingClassifier</c>,
/// <c>WhiteGlovePart2CompletionClassifier</c>. Future (v11.1+): <c>EnrollmentFailureCauseClassifier</c>,
/// <c>AppInstallOutcomeClassifier</c>.
/// First implementation shipped in M3: <c>WhiteGloveSealingClassifier</c>.
/// Future (v11.1+): <c>EnrollmentFailureCauseClassifier</c>, <c>AppInstallOutcomeClassifier</c>.
/// </para>
/// </summary>
public interface IClassifier
@@ -1,66 +0,0 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.State;
namespace AutopilotMonitor.DecisionCore.Classifiers
{
/// <summary>
/// Scoring classifier for WhiteGlove Part 2 completion. Plan §2.4 / §M3.4.
/// <para>
/// Scoring rules (new — no Legacy equivalent): each of the four Part-2 post-reboot user
/// signals contributes +25; all four together cross the Confirmed threshold of 100.
/// Missing one signal gives 75 -> Weak. This matches the plan's requirement that Part-2
/// completion needs the full UserSignIn + Hello + Desktop + AccountSetup set.
/// </para>
/// </summary>
public sealed class WhiteGlovePart2CompletionClassifier : IClassifier
{
public const string ClassifierId = "whiteglove-part2-completion";
internal const int HighThreshold = 100; // exactly-all-four -> Confirmed
internal const int LowThreshold = 25; // any single signal -> Weak
internal const int WeightUserAadSignIn = 25;
internal const int WeightHelloResolved = 25;
internal const int WeightDesktopArrived = 25;
internal const int WeightAccountSetup = 25;
public string Id => ClassifierId;
public Type SnapshotType => typeof(WhiteGlovePart2CompletionSnapshot);
public ClassifierVerdict Classify(object snapshot)
{
if (snapshot == null) throw new ArgumentNullException(nameof(snapshot));
if (!(snapshot is WhiteGlovePart2CompletionSnapshot s))
{
throw new ArgumentException(
$"Expected {nameof(WhiteGlovePart2CompletionSnapshot)}, got {snapshot.GetType().Name}.",
nameof(snapshot));
}
var factors = new List<string>();
int score = 0;
if (s.UserAadSignInComplete) { score += WeightUserAadSignIn; factors.Add($"user_aad_signin:{WeightUserAadSignIn:+#;-#;0}"); }
if (s.HelloResolvedPart2) { score += WeightHelloResolved; factors.Add($"hello_part2:{WeightHelloResolved:+#;-#;0}"); }
if (s.DesktopArrivedPart2) { score += WeightDesktopArrived; factors.Add($"desktop_part2:{WeightDesktopArrived:+#;-#;0}"); }
if (s.AccountSetupCompletedPart2) { score += WeightAccountSetup; factors.Add($"account_setup_part2:{WeightAccountSetup:+#;-#;0}"); }
if (score < 0) score = 0;
if (score > 100) score = 100;
HypothesisLevel level;
if (score >= HighThreshold) level = HypothesisLevel.Confirmed;
else if (score >= LowThreshold) level = HypothesisLevel.Weak;
else level = HypothesisLevel.Unknown;
return new ClassifierVerdict(
classifierId: ClassifierId,
level: level,
score: score,
contributingFactors: factors,
reason: $"confidence={level} score={score}",
inputHash: s.ComputeInputHash());
}
}
}
@@ -1,58 +0,0 @@
using System;
using System.Security.Cryptography;
using System.Text;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.DecisionCore.Classifiers
{
/// <summary>
/// Input snapshot for <see cref="WhiteGlovePart2CompletionClassifier"/>.
/// Plan §2.4 / §M3.4 — the four Part-2 post-reboot user signals drive the score.
/// </summary>
public sealed class WhiteGlovePart2CompletionSnapshot : IClassifierSnapshot
{
public WhiteGlovePart2CompletionSnapshot(
bool userAadSignInComplete,
bool helloResolvedPart2,
bool desktopArrivedPart2,
bool accountSetupCompletedPart2,
EnrollmentPhase? currentEnrollmentPhase,
DateTime? systemRebootUtc)
{
UserAadSignInComplete = userAadSignInComplete;
HelloResolvedPart2 = helloResolvedPart2;
DesktopArrivedPart2 = desktopArrivedPart2;
AccountSetupCompletedPart2 = accountSetupCompletedPart2;
CurrentEnrollmentPhase = currentEnrollmentPhase;
SystemRebootUtc = systemRebootUtc;
}
public bool UserAadSignInComplete { get; }
public bool HelloResolvedPart2 { get; }
public bool DesktopArrivedPart2 { get; }
public bool AccountSetupCompletedPart2 { get; }
public EnrollmentPhase? CurrentEnrollmentPhase { get; }
public DateTime? SystemRebootUtc { get; }
public string ComputeInputHash()
{
var canonical = string.Join(
"|",
"whiteglove-part2-completion-v1",
UserAadSignInComplete ? "1" : "0",
HelloResolvedPart2 ? "1" : "0",
DesktopArrivedPart2 ? "1" : "0",
AccountSetupCompletedPart2 ? "1" : "0",
CurrentEnrollmentPhase?.ToString() ?? "null",
SystemRebootUtc?.ToString("O") ?? "null");
using (var sha = SHA256.Create())
{
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(canonical));
var sb = new StringBuilder(16);
for (int i = 0; i < 8; i++) sb.Append(bytes[i].ToString("x2"));
return sb.ToString();
}
}
}
}
@@ -30,9 +30,6 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <summary>Periodic classifier-tick every 30 s — replaces legacy signal-correlated loop.</summary>
public const string ClassifierTick = "classifier_tick";
/// <summary>24 h watchdog after White-Glove Part 1 → reboot → awaiting user sign-in. Plan §2.3.</summary>
public const string WhiteGlovePart2Safety = "whiteglove_part2_safety";
/// <summary>
/// Brief grace window (~5 s) between both-prerequisites-resolved and Completed.
/// Gives the reducer-emitted <c>phase_transition(FinalizingSetup)</c> and the terminal
@@ -54,7 +51,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <summary>On <c>EspPhaseChanged</c>: the raw phase name as observed by the collector.</summary>
public const string EspPhase = "phase";
/// <summary>On <c>HelloResolved</c> / <c>HelloResolvedPart2</c>: outcome string (e.g. Success, Timeout, Skipped).</summary>
/// <summary>On <c>HelloResolved</c>: outcome string (e.g. Success, Timeout, Skipped).</summary>
public const string HelloOutcome = "outcome";
/// <summary>On <c>AadUserJoinedLate</c>: user presence indicator ("true" / "false").</summary>
@@ -184,12 +184,6 @@ namespace AutopilotMonitor.DecisionCore.Engine
seen.Add("ime_pattern_matched");
evidence["imePatternMatched"] = OrdinalEvidence(s.ImeMatchedPatternId.SourceSignalOrdinal);
}
if (s.UserAadSignInCompleteUtc != null)
{
seen.Add("user_aad_sign_in_complete");
timestamps["userAadSignInComplete"] = FormatUtc(s.UserAadSignInCompleteUtc.Value);
evidence["userAadSignInComplete"] = TimestampedEvidence(s.UserAadSignInCompleteUtc);
}
if (s.SystemRebootUtc != null)
{
seen.Add("system_reboot");
@@ -226,26 +220,6 @@ namespace AutopilotMonitor.DecisionCore.Engine
};
}
// WhiteGlove Part-2 facts.
if (s.HelloResolvedPart2Utc != null)
{
seen.Add("hello_resolved_part2");
timestamps["helloResolvedPart2"] = FormatUtc(s.HelloResolvedPart2Utc.Value);
evidence["helloResolvedPart2"] = TimestampedEvidence(s.HelloResolvedPart2Utc);
}
if (s.DesktopArrivedPart2Utc != null)
{
seen.Add("desktop_arrived_part2");
timestamps["desktopArrivedPart2"] = FormatUtc(s.DesktopArrivedPart2Utc.Value);
evidence["desktopArrivedPart2"] = TimestampedEvidence(s.DesktopArrivedPart2Utc);
}
if (s.AccountSetupCompletedPart2Utc != null)
{
seen.Add("account_setup_completed_part2");
timestamps["accountSetupCompletedPart2"] = FormatUtc(s.AccountSetupCompletedPart2Utc.Value);
evidence["accountSetupCompletedPart2"] = TimestampedEvidence(s.AccountSetupCompletedPart2Utc);
}
return (seen, evidence, timestamps);
}
@@ -82,38 +82,6 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(newState, transition, effects);
}
/// <summary>
/// Handle <see cref="DecisionSignalKind.SessionRecovered"/>. Plan §2.7 sonder-case 1.
/// <para>
/// In M3.0 scope this is a generic bookkeeping handler. The White-Glove Part-1 →
/// Part-2 post-reboot transition is implemented in <c>DecisionEngine.WhiteGlovePart2.cs</c>
/// (M3.4) and takes precedence there when the prior stage was
/// <see cref="SessionStage.WhiteGloveSealed"/>.
/// </para>
/// </summary>
private DecisionStep HandleSessionRecoveredV1(DecisionState state, DecisionSignal signal)
{
// Plan §2.7 sonder-case 1: WhiteGlove Part 1 -> Reboot -> Part 2.
// If the recovered session was sealed, transition into the Part 2 awaiting-user
// stage and arm the 24h safety deadline. See DecisionEngine.WhiteGlovePart2.cs.
if (state.Stage == SessionStage.WhiteGloveSealed)
{
return HandleWhiteGlovePart1To2Bridge(state, signal);
}
// Otherwise the recovered state is already mid-flight elsewhere; the handler is
// a neutral "observed a restart" step — stage unchanged, bookkeeping advanced.
var newState = BumpStepBookkeeping(state, signal);
var transition = BuildTakenTransition(
before: state,
signal: signal,
toStage: state.Stage,
nextStepIndex: newState.StepIndex,
trigger: nameof(DecisionSignalKind.SessionRecovered));
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
}
/// <summary>
/// Handle <see cref="DecisionSignalKind.EffectInfrastructureFailure"/>. Codex
/// follow-up #2 — posted synchronously by the EffectRunner when a critical
@@ -370,14 +338,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
return HandleDeviceOnlyEspDetectionDeadlineFired(state, signal);
case DeadlineNames.ClassifierTick:
return HandleClassifierTickDeadlineFired(state, signal);
case DeadlineNames.WhiteGlovePart2Safety:
return HandleWhiteGlovePart2SafetyDeadlineFired(state, signal);
case DeadlineNames.FinalizingGrace:
return HandleFinalizingGraceDeadlineFired(state, signal);
default:
// Deadline name not recognized in this sub-milestone. Cancel it from state
// and record a neutral taken transition — M3.3+ adds handlers for
// ClassifierTick, M3.4 for WhiteGlovePart2Safety, etc.
// ClassifierTick, etc.
var nextStepIgnored = state.StepIndex + 1;
var cancelled = state.ToBuilder()
.WithStepIndex(nextStepIgnored)
@@ -197,12 +197,6 @@ namespace AutopilotMonitor.DecisionCore.Engine
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
// M3.4 Part-2 classifier runs through its own applier.
if (classifier == WhiteGlovePart2CompletionClassifier.ClassifierId)
{
return ApplyWhiteGlovePart2Verdict(state, signal, level, score, reason, inputHash);
}
if (classifier == WhiteGloveSealingClassifier.ClassifierId)
{
var updatedSealing = state.ClassifierOutcomes.WhiteGloveSealing.With(
@@ -283,8 +277,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
var reachedTerminal = state.Stage == SessionStage.Completed
|| state.Stage == SessionStage.Failed
|| state.Stage == SessionStage.WhiteGloveSealed
|| state.Stage == SessionStage.WhiteGloveCompletedPart2;
|| state.Stage == SessionStage.WhiteGloveSealed;
if (!reachedTerminal)
{
@@ -1,292 +0,0 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
namespace AutopilotMonitor.DecisionCore.Engine
{
// WhiteGlove Part 2 (post-reboot user sign-in) handlers. Plan §2.5 / §M3.4.
public sealed partial class DecisionEngine
{
// Plan §2.3: "Part-2-Safety-Timeout (z.B. 24h nach Reboot)" watches for the user
// never signing in. Firing yields Outcome=EnrollmentFailed reason=part2_user_absent.
internal static readonly TimeSpan s_whiteGlovePart2SafetyWindow = TimeSpan.FromHours(24);
/// <summary>
/// WhiteGlove Part 1 → Part 2 bridge. Called from <see cref="HandleSessionRecoveredV1"/>
/// when the recovered state is <see cref="SessionStage.WhiteGloveSealed"/>. Transitions
/// into <see cref="SessionStage.WhiteGloveAwaitingUserSignIn"/> and arms the Part-2
/// safety deadline.
/// </summary>
private DecisionStep HandleWhiteGlovePart1To2Bridge(DecisionState state, DecisionSignal signal)
{
var nextStep = state.StepIndex + 1;
var safety = new ActiveDeadline(
name: DeadlineNames.WhiteGlovePart2Safety,
dueAtUtc: signal.OccurredAtUtc.Add(s_whiteGlovePart2SafetyWindow),
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.WhiteGlovePart2Safety,
});
var builder = state.ToBuilder()
.WithStage(SessionStage.WhiteGloveAwaitingUserSignIn)
.WithOutcome(null) // leaving the Part-1 Sealed outcome aside until Part-2 concludes.
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
.AddDeadline(safety);
builder.SystemRebootUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
var newState = builder.Build();
var transition = BuildTakenTransition(
before: state,
signal: signal,
toStage: SessionStage.WhiteGloveAwaitingUserSignIn,
nextStepIndex: nextStep,
trigger: "SessionRecovered:WhiteGloveSealed->AwaitingUserSignIn");
var resumedTrigger = "SessionRecovered:WhiteGloveSealed->AwaitingUserSignIn";
var resumedAudit = DecisionAuditTrailBuilder.Build(
postState: newState,
decidedStage: SessionStage.WhiteGloveAwaitingUserSignIn,
trigger: resumedTrigger);
// Augment with bridge-specific scalars so the resumedAt timestamp + the recovering
// collector's source label are preserved on the wire (kept on Parameters too for
// tests / consumers that read scalar keys).
resumedAudit["resumedAtUtc"] = signal.OccurredAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture);
resumedAudit["sourceOrigin"] = signal.SourceOrigin ?? string.Empty;
var effects = new[]
{
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: safety),
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: new Dictionary<string, string>
{
["eventType"] = "whiteglove_resumed",
["resumedAtUtc"] = signal.OccurredAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture),
["sourceOrigin"] = signal.SourceOrigin ?? string.Empty,
},
typedPayload: resumedAudit),
};
return new DecisionStep(newState, transition, effects);
}
private DecisionStep HandleUserAadSignInCompleteV1(DecisionState state, DecisionSignal signal)
{
return UpdatePart2Fact(
state,
signal,
applyFact: b => { b.UserAadSignInCompleteUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal); },
trigger: nameof(DecisionSignalKind.UserAadSignInComplete));
}
private DecisionStep HandleHelloResolvedPart2V1(DecisionState state, DecisionSignal signal)
{
return UpdatePart2Fact(
state,
signal,
applyFact: b => { b.HelloResolvedPart2Utc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal); },
trigger: nameof(DecisionSignalKind.HelloResolvedPart2));
}
private DecisionStep HandleDesktopArrivedPart2V1(DecisionState state, DecisionSignal signal)
{
return UpdatePart2Fact(
state,
signal,
applyFact: b => { b.DesktopArrivedPart2Utc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal); },
trigger: nameof(DecisionSignalKind.DesktopArrivedPart2));
}
private DecisionStep HandleAccountSetupCompletedPart2V1(DecisionState state, DecisionSignal signal)
{
return UpdatePart2Fact(
state,
signal,
applyFact: b => { b.AccountSetupCompletedPart2Utc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal); },
trigger: nameof(DecisionSignalKind.AccountSetupCompletedPart2));
}
/// <summary>
/// Part-2 safety deadline fired — user never signed in within 24 h. Terminates the
/// session as <see cref="SessionOutcome.EnrollmentFailed"/> with reason
/// <c>part2_user_absent</c>.
/// </summary>
private DecisionStep HandleWhiteGlovePart2SafetyDeadlineFired(DecisionState state, DecisionSignal signal)
{
var nextStep = state.StepIndex + 1;
var builder = state.ToBuilder()
.WithStage(SessionStage.Failed)
.WithOutcome(SessionOutcome.EnrollmentFailed)
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
.ClearDeadlines();
var rejectedPart2 = state.ClassifierOutcomes.WhiteGlovePart2Completion.With(
level: HypothesisLevel.Rejected,
reason: "part2_user_absent",
score: 0,
lastUpdatedUtc: signal.OccurredAtUtc);
builder.ClassifierOutcomes = state.ClassifierOutcomes.WithWhiteGlovePart2Completion(rejectedPart2);
var newState = builder.Build();
var transition = BuildTakenTransition(
before: state,
signal: signal,
toStage: SessionStage.Failed,
nextStepIndex: nextStep,
trigger: $"DeadlineFired:{DeadlineNames.WhiteGlovePart2Safety}");
var effects = new[]
{
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: new Dictionary<string, string>
{
["eventType"] = "enrollment_failed",
["reason"] = "part2_user_absent",
},
typedPayload: DecisionAuditTrailBuilder.Build(
postState: newState,
decidedStage: SessionStage.Failed,
trigger: $"DeadlineFired:{DeadlineNames.WhiteGlovePart2Safety}",
failureReason: "part2_user_absent")),
};
return new DecisionStep(newState, transition, effects);
}
// ============================================================ internal helpers
private DecisionStep UpdatePart2Fact(
DecisionState state,
DecisionSignal signal,
Action<DecisionStateBuilder> applyFact,
string trigger)
{
var nextStep = state.StepIndex + 1;
var builder = state.ToBuilder()
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
applyFact(builder);
// Part-2 only matters once we're in the WhiteGloveAwaitingUserSignIn stage;
// if signals arrive on other stages, still record the fact but do not emit
// a Part-2 classifier run.
var emitClassifier =
state.Stage == SessionStage.WhiteGloveAwaitingUserSignIn
|| state.Stage == SessionStage.WhiteGloveCompletedPart2;
var stateAfterFact = builder.Build();
var effects = emitClassifier
? new[] { BuildRunPart2ClassifierEffect(stateAfterFact) }
: Array.Empty<DecisionEffect>();
var transition = BuildTakenTransition(
before: state,
signal: signal,
toStage: stateAfterFact.Stage,
nextStepIndex: nextStep,
trigger: trigger);
return new DecisionStep(stateAfterFact, transition, effects);
}
private static DecisionEffect BuildRunPart2ClassifierEffect(DecisionState state) =>
new DecisionEffect(
kind: DecisionEffectKind.RunClassifier,
classifierId: WhiteGlovePart2CompletionClassifier.ClassifierId,
classifierSnapshot: BuildWhiteGlovePart2CompletionSnapshot(state));
internal static WhiteGlovePart2CompletionSnapshot BuildWhiteGlovePart2CompletionSnapshot(DecisionState state) =>
new WhiteGlovePart2CompletionSnapshot(
userAadSignInComplete: state.UserAadSignInCompleteUtc != null,
helloResolvedPart2: state.HelloResolvedPart2Utc != null,
desktopArrivedPart2: state.DesktopArrivedPart2Utc != null,
accountSetupCompletedPart2: state.AccountSetupCompletedPart2Utc != null,
currentEnrollmentPhase: state.CurrentEnrollmentPhase?.Value,
systemRebootUtc: state.SystemRebootUtc?.Value);
/// <summary>
/// Apply a Part-2 classifier verdict to <see cref="DecisionState.WhiteGlovePart2Completion"/>.
/// Called from the extended <see cref="HandleClassifierVerdictIssuedV1"/> router when
/// the verdict's classifier id is the Part-2 completion classifier. On Confirmed,
/// transitions to <see cref="SessionStage.WhiteGloveCompletedPart2"/> + Outcome
/// <see cref="SessionOutcome.WhiteGlovePart2Complete"/> and emits the
/// <c>whiteglove_part2_complete</c> event.
/// </summary>
private DecisionStep ApplyWhiteGlovePart2Verdict(
DecisionState state,
DecisionSignal signal,
HypothesisLevel level,
int score,
string reason,
string inputHash)
{
var nextStep = state.StepIndex + 1;
var builder = state.ToBuilder()
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
var updatedPart2 = state.ClassifierOutcomes.WhiteGlovePart2Completion.With(
level: level,
reason: reason,
score: score,
lastUpdatedUtc: signal.OccurredAtUtc,
lastClassifierVerdictId: inputHash);
builder.ClassifierOutcomes = state.ClassifierOutcomes.WithWhiteGlovePart2Completion(updatedPart2);
if (level == HypothesisLevel.Confirmed)
{
builder
.WithStage(SessionStage.WhiteGloveCompletedPart2)
.WithOutcome(SessionOutcome.WhiteGlovePart2Complete)
.ClearDeadlines();
var sealedState = builder.Build();
var sealedTransition = BuildTakenTransition(
before: state,
signal: signal,
toStage: SessionStage.WhiteGloveCompletedPart2,
nextStepIndex: nextStep,
trigger: $"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:Confirmed");
var part2Verdict = new ClassifierVerdictInfo(
WhiteGlovePart2CompletionClassifier.ClassifierId, level.ToString(), score, reason, inputHash);
var effects = new[]
{
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: new Dictionary<string, string> { ["eventType"] = "whiteglove_part2_complete" },
typedPayload: DecisionAuditTrailBuilder.Build(
postState: sealedState,
decidedStage: SessionStage.WhiteGloveCompletedPart2,
trigger: $"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:Confirmed",
classifier: part2Verdict,
classifierInputs: BuildWhiteGlovePart2CompletionSnapshot(sealedState))),
};
return new DecisionStep(sealedState, sealedTransition, effects);
}
var newState = builder.Build();
var transition = BuildTakenTransition(
before: state,
signal: signal,
toStage: state.Stage,
nextStepIndex: nextStep,
trigger: $"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:{level}");
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
}
}
}
@@ -14,8 +14,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// Dispatches on <c>(<see cref="DecisionSignalKind"/>, <see cref="DecisionSignal.KindSchemaVersion"/>)</c>
/// to partial-class handlers. All handlers live in sibling files named
/// <c>DecisionEngine.Classic.cs</c>, <c>DecisionEngine.SelfDeploying.cs</c>,
/// <c>DecisionEngine.WhiteGlove.cs</c>, <c>DecisionEngine.WhiteGlovePart2.cs</c>,
/// <c>DecisionEngine.Shared.cs</c>.
/// <c>DecisionEngine.WhiteGlove.cs</c>, <c>DecisionEngine.Shared.cs</c>.
/// </para>
/// <para>
/// Exception fail-safe (L.16): a handler exception never corrupts state; the kernel
@@ -57,7 +56,6 @@ namespace AutopilotMonitor.DecisionCore.Engine
{
// ----- Lifecycle (DecisionEngine.Shared.cs) -----
(DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal),
(DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal),
(DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal),
(DecisionSignalKind.AdminPreemptionDetected, 1) => HandleAdminPreemptionDetectedV1(state, signal),
(DecisionSignalKind.DeadlineFired, 1) => HandleDeadlineFiredV1(state, signal),
@@ -79,12 +77,6 @@ namespace AutopilotMonitor.DecisionCore.Engine
(DecisionSignalKind.WhiteGloveSealingPatternDetected, 1) => HandleWhiteGloveSealingPatternDetectedV1(state, signal),
(DecisionSignalKind.ClassifierVerdictIssued, 1) => HandleClassifierVerdictIssuedV1(state, signal),
// ----- WhiteGlove Part 2 (DecisionEngine.WhiteGlovePart2.cs) -----
(DecisionSignalKind.UserAadSignInComplete, 1) => HandleUserAadSignInCompleteV1(state, signal),
(DecisionSignalKind.HelloResolvedPart2, 1) => HandleHelloResolvedPart2V1(state, signal),
(DecisionSignalKind.DesktopArrivedPart2, 1) => HandleDesktopArrivedPart2V1(state, signal),
(DecisionSignalKind.AccountSetupCompletedPart2, 1) => HandleAccountSetupCompletedPart2V1(state, signal),
// ----- Edge (DecisionEngine.Edge.cs) -----
(DecisionSignalKind.EspResumed, 1) => HandleEspResumedV1(state, signal),
(DecisionSignalKind.EspTerminalFailure, 1) => HandleEspTerminalFailureV1(state, signal),
@@ -58,10 +58,6 @@ namespace AutopilotMonitor.DecisionCore.Engine
["systemRebootUtc"] = SerializeFact(state.SystemRebootUtc, FormatUtc),
["helloOutcome"] = SerializeFact(state.HelloOutcome, v => v),
["imeMatchedPatternId"] = SerializeFact(state.ImeMatchedPatternId, v => v),
["userAadSignInCompleteUtc"] = SerializeFact(state.UserAadSignInCompleteUtc, FormatUtc),
["helloResolvedPart2Utc"] = SerializeFact(state.HelloResolvedPart2Utc, FormatUtc),
["desktopArrivedPart2Utc"] = SerializeFact(state.DesktopArrivedPart2Utc, FormatUtc),
["accountSetupCompletedPart2Utc"] = SerializeFact(state.AccountSetupCompletedPart2Utc, FormatUtc),
["helloPolicyEnabled"] = SerializeFact(state.HelloPolicyEnabled, v => (object)v),
};
@@ -33,7 +33,6 @@ namespace AutopilotMonitor.DecisionCore.Engine
SharedConstants.EventTypes.DesktopArrived, // desktop_arrived
SharedConstants.EventTypes.AadPlaceholderUserDetected, // aad_placeholder_user_detected
SharedConstants.EventTypes.AadUserJoinedObserved, // aad_user_joined_observed
SharedConstants.EventTypes.UserAadSignInComplete, // user_aad_signin_complete
SharedConstants.EventTypes.HybridLoginPending, // hybrid_login_pending
SharedConstants.EventTypes.AgentShuttingDown, // agent_shutting_down
SharedConstants.EventTypes.SystemRebootDetected, // system_reboot_detected
@@ -53,12 +53,6 @@ namespace AutopilotMonitor.DecisionCore.Signals
// </para>
EnrollmentFactsObserved,
// --- Raw — WhiteGlove Part 2 (Post-Reboot User-Sign-In) ---
UserAadSignInComplete,
HelloResolvedPart2,
DesktopArrivedPart2,
AccountSetupCompletedPart2,
// --- Synthetic ---
DeadlineFired,
ClassifierVerdictIssued,
@@ -74,7 +68,6 @@ namespace AutopilotMonitor.DecisionCore.Signals
// --- Lifecycle ---
SessionStarted,
SessionAborted,
SessionRecovered,
// --- Admin-driven preemption (Plan §2.7 admin-action audit, V2 parity PR-B3) ---
AdminPreemptionDetected,
@@ -29,7 +29,7 @@ namespace AutopilotMonitor.DecisionCore.State
FiresPayload = firesPayload;
}
/// <summary>Unique deadline name within a session (e.g. "hello_safety", "part2_safety").</summary>
/// <summary>Unique deadline name within a session (e.g. "hello_safety", "esp_failure_grace").</summary>
public string Name { get; }
public DateTime DueAtUtc { get; }
@@ -4,15 +4,15 @@ namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Container for the reducer-side classifier verdict + anti-loop state. Codex follow-up #5 —
/// replaces the legacy <c>WhiteGloveSealing</c> / <c>WhiteGlovePart2Completion</c> /
/// <c>DeviceOnlyDeployment</c> <see cref="Hypothesis"/> fields on <c>DecisionState</c> with a
/// single aggregate. Scoring logic, weights and thresholds live in the classifier
/// implementations (<c>WhiteGloveSealingClassifier</c>, <c>WhiteGlovePart2CompletionClassifier</c>)
/// and are not touched by this refactor — only the verdict storage moved.
/// replaces the legacy <c>WhiteGloveSealing</c> / <c>DeviceOnlyDeployment</c>
/// <see cref="Hypothesis"/> fields on <c>DecisionState</c> with a single aggregate. Scoring
/// logic, weights and thresholds live in the classifier implementations
/// (<c>WhiteGloveSealingClassifier</c>) and are not touched by this refactor — only the
/// verdict storage moved.
/// <para>
/// <b>Invariants</b>:
/// <list type="bullet">
/// <item>All three <see cref="Hypothesis"/> values are non-null; <see cref="Empty"/> uses <see cref="Hypothesis.UnknownInstance"/>.</item>
/// <item>Both <see cref="Hypothesis"/> values are non-null; <see cref="Empty"/> uses <see cref="Hypothesis.UnknownInstance"/>.</item>
/// <item>Immutable; the <c>With…</c> methods return new instances.</item>
/// <item><see cref="DeviceOnlyDeployment"/> has no dedicated classifier — it's updated by the reducer (<c>DeviceSetupProvisioningComplete</c> handler + <c>DeviceOnlyEspDetection</c> deadline) but is modeled here for symmetry, because the WhiteGlove classifier reads it as an input.</item>
/// </list>
@@ -22,25 +22,19 @@ namespace AutopilotMonitor.DecisionCore.State
{
public static readonly ClassifierOutcomes Empty = new ClassifierOutcomes(
whiteGloveSealing: Hypothesis.UnknownInstance,
whiteGlovePart2Completion: Hypothesis.UnknownInstance,
deviceOnlyDeployment: Hypothesis.UnknownInstance);
public ClassifierOutcomes(
Hypothesis whiteGloveSealing,
Hypothesis whiteGlovePart2Completion,
Hypothesis deviceOnlyDeployment)
{
WhiteGloveSealing = whiteGloveSealing ?? throw new ArgumentNullException(nameof(whiteGloveSealing));
WhiteGlovePart2Completion = whiteGlovePart2Completion ?? throw new ArgumentNullException(nameof(whiteGlovePart2Completion));
DeviceOnlyDeployment = deviceOnlyDeployment ?? throw new ArgumentNullException(nameof(deviceOnlyDeployment));
}
/// <summary>Verdict from <c>WhiteGloveSealingClassifier</c>. Plan §2.4.</summary>
public Hypothesis WhiteGloveSealing { get; }
/// <summary>Verdict from <c>WhiteGlovePart2CompletionClassifier</c>. Plan §2.4 / §M3.4.</summary>
public Hypothesis WhiteGlovePart2Completion { get; }
/// <summary>
/// Reducer-maintained (no dedicated classifier) device-only hypothesis. Inputs into the
/// WhiteGlove sealing snapshot as <c>IsDeviceOnlyDeploymentHypothesis</c>.
@@ -49,20 +43,12 @@ namespace AutopilotMonitor.DecisionCore.State
public ClassifierOutcomes WithWhiteGloveSealing(Hypothesis value) =>
new ClassifierOutcomes(
value ?? throw new ArgumentNullException(nameof(value)),
WhiteGlovePart2Completion,
DeviceOnlyDeployment);
public ClassifierOutcomes WithWhiteGlovePart2Completion(Hypothesis value) =>
new ClassifierOutcomes(
WhiteGloveSealing,
value ?? throw new ArgumentNullException(nameof(value)),
DeviceOnlyDeployment);
public ClassifierOutcomes WithDeviceOnlyDeployment(Hypothesis value) =>
new ClassifierOutcomes(
WhiteGloveSealing,
WhiteGlovePart2Completion,
value ?? throw new ArgumentNullException(nameof(value)));
}
}
@@ -13,7 +13,7 @@ namespace AutopilotMonitor.DecisionCore.State
/// </para>
/// <para>
/// Codex follow-up #5 — the legacy 9-field hypothesis/fact mosaic
/// (<c>EnrollmentType</c>, <c>WhiteGloveSealing</c>, <c>WhiteGlovePart2Completion</c>,
/// (<c>EnrollmentType</c>, <c>WhiteGloveSealing</c>,
/// <c>DeviceOnlyDeployment</c>, <c>SkipUserEsp</c>, <c>SkipDeviceEsp</c>,
/// <c>AadJoinedWithUser</c>, <c>ShellCoreWhiteGloveSuccessSeen</c>,
/// <c>WhiteGloveSealingPatternSeen</c>) has been replaced by three structured aggregates:
@@ -47,10 +47,6 @@ namespace AutopilotMonitor.DecisionCore.State
SignalFact<DateTime>? systemRebootUtc,
SignalFact<string>? helloOutcome,
SignalFact<string>? imeMatchedPatternId,
SignalFact<DateTime>? userAadSignInCompleteUtc,
SignalFact<DateTime>? helloResolvedPart2Utc,
SignalFact<DateTime>? desktopArrivedPart2Utc,
SignalFact<DateTime>? accountSetupCompletedPart2Utc,
IReadOnlyList<ActiveDeadline> deadlines,
long lastAppliedSignalOrdinal,
int stepIndex,
@@ -85,10 +81,6 @@ namespace AutopilotMonitor.DecisionCore.State
SystemRebootUtc = systemRebootUtc;
HelloOutcome = helloOutcome;
ImeMatchedPatternId = imeMatchedPatternId;
UserAadSignInCompleteUtc = userAadSignInCompleteUtc;
HelloResolvedPart2Utc = helloResolvedPart2Utc;
DesktopArrivedPart2Utc = desktopArrivedPart2Utc;
AccountSetupCompletedPart2Utc = accountSetupCompletedPart2Utc;
Deadlines = deadlines ?? throw new ArgumentNullException(nameof(deadlines));
LastAppliedSignalOrdinal = lastAppliedSignalOrdinal;
StepIndex = stepIndex;
@@ -132,12 +124,6 @@ namespace AutopilotMonitor.DecisionCore.State
public SignalFact<string>? ImeMatchedPatternId { get; }
// --- WhiteGlove Part 2 (post-reboot user sign-in) facts ---
public SignalFact<DateTime>? UserAadSignInCompleteUtc { get; }
public SignalFact<DateTime>? HelloResolvedPart2Utc { get; }
public SignalFact<DateTime>? DesktopArrivedPart2Utc { get; }
public SignalFact<DateTime>? AccountSetupCompletedPart2Utc { get; }
public IReadOnlyList<ActiveDeadline> Deadlines { get; }
public long LastAppliedSignalOrdinal { get; }
@@ -166,7 +152,7 @@ namespace AutopilotMonitor.DecisionCore.State
public EnrollmentScenarioObservations ScenarioObservations { get; }
/// <summary>
/// Classifier verdict storage + anti-loop state (WhiteGlove sealing, WhiteGlove Part 2,
/// Classifier verdict storage + anti-loop state (WhiteGlove sealing,
/// device-only deployment). Codex follow-up #5. Never null; defaults to
/// <see cref="ClassifierOutcomes.Empty"/>.
/// </summary>
@@ -213,10 +199,6 @@ namespace AutopilotMonitor.DecisionCore.State
systemRebootUtc: null,
helloOutcome: null,
imeMatchedPatternId: null,
userAadSignInCompleteUtc: null,
helloResolvedPart2Utc: null,
desktopArrivedPart2Utc: null,
accountSetupCompletedPart2Utc: null,
deadlines: Array.Empty<ActiveDeadline>(),
lastAppliedSignalOrdinal: -1,
stepIndex: 0,
@@ -33,10 +33,6 @@ namespace AutopilotMonitor.DecisionCore.State
SystemRebootUtc = source.SystemRebootUtc;
HelloOutcome = source.HelloOutcome;
ImeMatchedPatternId = source.ImeMatchedPatternId;
UserAadSignInCompleteUtc = source.UserAadSignInCompleteUtc;
HelloResolvedPart2Utc = source.HelloResolvedPart2Utc;
DesktopArrivedPart2Utc = source.DesktopArrivedPart2Utc;
AccountSetupCompletedPart2Utc = source.AccountSetupCompletedPart2Utc;
Deadlines = new List<ActiveDeadline>(source.Deadlines);
LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal;
StepIndex = source.StepIndex;
@@ -62,10 +58,6 @@ namespace AutopilotMonitor.DecisionCore.State
public SignalFact<DateTime>? SystemRebootUtc { get; set; }
public SignalFact<string>? HelloOutcome { get; set; }
public SignalFact<string>? ImeMatchedPatternId { get; set; }
public SignalFact<DateTime>? UserAadSignInCompleteUtc { get; set; }
public SignalFact<DateTime>? HelloResolvedPart2Utc { get; set; }
public SignalFact<DateTime>? DesktopArrivedPart2Utc { get; set; }
public SignalFact<DateTime>? AccountSetupCompletedPart2Utc { get; set; }
public List<ActiveDeadline> Deadlines { get; set; }
public long LastAppliedSignalOrdinal { get; set; }
public int StepIndex { get; set; }
@@ -167,10 +159,6 @@ namespace AutopilotMonitor.DecisionCore.State
systemRebootUtc: SystemRebootUtc,
helloOutcome: HelloOutcome,
imeMatchedPatternId: ImeMatchedPatternId,
userAadSignInCompleteUtc: UserAadSignInCompleteUtc,
helloResolvedPart2Utc: HelloResolvedPart2Utc,
desktopArrivedPart2Utc: DesktopArrivedPart2Utc,
accountSetupCompletedPart2Utc: AccountSetupCompletedPart2Utc,
deadlines: Deadlines.ToArray(),
lastAppliedSignalOrdinal: LastAppliedSignalOrdinal,
stepIndex: StepIndex,
@@ -3,7 +3,7 @@ namespace AutopilotMonitor.DecisionCore.State
/// <summary>
/// Terminal outcome of a session. Plan §2.3.
/// Non-null only when <see cref="SessionStage"/> is a terminal stage
/// (<c>Completed</c>, <c>Failed</c>, or <c>WhiteGloveCompletedPart2</c>).
/// (<c>Completed</c>, <c>Failed</c>, or <c>WhiteGloveSealed</c>).
/// </summary>
public enum SessionOutcome
{
@@ -11,7 +11,6 @@ namespace AutopilotMonitor.DecisionCore.State
EnrollmentComplete,
EnrollmentFailed,
WhiteGlovePart1Sealed,
WhiteGlovePart2Complete,
Aborted,
// V2 parity PR-B3 / plan §2.7 admin-action audit:
@@ -37,10 +37,6 @@ namespace AutopilotMonitor.DecisionCore.State
WhiteGloveCandidate,
WhiteGloveSealed,
// WhiteGlove Part 2 (post-reboot user sign-in)
WhiteGloveAwaitingUserSignIn,
WhiteGloveCompletedPart2,
// Terminal
Completed,
Failed,
@@ -11,10 +11,11 @@ namespace AutopilotMonitor.DecisionCore.State
/// DiagnosticsPackageService) run, and no further signals are expected.
/// <para>
/// <see cref="SessionStage.WhiteGloveSealed"/> is terminal for Part 1 only — the agent
/// exits but the session is paused (Part 2 resumes after reboot). The distinction
/// matters for CleanupService: Part-1 exit must NOT self-destruct; Part-2 exit
/// (<see cref="SessionStage.WhiteGloveCompletedPart2"/>) and the classic terminals
/// (<see cref="SessionStage.Completed"/> / <see cref="SessionStage.Failed"/>) do.
/// exits but the session is paused. After the reseal-reboot, the orchestrator
/// archives the Part-1 state folder and runs Part 2 as a fresh Classic enrollment
/// (Plan §4 PR-A). The distinction still matters for CleanupService: Part-1 exit
/// must NOT self-destruct; the classic terminals (<see cref="SessionStage.Completed"/>
/// / <see cref="SessionStage.Failed"/>) do.
/// </para>
/// </summary>
public static bool IsTerminal(this SessionStage stage)
@@ -24,7 +25,6 @@ namespace AutopilotMonitor.DecisionCore.State
case SessionStage.Completed:
case SessionStage.Failed:
case SessionStage.WhiteGloveSealed:
case SessionStage.WhiteGloveCompletedPart2:
return true;
default:
return false;
@@ -247,8 +247,7 @@ namespace AutopilotMonitor.Shared
// emits no timeline effect), so the AadJoinWatcherAdapter dual-emits them as
// informational events too. Without this the FailureSnapshotBuilder cannot tell that
// the real AAD user was ever observed (Codex review 2026-05-01).
public const string AadUserJoinedObserved = "aad_user_joined_observed"; // Real user observed in JoinInfo (Part 1 path) — synchronous startup-read OR mid-session watcher fire
public const string UserAadSignInComplete = "user_aad_signin_complete"; // Real user signs in post-reboot (Part 2 path)
public const string AadUserJoinedObserved = "aad_user_joined_observed"; // Real user observed in JoinInfo — synchronous startup-read OR mid-session watcher fire
}
// -----------------------------------------------------------------------
@@ -3,7 +3,7 @@
import { useMemo } from "react";
import { EnrollmentEvent, Session } from "@/types";
import { V1_PHASE_NAMES, V2_PHASE_NAMES, V1_PHASE_ORDER, V2_PHASE_ORDER } from "../utils/phaseConstants";
import { groupEventsByPhase } from "../utils/eventHelpers";
import { computeWhiteGloveSplitSequence, groupEventsByPhase } from "../utils/eventHelpers";
interface PhaseGrouping {
eventsByPhase: Record<string, EnrollmentEvent[]>;
@@ -147,34 +147,7 @@ export function useSessionDerivedData(
const whiteGloveSplitSequence = useMemo(() => {
if (!isWhiteGloveSession) return -1;
const wgEvent = events.find(e => e.eventType === "whiteglove_complete");
const resumedEvent = events.find(e => e.eventType === "whiteglove_resumed");
// whiteglove_resumed is the definitive Part 2 marker (emitted at Part 2 agent startup).
// This handles both normal ordering and the race condition where Windows writes the
// whiteglove_complete event (Event 62407) after the Part 2 reboot.
if (resumedEvent) {
return resumedEvent.sequence - 1;
}
// Fallback for old agents without whiteglove_resumed:
// first agent_started AFTER whiteglove_complete = Part 2 start.
// NOTE: We do NOT use the "last agent_started before whiteglove_complete" heuristic
// because reboots within Part 1 would be misidentified as Part 2.
if (wgEvent) {
const nextStart = events.find(e =>
e.eventType === "agent_started" && e.sequence > wgEvent.sequence
);
if (nextStart) return nextStart.sequence - 1;
// Pre-provisioning only (no Part 2 yet). Include cleanup events
// (local_admin_analysis, agent_shutdown) that follow whiteglove_complete.
const shutdownAfterWg = events.find(e =>
e.eventType === "agent_shutdown" && e.sequence > wgEvent.sequence
);
return shutdownAfterWg?.sequence ?? wgEvent.sequence;
}
return -1;
return computeWhiteGloveSplitSequence(events);
}, [events, isWhiteGloveSession]);
// For WhiteGlove sessions: split filtered events into pre-provisioning and user-enrollment parts.
@@ -1,5 +1,41 @@
import { EnrollmentEvent } from "@/types";
// Pure helper — finds the WhiteGlove "Part 1 ends, Part 2 begins" split sequence.
//
// Returns the sequence number AT WHICH (inclusive) Part 1 ends; events with sequence
// strictly greater belong to Part 2. Returns -1 when no split point can be derived
// (Part 1 still in progress, or session is not WhiteGlove at all).
//
// Resolution order — mirrors the agent-side V1-symmetric resume mechanic (PR-A):
// 1. `whiteglove_resumed` is the definitive Part 2 marker (emitted by the orchestrator
// after Archive-and-Reset detects WhiteGloveSealed snapshot). Returns its sequence - 1.
// 2. Fallback for older agents that never emit `whiteglove_resumed`: the first
// `agent_started` AFTER `whiteglove_complete` is the Part 2 boot. Returns its sequence - 1.
// 3. Pre-provisioning only (no Part 2 yet): the last cleanup event after `whiteglove_complete`
// (`agent_shutdown`) ends Part 1; otherwise the `whiteglove_complete` sequence itself.
// 4. Nothing: -1.
export function computeWhiteGloveSplitSequence(events: EnrollmentEvent[]): number {
const wgEvent = events.find(e => e.eventType === "whiteglove_complete");
const resumedEvent = events.find(e => e.eventType === "whiteglove_resumed");
if (resumedEvent) {
return resumedEvent.sequence - 1;
}
if (wgEvent) {
const nextStart = events.find(e =>
e.eventType === "agent_started" && e.sequence > wgEvent.sequence
);
if (nextStart) return nextStart.sequence - 1;
const shutdownAfterWg = events.find(e =>
e.eventType === "agent_shutdown" && e.sequence > wgEvent.sequence
);
return shutdownAfterWg?.sequence ?? wgEvent.sequence;
}
return -1;
}
// Pure helper — groups a flat event list into phase buckets.
// Extracted from the useMemo so it can be called multiple times for WhiteGlove split timelines.
//
@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { computeWhiteGloveSplitSequence } from "../../app/sessions/[sessionId]/utils/eventHelpers";
import type { EnrollmentEvent } from "@/types";
// Fixture builder — keeps the tests focused on sequence/eventType only.
function ev(seq: number, eventType: string, phase = 0): EnrollmentEvent {
return {
eventId: `e${seq}`,
sessionId: "s1",
timestamp: new Date(2026, 4, 4, 10, 0, seq).toISOString(),
eventType,
severity: "Info",
source: "Agent",
phase,
message: eventType,
sequence: seq,
};
}
describe("computeWhiteGloveSplitSequence", () => {
it("returns -1 for a session with no WhiteGlove markers (single Device-Enrollment block)", () => {
const events: EnrollmentEvent[] = [
ev(1, "agent_started"),
ev(2, "esp_phase_changed"),
ev(3, "enrollment_complete"),
];
expect(computeWhiteGloveSplitSequence(events)).toBe(-1);
});
it("returns whiteglove_resumed.sequence-1 when the agent emitted the V1-symmetric Part 2 marker", () => {
// PR-A: orchestrator archives state and emits whiteglove_resumed after Part-1 reseal.
const events: EnrollmentEvent[] = [
ev(1, "agent_started"),
ev(2, "whiteglove_complete"),
ev(3, "agent_shutdown"),
ev(4, "agent_started"), // Part 2 boot
ev(5, "whiteglove_resumed"), // definitive Part 2 marker (post Archive-and-Reset)
ev(6, "esp_phase_changed"),
ev(7, "enrollment_complete"),
];
expect(computeWhiteGloveSplitSequence(events)).toBe(4); // 5 - 1
});
it("falls back to first agent_started after whiteglove_complete when no whiteglove_resumed (older agents)", () => {
const events: EnrollmentEvent[] = [
ev(1, "agent_started"),
ev(2, "whiteglove_complete"),
ev(3, "agent_shutdown"),
ev(4, "agent_started"), // Part 2 boot — split point
ev(5, "esp_phase_changed"),
ev(6, "enrollment_complete"),
];
expect(computeWhiteGloveSplitSequence(events)).toBe(3); // 4 - 1
});
it("returns the agent_shutdown sequence after whiteglove_complete when only Part 1 has finished (Pending)", () => {
const events: EnrollmentEvent[] = [
ev(1, "agent_started"),
ev(2, "whiteglove_complete"),
ev(3, "agent_shutdown"),
];
expect(computeWhiteGloveSplitSequence(events)).toBe(3);
});
it("returns whiteglove_complete.sequence when nothing follows it (Part 1 still wrapping up)", () => {
const events: EnrollmentEvent[] = [
ev(1, "agent_started"),
ev(2, "whiteglove_complete"),
];
expect(computeWhiteGloveSplitSequence(events)).toBe(2);
});
it("prefers whiteglove_resumed over the agent_started fallback even if both are present", () => {
// Race: Windows writes the WhiteGlove success event after the Part-2 reboot, so the
// event list contains both signals. The resumed marker is authoritative.
const events: EnrollmentEvent[] = [
ev(1, "agent_started"),
ev(2, "agent_shutdown"),
ev(3, "agent_started"), // Part 2 boot — would be the fallback split
ev(4, "whiteglove_resumed"), // primary trigger wins
ev(5, "whiteglove_complete"), // arrived late from Windows
ev(6, "enrollment_complete"),
];
expect(computeWhiteGloveSplitSequence(events)).toBe(3); // 4 - 1, NOT 2
});
});
@@ -303,32 +303,28 @@ export function generateUiExport(
// (shown when no user-enrollment events
// exist and session status is "Pending")
//
// SPLIT POINT CALCULATION (mirrors page.tsx):
// Normal: first agent_started AFTER whiteglove_complete → split = that event's sequence - 1
// Fallback: if whiteglove_complete arrived after Part 2 boot, the LAST agent_started
// before whiteglove_complete is the Part 2 start → split = that event's sequence - 1
// Single: only pre-prov part present → split = whiteglove_complete.sequence
// SPLIT POINT CALCULATION (mirrors useSessionDerivedData.ts via the agent-side
// V1-symmetric resume mechanic, PR-A):
// Primary: `whiteglove_resumed` is the definitive Part 2 marker (emitted by the
// orchestrator after Archive-and-Reset detects WhiteGloveSealed snapshot)
// → split = that event's sequence - 1
// Fallback: older agents — first agent_started AFTER whiteglove_complete is Part 2 boot
// → split = that event's sequence - 1
// Single: only pre-prov part present
// → split = whiteglove_complete.sequence
//
// Events are assigned to parts purely by sequence number (no whiteglove_complete override).
const wgCompleteEv = sorted.find(e => e.eventType === "whiteglove_complete");
const wgSeq = wgCompleteEv?.sequence ?? sorted[sorted.length - 1]?.sequence ?? 0;
const agentStartedEvs = sorted.filter(e => e.eventType === "agent_started");
const afterWg = agentStartedEvs.filter(e => e.sequence > wgSeq);
const resumedEv = sorted.find(e => e.eventType === "whiteglove_resumed");
let splitSeq: number;
if (afterWg.length > 0) {
splitSeq = afterWg[0].sequence - 1;
if (resumedEv) {
splitSeq = resumedEv.sequence - 1;
} else {
const beforeWg = agentStartedEvs.filter(e => e.sequence < wgSeq);
if (beforeWg.length >= 2) {
// Race condition: whiteglove_complete arrived after Part 2 boot.
// The LAST agent_started before wgEvent is the Part 2 start.
splitSeq = beforeWg[beforeWg.length - 1].sequence - 1;
} else {
splitSeq = wgSeq;
}
const afterWg = sorted.filter(e => e.eventType === "agent_started" && e.sequence > wgSeq);
splitSeq = afterWg.length > 0 ? afterWg[0].sequence - 1 : wgSeq;
}
// Events are assigned purely by sequence number — no special-casing for whiteglove_complete.
+1 -2
View File
@@ -21,9 +21,8 @@ A fixture may only land here when **all** of the following hold:
- `selfdeploying-*`
- `whiteglove-inline-*`
- `whiteglove-signal-correlated-*`
- `whiteglove-part2-happy-*`
- `whiteglove-part2-stuck-*`
- `whiteglove-false-positive-*`
- `whiteglove-antiloop-*`
- `hybrid-reboot-*`
- `esp-terminal-failure-*`
- `hello-timeout-*`
@@ -1,16 +0,0 @@
# Scenario: WhiteGlove Part 2 Happy (Post-Reboot user sign-in)
# Session picks up from a WhiteGloveSealed Part 1 state (simulated here by driving
# ShellCoreWhiteGloveSuccess first, which confirms Part 1 inline). SessionRecovered
# after a reboot transitions the session to WhiteGloveAwaitingUserSignIn and arms the
# 24h Part-2 safety deadline. All four Part-2 user signals arrive within minutes ->
# Part2 completion classifier returns Confirmed -> Stage=WhiteGloveCompletedPart2 +
# Outcome=WhiteGlovePart2Complete, whiteglove_part2_complete event emitted.
# Plan: §4 M3.4.
{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-part2-happy:started","Summary":"Session start anchor"}}
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}}
{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"WhiteGloveShellCoreSuccess","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:02:00Z","SourceOrigin":"ShellCoreTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62407:recordId:310","Summary":"Shell Core white-glove success"}}
{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"SessionRecovered","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:07:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-part2-happy:recovered","Summary":"Agent started after reboot; Part 1 sealed state persisted"}}
{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"UserAadSignInComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:10:00Z","SourceOrigin":"AadJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"aad-join-watcher-v1","Summary":"AAD user sign-in complete","DerivationInputs":{"user":"user-anon-0015"}}}
{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"HelloResolvedPart2","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:11:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"hello-event-recordId:211","Summary":"Hello for Business enrolled (Part 2)"},"Payload":{"outcome":"Success"}}
{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"DesktopArrivedPart2","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:12:00Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-detector-v1","Summary":"Explorer.exe running as real user (Part 2)","DerivationInputs":{"user":"user-anon-0015","explorerPid":"4300"}}}
{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"AccountSetupCompletedPart2","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:13:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"account-setup-complete-detector-v1","Summary":"Account setup phase marked complete (Part 2)","DerivationInputs":{"registryKey":"HKLM\\...EspStatus\\AccountSetup","rawValue":"Complete"}}}
@@ -1,11 +0,0 @@
# Scenario: WhiteGlove Part 2 Stuck (user never signs in; 24h safety deadline fires)
# Session seals Part 1, reboots, and waits for the user — who never signs in. The
# 24h WhiteGlovePart2Safety deadline fires -> Stage=Failed, Outcome=EnrollmentFailed,
# Part2Completion hypothesis Rejected with reason "part2_user_absent",
# enrollment_failed event emitted with reason="part2_user_absent".
# Plan: §4 M3.4.
{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-part2-stuck:started","Summary":"Session start anchor"}}
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T09:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}}
{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"WhiteGloveShellCoreSuccess","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T09:02:00Z","SourceOrigin":"ShellCoreTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62407:recordId:320","Summary":"Shell Core white-glove success"}}
{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"SessionRecovered","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T09:07:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-part2-stuck:recovered","Summary":"Agent started after reboot"}}
{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T09:07:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:whiteglove_part2_safety:fired","Summary":"Part 2 safety deadline fired — no user sign-in within 24 h"},"Payload":{"deadline":"whiteglove_part2_safety"}}