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 |
| 45 | import fs from 'node:fs'; |
| 46 | import path from 'node:path'; |
| 47 | import { fileURLToPath } from 'node:url'; |
| 48 | import { open, newChat, shot, errors } from './harness.mjs'; |
| 49 | |
| 50 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 51 | const WWW = path.join(HERE, '..', 'www'); |
| 52 | |
| 53 | const ok = [], bad = []; |
| 54 | const check = (name, pass, detail) => { |
| 55 | (pass ? ok : bad).push(name); |
| 56 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 57 | }; |
| 58 | const die = (why) => { console.error('ABORT: ' + why); process.exit(2); }; |
| 59 | |
| 60 | const BREAK = (() => { |
| 61 | const i = process.argv.indexOf('--break'); |
| 62 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 63 | })(); |
| 64 | const 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 | }; |
| 96 | if (BREAK && !BREAKS[BREAK]) die(`no break called "${BREAK}"`); |
| 97 | if (BREAK) console.log(`\n*** BREAK ${BREAK}: ${BREAKS[BREAK].what} — failures below are the point ***\n`); |
| 98 | |
| 99 | const SRC = fs.readFileSync(path.join(WWW, 'js/daimond.js'), 'utf8'); |
| 100 | const BROKEN = BREAKS[BREAK] ? BREAKS[BREAK].edit(SRC) : SRC; |
| 101 | if (BREAK && BROKEN === SRC) die(`the "${BREAK}" break no longer matches www/js/daimond.js`); |
| 102 | |
| 103 | const 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 | }); |
| 111 | const p = s.page; |
| 112 | await p.waitForFunction(() => !!window.DaimondDiamond && !!window.DaimondPanels, null, { timeout: 20000 }); |
| 113 | |
| 114 | const [A, B] = await p.evaluate(() => |
| 115 | [...document.querySelectorAll('#diamond-list .diamond-box')].map(b => b.dataset.id)); |
| 116 | if (!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. |
| 121 | await 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]); |
| 126 | await 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 | |
| 131 | const 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 | }; |
| 137 | const 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. |
| 146 | const 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. |
| 156 | const 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. |
| 171 | const 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. |
| 186 | console.log('\n1. a second daimon starts while the first is still running\n'); |
| 187 | await goDiamond(A); |
| 188 | const AName = await p.evaluate(() => (window.DaimondDiamond.current() || {}).name || ''); |
| 189 | if (!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. |
| 197 | await say('@slow 9000'); |
| 198 | await p.waitForTimeout(900); |
| 199 | // THE CONTROL. Nothing below means anything unless A really was mid-turn. |
| 200 | check('the control: daimon A is mid-turn before B is prompted', |
| 201 | (await state(A)).gen); |
| 202 | |
| 203 | await goDiamond(B); |
| 204 | await 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. |
| 207 | let 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 | } |
| 217 | check('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) })); |
| 219 | check('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)); |
| 221 | await 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. |
| 228 | console.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 ── |
| 240 | console.log('\n3. a daimon still queues behind its OWN turn\n'); |
| 241 | await goDiamond(A); |
| 242 | await say('@slow 6000'); |
| 243 | await p.waitForTimeout(900); |
| 244 | check('the control: A is mid-turn again', (await state(A)).gen); |
| 245 | await say('held one'); |
| 246 | await p.waitForTimeout(400); |
| 247 | check('a second message to the SAME daimon is held, not sent', |
| 248 | (await state(A)).queue.length === 1, JSON.stringify((await state(A)).queue)); |
| 249 | check('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. |
| 265 | console.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. |
| 371 | console.log('\n5. the Agents panel opens when a fan-out starts — every time\n'); |
| 372 | const 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. |
| 376 | const 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. |
| 387 | const workersLive = () => p.evaluate(() => |
| 388 | ((document.getElementById('agents-count') || {}).textContent || '').trim().length > 0); |
| 389 | const 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 | }); |
| 395 | const 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 | }; |
| 403 | const 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 | |
| 412 | await p.evaluate(() => window.DaimondPanels.hide('agents')); |
| 413 | await p.waitForTimeout(200); |
| 414 | check('the control: the Agents panel is shut before the first fan-out', |
| 415 | (await agentsOpen()) === false); |
| 416 | await fanOut(A, 'wk1'); |
| 417 | check('the first fan-out opens it', (await panelSettles(true)) === true); |
| 418 | await 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. |
| 422 | await dockQuiet(); |
| 423 | await closeAgents(); |
| 424 | await p.waitForTimeout(300); |
| 425 | check('the control: it is shut again, by hand, with nothing running', |
| 426 | (await agentsOpen()) === false); |
| 427 | await fanOut(B, 'wk2'); |
| 428 | check('THE SECOND fan-out opens it too — this is note 10', |
| 429 | (await panelSettles(true)) === true); |
| 430 | await 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. |
| 436 | console.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 | |
| 464 | const errs = errors(s).filter(e => !/favicon|401|402|502|Unauthorized|Payment|Bad Gateway/i.test(e)); |
| 465 | check('no console errors', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 466 | |
| 467 | await s.close(); |
| 468 | |
| 469 | console.log(`\n${ok.length} ok, ${bad.length} failed`); |
| 470 | if (bad.length) console.log(' ' + bad.join('\n ')); |
| 471 | if (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 | } |
| 477 | process.exit(bad.length ? 1 : 0); |