oxedyne/daimond/dev/verify_gather.mjs
14.1 KiB, 1 run
created by r2519314175:431, 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 | // A worker's report reaches the daimon that dispatched it. |
| 2 | // |
| 3 | // Fan-out existed and gather did not. `spawn_agent` dispatched a worker, the |
| 4 | // worker ran, and its text landed on a tile in the Agents panel -- where the only |
| 5 | // route onward was a person pressing "Fold in", which runs the reducer to propose |
| 6 | // a new crystal. That is the CRYSTAL's path, not the conductor's read. So the |
| 7 | // daimon could not read what its own workers found, could not compare two of |
| 8 | // them, could not notice that one contradicted another, and could not iterate. |
| 9 | // |
| 10 | // What is asserted here is measured FROM THE WIRE, not from the tiles: after the |
| 11 | // batch finishes, a further request reaches the model carrying both workers' |
| 12 | // reports. A tile that shows the text proves nothing about what the daimon saw. |
| 13 | // |
| 14 | // node dev/verify_gather.mjs |
| 15 | // node dev/verify_gather.mjs --break nogather # must fail something |
| 16 | import { open, shot, mockLog, clearMockLog } from './harness.mjs'; |
| 17 | |
| 18 | const BREAK = (() => { |
| 19 | const i = process.argv.indexOf('--break'); |
| 20 | return (i >= 0 && process.argv[i + 1]) ? process.argv[i + 1] : ''; |
| 21 | })(); |
| 22 | const BREAKS = ['nogather', 'nodispatch', 'nonotice']; |
| 23 | if (BREAK && !BREAKS.includes(BREAK)) { |
| 24 | console.error(`unknown break '${BREAK}'; one of: ${BREAKS.join(', ')}`); |
| 25 | process.exit(2); |
| 26 | } |
| 27 | |
| 28 | const ok = [], bad = []; |
| 29 | const check = (name, pass, detail) => { |
| 30 | (pass ? ok : bad).push(name); |
| 31 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 32 | }; |
| 33 | |
| 34 | const s = await open({ name: 'gather' + (BREAK ? '-' + BREAK : '') }); |
| 35 | const { page } = s; |
| 36 | |
| 37 | await page.click('#new-diamond-btn'); |
| 38 | await page.waitForSelector('.dlg-input', { timeout: 8000 }); |
| 39 | await page.fill('.dlg-input', 'Gather Test'); |
| 40 | await page.click('.dlg-ok'); |
| 41 | await page.waitForTimeout(1200); |
| 42 | |
| 43 | // The break: the depth limit at zero, so no gather round may follow. That is |
| 44 | // exactly the behaviour before this feature -- the workers still run, the tiles |
| 45 | // still fill, "Fold in" still works. The old behaviour was not broken, it was |
| 46 | // incomplete, and a check that cannot tell those apart is worthless. |
| 47 | if (BREAK === 'nogather') { |
| 48 | const applied = await page.evaluate(() => { |
| 49 | if (!window.DaimondWorkers) return false; |
| 50 | window.DaimondWorkers.MAX_GATHER_DEPTH = 0; |
| 51 | return true; |
| 52 | }); |
| 53 | if (!applied) { |
| 54 | console.error("break 'nogather': no handle on the worker pump, so nothing was broken."); |
| 55 | process.exit(2); |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // The other break: the pump itself. `spawn_agent` still answers, the turn still |
| 60 | // ends, and NOTHING is started -- which is exactly the shape of the defect the |
| 61 | // checks below were written for, measured 2026-08-24. A model was told |
| 62 | // "Dispatched agent 'X'. It runs in its own context and reports back a summary", |
| 63 | // waited for a result, and spent the rest of a turn on it; the relay log for that |
| 64 | // run held twelve requests, all one growing chain, and not one worker request. |
| 65 | if (BREAK === 'nodispatch') { |
| 66 | const applied = await page.evaluate(() => { |
| 67 | if (!window.DaimondWorkers || !window.DaimondWorkers.dispatch) return false; |
| 68 | window.DaimondWorkers.dispatch = function () {}; |
| 69 | return true; |
| 70 | }); |
| 71 | if (!applied) { |
| 72 | console.error("break 'nodispatch': no handle on the worker pump, so nothing was broken."); |
| 73 | process.exit(2); |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // The third break: the app keeps its own counsel. `Workers.tellDaimon` is the one |
| 78 | // door through which the app's word about a fan-out reaches the model, so stubbing it |
| 79 | // restores exactly the behaviour measured on 2026-08-25 -- a status line on the |
| 80 | // crystal, and a daimon that never learns its workers did not run. |
| 81 | if (BREAK === 'nonotice') { |
| 82 | const applied = await page.evaluate(() => { |
| 83 | if (!window.DaimondWorkers || !window.DaimondWorkers.tellDaimon) return false; |
| 84 | window.DaimondWorkers.tellDaimon = function () { return false; }; |
| 85 | return true; |
| 86 | }); |
| 87 | if (!applied) { |
| 88 | console.error("break 'nonotice': no handle on the notice, so nothing was broken."); |
| 89 | process.exit(2); |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | clearMockLog(); |
| 94 | |
| 95 | // Two workers in one turn, each answering with something the other does not say, |
| 96 | // so a round that carries both can be told from a round that carries one. |
| 97 | await page.fill('#chat-input', |
| 98 | '@tools spawn_agent {"name":"alpha","task":"say ALPHAFOUND"} ' |
| 99 | + ';; spawn_agent {"name":"beta","task":"say BETAFOUND"}'); |
| 100 | await page.keyboard.press('Enter'); |
| 101 | await page.waitForTimeout(2000); |
| 102 | |
| 103 | // Let both workers run to a terminal state, then let the gather round go out. |
| 104 | await page.waitForTimeout(9000); |
| 105 | await shot(s, 'gather-1-dispatched'); |
| 106 | |
| 107 | const runs = await page.evaluate(() => |
| 108 | [...document.querySelectorAll('#panel-agents .acard')].length); |
| 109 | |
| 110 | const wire = mockLog(); |
| 111 | // The dispatching turn is the first request; a gather round is a LATER one whose |
| 112 | // prompt carries both workers' names. Searching every message of every request, |
| 113 | // because the instruction is a user message on the daimon's own conversation. |
| 114 | const carries = (m, needle) => (m.messages || []) |
| 115 | .some(x => typeof x.content === 'string' && x.content.includes(needle)); |
| 116 | const gatherReqs = wire.filter(m => carries(m, 'alpha') && carries(m, 'beta') |
| 117 | && carries(m, 'finished')); |
| 118 | |
| 119 | check('the dispatch produced two agent tiles (nothing below is true of an empty pane)', |
| 120 | runs >= 2, `${runs} tile(s)`); |
| 121 | check('a further request reached the model after the batch finished', |
| 122 | gatherReqs.length >= 1, `${gatherReqs.length} of ${wire.length} request(s) carry both names`); |
| 123 | check('and it carried BOTH workers\' reports, not one', |
| 124 | gatherReqs.some(m => carries(m, 'alpha') && carries(m, 'beta')), |
| 125 | gatherReqs.length ? 'yes' : 'no such request'); |
| 126 | check('the round says the workers finished, so the daimon knows why it is being asked', |
| 127 | gatherReqs.some(m => carries(m, 'finished'))); |
| 128 | |
| 129 | // ── What `spawn_agent` claimed, held against what the relay actually saw ───── |
| 130 | // |
| 131 | // `spawn_agent` returns a sentence and starts nothing: the page collects the calls |
| 132 | // and starts the workers after the turn ends. So the sentence is a promise about a |
| 133 | // request that does not exist yet, and the only honest test of it is the wire. |
| 134 | // Asked here rather than in a Rust test because a Rust test can only read the |
| 135 | // sentence; this can read whether it came true. |
| 136 | const said = (needle) => wire.some((m) => JSON.stringify(m.messages || []).includes(needle)); |
| 137 | // A worker's own request carries its task and never the tool that asked for it: |
| 138 | // a worker cannot see the conversation that sent it, so `spawn_agent` is absent |
| 139 | // from its messages while the dispatching chain carries it in every round. |
| 140 | const workerReqs = wire.filter((m) => { |
| 141 | const j = JSON.stringify(m.messages || []); |
| 142 | return j.includes('ALPHAFOUND') && !j.includes('spawn_agent'); |
| 143 | }); |
| 144 | check('a worker request of its own reached the relay, so the dispatch was real', |
| 145 | workerReqs.length >= 1, `${workerReqs.length} of ${wire.length} request(s)`); |
| 146 | check('and the daimon was never told a worker had started, because none had', |
| 147 | !said('Dispatched agent'), |
| 148 | said('Dispatched agent') ? 'a tool result claimed a dispatch' : 'no such claim'); |
| 149 | check('it was told WHEN the worker starts, so it does not spend the turn waiting', |
| 150 | said('begins when the turn ends')); |
| 151 | |
| 152 | await shot(s, 'gather-2-reported'); |
| 153 | |
| 154 | // ── A fan-out the app decides NOT to start ────────────────────────────────── |
| 155 | // |
| 156 | // The other half of the same promise, and the one that was missing. `spawn_agent` |
| 157 | // tells the daimon its workers begin when the turn ends and that their reports come |
| 158 | // back as a later turn, and the daimon then stops, which is what it was told to do. |
| 159 | // Everything that can decide otherwise decides it AFTER the turn has ended -- the |
| 160 | // spend gate declining, a paused node refusing, a turn that died before it stopped -- |
| 161 | // and every one of those used to write a line of grey status text on the crystal and |
| 162 | // return. A status line is not addressed to the model. |
| 163 | // |
| 164 | // Measured 2026-08-25 as AA-4 in dev/HATES.md: a daimon dispatched eight workers, |
| 165 | // said "I'll wait for the workers to complete", ended its turn, and 25 tool calls |
| 166 | // bought nothing. It never learnt otherwise, and it could not have. |
| 167 | // |
| 168 | // The gate is tripped here rather than mocked: the burst is charged past its budget |
| 169 | // through the governor's own `observe`, exactly as a long turn charges it, so what is |
| 170 | // under test is the shipped rule and not a stub of it. The arithmetic is the |
| 171 | // product's -- with no learned baseline a worker is priced at $0.08 and the burst |
| 172 | // budget is $1.00, so eight workers need only $0.36 already spent to trip it, which |
| 173 | // a 25-call turn passes on its own. |
| 174 | const armed = await page.evaluate(() => { |
| 175 | if (!window.DaimondGovernor || !window.DaimondGovernor.observe) return false; |
| 176 | window.DaimondGovernor.observe({ t: Date.now(), u: 9 }); |
| 177 | return true; |
| 178 | }); |
| 179 | check('the burst is over budget, so the next fan-out must be asked about', |
| 180 | armed, armed ? 'charged $9' : 'no governor to charge'); |
| 181 | |
| 182 | clearMockLog(); |
| 183 | await page.fill('#chat-input', |
| 184 | '@tools spawn_agent {"name":"gamma","task":"say GAMMAFOUND"} ' |
| 185 | + ';; spawn_agent {"name":"delta","task":"say DELTAFOUND"}'); |
| 186 | await page.keyboard.press('Enter'); |
| 187 | // The gate is asked AFTER the turn ends, so the dialog appears once the daimon has |
| 188 | // already stopped. That is the whole shape of the defect. |
| 189 | await page.waitForSelector('.dlg-card .dlg-cancel', { timeout: 20000 }); |
| 190 | await shot(s, 'gather-3-gate'); |
| 191 | await page.click('.dlg-card .dlg-cancel'); |
| 192 | await page.waitForTimeout(3000); |
| 193 | |
| 194 | const wire2 = mockLog(); |
| 195 | const ranGamma = wire2.some((m) => { |
| 196 | const j = JSON.stringify(m.messages || []); |
| 197 | return j.includes('GAMMAFOUND') && !j.includes('spawn_agent'); |
| 198 | }); |
| 199 | check('the declined fan-out started no worker, so nothing was spent on it', |
| 200 | !ranGamma, ranGamma ? 'a worker request reached the relay anyway' : 'no worker request'); |
| 201 | |
| 202 | // THE PROPERTY. The next thing the daimon is sent must contradict what `spawn_agent` |
| 203 | // told it, or it goes on believing two workers are out there. Read off the wire and |
| 204 | // not out of the page: the daimon's conversation is reachable only from inside |
| 205 | // daimond.js, and the relay sees exactly what the model sees. |
| 206 | clearMockLog(); |
| 207 | await page.fill('#chat-input', 'carry on'); |
| 208 | await page.keyboard.press('Enter'); |
| 209 | await page.waitForTimeout(6000); |
| 210 | const wire3 = mockLog(); |
| 211 | const told = (needle) => wire3.some((m) => |
| 212 | JSON.stringify(m.messages || []).includes(needle)); |
| 213 | check('the daimon\'s next turn carries the app\'s word that they were NOT started', |
| 214 | told('WERE NOT STARTED'), |
| 215 | `${wire3.length} request(s) since`); |
| 216 | check('and it says the reason, so the daimon is not left guessing at silence', |
| 217 | told('told no')); |
| 218 | check('and that nothing was spent, so it does not report a cost it never paid', |
| 219 | told('nothing was spent')); |
| 220 | check('and that there is nothing to wait for, which is what ends the lost turn', |
| 221 | told('nothing to wait for')); |
| 222 | await shot(s, 'gather-4-told'); |
| 223 | |
| 224 | // ── A batch that finished, and a round that was not run ───────────────────── |
| 225 | // |
| 226 | // The other silent drop, and the one the chat path was already fixed for. `gather` |
| 227 | // has four guards that are each a good reason not to SPEND a round -- the pump held, |
| 228 | // the depth cap reached, the user looking at another Diamond, no model to run on -- |
| 229 | // and each of them used to `return` with the reports left on their tiles and the |
| 230 | // daimon still waiting for a report that would now never come. The reports are not |
| 231 | // lost and never were; what was lost is the daimon's ability to read them. Driven |
| 232 | // through the depth cap because that one is a number this can set. |
| 233 | const reset = await page.evaluate(() => { |
| 234 | if (!window.DaimondGovernor || !window.DaimondWorkers) return false; |
| 235 | window.DaimondGovernor.reset(); // the burst above must not gate this fan-out |
| 236 | window.DaimondWorkers.MAX_GATHER_DEPTH = 0; |
| 237 | return true; |
| 238 | }); |
| 239 | check('the depth cap is at zero, so no round may follow this batch', reset); |
| 240 | |
| 241 | clearMockLog(); |
| 242 | await page.fill('#chat-input', |
| 243 | '@tools spawn_agent {"name":"epsilon","task":"say EPSILONFOUND"}'); |
| 244 | await page.keyboard.press('Enter'); |
| 245 | await page.waitForTimeout(11000); |
| 246 | const wire4 = mockLog(); |
| 247 | check('the worker ran, so there is a report for the daimon to be denied', |
| 248 | wire4.some((m) => { |
| 249 | const j = JSON.stringify(m.messages || []); |
| 250 | return j.includes('EPSILONFOUND') && !j.includes('spawn_agent'); |
| 251 | }), `${wire4.length} request(s)`); |
| 252 | // A ROUND IS THE LAST MESSAGE OF A REQUEST, never a phrase anywhere in it: the |
| 253 | // daimon's conversation is cumulative, so scenario 1's gather instruction is still in |
| 254 | // the history of every request from here on and a substring search says "a round ran" |
| 255 | // for ever after the first one. |
| 256 | const isRound = (m) => { |
| 257 | const msgs = m.messages || []; |
| 258 | const last = msgs.length ? msgs[msgs.length - 1] : null; |
| 259 | return !!(last && last.role === 'user' && typeof last.content === 'string' |
| 260 | && last.content.includes('finished. Their reports follow')); |
| 261 | }; |
| 262 | check('and no round followed it, which is what the depth cap is for', |
| 263 | !wire4.some(isRound), `${wire4.filter(isRound).length} round(s)`); |
| 264 | |
| 265 | clearMockLog(); |
| 266 | await page.fill('#chat-input', 'what did it find'); |
| 267 | await page.keyboard.press('Enter'); |
| 268 | await page.waitForTimeout(6000); |
| 269 | const wire5 = mockLog(); |
| 270 | // `### epsilon` AND NOT MERELY `EPSILONFOUND`. The token the worker was asked to say |
| 271 | // is in the daimon's own `spawn_agent` arguments, which are in its history from the |
| 272 | // moment it made the call, so a search for it alone passes with the whole delivery |
| 273 | // removed -- checked, by stubbing the notice and watching it pass. The `###` heading |
| 274 | // is written by `gather` when it composes the reports and appears nowhere else. |
| 275 | check('the daimon\'s next turn carries the report anyway, unspent and unlost', |
| 276 | wire5.some((m) => { |
| 277 | const j = JSON.stringify(m.messages || []); |
| 278 | return j.includes('### epsilon') && j.includes('EPSILONFOUND'); |
| 279 | }), `${wire5.length} request(s) since`); |
| 280 | check('and says the round did not run, so a silence is not read as a result', |
| 281 | wire5.some((m) => JSON.stringify(m.messages || []).includes('no round was run'))); |
| 282 | await shot(s, 'gather-5-undelivered'); |
| 283 | |
| 284 | await s.close(); |
| 285 | |
| 286 | if (BREAK) { |
| 287 | console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed` |
| 288 | + (bad.length ? '' : ' — NOTHING FAILED, so the checks above prove nothing')); |
| 289 | process.exit(bad.length ? 0 : 1); |
| 290 | } |
| 291 | console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`); |
| 292 | process.exit(bad.length === 0 ? 0 : 1); |