diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
index 3c2cd019..0550e794 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
@@ -697,20 +697,26 @@ namespace AutopilotMonitor.Agent.V2
// alerting. Fires AFTER orchestrator.Start so the EventEmitter is alive.
EmitUnrestrictedModeAuditIfChanged(orchestrator, agentConfig, configMergeResult, logger);
- // V1 parity — agent_started / whiteglove_resumed / system_reboot_detected
- // lifecycle events. These are purely informational (Phase=Unknown) but the
- // session timeline and downstream alerting depend on them to classify the
- // boot.
+ // V1 parity — agent_started lifecycle event. Purely informational
+ // (Phase=Unknown); the session timeline and downstream alerting rely on
+ // it to classify the boot.
EmitAgentStartedEvent(orchestrator, agentConfig, previousExit, logger);
+
+ // 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.
if (isWhiteGloveResume)
{
- EmitWhiteGloveResumedEvent(orchestrator, agentConfig, logger);
try { sessionPersistence.ClearWhiteGloveComplete(logger); }
catch (Exception ex) { logger.Debug($"ClearWhiteGloveComplete threw: {ex.Message}"); }
}
+
+ // Reboot mid-session: post SystemRebootObserved so the reducer records
+ // the fact and emits the system_reboot_detected timeline entry.
if (string.Equals(previousExit?.ExitType, "reboot_kill", StringComparison.OrdinalIgnoreCase))
{
- EmitSystemRebootDetectedEvent(orchestrator, agentConfig, previousExit, logger);
+ PostSystemRebootObservedSignal(orchestrator.IngressSink, previousExit, logger);
}
// V2 parity — post SessionStarted so the reducer establishes the session anchor
@@ -724,20 +730,18 @@ namespace AutopilotMonitor.Agent.V2
PostSessionStartedSignal(orchestrator.IngressSink, registrationResult, agentConfig, logger);
}
- // V1 parity (MonitoringService.cs:388-413 — the "ADMIN OVERRIDE on startup"
- // block) — if the register-session response carried an AdminAction the
- // session was already marked terminal by an operator via the portal. Emit
- // the matching lifecycle event and synthesise a Terminated so the normal
- // termination pipeline (cleanup + summary + self-destruct) runs. The
- // orchestrator has only just started so the event ordering is clean.
+ // V1 parity (MonitoringService.cs:388-413 "ADMIN OVERRIDE on startup") —
+ // if the register-session response carried an AdminAction the operator
+ // has already marked the session terminal via the portal. Post
+ // AdminPreemptionDetected so the reducer transitions Stage to Completed /
+ // Failed and emits the enrollment_complete/_failed timeline event as a
+ // side effect. The orchestrator's DecisionStepProcessor picks up the
+ // terminal stage and raises Terminated, which runs the termination
+ // pipeline (cleanup + summary + self-destruct) through the subscribed
+ // handler — no direct synthesis needed.
if (!string.IsNullOrEmpty(registrationResult.AdminAction))
{
- HandleStartupAdminOverride(
- orchestrator,
- terminationHandler,
- agentConfig,
- registrationResult.AdminAction,
- logger);
+ PostAdminPreemptionSignal(orchestrator.IngressSink, registrationResult, logger);
}
// M4.6.δ — fire-and-forget startup analyzers (LocalAdmin / SoftwareInventory /
@@ -906,59 +910,40 @@ namespace AutopilotMonitor.Agent.V2
}
///
- /// V1 parity (MonitoringService.cs:388-413) — when the register-session response
- /// carried an AdminAction ("Succeeded" / "Failed"), emit the matching
- /// enrollment_complete / enrollment_failed event and synthesise a
- /// so the termination pipeline (cleanup,
- /// summary dialog, self-destruct) runs without waiting for an external signal.
+ /// V2 parity — post when the
+ /// register-session response carried an AdminAction. The reducer handler at
+ /// DecisionEngine.Shared.cs#HandleAdminPreemptionDetectedV1 transitions Stage to the
+ /// terminal state and emits the enrollment_complete/_failed telemetry event; the
+ /// orchestrator's DecisionStepProcessor then raises the Terminated event, which the
+ /// subscribed EnrollmentTerminationHandler picks up — no direct synthesis needed.
///
- private static void HandleStartupAdminOverride(
- EnrollmentOrchestrator orchestrator,
- EnrollmentTerminationHandler terminationHandler,
- AgentConfiguration agentConfig,
- string adminAction,
+ private static void PostAdminPreemptionSignal(
+ ISignalIngressSink ingressSink,
+ SessionRegistrationResult registrationResult,
AgentLogger logger)
{
try
{
- var succeeded = string.Equals(adminAction, "Succeeded", StringComparison.OrdinalIgnoreCase);
+ var adminOutcome = registrationResult.AdminAction; // "Succeeded" | "Failed"
logger.Warning(
- $"=== ADMIN OVERRIDE on startup: session already marked as {adminAction} by administrator — running cleanup only ===");
+ $"=== ADMIN OVERRIDE on startup: session already marked as {adminOutcome} by administrator — posting AdminPreemptionDetected signal ===");
- orchestrator.EventEmitter.Emit(new EnrollmentEvent
- {
- SessionId = agentConfig.SessionId,
- TenantId = agentConfig.TenantId,
- EventType = succeeded ? "enrollment_complete" : "enrollment_failed",
- Severity = succeeded ? EventSeverity.Info : EventSeverity.Warning,
- Source = "AdminOverride",
- Phase = EnrollmentPhase.Complete,
- Message = $"Session {adminAction.ToLowerInvariant()} by administrator (detected on restart) — cleanup initiated.",
- Data = new System.Collections.Generic.Dictionary
+ ingressSink.Post(
+ kind: DecisionSignalKind.AdminPreemptionDetected,
+ occurredAtUtc: DateTime.UtcNow,
+ sourceOrigin: "register_session_response",
+ evidence: new Evidence(
+ kind: EvidenceKind.Synthetic,
+ identifier: $"admin_preemption:{adminOutcome}",
+ summary: $"Operator marked session as {adminOutcome} via portal before agent startup."),
+ payload: new System.Collections.Generic.Dictionary(StringComparer.Ordinal)
{
- { "adminAction", adminAction },
- { "source", "register_session_response" },
- },
- ImmediateUpload = true,
- });
-
- var outcome = succeeded
- ? EnrollmentTerminationOutcome.Succeeded
- : EnrollmentTerminationOutcome.Failed;
- var stage = succeeded ? SessionStage.Completed : SessionStage.Failed;
-
- terminationHandler.Handle(
- sender: null,
- args: new EnrollmentTerminatedEventArgs(
- reason: EnrollmentTerminationReason.DecisionTerminalStage,
- outcome: outcome,
- stageName: stage.ToString(),
- terminatedAtUtc: DateTime.UtcNow,
- details: $"Admin marked session as {adminAction} via portal (detected on register-session)."));
+ ["adminOutcome"] = adminOutcome,
+ });
}
catch (Exception ex)
{
- logger.Warning($"HandleStartupAdminOverride failed: {ex.Message}");
+ logger.Warning($"AdminPreemptionDetected post failed: {ex.Message}");
}
}
@@ -1060,71 +1045,38 @@ namespace AutopilotMonitor.Agent.V2
}
///
- /// Emits whiteglove_resumed when the whiteglove.complete marker was present at
- /// boot — signals that the agent has resumed Part 2 after the device reseal.
+ /// V2 parity — post when the prior
+ /// agent process was terminated by an OS reboot. The reducer handler at
+ /// DecisionEngine.Edge.cs#HandleSystemRebootObservedV1 records the reboot fact on the
+ /// state (used by the WhiteGlove reboot-observed scoring weight, plan §2.4) and emits
+ /// the system_reboot_detected telemetry event as a side effect.
///
- private static void EmitWhiteGloveResumedEvent(
- EnrollmentOrchestrator orchestrator,
- AgentConfiguration agentConfig,
- AgentLogger logger)
- {
- try
- {
- orchestrator.EventEmitter.Emit(new EnrollmentEvent
- {
- SessionId = agentConfig.SessionId,
- TenantId = agentConfig.TenantId,
- EventType = "whiteglove_resumed",
- Severity = EventSeverity.Info,
- Source = "AutopilotMonitor.Agent.V2",
- Phase = EnrollmentPhase.Unknown,
- Message = "WhiteGlove Part 2 resume detected — continuing user-phase monitoring.",
- Data = new System.Collections.Generic.Dictionary
- {
- { "resumedAtUtc", DateTime.UtcNow.ToString("o") },
- },
- ImmediateUpload = true,
- });
- }
- catch (Exception ex)
- {
- logger.Warning($"whiteglove_resumed emission failed: {ex.Message}");
- }
- }
-
- ///
- /// Emits system_reboot_detected when the previous agent exit was classified as
- /// reboot_kill — the OS rebooted mid-session. Gives the backend a hook to split
- /// the session into pre-reboot and post-reboot segments.
- ///
- private static void EmitSystemRebootDetectedEvent(
- EnrollmentOrchestrator orchestrator,
- AgentConfiguration agentConfig,
+ private static void PostSystemRebootObservedSignal(
+ ISignalIngressSink ingressSink,
PreviousExitSummary previousExit,
AgentLogger logger)
{
try
{
- orchestrator.EventEmitter.Emit(new EnrollmentEvent
+ var payload = new System.Collections.Generic.Dictionary(StringComparer.Ordinal)
{
- SessionId = agentConfig.SessionId,
- TenantId = agentConfig.TenantId,
- EventType = "system_reboot_detected",
- Severity = EventSeverity.Info,
- Source = "AutopilotMonitor.Agent.V2",
- Phase = EnrollmentPhase.Unknown,
- Message = "Session resumed after OS reboot — prior agent process was terminated by the reboot.",
- Data = new System.Collections.Generic.Dictionary
- {
- { "lastBootUtc", previousExit?.LastBootUtc?.ToString("o") ?? string.Empty },
- { "previousExitType", previousExit?.ExitType ?? string.Empty },
- },
- ImmediateUpload = true,
- });
+ ["previousExitType"] = previousExit?.ExitType ?? string.Empty,
+ ["lastBootUtc"] = previousExit?.LastBootUtc?.ToString("o") ?? string.Empty,
+ };
+
+ ingressSink.Post(
+ kind: DecisionSignalKind.SystemRebootObserved,
+ occurredAtUtc: DateTime.UtcNow,
+ sourceOrigin: "Program.DetectPreviousExit",
+ evidence: new Evidence(
+ kind: EvidenceKind.Synthetic,
+ identifier: "previous_exit_reboot_kill",
+ summary: $"Prior agent process terminated by OS reboot (exitType={previousExit?.ExitType})."),
+ payload: payload);
}
catch (Exception ex)
{
- logger.Warning($"system_reboot_detected emission failed: {ex.Message}");
+ logger.Warning($"SystemRebootObserved post failed: {ex.Message}");
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index 257e5632..b16e5f75 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-102
+104
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index e255a855..bba0bc80 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1072
+1073
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs
new file mode 100644
index 00000000..79619e94
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs
@@ -0,0 +1,196 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+namespace AutopilotMonitor.DecisionCore.Tests
+{
+ ///
+ /// Plan V2-parity PR-B — the reducer is the single state owner, and telemetry events
+ /// for the three lifecycle signals (SystemRebootObserved, SessionRecovered→WG bridge,
+ /// AdminPreemptionDetected) appear as
+ /// effects rather than being synthesised by the V2 host.
+ ///
+ public sealed class ReducerTimelineEffectTests
+ {
+ private static DecisionSignal BuildSignal(
+ DecisionSignalKind kind,
+ DateTime occurredAtUtc,
+ IReadOnlyDictionary? payload = null,
+ string sourceOrigin = "test",
+ int ordinal = 1)
+ {
+ return new DecisionSignal(
+ sessionSignalOrdinal: ordinal,
+ sessionTraceOrdinal: ordinal,
+ kind: kind,
+ kindSchemaVersion: 1,
+ occurredAtUtc: occurredAtUtc,
+ sourceOrigin: sourceOrigin,
+ evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
+ payload: payload);
+ }
+
+ // ---------------------------------------------------------------- SystemRebootObserved
+
+ [Fact]
+ public void SystemRebootObservedV1_emits_system_reboot_detected_timeline_entry()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+ var payload = new Dictionary
+ {
+ ["previousExitType"] = "reboot_kill",
+ ["lastBootUtc"] = "2026-04-22T08:00:00.0000000Z",
+ };
+ var signal = BuildSignal(
+ DecisionSignalKind.SystemRebootObserved,
+ new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
+ payload);
+
+ var step = engine.Reduce(state, signal);
+
+ Assert.NotNull(step.NewState.SystemRebootUtc);
+ var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry);
+ Assert.NotNull(timelineEffect.Parameters);
+ Assert.Equal("system_reboot_detected", timelineEffect.Parameters!["eventType"]);
+ Assert.Equal("reboot_kill", timelineEffect.Parameters["previousExitType"]);
+ Assert.Equal("2026-04-22T08:00:00.0000000Z", timelineEffect.Parameters["lastBootUtc"]);
+ }
+
+ [Fact]
+ public void SystemRebootObservedV1_without_payload_still_emits_timeline_entry()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+ var signal = BuildSignal(
+ DecisionSignalKind.SystemRebootObserved,
+ new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc));
+
+ var step = engine.Reduce(state, signal);
+
+ var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry);
+ Assert.Equal("system_reboot_detected", timelineEffect.Parameters!["eventType"]);
+ 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);
+ }
+
+ // ---------------------------------------------------- AdminPreemptionDetected
+
+ [Fact]
+ public void AdminPreemptionDetectedV1_succeeded_transitions_to_completed_and_emits_complete_event()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+ var signal = BuildSignal(
+ DecisionSignalKind.AdminPreemptionDetected,
+ new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
+ new Dictionary { ["adminOutcome"] = "Succeeded" },
+ sourceOrigin: "register_session_response");
+
+ var step = engine.Reduce(state, signal);
+
+ Assert.Equal(SessionStage.Completed, step.NewState.Stage);
+ Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome);
+
+ var effect = Assert.Single(step.Effects);
+ Assert.Equal(DecisionEffectKind.EmitEventTimelineEntry, effect.Kind);
+ Assert.Equal("enrollment_complete", effect.Parameters!["eventType"]);
+ Assert.Equal("Succeeded", effect.Parameters["adminAction"]);
+ Assert.Equal("register_session_response", effect.Parameters["source"]);
+ }
+
+ [Fact]
+ public void AdminPreemptionDetectedV1_failed_transitions_to_failed_and_emits_failed_event()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+ var signal = BuildSignal(
+ DecisionSignalKind.AdminPreemptionDetected,
+ new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
+ new Dictionary { ["adminOutcome"] = "Failed" });
+
+ var step = engine.Reduce(state, signal);
+
+ Assert.Equal(SessionStage.Failed, step.NewState.Stage);
+ Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome);
+
+ var effect = Assert.Single(step.Effects);
+ Assert.Equal("enrollment_failed", effect.Parameters!["eventType"]);
+ Assert.Equal("Failed", effect.Parameters["adminAction"]);
+ }
+
+ [Fact]
+ public void AdminPreemptionDetectedV1_missing_outcome_defaults_to_failed()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+ var signal = BuildSignal(
+ DecisionSignalKind.AdminPreemptionDetected,
+ new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
+ payload: null);
+
+ var step = engine.Reduce(state, signal);
+
+ Assert.Equal(SessionStage.Failed, step.NewState.Stage);
+ Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome);
+ }
+
+ [Fact]
+ public void AdminPreemptionDetectedV1_clears_active_deadlines()
+ {
+ var engine = new DecisionEngine();
+ var stateWithDeadline = DecisionState.CreateInitial("s", "t")
+ .ToBuilder()
+ .AddDeadline(new ActiveDeadline(
+ name: DeadlineNames.ClassifierTick,
+ dueAtUtc: new DateTime(2026, 4, 22, 11, 0, 0, DateTimeKind.Utc),
+ firesSignalKind: DecisionSignalKind.DeadlineFired,
+ firesPayload: new Dictionary
+ {
+ [SignalPayloadKeys.Deadline] = DeadlineNames.ClassifierTick,
+ }))
+ .Build();
+
+ var signal = BuildSignal(
+ DecisionSignalKind.AdminPreemptionDetected,
+ new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
+ new Dictionary { ["adminOutcome"] = "Succeeded" });
+
+ var step = engine.Reduce(stateWithDeadline, signal);
+
+ Assert.Empty(step.NewState.Deadlines);
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
index c414e942..73183aeb 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
@@ -86,7 +86,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
///
/// Handle . Records the fact so
- /// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight.
+ /// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight, and emits
+ /// the system_reboot_detected timeline entry so the session timeline shows the
+ /// split between pre-reboot and post-reboot collection.
///
private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal)
{
@@ -108,7 +110,27 @@ namespace AutopilotMonitor.DecisionCore.Engine
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.SystemRebootObserved));
- return new DecisionStep(newState, transition, Array.Empty());
+ var timelineParams = new Dictionary
+ {
+ ["eventType"] = "system_reboot_detected",
+ ["reason"] = "prior agent process was terminated by the reboot",
+ };
+ if (signal.Payload != null)
+ {
+ if (signal.Payload.TryGetValue("previousExitType", out var previousExitType))
+ timelineParams["previousExitType"] = previousExitType ?? string.Empty;
+ if (signal.Payload.TryGetValue("lastBootUtc", out var lastBootUtc))
+ timelineParams["lastBootUtc"] = lastBootUtc ?? string.Empty;
+ }
+
+ var effects = new[]
+ {
+ new DecisionEffect(
+ DecisionEffectKind.EmitEventTimelineEntry,
+ parameters: timelineParams),
+ };
+
+ return new DecisionStep(newState, transition, effects);
}
}
}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
index d2a55719..55050224 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
@@ -139,6 +139,64 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(newState, transition, Array.Empty());
}
+ ///
+ /// Handle . Plan §2.7 admin-
+ /// action audit; V2 parity PR-B3.
+ ///
+ /// Emitted by Program.RunAgent when the register-session response carries an
+ /// AdminAction value (operator marked the session terminal via the portal before
+ /// the agent even started). The signal payload carries
+ /// adminOutcome=Succeeded|Failed.
+ ///
+ ///
+ /// Stage transitions to (Succeeded) or
+ /// (anything else);
+ /// captures the non-enrollment nature of the transition so dashboards + KQL can tell
+ /// an admin-override apart from a genuine enrollment_complete/_failed.
+ ///
+ ///
+ private DecisionStep HandleAdminPreemptionDetectedV1(DecisionState state, DecisionSignal signal)
+ {
+ var adminOutcome = signal.Payload != null && signal.Payload.TryGetValue("adminOutcome", out var v)
+ ? v
+ : "Failed"; // defensive default: preemption without outcome is treated as failure.
+
+ var succeeded = string.Equals(adminOutcome, "Succeeded", StringComparison.OrdinalIgnoreCase);
+ var toStage = succeeded ? SessionStage.Completed : SessionStage.Failed;
+ var eventType = succeeded ? "enrollment_complete" : "enrollment_failed";
+
+ var nextStep = state.StepIndex + 1;
+ var newState = state.ToBuilder()
+ .WithStage(toStage)
+ .WithOutcome(SessionOutcome.AdminPreempted)
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
+ .ClearDeadlines()
+ .Build();
+
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: toStage,
+ nextStepIndex: nextStep,
+ trigger: $"AdminPreemption:{adminOutcome}");
+
+ var effects = new[]
+ {
+ new DecisionEffect(
+ DecisionEffectKind.EmitEventTimelineEntry,
+ parameters: new Dictionary
+ {
+ ["eventType"] = eventType,
+ ["adminAction"] = adminOutcome,
+ ["source"] = signal.SourceOrigin ?? "register_session_response",
+ ["reason"] = $"Session {adminOutcome.ToLowerInvariant()} by administrator (detected on register-session).",
+ }),
+ };
+
+ return new DecisionStep(newState, transition, effects);
+ }
+
// ============================================================== shared helpers
// Partial-class shared helpers used by Classic / SelfDeploying / WhiteGlove handlers
// as they come online in M3.1+ live below. M3.0 establishes the skeleton; the bodies
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs
index 585b8043..2fc8b068 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs
@@ -51,6 +51,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
var effects = new[]
{
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: safety),
+ new DecisionEffect(
+ DecisionEffectKind.EmitEventTimelineEntry,
+ parameters: new Dictionary
+ {
+ ["eventType"] = "whiteglove_resumed",
+ ["resumedAtUtc"] = signal.OccurredAtUtc.ToString("o"),
+ ["sourceOrigin"] = signal.SourceOrigin ?? string.Empty,
+ }),
};
return new DecisionStep(newState, transition, effects);
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
index 72c62d73..7928461f 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
@@ -56,10 +56,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
return (signal.Kind, signal.KindSchemaVersion) switch
{
// ----- Lifecycle (DecisionEngine.Shared.cs) -----
- (DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal),
- (DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal),
- (DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal),
- (DecisionSignalKind.DeadlineFired, 1) => HandleDeadlineFiredV1(state, signal),
+ (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),
// ----- Classic UserDriven-v1 (DecisionEngine.Classic.cs) -----
(DecisionSignalKind.EspPhaseChanged, 1) => HandleEspPhaseChangedV1(state, signal),
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs
index a704411f..b9f28796 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs
@@ -41,5 +41,8 @@ namespace AutopilotMonitor.DecisionCore.Signals
SessionStarted,
SessionAborted,
SessionRecovered,
+
+ // --- Admin-driven preemption (Plan §2.7 admin-action audit, V2 parity PR-B3) ---
+ AdminPreemptionDetected,
}
}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs
index 11694d36..ae7a5994 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs
@@ -13,5 +13,12 @@ namespace AutopilotMonitor.DecisionCore.State
WhiteGlovePart1Sealed,
WhiteGlovePart2Complete,
Aborted,
+
+ // V2 parity PR-B3 / plan §2.7 admin-action audit:
+ // Set when the register-session response carries an AdminAction
+ // (operator marked the session terminal via the portal before the
+ // agent even started). Stage transitions to Completed or Failed
+ // depending on the adminOutcome payload.
+ AdminPreempted,
}
}
diff --git a/tests/fixtures/signal-kinds/admin-preemption-detected-v1-failed.json b/tests/fixtures/signal-kinds/admin-preemption-detected-v1-failed.json
new file mode 100644
index 00000000..9bc45f23
--- /dev/null
+++ b/tests/fixtures/signal-kinds/admin-preemption-detected-v1-failed.json
@@ -0,0 +1,3 @@
+# AdminPreemptionDetected v1 — adminOutcome=Failed → Stage.Failed,
+# Outcome=AdminPreempted, effect emits enrollment_failed timeline entry.
+{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"AdminPreemptionDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"register_session_response","Evidence":{"Kind":"Synthetic","Identifier":"admin_preemption:Failed","Summary":"Operator marked session as Failed via portal before agent startup."},"Payload":{"adminOutcome":"Failed"}}
diff --git a/tests/fixtures/signal-kinds/admin-preemption-detected-v1-succeeded.json b/tests/fixtures/signal-kinds/admin-preemption-detected-v1-succeeded.json
new file mode 100644
index 00000000..e324a705
--- /dev/null
+++ b/tests/fixtures/signal-kinds/admin-preemption-detected-v1-succeeded.json
@@ -0,0 +1,4 @@
+# AdminPreemptionDetected v1 — adminOutcome=Succeeded → Stage.Completed,
+# Outcome=AdminPreempted, effect emits enrollment_complete timeline entry with
+# adminAction=Succeeded + source=register_session_response.
+{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"AdminPreemptionDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"register_session_response","Evidence":{"Kind":"Synthetic","Identifier":"admin_preemption:Succeeded","Summary":"Operator marked session as Succeeded via portal before agent startup."},"Payload":{"adminOutcome":"Succeeded"}}
diff --git a/tests/fixtures/signal-kinds/system-reboot-observed-v1.json b/tests/fixtures/signal-kinds/system-reboot-observed-v1.json
new file mode 100644
index 00000000..d07824df
--- /dev/null
+++ b/tests/fixtures/signal-kinds/system-reboot-observed-v1.json
@@ -0,0 +1,5 @@
+# SystemRebootObserved v1 — V2 parity PR-B2.
+# Reducer handler HandleSystemRebootObservedV1 records SystemRebootUtc fact and emits
+# system_reboot_detected timeline entry. Payload carries previousExitType + lastBootUtc
+# so the backend can split the session into pre-/post-reboot segments.
+{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"SystemRebootObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"Program.DetectPreviousExit","Evidence":{"Kind":"Synthetic","Identifier":"previous_exit_reboot_kill","Summary":"Prior agent process terminated by OS reboot."},"Payload":{"previousExitType":"reboot_kill","lastBootUtc":"2026-04-22T08:00:00.0000000Z"}}