fix: Agent bricht bei Page-Refresh nicht mehr ab + SSE-Writes crash-sicher

Ursache: WriteSseEvent in ProcessStream warf OperationCanceledException
bei Client-Disconnect (Page-Refresh) → sseCt war gelinkt mit dem
ASP.NET cancellationToken → Exception wurde im catch-Block als
'stopped' behandelt → Agent brach ab obwohl nur der Browser refreshed wurde.

Fixes:
- ProcessStream: Alle WriteSseEvent durch TryWriteSse ersetzt
  SSE-Writes bei Client-Disconnect werden jetzt zu stillen No-Ops
  statt den gesamten Agent-Flow per Exception zu killen
- catch-Blöcke: OperationCanceledException nur bei workCt/hardCt als
  'stopped' behandeln, neuer catch für Client-Disconnect als
  'client_disconnected' (Fallback, sollte nicht mehr auftreten)
- done-Event nach Stop: sseCt → CancellationToken.None damit das
  done-Event trotz gecancelltem sseCt noch gesendet wird
- Frontend: done-Handler rendert Chain-Badge direkt im apiGetChain-
  Callback statt Race-Condition mit finally-Block
- Frontend: 'stopped' Badge wieder angezeigt (war nur fälschlich bei
  Page-Refresh, jetzt korrekt nur bei echtem Stop)
This commit is contained in:
Meleki committed 2026-03-10 11:21:28 +01:00
1 parent b1d449b997
commit 6499864f13
2 files changed
+55 -13

No files matched your search

+45 -9
View File
@@ -272,10 +272,25 @@ public class AnthropicService
await TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt); await TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt);
await TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt); await TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt);
} }
catch (OperationCanceledException) catch (OperationCanceledException) when (workCt.IsCancellationRequested || hardCt.IsCancellationRequested)
{ {
// Stop-Button gedrückt (workCt oder hardCt wurde gecancelled) // Stop-Button gedrückt (workCt oder hardCt wurde gecancelled)
await FinalizeChain(chain, usage, iterations, "stopped", sw); await FinalizeChain(chain, usage, iterations, "stopped", sw);
// sseCt ist hier bereits cancelled (gelinkt mit workCt), daher CancellationToken.None
// Der SSE-Stream zum Client ist noch offen – nur der Stop-Button hat ausgelöst
await TryWriteSse(response, "done", new
{
model = modelId,
inputTokens = usage.InputTokens,
outputTokens = usage.OutputTokens,
cacheCreationTokens = usage.CacheCreationTokens,
cacheReadTokens = usage.CacheReadTokens,
iterations,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = "stopped",
planMode
}, CancellationToken.None);
} }
catch (Exception) when (workCt.IsCancellationRequested || hardCt.IsCancellationRequested) catch (Exception) when (workCt.IsCancellationRequested || hardCt.IsCancellationRequested)
{ {
@@ -283,6 +298,27 @@ public class AnthropicService
// ObjectDisposedException/IOException aus statt OperationCanceledException. // ObjectDisposedException/IOException aus statt OperationCanceledException.
// Wenn workCt/hardCt gecancelled ist, ist das ein regulärer Stop. // Wenn workCt/hardCt gecancelled ist, ist das ein regulärer Stop.
await FinalizeChain(chain, usage, iterations, "stopped", sw); await FinalizeChain(chain, usage, iterations, "stopped", sw);
// sseCt ist hier bereits cancelled (gelinkt mit workCt), daher CancellationToken.None
await TryWriteSse(response, "done", new
{
model = modelId,
inputTokens = usage.InputTokens,
outputTokens = usage.OutputTokens,
cacheCreationTokens = usage.CacheCreationTokens,
cacheReadTokens = usage.CacheReadTokens,
iterations,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = "stopped",
planMode
}, CancellationToken.None);
}
catch (OperationCanceledException)
{
// Client-Disconnect (cancellationToken gecancelled, aber NICHT workCt/hardCt)
// Agent-Arbeit ist leider schon abgebrochen – Chain als "client_disconnected" finalisieren
// (sollte durch TryWriteSse eigentlich nicht mehr passieren)
await FinalizeChain(chain, usage, iterations, "client_disconnected", sw);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1136,7 +1172,7 @@ public class AnthropicService
if (cbType == "text") if (cbType == "text")
{ {
currentTextBlock.Clear(); currentTextBlock.Clear();
await WriteSseEvent(response, "text_start", new { index = idx }, sseCt); await TryWriteSse(response, "text_start", new { index = idx }, sseCt);
} }
else if (cbType == "tool_use") else if (cbType == "tool_use")
{ {
@@ -1148,7 +1184,7 @@ public class AnthropicService
currentToolId = cb.GetProperty("id").GetString() ?? ""; currentToolId = cb.GetProperty("id").GetString() ?? "";
currentToolName = cb.GetProperty("name").GetString() ?? ""; currentToolName = cb.GetProperty("name").GetString() ?? "";
currentToolInputJson.Clear(); currentToolInputJson.Clear();
await WriteSseEvent(response, "tool_start", new { name = currentToolName, id = currentToolId }, sseCt); await TryWriteSse(response, "tool_start", new { name = currentToolName, id = currentToolId }, sseCt);
} }
else if (cbType == "server_tool_use") else if (cbType == "server_tool_use")
{ {
@@ -1160,7 +1196,7 @@ public class AnthropicService
currentToolId = cb.GetProperty("id").GetString() ?? ""; currentToolId = cb.GetProperty("id").GetString() ?? "";
currentToolName = cb.GetProperty("name").GetString() ?? ""; currentToolName = cb.GetProperty("name").GetString() ?? "";
currentToolInputJson.Clear(); currentToolInputJson.Clear();
await WriteSseEvent(response, "web_search_start", new { name = currentToolName, id = currentToolId }, sseCt); await TryWriteSse(response, "web_search_start", new { name = currentToolName, id = currentToolId }, sseCt);
} }
else if (cbType == "web_search_tool_result") else if (cbType == "web_search_tool_result")
{ {
@@ -1185,7 +1221,7 @@ public class AnthropicService
{ {
var text = delta.GetProperty("text").GetString() ?? ""; var text = delta.GetProperty("text").GetString() ?? "";
currentTextBlock.Append(text); currentTextBlock.Append(text);
await WriteSseEvent(response, "text_delta", new { text }, sseCt); await TryWriteSse(response, "text_delta", new { text }, sseCt);
} }
else if (deltaType == "input_json_delta") else if (deltaType == "input_json_delta")
{ {
@@ -1206,7 +1242,7 @@ public class AnthropicService
if (text.Length > 0) if (text.Length > 0)
{ {
assistantContent.Add(new { type = "text", text }); assistantContent.Add(new { type = "text", text });
await WriteSseEvent(response, "text_end", new { }, sseCt); await TryWriteSse(response, "text_end", new { }, sseCt);
} }
currentTextBlock.Clear(); currentTextBlock.Clear();
} }
@@ -1243,7 +1279,7 @@ public class AnthropicService
} }
} }
await WriteSseEvent(response, "user_choice", new await TryWriteSse(response, "user_choice", new
{ {
toolCallId = currentToolId, toolCallId = currentToolId,
question, question,
@@ -1268,7 +1304,7 @@ public class AnthropicService
workCt.ThrowIfCancellationRequested(); workCt.ThrowIfCancellationRequested();
executedTools.Add(new ToolResult(currentToolId, toolResult, isError)); executedTools.Add(new ToolResult(currentToolId, toolResult, isError));
await WriteSseEvent(response, "tool_end", new await TryWriteSse(response, "tool_end", new
{ {
name = currentToolName, name = currentToolName,
input = currentToolInputJson.ToString(), input = currentToolInputJson.ToString(),
@@ -1296,7 +1332,7 @@ public class AnthropicService
// Web-Search-Ergebnis von Anthropic – als Raw-JSON in assistantContent aufnehmen // Web-Search-Ergebnis von Anthropic – als Raw-JSON in assistantContent aufnehmen
assistantContent.Add(webSearchResultBlocks[idx]); assistantContent.Add(webSearchResultBlocks[idx]);
var sources = webSearchSources.TryGetValue(idx, out var s) ? s : new List<object>(); var sources = webSearchSources.TryGetValue(idx, out var s) ? s : new List<object>();
await WriteSseEvent(response, "web_search_end", new { id = currentToolId, sources }, sseCt); await TryWriteSse(response, "web_search_end", new { id = currentToolId, sources }, sseCt);
} }
else if (bt.EndsWith("_tool_result") && serverToolResultBlocks.TryGetValue(idx, out var srvResultBlock)) else if (bt.EndsWith("_tool_result") && serverToolResultBlocks.TryGetValue(idx, out var srvResultBlock))
{ {
+10 -4
View File
@@ -121,11 +121,13 @@ function handleStreamEvent(eventType, data) {
if (data.model) { if (data.model) {
_lastDoneChainData = data; _lastDoneChainData = data;
} else if (data.chainId) { } else if (data.chainId) {
// Fallback: Chain-Daten vom Server nachladen und Badge direkt rendern
// (async – _lastDoneChainData wäre im finally schon gelesen)
apiGetChain(data.chainId).then(chain => { apiGetChain(data.chainId).then(chain => {
if (chain) _lastDoneChainData = chain; if (chain) addChainMetaBubble(chain, false);
}).catch(() => {}); }).catch(() => {});
} }
setMeta('Bereit'); setMeta(data.stopReason === 'stopped' ? 'Gestoppt' : 'Bereit');
break; break;
} }
} }
@@ -238,8 +240,7 @@ async function _runStream(fetchPromise) {
if (_chatAbandoned) return; if (_chatAbandoned) return;
endStreamBubble(); endStreamBubble();
if (e.name === 'AbortError') { if (e.name === 'AbortError') {
setMeta('Abgebrochen'); setMeta('Gestoppt');
await new Promise(r => setTimeout(r, 500));
} else { } else {
try { try {
const status = await apiIsStreaming(sid); const status = await apiIsStreaming(sid);
@@ -261,6 +262,11 @@ async function _runStream(fetchPromise) {
stopBtn.title = 'Antwort abbrechen'; stopBtn.title = 'Antwort abbrechen';
isStreaming = false; isStreaming = false;
_abortController = null; _abortController = null;
// Meta zurücksetzen falls kein done-Event kam (z.B. nach Stop)
var metaEl = $('#meta');
if (metaEl && metaEl.textContent.includes('Streaming')) {
setMeta(_stopClickCount > 0 ? 'Gestoppt' : 'Bereit');
}
} }
endStreamBubble(); endStreamBubble();
removeStreamedElements(); removeStreamedElements();