Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_durability.mjs

9.7 KiB, 1 run

created by r2519314175:391, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_durability.mjs — a turn cut off by the tab dying loses nothing but the split-second in
2// flight, and can be continued.
3//
4// Before the write-ahead journal, a browser closed mid-turn lost the whole turn — the prompt
5// included — because the transcript was saved only when the turn FINISHED. This drives the real
6// failure: it sends a slow reply, RELOADS the page mid-stream (which kills the in-flight fetch
7// exactly as a crash would), and then checks that on the next boot the prompt and the partial
8// reply are both there, badged interrupted, with a Continue button that runs it again.
9//
10// It also checks the other side of the ledger: a turn that COMPLETED normally leaves nothing
11// behind — its journal is pruned — so a completed turn never masquerades as interrupted.
12import { open, signInAs, connectMock, chat, errors, scratch, clearChats } from './harness.mjs';
13
14const ok = [], bad = [];
15const check = (name, pass, detail) => {
16 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
17 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
18};
19// Poll a page-side predicate rather than sleeping a fixed time: the timing windows here are real
20// (a turn in flight, recovery having run) but their duration varies with machine load, so waiting
21// on the CONDITION is what keeps the test honest and stable.
22async function until(page, fn, ms = 12000, step = 150) {
23 const t0 = Date.now();
24 while (Date.now() - t0 < ms) {
25 if (await page.evaluate(fn)) return true;
26 await page.waitForTimeout(step);
27 }
28 return false;
29}
30
31// A FIXED profile, so the identity survives between runs — and so, until this was
32// noticed, did the chats. The last check counts how many times one prompt appears
33// on screen, and every run added another copy to the same chat: it read x2, then
34// x3, then x5, and looked for all the world like a regression somebody had just
35// introduced. It was the profile. The store is emptied below rather than left to
36// the reader to remember (the note in the project's own memory says "rm -rf the
37// profile", which is the same discovery made once already).
38const s = await open({ name: 'durability', connect: false, signIn: false, profile: scratch('durability-profile') });
39const p = s.page;
40p.on('dialog', d => d.accept().catch(() => {})); // a beforeunload warning must not block the reload
41await p.waitForTimeout(1500);
42await signInAs(s, 'Dura');
43await p.waitForTimeout(500);
44await clearChats(s);
45await p.reload({ waitUntil: 'domcontentloaded' });
46await p.waitForTimeout(1200);
47await signInAs(s, 'Dura');
48await p.waitForTimeout(500);
49await connectMock(s);
50await p.waitForTimeout(700);
51
52// ── A completed turn leaves nothing behind ──────────────────────────────
53
54await chat(s, '@text a normal complete answer');
55const afterComplete = await p.evaluate(async () => {
56 const r = await window.DaimondJournal.recover();
57 return { interruptedChats: r.turns.length };
58});
59check('a completed turn is pruned from the journal — nothing to recover',
60 afterComplete.interruptedChats === 0, String(afterComplete.interruptedChats));
61
62// ── Start a slow turn, then simulate a crash mid-stream ─────────────────
63
64await p.evaluate(() => {
65 const box = document.getElementById('chat-input');
66 box.value = '@long 90'; // 90 chunks at ~120ms each = a turn that stays in flight for ~10s
67 box.dispatchEvent(new Event('input', { bubbles: true }));
68 document.getElementById('chat-send').click();
69});
70// Wait until the turn is genuinely in flight with some reply streamed — poll, don't guess.
71await until(p, () => {
72 const b = document.getElementById('chat-send');
73 const generating = /stop/i.test((b.getAttribute('title') || '') + b.className);
74 return generating && /chunk-1/.test(document.getElementById('chat-output').innerText);
75});
76
77const midTurn = await p.evaluate(async () => {
78 const generating = /stop/i.test((document.getElementById('chat-send').getAttribute('title') || '') + document.getElementById('chat-send').className);
79 // The prompt is already durable (persist-first), before the turn has finished.
80 // From IndexedDB, which is where transcripts live now — localStorage ran out of
81 // room for a day's tool results and failed silently when it did.
82 const snap = await new Promise((res) => {
83 const req = indexedDB.open('daimond-chats', 1);
84 req.onsuccess = () => {
85 const db = req.result;
86 let t;
87 try { t = db.transaction('chats', 'readonly'); } catch (e) { res([]); return; }
88 const all = t.objectStore('chats').getAll();
89 all.onsuccess = () => res(all.result || []);
90 all.onerror = () => res([]);
91 };
92 req.onerror = () => res([]);
93 });
94 const msgs = (snap[0] && snap[0].messages) || [];
95 const hasPrompt = msgs.some(m => m.role === 'user' && /@long 90/.test(m.content || ''));
96 // The journal holds the open turn with its partial reply.
97 const r = await window.DaimondJournal.recover();
98 const t = r.turns[0];
99 return { generating, hasPrompt, partial: t ? t.text : '', hasChunks: t ? /chunk-1/.test(t.text) : false };
100});
101check('the turn is genuinely in flight when we pull the plug', midTurn.generating === true);
102check('the prompt is ALREADY durable mid-turn (persist-first)', midTurn.hasPrompt === true);
103check('the journal holds the partial reply as it streams', midTurn.hasChunks === true,
104 midTurn.partial.slice(0, 40));
105
106// The crash: reload kills the in-flight fetch and everything in memory, exactly as closing the
107// browser would.
108await p.reload({ waitUntil: 'domcontentloaded' });
109await p.waitForTimeout(1200);
110await signInAs(s, 'Dura'); // unlock again
111// Wait until recovery has folded the interrupted turn back in, rather than assuming a duration.
112await until(p, () => !!document.querySelector('.chat-msg.interrupted, .ti-continue'));
113
114// ── After the crash: the turn is recovered, badged, continuable ─────────
115
116const recovered = await p.evaluate(() => {
117 const out = document.getElementById('chat-output');
118 const text = out ? out.innerText : '';
119 return {
120 hasPrompt: /@long 90/.test(text),
121 hasPartial: /chunk-1/.test(text),
122 interruptedBadge: !!out.querySelector('.chat-msg.interrupted'),
123 continueBtn: !!out.querySelector('.ti-continue'),
124 };
125});
126check('after the crash, the prompt survived', recovered.hasPrompt === true);
127check('after the crash, the partial reply survived', recovered.hasPartial === true);
128check('the recovered turn is badged interrupted', recovered.interruptedBadge === true);
129check('and it offers a Continue button', recovered.continueBtn === true);
130
131// The journal was pruned once recovery folded the turn into the snapshot, so a SECOND reload does
132// not recover it twice.
133const doubleReload = await p.evaluate(async () => {
134 const r = await window.DaimondJournal.recover();
135 return r.turns.length;
136});
137check('recovery is one-shot — the journal is cleared once folded in', doubleReload === 0, String(doubleReload));
138
139// ── Continue re-runs the turn ───────────────────────────────────────────
140
141await p.evaluate(() => document.querySelector('.ti-continue').click());
142// The interrupted attempt is dropped immediately; the re-run starts generating shortly after.
143const interruptedGone = await p.evaluate(() => !document.querySelector('.chat-msg.interrupted'));
144const started = await until(p, () => {
145 const b = document.getElementById('chat-send');
146 return /stop/i.test((b.getAttribute('title') || '') + b.className);
147});
148check('Continue starts the turn running again', started === true);
149check('and drops the interrupted attempt it is replacing', interruptedGone === true);
150
151// Stop the re-run so it settles as a completed (stopped) turn, keeping the test quick.
152await p.evaluate(() => { const b = document.getElementById('chat-send'); if (/stop/i.test((b.getAttribute('title') || '') + b.className)) b.click(); });
153await until(p, () => { const b = document.getElementById('chat-send'); return !/stop/i.test((b.getAttribute('title') || '') + b.className); });
154await p.waitForTimeout(400);
155
156// ── The Finding-1 regression: a continued turn must NOT resurrect ───────
157//
158// Continue filters the interrupted turn from the in-memory array, but the snapshot is an
159// append-only UNION — without a tombstone the merge silently re-adds the removed messages, so on
160// the next reload the interrupted turn (duplicate prompt, stale Continue button, partial re-fed as
161// history) comes back. This is the exact bug; reload once more and prove it stays gone.
162await p.reload({ waitUntil: 'domcontentloaded' });
163await p.waitForTimeout(1200);
164await signInAs(s, 'Dura');
165await p.waitForTimeout(1500);
166const afterSecondReload = await p.evaluate(() => {
167 const out = document.getElementById('chat-output');
168 const text = out ? out.innerText : '';
169 const prompts = (text.match(/@long 90/g) || []).length;
170 return { prompts, staleInterrupted: !!out.querySelector('.chat-msg.interrupted'), staleContinue: !!out.querySelector('.ti-continue') };
171});
172check('a continued turn does not resurrect on the next reload — the prompt appears once',
173 afterSecondReload.prompts === 1, 'x' + afterSecondReload.prompts);
174check('no stale "Interrupted" badge survives a continue', afterSecondReload.staleInterrupted === false);
175check('no stale Continue button survives a continue', afterSecondReload.staleContinue === false);
176
177const errs = errors(s).filter(e => !/favicon|404|401|502|Bad Gateway|net::ERR/.test(e));
178console.log('\nconsole errors:', errs.slice(0, 4));
179check('nothing throws through the crash and recovery', errs.length === 0, errs[0] || '');
180
181await s.close();
182console.log(`\n${ok.length} passed, ${bad.length} failed`);
183if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
184process.exit(bad.length ? 1 : 0);