diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1
index 513225f7..056c8acb 100644
--- a/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1
+++ b/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1
@@ -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) ====="
diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1
index 0653b5b4..e0aa8fe3 100644
--- a/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1
+++ b/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1
@@ -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 ====="
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/EnrollmentOrchestratorFixture.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/EnrollmentOrchestratorFixture.cs
index 751e842e..ca64def6 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/EnrollmentOrchestratorFixture.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/EnrollmentOrchestratorFixture.cs
@@ -22,7 +22,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
///
/// Drives the real pipeline: DecisionEngine + DecisionStepProcessor + SignalIngress +
/// EffectRunner + Persistence (SignalLog/Journal/Snapshot) + TelemetryTransport + production
- /// Classifiers (WhiteGloveSealingClassifier + WhiteGlovePart2CompletionClassifier).
+ /// Classifiers (WhiteGloveSealingClassifier).
///
///
/// System-boundary fakes only: HTTP upload (),
@@ -64,7 +64,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
var classifiers = new List
{
new WhiteGloveSealingClassifier(),
- new WhiteGlovePart2CompletionClassifier(),
};
Orchestrator = new EnrollmentOrchestrator(
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ScenarioTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ScenarioTests.cs
index 5cfb2d5f..d7e46fd3 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ScenarioTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ScenarioTests.cs
@@ -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
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs
index 2bfa84cd..7fa777ae 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs
@@ -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();
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorPart2EmissionTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorPart2EmissionTests.cs
new file mode 100644
index 00000000..62b5937f
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorPart2EmissionTests.cs
@@ -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
+{
+ ///
+ /// PR-A acceptance: emits the
+ /// whiteglove_resumed lifecycle event exactly once when Start() detects a
+ /// persisted snapshot, and emits nothing
+ /// on a fresh first boot. The post is positioned AFTER the onIngressReady
+ /// hook (so caller-side agent_started lands first, V1-symmetric ordering)
+ /// and BEFORE collector hosts start (so the resumed-marker precedes any collector
+ /// telemetry).
+ ///
+ [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)!;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/RecoveryPathTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/RecoveryPathTests.cs
index 995139f8..e03c1ac7 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/RecoveryPathTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/RecoveryPathTests.cs
@@ -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-/` 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.
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/StateArchiverTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/StateArchiverTests.cs
new file mode 100644
index 00000000..a4eb189b
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/StateArchiverTests.cs
@@ -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(() =>
+ StateArchiver.ArchiveStateFolder(null!, "x", () => At, MakeLogger(tmp)));
+ }
+
+ [Fact]
+ public void Archive_throws_on_null_clock()
+ {
+ using var tmp = new TempDirectory();
+ Assert.Throws(() =>
+ StateArchiver.ArchiveStateFolder(tmp.Path, "x", null!, MakeLogger(tmp)));
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/InstallModeTaskXmlTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/InstallModeTaskXmlTests.cs
new file mode 100644
index 00000000..b5dbf097
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/InstallModeTaskXmlTests.cs
@@ -0,0 +1,102 @@
+using System.Xml;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
+{
+ ///
+ /// Pins the hardened Task Scheduler XML produced by the V2 install mode. The CLI
+ /// equivalent (schtasks /Create /SC ONSTART /RU SYSTEM /RL HIGHEST) 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.
+ ///
+ 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("false", xml);
+ Assert.Contains("false", 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("S-1-5-18", xml);
+ Assert.Contains("HighestAvailable", xml);
+ Assert.Contains("", 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("PT0S", 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("true", xml);
+ Assert.Contains("true", 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\AutoMonitor\Agent.V2.exe";
+
+ var xml = AutopilotMonitor.Agent.V2.Program.BuildScheduledTaskXml(trickyExe);
+
+ Assert.DoesNotContain(trickyExe, xml);
+ Assert.Contains("Program Files & Co", xml);
+ Assert.Contains("Auto<pilot>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(
+ () => AutopilotMonitor.Agent.V2.Program.BuildScheduledTaskXml(string.Empty));
+ Assert.Throws(
+ () => AutopilotMonitor.Agent.V2.Program.BuildScheduledTaskXml(null!));
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs
index 0250c9ff..840d4ec4 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs
@@ -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()
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs
index 1068c134..7612c9db 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs
@@ -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()
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/DesktopArrivalDetectorAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/DesktopArrivalDetectorAdapterTests.cs
index ea00bb4a..dffe72b8 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/DesktopArrivalDetectorAdapterTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/DesktopArrivalDetectorAdapterTests.cs
@@ -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()
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterHelloOutcomeTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterHelloOutcomeTests.cs
index dc616a0b..67225c58 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterHelloOutcomeTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterHelloOutcomeTests.cs
@@ -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
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterTests.cs
index 38efdf14..c339e2f5 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterTests.cs
@@ -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()
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/HelloTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/HelloTrackerAdapterTests.cs
index 4271b321..44416260 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/HelloTrackerAdapterTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/HelloTrackerAdapterTests.cs
@@ -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")]
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventTimelineEmitterDecisionStateEnrichmentTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventTimelineEmitterDecisionStateEnrichmentTests.cs
index f018057c..a308d7ed 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventTimelineEmitterDecisionStateEnrichmentTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventTimelineEmitterDecisionStateEnrichmentTests.cs
@@ -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 },
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs
index a4053311..60fd0b22 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs
@@ -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)));
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/FinalStatusBuilderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/FinalStatusBuilderTests.cs
index 3828c10b..e6f87752 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/FinalStatusBuilderTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/FinalStatusBuilderTests.cs
@@ -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
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs
index 1c281d67..dbae9a09 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs
@@ -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;
///
- /// true wenn der Start mit einem persistierten WhiteGloveSealed-State
- /// gelaufen ist und die Part-2-Bridge via SessionRecovered-Signal gezündet wurde.
- /// Plan §2.7 Sonderfall 1.
+ /// true when Start() detected a persisted
+ /// snapshot, archived the prior state folder to state/.part1-<ts>/, and
+ /// began a fresh Classic enrollment flow. V1-symmetric to
+ /// MonitoringService._isWhiteGlovePart2. Plan §2.7 Sonderfall 1.
///
- public bool IsWhiteGlovePart2Resume => _isWhiteGlovePart2Resume;
+ public bool IsWhiteGlovePart2 => _isWhiteGlovePart2;
///
/// true 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
+ // .part1-<ts>/ bucket BEFORE the writers below open them for
+ // scanning. event-sequence.json is intentionally preserved so the
+ // session-wide event sequence stays monotonic across the resume boundary
+ // (the backend orders events by (SessionId, Sequence) and the
+ // Web UI splits on resumed.sequence - 1). The orchestrator then
+ // runs through the normal recovery branches as a fresh first-boot
+ // (branch c1), re-emits a whiteglove_resumed 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 whiteglove_resumed).
+ 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(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(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,
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/StateArchiver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/StateArchiver.cs
new file mode 100644
index 00000000..86e6c2a7
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/StateArchiver.cs
@@ -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
+{
+ ///
+ /// Archives the reducer-state segment files (snapshot.json, signal-log.jsonl,
+ /// journal.jsonl) into a timestamped .part1-<ts>/ subfolder so the
+ /// WhiteGlove Part-2 resume path can start from a fresh decision-engine state
+ /// while preserving the Part-1 segments for diagnostics.
+ ///
+ /// Not archived: event-sequence.json. Part 2 reuses the same SessionId
+ /// as Part 1, and the backend stores events under an (SessionId, Sequence)
+ /// ordering that the Inspector + the Web WhiteGlove split-detection
+ /// (useSessionDerivedData.ts: splitSequence = resumed.sequence - 1)
+ /// 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.
+ ///
+ ///
+ /// Used exclusively by when it detects a
+ /// persisted snapshot in SessionStage.WhiteGloveSealed. The archive-and-reset
+ /// pattern is V1-symmetric to SessionPersistence.ClearWhiteGloveComplete:
+ /// 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).
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ public static class StateArchiver
+ {
+ ///
+ /// Reducer-state segment files moved into the .part1-<ts>/ bucket on
+ /// archive. event-sequence.json is intentionally NOT in this list — see the
+ /// class summary.
+ ///
+ private static readonly string[] KnownSegmentFiles = new[]
+ {
+ "snapshot.json",
+ "signal-log.jsonl",
+ "journal.jsonl",
+ };
+
+ ///
+ /// Move all existing state-segment files in
+ /// to a timestamped .part1-<utc-timestamp>/ subfolder. No-op when
+ /// the state directory does not exist; no-op (without creating the bucket) when
+ /// none of the known segment files exist.
+ ///
+ /// The state directory holding the four segment files.
+ /// Free-text reason recorded as reason.txt alongside the archived files.
+ /// Clock for the timestamped bucket name; injected for deterministic tests.
+ /// Optional logger for INFO-level breadcrumb after a successful archive.
+ /// The full path of the created bucket, or null if there was nothing to archive.
+ public static string? ArchiveStateFolder(
+ string stateDirectory,
+ string reason,
+ Func 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- 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;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs
index b2cdcb64..9d4ef996 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs
@@ -14,8 +14,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
/// Adapter for :
///
/// - →
- /// (Part 1) or
- /// (Part 2). Plan §2.1a / §2.2.
+ /// . Plan §2.1a / §2.2.
/// - → emits a
/// aad_placeholder_user_detected 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(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(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,
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/DesktopArrivalDetectorAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/DesktopArrivalDetectorAdapter.cs
index 795f78d7..78403d7e 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/DesktopArrivalDetectorAdapter.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/DesktopArrivalDetectorAdapter.cs
@@ -10,8 +10,7 @@ using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
///
- /// Adapter for →
- /// (or in post-reboot WhiteGlove-Part-2 mode).
+ /// Adapter for → .
/// Plan §2.1a / §2.2.
///
/// 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(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(
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs
index 23b3b63b..fc694b84 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs
@@ -21,7 +21,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
///
/// Signal-Mapping identisch zu den Sub-Tracker-Adaptern (Single-Tracker-Wiring-Szenarien):
///
- /// - HelloCompleted → /
+ /// - HelloCompleted →
/// - FinalizingSetupPhaseTriggered → (phase=FinalizingSetup)
/// - WhiteGloveCompleted →
/// - EspFailureDetected → (merged aus ShellCore + Provisioning)
@@ -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(
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/HelloTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/HelloTrackerAdapter.cs
index 7dd155ba..1e384f02 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/HelloTrackerAdapter.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/HelloTrackerAdapter.cs
@@ -9,8 +9,8 @@ using AutopilotMonitor.DecisionCore.Signals;
namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
///
- /// Adapter for →
- /// (Part 1) or (Part 2). Plan §2.1a / §2.2.
+ /// Adapter for → .
+ /// Plan §2.1a / §2.2.
///
/// Fires genau einmal, wenn der Tracker Hello-Abschluss feststellt. Payload enthält den
/// HelloOutcome-String (completed | skipped | timeout | not_configured | wizard_not_started),
@@ -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(
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs
index 0b186871..bb6aba97 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs
@@ -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
+ // Completed/Failed 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
+ // () => orchestrator.IsWhiteGlovePart2; null in tests = legacy (always
+ // treats as Part-1/null).
+ private readonly Func _isWhiteGlovePart2Accessor;
private int _handled;
@@ -132,7 +141,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
Func ignoredCountAccessor = null,
Func pendingItemCountAccessor = null,
Action writeCleanExitMarker = null,
- Func ingressPendingSignalCountAccessor = null)
+ Func ingressPendingSignalCountAccessor = null,
+ Func 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;
}
///
@@ -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
+ // 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 Completed
+ // /Failed; the orchestrator preserves the Part-2 origin via the
+ // in-memory _isWhiteGlovePart2 flag wired here. V1-symmetric:
+ // runShutdownAnalyzers(_isWhiteGlovePart2 ? 2 : null).
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;
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatusBuilder.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatusBuilder.cs
index 7502b205..5bf2d362 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatusBuilder.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatusBuilder.cs
@@ -59,7 +59,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
///
/// Schema 2 — derives a human-readable explanation rendered as the dialog's failure
/// banner. Returns null 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).
///
/// Source priority:
///
@@ -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;
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj b/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj
index 4825c809..e35d8cd0 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj
+++ b/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj
@@ -43,6 +43,13 @@
+
+