mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +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.
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// V1 parity (MonitoringService.cs:388-413) — when the register-session response
|
||||
/// carried an <c>AdminAction</c> ("Succeeded" / "Failed"), emit the matching
|
||||
/// <c>enrollment_complete</c> / <c>enrollment_failed</c> event and synthesise a
|
||||
/// <see cref="EnrollmentTerminatedEventArgs"/> so the termination pipeline (cleanup,
|
||||
/// summary dialog, self-destruct) runs without waiting for an external signal.
|
||||
/// V2 parity — post <see cref="DecisionSignalKind.AdminPreemptionDetected"/> when the
|
||||
/// register-session response carried an <c>AdminAction</c>. 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 <c>EnrollmentTerminationHandler</c> picks up — no direct synthesis needed.
|
||||
/// </summary>
|
||||
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<string, object>
|
||||
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<string, string>(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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits <c>whiteglove_resumed</c> when the whiteglove.complete marker was present at
|
||||
/// boot — signals that the agent has resumed Part 2 after the device reseal.
|
||||
/// V2 parity — post <see cref="DecisionSignalKind.SystemRebootObserved"/> 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 <c>system_reboot_detected</c> telemetry event as a side effect.
|
||||
/// </summary>
|
||||
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<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,
|
||||
private static void PostSystemRebootObservedSignal(
|
||||
ISignalIngressSink ingressSink,
|
||||
PreviousExitSummary previousExit,
|
||||
AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
orchestrator.EventEmitter.Emit(new EnrollmentEvent
|
||||
var payload = new System.Collections.Generic.Dictionary<string, string>(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<string, object>
|
||||
{
|
||||
{ "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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
/// 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>
|
||||
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<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>());
|
||||
}
|
||||
|
||||
/// <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
|
||||
// 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
|
||||
|
||||
@@ -51,6 +51,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
var effects = new[]
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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