B2 beheben: Chat-Laeufe pro Agent serialisieren
Der Konversationskontext _chatContexts[agentId] ist eine geteilte List<ChatMessage>. Nur der Lookup lief unter Lock, alle Add-Aufrufe im Schleifenkoerper waren ungeschuetzt. Da WebView, ToolJob-Wakeups und AgentComm denselben Agenten gleichzeitig ansprechen koennen, verschraenkten sich ihre Nachrichten zu einer ungueltigen Tool-Sequenz, die die API mit HTTP 400 ablehnt. ChatAsync laeuft jetzt hinter einem SemaphoreSlim(1,1) pro Agent; verschiedene Agenten bleiben unabhaengig. Die Timeout-Uhr startet erst nach dem Eintritt, damit Wartezeit in der Warteschlange den Lauf nicht aufzehrt. Zwei Folgeprobleme mit demselben Ursprung: - _runningChats hielt nur EINE CancellationTokenSource je Agent; der zweite Lauf ueberschrieb den ersten. AbortChat brach dadurch nur einen ab, der andere lief bis ins Run-Timeout. Jetzt eine Liste, die auch wartende Laeufe erfasst. - ExecuteToolCallAsync fing OperationCanceledException mit ab und gab sie als Tool-Ergebnis zurueck, wodurch der Abbruch erst einen Schritt spaeter griff. Cancellation wird nun durchgereicht. Ausserdem: send_message an den eigenen Agenten wird abgelehnt — es waere mit dem neuen Gate in einen Deadlock gelaufen. Neu: GetChatContext(agentId) als Momentaufnahme des Kontexts, fuer Diagnose und Kontextgroessen-Anzeige. Build-Fix: Das WinForms-Projekt globbt **/*.cs und kompilierte dadurch die Test-Quellen mit. tests\** wird jetzt wie src\** ausgeschlossen. Alle 49 Tests gruen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
eae13771cf
commit
6bbe9f9a80
@@ -0,0 +1,146 @@
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Regressionstests für Bug B2: _chatContexts[agentId] ist eine geteilte
|
||||
/// List<ChatMessage>. Nur der Lookup läuft unter Lock — alle Add-Aufrufe im
|
||||
/// Schleifenkörper sind ungeschützt.
|
||||
///
|
||||
/// Derselbe Agent kann gleichzeitig von mehreren Seiten angestoßen werden:
|
||||
/// Telegram-/Mail-ToolJob, WebView-Nachricht des Benutzers, send_message eines
|
||||
/// anderen Agenten. Ergebnis: korrupte Liste, verschränkte Tool-Sequenzen und
|
||||
/// dadurch HTTP-400-Fehler.
|
||||
/// </summary>
|
||||
public sealed class AgentEngineConcurrencyTests
|
||||
{
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// E1 — Zwei parallele Chats auf demselben Agenten
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Theory]
|
||||
[Repeat(15)]
|
||||
public async Task Zwei_parallele_Chats_hinterlassen_einen_gueltigen_Kontext(int iteration)
|
||||
{
|
||||
_ = iteration;
|
||||
|
||||
var fixture = new EngineFixture()
|
||||
.WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(15)));
|
||||
var agent = fixture.AddAgent("agent-a", "TestTool");
|
||||
|
||||
// Jeder Lauf: ein Tool-Schritt, dann eine Textantwort — unabhängig davon,
|
||||
// wie die beiden Konversationen ineinander verschränkt werden.
|
||||
fixture.Client.RespondsContextually();
|
||||
|
||||
await Task.WhenAll(
|
||||
fixture.Engine.ChatAsync(agent, "Anfrage A", "test-instance", default),
|
||||
fixture.Engine.ChatAsync(agent, "Anfrage B", "test-instance", default));
|
||||
|
||||
var context = fixture.Engine.GetChatContext(agent.AgentId);
|
||||
ContextInvariants.AssertValid(context);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// E2 — Stresstest
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Viele_parallele_Chats_korrumpieren_den_Kontext_nicht()
|
||||
{
|
||||
var fixture = new EngineFixture()
|
||||
.WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(2)));
|
||||
var agent = fixture.AddAgent("agent-stress", "TestTool");
|
||||
|
||||
fixture.Client.RespondsContextually();
|
||||
|
||||
var tasks = Enumerable.Range(0, 30)
|
||||
.Select(i => fixture.Engine.ChatAsync(agent, $"Anfrage {i}", "test-instance", default));
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
results.ShouldAllBe(r => r.Status == AgentRunStatus.Completed);
|
||||
|
||||
var context = fixture.Engine.GetChatContext(agent.AgentId);
|
||||
ContextInvariants.AssertValid(context);
|
||||
|
||||
// Jede der 30 Anfragen muss genau einmal im Kontext stehen — nichts verloren,
|
||||
// nichts doppelt durch verlorene Schreibzugriffe auf die Liste.
|
||||
var userMessages = context.Count(m => m.Role == "user");
|
||||
userMessages.ShouldBe(30);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// E3 — AbortChat
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task AbortChat_bricht_alle_laufenden_Chats_des_Agenten_ab()
|
||||
{
|
||||
var fixture = new EngineFixture()
|
||||
.WithTool(FakeTool.Slow(TimeSpan.FromSeconds(10)));
|
||||
var agent = fixture.AddAgent("agent-abort", "TestTool");
|
||||
|
||||
fixture.Client.AlwaysRespondsWithToolCall();
|
||||
|
||||
var first = fixture.Engine.ChatAsync(agent, "Erste", "test-instance", default);
|
||||
var second = fixture.Engine.ChatAsync(agent, "Zweite", "test-instance", default);
|
||||
|
||||
// Warten, bis beide tatsächlich angelaufen sind.
|
||||
await WaitUntilAsync(() => fixture.Engine.IsRunning(agent.AgentId));
|
||||
await Task.Delay(50);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
fixture.Engine.AbortChat(agent.AgentId);
|
||||
|
||||
var results = await Task.WhenAll(first, second);
|
||||
sw.Stop();
|
||||
|
||||
results.ShouldAllBe(r => r.Status == AgentRunStatus.Cancelled);
|
||||
|
||||
// Entscheidend: _runningChats[agentId] hielt bisher nur EINE CancellationTokenSource —
|
||||
// der zweite Lauf überschrieb den ersten. AbortChat brach dann nur einen ab, der
|
||||
// andere lief bis ins Run-Timeout. Ohne diese Zeitprüfung wäre der Test grün,
|
||||
// obwohl der Abbruch gar nicht gegriffen hat.
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5),
|
||||
"AbortChat muss beide Läufe sofort beenden, nicht erst über das Timeout");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// E4 — Gegenprobe: verschiedene Agenten dürfen parallel laufen
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Verschiedene_Agenten_laufen_wirklich_parallel()
|
||||
{
|
||||
// Wichtige Gegenprobe: Der Fix für B2 darf nicht versehentlich alle Agenten
|
||||
// gegeneinander sperren.
|
||||
var toolDelay = TimeSpan.FromMilliseconds(200);
|
||||
var fixture = new EngineFixture().WithTool(FakeTool.Slow(toolDelay));
|
||||
|
||||
var a = fixture.AddAgent("agent-1", "TestTool");
|
||||
var b = fixture.AddAgent("agent-2", "TestTool");
|
||||
var c = fixture.AddAgent("agent-3", "TestTool");
|
||||
|
||||
fixture.Client.AlwaysRespondsWithText("Antwort");
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
await Task.WhenAll(
|
||||
fixture.Engine.ChatAsync(a, "x", "test-instance", default),
|
||||
fixture.Engine.ChatAsync(b, "x", "test-instance", default),
|
||||
fixture.Engine.ChatAsync(c, "x", "test-instance", default));
|
||||
sw.Stop();
|
||||
|
||||
// Seriell wären es mindestens 3 × 200 ms. Parallel deutlich weniger.
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(450),
|
||||
"Agenten dürfen sich nicht gegenseitig blockieren");
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, int timeoutMs = 2_000)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
while (!condition() && sw.ElapsedMilliseconds < timeoutMs)
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user