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";