fix(live): Wiederverbindung sendet nie von selbst — Server-Stand oder Rückfrage (D89-Nachtrag 2)
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s

Ein Fenster, das mit altem Text aufwachte, schickte ihn beim Wiederverbinden
als Diff und überschrieb den aktuellen Plan (v660, v679). Jetzt entscheidet,
ob seit dem letzten Abgleich hier gearbeitet wurde: nein → Server-Stand
übernehmen (lokaler Text in die Sicherungen), ja → Konflikt-Band fragt.
Regel headless in live.js (reconnectAction), Bandtext in neun Sprachen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-04 12:34:01 +02:00
co-authored by Claude Fable 5.1
parent 2e408d956c
commit 28ff6844b0
6 changed files with 134 additions and 7 deletions
+1
View File
@@ -19,6 +19,7 @@ reverse.
## 2026-09-04
- Fix: a shared document's reconnect no longer pushes whatever text the window holds — a phone waking up with yesterday's state overwrote the current plan twice; now the server state is taken when nothing was edited here, and the conflict bar asks when it was
- A `mirror-docs` script runs a list of shared documents through `pull-doc --git-commit` and pushes once — as a 15-minute timer on the backend host it gives a shared plan a git history without anyone remembering to run it
## 2026-09-03
+52
View File
@@ -7815,6 +7815,58 @@ Entscheidungen: **D94** und `docs/rfc/002-mehrfenster.md`. Der Präsenz-Kanal
(Herzschlag, Timeout, Notluke) und der modale Dialog sind ersatzlos
ausgebaut; die drei anderen Netze (Sicherungen, Rettung, Wachhund) bleiben.
**Nachtrag 2 — die Wiederverbindung sendet nie von selbst (2026-09-04).**
Der zweite Datenverlust am selben Dokument, diesmal nachgemessen bis zur
Ursache: Version 679 (11:49 Uhr) kam von einem anderen Client (der PWA auf
dem Telefon) und war byte-genau Version 667 — der letzte Stand, den das
Telefon am Vortag gesehen hatte — plus drei Faltmarken. Die 16 Zeilen aus
668 bis 671 waren weg; der Nutzer hat 671 per Rollback (v680) zurückgeholt.
Dasselbe Muster wie v660 am Abend davor (byte-gleich mit v339).
**Der Mechanismus stand im D90-Nachtrag, als Rettung gemeint.** Die
Wiederverbindung nach einer toten Sitzung holte den Server-Stand als
Schattenkopie und schickte dann jeden abweichenden lokalen Text als Diff —
„ungesendeter Text bleibt stehen, die keepMine-Semantik". Sie konnte nicht
unterscheiden, ob der lokale Text **neuer** ist als der Server (ungesendete
Arbeit) oder **älter** (ein Fenster, das lange geschlafen hat): In beiden
Fällen ist er „anders", und die alte Fassung ging als Diff gegen den
aktuellen Server hinaus, was einer Vollersetzung gleichkommt. Die Kette auf
dem Telefon: Die PWA wacht auf, der erste Abruf scheitert (Funk noch aus),
die Sitzung ist tot, der Editor zeigt den lokal gespeicherten Text vom
Vortag. Ein Tipp auf ein Falt-Zeichen ändert ihn (Falten schreibt in den
Text, D38-Nachtrag 2). Der Wachhund verbindet neu, sieht „lokal ≠ Server"
und schickt.
**Entscheidend ist nicht der Vergleich der Texte, sondern ob hier gearbeitet
wurde.** Zwei Zähler in app.js: einer zählt jede Textänderung an einem
Server-Dokument — mit oder ohne Sitzung, denn das Falten auf einem toten
Telefon ist genau der Fall —, der andere hält den Stand, den der Server
zuletzt bestätigt hat oder den wir von ihm übernommen haben (gelungenes
Senden, Laden, Volltext aus dem Feed, *Fremde übernehmen*). Ein Senden,
während weiter getippt wird, bestätigt nur, was es getragen hat. Die Regel
selbst steht headless in `live.js` (`reconnectAction`, D54-Nachtrag 3):
Texte gleich → nichts zu entscheiden; nicht gearbeitet → der Server gilt,
der lokale Text wandert in die lokalen Sicherungen; gearbeitet → das
vorhandene Konflikt-Band fragt, mit eigenem Text („Dieses Fenster war
getrennt und hat eigene Änderungen — der Stand auf dem Server weicht ab").
Nichts geschieht stumm, und beide Stände existieren, bis ein Mensch
entscheidet.
**Nachgemessen** im Browser gegen ein lokales Backend, das Telefon-Szenario
nachgestellt: Server auf v2 gesetzt, Backend gestoppt, Seite geladen — Chip
„getrennt", Editor zeigt den alten Spiegel (v1). Backend wieder da, nichts
angefasst: nach ≤ 20 s zeigt der Editor v2, der Server steht **unverändert
auf v2** (vorher hätte das Fenster hier v3 mit dem alten Text geschrieben).
Zweiter Durchlauf mit einer getippten Zeile im getrennten Zustand: Das Band
erscheint mit dem neuen Text, der Server bleibt auf v2, der lokale Text
steht; *Eigene durchsetzen* schreibt v3 mit genau dieser Zeile. 669 Tests,
davon 3 neue.
**Was bleibt:** Ein Fenster, das mit **ungesendeter** Arbeit ganz beendet
wird (Android räumt die PWA ab), startet mit dem Server-Stand; die Arbeit
liegt dann in den lokalen Sicherungen (Netz 3 aus dem Haupttext), nicht im
Editor — unverändert seit D89.
## D90 — Die Dokumentart steht grau hinter dem Namens-Chip
Nutzerwunsch, unmittelbar aus dem D89-Vorfall: Hinter der Brotkrume
(„Werkbaum Name", D81) soll erkennbar sein, was für ein Dokument vorn ist —
+7
View File
@@ -951,6 +951,13 @@ anderen, ohne neu zu laden.
wandert er in die **lokalen Sicherungen**: Für Server-Dokumente sammelt der
10-Minuten-Takt sie wieder, und das Uhr-Menü zeigt sie als eigenen Abschnitt
unter den Server-Meilensteinen. Siehe D89.
- **Eine tote Sitzung verbindet sich von selbst neu — und sendet dabei nie
von selbst.** Wurde in diesem Fenster seit dem letzten gelungenen Abgleich
nicht gearbeitet, gilt der Stand des Servers; der lokale Text ist dann nur
ein alter Spiegel und wandert in die lokalen Sicherungen. Wurde gearbeitet,
fragt das Konflikt-Band, wessen Fassung gelten soll. Ein Fenster, das mit
dem Stand vom Vortag aufwacht, kann den Plan so nicht mehr überschreiben.
Siehe D89-Nachtrag 2.
**Ein zweites Werkbaum-Fenster desselben Browsers** — Tab, Fenster oder PWA
— kollidiert nur noch am **selben nicht geteilten Dokument**: Text, Name und
+39 -7
View File
@@ -3484,6 +3484,7 @@ const I18N = {
liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.",
liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?",
liveReconnectText:"Dieses Fenster war getrennt und hat eigene Änderungen — der Stand auf dem Server weicht ab. Wessen Fassung soll gelten?",
liveConflictTheirs:"Fremde übernehmen",
liveConflictMine:"Eigene durchsetzen",
riskTooltip:"High Risk Aufwand noch unklar.",
@@ -3627,6 +3628,7 @@ const I18N = {
liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.",
liveConflictText:"Someone changed the same lines. Whose version should win?",
liveReconnectText:"This window was disconnected and holds changes of its own — the server state differs. Whose version should win?",
liveConflictTheirs:"Take theirs",
liveConflictMine:"Keep mine",
riskTooltip:"High risk effort still unclear.",
@@ -3769,6 +3771,7 @@ const I18N = {
liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.",
liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?",
liveReconnectText:"Esta ventana estuvo desconectada y tiene cambios propios — el estado en el servidor difiere. ¿Qué versión debe prevalecer?",
liveConflictTheirs:"Tomar la ajena",
liveConflictMine:"Mantener la mía",
riskTooltip:"Alto riesgo esfuerzo aún incierto.",
@@ -3911,6 +3914,7 @@ const I18N = {
liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/&lt;uuid&gt;) ?",
liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.",
liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?",
liveReconnectText:"Cette fenêtre a été déconnectée et contient ses propres modifications — l'état sur le serveur diffère. Quelle version doit l'emporter ?",
liveConflictTheirs:"Prendre la sienne",
liveConflictMine:"Garder la mienne",
riskTooltip:"Risque élevé effort encore incertain.",
@@ -4053,6 +4057,7 @@ const I18N = {
liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.",
liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?",
liveReconnectText:"To okno było rozłączone i ma własne zmiany — stan na serwerze się różni. Która wersja ma obowiązywać?",
liveConflictTheirs:"Przyjmij cudzą",
liveConflictMine:"Zachowaj własną",
riskTooltip:"Wysokie ryzyko nakład jeszcze niejasny.",
@@ -4195,6 +4200,7 @@ const I18N = {
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
liveReconnectText:"Это окно было отключено и содержит собственные изменения — состояние на сервере отличается. Чья версия должна остаться?",
liveConflictTheirs:"Принять чужую",
liveConflictMine:"Оставить свою",
riskTooltip:"Высокий риск – оценка ещё не ясна.",
@@ -4337,6 +4343,7 @@ const I18N = {
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
liveReconnectText:"यह विंडो डिस्कनेक्ट थी और इसमें अपने बदलाव हैं — सर्वर की स्थिति अलग है। किसका संस्करण रहे?",
liveConflictTheirs:"दूसरे का लें",
liveConflictMine:"अपना रखें",
riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।",
@@ -4490,6 +4497,7 @@ const I18N = {
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?",
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
liveConflictText:"有人改动了同样的行。以谁的版本为准?",
liveReconnectText:"此窗口曾断开连接并有自己的改动——服务器上的状态与此不同。以谁的版本为准?",
liveConflictTheirs:"采用对方的",
liveConflictMine:"保留我的",
riskTooltip:"高风险 – 工作量尚不明确。",
@@ -4632,6 +4640,7 @@ const I18N = {
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。",
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
liveReconnectText:"このウィンドウは切断されていて独自の変更があります — サーバー側の状態は異なります。どちらの版を採りますか。",
liveConflictTheirs:"相手の版",
liveConflictMine:"自分の版",
riskTooltip:"高リスク – 規模はまだ不明。",
@@ -5787,6 +5796,7 @@ document.addEventListener('pointerdown', e => {
});
function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : '';
liveSynced(); /* der Text im Feld ist jetzt der geladene Stand — nichts davon ist Arbeit dieses Fensters */
clearLens(); /* die Personen-Linse (D87) gilt je Dokument-Sitzung */
/* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der
erste Takt nach dem Öffnen auch ein unverändertes Dokument weg. */
@@ -6396,6 +6406,17 @@ function nextSeq(){
let liveState = null; /* {urls, id, version, shadow, seq, pushTimer, feedAbort, busy} */
let liveConflict = null; /* offener Konflikt: bis der Mensch entscheidet, ruht alles */
/* Wurde in diesem Fenster seit dem letzten gelungenen Abgleich gearbeitet?
Zwei Zähler statt eines Flags: `liveEditSeq` zählt jede Textänderung an
einem Server-Dokument (auch ohne Sitzung das Falten auf einem toten
Telefon ist genau der Fall), `liveSyncSeq` hält den Stand, den der Server
zuletzt bestätigt hat oder den wir von ihm übernommen haben. Ein Senden,
während weiter getippt wird, gilt so nur für das, was es getragen hat.
Die Wiederverbindung fragt danach, statt den Text zu vergleichen
(D89-Nachtrag 2). */
let liveEditSeq = 0, liveSyncSeq = 0;
function liveEdited(){ return liveEditSeq !== liveSyncSeq; }
function liveSynced(){ liveSyncSeq = liveEditSeq; }
function liveActive(){ return !!liveState && activeId === liveState.id; }
@@ -6488,6 +6509,8 @@ function adoptLive(doc){
weckt den wartenden Feed 39 ms nach dem PATCH (D79). Sie bleibt ein
Debounce, kein Takt: Wer durchtippt, erzeugt weiterhin keine Version. */
function scheduleLivePush(){
const d = activeDoc();
if(d && String(d.id).startsWith('live:')) liveEditSeq++; /* gearbeitet — mit oder ohne Sitzung */
if(!liveActive() || liveConflict) return;
if(liveState.pushTimer) clearTimeout(liveState.pushTimer);
liveState.pushTimer = setTimeout(() => { liveState.pushTimer = null; pushLive(); },
@@ -6520,9 +6543,12 @@ function rescueSnapshot(docId, text){
/* Eine tote Sitzung heilt sich SELBST (Nutzer-Einwand zu D89: Ohne Reload
ist wieder alles tot?" nein): Der Wachhund versucht die Wiederverbindung,
alle 10 s, solange das aktive Dokument ein Server-Dokument ohne Sitzung
ist. Ungesendeter Text bleibt dabei STEHEN und geht als Diff an den Server
(die keepMine-Semantik aus dem Konflikt-Band) die Wiederverbindung darf
nicht kosten, was sie retten soll. */
ist. Sie sendet NIE von selbst (D89-Nachtrag 2): Wurde hier seit dem
letzten Abgleich nicht gearbeitet, gilt der Server-Stand der lokale
Text ist dann nur ein alter Spiegel; wurde gearbeitet, fragt das
Konflikt-Band, wessen Fassung gelten soll. Die frühere Fassung schickte
jeden abweichenden lokalen Text als Diff und ließ damit ein aufgewachtes
Telefon den Plan mit dem Stand vom Vortag überschreiben. */
let reconnectBusy = false, reconnectNext = 0;
async function reconnectLive(d){
if(reconnectBusy || Date.now() < reconnectNext) return;
@@ -6535,7 +6561,9 @@ async function reconnectLive(d){
const serverText = live.normalize(doc.content || '');
liveState = {urls, id: d.id, version: doc.version, shadow: live.lines(serverText),
pushTimer: null, feedAbort: null, busy: false};
if(live.normalize(src.value) !== serverText) pushLive();
const what = live.reconnectAction(liveEdited(), src.value, serverText);
if(what === 'adopt') adoptLive(doc); /* sichert den lokalen Text vorher (D89) */
else if(what === 'ask') openConflict(doc.version, live.lines(serverText), 'liveReconnectText');
runFeed();
if(sourceWarning && (sourceWarning.type === 'liveLoad' || sourceWarning.type === 'liveStale')){
sourceWarning = null;
@@ -6594,6 +6622,7 @@ async function pushLive(meilenstein){
const sitzung = liveState;
sitzung.busy = true;
const seq = nextSeq();
const editStand = liveEditSeq; /* was danach getippt wird, trägt dieses Senden nicht */
/* Die Basis, gegen die `ops` gerechnet sind VOR dem Warten festgehalten.
Sie hinterher aus `liveState` zu lesen hieße anzunehmen, dass sich
dazwischen nichts ändert; genau diese Annahme ist gebrochen, sobald der
@@ -6628,6 +6657,7 @@ async function pushLive(meilenstein){
sitzung.shadow = live.applyOps(
foreign.length ? live.applyOps(basis, foreign) : basis, meine);
sitzung.version = result.version;
liveSyncSeq = editStand;
/* Ein gelungener Abgleich räumt liegengebliebene Live-Warnungen SOFORT
weg nicht geladen" oder „ungesendet" neben funktionierendem Senden
wäre eine Lüge, und der Wachhund-Tick kann gedrosselt sein (1/min in
@@ -6737,6 +6767,7 @@ function applyFeed(feed){
liveState.version = feed.currentVersion;
liveState.shadow = live.lines(neu);
setLiveText(live.text(liveState.shadow), null);
liveSynced();
return;
}
const alt = liveState.shadow;
@@ -6808,9 +6839,9 @@ function setLiveText(text, caret){
/* Echte Überschneidung: Der Server hat abgelehnt, und jetzt entscheidet der
Mensch. Bis dahin ruhen Senden und Feed sonst zöge der Stand unter der
Frage weg, die gerade gestellt ist. */
function openConflict(version, serverLines){
function openConflict(version, serverLines, textKey){
if(liveConflict) return; /* eine Frage zur Zeit */
liveConflict = {version, serverLines};
liveConflict = {version, serverLines, textKey: textKey || 'liveConflictText'};
showConflictBanner();
}
@@ -6829,6 +6860,7 @@ function takeTheirs(){
liveState.version = c.version;
liveState.shadow = c.serverLines;
setLiveText(live.text(c.serverLines), null);
liveSynced();
}
/* Eigene durchsetzen: Die Schattenkopie zieht auf den Server-Stand nach, der
@@ -6853,7 +6885,7 @@ function showConflictBanner(){
'<div class="live-conflict-actions">' +
'<button type="button" class="theirs"></button>' +
'<button type="button" class="mine"></button></div>';
bar.querySelector('span').textContent = t('liveConflictText');
bar.querySelector('span').textContent = t(liveConflict.textKey);
const theirs = bar.querySelector('.theirs');
const mine = bar.querySelector('.mine');
theirs.textContent = t('liveConflictTheirs');
+17
View File
@@ -302,3 +302,20 @@ export function feedAction(feed, shadowVersion, pushInFlight){
if(Array.isArray(feed.ops) && feed.fromVersion === shadowVersion) return 'apply';
return 'skip';
}
/* Was die Wiederverbindung nach einer toten Sitzung mit dem lokalen Text
tut (D89-Nachtrag 2). Die erste Fassung schickte ihn, sobald er vom Server
abwich — und konnte nicht unterscheiden, ob er NEUER ist als der Server
(ungesendete Arbeit) oder ÄLTER (ein Fenster, das lange geschlafen hat):
Ein Telefon, das mit dem Stand vom Vortag aufwachte, schrieb ihn als
Vollersetzung über den aktuellen Plan. Entscheidend ist deshalb nicht der
Vergleich der Texte, sondern ob in diesem Fenster seit dem letzten
gelungenen Abgleich GEARBEITET wurde.
'none' Texte gleich: nur Version und Schattenkopie übernehmen
'adopt' nicht gearbeitet: der Server-Stand gilt, der lokale weicht
'ask' gearbeitet: beide Stände existieren, ein Mensch entscheidet */
export function reconnectAction(edited, localText, serverText){
if(normalize(localText) === normalize(serverText)) return 'none';
return edited ? 'ask' : 'adopt';
}
+18
View File
@@ -2,6 +2,7 @@ import {describe, it, expect} from 'vitest';
import {
liveUrls, normalize, lines, text, computeOps, applyOps,
mapLine, caretToLineCol, lineColToCaret, feedAction, serverBase, documentsUrl, infoUrl,
reconnectAction,
} from '../src/live.js';
describe('Adressen', () => {
@@ -243,3 +244,20 @@ describe('Feed-Antwort anwenden oder nicht', () => {
expect(feedAction({fromVersion: null, currentVersion: 87, content: 'x'}, 4, true)).toBe('skip');
});
});
describe('Wiederverbindung (D89-Nachtrag 2)', () => {
// Der Vorfall: Ein Telefon wacht mit dem Stand vom Vortag auf, die Sitzung
// ist tot, ein Tipp auf ein Falt-Zeichen — und die alte Fassung ging als
// Diff über den aktuellen Plan. Entscheidend ist nicht, ob die Texte
// abweichen, sondern ob hier gearbeitet wurde.
it('gleicher Text: nichts zu entscheiden', () => {
expect(reconnectAction(false, 'a\nb', 'a\nb')).toBe('none');
expect(reconnectAction(true, 'a\r\nb', 'a\nb')).toBe('none'); // Zeilenenden normalisiert
});
it('nicht gearbeitet: der Server gilt, auch wenn der lokale Text abweicht', () => {
expect(reconnectAction(false, 'alter Stand', 'neuer Stand')).toBe('adopt');
});
it('gearbeitet: fragen statt senden — beide Stände existieren', () => {
expect(reconnectAction(true, 'alter Stand plus Faltmarke', 'neuer Stand')).toBe('ask');
});
});