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