From 87a8fa2a9d0ffd3d0b4b31882e70c4f60462bb29 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:20:45 -0700 Subject: [PATCH] .NET: Fix intermittent checkpoint-restore race in in-process workflow runs (#5134) * Improve workflow unit tests * Update test name prefix for clarity. * Update tests to surface any errors. * fix check-point restore-time race in off-thread workflow event stream * Fixes an intermittent checkpoint-restore race in in-process workflow runs. --- .../InProc/InProcessRunnerContext.cs | 6 +++ .../CheckpointResumeTests.cs | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index f0bb8cac26..d6c7d301e3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -419,6 +419,12 @@ internal sealed class InProcessRunnerContext : IRunnerContext .Select(id => this.EnsureExecutorAsync(id, tracer: null).AsTask()) .ToArray(); + // Discard queued external deliveries from the superseded timeline so a runtime + // restore cannot apply stale responses after importing the checkpoint state. + while (this._queuedExternalDeliveries.TryDequeue(out _)) + { + } + this._nextStep = new StepContext(); this._nextStep.ImportMessages(importedState.QueuedMessages); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs index 9d4b514af7..53ea644712 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointResumeTests.cs @@ -279,6 +279,48 @@ public class CheckpointResumeTests "the workflow should be able to continue after the runtime restore replay"); } + /// + /// Verifies that restoring a live run clears any queued external responses from the + /// superseded timeline before importing checkpoint state. + /// + [Fact] + internal async Task Checkpoint_Restore_ClearsQueuedExternalResponsesBeforeImportAsync() + { + Workflow workflow = CreateSimpleRequestWorkflow(); + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = ExecutionEnvironment.InProcess_Lockstep.ToWorkflowExecutionEnvironment(); + + await using StreamingRun run = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello"); + + (ExternalRequest pendingRequest, CheckpointInfo checkpoint) = await CapturePendingRequestAndCheckpointAsync(run); + + await run.SendResponseAsync(pendingRequest.CreateResponse("World")); + await run.RestoreCheckpointAsync(checkpoint); + + List restoredEvents = await ReadToHaltAsync(run); + ExternalRequest replayedRequest = restoredEvents.OfType() + .Select(evt => evt.Request) + .Should() + .ContainSingle("the restored run should still be waiting for the checkpointed request") + .Subject; + + restoredEvents.OfType().Should().BeEmpty( + "a queued response from the superseded timeline should not be processed after restore"); + RunStatus statusAfterRestore = await run.GetStatusAsync(); + statusAfterRestore.Should().Be(RunStatus.PendingRequests, + "the restored run should remain pending until a post-restore response is sent"); + + await run.SendResponseAsync(replayedRequest.CreateResponse("Again")); + + List completionEvents = await ReadToHaltAsync(run); + completionEvents.OfType().Should().BeEmpty( + "the restored request should complete cleanly once a new response is provided"); + RunStatus finalStatus = await run.GetStatusAsync(); + finalStatus.Should().Be(RunStatus.Idle, + "the workflow should finish once the replayed request receives a fresh response"); + } + /// /// Verifies that a resumed parent workflow re-emits pending requests that originated in a subworkflow. ///