Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/DecisionGraphBuilderTests.cs
T
Oliver Kieselbach d16b60f14c feat(v2): WG Part-2 architectural cleanup + install-mode handoff fix
Bundled change covering three orthogonal pieces of work that all landed in
the same working tree before the next PR-style commit could be split out.

## WG Part-2 architectural cleanup (PR-A through PR-E)

Plan: ~/.claude/plans/wg-part2-architectural-cleanup.md.

The V2 agent carried a full separate "Part-2 apparatus" (4 special signal
kinds, 2 special stages, a special outcome, a special classifier, a 24h
safety deadline, a dedicated reducer file, adapter `part2Mode` switches,
a SessionRecovered bridge). This did not match OS reality: post-reseal
Part 2 is just a normal user-driven OOBE running through the Classic
flow. The apparatus also carried a real risk (24h false-positive
enrollment_failed mid-success) and was not even active in practice
(observed in session bdb3cf9d-541b-4c44-aebe-5b9e72743b0a, 2026-05-04).

V2 is now V1-symmetric:

  PR-A — Orchestrator Resume Mechanic. EnrollmentOrchestrator detects a
  persisted Stage=WhiteGloveSealed snapshot at Start(), archives the
  reducer-state segments to state/.part1-<utc-ts>/, runs as a fresh
  first-boot through the normal recovery branches, and posts a single
  whiteglove_resumed InformationalEvent after the onIngressReady hook.
  New helper class StateArchiver.cs + StateArchiverTests +
  EnrollmentOrchestratorPart2EmissionTests.

  PR-B — DecisionCore Cleanup. Removed:
    - DecisionSignalKind.UserAadSignInComplete / HelloResolvedPart2 /
      DesktopArrivedPart2 / AccountSetupCompletedPart2 / SessionRecovered
    - SessionStage.WhiteGloveAwaitingUserSignIn / WhiteGloveCompletedPart2
    - SessionOutcome.WhiteGlovePart2Complete
    - DeadlineNames.WhiteGlovePart2Safety
    - DecisionState's 4 Part-2 SignalFacts (+ builder + snapshot slots)
    - WhiteGlovePart2CompletionClassifier + WhiteGlovePart2CompletionSnapshot
    - DecisionEngine.WhiteGlovePart2.cs (entire reducer file)
    - HandleSessionRecoveredV1 dispatcher + handler

  PR-C — Adapter Cleanup. Dropped the inert `part2Mode` ctor parameter
  from AadJoinWatcherAdapter, DesktopArrivalDetectorAdapter,
  HelloTrackerAdapter, EspAndHelloTrackerAdapter (and their tests).
  AadJoinHost is the only production caller using a named-arg form,
  unaffected.

  PR-D — Backend + Web. Removed `WhiteGloveCompletedPart2` cases from
  TelemetryPayloadParser.IsTerminalStage and DecisionGraphBuilder
  (IsTerminalStage + DeriveTerminalOutcome) plus their tests. Dropped
  the dead `UserAadSignInComplete` constant from Shared.Constants.
  Extracted pure helper computeWhiteGloveSplitSequence(events) in
  app/sessions/[sessionId]/utils/eventHelpers.ts; useSessionDerivedData +
  utils/sessionExportUtils now share it. New 6-case vitest covering
  splitter behaviour (no marker, primary trigger via whiteglove_resumed,
  agent_started fallback, Pending-only variants, primary-vs-fallback
  race). PhaseTimeline + eventTypes were already on the new model.

  PR-E — Tests + Fixtures Final Cleanup. Deleted the two
  whiteglove-part2-*.jsonl fixtures + EdgeScenarioTests references;
  removed dead `whiteglove_part2` case + test from SummaryDialog
  OutcomeMapper; trimmed PR-B history breadcrumbs that still embedded
  removed symbol names so the final grep is clean. Updated MEMORY
  entries (project_v2_completion_location, project_wg_part2_simple_model,
  project_wg_signal_validation_pending, project_bdb3cf9d_wg_followups,
  MEMORY.md index).

What stayed: SessionStage.WhiteGloveSealed (Part-1 marker), the
`_isWhiteGlovePart2` Hint-Bool (V1-symmetric, plan §11) wired only into
EnrollmentTerminationHandler / SoftwareInventoryAnalyzer for shutdown-
analyzer phase=2 tagging, the `whiteglove_part1_complete` event-type +
`whiteglove_part1` outcome-string, `PausedForPart2` terminal-outcome
label for the Inspector, the `whiteglove_resumed` event as UI-splitter
trigger.

Final acceptance: grep for the removed symbol set in src/ excluding V1
(Agent.Core/) is empty. V2.Core.Tests 991 / DecisionCore.Tests 282 /
Functions.Tests 862 / SummaryDialog.Tests 44 / Web vitest 227 — all
green. Solution build clean.

## Resume-path correctness fixes (Codex review of the cleanup)

Three findings on the new resume mechanic:

  P1a — AgentRuntimeHost was still skipping LifecycleEmitters
  PostEnrollmentFactsObserved + PostSessionStarted on isWhiteGloveResume.
  After PR-A's archive-and-reset the decision state is empty, so the
  Classic flow needs both anchors (otherwise ScenarioProfile stays at
  default and the Hello+Desktop AND-gate misclassifies). Fixed: post
  unconditionally for facts; SessionStarted is now only gated by an
  AdminAction, not by the resume flag. The orchestrator's
  whiteglove_resumed lands first (smaller sequence) so the Web splitter
  (`splitSequence = resumed.sequence - 1`) still puts SessionStarted
  into the Part-2 timeline block.

  P1b — StateArchiver was archiving event-sequence.json along with the
  reducer state. Part 2 reuses the same SessionId and the backend orders
  events session-wide by (SessionId, Sequence); the Web split also uses
  `splitSequence = resumed.sequence - 1`. Resetting the counter at the
  resume boundary would collide Part-1 and Part-2 sequences and break
  the splitter. event-sequence.json is now intentionally preserved; the
  three reducer-state segments still archive. StateArchiverTests +
  RecoveryPathTests asserting the new contract.

  P2 — V1 reset the local session-age clock on Part-2 resume
  (SessionPersistence.ResetSessionCreatedAt, MonitoringService.cs:530).
  V2 was clearing only the WhiteGlove marker, so a crash/reboot during
  Part-2 AccountSetup would let the next start run the emergency-break
  watchdog against the original Part-1 timestamp and trip too early.
  AgentRuntimeHost now also calls SessionIdPersistence.SaveSessionCreatedAt
  (DateTime.UtcNow) right after ClearWhiteGloveComplete. New
  ProgramGuardsTests test asserting the V1-symmetric resume contract.

## Install-mode handoff hardening

Plan reference (memory): project_v2_install_runtime_handoff.md.
Bootstrap script + Program.InstallMode + InstallModeTaskXmlTests.

  PR1 — Replace `schtasks /Run` with WMI Win32_Process.Create for the
  install-time runtime handoff. schtasks /Run reports SUCCESS as soon
  as the run is queued; the same battery / AV / OOBE-defer paths that
  motivate PR2 also queue a manual /Run (observed WinPE 2026-05-04,
  task stuck in Queued state). WMI also gives the runtime process the
  same isolation Task Scheduler uses, without the Job-Object inheritance
  risk of a direct Process.Start.

  PR2 — Register the BootTrigger Scheduled Task from a hardened XML
  definition rather than `schtasks /Create`. The CLI form inherits XML
  schema defaults DisallowStartIfOnBatteries=true and
  StopIfGoingOnBatteries=true, which queue the BootTrigger run
  indefinitely on a laptop that boots on battery in OOBE / WinPE. The
  XML disables both, sets StartWhenAvailable, and pins
  ExecutionTimeLimit=PT0S so the long-running agent is never killed at
  the schtasks 72h CLI default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:36:37 +02:00

180 lines
6.7 KiB
C#

using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="DecisionGraphBuilder"/> — pure projection, zero IO. Covers node
/// de-duplication, terminal classification, dead-end preservation, visit-count semantics, and
/// empty / out-of-order inputs.
/// </summary>
public class DecisionGraphBuilderTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
private static DecisionTransitionRecord Transition(
int step, string from, string to, bool taken = true, string? deadEnd = null, string trigger = "t",
string reducer = "1.0.0")
=> new DecisionTransitionRecord
{
TenantId = TenantId,
SessionId = SessionId,
StepIndex = step,
FromStage = from,
ToStage = to,
Taken = taken,
DeadEndReason = deadEnd,
Trigger = trigger,
ReducerVersion = reducer,
IsTerminal = DecisionGraphBuilder.IsTerminalStage(to),
OccurredAtUtc = new DateTime(2026, 4, 21, 10, step, 0, DateTimeKind.Utc),
};
// ============================================================ Empty input
[Fact]
public void Build_returns_empty_projection_when_no_transitions()
{
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, Array.Empty<DecisionTransitionRecord>());
Assert.Equal(TenantId, graph.TenantId);
Assert.Equal(SessionId, graph.SessionId);
Assert.Empty(graph.Nodes);
Assert.Empty(graph.Edges);
Assert.Equal(string.Empty, graph.ReducerVersion);
}
// ============================================================ Node de-duplication
[Fact]
public void Build_deduplicates_stages_into_unique_nodes()
{
// Same stage visited twice through different paths — appears once in Nodes.
var transitions = new[]
{
Transition(0, "SessionStarted", "EspInProgress"),
Transition(1, "EspInProgress", "AccountSetup"),
Transition(2, "AccountSetup", "EspInProgress"), // back-edge (hypothetical)
Transition(3, "EspInProgress", "Completed"),
};
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
// Unique stages: SessionStarted, EspInProgress, AccountSetup, Completed = 4 nodes.
Assert.Equal(4, graph.Nodes.Count);
Assert.Contains(graph.Nodes, n => n.Id == "SessionStarted");
Assert.Contains(graph.Nodes, n => n.Id == "EspInProgress");
Assert.Contains(graph.Nodes, n => n.Id == "AccountSetup");
Assert.Contains(graph.Nodes, n => n.Id == "Completed");
}
// ============================================================ Terminal classification
[Theory]
[InlineData("Completed", "Succeeded")]
[InlineData("Failed", "Failed")]
[InlineData("WhiteGloveSealed", "PausedForPart2")]
public void Build_marks_terminal_stages_and_derives_outcome(string terminalStage, string expectedOutcome)
{
var transitions = new[]
{
Transition(0, "Start", terminalStage),
};
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
var node = graph.Nodes.Single(n => n.Id == terminalStage);
Assert.True(node.IsTerminal);
Assert.Equal(expectedOutcome, node.TerminalOutcome);
}
[Fact]
public void Build_leaves_non_terminal_nodes_unmarked()
{
var transitions = new[] { Transition(0, "SessionStarted", "EspInProgress") };
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
Assert.All(graph.Nodes, n =>
{
Assert.False(n.IsTerminal);
Assert.Null(n.TerminalOutcome);
});
}
// ============================================================ Edges
[Fact]
public void Build_preserves_all_edges_including_dead_ends()
{
var transitions = new[]
{
Transition(0, "EspInProgress", "Completed", taken: true),
Transition(1, "EspInProgress", "EspInProgress", taken: false,
deadEnd: "hybrid_reboot_gate_blocking", trigger: "EspExiting"),
};
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
Assert.Equal(2, graph.Edges.Count);
Assert.Contains(graph.Edges, e => e.Taken && e.ToStage == "Completed");
var deadEnd = graph.Edges.Single(e => !e.Taken);
Assert.Equal("hybrid_reboot_gate_blocking", deadEnd.DeadEndReason);
Assert.Equal("EspExiting", deadEnd.Trigger);
}
[Fact]
public void Build_orders_edges_by_StepIndex_even_if_input_is_shuffled()
{
// Repository sorts, but defend anyway — the builder is used directly in tests and
// could be fed arbitrary lists.
var transitions = new[]
{
Transition(2, "AccountSetup", "Completed"),
Transition(0, "SessionStarted", "EspInProgress"),
Transition(1, "EspInProgress", "AccountSetup"),
};
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
Assert.Equal(new[] { 0, 1, 2 }, graph.Edges.Select(e => e.StepIndex));
}
// ============================================================ VisitCount
[Fact]
public void Build_counts_only_taken_transitions_toward_VisitCount()
{
var transitions = new[]
{
Transition(0, "Start", "EspInProgress", taken: true),
Transition(1, "EspInProgress", "EspInProgress", taken: false, deadEnd: "blocked"),
Transition(2, "EspInProgress", "Completed", taken: true),
Transition(3, "Start", "EspInProgress", taken: true), // hypothetical retry
};
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
var esp = graph.Nodes.Single(n => n.Id == "EspInProgress");
Assert.Equal(2, esp.VisitCount); // two taken → EspInProgress; the dead-end does not count
var completed = graph.Nodes.Single(n => n.Id == "Completed");
Assert.Equal(1, completed.VisitCount);
}
[Fact]
public void Build_exposes_ReducerVersion_from_first_row()
{
var transitions = new[]
{
Transition(0, "Start", "EspInProgress", reducer: "1.4.2"),
Transition(1, "EspInProgress", "Completed", reducer: "1.4.2"),
};
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
Assert.Equal("1.4.2", graph.ReducerVersion);
}
}