From 4af0adb3edc09ea9ca04716a2b40f78ceec54f27 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 3 Jul 2026 10:45:22 +0200 Subject: [PATCH] fix(decision-core): parked-tripwire dwell, IME evidence upgrade rule, advisory anchor fix (M1, M2, L5, L8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delta-review 2026-07-02 findings: - M1: session_parked_without_deadline evaluated the parked predicate on the very step that produced the state, so the healthy Classic window (HelloResolved -> AwaitingDesktop, no deadline, desktop seconds away) fired a spurious Warning AND consumed the one-shot, masking a real variant-4 park later in the run. The tripwire now only arms a 10-min dwell timer; it fires (and consumes the one-shot) only if the session is still parked when the dwell elapses. Steps that clear the parked condition cancel the pending arm; parked pass-throughs do not re-base it. Processor is now IDisposable (timer), disposed by the orchestrator. - L8 (rides along): the tripwire post moved off the ingress worker onto the timer thread — a full channel can no longer self-deadlock on it. - M2: ImeUserSessionCompletedUtc was set-once, so an early defaultuser0 completion permanently failed the AdvisoryCompletion conjunction and resolved genuinely-completed sessions as Failed. New rule: first post-AccountSetup observation wins (one upgrade of a pre-anchor stamp, then frozen). Pure function of (state, signal) — replay-safe. - L5: HandleAdvisoryCompletionDeadlineFired`s esp-exit anchor is presence-only now; the old >= AccountSetupEntered comparison rejected fires whose arming exit carried a backfilled source timestamp, re-opening the dead-end. deadlineStillArmed remains the stray-fire guard. Co-Authored-By: Claude Fable 5 --- ...ecisionStepProcessorParkedTripwireTests.cs | 152 +++++++++++++++--- .../Orchestration/DecisionStepProcessor.cs | 123 ++++++++++++-- .../Orchestration/EnrollmentOrchestrator.cs | 1 + .../AdvisoryCompletionDeadlineTests.cs | 91 ++++++++++- .../Engine/DecisionEngine.Classic.cs | 20 ++- .../Engine/DecisionEngine.Edge.cs | 15 +- 6 files changed, 356 insertions(+), 46 deletions(-) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorParkedTripwireTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorParkedTripwireTests.cs index c5bc9eba..90d5d20c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorParkedTripwireTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorParkedTripwireTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Orchestration; using AutopilotMonitor.Agent.V2.Core.Tests.Harness; @@ -18,11 +19,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration /// itself via a one-shot session_parked_without_deadline Warning. The parked states /// here are built artificially via the builder — production arming sites now prevent them, /// which is exactly why the tripwire exists (it catches the unknown variant 4). + /// + /// M1 (delta review 2026-07-02): the tripwire is dwell-based — a parked step only ARMS a + /// timer; the Warning fires (and consumes the one-shot) only if the session is still parked + /// when the dwell elapses. A healthy transient park (HelloResolved → AwaitingDesktop, + /// desktop arrives seconds later) must neither fire nor burn the one-shot. + /// /// + [Collection("SerialThreading")] // timer-driven assertions — serialise against other timing-sensitive suites public sealed class DecisionStepProcessorParkedTripwireTests : IDisposable { private static DateTime At => new DateTime(2026, 6, 12, 10, 0, 0, DateTimeKind.Utc); + /// Short dwell so fire-path tests complete quickly; suppression tests wait ≥10× this. + private static readonly TimeSpan TestDwell = TimeSpan.FromMilliseconds(25); + private static readonly TimeSpan FireWait = TimeSpan.FromSeconds(5); + private static readonly TimeSpan SuppressWait = TimeSpan.FromMilliseconds(400); + private readonly TempDirectory _tmp = new TempDirectory(); private readonly AgentLogger _logger; private readonly FakeJournalWriter _journal = new FakeJournalWriter(); @@ -31,23 +44,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration private readonly FakeQuarantineSink _quarantine = new FakeQuarantineSink(); private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink(); private readonly DecisionEngine _engine = new DecisionEngine(); + private DecisionStepProcessor _sut; public DecisionStepProcessorParkedTripwireTests() { _logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info); } - public void Dispose() => _tmp.Dispose(); + public void Dispose() + { + _sut?.Dispose(); + _tmp.Dispose(); + } - private DecisionStepProcessor Build(DecisionState initialState) => - new DecisionStepProcessor( + private DecisionStepProcessor Build(DecisionState initialState, TimeSpan? dwell = null) => + _sut = new DecisionStepProcessor( initialState: initialState, journal: _journal, effectRunner: _effects, snapshot: _snapshot, quarantineSink: _quarantine, logger: _logger, - informationalEvents: new InformationalEventPost(_sink, new FixedClock(At), _logger)); + informationalEvents: new InformationalEventPost(_sink, new FixedClock(At), _logger), + parkedTripwireDwell: dwell ?? TestDwell); /// /// Baseline parked shape: AccountSetup entered, ESP exited finally AFTER that entry, @@ -88,7 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration return (step, signal); } - private FakeSignalIngressSink.PostedSignal? FindTripwirePost() + private FakeSignalIngressSink.PostedSignal FindTripwirePost() { foreach (var posted in _sink.Posted) { @@ -117,28 +136,67 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration return count; } + /// Blocks until at least one tripwire post landed (dwell elapsed) or times out. + private bool WaitForTripwire() => + SpinWait.SpinUntil(() => CountTripwirePosts() >= 1, FireWait); + + /// Proves absence: waits well past the dwell, then asserts no post arrived. + private void AssertNoTripwireAfterDwell() + { + Thread.Sleep(SuppressWait); + Assert.Equal(0, CountTripwirePosts()); + } + // ===================================================================== Fires [Fact] - public void Parked_state_fires_tripwire_exactly_once() + public void Parked_state_fires_tripwire_exactly_once_after_dwell() { var sut = Build(BuildParkedState()); var (s1, sig1) = ReduceInformational(sut, 10); sut.ApplyStep(s1, sig1); + + Assert.True(WaitForTripwire(), "tripwire did not fire within the wait budget"); + + // Further parked pass-throughs after the fire must not emit again (one-shot). var (s2, sig2) = ReduceInformational(sut, 11); sut.ApplyStep(s2, sig2); - + Thread.Sleep(SuppressWait); Assert.Equal(1, CountTripwirePosts()); - var post = FindTripwirePost()!; + var post = FindTripwirePost(); Assert.Equal(DecisionSignalKind.InformationalEvent, post.Kind); - Assert.Equal(nameof(SessionStage.EspAccountSetup), post.Payload!["stage"]); + Assert.Equal(nameof(SessionStage.EspAccountSetup), post.Payload["stage"]); Assert.Equal("Warning", post.Payload[SignalPayloadKeys.Severity]); Assert.Equal("true", post.Payload[SignalPayloadKeys.ImmediateUpload]); Assert.Contains("esp_final_exit", post.Payload["signalsSeen"]); Assert.Contains("account_setup_entered", post.Payload["signalsSeen"]); Assert.Equal(string.Empty, post.Payload["armedDeadlines"]); + Assert.True(post.Payload.ContainsKey("dwellSeconds")); + } + + [Fact] + public void Parked_passthrough_steps_do_not_rebase_the_dwell() + { + // A truly parked session still produces telemetry pass-throughs; if each of them + // re-based the dwell the tripwire would never fire exactly when it matters most. + var sut = Build(BuildParkedState(), dwell: TimeSpan.FromMilliseconds(150)); + + var (s1, sig1) = ReduceInformational(sut, 10); + sut.ApplyStep(s1, sig1); + + // Keep feeding parked pass-throughs faster than the dwell. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + long ordinal = 11; + while (CountTripwirePosts() == 0 && DateTime.UtcNow < deadline) + { + var (s, sig) = ReduceInformational(sut, ordinal++); + sut.ApplyStep(s, sig); + Thread.Sleep(20); + } + + Assert.Equal(1, CountTripwirePosts()); } [Fact] @@ -149,6 +207,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration var (step, signal) = ReduceInformational(sut, 10); sut.ApplyStep(step, signal); + Assert.True(WaitForTripwire()); Assert.Equal(1, CountTripwirePosts()); } @@ -162,24 +221,67 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration var (step, signal) = ReduceInformational(sut, 10); sut.ApplyStep(step, signal); - Assert.Equal(1, CountTripwirePosts()); - Assert.Equal(DeadlineNames.ClassifierTick, FindTripwirePost()!.Payload!["armedDeadlines"]); + Assert.True(WaitForTripwire()); + Assert.Equal(DeadlineNames.ClassifierTick, FindTripwirePost().Payload["armedDeadlines"]); } [Fact] public void AwaitingDesktop_parked_fires_with_stage_in_data() { // Deliberate design decision (plan PR1): AwaitingDesktop has no deadline today, so a - // parked AwaitingDesktop session IS visible via the tripwire. The stage field keeps - // it distinguishable in the field; a dedicated DesktopSafety deadline is a possible - // later iteration — NOT part of PR1. + // parked AwaitingDesktop session IS visible via the tripwire — but only after the + // dwell, so the healthy Hello→Desktop window stays silent (see the dwell tests). var sut = Build(BuildParkedState(stage: SessionStage.AwaitingDesktop)); var (step, signal) = ReduceInformational(sut, 10); sut.ApplyStep(step, signal); + Assert.True(WaitForTripwire()); + Assert.Equal(nameof(SessionStage.AwaitingDesktop), FindTripwirePost().Payload["stage"]); + } + + // ===================================================================== Dwell semantics + + [Fact] + public void Transient_park_resolved_before_dwell_neither_fires_nor_burns_the_one_shot() + { + // The healthy Classic window: HelloResolved cancels HelloSafety → AwaitingDesktop + // with no deadline; DesktopArrived (here: a resolution deadline appearing) follows + // before the dwell elapses. + var sut = Build(BuildParkedState(stage: SessionStage.AwaitingDesktop), dwell: TimeSpan.FromMilliseconds(250)); + + var (s1, sig1) = ReduceInformational(sut, 10); + sut.ApplyStep(s1, sig1); + + // Park resolves quickly: next step carries a resolution-capable deadline. + var resolvedState = BuildParkedState( + stage: SessionStage.AwaitingDesktop, + deadlines: Deadline(DeadlineNames.AdvisoryCompletion)); + var (s2Template, sig2) = ReduceInformational(sut, 11); + sut.ApplyStep(new DecisionStep(resolvedState, s2Template.Transition, Array.Empty()), sig2); + + AssertNoTripwireAfterDwell(); // no false alarm + + // A REAL park later in the same run must still be able to fire — the one-shot was + // not consumed by the transient window. + var parkedAgain = BuildParkedState(stage: SessionStage.AwaitingDesktop); + var (s3Template, sig3) = ReduceInformational(sut, 12); + sut.ApplyStep(new DecisionStep(parkedAgain, s3Template.Transition, Array.Empty()), sig3); + + Assert.True(WaitForTripwire(), "genuine park after a transient window must still fire"); Assert.Equal(1, CountTripwirePosts()); - Assert.Equal(nameof(SessionStage.AwaitingDesktop), FindTripwirePost()!.Payload!["stage"]); + } + + [Fact] + public void Dispose_cancels_a_pending_dwell() + { + var sut = Build(BuildParkedState()); + + var (step, signal) = ReduceInformational(sut, 10); + sut.ApplyStep(step, signal); + sut.Dispose(); + + AssertNoTripwireAfterDwell(); } // ===================================================================== Suppressed @@ -192,7 +294,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration var (step, signal) = ReduceInformational(sut, 10); sut.ApplyStep(step, signal); - Assert.Equal(0, CountTripwirePosts()); + AssertNoTripwireAfterDwell(); } [Fact] @@ -204,7 +306,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration var (step, signal) = ReduceInformational(sut, 10); sut.ApplyStep(step, signal); - Assert.Equal(0, CountTripwirePosts()); + AssertNoTripwireAfterDwell(); } [Fact] @@ -215,7 +317,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration var (step, signal) = ReduceInformational(sut, 10); sut.ApplyStep(step, signal); - Assert.Equal(0, CountTripwirePosts()); + AssertNoTripwireAfterDwell(); } [Fact] @@ -226,7 +328,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration var (step, signal) = ReduceInformational(sut, 10); sut.ApplyStep(step, signal); - Assert.Equal(0, CountTripwirePosts()); + AssertNoTripwireAfterDwell(); } [Fact] @@ -238,7 +340,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration var (step, signal) = ReduceInformational(sut, 10); sut.ApplyStep(step, signal); - Assert.Equal(0, CountTripwirePosts()); + AssertNoTripwireAfterDwell(); } [Fact] @@ -252,7 +354,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration var (step, signal) = ReduceInformational(sut, 10); sut.ApplyStep(step, signal); - Assert.Equal(0, CountTripwirePosts()); + AssertNoTripwireAfterDwell(); } [Fact] @@ -269,24 +371,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration var (step, signal) = ReduceInformational(sut, 10); sut.ApplyStep(step, signal); - Assert.Equal(0, CountTripwirePosts()); + AssertNoTripwireAfterDwell(); } [Fact] public void Without_informational_post_wired_parked_state_is_tolerated() { // Legacy ctor shape (no InformationalEventPost) — the tripwire is simply disabled. - var sut = new DecisionStepProcessor( + var sut = _sut = new DecisionStepProcessor( initialState: BuildParkedState(), journal: _journal, effectRunner: _effects, snapshot: _snapshot, quarantineSink: _quarantine, - logger: _logger); + logger: _logger, + parkedTripwireDwell: TestDwell); var (step, signal) = ReduceInformational(sut, 10); sut.ApplyStep(step, signal); + Thread.Sleep(SuppressWait); Assert.Empty(_sink.Posted); } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs index f47d3858..7a78662b 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs @@ -45,11 +45,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// nächsten Reduce, ebenfalls unlockbar sicher. /// /// - public sealed class DecisionStepProcessor : IDecisionStepProcessor + public sealed class DecisionStepProcessor : IDecisionStepProcessor, IDisposable { /// Default — 3 Failures hintereinander → Quarantine. public const int DefaultQuarantineThreshold = 3; + /// + /// M1 (delta review 2026-07-02) — dwell before the parked tripwire fires. The parked + /// predicate is true during a HEALTHY transient window: Classic HelloResolved (or the + /// HelloSafety timeout) cancels the only armed deadline and moves to AwaitingDesktop + /// seconds-to-minutes before DesktopArrived; firing on the step that produces that state + /// is a false alarm AND consumes the one-shot, masking a real variant-4 park later in + /// the run. 10 min comfortably clears DesktopArrivalDetector's validation latency while + /// staying far below the 360-min max-lifetime watchdog. + /// + public static readonly TimeSpan DefaultParkedTripwireDwell = TimeSpan.FromMinutes(10); + /// /// H1a (Wave 2) — the snapshot is a recovery CACHE, not the source of truth (SignalLog + /// Journal are, both still fsync'd per step). Pure InformationalEvent pass-throughs (Stage @@ -77,6 +88,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration private int _passThroughStepsSinceSnapshot; // H1a — pass-through steps skipped since last snapshot private bool _parkedTripwireFired; // liveness PR1 — one-shot per agent run, no state-schema touch + // M1 — parked-tripwire dwell machinery. The timer thread is also what fixes L8: the + // tripwire post no longer runs on the ingress worker, so a full channel cannot + // self-deadlock on it. Guarded by _parkedLock because the callback races ApplyStep. + private readonly object _parkedLock = new object(); + private readonly TimeSpan _parkedTripwireDwell; + private Timer? _parkedDwellTimer; + private DecisionSignal? _parkedCandidateSignal; + private bool _disposed; + public DecisionStepProcessor( DecisionState initialState, IJournalWriter journal, @@ -87,7 +107,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration int quarantineThreshold = DefaultQuarantineThreshold, Action? onTerminalStageReached = null, TelemetryTransitionEmitter? transitionEmitter = null, - InformationalEventPost? informationalEvents = null) + InformationalEventPost? informationalEvents = null, + TimeSpan? parkedTripwireDwell = null) { if (quarantineThreshold <= 0) { @@ -106,6 +127,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration _onTerminalStageReached = onTerminalStageReached; _transitionEmitter = transitionEmitter; _informationalEvents = informationalEvents; + _parkedTripwireDwell = parkedTripwireDwell ?? DefaultParkedTripwireDwell; // If recovery loaded a state that already sits on a terminal stage (e.g. a crash // after a success-path step but before Stop()), treat it as already-notified so we @@ -278,18 +300,95 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration // arming sites; this invariant check is the tripwire for variant 4. Expected to // NEVER fire in production — every occurrence is a bug report. Skipped on abort // steps: the caller is about to synthesise a terminal signal anyway. - if (!_parkedTripwireFired - && _informationalEvents != null - && !effectResult.SessionMustAbort - && IsParkedWithoutDeadline(_currentState)) + // + // M1 (delta review 2026-07-02): the predicate is true during a HEALTHY transient + // window (HelloResolved → AwaitingDesktop, no deadline, desktop arrives seconds + // later), so a parked state only ARMS a dwell timer here; the tripwire fires — + // and consumes its one-shot — only if the session is STILL parked when the dwell + // elapses. Any step that leaves the parked condition cancels the pending arm. + if (_informationalEvents != null && !_parkedTripwireFired) { - _parkedTripwireFired = true; - EmitParkedTripwire(_currentState, signal); + if (!effectResult.SessionMustAbort && IsParkedWithoutDeadline(_currentState)) + ArmParkedDwell(signal); + else + CancelParkedDwell(); } return effectResult; } + /// + /// Arms the parked dwell timer if not already pending. Deliberately does NOT re-base an + /// already-armed timer: a truly parked session still produces InformationalEvent + /// pass-through steps (perf snapshots, IME telemetry), and re-basing on each of them + /// would push the dwell forever. The first parked step of an episode starts the clock; + /// only a step that clears the parked condition resets it. + /// + private void ArmParkedDwell(DecisionSignal signal) + { + lock (_parkedLock) + { + if (_disposed || _parkedTripwireFired || _parkedDwellTimer != null) return; + _parkedCandidateSignal = signal; + _parkedDwellTimer = new Timer(OnParkedDwellElapsed, null, _parkedTripwireDwell, Timeout.InfiniteTimeSpan); + } + } + + private void CancelParkedDwell() + { + lock (_parkedLock) + { + if (_parkedDwellTimer == null) return; + _parkedDwellTimer.Dispose(); + _parkedDwellTimer = null; + _parkedCandidateSignal = null; + } + } + + private void OnParkedDwellElapsed(object? _) + { + try + { + DecisionState state; + DecisionSignal? armingSignal; + lock (_parkedLock) + { + if (_disposed || _parkedTripwireFired || _parkedDwellTimer == null) return; + _parkedDwellTimer.Dispose(); + _parkedDwellTimer = null; + armingSignal = _parkedCandidateSignal; + _parkedCandidateSignal = null; + + // Re-check against the LIVE state: a step that resolved the park in the last + // instant may not have reached CancelParkedDwell yet. _currentState is an + // immutable object written by the single ingress worker; the reference read + // is atomic. + state = _currentState; + if (armingSignal == null || !IsParkedWithoutDeadline(state)) return; + _parkedTripwireFired = true; + } + + EmitParkedTripwire(state, armingSignal); + } + catch (Exception ex) + { + // Observability must never break anything — and this runs on a timer thread. + _logger.Error("DecisionStepProcessor: parked-dwell evaluation failed.", ex); + } + } + + public void Dispose() + { + lock (_parkedLock) + { + if (_disposed) return; + _disposed = true; + _parkedDwellTimer?.Dispose(); + _parkedDwellTimer = null; + _parkedCandidateSignal = null; + } + } + /// /// Liveness invariant (plan PR1): a non-terminal session that has entered the /// post-AccountSetup dead-end zone must always have a resolution-capable deadline armed. @@ -327,7 +426,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration try { var census = DecisionStateSignalCensus.Build(state); - var data = new Dictionary(capacity: 6, comparer: StringComparer.Ordinal) + var data = new Dictionary(capacity: 7, comparer: StringComparer.Ordinal) { ["stage"] = state.Stage.ToString(), ["stepIndex"] = state.StepIndex.ToString(CultureInfo.InvariantCulture), @@ -336,13 +435,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration .ToString("o", CultureInfo.InvariantCulture), ["signalsSeen"] = string.Join(",", census.SignalsSeen), ["armedDeadlines"] = string.Join(",", state.Deadlines.Select(d => d.Name)), + ["dwellSeconds"] = _parkedTripwireDwell.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture), }; _informationalEvents!.Emit( eventType: Constants.EventTypes.SessionParkedWithoutDeadline, source: "DecisionStepProcessor", - message: $"Session is parked without a resolution-capable deadline at stage {state.Stage} — " + - "no signal is guaranteed to ever re-ask the completion question.", + message: $"Session has been parked without a resolution-capable deadline at stage {state.Stage} " + + $"for {_parkedTripwireDwell.TotalMinutes:F0} min — no signal is guaranteed to ever " + + "re-ask the completion question.", severity: EventSeverity.Warning, immediateUpload: true, data: data); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs index ba6b65bd..86de7d52 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs @@ -974,6 +974,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration try { _transport?.Dispose(); } catch { /* best-effort */ } try { _ingress?.Dispose(); } catch { /* best-effort */ } try { _drainCts?.Dispose(); } catch { /* best-effort */ } + try { _processor?.Dispose(); } catch { /* best-effort — parked-dwell timer */ } _logger.Info("EnrollmentOrchestrator: stopped."); } diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/AdvisoryCompletionDeadlineTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/AdvisoryCompletionDeadlineTests.cs index 0fadb72e..c22eadd8 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/AdvisoryCompletionDeadlineTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/AdvisoryCompletionDeadlineTests.cs @@ -345,10 +345,10 @@ namespace AutopilotMonitor.DecisionCore.Tests [Fact] public void DeadlineFired_WithoutAnyAnchor_DeadEnds() { - // No advisory recorded AND the only esp_exiting was the intermediate Device→Account - // handoff (before AccountSetup entry) — neither arming anchor exists. + // Neither anchor fact exists: no advisory recorded and no esp_exiting ever observed. var engine = new DecisionEngine(); - var state = SetupAdvisoryEligibleSession(engine); + var state = DecisionState.CreateInitial("sess-noanchor", "tenant-noanchor", T0); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(57.5), DeadlineNames.AdvisoryCompletion)); @@ -358,6 +358,44 @@ namespace AutopilotMonitor.DecisionCore.Tests Assert.Equal(state.Stage, step.NewState.Stage); } + [Fact] + public void DeadlineFired_IntermediateExitOnly_DeadEndsAsStaleNotArmed() + { + // L5 (delta review 2026-07-02): the anchor is presence-only now — the intermediate + // pre-AccountSetup exit plus AccountSetup entry passes the anchor check, and the + // UNARMED window is what dead-ends this stray fire. + var engine = new DecisionEngine(); + var state = SetupAdvisoryEligibleSession(engine); + + var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(57.5), DeadlineNames.AdvisoryCompletion)); + + Assert.False(step.Transition.Taken); + Assert.Equal("advisory_completion_stale_deadline_not_armed", step.Transition.DeadEndReason); + Assert.Empty(step.Effects); + Assert.Equal(state.Stage, step.NewState.Stage); + } + + [Fact] + public void DeadlineFired_EspExitVariant_BackdatedExitTimestamp_StillResolves() + { + // L5 (delta review 2026-07-02): a guard-blocked post-AccountSetup exit whose SOURCE + // timestamp predates AccountSetup entry (replayed CMTrace content / clamped clock) + // arms the window; the fire must resolve the session, not dead-end on + // advisory_completion_without_anchor (which re-opened the idle-until-max-lifetime + // dead-end this deadline exists to close). + var engine = new DecisionEngine(); + var state = SetupAdvisoryEligibleSession(engine); // desktop + post-anchor IME evidence + + var armStep = engine.Reduce(state, MakeSignal(50, DecisionSignalKind.EspExiting, T0.AddMinutes(16))); + Assert.NotNull(FindDeadline(armStep.NewState, DeadlineNames.AdvisoryCompletion)); + Assert.True(armStep.NewState.EspFinalExitUtc!.Value < armStep.NewState.AccountSetupEnteredUtc!.Value); + + var step = engine.Reduce(armStep.NewState, DeadlineFired(70, T0.AddMinutes(46), DeadlineNames.AdvisoryCompletion)); + + Assert.True(step.Transition.Taken); + Assert.Equal(SessionStage.Finalizing, step.NewState.Stage); + } + [Fact] public void DeadlineFired_AfterNormalCompletion_DeadEndsViaPostTerminalGuard() { @@ -580,7 +618,8 @@ namespace AutopilotMonitor.DecisionCore.Tests Assert.Equal(T0.AddMinutes(5), state.ImeUserSessionCompletedUtc!.Value); Assert.Equal(10, state.ImeUserSessionCompletedUtc!.SourceSignalOrdinal); - // A replayed second observation keeps the original anchor. + // A replayed second observation keeps the original anchor (no AccountSetup entry + // recorded — the post-anchor upgrade rule cannot apply). state = engine.Reduce(state, MakeSignal( 20, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(9), new Dictionary { [SignalPayloadKeys.ImePatternId] = "IME-USER-SESSION-COMPLETED" })).NewState; @@ -589,6 +628,50 @@ namespace AutopilotMonitor.DecisionCore.Tests Assert.Equal(10, state.ImeUserSessionCompletedUtc!.SourceSignalOrdinal); } + [Fact] + public void ImeUserSessionCompleted_PreAnchorGhost_UpgradedByFirstPostAnchorObservation() + { + // M2 (delta review 2026-07-02): a defaultuser0 completion observed BEFORE + // AccountSetup entry must not permanently poison the conjunction — the first + // at-or-after-anchor observation upgrades the fact, then it freezes. + var engine = new DecisionEngine(); + var state = SetupAdvisoryEligibleSession(engine, imeUserSessionCompletedAt: T0.AddMinutes(10)); + Assert.Equal(T0.AddMinutes(10), state.ImeUserSessionCompletedUtc!.Value); // ghost recorded + + // Real user's IME session completes post-AccountSetup (+17) → upgrade. + state = engine.Reduce(state, MakeSignal( + 45, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(27), + new Dictionary { [SignalPayloadKeys.ImePatternId] = "IME-USER-SESSION-COMPLETED" })).NewState; + Assert.Equal(T0.AddMinutes(27), state.ImeUserSessionCompletedUtc!.Value); + Assert.Equal(45, state.ImeUserSessionCompletedUtc!.SourceSignalOrdinal); + + // Frozen after the upgrade: a third observation does not re-base the genuine stamp. + state = engine.Reduce(state, MakeSignal( + 48, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(29), + new Dictionary { [SignalPayloadKeys.ImePatternId] = "IME-USER-SESSION-COMPLETED" })).NewState; + Assert.Equal(T0.AddMinutes(27), state.ImeUserSessionCompletedUtc!.Value); + Assert.Equal(45, state.ImeUserSessionCompletedUtc!.SourceSignalOrdinal); + } + + [Fact] + public void DeadlineFired_GhostThenRealImeCompletion_CompletesThroughFinalizing() + { + // End-to-end M2: ghost pre-anchor completion, then the real user's post-anchor + // completion — the AdvisoryCompletion conjunction must hold and the session must + // complete instead of resolving Failed on poisoned evidence. + var engine = new DecisionEngine(); + var state = SetupAdvisoryEligibleSession(engine, imeUserSessionCompletedAt: T0.AddMinutes(10)); + state = engine.Reduce(state, MakeSignal( + 45, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(27), + new Dictionary { [SignalPayloadKeys.ImePatternId] = "IME-USER-SESSION-COMPLETED" })).NewState; + state = ApplyEspTerminalFailure(engine, state, ordinal: 50); + + var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(57.5), DeadlineNames.AdvisoryCompletion)); + + Assert.Equal(SessionStage.Finalizing, step.NewState.Stage); + Assert.Equal("Skipped", step.NewState.HelloOutcome!.Value); + } + // ================================================== serialization compat ==== [Fact] diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs index 54b77571..5f04eefc 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs @@ -506,7 +506,8 @@ namespace AutopilotMonitor.DecisionCore.Engine /// is a strong UserDriven-v1 indicator), and records the matched pattern id. /// /// Additionally records - /// (set-once — the first observation wins, replays keep the original anchor). The fact + /// (first post-AccountSetup observation wins — a pre-AccountSetup defaultuser0 stamp is + /// upgraded once by the first at-or-after-anchor observation, then frozen). The fact /// is NOT a completion trigger here: the IME "user session" can run under /// defaultuser0, so the raw signal proves nothing about the real user. The /// AdvisoryCompletion deadline handler consumes it lazily inside a correlation @@ -529,7 +530,22 @@ namespace AutopilotMonitor.DecisionCore.Engine builder.ImeMatchedPatternId = new SignalFact(patternId!, signal.SessionSignalOrdinal); } - if (state.ImeUserSessionCompletedUtc == null) + // Recording rule: first POST-AccountSetup observation wins (M2, delta review + // 2026-07-02). Plain set-once let an early defaultuser0 completion (pre-AccountSetup + // frame) permanently poison the AdvisoryCompletion conjunction — the real user's + // later completion was skipped, `imeUserSessionGenuine` could never hold, and a + // session with genuine evidence resolved Failed. Upgrade exactly once: a pre-anchor + // stamp is replaced by the first at-or-after-anchor observation; a genuine stamp is + // never downgraded or re-based. Pure function of (state, signal) — replay-safe. + var recordImeUserSession = state.ImeUserSessionCompletedUtc == null; + if (!recordImeUserSession + && state.AccountSetupEnteredUtc != null + && state.ImeUserSessionCompletedUtc!.Value < state.AccountSetupEnteredUtc.Value + && signal.OccurredAtUtc >= state.AccountSetupEnteredUtc.Value) + { + recordImeUserSession = true; + } + if (recordImeUserSession) { builder.ImeUserSessionCompletedUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs index 2e77334f..4a0fb4a5 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs @@ -356,12 +356,17 @@ namespace AutopilotMonitor.DecisionCore.Engine var deadlineStillArmed = HasAdvisoryCompletionDeadline(state); var hasAdvisoryAnchor = state.EspAdvisoryFailureRecordedUtc != null; - // The esp-exit arming site only runs for a final exit observed at-or-after - // AccountSetup entry; an intermediate Device→Account exit (recorded earlier than - // AccountSetupEnteredUtc) does not count as an anchor. + // Presence-only anchor (L5, delta review 2026-07-02). The esp-exit arming site only + // runs when AccountSetupEnteredUtc is already set, so both facts being present is + // the anchor; `deadlineStillArmed` below is what rejects stray timer fires. The old + // additional `EspFinalExitUtc >= AccountSetupEnteredUtc` comparison rejected fires + // whose arming exit carried a backfilled/out-of-order source timestamp — returning + // the session to the idle-until-max-lifetime dead-end this deadline exists to close. + // An intermediate Device→Account exit cannot slip in here: it happens BEFORE + // AccountSetup entry, never arms the window, and thus dead-ends on + // `advisory_completion_stale_deadline_not_armed`. var hasEspExitAnchor = state.EspFinalExitUtc != null - && state.AccountSetupEnteredUtc != null - && state.EspFinalExitUtc.Value >= state.AccountSetupEnteredUtc.Value; + && state.AccountSetupEnteredUtc != null; string? staleReason = null; if (!hasAdvisoryAnchor && !hasEspExitAnchor) staleReason = "advisory_completion_without_anchor";