From d16b60f14cf6cbe65a54d13aee3d5e943209b912 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 4 May 2026 18:36:37 +0200 Subject: [PATCH] feat(v2): WG Part-2 architectural cleanup + install-mode handoff fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-/, 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) --- .../Install-AutopilotMonitor-v2-dev.ps1 | 14 +- .../Bootstrap/Install-AutopilotMonitor-v2.ps1 | 20 +- .../EnrollmentOrchestratorFixture.cs | 3 +- .../Integration/ScenarioTests.cs | 49 +-- ...DecisionStepProcessorTerminalStageTests.cs | 1 - ...nrollmentOrchestratorPart2EmissionTests.cs | 177 +++++++++++ .../Orchestration/RecoveryPathTests.cs | 95 ++++-- .../Orchestration/StateArchiverTests.cs | 190 ++++++++++++ .../Program/InstallModeTaskXmlTests.cs | 102 ++++++ .../Program/ProgramGuardsTests.cs | 37 +++ .../AadJoinWatcherAdapterTests.cs | 20 -- .../DesktopArrivalDetectorAdapterTests.cs | 17 - ...AndHelloTrackerAdapterHelloOutcomeTests.cs | 4 +- .../EspAndHelloTrackerAdapterTests.cs | 12 - .../HelloTrackerAdapterTests.cs | 12 - ...lineEmitterDecisionStateEnrichmentTests.cs | 1 - .../EnrollmentTerminationHandlerTests.cs | 14 +- .../Termination/FinalStatusBuilderTests.cs | 2 - .../Orchestration/EnrollmentOrchestrator.cs | 131 +++++--- .../Orchestration/StateArchiver.cs | 112 +++++++ .../SignalAdapters/AadJoinWatcherAdapter.cs | 34 +- .../DesktopArrivalDetectorAdapter.cs | 17 +- .../EspAndHelloTrackerAdapter.cs | 10 +- .../SignalAdapters/HelloTrackerAdapter.cs | 12 +- .../EnrollmentTerminationHandler.cs | 25 +- .../Termination/FinalStatusBuilder.cs | 12 +- .../AutopilotMonitor.Agent.V2.csproj | 7 + .../Program.InstallMode.cs | 188 +++++++++-- .../Runtime/AgentRuntimeHost.cs | 69 +++-- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../DecisionAuditTrailTests.cs | 124 +------- .../DecisionStateSnapshotBuilderTests.cs | 15 +- .../LifecycleAnchorEventTypesTests.cs | 9 +- .../ReducerFailSafeTests.cs | 2 +- .../ReducerTimelineEffectTests.cs | 33 +- .../Scenarios/EdgeScenarioTests.cs | 10 +- .../Scenarios/WhiteGlovePart2ScenarioTests.cs | 91 ------ .../SmokeTests.cs | 1 - .../State/ClassifierOutcomesTests.cs | 6 +- .../State/SessionStageExtensionsTests.cs | 3 - .../OutcomeMapperTests.cs | 3 +- .../Models/OutcomeMapper.cs | 9 +- .../DecisionGraphBuilderTests.cs | 1 - .../TelemetryPayloadParserTests.cs | 3 +- .../Ingest/TelemetryPayloadParser.cs | 1 - .../Services/DecisionGraphBuilder.cs | 2 - .../Classifiers/ClassifierVerdictLookup.cs | 1 - .../Classifiers/IClassifier.cs | 5 +- .../WhiteGlovePart2CompletionClassifier.cs | 66 ---- .../WhiteGlovePart2CompletionSnapshot.cs | 58 ---- .../Engine/DeadlineNames.cs | 5 +- .../Engine/DecisionAuditTrailBuilder.cs | 26 -- .../Engine/DecisionEngine.Shared.cs | 36 +-- .../Engine/DecisionEngine.WhiteGlove.cs | 9 +- .../Engine/DecisionEngine.WhiteGlovePart2.cs | 292 ------------------ .../Engine/DecisionEngine.cs | 10 +- .../Engine/DecisionStateSnapshotBuilder.cs | 4 - .../Engine/LifecycleAnchorEventTypes.cs | 1 - .../Signals/DecisionSignalKind.cs | 7 - .../State/ActiveDeadline.cs | 2 +- .../State/ClassifierOutcomes.cs | 26 +- .../State/DecisionState.cs | 22 +- .../State/DecisionStateBuilder.cs | 12 - .../State/SessionOutcome.cs | 3 +- .../State/SessionStage.cs | 4 - .../State/SessionStageExtensions.cs | 10 +- .../AutopilotMonitor.Shared/Constants.cs | 3 +- .../hooks/useSessionDerivedData.ts | 31 +- .../[sessionId]/utils/eventHelpers.ts | 36 +++ .../lib/__tests__/whiteGloveSplit.test.ts | 86 ++++++ .../utils/sessionExportUtils.ts | 32 +- tests/fixtures/enrollment-sessions/README.md | 3 +- .../whiteglove-part2-happy-v1.jsonl | 16 - .../whiteglove-part2-stuck-v1.jsonl | 11 - 75 files changed, 1277 insertions(+), 1244 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorPart2EmissionTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/StateArchiverTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/InstallModeTaskXmlTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/StateArchiver.cs delete mode 100644 src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/WhiteGlovePart2ScenarioTests.cs delete mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGlovePart2CompletionClassifier.cs delete mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGlovePart2CompletionSnapshot.cs delete mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/whiteGloveSplit.test.ts delete mode 100644 tests/fixtures/enrollment-sessions/whiteglove-part2-happy-v1.jsonl delete mode 100644 tests/fixtures/enrollment-sessions/whiteglove-part2-stuck-v1.jsonl 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 @@ + +