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"}}