mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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<DecisionEffect>(). 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) <noreply@anthropic.com>
This commit is contained in:
@@ -697,20 +697,26 @@ namespace AutopilotMonitor.Agent.V2
|
|||||||
// alerting. Fires AFTER orchestrator.Start so the EventEmitter is alive.
|
// alerting. Fires AFTER orchestrator.Start so the EventEmitter is alive.
|
||||||
EmitUnrestrictedModeAuditIfChanged(orchestrator, agentConfig, configMergeResult, logger);
|
EmitUnrestrictedModeAuditIfChanged(orchestrator, agentConfig, configMergeResult, logger);
|
||||||
|
|
||||||
// V1 parity — agent_started / whiteglove_resumed / system_reboot_detected
|
// V1 parity — agent_started lifecycle event. Purely informational
|
||||||
// lifecycle events. These are purely informational (Phase=Unknown) but the
|
// (Phase=Unknown); the session timeline and downstream alerting rely on
|
||||||
// session timeline and downstream alerting depend on them to classify the
|
// it to classify the boot.
|
||||||
// boot.
|
|
||||||
EmitAgentStartedEvent(orchestrator, agentConfig, previousExit, logger);
|
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)
|
if (isWhiteGloveResume)
|
||||||
{
|
{
|
||||||
EmitWhiteGloveResumedEvent(orchestrator, agentConfig, logger);
|
|
||||||
try { sessionPersistence.ClearWhiteGloveComplete(logger); }
|
try { sessionPersistence.ClearWhiteGloveComplete(logger); }
|
||||||
catch (Exception ex) { logger.Debug($"ClearWhiteGloveComplete threw: {ex.Message}"); }
|
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))
|
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
|
// 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);
|
PostSessionStartedSignal(orchestrator.IngressSink, registrationResult, agentConfig, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
// V1 parity (MonitoringService.cs:388-413 — the "ADMIN OVERRIDE on startup"
|
// V1 parity (MonitoringService.cs:388-413 "ADMIN OVERRIDE on startup") —
|
||||||
// block) — if the register-session response carried an AdminAction the
|
// if the register-session response carried an AdminAction the operator
|
||||||
// session was already marked terminal by an operator via the portal. Emit
|
// has already marked the session terminal via the portal. Post
|
||||||
// the matching lifecycle event and synthesise a Terminated so the normal
|
// AdminPreemptionDetected so the reducer transitions Stage to Completed /
|
||||||
// termination pipeline (cleanup + summary + self-destruct) runs. The
|
// Failed and emits the enrollment_complete/_failed timeline event as a
|
||||||
// orchestrator has only just started so the event ordering is clean.
|
// 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))
|
if (!string.IsNullOrEmpty(registrationResult.AdminAction))
|
||||||
{
|
{
|
||||||
HandleStartupAdminOverride(
|
PostAdminPreemptionSignal(orchestrator.IngressSink, registrationResult, logger);
|
||||||
orchestrator,
|
|
||||||
terminationHandler,
|
|
||||||
agentConfig,
|
|
||||||
registrationResult.AdminAction,
|
|
||||||
logger);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// M4.6.δ — fire-and-forget startup analyzers (LocalAdmin / SoftwareInventory /
|
// M4.6.δ — fire-and-forget startup analyzers (LocalAdmin / SoftwareInventory /
|
||||||
@@ -906,59 +910,40 @@ namespace AutopilotMonitor.Agent.V2
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// V1 parity (MonitoringService.cs:388-413) — when the register-session response
|
/// V2 parity — post <see cref="DecisionSignalKind.AdminPreemptionDetected"/> when the
|
||||||
/// carried an <c>AdminAction</c> ("Succeeded" / "Failed"), emit the matching
|
/// register-session response carried an <c>AdminAction</c>. The reducer handler at
|
||||||
/// <c>enrollment_complete</c> / <c>enrollment_failed</c> event and synthesise a
|
/// DecisionEngine.Shared.cs#HandleAdminPreemptionDetectedV1 transitions Stage to the
|
||||||
/// <see cref="EnrollmentTerminatedEventArgs"/> so the termination pipeline (cleanup,
|
/// terminal state and emits the enrollment_complete/_failed telemetry event; the
|
||||||
/// summary dialog, self-destruct) runs without waiting for an external signal.
|
/// orchestrator's DecisionStepProcessor then raises the Terminated event, which the
|
||||||
|
/// subscribed <c>EnrollmentTerminationHandler</c> picks up — no direct synthesis needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void HandleStartupAdminOverride(
|
private static void PostAdminPreemptionSignal(
|
||||||
EnrollmentOrchestrator orchestrator,
|
ISignalIngressSink ingressSink,
|
||||||
EnrollmentTerminationHandler terminationHandler,
|
SessionRegistrationResult registrationResult,
|
||||||
AgentConfiguration agentConfig,
|
|
||||||
string adminAction,
|
|
||||||
AgentLogger logger)
|
AgentLogger logger)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var succeeded = string.Equals(adminAction, "Succeeded", StringComparison.OrdinalIgnoreCase);
|
var adminOutcome = registrationResult.AdminAction; // "Succeeded" | "Failed"
|
||||||
logger.Warning(
|
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
|
ingressSink.Post(
|
||||||
{
|
kind: DecisionSignalKind.AdminPreemptionDetected,
|
||||||
SessionId = agentConfig.SessionId,
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
TenantId = agentConfig.TenantId,
|
sourceOrigin: "register_session_response",
|
||||||
EventType = succeeded ? "enrollment_complete" : "enrollment_failed",
|
evidence: new Evidence(
|
||||||
Severity = succeeded ? EventSeverity.Info : EventSeverity.Warning,
|
kind: EvidenceKind.Synthetic,
|
||||||
Source = "AdminOverride",
|
identifier: $"admin_preemption:{adminOutcome}",
|
||||||
Phase = EnrollmentPhase.Complete,
|
summary: $"Operator marked session as {adminOutcome} via portal before agent startup."),
|
||||||
Message = $"Session {adminAction.ToLowerInvariant()} by administrator (detected on restart) — cleanup initiated.",
|
payload: new System.Collections.Generic.Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
Data = new System.Collections.Generic.Dictionary<string, object>
|
|
||||||
{
|
{
|
||||||
{ "adminAction", adminAction },
|
["adminOutcome"] = adminOutcome,
|
||||||
{ "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)."));
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.Warning($"HandleStartupAdminOverride failed: {ex.Message}");
|
logger.Warning($"AdminPreemptionDetected post failed: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1060,71 +1045,38 @@ namespace AutopilotMonitor.Agent.V2
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Emits <c>whiteglove_resumed</c> when the whiteglove.complete marker was present at
|
/// V2 parity — post <see cref="DecisionSignalKind.SystemRebootObserved"/> when the prior
|
||||||
/// boot — signals that the agent has resumed Part 2 after the device reseal.
|
/// 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 <c>system_reboot_detected</c> telemetry event as a side effect.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void EmitWhiteGloveResumedEvent(
|
private static void PostSystemRebootObservedSignal(
|
||||||
EnrollmentOrchestrator orchestrator,
|
ISignalIngressSink ingressSink,
|
||||||
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<string, object>
|
|
||||||
{
|
|
||||||
{ "resumedAtUtc", DateTime.UtcNow.ToString("o") },
|
|
||||||
},
|
|
||||||
ImmediateUpload = true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.Warning($"whiteglove_resumed emission failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Emits <c>system_reboot_detected</c> when the previous agent exit was classified as
|
|
||||||
/// <c>reboot_kill</c> — the OS rebooted mid-session. Gives the backend a hook to split
|
|
||||||
/// the session into pre-reboot and post-reboot segments.
|
|
||||||
/// </summary>
|
|
||||||
private static void EmitSystemRebootDetectedEvent(
|
|
||||||
EnrollmentOrchestrator orchestrator,
|
|
||||||
AgentConfiguration agentConfig,
|
|
||||||
PreviousExitSummary previousExit,
|
PreviousExitSummary previousExit,
|
||||||
AgentLogger logger)
|
AgentLogger logger)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
orchestrator.EventEmitter.Emit(new EnrollmentEvent
|
var payload = new System.Collections.Generic.Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
SessionId = agentConfig.SessionId,
|
["previousExitType"] = previousExit?.ExitType ?? string.Empty,
|
||||||
TenantId = agentConfig.TenantId,
|
["lastBootUtc"] = previousExit?.LastBootUtc?.ToString("o") ?? string.Empty,
|
||||||
EventType = "system_reboot_detected",
|
};
|
||||||
Severity = EventSeverity.Info,
|
|
||||||
Source = "AutopilotMonitor.Agent.V2",
|
ingressSink.Post(
|
||||||
Phase = EnrollmentPhase.Unknown,
|
kind: DecisionSignalKind.SystemRebootObserved,
|
||||||
Message = "Session resumed after OS reboot — prior agent process was terminated by the reboot.",
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
Data = new System.Collections.Generic.Dictionary<string, object>
|
sourceOrigin: "Program.DetectPreviousExit",
|
||||||
{
|
evidence: new Evidence(
|
||||||
{ "lastBootUtc", previousExit?.LastBootUtc?.ToString("o") ?? string.Empty },
|
kind: EvidenceKind.Synthetic,
|
||||||
{ "previousExitType", previousExit?.ExitType ?? string.Empty },
|
identifier: "previous_exit_reboot_kill",
|
||||||
},
|
summary: $"Prior agent process terminated by OS reboot (exitType={previousExit?.ExitType})."),
|
||||||
ImmediateUpload = true,
|
payload: payload);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.Warning($"system_reboot_detected emission failed: {ex.Message}");
|
logger.Warning($"SystemRebootObserved post failed: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
102
|
104
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1072
|
1073
|
||||||
|
|||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="DecisionEffectKind.EmitEventTimelineEntry"/>
|
||||||
|
/// effects rather than being synthesised by the V2 host.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReducerTimelineEffectTests
|
||||||
|
{
|
||||||
|
private static DecisionSignal BuildSignal(
|
||||||
|
DecisionSignalKind kind,
|
||||||
|
DateTime occurredAtUtc,
|
||||||
|
IReadOnlyDictionary<string, string>? 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<string, string>
|
||||||
|
{
|
||||||
|
["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<string, string> { ["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<string, string> { ["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<string, string>
|
||||||
|
{
|
||||||
|
[SignalPayloadKeys.Deadline] = DeadlineNames.ClassifierTick,
|
||||||
|
}))
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var signal = BuildSignal(
|
||||||
|
DecisionSignalKind.AdminPreemptionDetected,
|
||||||
|
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
|
||||||
|
new Dictionary<string, string> { ["adminOutcome"] = "Succeeded" });
|
||||||
|
|
||||||
|
var step = engine.Reduce(stateWithDeadline, signal);
|
||||||
|
|
||||||
|
Assert.Empty(step.NewState.Deadlines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,7 +86,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handle <see cref="DecisionSignalKind.SystemRebootObserved"/>. Records the fact so
|
/// Handle <see cref="DecisionSignalKind.SystemRebootObserved"/>. 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 <c>system_reboot_detected</c> timeline entry so the session timeline shows the
|
||||||
|
/// split between pre-reboot and post-reboot collection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal)
|
private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal)
|
||||||
{
|
{
|
||||||
@@ -108,7 +110,27 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
nextStepIndex: nextStep,
|
nextStepIndex: nextStep,
|
||||||
trigger: nameof(DecisionSignalKind.SystemRebootObserved));
|
trigger: nameof(DecisionSignalKind.SystemRebootObserved));
|
||||||
|
|
||||||
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
var timelineParams = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,64 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handle <see cref="DecisionSignalKind.AdminPreemptionDetected"/>. Plan §2.7 admin-
|
||||||
|
/// action audit; V2 parity PR-B3.
|
||||||
|
/// <para>
|
||||||
|
/// Emitted by <c>Program.RunAgent</c> when the register-session response carries an
|
||||||
|
/// <c>AdminAction</c> value (operator marked the session terminal via the portal before
|
||||||
|
/// the agent even started). The signal payload carries
|
||||||
|
/// <c>adminOutcome=Succeeded|Failed</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Stage transitions to <see cref="SessionStage.Completed"/> (Succeeded) or
|
||||||
|
/// <see cref="SessionStage.Failed"/> (anything else); <see cref="SessionOutcome.AdminPreempted"/>
|
||||||
|
/// captures the non-enrollment nature of the transition so dashboards + KQL can tell
|
||||||
|
/// an admin-override apart from a genuine enrollment_complete/_failed.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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<string, string>
|
||||||
|
{
|
||||||
|
["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
|
// ============================================================== shared helpers
|
||||||
// Partial-class shared helpers used by Classic / SelfDeploying / WhiteGlove handlers
|
// 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
|
// as they come online in M3.1+ live below. M3.0 establishes the skeleton; the bodies
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
var effects = new[]
|
var effects = new[]
|
||||||
{
|
{
|
||||||
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: safety),
|
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: safety),
|
||||||
|
new DecisionEffect(
|
||||||
|
DecisionEffectKind.EmitEventTimelineEntry,
|
||||||
|
parameters: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["eventType"] = "whiteglove_resumed",
|
||||||
|
["resumedAtUtc"] = signal.OccurredAtUtc.ToString("o"),
|
||||||
|
["sourceOrigin"] = signal.SourceOrigin ?? string.Empty,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return new DecisionStep(newState, transition, effects);
|
return new DecisionStep(newState, transition, effects);
|
||||||
|
|||||||
@@ -56,10 +56,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
return (signal.Kind, signal.KindSchemaVersion) switch
|
return (signal.Kind, signal.KindSchemaVersion) switch
|
||||||
{
|
{
|
||||||
// ----- Lifecycle (DecisionEngine.Shared.cs) -----
|
// ----- Lifecycle (DecisionEngine.Shared.cs) -----
|
||||||
(DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal),
|
(DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal),
|
||||||
(DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal),
|
(DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal),
|
||||||
(DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal),
|
(DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal),
|
||||||
(DecisionSignalKind.DeadlineFired, 1) => HandleDeadlineFiredV1(state, signal),
|
(DecisionSignalKind.AdminPreemptionDetected, 1) => HandleAdminPreemptionDetectedV1(state, signal),
|
||||||
|
(DecisionSignalKind.DeadlineFired, 1) => HandleDeadlineFiredV1(state, signal),
|
||||||
|
|
||||||
// ----- Classic UserDriven-v1 (DecisionEngine.Classic.cs) -----
|
// ----- Classic UserDriven-v1 (DecisionEngine.Classic.cs) -----
|
||||||
(DecisionSignalKind.EspPhaseChanged, 1) => HandleEspPhaseChangedV1(state, signal),
|
(DecisionSignalKind.EspPhaseChanged, 1) => HandleEspPhaseChangedV1(state, signal),
|
||||||
|
|||||||
@@ -41,5 +41,8 @@ namespace AutopilotMonitor.DecisionCore.Signals
|
|||||||
SessionStarted,
|
SessionStarted,
|
||||||
SessionAborted,
|
SessionAborted,
|
||||||
SessionRecovered,
|
SessionRecovered,
|
||||||
|
|
||||||
|
// --- Admin-driven preemption (Plan §2.7 admin-action audit, V2 parity PR-B3) ---
|
||||||
|
AdminPreemptionDetected,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,12 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
WhiteGlovePart1Sealed,
|
WhiteGlovePart1Sealed,
|
||||||
WhiteGlovePart2Complete,
|
WhiteGlovePart2Complete,
|
||||||
Aborted,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}}
|
||||||
@@ -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"}}
|
||||||
@@ -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"}}
|
||||||
Reference in New Issue
Block a user