fix(chat): Doppel-Bubble nach Retry + verwaiste ACK-Timer + docs
Race nach Etappe-3-Reconnect-Fix: lokale failed-Bubble (mit clientMsgId) und Server-Backup-Eintrag (ohne clientMsgId, aus alter Bridge-Version) landeten beide im Merge → User sah Doppelpost: einmal ueber der ARIA-Antwort (Server), einmal mit Retry-Knopf darunter (lokal). Plus ACK-Timer konnte weiterlaufen obwohl die Bubble schon delivered war — Retry pushte den Status zurueck auf sending und nach 30 s auf failed. App: - chat_history_response-Merge faellt zusaetzlich auf text+timestamp- Heuristik im 5-Min-Fenster zurueck wenn die Server-Bubble keine clientMsgId hat → lokale Kopie wird verworfen, kein Doppelpost - messagesRef + dispatchWithAck prueft vor Send/Retry ob die Bubble bereits delivered ist → kein verspaetetes failed mehr - ARIA-Reply cleart ALLE laufenden ACK-Timer (Bridge hat unsere Messages ja offensichtlich verarbeitet) Docs: - issue.md: neuer Block 'Chat-Stabilitaet' mit den drei Etappen + beiden Race-Fixes; AsyncStorage-Race-Punkt aus 'Offen' abgehakt - README.md: Chat-Such-Zeile aktualisiert (highlight statt filter), Jump-to-Bottom + Delivery-Status-Bubbles dokumentiert Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -270,6 +270,9 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const messageIdCounter = useRef(0);
|
||||
// Spiegel der messages-Liste in einer Ref — Closures (z.B. dispatchWithAck-
|
||||
// Retry) brauchen Zugriff auf den aktuellen Status einer Bubble.
|
||||
const messagesRef = useRef<ChatMessage[]>([]);
|
||||
// Watchdog gegen "ARIA denkt"-Hang: wird bei jedem agent_activity-Event mit
|
||||
// nicht-idle Status neu armiert. Feuert er, sind 180s lang KEINE Updates
|
||||
// vom Brain mehr gekommen → wir gehen davon aus dass die Verbindung
|
||||
@@ -334,8 +337,19 @@ const ChatScreen: React.FC = () => {
|
||||
// - Wenn offline → status='queued', wird beim Reconnect rausgeschickt.
|
||||
// - Wenn online → status='sending', Timer fuer ACK-Erwartung.
|
||||
// - Bei ACK-Timeout: retry (bis MAX_SEND_ATTEMPTS) oder 'failed'.
|
||||
// - Wenn die Bubble inzwischen 'delivered' ist (z.B. ARIA hat geantwortet
|
||||
// bevor das ACK durchkam) → komplett abbrechen, keinen Retry mehr.
|
||||
const dispatchWithAck = useCallback(
|
||||
(cmid: string, type: 'chat' | 'audio', payload: Record<string, unknown>, attempt = 1) => {
|
||||
// Schutz: wenn die Bubble inzwischen delivered ist, Retry-Loop stoppen
|
||||
// (kann bei verspaeteten ACKs oder manuellem Retry passieren wenn ARIA
|
||||
// schon laengst geantwortet hat).
|
||||
const current = messagesRef.current.find(m => m.clientMsgId === cmid);
|
||||
if (current?.deliveryStatus === 'delivered') {
|
||||
clearAckTimer(cmid);
|
||||
pendingPayloads.current.delete(cmid);
|
||||
return;
|
||||
}
|
||||
pendingPayloads.current.set(cmid, { type, payload });
|
||||
const online = connectionStateRef.current === 'connected';
|
||||
if (!online) {
|
||||
@@ -350,6 +364,13 @@ const ChatScreen: React.FC = () => {
|
||||
cmid,
|
||||
setTimeout(() => {
|
||||
ackTimers.current.delete(cmid);
|
||||
// Vor dem Retry erneut pruefen ob die Bubble nicht inzwischen
|
||||
// delivered wurde — sonst spawnen wir endlose Retries.
|
||||
const fresh = messagesRef.current.find(m => m.clientMsgId === cmid);
|
||||
if (fresh?.deliveryStatus === 'delivered') {
|
||||
pendingPayloads.current.delete(cmid);
|
||||
return;
|
||||
}
|
||||
if (attempt >= MAX_SEND_ATTEMPTS) {
|
||||
updateMessageStatus(cmid, { deliveryStatus: 'failed', sendAttempts: attempt });
|
||||
console.warn('[Chat] Send fehlgeschlagen nach %d Versuchen: %s', attempt, cmid);
|
||||
@@ -644,15 +665,27 @@ const ChatScreen: React.FC = () => {
|
||||
// gesetzt UND text leer/Placeholder)
|
||||
// - User-Bubbles deren clientMsgId der Server noch nicht kennt:
|
||||
// z.B. waehrend Reconnect-Race oder solange flushQueuedMessages
|
||||
// noch laeuft. Ohne diesen Schutz haette der history_response
|
||||
// die gerade reaktivierten Offline-Nachrichten geloescht.
|
||||
const localOnly = prev.filter(m =>
|
||||
m.skillCreated ||
|
||||
m.triggerCreated ||
|
||||
m.memorySaved ||
|
||||
(m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) ||
|
||||
(m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId))
|
||||
);
|
||||
// noch laeuft. ABER: wenn der Server eine textgleiche Bubble
|
||||
// im gleichen 5-Min-Fenster hat (Alter Backup-Eintrag ohne
|
||||
// clientMsgId, vor dem Bridge-Patch geschrieben), werten wir
|
||||
// das als Treffer und verwerfen die lokale Kopie — sonst
|
||||
// Doppelpost: einmal als Server-Bubble (delivered) und einmal
|
||||
// als lokale failed/queued mit Retry-Knopf.
|
||||
const FIVE_MIN = 5 * 60 * 1000;
|
||||
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;
|
||||
if (m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId)) {
|
||||
const serverHasIt = fromServer.some(s =>
|
||||
s.sender === 'user' &&
|
||||
s.text === m.text &&
|
||||
Math.abs((s.timestamp || 0) - (m.timestamp || 0)) < FIVE_MIN,
|
||||
);
|
||||
if (serverHasIt) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// Server-Stand + lokal-only (chronologisch sortiert)
|
||||
const merged = [...fromServer, ...localOnly].sort((a, b) => a.timestamp - b.timestamp);
|
||||
return capMessages(merged);
|
||||
@@ -919,6 +952,14 @@ const ChatScreen: React.FC = () => {
|
||||
});
|
||||
// ARIA hat geantwortet → Watchdog clearen, falls noch armiert
|
||||
clearStuckWatchdog();
|
||||
// ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages
|
||||
// ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn
|
||||
// ein ACK aus Netzgruenden verloren ging, soll der Retry nicht
|
||||
// nachtraeglich loslaufen und die Bubble auf 'failed' setzen.
|
||||
for (const cmid of Array.from(ackTimers.current.keys())) {
|
||||
clearAckTimer(cmid);
|
||||
pendingPayloads.current.delete(cmid);
|
||||
}
|
||||
}
|
||||
|
||||
// TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
|
||||
@@ -1225,6 +1266,10 @@ const ChatScreen: React.FC = () => {
|
||||
return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
|
||||
}, [messages]);
|
||||
|
||||
// messagesRef immer aktuell halten — wird von dispatchWithAck/Retry gelesen
|
||||
// damit Retries auf den aktuellen deliveryStatus reagieren koennen.
|
||||
useEffect(() => { messagesRef.current = messages; }, [messages]);
|
||||
|
||||
// Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
|
||||
// Spezial-Bubbles (memorySaved/triggerCreated/skillCreated) sollen im Chat
|
||||
// NICHT mehr erscheinen — sie werden in der Notizen-Inbox angezeigt.
|
||||
|
||||
Reference in New Issue
Block a user