diff --git a/Services/AnthropicService.Agents.cs b/Services/AnthropicService.Agents.cs
index 6546556..623904b 100644
--- a/Services/AnthropicService.Agents.cs
+++ b/Services/AnthropicService.Agents.cs
@@ -416,6 +416,15 @@ public partial class AnthropicService
public string SsePrefix { get; set; }
public string AgentName { get; set; }
+ /// Optionaler Write-Lock: serialisiert SSE-Writes, wenn mehrere Agenten
+ /// (Panel-Member) parallel auf DIESELBE HttpResponse streamen. Null = kein Lock.
+ public SemaphoreSlim SseGate { get; set; }
+
+ /// Member-Index für Panel-Events (wird in die SSE-Payloads gelegt,
+ /// damit das Frontend Token/Tool-Events der richtigen Member-Bubble zuordnet).
+ /// -1 = kein Panel-Member (Review/Finalizer ignorieren das Feld).
+ public int MemberIndex { get; set; } = -1;
+
/// Tag für DB-Records (z.B. "review", "finalizer").
public string Tag { get; set; }
@@ -522,7 +531,7 @@ public partial class AnthropicService
Content = errMsg, IsError = true, Tag = cb.Tag
});
if (cb.SseResponse != null)
- await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_error", new { error = errMsg }, cb.SseCt);
+ await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_error", new { index = cb.MemberIndex, error = errMsg }, cb.SseCt, cb.SseGate);
break;
}
@@ -560,7 +569,7 @@ public partial class AnthropicService
_embeddingBg?.Enqueue(msgId, chainId, sessionId, toolOpts.AgentId, "assistant", text);
}
if (cb.SseResponse != null)
- await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_text", new { text, agentName = cb.AgentName }, cb.SseCt);
+ await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_text", new { index = cb.MemberIndex, text, agentName = cb.AgentName }, cb.SseCt, cb.SseGate);
}
else if (blockType == "tool_use")
{
@@ -619,7 +628,7 @@ public partial class AnthropicService
});
if (cb.SseResponse != null)
- await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_tool", new { name = toolName, result = TruncateToolResult(toolResult), isError, agentName = cb.AgentName }, cb.SseCt);
+ await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_tool", new { index = cb.MemberIndex, name = toolName, result = TruncateToolResult(toolResult), isError, agentName = cb.AgentName }, cb.SseCt, cb.SseGate);
}
}
diff --git a/Services/AnthropicService.Panel.cs b/Services/AnthropicService.Panel.cs
index d1973a6..500661b 100644
--- a/Services/AnthropicService.Panel.cs
+++ b/Services/AnthropicService.Panel.cs
@@ -52,7 +52,12 @@ public partial class AnthropicService
{
if (panel == null || panel.Count == 0) return 0;
- await SseHelper.TryWriteSse(response, "panel_start", new { members = panel.Count, mode = "fusion" }, sseCt);
+ // Write-Lock: alle Member streamen LIVE parallel auf DIESELBE HttpResponse →
+ // ohne Serialisierung würden sich ihre SSE-Frames auf Byte-Ebene vermischen.
+ // Serialisiert nur die µs-Byte-Flushes, nicht die parallele Modell-Arbeit.
+ using var sseGate = new SemaphoreSlim(1, 1);
+
+ await SseHelper.TryWriteSse(response, "panel_start", new { members = panel.Count, mode = "fusion" }, sseCt, sseGate);
// Concurrency begrenzen: bei CLI-Membern sind das parallele claude-Children.
using var sem = new SemaphoreSlim(Math.Min(3, panel.Count));
@@ -64,7 +69,7 @@ public partial class AnthropicService
tasks.Add(Task.Run(async () =>
{
await sem.WaitAsync(workCt);
- try { return await RunPanelMember(response, member, workerMessages, index, chainId, sessionId, apiToken, workCt, sseCt); }
+ try { return await RunPanelMember(response, member, workerMessages, index, chainId, sessionId, apiToken, sseGate, workCt, sseCt); }
finally { sem.Release(); }
}, workCt));
}
@@ -78,7 +83,7 @@ public partial class AnthropicService
if (ordered.Count == 0)
{
- await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = false, members = 0 }, sseCt);
+ await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = false, members = 0 }, sseCt, sseGate);
return 0;
}
@@ -103,7 +108,7 @@ public partial class AnthropicService
MergePanelIntoLastUserText(workerMessages, sb.ToString());
- await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = true, members = ordered.Count }, sseCt);
+ await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = true, members = ordered.Count }, sseCt, sseGate);
return ordered.Count;
}
@@ -120,11 +125,12 @@ public partial class AnthropicService
string chainId,
string sessionId,
string apiToken,
+ SemaphoreSlim sseGate,
CancellationToken workCt,
CancellationToken sseCt)
{
var name = member.Agent?.Name ?? $"Panel-Agent {index + 1}";
- await SseHelper.TryWriteSse(response, "panel_member_start", new { index, name, model = member.ModelId }, sseCt);
+ await SseHelper.TryWriteSse(response, "panel_member_start", new { index, name, model = member.ModelId }, sseCt, sseGate);
var memberMessages = new List