Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_daimonconc.mjs

21.7 KiB, 1 run

created by r2519314175:343, 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_daimonconc.mjs — two daimons at once, and the Agents panel that shows up
2// when work starts.
3//
4// TESTER NOTE 14: *"When I have a daimon (or chat) in-progress and then prompt a
5// different daimon (or chat), the new prompt is queued ('Waiting, Sent when this
6// answer finishes'), apparently waiting for the other daimon/chat to finish. I'd
7// expect them to be able to be sent independently!"*
8//
9// They were serialised, and nothing about it was deliberate. `crystalBusy` was ONE
10// BOOLEAN for the whole app (`www/js/daimond.js`), read by the Diamond branch of
11// `sendUserMessage` and by `doSteer`, so a turn on Diamond A queued a message
12// typed at Diamond B. No governor asked for it -- `www/js/governor.js` gates the
13// SIZE of a fan-out and nothing else -- and no engine limit required it:
14// `steer_crystal` composes a fresh `Agent`, a fresh `ToolRegistry` and a LOCAL
15// `Session` per call (`src/wasm/app.rs`), so two Diamonds sharing one `DaimondApp`
16// can be steered at the same moment. Ordinary chats have run concurrently all
17// along, gated per record by `_generating`.
18//
19// THE SECOND HALF, WHICH THE NOTE DOES NOT MENTION AND IS WORSE. The queue was
20// drained at the end of the turn it waited on -- and that turn belonged to the
21// OTHER Diamond. So the message did not merely wait: when A finished, nothing
22// drained B, because `drainSteerQueue` runs for A's record and `resumeSteerQueue`
23// only fires on coming BACK to a Diamond. A user sitting on B watched A finish and
24// their own message stay badged and unsent for ever. Measured before the fix:
25// `B after send: queue:["@text hello from B"]` … `after A finishes: queue:["@text
26// hello from B"]`. So §1 asserts the OVERLAP and §2 asserts B actually answered.
27//
28// WHAT IS DELIBERATE AND STAYS: one turn per Diamond. A steer and a fold both
29// rewrite that Diamond's `crystal.json`, and two writers on one crystal lose each
30// other's edits. §3 holds that line, and §4 holds it for the fold -- which now
31// says so out loud instead of leaving a dead button.
32//
33// TESTER NOTE 10: *"When a daimon or chat initiates a new agent worker, the Agent
34// dock panel should be toggled to visible, currently this is not happening."* It
35// was ONCE EVER, remembered under `daimond-agents-revealed`, so the first fan-out
36// a browser ever ran opened the panel and none afterwards did. §5 dispatches
37// twice, closing the panel in between. §6 is the other side of it: a panel a
38// PERSON pushed away while workers were running is not argued with.
39//
40// node dev/verify_daimonconc.mjs --break oneflag # one busy flag for the whole app again
41// node dev/verify_daimonconc.mjs --break onceonly # the Agents panel reveals once ever again
42// node dev/verify_daimonconc.mjs --break noveto # it reopens over a panel just closed
43// node dev/verify_daimonconc.mjs --break sharefold # a fold ignores whose Diamond is busy
44// node dev/verify_daimonconc.mjs # and then, clean
45import fs from 'node:fs';
46import path from 'node:path';
47import { fileURLToPath } from 'node:url';
48import { open, newChat, shot, errors } from './harness.mjs';
49
50const HERE = path.dirname(fileURLToPath(import.meta.url));
51const WWW = path.join(HERE, '..', 'www');
52
53const ok = [], bad = [];
54const check = (name, pass, detail) => {
55 (pass ? ok : bad).push(name);
56 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
57};
58const die = (why) => { console.error('ABORT: ' + why); process.exit(2); };
59
60const BREAK = (() => {
61 const i = process.argv.indexOf('--break');
62 return i > 0 ? String(process.argv[i + 1] || '') : '';
63})();
64const BREAKS = {
65 oneflag: {
66 what: 'one busy flag for the whole app, so a second daimon queues behind the first',
67 edit: (src) => src.replace(
68 '\t\t\tif (diamondBusy(current.diamondId)) { enqueueMessage(current, text); return; }',
69 '\t\t\tif (anyCrystalBusy()) { enqueueMessage(current, text); return; }'),
70 },
71 onceonly: {
72 what: 'the Agents panel reveals once ever, as it did',
73 edit: (src) => src.replace(
74 '\tfunction revealAgents() {\n\t\tif (agentsPushedAway) return;',
75 '\tfunction revealAgents() {\n\t\tif (agentsPushedAway) return;\n'
76 + '\t\tif (localStorage.getItem(\'daimond-agents-revealed\') === \'1\') return;\n'
77 + '\t\tlocalStorage.setItem(\'daimond-agents-revealed\', \'1\');'),
78 },
79 noveto: {
80 what: 'a panel the user has just closed is opened again by the next dispatch',
81 edit: (src) => src.replace(
82 '\tfunction revealAgents() {\n\t\tif (agentsPushedAway) return;',
83 '\tfunction revealAgents() {'),
84 },
85 sharefold: {
86 what: 'a fold does not ask whether its target Diamond is already mid-turn',
87 edit: (src) => src.replace(
88 '\t\tif (diamondBusy(diamondId)) {\n'
89 + '\t\t\tnoticeDialog(t(\'fold.busy_title\'), t(\'fold.busy_body\', { diamond: f.name }));\n'
90 + '\t\t\treturn;\n'
91 + '\t\t}\n'
92 + '\t\tawait selectDiamond(f); // switch the centre to the Diamond crystal',
93 '\t\tawait selectDiamond(f); // switch the centre to the Diamond crystal'),
94 },
95};
96if (BREAK && !BREAKS[BREAK]) die(`no break called "${BREAK}"`);
97if (BREAK) console.log(`\n*** BREAK ${BREAK}: ${BREAKS[BREAK].what} — failures below are the point ***\n`);
98
99const SRC = fs.readFileSync(path.join(WWW, 'js/daimond.js'), 'utf8');
100const BROKEN = BREAKS[BREAK] ? BREAKS[BREAK].edit(SRC) : SRC;
101if (BREAK && BROKEN === SRC) die(`the "${BREAK}" break no longer matches www/js/daimond.js`);
102
103const s = await open({
104 name: 'daimonconc' + (BREAK ? '-' + BREAK : ''),
105 route: BREAK ? (async (page) => {
106 await page.route('**/js/daimond.js', (r) => r.fulfill({
107 status: 200, contentType: 'application/javascript', body: BROKEN,
108 }));
109 }) : null,
110});
111const p = s.page;
112await p.waitForFunction(() => !!window.DaimondDiamond && !!window.DaimondPanels, null, { timeout: 20000 });
113
114const [A, B] = await p.evaluate(() =>
115 [...document.querySelectorAll('#diamond-list .diamond-box')].map(b => b.dataset.id));
116if (!A || !B) die('this world does not hold the two seeded Diamonds');
117// THIS WORLD SEEDS A PAUSED DIAMOND, and a paused one dispatches nothing at all:
118// `governorClearsDispatch` refuses at `root/diamonds/<id>/self` before
119// `Workers.dispatch` is reached, so §5 would be measuring the pause tree and
120// calling it the Agents panel. Both are set playing here, in the app's own words.
121await p.evaluate((ids) => {
122 if (!window.DaimondPause) return;
123 ids.forEach((i) => DaimondPause.set(DaimondPause.id('root', 'diamonds', i, 'self'), true));
124 DaimondPause.set(DaimondPause.id('root', 'workers'), true);
125}, [A, B]);
126await p.waitForTimeout(300);
127
128// The name as the FOLD MENU spells it, which is `f.name` — asked of the app
129// rather than scraped off a tile, whose text also carries a meter and a badge.
130
131const goDiamond = async (id) => {
132 await p.evaluate((i) => {
133 document.querySelector(`#diamond-list .diamond-box[data-id="${i}"]`).click();
134 }, id);
135 await p.waitForTimeout(500);
136};
137const say = (text) => p.evaluate((t) => {
138 const el = document.getElementById('chat-input');
139 el.value = t;
140 el.dispatchEvent(new Event('input', { bubbles: true }));
141 document.getElementById('chat-send').click();
142}, text);
143/// What a daimon's own record says about itself. Asked of the CONVERSATION rather
144/// than of the screen: a turn that is running is running whether or not anybody is
145/// looking at it, and that distinction is the whole subject of this file.
146const state = (id) => p.evaluate((i) => {
147 const c = window.DaimondDiamond.conversation(i);
148 return {
149 gen: !!c._generating,
150 queue: (c._queue || []).slice(),
151 text: (c.messages || []).map(m => String(m.content || '')).join(' | '),
152 };
153}, id);
154/// Wait until a daimon is idle AND holding nothing: a queue that drains starts a
155/// turn of its own, so "not generating" alone is a moment, not a state.
156const settle = async (id, ms = 60000) => {
157 const t0 = Date.now();
158 for (;;) {
159 const st = await state(id);
160 if (!st.gen && st.queue.length === 0) {
161 await p.waitForTimeout(400);
162 const again = await state(id);
163 if (!again.gen && again.queue.length === 0) return again;
164 }
165 if (Date.now() - t0 > ms) return st;
166 await p.waitForTimeout(250);
167 }
168};
169
170/// Wait for a predicate on a daimon's record, or give up.
171const until = async (id, f, ms = 30000) => {
172 const t0 = Date.now();
173 for (;;) {
174 const st = await state(id);
175 if (f(st)) return st;
176 if (Date.now() - t0 > ms) return st;
177 await p.waitForTimeout(250);
178 }
179};
180
181// ── 1. TWO DAIMONS RUN AT ONCE ─────────────────────────────────────
182//
183// The overlap is the claim, so it is measured as an overlap: B's own record must
184// say it is generating AT A MOMENT WHEN A's still does. A check that only waited
185// for B's answer would pass on a queue that drained a minute later.
186console.log('\n1. a second daimon starts while the first is still running\n');
187await goDiamond(A);
188const AName = await p.evaluate(() => (window.DaimondDiamond.current() || {}).name || '');
189if (!AName) die('the app will not name the Diamond that is open');
190// A label after the number is legal again as of 2026-08-28: `numArg` in
191// dev/mockllm.mjs reads the FIRST token and refuses anything it cannot parse, where
192// `Number(d.rest)` used to read the whole line, make NaN of `@slow 9000 A-ANSWER`
193// and fall back to its 2-second default. That was long enough to pass §1 by luck and
194// far too short for §4, where it cost an afternoon. Nothing here needs the label --
195// A's turn is told apart by its own user message -- so this stays as it is, and the
196// paragraph stays as the record of why it was written.
197await say('@slow 9000');
198await p.waitForTimeout(900);
199// THE CONTROL. Nothing below means anything unless A really was mid-turn.
200check('the control: daimon A is mid-turn before B is prompted',
201 (await state(A)).gen);
202
203await goDiamond(B);
204await say('@text B-ANSWER');
205// B's turn is a round trip through the mock, so it is awaited rather than assumed
206// -- but only for as long as A is still going, which is what makes it an overlap.
207let overlap = null;
208{
209 const t0 = Date.now();
210 while (Date.now() - t0 < 8000) {
211 const [a, b] = [await state(A), await state(B)];
212 if (a.gen && b.gen) { overlap = { a, b }; break; }
213 if (!a.gen) break; // A finished first: no overlap to catch
214 await p.waitForTimeout(120);
215 }
216}
217check('B\'s turn is in flight while A\'s still is — they overlap',
218 !!overlap, overlap ? 'both _generating' : JSON.stringify({ a: await state(A), b: await state(B) }));
219check('nothing was queued on B: the prompt went, it did not wait',
220 (await state(B)).queue.length === 0, JSON.stringify((await state(B)).queue));
221await shot(s, 'daimonconc-overlap');
222
223// ── 2. AND B'S ANSWER ARRIVES, IN B'S OWN THREAD ───────────────────
224//
225// The half a queue could never reach: before the fix B's message sat unsent even
226// after A finished, because the drain runs at the end of the turn it waited on and
227// that turn was A's. So this asserts an ANSWER, not merely an empty queue.
228console.log('\n2. B answers, in B\'s thread, and A\'s answer stays in A\'s\n');
229{
230 const b = await until(B, (st) => !st.gen && /B-ANSWER/.test(st.text));
231 check('B has its own answer', /B-ANSWER/.test(b.text), b.text.slice(0, 160));
232 const a = await until(A, (st) => !st.gen, 30000);
233 check('A finished too', !a.gen);
234 check('B\'s words are not in A\'s thread', !/B-ANSWER/.test(a.text), a.text.slice(0, 160));
235 check('A\'s words are not in B\'s thread',
236 !/@slow/.test((await state(B)).text), (await state(B)).text.slice(0, 160));
237}
238
239// ── 3. ONE TURN PER DIAMOND, WHICH IS THE PART THAT IS DELIBERATE ──
240console.log('\n3. a daimon still queues behind its OWN turn\n');
241await goDiamond(A);
242await say('@slow 6000');
243await p.waitForTimeout(900);
244check('the control: A is mid-turn again', (await state(A)).gen);
245await say('held one');
246await p.waitForTimeout(400);
247check('a second message to the SAME daimon is held, not sent',
248 (await state(A)).queue.length === 1, JSON.stringify((await state(A)).queue));
249check('and the composer was cleared, so the press did something visible',
250 (await p.evaluate(() => document.getElementById('chat-input').value)) === '');
251
252// ── 4. THE FOLD CONTROLS, AND WHOSE TURN THEY ANSWER FOR ───────────
253//
254// `setCrystalBusy` used to disable `#chat-fold-btn` for the whole app, so a
255// Diamond quietly steering in the background left the control that compacts a
256// CHAT'S OWN CONTEXT dead on a chat with nothing to do with it. It answers for
257// the conversation the composer is on now.
258//
259// The exclusion that is real -- one writer per crystal, since a fold and a steer
260// both rewrite `crystal.json` -- is kept, and is said in words where the target is
261// known: the picker off the rail tile.
262//
263// The chat turn run here beside a steering daimon is also the other half of note
264// 14, which names chats as well as daimons.
265console.log('\n4. the fold controls answer for the right conversation\n');
266{
267 await goDiamond(A);
268 await settle(A);
269 await say('@slow 9000');
270 await p.waitForTimeout(900);
271 check('the control: A is steering in the background', (await state(A)).gen);
272
273 // A chat of its own, given something to say so its tile carries controls.
274 // Through the harness, which presses the tile's Start: a chat left `pending`
275 // draws no controls at all, and the picker would then be missing for a reason
276 // that has nothing to do with this file.
277 await newChat(s);
278 await say('@text CHAT-ANSWER');
279 const answered = await (async () => {
280 const t0 = Date.now();
281 while (Date.now() - t0 < 20000) {
282 const got = await p.evaluate(() => (document.getElementById('chat-output') || {}).textContent || '');
283 if (/CHAT-ANSWER/.test(got)) return true;
284 await p.waitForTimeout(250);
285 }
286 return false;
287 })();
288 check('a chat answers while a daimon is mid-turn — chats and daimons are independent too',
289 answered);
290 check('and the daimon was still running when it did', (await state(A)).gen);
291
292 const fold = await p.evaluate(() => {
293 const b = document.getElementById('chat-fold-btn');
294 return b ? { shown: b.style.display !== 'none', disabled: !!b.disabled } : null;
295 });
296 check('the chat\'s own Fold control is offered', !!(fold && fold.shown), JSON.stringify(fold));
297 check('and it is NOT disabled by another Diamond\'s turn',
298 !!(fold && !fold.disabled), JSON.stringify(fold));
299
300 // ── Folding this chat INTO the busy Diamond ────────────────
301 //
302 // A FRESH steer, started here rather than relied on from above: everything
303 // between the two is a real round trip through the mock, and a check that
304 // depends on the first one still running is a check on how fast the machine is.
305 // Max, because Simple draws no tile controls at all, so the picker would be off
306 // screen for a reason that is not this one.
307 await settle(A);
308 const chatTile = await p.evaluate(() =>
309 (document.querySelector('#session-list .session-box.active') || {}).dataset?.id || '');
310 await goDiamond(A);
311 await say('@slow 25000');
312 await p.waitForTimeout(800);
313 check('the control: A is mid-turn when the fold is attempted', (await state(A)).gen);
314 const wasView = await p.evaluate(() => window.DaimondView && window.DaimondView.get
315 ? window.DaimondView.get() : '');
316 await p.evaluate(() => window.DaimondView && window.DaimondView.set('max'));
317 await p.waitForTimeout(400);
318 await p.evaluate((id) => {
319 const t = document.querySelector(`#session-list .session-box[data-id="${id}"] .tile-label`)
320 || document.querySelector(`#session-list .session-box[data-id="${id}"]`);
321 if (t) t.click();
322 }, chatTile);
323 await p.waitForTimeout(600);
324 await p.evaluate(() => {
325 const b = document.querySelector('#session-list .session-box.active .tile-fold')
326 || document.querySelector('#session-list .tile-fold');
327 if (b) b.click();
328 });
329 await p.waitForTimeout(400);
330 const picked = await p.evaluate((name) => {
331 const items = [...document.querySelectorAll('.fold-menu .fold-menu-item')];
332 const want = items.find(i => (i.textContent || '').trim() === name);
333 if (!want) return { ok: false, saw: items.map(i => (i.textContent || '').trim()) };
334 want.click();
335 return { ok: true };
336 }, AName);
337 if (!picked.ok) {
338 check('the fold menu offers the busy Diamond', false,
339 `wanted ${JSON.stringify(AName)}, saw ${JSON.stringify(picked.saw)}`);
340 } else {
341 await p.waitForTimeout(700);
342 const dlg = await p.evaluate(() => {
343 const d = document.querySelector('.modal.dlg');
344 return d ? (d.textContent || '') : '';
345 });
346 check('folding into a Diamond that is mid-turn answers with a notice, not a silent nothing',
347 dlg.length > 0, dlg.slice(0, 200));
348 check('and the notice names the Diamond it refused', dlg.includes(AName), dlg.slice(0, 200));
349 await p.evaluate(() => {
350 const d = document.querySelector('.modal.dlg');
351 const b = d && d.querySelector('button');
352 if (b) b.click();
353 });
354 await p.waitForTimeout(300);
355 }
356 await p.evaluate((v) => window.DaimondView && window.DaimondView.set(v || 'simple'), wasView);
357 await p.waitForTimeout(400);
358 // A 25-second steer is not worth waiting out, and Stop is the app's own door.
359 await goDiamond(A);
360 await p.evaluate(() => {
361 const b = document.getElementById('chat-send');
362 if (b && /stop/i.test(b.getAttribute('aria-label') || b.title || '')) b.click();
363 });
364 await settle(A);
365}
366
367
368// ── 5. THE AGENTS PANEL OPENS EVERY TIME WORK STARTS ───────────────
369//
370// Twice, with a close in between, because once-ever passes the first half.
371console.log('\n5. the Agents panel opens when a fan-out starts — every time\n');
372const agentsOpen = () => p.evaluate(() => window.DaimondPanels.isOpen('agents'));
373/// Wait for the panel to reach a state, and answer with what it actually did.
374/// A fixed sleep here would make the check a measure of machine load: a fan-out
375/// is a tool round trip through the mock and then a worker being enqueued.
376const panelSettles = async (want, ms = 20000) => {
377 const t0 = Date.now();
378 for (;;) {
379 const now = await agentsOpen();
380 if (now === want || Date.now() - t0 > ms) return now;
381 await p.waitForTimeout(200);
382 }
383};
384/// Is the dock carrying live work? `#agents-count` is written on every render
385/// whether or not the panel is seated, so this reads the same fact the panel does
386/// without needing it on screen.
387const workersLive = () => p.evaluate(() =>
388 ((document.getElementById('agents-count') || {}).textContent || '').trim().length > 0);
389const closeAgents = () => p.evaluate(() => {
390 // Through the CLOSER a person presses, not through the engine: the app closes
391 // panels itself for reasons of its own, and only a hand means "not this".
392 const b = document.querySelector('[data-close="agents"]');
393 if (b) b.click(); else window.DaimondPanels.hide('agents');
394});
395const fanOut = async (id, name) => {
396 await goDiamond(id);
397 await settle(id); // a Diamond still holding a queue would queue this too
398 // The task is what the WORKER is prompted with, and the mock reads its
399 // directives from that — so a slow worker is how a live batch becomes a state
400 // this file can stand in rather than a flicker it has to catch.
401 await say(`@tool spawn_agent {"name":"${name}","task":"@slow 5000"}`);
402};
403const dockQuiet = async (ms = 90000) => {
404 const t0 = Date.now();
405 while (Date.now() - t0 < ms) {
406 if (!(await p.evaluate(() => window.DaimondCore.busy()))) return true;
407 await p.waitForTimeout(300);
408 }
409 return false;
410};
411
412await p.evaluate(() => window.DaimondPanels.hide('agents'));
413await p.waitForTimeout(200);
414check('the control: the Agents panel is shut before the first fan-out',
415 (await agentsOpen()) === false);
416await fanOut(A, 'wk1');
417check('the first fan-out opens it', (await panelSettles(true)) === true);
418await shot(s, 'daimonconc-agents-1');
419
420// Wait for the dock to fall quiet, then shut the panel while nothing is running:
421// a tidy-up between batches is not a veto on the next batch.
422await dockQuiet();
423await closeAgents();
424await p.waitForTimeout(300);
425check('the control: it is shut again, by hand, with nothing running',
426 (await agentsOpen()) === false);
427await fanOut(B, 'wk2');
428check('THE SECOND fan-out opens it too — this is note 10',
429 (await panelSettles(true)) === true);
430await shot(s, 'daimonconc-agents-2');
431
432// ── 6. AND IT DOES NOT ARGUE ───────────────────────────────────────
433//
434// Closed while workers are running, it stays closed until they are done. Without
435// this the fix above becomes a panel that reappears every time it is dismissed.
436console.log('\n6. a panel pushed away while work runs is left alone\n');
437{
438 await dockQuiet();
439 await fanOut(A, 'wk3');
440 // Closed WHILE the batch is live, which is what makes it a refusal rather than
441 // a tidy-up. Waited for on the dock's own count, not on a clock.
442 let live = false;
443 {
444 const t0 = Date.now();
445 while (Date.now() - t0 < 30000) {
446 if (await workersLive()) { live = true; break; }
447 await p.waitForTimeout(150);
448 }
449 }
450 check('the control: workers really were running when the panel was closed', live);
451 await closeAgents();
452 await p.waitForTimeout(200);
453 await fanOut(B, 'wk4');
454 check('a panel pushed away mid-batch is not reopened under the user',
455 (await panelSettles(true, 6000)) === false);
456 // And the refusal expires with the batch: it was about that work, not for ever.
457 await dockQuiet();
458 await p.waitForTimeout(600);
459 await fanOut(A, 'wk5');
460 check('and once the dock is quiet again, the next fan-out opens it',
461 (await panelSettles(true)) === true);
462}
463
464const errs = errors(s).filter(e => !/favicon|401|402|502|Unauthorized|Payment|Bad Gateway/i.test(e));
465check('no console errors', errs.length === 0, errs.slice(0, 3).join(' | '));
466
467await s.close();
468
469console.log(`\n${ok.length} ok, ${bad.length} failed`);
470if (bad.length) console.log(' ' + bad.join('\n '));
471if (BREAK) {
472 console.log(bad.length
473 ? '\nTHE BREAK WAS CAUGHT.'
474 : '\nTHE BREAK WAS NOT CAUGHT: this check proves nothing');
475 process.exit(bad.length ? 0 : 1);
476}
477process.exit(bad.length ? 1 : 0);