Doppel-Klick Hard-Stop + ReadLineAsync Cancellation-Fix
Stop-Button: Zwei Stufen statt einer:
- 1. Klick: Graceful Stop (workCt) - laufende API-Calls/Tools abbrechen
- 2. Klick: Hard Stop (hardCt) - sofortiger Abbruch aus while-Loop
StreamingTracker: Zwei separate CancellationTokenSources (StopCts + HardCts),
Cancel() gibt 'graceful'/'hard'/null zurück je nach Stufe.
ReadLineAsync Cancellation-Fix: StreamReader.ReadLineAsync propagiert den
CancellationToken nicht zuverlässig an den underlying NetworkStream.
Bei extended thinking blockiert ReadLineAsync minutenlang ohne Reaktion
auf Cancellation. Fix: workCt.Register() disposed den HTTP-Stream,
damit ReadLineAsync sofort mit Exception abbricht.
Bug-Fix: OperationCanceledException in RunAgentChat/ChatNonStreaming wurde
vom generischen catch(Exception) geschluckt - Agent lief trotz Stop weiter.
Jetzt: catch(OperationCanceledException) { throw; } vor catch(Exception).
Frontend: Stop-Button zeigt Hard-Stop visuell (rot + Pulse-Animation),
SSE-Stream wird erst beim 2. Klick clientseitig abgebrochen.
This commit is contained in:
1 parent
eb32a2526a
commit
b1d449b997
5 files changed
+92
-23
No files matched your search
@@ -175,14 +175,16 @@ public static class ChatEndpoint
|
|||||||
// Bei Choice-Response: toolChoice auf "auto" setzen, damit Claude frei antworten kann
|
// Bei Choice-Response: toolChoice auf "auto" setzen, damit Claude frei antworten kann
|
||||||
var effectiveToolChoice = isChoiceResponse ? "auto" : ctx.ToolChoice;
|
var effectiveToolChoice = isChoiceResponse ? "auto" : ctx.ToolChoice;
|
||||||
|
|
||||||
// Zwei separate CancellationTokens:
|
// Drei separate CancellationTokens:
|
||||||
// - workCt: Nur Stop-Button → für Anthropic API-Calls, Tool-Ausführungen, DB-Writes
|
// - workCt: Nur Stop-Button (graceful) → für Anthropic API-Calls, Tool-Ausführungen, DB-Writes
|
||||||
// Agent-Arbeit läuft bei Client-Disconnect weiter (Client pollt ja)
|
// Agent-Arbeit läuft bei Client-Disconnect weiter (Client pollt ja)
|
||||||
|
// - hardCt: Hard-Stop (Doppel-Klick) → sofortiger Abbruch aus while-Loop
|
||||||
// - sseCt: Stop-Button + Client-Disconnect → für SSE-Writes an den Browser
|
// - sseCt: Stop-Button + Client-Disconnect → für SSE-Writes an den Browser
|
||||||
// Bei Client-Disconnect werden SSE-Writes zu stillen No-Ops
|
// Bei Client-Disconnect werden SSE-Writes zu stillen No-Ops
|
||||||
var stopCts = streamingTracker.Start(sessionId, ctx.ChainId);
|
var tokens = streamingTracker.Start(sessionId, ctx.ChainId);
|
||||||
var workCt = stopCts.Token;
|
var workCt = tokens.StopToken;
|
||||||
using var sseLinkCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, stopCts.Token);
|
var hardCt = tokens.HardToken;
|
||||||
|
using var sseLinkCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, tokens.StopToken);
|
||||||
var sseCt = sseLinkCts.Token;
|
var sseCt = sseLinkCts.Token;
|
||||||
|
|
||||||
// SSE-Heartbeat: Alle 15s ein Kommentar senden, damit die Verbindung
|
// SSE-Heartbeat: Alle 15s ein Kommentar senden, damit die Verbindung
|
||||||
@@ -204,7 +206,7 @@ public static class ChatEndpoint
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, workCt: workCt, sseCt: sseCt);
|
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, workCt: workCt, hardCt: hardCt, sseCt: sseCt);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -225,7 +227,8 @@ public static class ChatEndpoint
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var cancelled = streamingTracker.Cancel(sessionId);
|
// Erster Klick = "graceful" (laufende Iteration fertig), zweiter = "hard" (sofort)
|
||||||
await res.WriteAsJsonAsync(new { stopped = cancelled });
|
var stopLevel = streamingTracker.Cancel(sessionId);
|
||||||
|
await res.WriteAsJsonAsync(new { stopped = stopLevel != null, level = stopLevel ?? "none" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +52,7 @@ public class AnthropicService
|
|||||||
bool allowUserChoice = true,
|
bool allowUserChoice = true,
|
||||||
bool planMode = false,
|
bool planMode = false,
|
||||||
CancellationToken workCt = default,
|
CancellationToken workCt = default,
|
||||||
|
CancellationToken hardCt = default,
|
||||||
CancellationToken sseCt = default)
|
CancellationToken sseCt = default)
|
||||||
{
|
{
|
||||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
@@ -71,6 +72,8 @@ public class AnthropicService
|
|||||||
{
|
{
|
||||||
while (iterations < maxIterations)
|
while (iterations < maxIterations)
|
||||||
{
|
{
|
||||||
|
// Hard-Stop: Sofortiger Abbruch aus while-Loop (Doppel-Klick Stop)
|
||||||
|
hardCt.ThrowIfCancellationRequested();
|
||||||
workCt.ThrowIfCancellationRequested();
|
workCt.ThrowIfCancellationRequested();
|
||||||
iterations++;
|
iterations++;
|
||||||
|
|
||||||
@@ -260,6 +263,9 @@ public class AnthropicService
|
|||||||
}
|
}
|
||||||
|
|
||||||
messages.Add(new { role = "user", content = toolResults });
|
messages.Add(new { role = "user", content = toolResults });
|
||||||
|
|
||||||
|
// Hard-Stop Check nach Tool-Ausführung: Sofort raus statt nächste Iteration
|
||||||
|
hardCt.ThrowIfCancellationRequested();
|
||||||
}
|
}
|
||||||
|
|
||||||
await FinalizeChain(chain, usage, iterations, "max_iterations", sw);
|
await FinalizeChain(chain, usage, iterations, "max_iterations", sw);
|
||||||
@@ -268,7 +274,14 @@ public class AnthropicService
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// Stop-Button gedrückt (workCt wurde gecancelled)
|
// Stop-Button gedrückt (workCt oder hardCt wurde gecancelled)
|
||||||
|
await FinalizeChain(chain, usage, iterations, "stopped", sw);
|
||||||
|
}
|
||||||
|
catch (Exception) when (workCt.IsCancellationRequested || hardCt.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Stream-Dispose durch CancellationToken-Registration löst
|
||||||
|
// ObjectDisposedException/IOException aus statt OperationCanceledException.
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -752,6 +765,7 @@ public class AnthropicService
|
|||||||
string toolResult;
|
string toolResult;
|
||||||
bool isError = false;
|
bool isError = false;
|
||||||
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt); }
|
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt); }
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
|
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
|
||||||
|
|
||||||
toolResults.Add(new
|
toolResults.Add(new
|
||||||
@@ -946,6 +960,7 @@ public class AnthropicService
|
|||||||
string toolResult;
|
string toolResult;
|
||||||
bool isError = false;
|
bool isError = false;
|
||||||
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId); }
|
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId); }
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
|
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
|
||||||
|
|
||||||
toolResults.Add(new
|
toolResults.Add(new
|
||||||
@@ -1067,6 +1082,11 @@ public class AnthropicService
|
|||||||
CancellationToken sseCt = default)
|
CancellationToken sseCt = default)
|
||||||
{
|
{
|
||||||
var stream = await httpResp.Content.ReadAsStreamAsync(workCt);
|
var stream = await httpResp.Content.ReadAsStreamAsync(workCt);
|
||||||
|
// Bei Cancellation den Stream disposen, damit ReadLineAsync sofort abbricht.
|
||||||
|
// StreamReader.ReadLineAsync propagiert den CancellationToken nicht zuverlässig
|
||||||
|
// an den underlying NetworkStream – der Read blockiert sonst bis Anthropic
|
||||||
|
// das nächste Chunk sendet (kann bei extended thinking Minuten dauern).
|
||||||
|
using var ctReg = workCt.Register(() => { try { stream.Dispose(); } catch { } });
|
||||||
using var reader = new StreamReader(stream);
|
using var reader = new StreamReader(stream);
|
||||||
|
|
||||||
var stopReason = "";
|
var stopReason = "";
|
||||||
|
|||||||
@@ -7,37 +7,56 @@ public class StreamingTracker
|
|||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<string, StreamingState> _streaming = new ConcurrentDictionary<string, StreamingState>();
|
private readonly ConcurrentDictionary<string, StreamingState> _streaming = new ConcurrentDictionary<string, StreamingState>();
|
||||||
|
|
||||||
public CancellationTokenSource Start(string sessionId, string chainId)
|
public StreamingTokens Start(string sessionId, string chainId)
|
||||||
{
|
{
|
||||||
var cts = new CancellationTokenSource();
|
var stopCts = new CancellationTokenSource();
|
||||||
_streaming[sessionId] = new StreamingState(chainId, cts);
|
var hardCts = new CancellationTokenSource();
|
||||||
return cts;
|
var state = new StreamingState(chainId, stopCts, hardCts);
|
||||||
|
_streaming[sessionId] = state;
|
||||||
|
return new StreamingTokens(stopCts.Token, hardCts.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Stop(string sessionId)
|
public void Stop(string sessionId)
|
||||||
{
|
{
|
||||||
if (_streaming.TryRemove(sessionId, out var state))
|
if (_streaming.TryRemove(sessionId, out var state))
|
||||||
{
|
{
|
||||||
state.Cts.Dispose();
|
state.StopCts.Dispose();
|
||||||
|
state.HardCts.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cancels the running stream for a session. Returns true if a stream was cancelled.
|
/// Cancels the running stream for a session.
|
||||||
|
/// First call = graceful (laufende Iteration wird fertig).
|
||||||
|
/// Second call = hard (sofortiger Abbruch aus while-Loop).
|
||||||
|
/// Returns "graceful", "hard" or null if not streaming.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Cancel(string sessionId)
|
public string Cancel(string sessionId)
|
||||||
{
|
{
|
||||||
if (_streaming.TryGetValue(sessionId, out var state))
|
if (_streaming.TryGetValue(sessionId, out var state))
|
||||||
{
|
{
|
||||||
try { state.Cts.Cancel(); } catch { }
|
if (!state.StopCts.IsCancellationRequested)
|
||||||
return true;
|
{
|
||||||
|
// Erster Klick → graceful stop
|
||||||
|
try { state.StopCts.Cancel(); } catch { }
|
||||||
|
return "graceful";
|
||||||
|
}
|
||||||
|
else if (!state.HardCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Zweiter Klick → hard stop
|
||||||
|
try { state.HardCts.Cancel(); } catch { }
|
||||||
|
return "hard";
|
||||||
|
}
|
||||||
|
return "hard"; // Bereits beides gecancelled
|
||||||
}
|
}
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsStreaming(string sessionId) => _streaming.ContainsKey(sessionId);
|
public bool IsStreaming(string sessionId) => _streaming.ContainsKey(sessionId);
|
||||||
|
|
||||||
public string GetChainId(string sessionId) => _streaming.TryGetValue(sessionId, out var state) ? state.ChainId : null;
|
public string GetChainId(string sessionId) => _streaming.TryGetValue(sessionId, out var state) ? state.ChainId : null;
|
||||||
|
|
||||||
private record StreamingState(string ChainId, CancellationTokenSource Cts);
|
public record StreamingTokens(CancellationToken StopToken, CancellationToken HardToken);
|
||||||
|
|
||||||
|
private record StreamingState(string ChainId, CancellationTokenSource StopCts, CancellationTokenSource HardCts);
|
||||||
}
|
}
|
||||||
@@ -440,6 +440,18 @@
|
|||||||
background: rgb(from var(--dangerous) r g b / 0.15);
|
background: rgb(from var(--dangerous) r g b / 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stop-btn.stop-hard {
|
||||||
|
background: var(--dangerous);
|
||||||
|
color: var(--primary-text);
|
||||||
|
animation: pulse-hard 0.6s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-hard {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.15); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
.input-area textarea {
|
.input-area textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
+18
-3
@@ -170,11 +170,23 @@ async function clearChat() {
|
|||||||
showArchiveLoadButton();
|
showArchiveLoadButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _stopClickCount = 0;
|
||||||
|
|
||||||
function stopStreaming() {
|
function stopStreaming() {
|
||||||
if (_abortController) {
|
_stopClickCount++;
|
||||||
_abortController.abort();
|
|
||||||
|
if (_stopClickCount >= 2) {
|
||||||
|
// Hard-Stop: Sofortiger Abbruch (Doppel-Klick)
|
||||||
|
var btn = $('#stop');
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.add('stop-hard');
|
||||||
|
btn.title = 'Hard-Stop gesendet!';
|
||||||
|
}
|
||||||
|
// SSE-Stream clientseitig sofort abbrechen
|
||||||
|
if (_abortController) _abortController.abort();
|
||||||
}
|
}
|
||||||
// Backend explizit stoppen (kills laufende Tools/API-Calls)
|
|
||||||
|
// Backend explizit stoppen (graceful beim 1. Klick, hard beim 2.)
|
||||||
var sid = getCurrentSessionId();
|
var sid = getCurrentSessionId();
|
||||||
if (sid) apiStopChat(sid).catch(function() {});
|
if (sid) apiStopChat(sid).catch(function() {});
|
||||||
}
|
}
|
||||||
@@ -193,6 +205,7 @@ async function _runStream(fetchPromise) {
|
|||||||
_abortController = new AbortController();
|
_abortController = new AbortController();
|
||||||
_currentToolBox = null;
|
_currentToolBox = null;
|
||||||
_chatAbandoned = false;
|
_chatAbandoned = false;
|
||||||
|
_stopClickCount = 0;
|
||||||
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming...</span>');
|
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming...</span>');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -244,6 +257,8 @@ async function _runStream(fetchPromise) {
|
|||||||
sendBtn.disabled = false;
|
sendBtn.disabled = false;
|
||||||
sendBtn.hidden = false;
|
sendBtn.hidden = false;
|
||||||
stopBtn.hidden = true;
|
stopBtn.hidden = true;
|
||||||
|
stopBtn.classList.remove('stop-hard');
|
||||||
|
stopBtn.title = 'Antwort abbrechen';
|
||||||
isStreaming = false;
|
isStreaming = false;
|
||||||
_abortController = null;
|
_abortController = null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user