mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
fix(decision-core): parked-tripwire dwell, IME evidence upgrade rule, advisory anchor fix (M1, M2, L5, L8)
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 <noreply@anthropic.com>
This commit is contained in:
+128
-24
@@ -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 <c>session_parked_without_deadline</c> 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).
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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);
|
||||
|
||||
/// <summary>Short dwell so fire-path tests complete quickly; suppression tests wait ≥10× this.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// <summary>Blocks until at least one tripwire post landed (dwell elapsed) or times out.</summary>
|
||||
private bool WaitForTripwire() =>
|
||||
SpinWait.SpinUntil(() => CountTripwirePosts() >= 1, FireWait);
|
||||
|
||||
/// <summary>Proves absence: waits well past the dwell, then asserts no post arrived.</summary>
|
||||
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<DecisionEffect>()), 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<DecisionEffect>()), 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,11 +45,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// nächsten Reduce, ebenfalls unlockbar sicher.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DecisionStepProcessor : IDecisionStepProcessor
|
||||
public sealed class DecisionStepProcessor : IDecisionStepProcessor, IDisposable
|
||||
{
|
||||
/// <summary>Default — 3 Failures hintereinander → Quarantine.</summary>
|
||||
public const int DefaultQuarantineThreshold = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultParkedTripwireDwell = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// 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<DecisionState>? 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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<string, string>(capacity: 6, comparer: StringComparer.Ordinal)
|
||||
var data = new Dictionary<string, string>(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);
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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<string, string> { [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<string, string> { [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<string, string> { [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<string, string> { [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]
|
||||
|
||||
@@ -506,7 +506,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// is a strong UserDriven-v1 indicator), and records the matched pattern id.
|
||||
/// <para>
|
||||
/// Additionally records <see cref="DecisionState.ImeUserSessionCompletedUtc"/>
|
||||
/// (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
|
||||
/// <c>defaultuser0</c>, so the raw signal proves nothing about the real user. The
|
||||
/// <c>AdvisoryCompletion</c> deadline handler consumes it lazily inside a correlation
|
||||
@@ -529,7 +530,22 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
builder.ImeMatchedPatternId = new SignalFact<string>(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<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user