Persist steering messages that arrive during chain finalization
A steering message confirmed to the user as injected:true could still be lost: after the loop's last HasPending check it runs out and the chain finalizes (done event + final messages still streaming — the "few extra messages" the user sees), then the finally block called Clear() and threw the queue away. A message arriving in that window was accepted (IsStreaming still true, Stop runs only in finally) but never drained and never persisted, so it vanished after F5. Platform-independent; Windows only widened the window via slower CLI teardown. Replace the queue with a per-session gated state: Open() at chain start, Close() at chain end. TryEnqueue() and Close() are mutually exclusive via a per-session lock, so a confirmed injection can no longer be lost — the loop either drains it or Close() returns it as leftover. The endpoint now opens the gate at chain start, uses TryEnqueue as the authoritative check (false → client resends as a normal message), and in finally closes the gate and persists any leftover as Type="steer" instead of discarding it, so it survives F5 and the model sees it on the next message. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
59e25f421c
commit
de07b49b67
2 files changed
+106
-22
No files matched your search
@@ -204,6 +204,8 @@ public static class ChatEndpoint
|
||||
// - sseCt: Stop-Button + Client-Disconnect → für SSE-Writes an den Browser
|
||||
// Bei Client-Disconnect werden SSE-Writes zu stillen No-Ops
|
||||
var tokens = streamingTracker.Start(sessionId, ctx.ChainId);
|
||||
// Steering-Gate öffnen, damit Einwürfe während dieser Chain angenommen werden.
|
||||
steeringQueue.Open(sessionId);
|
||||
var workCt = tokens.StopToken;
|
||||
var hardCt = tokens.HardToken;
|
||||
using var sseLinkCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, tokens.StopToken);
|
||||
@@ -278,9 +280,27 @@ public static class ChatEndpoint
|
||||
finally
|
||||
{
|
||||
streamingTracker.Stop(sessionId);
|
||||
// Nach Stop ist IsStreaming(sessionId) false → /api/chat/steer enqueued nichts mehr.
|
||||
// Etwaige nicht gedrainte Einwürfe verwerfen (kein Leak in die nächste Chain).
|
||||
steeringQueue.Clear(sessionId);
|
||||
// Steering-Gate schließen. Close() ist atomar gegen TryEnqueue: ein Einwurf,
|
||||
// der dem User als injected:true bestätigt wurde, kann ab hier nicht mehr
|
||||
// verloren gehen. Im Race-Fenster zwischen dem letzten Drain des Loops und
|
||||
// jetzt angekommene Einwürfe werden NICHT verworfen, sondern als Type="steer"
|
||||
// persistiert (sonst Symptom: Einwurf nach F5 weg, da nie gedraint).
|
||||
var leftoverSteers = steeringQueue.Close(sessionId);
|
||||
foreach (var steer in leftoverSteers)
|
||||
{
|
||||
try
|
||||
{
|
||||
await db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = ctx.ChainId,
|
||||
Iteration = 1,
|
||||
Role = "user",
|
||||
Type = "steer",
|
||||
Content = steer
|
||||
});
|
||||
}
|
||||
catch { /* Persistenz best-effort – darf das Chain-Ende nicht stören */ }
|
||||
}
|
||||
// Heartbeat-Task beenden (sseLinkCts wird disposed → Token cancelled)
|
||||
}
|
||||
}
|
||||
@@ -322,15 +342,22 @@ public static class ChatEndpoint
|
||||
return;
|
||||
}
|
||||
|
||||
// Nur einreihen wenn tatsächlich gerade eine Chain läuft – sonst würde der
|
||||
// Einwurf nie gedraint (kein laufender Loop) und bis zur nächsten Chain liegen bleiben.
|
||||
// Schneller Vorab-Check: läuft überhaupt eine Chain?
|
||||
if (!streamingTracker.IsStreaming(sessionId))
|
||||
{
|
||||
await res.WriteAsJsonAsync(new { injected = false, reason = "not_streaming" });
|
||||
return;
|
||||
}
|
||||
|
||||
steeringQueue.Enqueue(sessionId, text.Trim());
|
||||
// Maßgeblich ist das Steering-Gate: TryEnqueue ist atomar gegen Close() (Chain-Ende).
|
||||
// Liefert es false, ist das Gate bereits geschlossen (Race am Chain-Ende) → der
|
||||
// Client sendet die Nachricht dann als normale Nachricht statt sie zu verlieren.
|
||||
if (!steeringQueue.TryEnqueue(sessionId, text.Trim()))
|
||||
{
|
||||
await res.WriteAsJsonAsync(new { injected = false, reason = "not_streaming" });
|
||||
return;
|
||||
}
|
||||
|
||||
await res.WriteAsJsonAsync(new { injected = true });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user