From 32ace65c291f889407de1e7bee4808dfa892f6f6 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:48:26 +0000 Subject: [PATCH 1/5] Add the abilty for the caller to release and cancel background tasks --- .../BackgroundAgentRuntimeState.cs | 22 ++ .../BackgroundAgentsProvider.cs | 204 ++++++++++- .../BackgroundAgentsProviderTests.cs | 331 ++++++++++++++++++ 3 files changed, 550 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs index f8e2f3accc..f4d4171c32 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.Agents.AI; @@ -29,4 +30,25 @@ internal sealed class BackgroundAgentRuntimeState /// [JsonIgnore] public Dictionary BackgroundTaskSessions { get; } = []; + + /// + /// Gets the mapping of task IDs to the controlling their run. + /// + /// + /// A source is created when a task is started or continued, and is disposed and removed when the task is + /// finalized, cleared, or when the session is released via . + /// + [JsonIgnore] + public Dictionary TaskCancellations { get; } = []; + + /// + /// Gets or sets a value indicating whether this runtime has been released via + /// . + /// + /// + /// Once released, all in-flight tasks have been cancelled and awaited, and the runtime references have been + /// dropped. Tools that would start new background work refuse to run against a released runtime. + /// + [JsonIgnore] + public bool IsReleased { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs index 755aa45769..74ace97662 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs @@ -35,6 +35,11 @@ namespace Microsoft.Agents.AI; /// /// /// +/// Background tasks are tracked per session and keep running until they complete. When a host is finished with a +/// session it should call to cancel and await any in-flight tasks, so that +/// abandoned work does not continue to invoke models and tools in the background. +/// +/// /// Security considerations: The agents passed to the constructor are delegated /// arbitrary work by the parent agent — the parent sends them text input (which may include content /// derived from the parent's own untrusted context) and receives back whatever text they produce. A @@ -61,6 +66,7 @@ You have access to background agents that can perform work on your behalf. """; private readonly Dictionary _agents; + private static readonly TimeSpan s_defaultReleaseTimeout = TimeSpan.FromSeconds(30); private readonly ProviderSessionState _sessionState; private readonly ProviderSessionState _runtimeSessionState; private readonly string _instructions; @@ -148,6 +154,151 @@ public IReadOnlyList GetIncompleteTasks(AgentSession? sessio return incomplete; } + /// + /// Releases all runtime state held for the specified session, cancelling and awaiting any in-flight background tasks. + /// + /// The agent session whose background runtime should be released. If , this method does nothing. + /// + /// to cancel any background tasks that are still running; to require that + /// all background tasks have already completed. + /// + /// + /// The maximum amount of time to wait for cancelled tasks to finish. Defaults to 30 seconds when . + /// Use to wait indefinitely. If the timeout elapses, the remaining tasks are + /// abandoned rather than blocking the caller. + /// + /// The to monitor for cancellation requests while waiting. + /// A task that represents the asynchronous release operation. + /// is and one or more background tasks are still running. + /// + /// + /// Background tasks continue to execute — invoking models and tools — even after a host stops using the session + /// that started them. Hosts should call this method when a conversation ends, or from their own eviction policy, + /// so that abandoned work is stopped instead of running to completion with results nobody will read. + /// + /// + /// This method is idempotent: releasing an already-released session does nothing. Once released, the + /// background_agents_start_task and background_agents_continue_task tools refuse to run for that + /// session, and any tasks that were still running are recorded as + /// so a restored session does not report phantom running work. + /// + /// + public async Task ReleaseSessionAsync( + AgentSession? session, + bool cancelRunning = true, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + { + if (session is null) + { + return; + } + + BackgroundAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(session); + if (runtimeState.IsReleased) + { + return; + } + + BackgroundAgentState state = this._sessionState.GetOrInitializeState(session); + + var trackedTasks = runtimeState.InFlightTasks.ToArray(); + if (!cancelRunning) + { + int runningCount = trackedTasks.Count(t => !t.Value.IsCompleted); + if (runningCount > 0) + { + throw new InvalidOperationException( + $"Cannot release the session because {runningCount} background task(s) are still running. Pass cancelRunning: true to cancel them."); + } + } + + runtimeState.IsReleased = true; + + try + { + if (trackedTasks.Length > 0) + { + foreach (var kvp in trackedTasks) + { + if (!kvp.Value.IsCompleted && + runtimeState.TaskCancellations.TryGetValue(kvp.Key, out CancellationTokenSource? cts)) + { + try + { + cts.Cancel(); + } + catch (ObjectDisposedException) + { + // The source was already disposed by a concurrent finalization; nothing to cancel. + } + } + } + + await WaitForTasksAsync(trackedTasks.Select(t => t.Value), timeout ?? s_defaultReleaseTimeout, cancellationToken).ConfigureAwait(false); + } + } + finally + { + foreach (int taskId in runtimeState.TaskCancellations.Keys) + { + DisposeTaskCancellation(runtimeState, taskId); + } + + runtimeState.InFlightTasks.Clear(); + runtimeState.BackgroundTaskSessions.Clear(); + + foreach (BackgroundTaskInfo task in state.Tasks) + { + if (task.Status == BackgroundTaskStatus.Running) + { + task.Status = BackgroundTaskStatus.Failed; + task.ErrorText = "Task was canceled because the session was released."; + } + } + + this._sessionState.SaveState(session, state); + this._runtimeSessionState.SaveState(session, runtimeState); + } + } + + /// + /// Waits for the specified tasks to finish, ignoring their exceptions and giving up once the timeout elapses. + /// + private static async Task WaitForTasksAsync(IEnumerable tasks, TimeSpan timeout, CancellationToken cancellationToken) + { + Task[] pending = tasks.Where(t => !t.IsCompleted).ToArray(); + if (pending.Length == 0) + { + return; + } + + // Observe faults/cancellations so that awaiting the group never throws. + Task all = Task.WhenAll(pending.Select(t => t.ContinueWith( + static _ => { }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default))); + + if (timeout == Timeout.InfiniteTimeSpan && !cancellationToken.CanBeCanceled) + { + await all.ConfigureAwait(false); + return; + } + + using var delayCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task delay = Task.Delay(timeout, delayCts.Token); + + Task winner = await Task.WhenAny(all, delay).ConfigureAwait(false); + delayCts.Cancel(); + + if (winner != all) + { + // The wait was abandoned; the remaining tasks are left to finish on their own. + cancellationToken.ThrowIfCancellationRequested(); + } + } + /// /// Validates the agent collection and builds a case-insensitive name dictionary. /// @@ -256,6 +407,39 @@ private static void FinalizeTask(BackgroundTaskInfo taskInfo, Task + /// Removes and disposes the tracked for the specified task, if any. + /// + private static void DisposeTaskCancellation(BackgroundAgentRuntimeState runtimeState, int taskId) + { + if (runtimeState.TaskCancellations.TryGetValue(taskId, out CancellationTokenSource? cts)) + { + runtimeState.TaskCancellations.Remove(taskId); + cts.Dispose(); + } + } + + /// + /// Starts a background run for the specified task, tracking both the resulting task and a + /// that allows the run to be cancelled when the session is released. + /// + private static void StartTrackedRun(BackgroundAgentRuntimeState runtimeState, int taskId, AIAgent agent, string input, AgentSession subSession) + { + // Replace any cancellation source left over from a previous run of the same task. + DisposeTaskCancellation(runtimeState, taskId); + + var cts = new CancellationTokenSource(); + runtimeState.TaskCancellations[taskId] = cts; + + // Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async + // method that synchronously sets the static AsyncLocal CurrentRunContext. Without + // this isolation, the background agent's RunAsync would overwrite the outer (calling) + // agent's CurrentRunContext, corrupting all subsequent tool invocations in the + // same FICC batch. + runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(input, subSession, cancellationToken: cts.Token), cts.Token); } private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeState runtimeState, AgentSession? session) @@ -270,6 +454,11 @@ private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeS [Description("The request to pass to the background agent.")] string input, [Description("A description of the task used to identify the task later.")] string description) => { + if (runtimeState.IsReleased) + { + return "Error: The background agents runtime for this session has been released. No new background tasks can be started."; + } + if (!this._agents.TryGetValue(agentName, out AIAgent? agent)) { return $"Error: No background agent found with name '{agentName}'. Available agents: {string.Join(", ", this._agents.Keys)}"; @@ -288,12 +477,7 @@ private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeS // Create a dedicated session for this background task so it can be continued later. AgentSession subSession = await agent.CreateSessionAsync().ConfigureAwait(false); - // Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async - // method that synchronously sets the static AsyncLocal CurrentRunContext. Without - // this isolation, the background agent's RunAsync would overwrite the outer (calling) - // agent's CurrentRunContext, corrupting all subsequent tool invocations in the - // same FICC batch. - runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(input, subSession)); + StartTrackedRun(runtimeState, taskId, agent, input, subSession); runtimeState.BackgroundTaskSessions[taskId] = subSession; this._sessionState.SaveState(session, state); @@ -420,6 +604,11 @@ private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeS AIFunctionFactory.Create( (int taskId, string text) => { + if (runtimeState.IsReleased) + { + return "Error: The background agents runtime for this session has been released. Background tasks can no longer be continued."; + } + this.TryRefreshTaskState(state, runtimeState, session); BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId); @@ -454,7 +643,7 @@ private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeS taskInfo.ErrorText = null; // Wrap in Task.Run to isolate the ExecutionContext (see StartBackgroundTask comment). - runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(text, subSession)); + StartTrackedRun(runtimeState, taskId, agent, text, subSession); this._sessionState.SaveState(session, state); return $"Task {taskId} continued with new input."; @@ -488,6 +677,7 @@ private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeS // Clean up runtime references. runtimeState.InFlightTasks.Remove(taskId); runtimeState.BackgroundTaskSessions.Remove(taskId); + DisposeTaskCancellation(runtimeState, taskId); this._sessionState.SaveState(session, state); return $"Task {taskId} cleared."; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs index f071d5e084..e078c2bd32 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs @@ -885,6 +885,298 @@ public async Task CustomAgentListBuilder_UsedForAgentListAsync() #endregion + #region ReleaseSessionAsync Tests + + /// + /// Verify that releasing a session cancels and awaits an in-flight background task. + /// + [Fact] + public async Task ReleaseSessionAsync_CancelsInFlightTaskAsync() + { + // Arrange + var observedCancellation = new TaskCompletionSource(); + var agent = CreateMockAgentWithCancellableCallback("Research", async ct => + { + try + { + await Task.Delay(Timeout.Infinite, ct); + } + catch (OperationCanceledException) + { + observedCancellation.SetResult(true); + throw; + } + + return new AgentResponse(new ChatMessage(ChatRole.Assistant, "never")); + }); + + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task"); + + await startBackgroundTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + + // Act + await provider.ReleaseSessionAsync(session); + + // Assert — the background run observed cancellation and no tasks remain running. + Assert.True(await observedCancellation.Task); + Assert.Empty(provider.GetIncompleteTasks(session)); + } + + /// + /// Verify that releasing a session more than once is a no-op. + /// + [Fact] + public async Task ReleaseSessionAsync_IsIdempotentAsync() + { + // Arrange + var agent = CreateMockAgentWithCancellableCallback("Research", async ct => + { + await Task.Delay(Timeout.Infinite, ct); + return new AgentResponse(new ChatMessage(ChatRole.Assistant, "never")); + }); + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + AIFunction startBackgroundTask = GetTool(tools, "background_agents_start_task"); + + await startBackgroundTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + + // Act + await provider.ReleaseSessionAsync(session); + await provider.ReleaseSessionAsync(session); + + // Assert — the second release did not throw and the state remains released. + Assert.Empty(provider.GetIncompleteTasks(session)); + } + + /// + /// Verify that releasing one session does not affect background tasks in another session. + /// + [Fact] + public async Task ReleaseSessionAsync_DoesNotAffectOtherSessionsAsync() + { + // Arrange + var agent = CreateMockAgentWithCancellableCallback("Research", async ct => + { + await Task.Delay(Timeout.Infinite, ct); + return new AgentResponse(new ChatMessage(ChatRole.Assistant, "never")); + }); + var provider = new BackgroundAgentsProvider(new[] { agent }); + + var (toolsA, sessionA) = await CreateToolsForSessionAsync(provider); + var (toolsB, sessionB) = await CreateToolsForSessionAsync(provider); + + await GetTool(toolsA, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task A", + ["description"] = "Session A task", + }); + + await GetTool(toolsB, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task B", + ["description"] = "Session B task", + }); + + // Act + await provider.ReleaseSessionAsync(sessionA); + + // Assert — session B's task is untouched. + Assert.Empty(provider.GetIncompleteTasks(sessionA)); + Assert.Single(provider.GetIncompleteTasks(sessionB)); + } + + /// + /// Verify that releasing with cancelRunning false throws when tasks are still running. + /// + [Fact] + public async Task ReleaseSessionAsync_CancelRunningFalseWithRunningTask_ThrowsAsync() + { + // Arrange + var tcs = new TaskCompletionSource(); + var agent = CreateMockAgentWithRunResult("Research", tcs.Task); + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + + await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + + // Act & Assert + await Assert.ThrowsAsync( + () => provider.ReleaseSessionAsync(session, cancelRunning: false)); + + // Assert — the task is left running because the release was rejected. + Assert.Single(provider.GetIncompleteTasks(session)); + + tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + + /// + /// Verify that releasing with cancelRunning false succeeds when all tasks have completed. + /// + [Fact] + public async Task ReleaseSessionAsync_CancelRunningFalseWithCompletedTask_SucceedsAsync() + { + // Arrange + var agent = CreateMockAgentWithRunResult("Research", Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")))); + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + + await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + + // Ensure the run has completed before releasing. + await GetTool(tools, "background_agents_wait_for_first_completion").InvokeAsync(new AIFunctionArguments + { + ["taskIds"] = new List { 1 }, + }); + + Assert.Empty(provider.GetIncompleteTasks(session)); + + // Act + await provider.ReleaseSessionAsync(session, cancelRunning: false); + + // Assert — subsequent starts are rejected, confirming the runtime was released. + object? result = await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 2", + ["description"] = "Second task", + }); + + Assert.Contains("released", GetStringResult(result)); + } + + /// + /// Verify that a task still running when the session is released is recorded as failed. + /// + [Fact] + public async Task ReleaseSessionAsync_MarksRunningTasksAsFailedAsync() + { + // Arrange + var agent = CreateMockAgentWithCancellableCallback("Research", async ct => + { + await Task.Delay(Timeout.Infinite, ct); + return new AgentResponse(new ChatMessage(ChatRole.Assistant, "never")); + }); + + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + + await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + + // Act + await provider.ReleaseSessionAsync(session); + + // Assert + object? result = await GetTool(tools, "background_agents_get_all_tasks").InvokeAsync(new AIFunctionArguments()); + string text = GetStringResult(result); + Assert.Contains("[Failed]", text); + } + + /// + /// Verify that the start and continue tools return an error after the session is released. + /// + [Fact] + public async Task ReleaseSessionAsync_ToolsReturnErrorAfterReleaseAsync() + { + // Arrange + var agent = CreateMockAgentWithRunResult("Research", Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")))); + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + + await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + + await provider.ReleaseSessionAsync(session); + + // Act + object? startResult = await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 2", + ["description"] = "Second task", + }); + + object? continueResult = await GetTool(tools, "background_agents_continue_task").InvokeAsync(new AIFunctionArguments + { + ["taskId"] = 1, + ["text"] = "More work", + }); + + // Assert + Assert.Contains("released", GetStringResult(startResult)); + Assert.Contains("released", GetStringResult(continueResult)); + } + + /// + /// Verify that a task ignoring cancellation does not block the release beyond the timeout. + /// + [Fact] + public async Task ReleaseSessionAsync_TimeoutAbandonsUncooperativeTaskAsync() + { + // Arrange — the run never observes its cancellation token. + var tcs = new TaskCompletionSource(); + var agent = CreateMockAgentWithRunResult("Research", tcs.Task); + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + + await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + + // Act + await provider.ReleaseSessionAsync(session, timeout: TimeSpan.FromMilliseconds(50)); + + // Assert — release completed despite the task still being pending. + Assert.False(tcs.Task.IsCompleted); + Assert.Empty(provider.GetIncompleteTasks(session)); + + tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "late"))); + } + + /// + /// Verify that releasing a null session does nothing. + /// + [Fact] + public async Task ReleaseSessionAsync_NullSession_DoesNothingAsync() + { + // Arrange + var agent = CreateMockAgent("Research", "Research agent"); + var provider = new BackgroundAgentsProvider(new[] { agent }); + + // Act & Assert — does not throw. + await provider.ReleaseSessionAsync(null); + } + + #endregion + #region Helper Methods private static AIAgent CreateMockAgent(string? name, string? description) @@ -944,6 +1236,45 @@ private static AIAgent CreateMockAgentWithCallback(string name, Func> callback) + { + var mock = new Mock(); + mock.SetupGet(a => a.Name).Returns(name); + mock.Protected() + .Setup>( + "CreateSessionCoreAsync", + ItExpr.IsAny()) + .Returns(new ValueTask(new ChatClientAgentSession())); + mock.Protected() + .Setup>( + "RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns((IEnumerable _, AgentSession _, AgentRunOptions _, CancellationToken ct) => callback(ct)); + return mock.Object; + } + + private static async Task<(IEnumerable Tools, BackgroundAgentsProvider Provider, AgentSession Session)> CreateToolsWithSessionAsync(AIAgent agent) + { + var provider = new BackgroundAgentsProvider(new[] { agent }); + var (tools, session) = await CreateToolsForSessionAsync(provider); + return (tools, provider, session); + } + + private static async Task<(IEnumerable Tools, AgentSession Session)> CreateToolsForSessionAsync(BackgroundAgentsProvider provider) + { + var mockAgent = new Mock().Object; + var session = new ChatClientAgentSession(); +#pragma warning disable MAAI001 + var context = new AIContextProvider.InvokingContext(mockAgent, session, new AIContext()); +#pragma warning restore MAAI001 + + AIContext result = await provider.InvokingAsync(context); + return (result.Tools!, session); + } + private static AIContextProvider.InvokingContext CreateInvokingContext() { var mockAgent = new Mock().Object; From 0bd46059c9ce61a39bb2e95f9510fdf8c14a78d9 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:52:06 +0000 Subject: [PATCH 2/5] Improve param validation --- .../BackgroundAgents/BackgroundAgentsProvider.cs | 10 ++++------ .../BackgroundAgents/BackgroundAgentsProviderTests.cs | 8 ++++---- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs index 74ace97662..0ef45e5905 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs @@ -157,7 +157,7 @@ public IReadOnlyList GetIncompleteTasks(AgentSession? sessio /// /// Releases all runtime state held for the specified session, cancelling and awaiting any in-flight background tasks. /// - /// The agent session whose background runtime should be released. If , this method does nothing. + /// The agent session whose background runtime should be released. /// /// to cancel any background tasks that are still running; to require that /// all background tasks have already completed. @@ -169,6 +169,7 @@ public IReadOnlyList GetIncompleteTasks(AgentSession? sessio /// /// The to monitor for cancellation requests while waiting. /// A task that represents the asynchronous release operation. + /// is . /// is and one or more background tasks are still running. /// /// @@ -184,15 +185,12 @@ public IReadOnlyList GetIncompleteTasks(AgentSession? sessio /// /// public async Task ReleaseSessionAsync( - AgentSession? session, + AgentSession session, bool cancelRunning = true, TimeSpan? timeout = null, CancellationToken cancellationToken = default) { - if (session is null) - { - return; - } + _ = Throw.IfNull(session); BackgroundAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(session); if (runtimeState.IsReleased) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs index e078c2bd32..b408ddf941 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs @@ -1162,17 +1162,17 @@ await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionA } /// - /// Verify that releasing a null session does nothing. + /// Verify that releasing a null session throws. /// [Fact] - public async Task ReleaseSessionAsync_NullSession_DoesNothingAsync() + public async Task ReleaseSessionAsync_NullSession_ThrowsAsync() { // Arrange var agent = CreateMockAgent("Research", "Research agent"); var provider = new BackgroundAgentsProvider(new[] { agent }); - // Act & Assert — does not throw. - await provider.ReleaseSessionAsync(null); + // Act & Assert + await Assert.ThrowsAsync(() => provider.ReleaseSessionAsync(null!)); } #endregion From 89bed19c9132eb06dfba15c94a754997e7d6afe5 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:18:01 +0000 Subject: [PATCH 3/5] Address PR comments --- .../BackgroundAgentRuntimeState.cs | 12 + .../BackgroundAgentsProvider.cs | 273 ++++++++++++------ .../BackgroundAgentsProviderTests.cs | 148 ++++++++++ 3 files changed, 348 insertions(+), 85 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs index f4d4171c32..7aad0db29c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs @@ -18,6 +18,18 @@ namespace Microsoft.Agents.AI; /// internal sealed class BackgroundAgentRuntimeState { + /// + /// Gets an object used to synchronize access to the runtime references held by this instance. + /// + /// + /// Background task registration happens on the agent's tool-invocation path, while + /// may be called concurrently by a host. + /// All mutations of the dictionaries below, and of , must be performed under this lock + /// so that a task can never be registered into an already-released runtime. + /// + [JsonIgnore] + public object SyncRoot { get; } = new(); + /// /// Gets the mapping of task IDs to their in-flight instances. /// diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs index 0ef45e5905..aec6a9883e 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs @@ -65,6 +65,14 @@ You have access to background agents that can perform work on your behalf. {background_agents} """; + private const string ReleasedRuntimeStartError = + "Error: The background agents runtime for this session has been released. No new background tasks can be started."; + + private const string ReleasedRuntimeContinueError = + "Error: The background agents runtime for this session has been released. Background tasks can no longer be continued."; + + private const string ReleasedTaskCanceledMessage = "Task was canceled because the session was released."; + private readonly Dictionary _agents; private static readonly TimeSpan s_defaultReleaseTimeout = TimeSpan.FromSeconds(30); private readonly ProviderSessionState _sessionState; @@ -170,6 +178,7 @@ public IReadOnlyList GetIncompleteTasks(AgentSession? sessio /// The to monitor for cancellation requests while waiting. /// A task that represents the asynchronous release operation. /// is . + /// is negative and is not . /// is and one or more background tasks are still running. /// /// @@ -192,66 +201,101 @@ public async Task ReleaseSessionAsync( { _ = Throw.IfNull(session); - BackgroundAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(session); - if (runtimeState.IsReleased) + TimeSpan effectiveTimeout = timeout ?? s_defaultReleaseTimeout; + if (effectiveTimeout < TimeSpan.Zero && effectiveTimeout != Timeout.InfiniteTimeSpan) { - return; + throw new ArgumentOutOfRangeException( + nameof(timeout), + effectiveTimeout, + "The timeout must not be negative, unless it is Timeout.InfiniteTimeSpan."); } + BackgroundAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(session); BackgroundAgentState state = this._sessionState.GetOrInitializeState(session); - var trackedTasks = runtimeState.InFlightTasks.ToArray(); - if (!cancelRunning) + KeyValuePair>[] trackedTasks; + HashSet pendingTaskIds; + + lock (runtimeState.SyncRoot) { - int runningCount = trackedTasks.Count(t => !t.Value.IsCompleted); - if (runningCount > 0) + if (runtimeState.IsReleased) + { + return; + } + + trackedTasks = runtimeState.InFlightTasks.ToArray(); + + // Snapshot which tasks were still pending before anything is cancelled. Tasks that had already + // finished keep their real outcome; only these pending ones are reported as released. + pendingTaskIds = [.. trackedTasks.Where(t => !t.Value.IsCompleted).Select(t => t.Key)]; + + if (!cancelRunning && pendingTaskIds.Count > 0) { throw new InvalidOperationException( - $"Cannot release the session because {runningCount} background task(s) are still running. Pass cancelRunning: true to cancel them."); + $"Cannot release the session because {pendingTaskIds.Count} background task(s) are still running. Pass cancelRunning: true to cancel them."); } - } - runtimeState.IsReleased = true; + runtimeState.IsReleased = true; - try - { - if (trackedTasks.Length > 0) + foreach (int taskId in pendingTaskIds) { - foreach (var kvp in trackedTasks) + if (runtimeState.TaskCancellations.TryGetValue(taskId, out CancellationTokenSource? cts)) { - if (!kvp.Value.IsCompleted && - runtimeState.TaskCancellations.TryGetValue(kvp.Key, out CancellationTokenSource? cts)) + try { - try - { - cts.Cancel(); - } - catch (ObjectDisposedException) - { - // The source was already disposed by a concurrent finalization; nothing to cancel. - } + cts.Cancel(); + } + catch (ObjectDisposedException) + { + // The source was already disposed by a concurrent finalization; nothing to cancel. } } - - await WaitForTasksAsync(trackedTasks.Select(t => t.Value), timeout ?? s_defaultReleaseTimeout, cancellationToken).ConfigureAwait(false); } } + + try + { + await WaitForTasksAsync(trackedTasks.Select(t => t.Value), effectiveTimeout, cancellationToken).ConfigureAwait(false); + } finally { - foreach (int taskId in runtimeState.TaskCancellations.Keys) + lock (runtimeState.SyncRoot) { - DisposeTaskCancellation(runtimeState, taskId); - } + // Finalize every tracked task that actually finished, so successful results and real failure + // reasons are preserved rather than being overwritten with a release failure. + foreach (var kvp in trackedTasks) + { + BackgroundTaskInfo? tracked = state.Tasks.FirstOrDefault(t => t.Id == kvp.Key); + if (tracked is null || tracked.Status != BackgroundTaskStatus.Running || !kvp.Value.IsCompleted) + { + continue; + } - runtimeState.InFlightTasks.Clear(); - runtimeState.BackgroundTaskSessions.Clear(); + FinalizeTask(tracked, kvp.Value, runtimeState); - foreach (BackgroundTaskInfo task in state.Tasks) - { - if (task.Status == BackgroundTaskStatus.Running) + if (kvp.Value.IsCanceled && pendingTaskIds.Contains(kvp.Key)) + { + // Report the actual reason rather than the generic cancellation message. + tracked.ErrorText = ReleasedTaskCanceledMessage; + } + } + + foreach (int taskId in runtimeState.TaskCancellations.Keys.ToArray()) { - task.Status = BackgroundTaskStatus.Failed; - task.ErrorText = "Task was canceled because the session was released."; + DisposeTaskCancellation(runtimeState, taskId); + } + + runtimeState.InFlightTasks.Clear(); + runtimeState.BackgroundTaskSessions.Clear(); + + // Anything still running was abandoned (for example after the timeout elapsed). + foreach (BackgroundTaskInfo task in state.Tasks) + { + if (task.Status == BackgroundTaskStatus.Running) + { + task.Status = BackgroundTaskStatus.Failed; + task.ErrorText = ReleasedTaskCanceledMessage; + } } } @@ -261,22 +305,26 @@ public async Task ReleaseSessionAsync( } /// - /// Waits for the specified tasks to finish, ignoring their exceptions and giving up once the timeout elapses. + /// Waits for the specified tasks to finish, observing their exceptions and giving up once the timeout elapses. /// private static async Task WaitForTasksAsync(IEnumerable tasks, TimeSpan timeout, CancellationToken cancellationToken) { - Task[] pending = tasks.Where(t => !t.IsCompleted).ToArray(); + // Attach an observer to every task, including those that already completed, so that a fault is always + // observed. Otherwise clearing the last reference to a faulted task can surface an UnobservedTaskException. + Task[] observers = tasks.Select(t => t.ContinueWith( + static antecedent => _ = antecedent.Exception, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default)).ToArray(); + + // Observers never fault, so awaiting them cannot throw. + Task[] pending = observers.Where(o => !o.IsCompleted).ToArray(); if (pending.Length == 0) { return; } - // Observe faults/cancellations so that awaiting the group never throws. - Task all = Task.WhenAll(pending.Select(t => t.ContinueWith( - static _ => { }, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default))); + Task all = Task.WhenAll(pending); if (timeout == Timeout.InfiniteTimeSpan && !cancellationToken.CanBeCanceled) { @@ -353,25 +401,28 @@ private static string BuildDefaultAgentListText(IReadOnlyDictionary? inFlight)) - { - // In-flight reference lost (e.g., after restart/deserialization). - task.Status = BackgroundTaskStatus.Lost; - changed = true; - continue; - } + if (!runtimeState.InFlightTasks.TryGetValue(task.Id, out Task? inFlight)) + { + // In-flight reference lost (e.g., after restart/deserialization). + task.Status = BackgroundTaskStatus.Lost; + changed = true; + continue; + } - if (inFlight.IsCompleted) - { - FinalizeTask(task, inFlight, runtimeState); - changed = true; + if (inFlight.IsCompleted) + { + FinalizeTask(task, inFlight, runtimeState); + changed = true; + } } } @@ -384,6 +435,7 @@ private void TryRefreshTaskState(BackgroundAgentState state, BackgroundAgentRunt /// /// Finalizes a task by extracting results from the completed Task and updating the BackgroundTaskInfo. /// + /// Callers must hold . private static void FinalizeTask(BackgroundTaskInfo taskInfo, Task completedTask, BackgroundAgentRuntimeState runtimeState) { if (completedTask.Status == TaskStatus.RanToCompletion) @@ -411,6 +463,7 @@ private static void FinalizeTask(BackgroundTaskInfo taskInfo, Task /// Removes and disposes the tracked for the specified task, if any. /// + /// Callers must hold . private static void DisposeTaskCancellation(BackgroundAgentRuntimeState runtimeState, int taskId) { if (runtimeState.TaskCancellations.TryGetValue(taskId, out CancellationTokenSource? cts)) @@ -424,20 +477,35 @@ private static void DisposeTaskCancellation(BackgroundAgentRuntimeState runtimeS /// Starts a background run for the specified task, tracking both the resulting task and a /// that allows the run to be cancelled when the session is released. /// - private static void StartTrackedRun(BackgroundAgentRuntimeState runtimeState, int taskId, AIAgent agent, string input, AgentSession subSession) + /// + /// if the run was started and tracked; if the session was + /// released before the run could be registered, in which case nothing is started. + /// + private static bool StartTrackedRun(BackgroundAgentRuntimeState runtimeState, int taskId, AIAgent agent, string input, AgentSession subSession) { - // Replace any cancellation source left over from a previous run of the same task. - DisposeTaskCancellation(runtimeState, taskId); - - var cts = new CancellationTokenSource(); - runtimeState.TaskCancellations[taskId] = cts; - - // Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async - // method that synchronously sets the static AsyncLocal CurrentRunContext. Without - // this isolation, the background agent's RunAsync would overwrite the outer (calling) - // agent's CurrentRunContext, corrupting all subsequent tool invocations in the - // same FICC batch. - runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(input, subSession, cancellationToken: cts.Token), cts.Token); + lock (runtimeState.SyncRoot) + { + // Re-check under the lock: the session may have been released while the caller awaited session creation. + // Starting here would produce a task that is never tracked and therefore never cancelled. + if (runtimeState.IsReleased) + { + return false; + } + + // Replace any cancellation source left over from a previous run of the same task. + DisposeTaskCancellation(runtimeState, taskId); + + var cts = new CancellationTokenSource(); + runtimeState.TaskCancellations[taskId] = cts; + + // Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async + // method that synchronously sets the static AsyncLocal CurrentRunContext. Without + // this isolation, the background agent's RunAsync would overwrite the outer (calling) + // agent's CurrentRunContext, corrupting all subsequent tool invocations in the + // same FICC batch. + runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(input, subSession, cancellationToken: cts.Token), cts.Token); + return true; + } } private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeState runtimeState, AgentSession? session) @@ -454,7 +522,7 @@ private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeS { if (runtimeState.IsReleased) { - return "Error: The background agents runtime for this session has been released. No new background tasks can be started."; + return ReleasedRuntimeStartError; } if (!this._agents.TryGetValue(agentName, out AIAgent? agent)) @@ -475,8 +543,18 @@ private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeS // Create a dedicated session for this background task so it can be continued later. AgentSession subSession = await agent.CreateSessionAsync().ConfigureAwait(false); - StartTrackedRun(runtimeState, taskId, agent, input, subSession); - runtimeState.BackgroundTaskSessions[taskId] = subSession; + if (!StartTrackedRun(runtimeState, taskId, agent, input, subSession)) + { + // The session was released while the background session was being created. + state.Tasks.Remove(taskInfo); + this._sessionState.SaveState(session, state); + return ReleasedRuntimeStartError; + } + + lock (runtimeState.SyncRoot) + { + runtimeState.BackgroundTaskSessions[taskId] = subSession; + } this._sessionState.SaveState(session, state); return $"Background task {taskId} started on agent '{agentName}'."; @@ -499,11 +577,14 @@ private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeS // Collect in-flight tasks matching the requested IDs (including already-completed ones, // since Task.WhenAny returns immediately for completed tasks). var waitableTasks = new List<(int Id, Task Task)>(); - foreach (int id in taskIds) + lock (runtimeState.SyncRoot) { - if (runtimeState.InFlightTasks.TryGetValue(id, out Task? inFlight)) + foreach (int id in taskIds) { - waitableTasks.Add((id, inFlight)); + if (runtimeState.InFlightTasks.TryGetValue(id, out Task? inFlight)) + { + waitableTasks.Add((id, inFlight)); + } } } @@ -533,7 +614,14 @@ private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeS BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == completedEntry.Id); if (taskInfo is not null) { - FinalizeTask(taskInfo, completedEntry.Task, runtimeState); + lock (runtimeState.SyncRoot) + { + if (taskInfo.Status == BackgroundTaskStatus.Running) + { + FinalizeTask(taskInfo, completedEntry.Task, runtimeState); + } + } + this._sessionState.SaveState(session, state); } @@ -604,7 +692,7 @@ private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeS { if (runtimeState.IsReleased) { - return "Error: The background agents runtime for this session has been released. Background tasks can no longer be continued."; + return ReleasedRuntimeContinueError; } this.TryRefreshTaskState(state, runtimeState, session); @@ -630,7 +718,13 @@ private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeS return $"Error: Agent '{taskInfo.AgentName}' is no longer available."; } - if (!runtimeState.BackgroundTaskSessions.TryGetValue(taskId, out AgentSession? subSession)) + AgentSession? subSession; + lock (runtimeState.SyncRoot) + { + _ = runtimeState.BackgroundTaskSessions.TryGetValue(taskId, out subSession); + } + + if (subSession is null) { return $"Error: Session for task {taskId} is no longer available."; } @@ -641,7 +735,13 @@ private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeS taskInfo.ErrorText = null; // Wrap in Task.Run to isolate the ExecutionContext (see StartBackgroundTask comment). - StartTrackedRun(runtimeState, taskId, agent, text, subSession); + if (!StartTrackedRun(runtimeState, taskId, agent, text, subSession)) + { + taskInfo.Status = BackgroundTaskStatus.Failed; + taskInfo.ErrorText = ReleasedTaskCanceledMessage; + this._sessionState.SaveState(session, state); + return ReleasedRuntimeContinueError; + } this._sessionState.SaveState(session, state); return $"Task {taskId} continued with new input."; @@ -673,9 +773,12 @@ private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeS state.Tasks.Remove(taskInfo); // Clean up runtime references. - runtimeState.InFlightTasks.Remove(taskId); - runtimeState.BackgroundTaskSessions.Remove(taskId); - DisposeTaskCancellation(runtimeState, taskId); + lock (runtimeState.SyncRoot) + { + runtimeState.InFlightTasks.Remove(taskId); + runtimeState.BackgroundTaskSessions.Remove(taskId); + DisposeTaskCancellation(runtimeState, taskId); + } this._sessionState.SaveState(session, state); return $"Task {taskId} cleared."; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs index b408ddf941..75cc0cabb2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs @@ -894,9 +894,11 @@ public async Task CustomAgentListBuilder_UsedForAgentListAsync() public async Task ReleaseSessionAsync_CancelsInFlightTaskAsync() { // Arrange + var callbackEntered = new TaskCompletionSource(); var observedCancellation = new TaskCompletionSource(); var agent = CreateMockAgentWithCancellableCallback("Research", async ct => { + callbackEntered.SetResult(true); try { await Task.Delay(Timeout.Infinite, ct); @@ -920,6 +922,10 @@ await startBackgroundTask.InvokeAsync(new AIFunctionArguments ["description"] = "First task", }); + // Wait until the run is actually executing, otherwise cancellation may prevent the + // delegate from ever being scheduled and the cancellation signal would never be set. + Assert.True(await callbackEntered.Task); + // Act await provider.ReleaseSessionAsync(session); @@ -1175,6 +1181,124 @@ public async Task ReleaseSessionAsync_NullSession_ThrowsAsync() await Assert.ThrowsAsync(() => provider.ReleaseSessionAsync(null!)); } + /// + /// Verify that a task which completed but has not yet been refreshed keeps its result instead of + /// being reported as canceled by the release. + /// + [Fact] + public async Task ReleaseSessionAsync_CompletedButUnrefreshedTask_KeepsResultAsync() + { + // Arrange + var runEntered = new TaskCompletionSource(); + var agent = CreateMockAgentWithCancellableCallback("Research", _ => + { + runEntered.TrySetResult(true); + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Result 1"))); + }); + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + + await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + + // Wait until the run has actually started, so it cannot be cancelled before it produces a result. + // The persisted state is never refreshed, so the task is still marked Running when the release begins. + Assert.True(await runEntered.Task); + + // Act + await provider.ReleaseSessionAsync(session); + + // Assert — the successful result is preserved rather than overwritten with a release failure. + object? result = await GetTool(tools, "background_agents_get_task_results").InvokeAsync(new AIFunctionArguments + { + ["taskId"] = 1, + }); + + Assert.Equal("Result 1", GetStringResult(result)); + } + + /// + /// Verify that an invalid timeout is rejected before the session is released. + /// + [Fact] + public async Task ReleaseSessionAsync_NegativeTimeout_ThrowsWithoutReleasingAsync() + { + // Arrange + var tcs = new TaskCompletionSource(); + var agent = CreateMockAgentWithRunResult("Research", tcs.Task); + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + + await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + + // Act & Assert + await Assert.ThrowsAsync( + () => provider.ReleaseSessionAsync(session, timeout: TimeSpan.FromSeconds(-5))); + + // Assert — the session was not released, so the task is untouched and new tasks can still start. + Assert.Single(provider.GetIncompleteTasks(session)); + + object? startResult = await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 2", + ["description"] = "Second task", + }); + + Assert.DoesNotContain("released", GetStringResult(startResult)); + + tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + + /// + /// Verify that a start racing with a release does not register an untracked background task. + /// + [Fact] + public async Task ReleaseSessionAsync_DuringStart_RefusesToRegisterTaskAsync() + { + // Arrange — session creation blocks so the release can happen mid-start. + var sessionCreationGate = new TaskCompletionSource(); + var runStarted = false; + var agent = CreateMockAgentWithGatedSession( + "Research", + sessionCreationGate.Task, + () => + { + runStarted = true; + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done"))); + }); + + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + + Task startTask = GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }).AsTask(); + + // Act — release while the start is awaiting session creation, then let creation finish. + await provider.ReleaseSessionAsync(session); + sessionCreationGate.SetResult(true); + + object? startResult = await startTask; + + // Assert — the start was refused, no run was launched, and no task was recorded. + Assert.Contains("released", GetStringResult(startResult)); + Assert.False(runStarted); + Assert.Empty(provider.GetIncompleteTasks(session)); + + object? allTasks = await GetTool(tools, "background_agents_get_all_tasks").InvokeAsync(new AIFunctionArguments()); + Assert.Equal("No tasks.", GetStringResult(allTasks)); + } + #endregion #region Helper Methods @@ -1256,6 +1380,30 @@ private static AIAgent CreateMockAgentWithCancellableCallback(string name, Func< return mock.Object; } + private static AIAgent CreateMockAgentWithGatedSession(string name, Task sessionGate, Func> callback) + { + var mock = new Mock(); + mock.SetupGet(a => a.Name).Returns(name); + mock.Protected() + .Setup>( + "CreateSessionCoreAsync", + ItExpr.IsAny()) + .Returns(async () => + { + await sessionGate; + return new ChatClientAgentSession(); + }); + mock.Protected() + .Setup>( + "RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(callback); + return mock.Object; + } + private static async Task<(IEnumerable Tools, BackgroundAgentsProvider Provider, AgentSession Session)> CreateToolsWithSessionAsync(AIAgent agent) { var provider = new BackgroundAgentsProvider(new[] { agent }); From 0fc3da4e72f17d2367ea5df2dc269524b9c57311 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:39:47 +0000 Subject: [PATCH 4/5] Address PR comments. --- .../BackgroundAgentRuntimeState.cs | 14 ++ .../BackgroundAgentsProvider.cs | 122 +++++++++--- .../BackgroundAgentsProviderTests.cs | 181 ++++++++++++++++++ 3 files changed, 287 insertions(+), 30 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs index 7aad0db29c..1f0c2b05a8 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentRuntimeState.cs @@ -63,4 +63,18 @@ internal sealed class BackgroundAgentRuntimeState /// [JsonIgnore] public bool IsReleased { get; set; } + + /// + /// Gets or sets the completion signalled once the release of this runtime has finished all of its cleanup. + /// + /// + /// Set under by the caller that first releases the runtime, and completed once that + /// caller has finished waiting for the in-flight tasks and has dropped the runtime references. Callers that + /// arrive while a release is already in progress await this instead of returning early, so that a completed + /// always means the cleanup is done. It is completed + /// successfully even when the releasing caller fails, because a waiter should observe that cleanup finished + /// rather than inherit another caller's failure. + /// + [JsonIgnore] + public TaskCompletionSource? ReleaseCompletion { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs index aec6a9883e..4283b0db50 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs @@ -192,6 +192,13 @@ public IReadOnlyList GetIncompleteTasks(AgentSession? sessio /// session, and any tasks that were still running are recorded as /// so a restored session does not report phantom running work. /// + /// + /// It is also safe to call concurrently. A caller that arrives while another release of the same session is + /// still in progress waits for that release to finish rather than returning early, so a completed call always + /// means the background tasks have been cancelled, awaited and cleaned up. Such a caller observes only its own + /// ; it neither inherits the in-progress release's failure nor is held up + /// by that caller's . + /// /// public async Task ReleaseSessionAsync( AgentSession session, @@ -213,46 +220,66 @@ public async Task ReleaseSessionAsync( BackgroundAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(session); BackgroundAgentState state = this._sessionState.GetOrInitializeState(session); - KeyValuePair>[] trackedTasks; - HashSet pendingTaskIds; + KeyValuePair>[] trackedTasks = []; + HashSet pendingTaskIds = []; + TaskCompletionSource? releaseCompletion = null; + Task? releaseInProgress = null; lock (runtimeState.SyncRoot) { if (runtimeState.IsReleased) { - return; + // A release is already in progress or has completed. Await it rather than returning early, so that + // a completed call always means the in-flight tasks have been cancelled, awaited and cleaned up. + releaseInProgress = runtimeState.ReleaseCompletion?.Task; } + else + { + trackedTasks = runtimeState.InFlightTasks.ToArray(); - trackedTasks = runtimeState.InFlightTasks.ToArray(); - - // Snapshot which tasks were still pending before anything is cancelled. Tasks that had already - // finished keep their real outcome; only these pending ones are reported as released. - pendingTaskIds = [.. trackedTasks.Where(t => !t.Value.IsCompleted).Select(t => t.Key)]; + // Snapshot which tasks were still pending before anything is cancelled. Tasks that had already + // finished keep their real outcome; only these pending ones are reported as released. + pendingTaskIds = [.. trackedTasks.Where(t => !t.Value.IsCompleted).Select(t => t.Key)]; - if (!cancelRunning && pendingTaskIds.Count > 0) - { - throw new InvalidOperationException( - $"Cannot release the session because {pendingTaskIds.Count} background task(s) are still running. Pass cancelRunning: true to cancel them."); - } + if (!cancelRunning && pendingTaskIds.Count > 0) + { + throw new InvalidOperationException( + $"Cannot release the session because {pendingTaskIds.Count} background task(s) are still running. Pass cancelRunning: true to cancel them."); + } - runtimeState.IsReleased = true; + // Continuations run asynchronously so that a waiting caller never resumes inline on the thread + // that is completing the release. + releaseCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + runtimeState.ReleaseCompletion = releaseCompletion; + runtimeState.IsReleased = true; - foreach (int taskId in pendingTaskIds) - { - if (runtimeState.TaskCancellations.TryGetValue(taskId, out CancellationTokenSource? cts)) + foreach (int taskId in pendingTaskIds) { - try + if (runtimeState.TaskCancellations.TryGetValue(taskId, out CancellationTokenSource? cts)) { - cts.Cancel(); - } - catch (ObjectDisposedException) - { - // The source was already disposed by a concurrent finalization; nothing to cancel. + try + { + cts.Cancel(); + } + catch (ObjectDisposedException) + { + // The source was already disposed by a concurrent finalization; nothing to cancel. + } } } } } + if (releaseCompletion is null) + { + if (releaseInProgress is not null) + { + await AwaitReleaseInProgressAsync(releaseInProgress, cancellationToken).ConfigureAwait(false); + } + + return; + } + try { await WaitForTasksAsync(trackedTasks.Select(t => t.Value), effectiveTimeout, cancellationToken).ConfigureAwait(false); @@ -301,6 +328,36 @@ public async Task ReleaseSessionAsync( this._sessionState.SaveState(session, state); this._runtimeSessionState.SaveState(session, runtimeState); + + // Signalled last so that any caller awaiting this release observes fully cleaned-up state. Completed + // successfully even when this caller failed, because the cleanup above always runs. + releaseCompletion.TrySetResult(true); + } + } + + /// + /// Waits for a release that another caller started to finish its cleanup, giving up if the caller's own + /// is signalled. + /// + private static async Task AwaitReleaseInProgressAsync(Task releaseInProgress, CancellationToken cancellationToken) + { + if (releaseInProgress.IsCompleted || !cancellationToken.CanBeCanceled) + { + // The release completion is never faulted or cancelled, so awaiting it cannot throw. + await releaseInProgress.ConfigureAwait(false); + return; + } + + // Do not let this caller be held up by the releasing caller's timeout. + var cancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(true), cancellation)) + { + await Task.WhenAny(releaseInProgress, cancellation.Task).ConfigureAwait(false); + } + + if (!releaseInProgress.IsCompleted) + { + cancellationToken.ThrowIfCancellationRequested(); } } @@ -474,13 +531,18 @@ private static void DisposeTaskCancellation(BackgroundAgentRuntimeState runtimeS } /// - /// Starts a background run for the specified task, tracking both the resulting task and a - /// that allows the run to be cancelled when the session is released. + /// Starts a background run for the specified task, tracking the resulting task, the background agent session, + /// and a that allows the run to be cancelled when the session is released. /// /// /// if the run was started and tracked; if the session was /// released before the run could be registered, in which case nothing is started. /// + /// + /// All references for the task are registered under a single acquisition of + /// , so a concurrent release can never observe a partially + /// registered task, nor can a caller re-add references to a runtime that has already been released. + /// private static bool StartTrackedRun(BackgroundAgentRuntimeState runtimeState, int taskId, AIAgent agent, string input, AgentSession subSession) { lock (runtimeState.SyncRoot) @@ -498,6 +560,11 @@ private static bool StartTrackedRun(BackgroundAgentRuntimeState runtimeState, in var cts = new CancellationTokenSource(); runtimeState.TaskCancellations[taskId] = cts; + // Registered here rather than by the caller so that the session reference cannot be re-added to a + // runtime that a concurrent release has already cleared. For a continued task this simply re-assigns + // the session the caller read from this same dictionary. + runtimeState.BackgroundTaskSessions[taskId] = subSession; + // Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async // method that synchronously sets the static AsyncLocal CurrentRunContext. Without // this isolation, the background agent's RunAsync would overwrite the outer (calling) @@ -551,11 +618,6 @@ private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeS return ReleasedRuntimeStartError; } - lock (runtimeState.SyncRoot) - { - runtimeState.BackgroundTaskSessions[taskId] = subSession; - } - this._sessionState.SaveState(session, state); return $"Background task {taskId} started on agent '{agentName}'."; }, diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs index 75cc0cabb2..8e9b0ee55f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs @@ -1299,6 +1299,179 @@ public async Task ReleaseSessionAsync_DuringStart_RefusesToRegisterTaskAsync() Assert.Equal("No tasks.", GetStringResult(allTasks)); } + /// + /// Verify that a release racing with a start never leaves behind a background agent session, which would + /// otherwise be retained for the lifetime of the parent session. + /// + [Fact] + public async Task ReleaseSessionAsync_ConcurrentWithStart_LeavesNoRegisteredSessionsAsync() + { + // Registering the task and its session under a single lock makes this invariant hold for every possible + // interleaving. Repeat so that a range of interleavings is exercised. + for (int i = 0; i < 50; i++) + { + // Arrange + var agent = CreateMockAgentWithCancellableCallback( + "Research", + _ => Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")))); + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + + // Act — start a task and release the session concurrently. + Task startTask = GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }).AsTask(); + + Task releaseTask = Task.Run(() => provider.ReleaseSessionAsync(session)); + + await startTask; + await releaseTask; + + // Assert — the release owns the cleanup, so no runtime reference may survive it. + BackgroundAgentRuntimeState runtimeState = GetRuntimeState(provider, session); + Assert.Empty(runtimeState.BackgroundTaskSessions); + Assert.Empty(runtimeState.InFlightTasks); + Assert.Empty(runtimeState.TaskCancellations); + } + } + + /// + /// Verify that a caller releasing a session while another release is in progress waits for that release to + /// finish rather than returning while tasks are still being cleaned up. + /// + [Fact] + public async Task ReleaseSessionAsync_ConcurrentReleases_AllWaitForCleanupAsync() + { + // Arrange — the run ignores cancellation, so the first release stays in its wait until the gate opens. + var runEntered = new TaskCompletionSource(); + var runGate = new TaskCompletionSource(); + var agent = CreateMockAgentWithCancellableCallback("Research", async _ => + { + runEntered.TrySetResult(true); + await runGate.Task; + return new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")); + }); + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + + await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + + Assert.True(await runEntered.Task); + + // Act — the first release blocks on the running task; the second arrives while it is still waiting. + Task firstRelease = provider.ReleaseSessionAsync(session, timeout: Timeout.InfiniteTimeSpan); + Task secondRelease = provider.ReleaseSessionAsync(session, timeout: Timeout.InfiniteTimeSpan); + + // Assert — the second caller must not report a completed release while cleanup is still outstanding. + await Task.Delay(50); + Assert.False(firstRelease.IsCompleted); + Assert.False(secondRelease.IsCompleted); + + runGate.SetResult(true); + await firstRelease; + await secondRelease; + + // Assert — both callers observe fully cleaned-up state. + BackgroundAgentRuntimeState runtimeState = GetRuntimeState(provider, session); + Assert.Empty(runtimeState.InFlightTasks); + Assert.Empty(runtimeState.BackgroundTaskSessions); + Assert.Empty(runtimeState.TaskCancellations); + } + + /// + /// Verify that a caller waiting on an in-progress release observes its own cancellation token rather than + /// being held up by the releasing caller. + /// + [Fact] + public async Task ReleaseSessionAsync_ConcurrentRelease_ObservesOwnCancellationAsync() + { + // Arrange + var runEntered = new TaskCompletionSource(); + var runGate = new TaskCompletionSource(); + var agent = CreateMockAgentWithCancellableCallback("Research", async _ => + { + runEntered.TrySetResult(true); + await runGate.Task; + return new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")); + }); + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + + await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + + Assert.True(await runEntered.Task); + + Task firstRelease = provider.ReleaseSessionAsync(session, timeout: Timeout.InfiniteTimeSpan); + + // Act & Assert — the second caller gives up on its own token instead of waiting for the first. + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + () => provider.ReleaseSessionAsync(session, cancellationToken: cts.Token)); + + Assert.False(firstRelease.IsCompleted); + + runGate.SetResult(true); + await firstRelease; + } + + /// + /// Verify that a caller waiting on an in-progress release does not inherit the failure of the caller that + /// started it. + /// + [Fact] + public async Task ReleaseSessionAsync_ConcurrentRelease_DoesNotInheritFirstCallerFailureAsync() + { + // Arrange — the run never completes, so the first release only ends when its own token is cancelled. + var runEntered = new TaskCompletionSource(); + var runGate = new TaskCompletionSource(); + var agent = CreateMockAgentWithCancellableCallback("Research", async _ => + { + runEntered.TrySetResult(true); + await runGate.Task; + return new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")); + }); + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + + await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + + Assert.True(await runEntered.Task); + + using var cts = new CancellationTokenSource(); + Task firstRelease = provider.ReleaseSessionAsync(session, timeout: Timeout.InfiniteTimeSpan, cancellationToken: cts.Token); + Task secondRelease = provider.ReleaseSessionAsync(session, timeout: Timeout.InfiniteTimeSpan); + + // Act — the first caller abandons its wait. + cts.Cancel(); + + // Assert — the second caller still completes successfully once the cleanup has run. + await Assert.ThrowsAnyAsync(() => firstRelease); + await secondRelease; + + BackgroundAgentRuntimeState runtimeState = GetRuntimeState(provider, session); + Assert.Empty(runtimeState.InFlightTasks); + Assert.Empty(runtimeState.BackgroundTaskSessions); + Assert.Empty(runtimeState.TaskCancellations); + + runGate.SetResult(true); + } + #endregion #region Helper Methods @@ -1423,6 +1596,14 @@ private static AIAgent CreateMockAgentWithGatedSession(string name, Task session return (result.Tools!, session); } + private static BackgroundAgentRuntimeState GetRuntimeState(BackgroundAgentsProvider provider, AgentSession session) + { + // The runtime state key is the second of the provider's state keys. + string runtimeStateKey = provider.StateKeys[1]; + Assert.True(session.StateBag.TryGetValue(runtimeStateKey, out BackgroundAgentRuntimeState? runtimeState, AgentJsonUtilities.DefaultOptions)); + return runtimeState!; + } + private static AIContextProvider.InvokingContext CreateInvokingContext() { var mockAgent = new Mock().Object; From 7509bae3ce47cd9bceddc2dfea8094692244b67b Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:50:19 +0000 Subject: [PATCH 5/5] Address PR comments: cancel tasks before publishing the release Set IsReleased and publish the ReleaseCompletion only after the in-flight tasks have actually been cancelled, so a failure to cancel leaves the session un-released instead of flagging it as released while its tasks are still running. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BackgroundAgentsProvider.cs | 14 ++++--- .../BackgroundAgentsProviderTests.cs | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs index 4283b0db50..5e7aa2ecc1 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs @@ -247,12 +247,8 @@ public async Task ReleaseSessionAsync( $"Cannot release the session because {pendingTaskIds.Count} background task(s) are still running. Pass cancelRunning: true to cancel them."); } - // Continuations run asynchronously so that a waiting caller never resumes inline on the thread - // that is completing the release. - releaseCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - runtimeState.ReleaseCompletion = releaseCompletion; - runtimeState.IsReleased = true; - + // Cancel before publishing the release. If cancelling throws, the runtime is left un-released so + // that the caller can retry, rather than being flagged as released with tasks still running. foreach (int taskId in pendingTaskIds) { if (runtimeState.TaskCancellations.TryGetValue(taskId, out CancellationTokenSource? cts)) @@ -267,6 +263,12 @@ public async Task ReleaseSessionAsync( } } } + + // Continuations run asynchronously so that a waiting caller never resumes inline on the thread + // that is completing the release. + releaseCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + runtimeState.ReleaseCompletion = releaseCompletion; + runtimeState.IsReleased = true; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs index 8e9b0ee55f..6de4356388 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cs @@ -1257,6 +1257,46 @@ await Assert.ThrowsAsync( tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done"))); } + /// + /// Verify that a failure to cancel the running tasks leaves the session un-released, rather than marking it + /// released while its tasks are still running. + /// + [Fact] + public async Task ReleaseSessionAsync_CancellationThrows_LeavesSessionUnreleasedAsync() + { + // Arrange — a cancellation callback that throws makes CancellationTokenSource.Cancel throw. + var runEntered = new TaskCompletionSource(); + var runGate = new TaskCompletionSource(); + var agent = CreateMockAgentWithCancellableCallback("Research", async ct => + { + ct.Register(() => throw new InvalidOperationException("Cancellation callback failed.")); + runEntered.TrySetResult(true); + await runGate.Task; + return new AgentResponse(new ChatMessage(ChatRole.Assistant, "done")); + }); + var (tools, provider, session) = await CreateToolsWithSessionAsync(agent); + + await GetTool(tools, "background_agents_start_task").InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + + Assert.True(await runEntered.Task); + + // Act & Assert + await Assert.ThrowsAsync(() => provider.ReleaseSessionAsync(session)); + + // Assert — the release did not take effect, so the session remains usable and can be released again. + BackgroundAgentRuntimeState runtimeState = GetRuntimeState(provider, session); + Assert.False(runtimeState.IsReleased); + Assert.Null(runtimeState.ReleaseCompletion); + Assert.Single(provider.GetIncompleteTasks(session)); + + runGate.SetResult(true); + } + /// /// Verify that a start racing with a release does not register an untracked background task. ///