Addresses all 4 findings from the Codex review on PR #48. 564 V2.Core + 111 DecisionCore tests green (up from 559 + 109; 7 new regression tests). Finding 1 (P1) — terminate_session drain deadlock TelemetryUploadOrchestrator raised ServerResponseReceived synchronously while holding _drainGuard. The terminate callback then invoked orchestrator.Stop() on the main thread, which tried a terminal DrainAllAsync and blocked on the same guard — timing out before the final agent_shutting_down event could be uploaded. Fix: buffer signal-bearing UploadResults during drain, release the guard, then raise events. Also added a defensive re-entry short-circuit in onTerminateRequested (skips if shutdown.IsSet) for the pathological case of a nested drain producing a different ActionId. Files: TelemetryUploadOrchestrator.cs, Program.cs Test: Handler_may_invoke_reentrant_drain_without_deadlock Finding 2 (P1) — structured Event.Data was degenerating to type-name strings InformationalEventPost.StringifyData fell through to kv.Value.ToString() for nested containers, so e.g. network_adapters.adapters became the literal string "System.Collections.Generic.List\`1[...]" on the wire. Fix (redesigned after review): typed-sidecar through the bus, mirroring the existing DecisionEffect.ClassifierSnapshot pattern. DecisionSignal.TypedPayload (object?) ← new DecisionEffect.TypedPayload (object?) ← new ISignalIngressSink.Post(..., typedPayload) EventTimelineEmitter.Emit(..., typedPayload) uses it as Data verbatim Live path does ZERO serialization for structured event data — the Dictionary<string,object> flows object-identity-preserving from collector to TelemetryEventEmitter, where JsonConvert.SerializeObject runs once at the wire boundary. Persistence writes TypedPayload to JSONL once at the disk boundary (SignalSerializer); replay restores it as JObject/JArray tokens that re-serialize identically. Files: DecisionSignal.cs, DecisionEffect.cs, DecisionEngine.Shared.cs, SignalSerializer.cs, ISignalIngressSink.cs, SignalIngress.cs, EnrollmentOrchestrator.cs (LazyIngressSinkRelay), IEventTimelineEmitter.cs, EventTimelineEmitter.cs, EffectRunner.cs, InformationalEventPost.cs, + FakeSignalIngressSink, FakeEventTimelineEmitter test doubles Tests: TypedPayload_dictionary_becomes_EnrollmentEvent_Data_verbatim_on_wire, TypedPayload_missing_falls_back_to_string_parameter_reconstruction, Emit_EnrollmentEvent_overload_forwards_Data_as_typed_payload_by_reference, Emit_EnrollmentEvent_overload_passes_DateTime_as_live_object_through_typed_payload, SignalSerializer_roundtrip_preserves_TypedPayload_structure, SignalSerializer_roundtrip_null_TypedPayload_stays_null Finding 3 (P2) — ImmediateUpload=false silently flipped to true Helper wrote the ImmediateUpload payload key only when true; the emitter defaulted "missing key → true". Net effect: a caller passing immediateUpload:false (e.g. performance_snapshot, agent_metrics_snapshot) had their intent silently overridden; every snapshot was flushed immediately. Fix: helper now writes the key both-ways ("true" or "false"). Emitter default stays true because reducer-synthesised terminal events (whiteglove_complete, enrollment_complete, enrollment_failed) construct effect parameters with just eventType and rely on that default for immediate flush — documented inline. Files: InformationalEventPost.cs, EventTimelineEmitter.cs (doc-only) Tests: ImmediateUpload_false_writes_explicit_false_key_to_payload, Emit_optional_payload_keys_are_set_only_when_caller_provides_them (updated) Finding 4 (P2) — PeriodicCollectorLifecycleHost saw only its own posts IdleActivityObservingIngressSink wrapped the ingress per-host, so _lastRealEventTimeUtc was only updated by signals routed through this host's own _post. Every other component (Program.cs lifecycle, IME, ESP, DeviceInfo, Analyzer, Gather, StallProbe, Network, AAD, Desktop, etc.) constructs its own InformationalEventPost against the raw shared ingress and bypassed the wrapper — and periodic events posted by the host itself were correctly filtered out. Result: the idle clock never advanced past session start; collectors stopped after idle timeout regardless of live enrollment activity. Fix: moved observation into SignalIngress itself as an event SignalPosted, fired after every successful enqueue (fast path + slow path). PeriodicCollectorLifecycleHost subscribes via the concrete SignalIngress type at Start, unsubscribes at Stop. The observer now sees ALL signal kinds (ESP, IME, Classifier, DeadlineFired, etc.), not just InformationalEvents. Deleted the obsolete wrapper class. Files: SignalIngress.cs, DefaultComponentFactory.cs Tests: SignalPosted_fires_for_every_successful_post_regardless_of_kind, SignalPosted_handler_exception_does_not_break_ingress, SignalPosted_is_raised_even_when_channel_took_slow_path Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Autopilot Monitor
Advanced monitoring and troubleshooting solution for Windows Autopilot deployments. Gain full visibility into every enrollment session with a detailed event timeline, fleet health dashboards, and session reporting. Define custom analysis rules to automatically detect issues and gather rules to collect targeted evidence. Retrieve diagnostics packages on demand, configure agent settings like auto-reboot behavior and automatic timezone adjustment — all managed centrally from the web dashboard.
Private Preview
Autopilot Monitor is currently running as a Private Preview. Visit autopilotmonitor.com to request access and learn more.
Overview
Autopilot Monitor provides real-time tracking, intelligent diagnostics, and automated troubleshooting for Windows Autopilot enrollment processes. It consists of:
- Bootstrap Script — PowerShell script deployed via Intune that starts monitoring early in the enrollment process
- Monitoring Agent — Lightweight .NET application that collects telemetry and evidence during enrollment
- Backend API — Azure Functions-based ingestion and processing pipeline
- Web Dashboard — Next.js application for real-time monitoring and fleet analytics
Architecture
For detailed information about the system architecture, components, and data flow, see Architecture Documentation.
Documentation
Full documentation is available at autopilotmonitor.com/docs
License
This project uses a split licensing model:
- MIT License — Agent (
src/Agent/) and Shared library (src/Shared/) — unrestricted use on end-user devices - AGPL-3.0 — Backend (
src/Backend/), Web Dashboard (src/Web/), and MCP Server (src/McpServer/) — server-side components remain open source
See LICENSE for full details.



