diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionStatusTransitionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionStatusTransitionTests.cs
index c5561ac7..2440371a 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionStatusTransitionTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionStatusTransitionTests.cs
@@ -50,10 +50,25 @@ public class SessionStatusTransitionTests
Assert.Equal(allowed, TableStorageService.IsTerminalTransitionAllowed(existing, SessionStatus.AwaitingUser));
}
- // ---- Non-terminal incoming is governed by downstream guards, allowed here ----
+ // ---- Only an active session stalls: a stall report never overwrites a verdict, a waiting
+ // or a pre-provisioned session (an offline batch can carry session_stalled next to
+ // enrollment_complete, and the sweep can lose the race against the completion) ----
+ [Theory]
+ [InlineData("InProgress", true)]
+ [InlineData("Succeeded", false)]
+ [InlineData("Failed", false)]
+ [InlineData("Incomplete", false)]
+ [InlineData("AwaitingUser", false)]
+ [InlineData("Pending", false)]
+ [InlineData("Stalled", false)]
+ public void Only_an_active_session_stalls(string existing, bool allowed)
+ {
+ Assert.Equal(allowed, TableStorageService.IsTerminalTransitionAllowed(existing, SessionStatus.Stalled));
+ }
+
+ // ---- InProgress and Pending are governed by downstream guards, allowed here ----
[Theory]
[InlineData(SessionStatus.InProgress)]
- [InlineData(SessionStatus.Stalled)]
[InlineData(SessionStatus.Pending)]
public void Non_terminal_incoming_is_allowed_here(SessionStatus incoming)
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs
index 7d187fea..5abcc6d7 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs
@@ -1915,7 +1915,10 @@ namespace AutopilotMonitor.Functions.Services
/// - Succeeded may UPGRADE any non-Succeeded state (late completion reconcile); idempotent no-op if already Succeeded.
/// - Failed / Incomplete (silent-terminal verdicts) never overwrite an existing terminal (Succeeded/Failed/Incomplete).
/// - AwaitingUser never regresses an existing terminal.
- /// - Non-terminal incoming statuses (InProgress/Stalled/Pending) are allowed here and governed by their own downstream guards.
+ /// - Stalled marks an active session that stopped progressing: only InProgress can stall. A stall report that
+ /// arrives with or after a verdict (an offline batch carrying session_stalled and enrollment_complete, a sweep that
+ /// lost the race against the completion) never overwrites it, nor an AwaitingUser or Pending session.
+ /// - InProgress and Pending are allowed here and governed by their own downstream guards.
///
/// Static + pure so the reconcile matrix is unit-testable without Table Storage.
///
@@ -1930,6 +1933,7 @@ namespace AutopilotMonitor.Functions.Services
SessionStatus.Succeeded => existingStatus != SessionStatus.Succeeded.ToString(),
SessionStatus.Failed or SessionStatus.Incomplete => !existingTerminal,
SessionStatus.AwaitingUser => !existingTerminal,
+ SessionStatus.Stalled => existingStatus == SessionStatus.InProgress.ToString(),
// Idempotency: the agent emits whiteglove_complete twice (raw Shell-Core event
// + engine timeline event, ~1s apart). The second Pending write must not report
// a "transition" — that fired duplicate Pre-Provisioning Completed webhooks.
@@ -2079,13 +2083,8 @@ namespace AutopilotMonitor.Functions.Services
// Status transitions from EventIngestProcessor: Succeeded, Failed, Pending, Stalled,
// and InProgress (the last one only for healing a Stalled session back to active).
- // Guard: never regress a Pending (WhiteGlove) session to Stalled via the ingest
- // path — WhiteGlove sessions are deliberately long-lived and handled via re-registration.
- if (status == SessionStatus.Stalled && existingStatusStr == SessionStatus.Pending.ToString())
- {
- _logger.LogInformation($"Session {sessionId} is in Pending (WhiteGlove) state, skipping Stalled transition");
- return false;
- }
+ // Stalled comes only from InProgress (IsTerminalTransitionAllowed), so a Pending
+ // (WhiteGlove) session — long-lived on purpose, resumed by re-registration — never stalls.
// Guard: only allow InProgress transition if the current state is Stalled (healing).
// Other callers must not regress terminal or Pending state via this path.