// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Agents.AI.Workflows.Sample; namespace Microsoft.Agents.AI.Workflows.UnitTests; /// /// Regression tests for GH-2485: pending objects must be /// re-emitted after resuming a workflow from a checkpoint. /// public class CheckpointResumeTests { /// /// Verifies that a resumed workflow re-emits s for /// pending external requests that existed at the time of the checkpoint. /// [Theory] [InlineData(ExecutionEnvironment.InProcess_OffThread)] [InlineData(ExecutionEnvironment.InProcess_Lockstep)] internal async Task Checkpoint_Resume_WithPendingRequests_RepublishesRequestInfoEventsAsync(ExecutionEnvironment environment) { // Arrange RequestPort requestPort = RequestPort.Create("TestPort"); ForwardMessageExecutor processor = new("Processor"); Workflow workflow = new WorkflowBuilder(requestPort) .AddEdge(requestPort, processor) .Build(); CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); // Act 1: Run workflow, collect pending requests and a checkpoint. List originalRequests = []; CheckpointInfo? checkpoint = null; await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) .RunStreamingAsync(workflow, "Hello")) { await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false)) { if (evt is RequestInfoEvent requestInfo) { originalRequests.Add(requestInfo.Request); } if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) { checkpoint = cp; } } originalRequests.Should().NotBeEmpty("the workflow should have created at least one external request"); checkpoint.Should().NotBeNull("a checkpoint should have been created"); } // Act 2: Resume from the checkpoint. await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) .ResumeStreamingAsync(workflow, checkpoint!); // Assert: The pending requests should be re-emitted. List reEmittedRequests = []; using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) { if (evt is RequestInfoEvent requestInfo) { reEmittedRequests.Add(requestInfo.Request); } } reEmittedRequests.Should().HaveCount(originalRequests.Count, "all pending requests from the checkpoint should be re-emitted after resume"); reEmittedRequests.Select(r => r.RequestId) .Should().BeEquivalentTo(originalRequests.Select(r => r.RequestId), "the re-emitted request IDs should match the original pending request IDs"); } /// /// Verifies that transitions to /// after resuming from a checkpoint with pending external requests (not stuck at NotStarted). /// [Theory] [InlineData(ExecutionEnvironment.InProcess_OffThread)] [InlineData(ExecutionEnvironment.InProcess_Lockstep)] internal async Task Checkpoint_Resume_WithPendingRequests_RunStatusIsPendingRequestsAsync(ExecutionEnvironment environment) { // Arrange RequestPort requestPort = RequestPort.Create("TestPort"); ForwardMessageExecutor processor = new("Processor"); Workflow workflow = new WorkflowBuilder(requestPort) .AddEdge(requestPort, processor) .Build(); CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); // First run: collect a checkpoint with pending requests. CheckpointInfo? checkpoint = null; await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) .RunStreamingAsync(workflow, "Hello")) { await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false)) { if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) { checkpoint = cp; } } checkpoint.Should().NotBeNull(); } // Act: Resume from the checkpoint and consume events so the run loop processes. await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) .ResumeStreamingAsync(workflow, checkpoint!); using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); await foreach (WorkflowEvent _ in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) { // Consume all events until the stream completes. } // Assert RunStatus status = await resumed.GetStatusAsync(); status.Should().Be(RunStatus.PendingRequests, "the resumed workflow should report PendingRequests after rehydration"); } /// /// Verifies the full roundtrip: resume from checkpoint, observe the re-emitted request, /// send a response, and verify the workflow completes without duplicating the request. /// [Theory] [InlineData(ExecutionEnvironment.InProcess_OffThread)] [InlineData(ExecutionEnvironment.InProcess_Lockstep)] internal async Task Checkpoint_Resume_RespondToPendingRequest_CompletesWithoutDuplicateAsync(ExecutionEnvironment environment) { // Arrange RequestPort requestPort = RequestPort.Create("TestPort"); ForwardMessageExecutor processor = new("Processor"); Workflow workflow = new WorkflowBuilder(requestPort) .AddEdge(requestPort, processor) .Build(); CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); // First run: collect checkpoint + pending request. ExternalRequest? pendingRequest = null; CheckpointInfo? checkpoint = null; await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) .RunStreamingAsync(workflow, "Hello")) { await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false)) { if (evt is RequestInfoEvent requestInfo) { pendingRequest = requestInfo.Request; } if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) { checkpoint = cp; } } pendingRequest.Should().NotBeNull(); checkpoint.Should().NotBeNull(); } // Act: Resume and respond to the restored request. await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) .ResumeStreamingAsync(workflow, checkpoint!); int requestEventCount = 0; using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); // Use blockOnPendingRequest: false for the first pass to see the re-emitted requests. await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) { if (evt is RequestInfoEvent requestInfo) { requestEventCount++; requestInfo.Request.RequestId.Should().Be(pendingRequest!.RequestId, "the re-emitted request should match the original"); } } requestEventCount.Should().Be(1, "the pending request should be emitted exactly once (no duplicates)"); // Assert intermediate state before responding: the run should be in PendingRequests // and we should have observed the re-emitted request. If the first WatchStreamAsync // didn't complete or yielded nothing, these assertions catch it with a clear message. RunStatus statusBeforeResponse = await resumed.GetStatusAsync(); statusBeforeResponse.Should().Be(RunStatus.PendingRequests, "the run should be in PendingRequests state before we send a response"); // Now send the response and verify the workflow processes it. ExternalResponse response = pendingRequest!.CreateResponse("World"); await resumed.SendResponseAsync(response); // Consume the resulting events to verify the workflow progresses without errors. List postResponseEvents = []; using CancellationTokenSource cts2 = new(TimeSpan.FromSeconds(10)); await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts2.Token)) { postResponseEvents.Add(evt); } postResponseEvents.Should().NotBeEmpty( "the workflow should process the response and produce events"); postResponseEvents.OfType().Should().BeEmpty( "no errors should occur when processing the restored request's response"); } /// /// Verifies that restoring a live run to a checkpoint re-emits pending requests and allows /// the workflow to continue from that restored point. /// [Theory] [InlineData(ExecutionEnvironment.InProcess_OffThread)] [InlineData(ExecutionEnvironment.InProcess_Lockstep)] internal async Task Checkpoint_Restore_WithPendingRequests_RepublishesRequestInfoEventsAsync(ExecutionEnvironment environment) { // Arrange Workflow workflow = CreateSimpleRequestWorkflow(); CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); await using StreamingRun run = await env.WithCheckpointing(checkpointManager) .RunStreamingAsync(workflow, "Hello"); (ExternalRequest pendingRequest, CheckpointInfo checkpoint) = await CapturePendingRequestAndCheckpointAsync(run); // Advance the run past the checkpoint so the restore has meaningful work to undo. await run.SendResponseAsync(pendingRequest.CreateResponse("World")); List firstCompletionEvents = await ReadToHaltAsync(run); firstCompletionEvents.OfType().Should().BeEmpty( "the workflow should continue cleanly before we restore"); RunStatus statusAfterFirstResponse = await run.GetStatusAsync(); statusAfterFirstResponse.Should().Be(RunStatus.Idle, "the workflow should finish processing the first response before we restore"); // Act await run.RestoreCheckpointAsync(checkpoint); // Assert List restoredEvents = await ReadToHaltAsync(run); ExternalRequest[] replayedRequests = [.. restoredEvents.OfType().Select(evt => evt.Request)]; replayedRequests.Should().ContainSingle("runtime restore should re-emit the restored pending request"); replayedRequests[0].RequestId.Should().Be(pendingRequest.RequestId, "the replayed request should match the request captured at the checkpoint"); await run.SendResponseAsync(replayedRequests[0].CreateResponse("Again")); List secondCompletionEvents = await ReadToHaltAsync(run); secondCompletionEvents.OfType().Should().BeEmpty( "runtime restore replay should not introduce workflow errors"); RunStatus statusAfterRestoreResponse = await run.GetStatusAsync(); statusAfterRestoreResponse.Should().Be(RunStatus.Idle, "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 fan-in edge state buffered before a checkpoint is still present after resume. /// [Theory] [InlineData(ExecutionEnvironment.InProcess_OffThread)] [InlineData(ExecutionEnvironment.InProcess_Lockstep)] internal async Task Checkpoint_Resume_PreservesFanInBarrierBufferedMessagesAsync(ExecutionEnvironment environment) { // Arrange Workflow workflow = CreateFanInBarrierWorkflow(); CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); ExternalRequest pendingRequest; CheckpointInfo checkpoint; await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) .RunStreamingAsync(workflow, "start")) { (pendingRequest, checkpoint) = await CapturePendingRequestAndCheckpointAsync(firstRun); } // Act + Assert ExternalRequest replayedRequest = await ResumeAndAssertBarrierReleasesAsync( env, checkpointManager, workflow, checkpoint, ["before", "after"]); pendingRequest.RequestId.Should().Be(replayedRequest.RequestId, "the replayed request should be the one from the checkpointed superstep"); } /// /// Verifies that fan-in barrier state is preserved across resume when more than two sources /// participate, and multiple contributions are buffered before the checkpoint. /// [Theory] [InlineData(ExecutionEnvironment.InProcess_OffThread)] [InlineData(ExecutionEnvironment.InProcess_Lockstep)] internal async Task Checkpoint_Resume_PreservesFanInBarrierBufferedMessages_MultiSourceAsync(ExecutionEnvironment environment) { // Arrange: a fan-out start broadcasts a trigger to two early barrier sources and to a // request-port kickoff. Both early sources contribute pre-checkpoint and only the port // response path is unseen at the checkpoint - the barrier must hold both buffered // contributions across resume and only release once the third source contributes. const string RequestPortId = "Approval"; const string SinkId = "Sink"; ForwardMessageExecutor start = new("Start"); ExecutorBinding earlyA = new BarrierContributor("EarlyA", SinkId, "before-1"); ExecutorBinding earlyB = new BarrierContributor("EarlyB", SinkId, "before-2"); ExecutorBinding kickoff = new RequestPortKickoff("Kickoff", RequestPortId); ExecutorBinding afterResume = new PostCheckpointBarrierSource("AfterResume", SinkId); ExecutorBinding sink = new BarrierSink(SinkId); RequestPort requestPort = RequestPort.Create(RequestPortId); Workflow workflow = new WorkflowBuilder(start) .AddEdge(start, earlyA) .AddEdge(start, earlyB) .AddEdge(start, kickoff) .AddEdge(kickoff, requestPort) .AddEdge(requestPort, afterResume) .AddFanInBarrierEdge([earlyA, earlyB, afterResume], sink) .Build(); CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); ExternalRequest pendingRequest; CheckpointInfo checkpoint; await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) .RunStreamingAsync(workflow, "start")) { (pendingRequest, checkpoint) = await CapturePendingRequestAndCheckpointAsync(firstRun); } // Act + Assert ExternalRequest replayedRequest = await ResumeAndAssertBarrierReleasesAsync( env, checkpointManager, workflow, checkpoint, ["before-1", "before-2", "after"]); pendingRequest.RequestId.Should().Be(replayedRequest.RequestId, "the replayed request should match the one captured at checkpoint time"); } /// /// Verifies that the same checkpoint can be resumed independently more than once - i.e. that /// completing one resumed run does not mutate state inside the stored checkpoint. /// /// /// Without a snapshot at the export/import boundary, FanInEdgeRunner would hand the /// in-memory CheckpointManager a live reference to the mutable FanInEdgeState. /// The first resume would then reset the buffer back to "all unseen" while completing, and a /// second resume from the same would deadlock waiting for the /// pre-checkpoint contribution that no longer exists. /// [Theory] [InlineData(ExecutionEnvironment.InProcess_OffThread)] [InlineData(ExecutionEnvironment.InProcess_Lockstep)] internal async Task Checkpoint_Resume_FanInBarrierCheckpointCanBeResumedTwiceAsync(ExecutionEnvironment environment) { // Arrange CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); CheckpointInfo checkpoint; await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) .RunStreamingAsync(CreateFanInBarrierWorkflow(), "start")) { (_, checkpoint) = await CapturePendingRequestAndCheckpointAsync(firstRun); } // Act + Assert: each resume needs a fresh Workflow object because the previous run takes // ownership of it. Resuming the same CheckpointInfo more than once must yield identical // results - the first resume must not mutate state the checkpoint store is still holding. for (int attempt = 0; attempt < 2; attempt++) { await ResumeAndAssertBarrierReleasesAsync( env, checkpointManager, CreateFanInBarrierWorkflow(), checkpoint, ["before", "after"]); } } /// /// Verifies that fan-in barrier state buffered inside a subworkflow is preserved across a /// checkpoint/resume cycle of the parent workflow. /// [Theory] [InlineData(ExecutionEnvironment.InProcess_OffThread)] [InlineData(ExecutionEnvironment.InProcess_Lockstep)] internal async Task Checkpoint_Resume_PreservesFanInBarrierBufferedMessages_InSubworkflowAsync(ExecutionEnvironment environment) { // Arrange: the fan-in barrier lives inside a subworkflow; the fix has to apply to the // subworkflow's runner context too. const string InnerRequestPortId = "InnerApproval"; const string ForwardedRequestId = "ForwardedInnerApproval"; Workflow BuildOuter() { ExecutorBinding subworkflow = CreateFanInBarrierWorkflow( requestPortId: InnerRequestPortId, sinkId: "InnerSink") .BindAsExecutor("InnerSubworkflow"); return new WorkflowBuilder(subworkflow) .AddExternalRequest(subworkflow, id: ForwardedRequestId) .Build(); } CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); ExternalRequest pendingRequest; CheckpointInfo checkpoint; await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) .RunStreamingAsync(BuildOuter(), "start")) { (pendingRequest, checkpoint) = await CapturePendingRequestAndCheckpointAsync(firstRun); } // Act + Assert ExternalRequest replayedRequest = await ResumeAndAssertBarrierReleasesAsync( env, checkpointManager, BuildOuter(), checkpoint, ["before", "after"]); pendingRequest.RequestId.Should().Be(replayedRequest.RequestId, "the replayed subworkflow request should match the one captured at checkpoint time"); } /// /// Verifies that a resumed parent workflow re-emits pending requests that originated in a subworkflow. /// [Theory] [InlineData(ExecutionEnvironment.InProcess_OffThread)] [InlineData(ExecutionEnvironment.InProcess_Lockstep)] internal async Task Checkpoint_Resume_SubworkflowWithPendingRequests_RepublishesQualifiedRequestInfoEventsAsync(ExecutionEnvironment environment) { // Arrange Workflow workflow = CreateCheckpointedSubworkflowRequestWorkflow(); CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); ExternalRequest pendingRequest; CheckpointInfo checkpoint; await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) .RunStreamingAsync(workflow, "Hello")) { (pendingRequest, checkpoint) = await CapturePendingRequestAndCheckpointAsync(firstRun); } // Act await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) .ResumeStreamingAsync(workflow, checkpoint); // Assert List resumedEvents = await ReadToHaltAsync(resumed); ExternalRequest[] replayedRequests = [.. resumedEvents.OfType().Select(evt => evt.Request)]; replayedRequests.Should().ContainSingle("the resumed parent workflow should surface the subworkflow request once"); replayedRequests[0].RequestId.Should().Be(pendingRequest.RequestId, "the replayed subworkflow request should match the checkpointed request"); replayedRequests[0].PortInfo.PortId.Should().Be(pendingRequest.PortInfo.PortId, "the replayed request should remain qualified through the subworkflow boundary"); await resumed.SendResponseAsync(replayedRequests[0].CreateResponse("World")); List completionEvents = await ReadToHaltAsync(resumed); completionEvents.OfType().Should().BeEmpty( "the resumed subworkflow request should not be replayed twice"); completionEvents.OfType().Should().BeEmpty( "subworkflow replay should not introduce workflow errors"); RunStatus statusAfterSubworkflowResponse = await resumed.GetStatusAsync(); statusAfterSubworkflowResponse.Should().Be(RunStatus.Idle, "the resumed subworkflow should continue after responding to the replayed request"); } /// /// Verifies that when republishPendingEvents is , /// no is re-emitted after resuming from a checkpoint. /// [Theory] [InlineData(ExecutionEnvironment.InProcess_OffThread)] [InlineData(ExecutionEnvironment.InProcess_Lockstep)] internal async Task Checkpoint_Resume_WithRepublishDisabled_DoesNotEmitRequestInfoEventsAsync(ExecutionEnvironment environment) { // Arrange RequestPort requestPort = RequestPort.Create("TestPort"); ForwardMessageExecutor processor = new("Processor"); Workflow workflow = new WorkflowBuilder(requestPort) .AddEdge(requestPort, processor) .Build(); CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); // First run: collect a checkpoint with pending requests. CheckpointInfo? checkpoint = null; await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) .RunStreamingAsync(workflow, "Hello")) { await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false)) { if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) { checkpoint = cp; } } checkpoint.Should().NotBeNull(); } // Act: Resume with republishPendingEvents: false via the internal API. await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) .ResumeStreamingInternalAsync(workflow, checkpoint!, republishPendingEvents: false); // Assert: No RequestInfoEvent should appear in the event stream. int requestEventCount = 0; using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) { if (evt is RequestInfoEvent) { requestEventCount++; } } requestEventCount.Should().Be(0, "no RequestInfoEvent should be emitted when republishPendingEvents is false"); } private static Workflow CreateSimpleRequestWorkflow( string requestPortId = "TestPort", string processorId = "Processor") { RequestPort requestPort = RequestPort.Create(requestPortId); ForwardMessageExecutor processor = new(processorId); return new WorkflowBuilder(requestPort) .AddEdge(requestPort, processor) .Build(); } private static Workflow CreateCheckpointedSubworkflowRequestWorkflow() { ExecutorBinding subworkflow = CreateSimpleRequestWorkflow( requestPortId: "InnerTestPort", processorId: "InnerProcessor") .BindAsExecutor("Subworkflow"); return new WorkflowBuilder(subworkflow) .AddExternalRequest(subworkflow, id: "ForwardedSubworkflowRequest") .Build(); } private static async ValueTask<(ExternalRequest PendingRequest, CheckpointInfo Checkpoint)> CapturePendingRequestAndCheckpointAsync(StreamingRun run) { ExternalRequest? pendingRequest = null; CheckpointInfo? checkpoint = null; await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false)) { if (evt is RequestInfoEvent requestInfo) { pendingRequest ??= requestInfo.Request; } if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) { checkpoint = cp; } } pendingRequest.Should().NotBeNull("the workflow should have emitted a pending request"); checkpoint.Should().NotBeNull("the workflow should have produced a checkpoint"); return (pendingRequest!, checkpoint!); } private static async ValueTask> ReadToHaltAsync(StreamingRun run) { List events = []; using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) { events.Add(evt); } return events; } private static Workflow CreateFanInBarrierWorkflow( string requestPortId = "Approval", string sinkId = "Sink") { ExecutorBinding before = new PreCheckpointBarrierSource("BeforePause", requestPortId, sinkId); ExecutorBinding after = new PostCheckpointBarrierSource("AfterResume", sinkId); ExecutorBinding sink = new BarrierSink(sinkId); RequestPort requestPort = RequestPort.Create(requestPortId); return new WorkflowBuilder(before) .AddEdge(before, requestPort) .AddEdge(requestPort, after) .AddFanInBarrierEdge([before, after], sink) .Build(); } private static async ValueTask ResumeAndAssertBarrierReleasesAsync( InProcessExecutionEnvironment env, CheckpointManager checkpointManager, Workflow workflow, CheckpointInfo checkpoint, IEnumerable expectedBarrierSources) { await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) .ResumeStreamingAsync(workflow, checkpoint); List resumedEvents = await ReadToHaltAsync(resumed); ExternalRequest replayedRequest = resumedEvents.OfType() .Select(evt => evt.Request) .Should() .ContainSingle("resume should replay exactly one pending request") .Subject; await resumed.SendResponseAsync(replayedRequest.CreateResponse(new ApprovalReply("yes"))); List completionEvents = await ReadToHaltAsync(resumed); completionEvents.OfType().Should().BeEmpty( "resuming across a partially satisfied fan-in barrier should not raise workflow errors"); string[] outputs = [.. completionEvents.OfType().Select(evt => evt.Source)]; outputs.Should().BeEquivalentTo(expectedBarrierSources, "the barrier should release every expected contribution"); RunStatus status = await resumed.GetStatusAsync(); status.Should().Be(RunStatus.Idle, "the resumed run should halt cleanly once every barrier source has contributed"); return replayedRequest; } private sealed record BarrierContribution(string Source); private sealed record ApprovalRequest(string Prompt); private sealed record ApprovalReply(string Value); private sealed class BarrierReleasedEvent(string source) : WorkflowEvent { public string Source { get; } = source; } private sealed class PreCheckpointBarrierSource(string id, string requestPortId, string sinkId) : Executor(id) { protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.HandleAsync)) .SendsMessage() .SendsMessage(); private async ValueTask HandleAsync(string input, IWorkflowContext ctx) { await ctx.SendMessageAsync(new BarrierContribution("before"), sinkId).ConfigureAwait(false); await ctx.SendMessageAsync(new ApprovalRequest("continue?"), requestPortId).ConfigureAwait(false); } } private sealed class BarrierContributor(string id, string sinkId, string label) : Executor(id) { protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.HandleAsync)) .SendsMessage(); private ValueTask HandleAsync(string input, IWorkflowContext ctx) => ctx.SendMessageAsync(new BarrierContribution(label), sinkId); } private sealed class RequestPortKickoff(string id, string requestPortId) : Executor(id) { protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.HandleAsync)) .SendsMessage(); private ValueTask HandleAsync(string input, IWorkflowContext ctx) => ctx.SendMessageAsync(new ApprovalRequest("continue?"), requestPortId); } private sealed class PostCheckpointBarrierSource(string id, string sinkId) : Executor(id) { protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.HandleAsync)) .SendsMessage(); private ValueTask HandleAsync(ApprovalReply reply, IWorkflowContext ctx) => ctx.SendMessageAsync(new BarrierContribution("after"), sinkId); } private sealed class BarrierSink(string id) : Executor(id) { protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.HandleAsync)); private ValueTask HandleAsync(BarrierContribution contribution, IWorkflowContext ctx) => ctx.AddEventAsync(new BarrierReleasedEvent(contribution.Source)); } }