mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(realmjoin): detect aborted RJ ESP (missing 110) and release the completion gate
An interactive logon during the RealmJoin first deployment (phase 100/101)
makes RJ reclassify the run as a secondary-user deployment, whose branch
writes 200/210 and never CompletedFirstDeployment (110) — no crash, no
reboot request. The V2 completion gate waited for exactly 110, so these
sessions hung until the 60-min hard timeout (session 224b2087; siblings
558bf067/3cc18724 delayed ~50 min the same way).
- promote realmjoin_phase_changed to a typed RealmJoinPhaseChanged signal
(dual-emitted by the adapter) so RealmJoinFacts.LastDeploymentPhase is
persisted across agent restarts
- abort rule: prev in {100,101} and current in {200,210} with no outcome
yet -> emit realmjoin_first_deployment_incomplete (Warning), cancel the
hard-timeout deadline, release the gate with outcome
FirstDeploymentIncomplete; also evaluated on the RealmJoinDetected
replay path (restart swallows the transition)
- deliberately NOT triggered by 101->0 (RJ service restart may still
complete the first deployment) nor by a first observation of 210
(session 6f1959c0: RJ already completed before agent boot)
- fix realmjoin_timeout always claiming "last phase: 0" - the persisted
phase now feeds the message
- backend EnrollmentTimeoutClassifier counts the new event as a gate
terminal; MCP resource catalog dual-synced
- CompleteIfDeferredOrBookkeep widened to a leading-effect list
Verified by replaying session 224b2087's signal log: gate releases at
15:05:17 (101->200) instead of hanging until the 15:54 deadline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -137,8 +137,17 @@ false` and an arm holds), `EspExiting`, `ImeUserSessionCompleted`,
|
||||
advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`:
|
||||
|
||||
1. **Completion gates** (currently only the RealmJoin AND-gate): if RealmJoin was
|
||||
detected and is neither resolved nor timed-out (hard 60-min timeout), completion is
|
||||
*deferred* — the session stays in stage and emits `completion_waiting`.
|
||||
detected and has no terminal outcome yet, completion is *deferred* — the session
|
||||
stays in stage and emits `completion_waiting`. Three terminals open the gate:
|
||||
`Resolved` (DeploymentPhase 110), `FirstDeploymentIncomplete` (phase left the
|
||||
first-deployment window 100/101 for 200/210 without 110 — the RJ ESP was aborted,
|
||||
typically by an interactive logon reclassifying the run as secondary-user; emits
|
||||
the `realmjoin_first_deployment_incomplete` Warning, session 224b2087), and
|
||||
`Timeout` (hard 60-min deadline). The abort rule reads the *persisted*
|
||||
`RealmJoinFacts.LastDeploymentPhase` (updated by the typed `RealmJoinPhaseChanged`
|
||||
signal), so it survives agent restarts — the `RealmJoinDetected` replay path
|
||||
evaluates it too. A first observation of 210 does **not** release (RJ may have
|
||||
completed before agent boot and deploy afterwards).
|
||||
2. `Finalizing` stage + ~5 s `FinalizingGrace` deadline.
|
||||
3. Grace fires → `Completed`, `EnrollmentComplete` outcome, `enrollment_complete`
|
||||
timeline event with full audit payload.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## 2026-07-23
|
||||
|
||||
* **Update**: `agent/decision-engine.md` — the RealmJoin completion gate has a third terminal, `FirstDeploymentIncomplete`. Session 224b2087 showed why: an interactive logon 17 s after DeploymentPhase 101 makes RealmJoin reclassify the run as a secondary-user deployment, whose branch writes 200/210 and never 110 — the gate stayed closed until the 60-min hard timeout although Hello and desktop were long resolved. `realmjoin_phase_changed` is now a typed `RealmJoinPhaseChanged` signal, so `RealmJoinFacts.LastDeploymentPhase` is persisted across agent restarts (this also fixes every `realmjoin_timeout` event claiming "last phase: 0"); a transition out of 100/101 into 200/210 releases the gate with the `realmjoin_first_deployment_incomplete` Warning, and the `RealmJoinDetected` replay path evaluates the same rule for restarts that swallow the transition. A first observation of 210 deliberately does not release — session 6f1959c0 started at 210 and only deployed afterwards.
|
||||
|
||||
* **Creation**: Added `url-registry.md` — all well-known hosts (own subdomains, both blob accounts, API origin, Graph, Entra login) are now defined once per component (`Constants.cs`, web `utils/config.ts`, MCP `src/config.ts`) and referenced everywhere else; three guard tests fail the build on any literal outside the registries. Written because the EU cutover missed two hardcoded copies of the legacy blob host — `LatestVersionsService` (feeds the portal's version badge) and `ValidateBootstrapCodeFunction` — which kept working only through the fail-soft legacy mirror in `build-agent.yml`; those two plus the agent `SelfUpdater` now use the `download.autopilotmonitor.com` alias (verified serving all four artifacts first). The portal's bootstrap allow-list accepts alias AND legacy host during the transition, which imposes a deploy order: web before (or with) backend. The sweep also surfaced a fourth copy of the www/portal host pair in `middleware.ts` (now derived from the registry via `hostRouting.ts`) and a backend short-link built on the bare apex domain, which only worked through a registrar-level 301.
|
||||
|
||||
## 2026-07-22
|
||||
|
||||
+8
-2
@@ -151,17 +151,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriggerPhaseChangedFromTest_emits_informational_only_no_decision_signal()
|
||||
public void TriggerPhaseChangedFromTest_emits_typed_signal_and_informational_event()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerPhaseChangedFromTest(prev: 100, curr: 200);
|
||||
|
||||
// Phase change is observability-only. There is no DecisionSignalKind.RealmJoinPhaseChanged.
|
||||
// 1) Typed signal — persists LastDeploymentPhase and drives the aborted-RJ-ESP
|
||||
// detection in the reducer (session 224b2087). Not Detected/Resolved.
|
||||
Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinDetected);
|
||||
Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinResolved);
|
||||
var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinPhaseChanged);
|
||||
Assert.Equal("RealmJoinWatcher", decision.SourceOrigin);
|
||||
Assert.Equal("200", decision.Payload![DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase]);
|
||||
Assert.Equal("100", decision.Payload[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase]);
|
||||
|
||||
// 2) Dual-emit informational event keeps the existing timeline entry.
|
||||
var info = f.Ingress.Posted.Single(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
|
||||
@@ -137,7 +137,24 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
|
||||
private void OnPhaseChanged(object? sender, RealmJoinPhaseChangedEventArgs e)
|
||||
{
|
||||
// Phase change is observability-only: no DecisionSignalKind, just a timeline event.
|
||||
// Dual-emission like OnDetected/OnResolved: (1) the typed signal persists the phase
|
||||
// into RealmJoinFacts.LastDeploymentPhase (restart-safe) and lets the reducer detect
|
||||
// the aborted-RJ-ESP shape (phase left 100/101 for 200/210 without 110 — session
|
||||
// 224b2087); (2) the InformationalEvent keeps the existing timeline entry.
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = e.CurrentPhase.ToString(),
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = e.PreviousPhase.ToString(),
|
||||
};
|
||||
_ingress.Post(
|
||||
kind: DecisionSignalKind.RealmJoinPhaseChanged,
|
||||
occurredAtUtc: _clock.UtcNow,
|
||||
sourceOrigin: SourceLabel,
|
||||
evidence: BuildEvidence(
|
||||
"realmjoin-phase-changed-v1",
|
||||
$"RealmJoin DeploymentPhase {e.PreviousPhase} -> {e.CurrentPhase}"),
|
||||
payload: payload);
|
||||
|
||||
// Both raw int (deploymentPhase / previousPhase) and human-readable name
|
||||
// (deploymentPhaseName / previousPhaseName) flow through — int stays for downstream
|
||||
// filters / KQL, name fields make the message + UI rows readable without a lookup.
|
||||
|
||||
@@ -241,6 +241,199 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_timeout");
|
||||
}
|
||||
|
||||
// ============================================================== Aborted first deployment (session 224b2087)
|
||||
|
||||
[Fact]
|
||||
public void PhaseChanged_leaving_first_deployment_for_200_releases_gate_with_warning()
|
||||
{
|
||||
// Session 224b2087: interactive logon 17 s after phase 101 → RJ reclassifies the run
|
||||
// as secondary-user deployment and writes 200/210, never 110. The 101 -> 200
|
||||
// transition is impossible in a healthy first deployment, so it releases the gate
|
||||
// with Outcome=FirstDeploymentIncomplete and a Warning timeline entry.
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState;
|
||||
Assert.NotEqual(SessionStage.Finalizing, state.Stage);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(10),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "200",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "101",
|
||||
}));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Equal("FirstDeploymentIncomplete", step.NewState.RealmJoinFacts.Outcome!.Value);
|
||||
Assert.NotNull(step.NewState.RealmJoinFacts.ResolvedUtc);
|
||||
Assert.Equal(200, step.NewState.RealmJoinFacts.LastDeploymentPhase!.Value);
|
||||
|
||||
// Hard-timeout deadline cancelled in state AND as a scheduler effect.
|
||||
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
Assert.Contains(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.CancelDeadline &&
|
||||
e.CancelDeadlineName == DeadlineNames.RealmJoinTimeout);
|
||||
|
||||
// Warning timeline entry names both phases.
|
||||
var warning = Assert.Single(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_first_deployment_incomplete");
|
||||
Assert.Equal("Warning", warning.Parameters!["severity"]);
|
||||
Assert.Equal("101", warning.Parameters["previousPhase"]);
|
||||
Assert.Equal("200", warning.Parameters["deploymentPhase"]);
|
||||
Assert.Contains("110", warning.Parameters["message"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhaseChanged_within_first_deployment_window_is_bookkeeping_only()
|
||||
{
|
||||
// 100 -> 101 stays inside the first-deployment window — no release, but the phase
|
||||
// fact must advance (it is the restart-safe witness for the abort rule).
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(6),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "100",
|
||||
}));
|
||||
|
||||
Assert.Equal(101, step.NewState.RealmJoinFacts.LastDeploymentPhase!.Value);
|
||||
Assert.Null(step.NewState.RealmJoinFacts.Outcome);
|
||||
Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
Assert.Empty(step.Effects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhaseChanged_from_completed_deployment_start_does_not_release()
|
||||
{
|
||||
// Session 6f1959c0 protection: RJ already stood at CompletedDeployment (210) when
|
||||
// the agent booted and only deployed AFTERWARDS (210 -> 200 -> 210). Neither
|
||||
// transition starts from the first-deployment window, so the abort rule must not
|
||||
// fire — this ambiguous shape keeps waiting for 110 / the hard timeout (status quo).
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "210" })).NewState;
|
||||
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(6),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "200",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "210",
|
||||
})).NewState;
|
||||
Assert.Null(state.RealmJoinFacts.Outcome);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(7),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "210",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "200",
|
||||
}));
|
||||
|
||||
Assert.Null(step.NewState.RealmJoinFacts.Outcome);
|
||||
Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detected_replay_after_restart_with_deployment_phase_releases_gate()
|
||||
{
|
||||
// Agent restarted between the 101 and 200 observations: the watcher re-fires
|
||||
// Detected with the CURRENT phase (200) and no PhaseChanged ever carries the
|
||||
// transition. The persisted LastDeploymentPhase=101 is the witness — the Detected
|
||||
// replay path must release the gate exactly like the PhaseChanged path.
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState;
|
||||
Assert.NotEqual(SessionStage.Finalizing, state.Stage);
|
||||
Assert.Equal(101, state.RealmJoinFacts.LastDeploymentPhase!.Value);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(12),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "200" }));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Equal("FirstDeploymentIncomplete", step.NewState.RealmJoinFacts.Outcome!.Value);
|
||||
Assert.Contains(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_first_deployment_incomplete");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timeout_message_reports_persisted_last_phase_instead_of_zero()
|
||||
{
|
||||
// Before RealmJoinPhaseChanged became a typed signal, LastDeploymentPhase was only
|
||||
// written at detection time — every realmjoin_timeout claimed "last phase: 0" no
|
||||
// matter how far RJ actually got, hiding this whole failure class from ops.
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(6),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "100",
|
||||
})).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.DesktopArrived, T0.AddMinutes(7))).NewState;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(8, DecisionSignalKind.DeadlineFired, T0.AddMinutes(65),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout }));
|
||||
|
||||
var timeout = Assert.Single(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_timeout");
|
||||
Assert.Contains("last phase: 101", timeout.Parameters!["message"]);
|
||||
Assert.Equal("101", timeout.Parameters["lastSeenPhase"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfDeploying_deferred_terminal_released_by_first_deployment_incomplete()
|
||||
{
|
||||
// Same deferred-release contract as RealmJoinResolved: when the SelfDeploying
|
||||
// terminal was deferred on the closed RJ gate, the aborted-first-deployment release
|
||||
// must complete the session directly (Completed + enrollment_complete), with the
|
||||
// Warning entry leading the effects.
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("rj-sd-3", "rj-tenant", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(2),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })).NewState;
|
||||
Assert.True(state.RealmJoinFacts.SelfDeployingDeferredCompletion?.Value);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(10),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "200",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "101",
|
||||
}));
|
||||
|
||||
Assert.Equal(SessionStage.Completed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentComplete, step.NewState.Outcome);
|
||||
Assert.Equal("FirstDeploymentIncomplete", step.NewState.RealmJoinFacts.Outcome!.Value);
|
||||
Assert.Contains(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_first_deployment_incomplete");
|
||||
Assert.Contains(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete");
|
||||
}
|
||||
|
||||
// ============================================================== SelfDeploying flow
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -113,9 +113,11 @@ public class EnrollmentTimeoutClassifierTests
|
||||
// Negative Hello terminals leave the agent waiting — must NOT count as resolved.
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("hello_provisioning_failed") }).HelloResolved);
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("hello_completion_timeout") }).HelloResolved);
|
||||
// Both RealmJoin gate terminals count as resolved (phase 110 or 60-min hard timeout).
|
||||
// All RealmJoin gate terminals count as resolved (phase 110, aborted first
|
||||
// deployment — session 224b2087 — or 60-min hard timeout).
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("realmjoin_resolved") }).RealmJoinResolved);
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("realmjoin_timeout") }).RealmJoinResolved);
|
||||
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("realmjoin_first_deployment_incomplete") }).RealmJoinResolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -147,9 +147,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
helloResolved = true;
|
||||
else if (Eq(type, "realmjoin_detected"))
|
||||
realmJoinDetected = true;
|
||||
// Either terminal opens the agent-side RealmJoin gate: phase 110 or the 60-min
|
||||
// hard timeout (both dual-emitted to the timeline by the agent/engine).
|
||||
else if (Eq(type, "realmjoin_resolved") || Eq(type, "realmjoin_timeout"))
|
||||
// Any terminal opens the agent-side RealmJoin gate: phase 110, the aborted
|
||||
// first deployment (phase left 100/101 for 200/210 without 110 — session
|
||||
// 224b2087) or the 60-min hard timeout (all emitted to the timeline by the
|
||||
// agent/engine).
|
||||
else if (Eq(type, "realmjoin_resolved") || Eq(type, "realmjoin_timeout")
|
||||
|| Eq(type, "realmjoin_first_deployment_incomplete"))
|
||||
realmJoinResolved = true;
|
||||
// Policy facts for the Hello-disabled / User-ESP-skipped completion mirror.
|
||||
// Contradicting observations are resolved pessimistically below (both-seen →
|
||||
|
||||
@@ -115,6 +115,7 @@ export const EVENT_TYPES_CATALOG = {
|
||||
'realmjoin_phase_changed',
|
||||
'realmjoin_resolved',
|
||||
'realmjoin_timeout',
|
||||
'realmjoin_first_deployment_incomplete',
|
||||
'realmjoin_package_started',
|
||||
'realmjoin_package_completed',
|
||||
],
|
||||
|
||||
@@ -9,11 +9,12 @@ using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||
namespace AutopilotMonitor.DecisionCore.Engine
|
||||
{
|
||||
// RealmJoin (RJ) deployment-tracking handlers. Plan: tasks/zany-gathering-oasis plan.
|
||||
// The agent posts five new DecisionSignalKinds when it observes the
|
||||
// The agent posts six DecisionSignalKinds when it observes the
|
||||
// HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters key + HKLM\SOFTWARE\RealmJoin\Packages
|
||||
// (and the HKU\<sid>\... user-scope counterpart). Detection arms a 60-min hard timeout
|
||||
// and gates the enrollment-completion AND-gate. Resolution (phase 110) or timeout
|
||||
// releases the gate so the session can complete.
|
||||
// and gates the enrollment-completion AND-gate. Resolution (phase 110), the
|
||||
// aborted-first-deployment release (phase left 100/101 for 200/210 without 110 —
|
||||
// session 224b2087) or the timeout releases the gate so the session can complete.
|
||||
public sealed partial class DecisionEngine
|
||||
{
|
||||
// Hard 60-minute deadline from RJ-detected. Not configurable (per design choice —
|
||||
@@ -25,6 +26,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
public static class RealmJoinPayloadKeys
|
||||
{
|
||||
public const string DeploymentPhase = "deploymentPhase";
|
||||
// Set on RealmJoinPhaseChanged: the phase the watcher observed immediately before
|
||||
// the transition. The reducer prefers the PERSISTED RealmJoinFacts.LastDeploymentPhase
|
||||
// (restart-safe) and only falls back to this process-local value.
|
||||
public const string PreviousPhase = "previousPhase";
|
||||
public const string PackageId = "packageId";
|
||||
public const string DisplayName = "displayName";
|
||||
public const string Version = "version";
|
||||
@@ -107,6 +112,25 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
}
|
||||
builder.RealmJoinFacts = updatedFacts;
|
||||
|
||||
// Replay path (agent restarted between the 101 and 200 observations): the watcher
|
||||
// re-fires Detected with the CURRENT phase and no PhaseChanged ever carries the
|
||||
// transition — the persisted LastDeploymentPhase is the only witness of the aborted
|
||||
// first deployment. Evaluate the abort rule here too so the gate does not stay
|
||||
// closed until the hard timeout.
|
||||
if (alreadyDetected
|
||||
&& phase.HasValue
|
||||
&& IsFirstDeploymentAbortTransition(state.RealmJoinFacts, state.RealmJoinFacts.LastDeploymentPhase?.Value, phase.Value))
|
||||
{
|
||||
return ReleaseFirstDeploymentIncomplete(
|
||||
state: state,
|
||||
signal: signal,
|
||||
builder: builder,
|
||||
nextStep: nextStep,
|
||||
previousPhase: state.RealmJoinFacts.LastDeploymentPhase!.Value,
|
||||
currentPhase: phase.Value,
|
||||
triggerBase: nameof(DecisionSignalKind.RealmJoinDetected));
|
||||
}
|
||||
|
||||
var effects = Array.Empty<DecisionEffect>();
|
||||
if (!alreadyDetected)
|
||||
{
|
||||
@@ -177,7 +201,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
preparedBuilder: builder,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.RealmJoinResolved),
|
||||
leadingEffect: cancelEffect);
|
||||
leadingEffects: cancelEffect != null ? new[] { cancelEffect } : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -237,7 +261,156 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
preparedBuilder: builder,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: $"DeadlineFired:{DeadlineNames.RealmJoinTimeout}",
|
||||
leadingEffect: timeoutEffect);
|
||||
leadingEffects: new[] { timeoutEffect });
|
||||
}
|
||||
|
||||
// RJ DeploymentPhase enum values relevant to the abort rule (mirrors
|
||||
// RealmJoin.Core.SoftwarePackaging.DeploymentPhase; the agent-side RealmJoinInfo
|
||||
// constants are not referencable from this netstandard shared lib).
|
||||
private const int RjPhaseRunningFirstDeployment = 100;
|
||||
private const int RjPhaseRunningFirstDeploymentAuto = 101;
|
||||
private const int RjPhaseCompletedFirstDeployment = 110;
|
||||
private const int RjPhaseRunningDeployment = 200;
|
||||
private const int RjPhaseCompletedDeployment = 210;
|
||||
|
||||
private static bool IsRealmJoinFirstDeploymentPhase(int phase) =>
|
||||
phase == RjPhaseRunningFirstDeployment || phase == RjPhaseRunningFirstDeploymentAuto;
|
||||
|
||||
/// <summary>
|
||||
/// The aborted-RJ-ESP shape (session 224b2087): the phase left the first-deployment
|
||||
/// window (100/101) for a regular deployment phase (200/210) — a transition that cannot
|
||||
/// occur in a healthy first deployment, whose only exit is CompletedFirstDeployment
|
||||
/// (110). Root cause observed in the RJ logs: an interactive logon lands seconds after
|
||||
/// 101, RJ reclassifies the run as a secondary-user deployment
|
||||
/// (isFirstMachineDeployment=False) and that branch writes 200/210, never 110.
|
||||
/// <para>
|
||||
/// Deliberately NOT triggered by 101 → 0 (RJ service restart may still complete the
|
||||
/// first deployment) nor by a first observation of 210 (RJ already completed before
|
||||
/// the agent booted — session 6f1959c0 starts 210 → 200; releasing there would be
|
||||
/// wrong). <paramref name="previousPhase"/> comes from the persisted
|
||||
/// <see cref="RealmJoinFacts.LastDeploymentPhase"/> where available so the rule
|
||||
/// survives an agent restart between the 101 and 200 observations.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static bool IsFirstDeploymentAbortTransition(RealmJoinFacts facts, int? previousPhase, int currentPhase) =>
|
||||
facts.DetectedUtc != null
|
||||
&& facts.Outcome == null
|
||||
&& previousPhase.HasValue
|
||||
&& IsRealmJoinFirstDeploymentPhase(previousPhase.Value)
|
||||
&& (currentPhase == RjPhaseRunningDeployment || currentPhase == RjPhaseCompletedDeployment);
|
||||
|
||||
/// <summary>
|
||||
/// Phase-transition observation from the watcher. Always persists the current phase
|
||||
/// into <see cref="RealmJoinFacts.LastDeploymentPhase"/> (restart-safe; also fixes the
|
||||
/// <c>realmjoin_timeout</c> event reporting "last phase: 0" regardless of how far RJ
|
||||
/// actually got). When <see cref="IsFirstDeploymentAbortTransition"/> matches, emits
|
||||
/// the <c>realmjoin_first_deployment_incomplete</c> Warning, cancels the hard-timeout
|
||||
/// deadline and releases the completion gate — for us RJ is finished at that point.
|
||||
/// </summary>
|
||||
private DecisionStep HandleRealmJoinPhaseChangedV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var currentPhase = TryReadPhase(signal);
|
||||
if (currentPhase == null)
|
||||
{
|
||||
var deadEnd = BumpStepBookkeeping(state, signal);
|
||||
var deadEndTransition = BuildDeadEndTransition(
|
||||
state: state,
|
||||
signal: signal,
|
||||
nextStepIndex: deadEnd.StepIndex,
|
||||
trigger: nameof(DecisionSignalKind.RealmJoinPhaseChanged),
|
||||
deadEndReason: "realmjoin_phase_changed_missing_phase");
|
||||
return new DecisionStep(deadEnd, deadEndTransition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
// Persisted fact first (survives agent restarts), watcher's process-local value
|
||||
// as fallback for the very first phase observation of a session.
|
||||
var previousPhase = state.RealmJoinFacts.LastDeploymentPhase?.Value
|
||||
?? TryReadInt(signal, RealmJoinPayloadKeys.PreviousPhase);
|
||||
|
||||
var nextStep = state.StepIndex + 1;
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||
builder.RealmJoinFacts = state.RealmJoinFacts.WithLastPhase(currentPhase.Value, signal.SessionSignalOrdinal);
|
||||
|
||||
if (!IsFirstDeploymentAbortTransition(state.RealmJoinFacts, previousPhase, currentPhase.Value))
|
||||
{
|
||||
var bookkept = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: state.Stage,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.RealmJoinPhaseChanged));
|
||||
return new DecisionStep(bookkept, transition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
return ReleaseFirstDeploymentIncomplete(
|
||||
state: state,
|
||||
signal: signal,
|
||||
builder: builder,
|
||||
nextStep: nextStep,
|
||||
previousPhase: previousPhase!.Value,
|
||||
currentPhase: currentPhase.Value,
|
||||
triggerBase: nameof(DecisionSignalKind.RealmJoinPhaseChanged));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared gate-release for the aborted-first-deployment rule — called from
|
||||
/// <see cref="HandleRealmJoinPhaseChangedV1"/> and from the
|
||||
/// <see cref="HandleRealmJoinDetectedV1"/> replay path (agent restarted between the
|
||||
/// 101 and 200 observations: the watcher re-fires Detected with the CURRENT phase and
|
||||
/// no PhaseChanged ever carries the transition, so the persisted fact is the only
|
||||
/// witness). Mirrors <see cref="HandleRealmJoinResolvedV1"/>'s release shape with a
|
||||
/// Warning timeline entry ahead of the completion effects.
|
||||
/// </summary>
|
||||
private DecisionStep ReleaseFirstDeploymentIncomplete(
|
||||
DecisionState state,
|
||||
DecisionSignal signal,
|
||||
DecisionStateBuilder builder,
|
||||
int nextStep,
|
||||
int previousPhase,
|
||||
int currentPhase,
|
||||
string triggerBase)
|
||||
{
|
||||
builder.CancelDeadline(DeadlineNames.RealmJoinTimeout);
|
||||
builder.RealmJoinFacts = builder.RealmJoinFacts.WithFirstDeploymentIncomplete(
|
||||
signal.OccurredAtUtc, currentPhase, signal.SessionSignalOrdinal);
|
||||
|
||||
var leadingEffects = new List<DecisionEffect>(capacity: 2);
|
||||
var cancelEffect = BuildRealmJoinTimeoutCancelEffectIfArmed(state);
|
||||
if (cancelEffect != null) leadingEffects.Add(cancelEffect);
|
||||
leadingEffects.Add(BuildRealmJoinFirstDeploymentIncompleteEvent(previousPhase, currentPhase));
|
||||
|
||||
return CompleteIfDeferredOrBookkeep(
|
||||
state: state,
|
||||
signal: signal,
|
||||
preparedBuilder: builder,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: triggerBase + ":FirstDeploymentIncomplete",
|
||||
leadingEffects: leadingEffects);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the <c>realmjoin_first_deployment_incomplete</c> Warning timeline entry.
|
||||
/// Reducer-owned (like <see cref="BuildRealmJoinTimeoutEvent"/>) — the agent adapter
|
||||
/// only dual-emits the neutral <c>realmjoin_phase_changed</c> observation; the abort
|
||||
/// interpretation lives here where the persisted facts are.
|
||||
/// </summary>
|
||||
private static DecisionEffect BuildRealmJoinFirstDeploymentIncompleteEvent(int previousPhase, int currentPhase)
|
||||
{
|
||||
return new DecisionEffect(
|
||||
kind: DecisionEffectKind.EmitEventTimelineEntry,
|
||||
parameters: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[SignalPayloadKeys.EventType] = SharedConstants.EventTypes.RealmJoinFirstDeploymentIncomplete,
|
||||
[SignalPayloadKeys.Source] = "DecisionEngine",
|
||||
[SignalPayloadKeys.Severity] = "Warning",
|
||||
[SignalPayloadKeys.Message] =
|
||||
$"RealmJoin left first deployment (phase {previousPhase} -> {currentPhase}) without CompletedFirstDeployment (110) — the RealmJoin ESP likely aborted. Treating the RealmJoin deployment as finished.",
|
||||
["previousPhase"] = previousPhase.ToString(CultureInfo.InvariantCulture),
|
||||
["deploymentPhase"] = currentPhase.ToString(CultureInfo.InvariantCulture),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -360,7 +533,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
DecisionStateBuilder preparedBuilder,
|
||||
int nextStepIndex,
|
||||
string trigger,
|
||||
DecisionEffect? leadingEffect)
|
||||
IReadOnlyList<DecisionEffect>? leadingEffects)
|
||||
{
|
||||
var selfDeployingDeferred = state.RealmJoinFacts.SelfDeployingDeferredCompletion?.Value == true;
|
||||
var classicReady = state.HelloResolvedUtc != null && state.DesktopArrivedUtc != null;
|
||||
@@ -421,8 +594,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// Plan v9 Phase 4 — UI phase coverage: emit FinalizingSetup + Complete phase
|
||||
// declarations BEFORE enrollment_complete so the Web timeline opens both bars
|
||||
// for RJ-deferred-completion just like the direct SelfDeploying-terminal path.
|
||||
var effects = new List<DecisionEffect>(capacity: 4);
|
||||
if (leadingEffect != null) effects.Add(leadingEffect);
|
||||
var effects = new List<DecisionEffect>(capacity: (leadingEffects?.Count ?? 0) + 3);
|
||||
if (leadingEffects != null && leadingEffects.Count > 0) effects.AddRange(leadingEffects);
|
||||
effects.Add(BuildPhaseTransitionEffect(EnrollmentPhase.FinalizingSetup, completedState, trigger + ":SelfDeployingDeferred"));
|
||||
effects.Add(BuildPhaseTransitionEffect(EnrollmentPhase.Complete, completedState, trigger + ":SelfDeployingDeferred"));
|
||||
effects.Add(BuildEnrollmentCompleteEffect(completedState, trigger + ":SelfDeployingDeferred"));
|
||||
@@ -434,19 +607,19 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
if (classicReady)
|
||||
{
|
||||
// This IS the gate-release path: the RealmJoin gate has just opened (the caller
|
||||
// wrote WithResolved / WithTimeoutOutcome into preparedBuilder), so complete
|
||||
// directly. Note the completion gates read `state` (pre-resolution), where RJ is
|
||||
// still closed — routing this through CompleteThroughFinalizingOrDefer would
|
||||
// re-defer on the very gate we just released. A future second gate that must
|
||||
// re-block here would re-check the *post* state (WDP-v2 follow-up, ARCH-F1).
|
||||
var extra = leadingEffect != null ? new[] { leadingEffect } : null;
|
||||
// wrote WithResolved / WithTimeoutOutcome / WithFirstDeploymentIncomplete into
|
||||
// preparedBuilder), so complete directly. Note the completion gates read `state`
|
||||
// (pre-resolution), where RJ is still closed — routing this through
|
||||
// CompleteThroughFinalizingOrDefer would re-defer on the very gate we just
|
||||
// released. A future second gate that must re-block here would re-check the
|
||||
// *post* state (WDP-v2 follow-up, ARCH-F1).
|
||||
return TransitionToFinalizing(
|
||||
state: state,
|
||||
signal: signal,
|
||||
preparedBuilder: preparedBuilder,
|
||||
nextStepIndex: nextStepIndex,
|
||||
trigger: trigger,
|
||||
extraLeadingEffects: extra);
|
||||
extraLeadingEffects: leadingEffects);
|
||||
}
|
||||
|
||||
// Bookkeeping only — defer to the next Hello / Desktop / SelfDeploying signal.
|
||||
@@ -458,10 +631,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
nextStepIndex: nextStepIndex,
|
||||
trigger: trigger);
|
||||
|
||||
var bookkeepEffects = leadingEffect != null
|
||||
? new[] { leadingEffect }
|
||||
: Array.Empty<DecisionEffect>();
|
||||
return new DecisionStep(newState, transition, bookkeepEffects);
|
||||
return new DecisionStep(newState, transition, MaterializeEffects(leadingEffects));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -143,6 +143,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// ----- RealmJoin deployment tracking (DecisionEngine.RealmJoin.cs) -----
|
||||
(DecisionSignalKind.RealmJoinDetected, 1) => HandleRealmJoinDetectedV1(state, signal),
|
||||
(DecisionSignalKind.RealmJoinResolved, 1) => HandleRealmJoinResolvedV1(state, signal),
|
||||
(DecisionSignalKind.RealmJoinPhaseChanged, 1) => HandleRealmJoinPhaseChangedV1(state, signal),
|
||||
(DecisionSignalKind.RealmJoinPackageStarted, 1) => HandleRealmJoinPackageStartedV1(state, signal),
|
||||
(DecisionSignalKind.RealmJoinPackageCompleted, 1) => HandleRealmJoinPackageCompletedV1(state, signal),
|
||||
// Note: RealmJoinTimeout is a deadline-only kind (no direct adapter signal) —
|
||||
|
||||
@@ -118,5 +118,12 @@ namespace AutopilotMonitor.DecisionCore.Signals
|
||||
RealmJoinTimeout,
|
||||
RealmJoinPackageStarted,
|
||||
RealmJoinPackageCompleted,
|
||||
// Session 224b2087 (2026-07-22): DeploymentPhase transition observed by the watcher.
|
||||
// Promoted from InformationalEvent-only so RealmJoinFacts.LastDeploymentPhase is
|
||||
// persisted across agent restarts AND so the reducer can detect the aborted-RJ-ESP
|
||||
// shape: an interactive logon during the first deployment (100/101) makes RJ reclassify
|
||||
// the run as a secondary-user deployment, which writes 200/210 and NEVER 110 — the
|
||||
// gate would otherwise stay closed until the 60-min hard timeout.
|
||||
RealmJoinPhaseChanged,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
/// <list type="bullet">
|
||||
/// <item>Immutable; <see cref="With"/>-methods return new instances.</item>
|
||||
/// <item><see cref="Packages"/> is capped at <see cref="MaxPackages"/>; overflow is discarded.</item>
|
||||
/// <item><see cref="ResolvedUtc"/> is set once on observing phase 110; later phase readings update <see cref="LastDeploymentPhase"/> only.</item>
|
||||
/// <item><see cref="Outcome"/> is one of <c>"Resolved"</c> / <c>"Timeout"</c>; null while RJ is still running.</item>
|
||||
/// <item><see cref="ResolvedUtc"/> is set once on observing phase 110 (or the aborted-first-deployment release); later phase readings update <see cref="LastDeploymentPhase"/> only.</item>
|
||||
/// <item><see cref="Outcome"/> is one of <c>"Resolved"</c> / <c>"Timeout"</c> / <c>"FirstDeploymentIncomplete"</c>; null while RJ is still running.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class RealmJoinFacts
|
||||
@@ -25,6 +25,11 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
|
||||
public const string OutcomeResolved = "Resolved";
|
||||
public const string OutcomeTimeout = "Timeout";
|
||||
// Session 224b2087 (2026-07-22): the RJ ESP was aborted (interactive logon during the
|
||||
// first deployment reclassified the run as secondary-user) — DeploymentPhase left
|
||||
// 100/101 for 200/210 without ever writing CompletedFirstDeployment (110). The gate
|
||||
// releases with this distinct outcome so reporting can tell the three cases apart.
|
||||
public const string OutcomeFirstDeploymentIncomplete = "FirstDeploymentIncomplete";
|
||||
|
||||
public static readonly RealmJoinFacts Empty = new RealmJoinFacts(
|
||||
detectedUtc: null,
|
||||
@@ -161,6 +166,27 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
packages: Packages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Terminal outcome for the aborted-RJ-ESP case: DeploymentPhase left the
|
||||
/// first-deployment window (100/101) for a regular deployment phase (200/210) without
|
||||
/// ever reaching CompletedFirstDeployment (110). Sets <see cref="ResolvedUtc"/> (this
|
||||
/// IS our resolution moment — RJ counts as finished for the completion gate) with
|
||||
/// <see cref="OutcomeFirstDeploymentIncomplete"/>. No-op when an outcome is already set.
|
||||
/// </summary>
|
||||
public RealmJoinFacts WithFirstDeploymentIncomplete(DateTime utc, int phase, long sourceSignalOrdinal)
|
||||
{
|
||||
if (Outcome != null) return this; // resolved/timeout already wins
|
||||
return new RealmJoinFacts(
|
||||
detectedUtc: DetectedUtc,
|
||||
resolvedUtc: new SignalFact<DateTime>(utc, sourceSignalOrdinal),
|
||||
lastDeploymentPhase: new SignalFact<int>(phase, sourceSignalOrdinal),
|
||||
outcome: new SignalFact<string>(OutcomeFirstDeploymentIncomplete, sourceSignalOrdinal),
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
releaseChannel: ReleaseChannel,
|
||||
packages: Packages);
|
||||
}
|
||||
|
||||
public RealmJoinFacts WithLastPhase(int phase, long sourceSignalOrdinal)
|
||||
{
|
||||
if (LastDeploymentPhase != null && LastDeploymentPhase.Value == phase) return this;
|
||||
|
||||
@@ -463,6 +463,11 @@ namespace AutopilotMonitor.Shared
|
||||
public const string RealmJoinPhaseChanged = "realmjoin_phase_changed";
|
||||
public const string RealmJoinResolved = "realmjoin_resolved";
|
||||
public const string RealmJoinTimeout = "realmjoin_timeout";
|
||||
// Warning: DeploymentPhase left the first-deployment window (100/101) for 200/210
|
||||
// without CompletedFirstDeployment (110) — the RJ ESP was aborted (typically an
|
||||
// interactive logon during the first deployment reclassified the run as
|
||||
// secondary-user). The completion gate releases at this point.
|
||||
public const string RealmJoinFirstDeploymentIncomplete = "realmjoin_first_deployment_incomplete";
|
||||
public const string RealmJoinPackageStarted = "realmjoin_package_started";
|
||||
public const string RealmJoinPackageCompleted = "realmjoin_package_completed";
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# RealmJoinPhaseChanged v1 — RealmJoin DeploymentPhase transition.
|
||||
# Posted by RealmJoinWatcherAdapter on every observed DeploymentPhase change (dual-emitted
|
||||
# alongside the existing realmjoin_phase_changed InformationalEvent). Payload carries the
|
||||
# current and previous phase DWORDs.
|
||||
# Reducer: HandleRealmJoinPhaseChangedV1 → always updates RealmJoinFacts.LastDeploymentPhase
|
||||
# (restart-safe persistence; fixes realmjoin_timeout reporting "last phase: 0"). When the
|
||||
# phase leaves the first-deployment window (100/101) for a regular deployment phase (200/210)
|
||||
# without CompletedFirstDeployment (110) ever being observed, the RJ ESP was aborted (e.g.
|
||||
# interactive logon during first deployment reclassified the run as secondary-user): emits
|
||||
# realmjoin_first_deployment_incomplete (Warning), cancels the RealmJoinTimeout deadline and
|
||||
# releases the completion gate via CompleteIfDeferredOrBookkeep with
|
||||
# Outcome="FirstDeploymentIncomplete".
|
||||
{"SessionSignalOrdinal":43,"SessionTraceOrdinal":43,"Kind":"RealmJoinPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T10:26:00.0000000Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-phase-changed-v1","Summary":"RealmJoin DeploymentPhase 101 -> 200","DerivationInputs":{"registryKey":"HKLM\\SYSTEM\\CurrentControlSet\\Services\\realmjoin\\Parameters"}},"Payload":{"deploymentPhase":"200","previousPhase":"101"}}
|
||||
Reference in New Issue
Block a user