From 5b7f424d190b0a347228ae455c9e22487bc64744 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 22 Apr 2026 16:00:25 +0200 Subject: [PATCH] refactor(agent-v2): route whiteglove_resumed / system_reboot_detected / admin preemption through reducer effects V2 parity cleanup - the three lifecycle events were emitted from Program.cs directly while the corresponding reducer handlers (HandleWhiteGlovePart1To2Bridge, HandleSystemRebootObservedV1) returned Array.Empty(). Admin preemption synthesised an EnrollmentTerminatedEventArgs and invoked terminationHandler.Handle directly, bypassing the reducer entirely. This commit unifies all three flows: the reducer is the single state owner, the telemetry event appears as an EmitEventTimelineEntry effect of the state transition, and the orchestrator's Terminated event fires automatically when stage goes terminal - no more synthetic bypass. - B1: HandleWhiteGlovePart1To2Bridge emits whiteglove_resumed timeline entry. - B2: HandleSystemRebootObservedV1 emits system_reboot_detected with payload keys previousExitType/lastBootUtc. - B3: new DecisionSignalKind.AdminPreemptionDetected + new SessionOutcome.AdminPreempted. Reducer HandleAdminPreemptionDetectedV1 transitions to Completed/Failed based on adminOutcome payload, emits enrollment_complete/_failed timeline entry with adminAction+source data keys. Program.cs posts the signal; terminationHandler still subscribes to orchestrator.Terminated (no direct Handle calls on the startup-preemption path). Fixtures: tests/fixtures/signal-kinds/{system-reboot-observed,admin-preemption-detected}-v1.json. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AutopilotMonitor.Agent.V2/Program.cs | 182 ++++++---------- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../ReducerTimelineEffectTests.cs | 196 ++++++++++++++++++ .../Engine/DecisionEngine.Edge.cs | 26 ++- .../Engine/DecisionEngine.Shared.cs | 58 ++++++ .../Engine/DecisionEngine.WhiteGlovePart2.cs | 8 + .../Engine/DecisionEngine.cs | 9 +- .../Signals/DecisionSignalKind.cs | 3 + .../State/SessionOutcome.cs | 7 + .../admin-preemption-detected-v1-failed.json | 3 + ...dmin-preemption-detected-v1-succeeded.json | 4 + .../system-reboot-observed-v1.json | 5 + 13 files changed, 382 insertions(+), 123 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs create mode 100644 tests/fixtures/signal-kinds/admin-preemption-detected-v1-failed.json create mode 100644 tests/fixtures/signal-kinds/admin-preemption-detected-v1-succeeded.json create mode 100644 tests/fixtures/signal-kinds/system-reboot-observed-v1.json 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"}}