fix(app): Queue friert nach Verbindungsabbruch nicht mehr ein (Issue 1)

Zwei Ursachen:
- pending_queue-Bubbles haben (noch) keine clientMsgId → der Reconnect-
  History-Sync erkannte sie nicht als lokal-only und verwarf sie, waehrend
  projectQueues den Eintrag behielt → 'N in Warteschlange' fror ein ohne
  sichtbare Nachricht. Jetzt bleiben pending_queue-Bubbles beim Sync erhalten.
- Watchdog: haengt ein Kontext >15s auf 'running', obwohl der Brain ihn NICHT
  als busy meldet (Antwort beim Abbruch verloren), schaltet die Queue jetzt
  selbst weiter (dequeue oder idle) statt fuer immer zu blockieren.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:18:06 +02:00
co-authored by Claude Opus 4.8
parent 55938c3173
commit 019b17ff97
+29 -2
View File
@@ -318,6 +318,9 @@ const ChatScreen: React.FC = () => {
const [projectQueues, setProjectQueues] = useState<Record<string, QueuedItem[]>>({});
const projectStatesRef = useRef<Record<string, CtxState>>({});
const projectQueuesRef = useRef<Record<string, QueuedItem[]>>({});
// Wann ist ein Kontext in 'running' gegangen? Fuer den Watchdog, der eine
// haengende Queue (z.B. Antwort waehrend Verbindungsabbruch verloren) loest.
const ctxRunningSinceRef = useRef<Record<string, number>>({});
// Pro-Projekt-Textfeld-Entwuerfe (noch nicht gesendeter Feldinhalt). Key = pid.
const projectDraftsRef = useRef<Record<string, string>>({});
const prevFocusedPidRef = useRef<string>('');
@@ -583,8 +586,24 @@ const ChatScreen: React.FC = () => {
try {
const s = await brainApi.getProjectQueueStatus();
if (cancelled) return;
setQueueStatus(s.contexts || {});
queueStatusRef.current = s.contexts || {};
const ctxs = s.contexts || {};
setQueueStatus(ctxs);
queueStatusRef.current = ctxs;
// Watchdog: haengt ein Kontext seit >15s auf 'running', obwohl der Brain
// ihn NICHT als busy meldet, ist die Antwort verloren gegangen (z.B.
// Verbindungsabbruch) — sonst friert die Queue ein. Dann weiterschalten.
const api = queueApiRef.current;
if (api) {
const now = Date.now();
for (const [pid, since] of Object.entries(ctxRunningSinceRef.current)) {
if (api.getCtxState(pid) !== 'running') continue;
const busy = !!ctxs[pid || '__main__']?.busy;
if (!busy && now - since > 15000) {
console.log('[Chat] Queue-Watchdog: Kontext %s haengt (running, brain idle) → weiterschalten', pid || '(main)');
api.advanceQueue(pid);
}
}
}
} catch {}
};
poll();
@@ -918,6 +937,11 @@ const ChatScreen: React.FC = () => {
const localOnly = prev.filter(m => {
if (m.skillCreated || m.triggerCreated || m.memorySaved) return true;
if (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) return true;
// Wartende Queue-Bubbles (noch nicht gesendet → kein clientMsgId, nicht
// auf dem Server) MUESSEN erhalten bleiben — sonst verschwindet die
// Bubble beim Reconnect-Sync, waehrend projectQueues den Eintrag behaelt
// → "N in Warteschlange" friert ein ohne sichtbare Nachricht.
if (m.sender === 'user' && m.deliveryStatus === 'pending_queue') return true;
if (m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId)) {
// Text-Match-Fallback: wenn der Server irgendwo eine textgleiche
// User-Bubble hat, ist es dieselbe Nachricht (vor cmid-Aera, ts
@@ -2050,6 +2074,9 @@ const ChatScreen: React.FC = () => {
const setCtxState = useCallback((pid: string, s: CtxState) => {
projectStatesRef.current = { ...projectStatesRef.current, [pid]: s };
// Watchdog-Zeitstempel: nur 'running' bekommt einen Start, sonst raus.
if (s === 'running') ctxRunningSinceRef.current[pid] = Date.now();
else delete ctxRunningSinceRef.current[pid];
setProjectStates(prev => ({ ...prev, [pid]: s }));
}, []);