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:
MelekiandClaude Opus 4.8 committed 2026-06-10 14:50:31 +02:00
1 parent 59e25f421c
commit de07b49b67
2 files changed
+106 -22

No files matched your search

+33 -6
View File
@@ -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 });
}
}