Files
gott/Services/SteeringQueue.cs
T
MelekiandClaude Opus 4.8 de07b49b67 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>
2026-06-10 14:50:31 +02:00

112 lines
4.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using System.Collections.Concurrent;
namespace Gott.Services;
/// <summary>
/// Out-of-band-Kanal für "Steering"-Einwürfe: Nachrichten, die der User schickt
/// WÄHREND eine Chain läuft (vgl. /btw bzw. Queued-Steering in Claude Code).
/// Lebenszyklus 1:1 gespiegelt vom <see cref="StreamingTracker"/> – keyed by sessionId.
///
/// Der Streaming-Loop (AnthropicService.StreamChat) draint die Queue an den
/// Iterations-Grenzen (nach einer Tool-Runde bzw. bevor die Chain finalisiert)
/// und injiziert die Texte als User-Message in die laufende Konversation.
/// Ein laufender messages.create-Request ist unveränderlich – Injektion geht
/// nur ZWISCHEN den API-Calls, nicht mitten im Stream.
///
/// Im CLI-Pfad (AnthropicService.StreamChatViaCli) läuft der Agentic-Loop im
/// claude-Child; dort draint ein Kill-and-Restart am nächsten Tool-Boundary
/// (Child killen, Verlauf + Einwurf an den Prompt hängen, CLI neu starten).
///
/// WICHTIG (Race-Schutz): <see cref="TryEnqueue"/> und <see cref="Close"/> sind
/// über ein per-Session-Lock gegenseitig ausgeschlossen. Damit kann ein Einwurf,
/// der dem User bereits als injected:true bestätigt wurde, NICHT mehr still
/// verloren gehen: Entweder der Loop draint ihn noch, oder <see cref="Close"/>
/// gibt ihn als "leftover" zurück, damit der Aufrufer ihn persistieren kann.
/// </summary>
public class SteeringQueue
{
private sealed class SessionState
{
public readonly Queue<string> Items = new Queue<string>();
public bool Open;
}
private readonly ConcurrentDictionary<string, SessionState> _sessions = new ConcurrentDictionary<string, SessionState>();
/// <summary>Öffnet das Steering-Gate für eine Session (beim Chain-Start aufrufen).</summary>
public void Open(string sessionId)
{
if (string.IsNullOrEmpty(sessionId)) return;
var s = _sessions.GetOrAdd(sessionId, _ => new SessionState());
lock (s)
{
s.Items.Clear();
s.Open = true;
}
}
/// <summary>
/// Reiht einen Einwurf ein – aber NUR solange das Gate offen ist (Chain läuft
/// und ist noch nicht am Finalisieren). Gibt false zurück, wenn das Gate bereits
/// geschlossen ist (Race am Chain-Ende) → der Aufrufer soll die Nachricht dann
/// als normale Nachricht senden statt sie zu verlieren.
/// </summary>
public bool TryEnqueue(string sessionId, string text)
{
if (string.IsNullOrEmpty(sessionId) || string.IsNullOrWhiteSpace(text)) return false;
if (!_sessions.TryGetValue(sessionId, out var s)) return false;
lock (s)
{
if (!s.Open) return false;
s.Items.Enqueue(text);
return true;
}
}
/// <summary>
/// Entnimmt ALLE pending Einwürfe einer Session (FIFO) und gibt sie zurück.
/// Leere Liste wenn nichts ansteht. Das Gate bleibt offen.
/// </summary>
public List<string> Drain(string sessionId)
{
var result = new List<string>();
if (_sessions.TryGetValue(sessionId, out var s))
{
lock (s)
{
while (s.Items.Count > 0) result.Add(s.Items.Dequeue());
}
}
return result;
}
/// <summary>Prüft ob für eine Session Einwürfe anstehen.</summary>
public bool HasPending(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var s)) return false;
lock (s) { return s.Items.Count > 0; }
}
/// <summary>
/// Schließt das Gate (beim Chain-Ende) und gibt etwaige NICHT gedrainte
/// Einwürfe zurück. Diese sind im Race-Fenster zwischen dem letzten Drain des
/// Loops und dem Schließen angekommen, wurden dem User aber bereits als
/// injected:true bestätigt – der Aufrufer MUSS sie persistieren, sonst
/// verschwinden sie (Symptom: Einwurf nach F5 weg).
/// </summary>
public List<string> Close(string sessionId)
{
var leftover = new List<string>();
if (_sessions.TryRemove(sessionId, out var s))
{
lock (s)
{
s.Open = false;
while (s.Items.Count > 0) leftover.Add(s.Items.Dequeue());
}
}
return leftover;
}
}