diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4a215e1..dc0e467 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3b93d2f..4732b91 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 — diff --git a/docs/SPEC.md b/docs/SPEC.md index ea66441..53869a4 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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 diff --git a/frontend/src/app.js b/frontend/src/app.js index c23d777..4405629 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -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/<uuid>)?", 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/<uuid>)?", 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/<uuid>)?", 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/<uuid>) ?", 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/<uuid>)?", 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/<uuid>)?", liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", + liveReconnectText:"Это окно было отключено и содержит собственные изменения — состояние на сервере отличается. Чья версия должна остаться?", liveConflictTheirs:"Принять чужую", liveConflictMine:"Оставить свою", riskTooltip:"Высокий риск – оценка ещё не ясна.", @@ -4337,6 +4343,7 @@ const I18N = { liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/<uuid>)?", liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", + liveReconnectText:"यह विंडो डिस्कनेक्ट थी और इसमें अपने बदलाव हैं — सर्वर की स्थिति अलग है। किसका संस्करण रहे?", liveConflictTheirs:"दूसरे का लें", liveConflictMine:"अपना रखें", riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।", @@ -4490,6 +4497,7 @@ const I18N = { liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/<uuid>)吗?", liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", liveConflictText:"有人改动了同样的行。以谁的版本为准?", + liveReconnectText:"此窗口曾断开连接并有自己的改动——服务器上的状态与此不同。以谁的版本为准?", liveConflictTheirs:"采用对方的", liveConflictMine:"保留我的", riskTooltip:"高风险 – 工作量尚不明确。", @@ -4632,6 +4640,7 @@ const I18N = { liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/<uuid>)ですか。", 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(){ '