diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs index 0a4356eda..5be2b3424 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs @@ -825,6 +825,38 @@ await AwaitAssertAsync(() => ClearReplyClientThrows(); } + [Fact] + public async Task Feedback_send_failure_faults_the_actor() + { + // Contract: when the session feedback pipe itself fails, the binding + // actor must fail loudly. A swallowed failure leaves a zombie session + // that waits on a delivery report that will never arrive. The loud + // path is a supervised restart, which re-creates the pipeline. + var ct = TestContext.Current.CancellationToken; + var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe()); + var sid = new SessionId("session-feedback-fail"); + var pipeline = new RecordingSessionPipeline(_ => + [ + new TextOutput("this will fail to post") { SessionId = sid }, + new TurnCompleted { SessionId = sid, TurnNumber = new Netclaw.Actors.Protocol.TurnNumber(1) } + ]) + { + FeedbackException = new InvalidOperationException("feedback pipe down") + }; + + SetReplyClientThrows(new InvalidOperationException("channel API down")); + CreateBindingActor(sid, pipeline, detector); + + // A supervised restart shows up as a second pipeline CreateAsync call. + await AwaitAssertAsync( + () => Assert.True( + pipeline.CreateCount >= 2, + $"expected a supervised restart to re-create the pipeline; CreateCount={pipeline.CreateCount}"), + cancellationToken: ct); + + ClearReplyClientThrows(); + } + // --- Pipeline Lifecycle --- [Fact] diff --git a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingSessionPipeline.cs b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingSessionPipeline.cs index 685c967c6..02009c173 100644 --- a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingSessionPipeline.cs +++ b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingSessionPipeline.cs @@ -59,12 +59,26 @@ public IReadOnlyList RecordedFeedback public ConcurrentQueue CapturedInputs { get; } = new(); public Func>? ResponseFactory { get; set; } + /// + /// Number of calls. A supervised actor restart + /// re-creates the pipeline, so tests observe a restart as a second call. + /// + public int CreateCount => Volatile.Read(ref _createCount); + private int _createCount; + + /// + /// When set, throws this exception and + /// does not record the feedback. This models a dead session feedback pipe. + /// + public Exception? FeedbackException { get; set; } + public Task CreateAsync( SessionId sessionId, SessionPipelineOptions options, IMaterializer? materializer = null, CancellationToken cancellationToken = default) { + Interlocked.Increment(ref _createCount); Volatile.Write(ref _capturedOptions, options); _created.TrySetResult(options); @@ -131,6 +145,8 @@ public Task CreateAsync( public Task SendFeedbackAsync(IWithSessionId feedback, CancellationToken ct = default) { + if (FeedbackException is { } feedbackException) + throw feedbackException; lock (_feedbackLock) _recordedFeedback.Add(feedback); return Task.CompletedTask; } diff --git a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs index bb7f9080a..930f9e28f 100644 --- a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs +++ b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs @@ -1477,7 +1477,11 @@ await _dependencies.Pipeline.SendFeedbackAsync(new DeliveryFailed } catch (Exception ex) { - _log.Error(ex, "Failed to send delivery feedback to session"); + // A dead feedback pipe means the session never learns the turn + // failed. Rethrow so supervision restarts the actor and + // re-creates the pipeline, same as the Slack binding actor. + _log.Error(ex, "Failed to send delivery feedback to session; propagating to trigger pipeline reinit"); + throw; } } diff --git a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs index fb9b72221..f8cdec113 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs @@ -1417,7 +1417,11 @@ await _dependencies.Pipeline.SendFeedbackAsync(new DeliveryFailed } catch (Exception ex) { - _log.Error(ex, "Failed to send delivery feedback to session"); + // A dead feedback pipe means the session never learns the turn + // failed. Rethrow so supervision restarts the actor and + // re-creates the pipeline, same as the Slack binding actor. + _log.Error(ex, "Failed to send delivery feedback to session; propagating to trigger pipeline reinit"); + throw; } }