From 8c8eb9eeea48d5f3a4c50133688b48fe271348f1 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 19 Aug 2026 15:04:24 -0500 Subject: [PATCH 1/3] Use the class Ask timeout in the double-approval sub-agent test SubAgentActorTests.Approve_once_does_not_leak_between_subagent_tool_calls used a 5-second literal on its outer Ask. Every sibling approval test in the file uses the class constant ApprovalAskTimeout, which is 30 seconds. This test drives two sequential approval round-trips. It makes more hops than any sibling, so it is the least able to tolerate a 5-second budget. A starved Windows CI runner made it flake. The literal now uses the class constant. The Ask keeps an explicit timeout, because Ask falls back to akka.actor.ask-timeout, which is infinite by default. Deletion would trade a flaky wait for a hang. --- src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index a5b709649..f10e083c0 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs @@ -1012,7 +1012,7 @@ public async Task Approve_once_does_not_leak_between_subagent_tool_calls() Task = "Run the same approval-gated tool twice", Timeout = TimeSpan.FromSeconds(5) }, - TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + ApprovalAskTimeout, TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.Equal(2, approvalBridge.RequestCount); From bb3f809d70ff0a52e5d4d0ee2bb444ff99ef9b18 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 19 Aug 2026 15:06:46 -0500 Subject: [PATCH 2/3] Raise the ErrorOutput expect budget in ErrorCorrelationTests ErrorCorrelationTests kept four 5-second literals on ExpectMsgAsync. Each one waits for a full provider-failure turn: actor spawn, Akka.Persistence recovery, a failed stream, and the error classification. A starved CI runner can exceed 5 seconds on that path. The literal measures scheduler load, not correctness. This commit adds the same Config-property raise that PR #2014 applies to this class, and drops the four literals so they inherit it. LlmSessionTestBase seals ConfigureAkka, so the Config property is the only seam. The comment text matches #2014 word for word, so the two changes agree where they overlap. The Ask literals in this file stay explicit. Ask falls back to akka.actor.ask-timeout, which is infinite by default. Deletion would trade a flaky wait for a hang. Production code is untouched. A green test pays no added time. The raise only slows failure reporting. --- .../Sessions/ErrorCorrelationTests.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Sessions/ErrorCorrelationTests.cs b/src/Netclaw.Actors.Tests/Sessions/ErrorCorrelationTests.cs index df67046cc..e87c383a6 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ErrorCorrelationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ErrorCorrelationTests.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using Akka.Actor; +using Akka.Configuration; using Akka.Hosting; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -23,6 +24,15 @@ namespace Netclaw.Actors.Tests.Sessions; /// public sealed class ErrorCorrelationTests(ITestOutputHelper output) : LlmSessionTestBase(output) { + // LlmSessionTestBase seals ConfigureAkka, so the raise goes through the + // Config property seam instead. The stock single-expect-default is 3 + // seconds. That value measures scheduler load on a starved CI runner. It + // does not measure correctness. Production allows 30 seconds for a + // comparable ack-after-work handshake — see + // ProactiveSendFormatting.ProactiveThreadAckTimeout. + protected override Config? Config => + ConfigurationFactory.ParseString("akka.test.single-expect-default = 15s"); + private readonly FailingChatClient _chatClient = new(); protected override void ConfigureSessionServices(IServiceCollection services) @@ -67,7 +77,7 @@ await sessionManager.Ask(new SendUserMessage Content = "trigger error" }, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); - var error = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + var error = await subscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(ErrorCategory.ProviderFailure, error.Category); Assert.NotEqual(Guid.Empty, error.CorrelationId); @@ -95,7 +105,7 @@ await sessionManager.Ask(new SendUserMessage Content = "first" }, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); - var firstError = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + var firstError = await subscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); await sessionManager.Ask(new SendUserMessage @@ -104,7 +114,7 @@ await sessionManager.Ask(new SendUserMessage Content = "second" }, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); - var secondError = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + var secondError = await subscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); Assert.NotEqual(firstError.CorrelationId, secondError.CorrelationId); @@ -134,7 +144,7 @@ await sessionManager.Ask(new SendUserMessage Content = "trigger timeout" }, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); - var error = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + var error = await subscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(ErrorCategory.Timeout, error.Category); Assert.NotEqual(Guid.Empty, error.CorrelationId); From d8c87a8912490ef5747b8638e5080bf456e7e7ee Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 19 Aug 2026 15:08:16 -0500 Subject: [PATCH 3/3] Size the shared approval-test budgets for starved CI schedulers Two shared test helpers held 5-second budgets. Each one bounds a multi-hop actor operation, not a correctness property. A short budget on a shared helper makes a whole group of tests flake at once. ShellApprovalHarness stops the approval actor twice: once to prove a persistent grant survives a restart, and once on dispose. Both stops wait for a persistence flush and an actor teardown. Every shell-approval test runs through this harness. ApprovalRehydrationTests resolves the session child at three sites, one of them the shared ColdRespawnAsync helper. The resolve waits for the actor spawn and the Akka.Persistence recovery. About twenty cold-respawn tests depend on it. Each budget moves to 15 seconds and carries a comment that states what it bounds. Production code is untouched. A green test pays no added time. The raise only slows failure reporting. --- .../Sessions/ApprovalRehydrationTests.cs | 15 ++++++++++++--- .../Tools/ShellApprovalHarness.cs | 11 +++++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs index 68923a476..bd3d35dc4 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs @@ -201,8 +201,10 @@ await sessionManager.Ask(new JoinSession(rejoinProbe) // Idle timeout with the recovered approval still pending: the session // passivates (approval state is journaled) instead of deferring forever. var escapedId = Uri.EscapeDataString(sessionId.Value); + // The resolve budget bounds the session spawn and recovery under a + // starved CI scheduler. It does not measure correctness. var child = await Sys.ActorSelection($"/user/session-manager/{escapedId}") - .ResolveOne(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + .ResolveOne(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken); Watch(child); child.Tell(new LeaveSession(rejoinProbe) { SessionId = sessionId }); child.Tell(ReceiveTimeout.Instance); @@ -1516,8 +1518,10 @@ await sessionManager.Ask(new JoinSession(subscriber) await subscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); var escapedId = Uri.EscapeDataString(sessionId.Value); + // The resolve budget bounds the session spawn and recovery under a + // starved CI scheduler. It does not measure correctness. var child = await Sys.ActorSelection($"/user/session-manager/{escapedId}") - .ResolveOne(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + .ResolveOne(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken); Watch(child); // Drop the subscriber and force the idle timeout so the session enters @@ -1550,8 +1554,13 @@ await sessionManager.Ask(new JoinSession(subscriber) private async Task ColdRespawnAsync(SessionId sessionId) { var escapedId = Uri.EscapeDataString(sessionId.Value); + // The resolve waits for the session child to finish its spawn and its + // Akka.Persistence recovery. The budget bounds that multi-hop startup + // under a starved CI scheduler. It does not measure correctness. About + // twenty cold-respawn tests call this helper, so a short budget makes + // the whole group flake at once. var child = await Sys.ActorSelection($"/user/session-manager/{escapedId}") - .ResolveOne(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + .ResolveOne(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken); Watch(child); Sys.Stop(child); await ExpectTerminatedAsync(child, cancellationToken: TestContext.Current.CancellationToken); diff --git a/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs b/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs index dfeaea011..f96fc8adb 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs @@ -142,7 +142,12 @@ await approvalService.RecordApprovalCandidatesAsync( if (persistentSeeds.Count > 0) { - await approvalActor.GracefulStop(TimeSpan.FromSeconds(5)); + // The stop waits for a persistence flush and the actor teardown. + // The budget bounds a multi-hop shutdown under a starved CI + // scheduler. It does not measure correctness. Every shell-approval + // test goes through this shared harness, so a short budget makes a + // whole suite flake at once. + await approvalActor.GracefulStop(TimeSpan.FromSeconds(15)); approvalActor = CreateApprovalActor(actorSystem, store); approvalService = CreateApprovalService(approvalActor); } @@ -295,7 +300,9 @@ public void CreateProjectFileSymlink(string linkPath, string targetPath) public async ValueTask DisposeAsync() { - await _approvalActor.GracefulStop(TimeSpan.FromSeconds(5)); + // Same reason as the seed-phase stop above: the budget bounds a + // multi-hop teardown under a starved CI scheduler, not correctness. + await _approvalActor.GracefulStop(TimeSpan.FromSeconds(15)); if (Directory.Exists(_rootDirectory)) Directory.Delete(_rootDirectory, recursive: true); }