oxedyne/daimond/dev/verify_workerdrop.mjs
10.8 KiB, 1 run
created by r2519314175:819, 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_workerdrop.mjs — an interrupted worker gets the Continue the chat already has. |
| 2 | // |
| 3 | // THE ASYMMETRY. A chat turn cut off by a dead road or a dying tab comes back badged, with a |
| 4 | // Continue button, and pressing it carries the answer on (dev/verify_dropped.mjs). A WORKER cut |
| 5 | // off the same way came back with the word `interrupted` on its tile and nothing to press. |
| 6 | // `Workers.load` rewrites any `running` or `queued` run to `interrupted` on the next boot and |
| 7 | // keeps its partial text, and `Workers.resume` already knows how to start a fresh session |
| 8 | // seeded with a run's own transcript plus a "carry on" line -- it simply refused any status but |
| 9 | // `paused`. Two states that differ only in WHO hung the worker up, one of them a dead end. |
| 10 | // |
| 11 | // WHY IT IS NOT THE SAME AS A BATCH. Batches are deliberately not persisted: "a tab that died |
| 12 | // mid-fan-out comes back with its agents marked `interrupted` -- there is no round to resume, |
| 13 | // and inventing one on reload would re-spend on a turn the user never saw" (www/js/daimond.js). |
| 14 | // That argument is about the GATHER -- the round that hands the workers' reports back to the |
| 15 | // daimon that dispatched them -- and it still stands: nothing here revives a batch. What it |
| 16 | // says nothing about is one worker, one task, and a person deciding to finish it. This file is |
| 17 | // about the person. |
| 18 | // |
| 19 | // FOUR PROPERTIES: |
| 20 | // |
| 21 | // 1. AN INTERRUPTED WORKER IS OFFERED A CONTINUE. Where there was a status word and no |
| 22 | // action, there is now the same play button a paused worker has. |
| 23 | // 2. AND PRESSING IT CARRIES THE WORK ON rather than starting it again: `resume` is set, |
| 24 | // which is what makes `start` seed the new session with the task AND the text so far and |
| 25 | // send `RESUME_NUDGE` instead of the task. |
| 26 | // 3. UNLESS THERE IS NOTHING TO CARRY ON FROM. A worker that produced no text is started |
| 27 | // over -- "do not repeat work already done above" is an instruction about nothing when |
| 28 | // there is nothing above, and a model asked to continue an empty answer invents one. |
| 29 | // 4. AND A FINISHED WORKER IS NOT OFFERED ONE. The check that makes the others mean |
| 30 | // something: a Continue on a run that is done would spend money to redo finished work. |
| 31 | // |
| 32 | // PROVED AGAINST BROKEN CODE FIRST: |
| 33 | // |
| 34 | // node dev/verify_workerdrop.mjs --break notile # 1-3 fail: the tile offers nothing |
| 35 | // node dev/verify_workerdrop.mjs --break norestart # 3 fails: an empty worker is "continued" |
| 36 | // node dev/verify_workerdrop.mjs # and then, clean |
| 37 | // |
| 38 | // `--break notile` is the tile exactly as it stood on 2026-08-27. |
| 39 | // |
| 40 | // THE RUNS ARE SEEDED, not produced by a real fan-out. What is under test is the tile and |
| 41 | // `resume`, and a real dispatch would put a Diamond, a governor gate and a mint between this |
| 42 | // file and the two lines it is about. `dev/verify_panelfacts.mjs` seeds the same way and for |
| 43 | // the same reason. |
| 44 | // |
| 45 | // eval "$(bash dev/world.sh 17 --up)" |
| 46 | // node dev/verify_workerdrop.mjs |
| 47 | // |
| 48 | // Needs dev/serve.mjs. No gateway, no model: nothing here sends a turn. |
| 49 | import fs from 'node:fs'; |
| 50 | import path from 'node:path'; |
| 51 | import { fileURLToPath } from 'node:url'; |
| 52 | import { open, newChat, scratch, shot } from './harness.mjs'; |
| 53 | |
| 54 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 55 | const WWW = path.join(HERE, '..', 'www'); |
| 56 | |
| 57 | const BREAK = (() => { |
| 58 | const i = process.argv.indexOf('--break'); |
| 59 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 60 | })(); |
| 61 | |
| 62 | const BREAKS = { |
| 63 | // The tile before the fix: only a paused run is offered anything. |
| 64 | notile: [ |
| 65 | "\t\t\t} else if (run.status === 'paused' || run.status === 'interrupted') {\n", |
| 66 | "\t\t\t} else if (run.status === 'paused') {\n", |
| 67 | ], |
| 68 | // The plausible over-correction: continue everything, including a run with no text |
| 69 | // to continue from. |
| 70 | norestart: [ |
| 71 | "\t\t\trun.resume = !!(run.text && run.text.trim());\n", |
| 72 | "\t\t\trun.resume = true;\n", |
| 73 | ], |
| 74 | }; |
| 75 | if (BREAK && !BREAKS[BREAK]) { |
| 76 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 77 | process.exit(2); |
| 78 | } |
| 79 | |
| 80 | let bad = 0; |
| 81 | const check = (pass, name, detail) => { |
| 82 | if (!pass) bad++; |
| 83 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 84 | }; |
| 85 | |
| 86 | const SRC = fs.readFileSync(path.join(WWW, 'js/daimond.js'), 'utf8'); |
| 87 | for (const [k, [from]] of Object.entries(BREAKS)) { |
| 88 | if (SRC.split(from).length !== 2) { |
| 89 | console.error(`the line the '${k}' break patches is not in js/daimond.js exactly once; ` |
| 90 | + 'the anchor has moved and the break would patch nothing'); |
| 91 | process.exit(2); |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | const s = await open({ |
| 96 | name: 'workerdrop', |
| 97 | profile: scratch('pw', 'workerdrop' + (BREAK ? '-' + BREAK : '')), |
| 98 | // The mock IS connected: check 2 lets the resumed worker actually run, and what it |
| 99 | // answers is the evidence that the transcript went with it. |
| 100 | route: BREAK ? (async (page) => { |
| 101 | const [from, to] = BREAKS[BREAK]; |
| 102 | await page.route('**/js/daimond.js', (r) => r.fulfill({ |
| 103 | status: 200, contentType: 'application/javascript', body: SRC.replace(from, to), |
| 104 | })); |
| 105 | }) : null, |
| 106 | }); |
| 107 | const { page: p } = s; |
| 108 | if (BREAK) console.log(`\n*** RUNNING UNDER --break ${BREAK}: failures below are the point ***\n`); |
| 109 | |
| 110 | /// Three runs: one cut off with something to show for it, one cut off with nothing, and one |
| 111 | /// that finished. Written through `Workers` itself rather than into localStorage, so what is |
| 112 | /// drawn is drawn from the same objects the app would hold after a `load`. |
| 113 | const seed = (chatId) => p.evaluate(async (chatId) => { |
| 114 | const W = window.DaimondWorkers; |
| 115 | W.runs = []; |
| 116 | const base = { |
| 117 | // A CHAT-DISPATCHED WORKER, because a worker has to be fenced to somewhere: with |
| 118 | // neither a chat nor a Diamond, `start` refuses with "an agent was dispatched |
| 119 | // without a Diamond to work in" before it reaches anything this file is about. |
| 120 | diamondId: '', diamondName: '', chatId: chatId, chatName: 'workerdrop', |
| 121 | model: 'mock/fast', provider: 'custom', tools: [], sees: false, |
| 122 | promptTokens: 40, completionTokens: 12, cachedTokens: 0, costUsd: 0, app: null, |
| 123 | }; |
| 124 | // `@text` is a directive to the mock, so the resumed leg's answer is known exactly and |
| 125 | // can be told apart from the partial that was already there. |
| 126 | W.runs.push(Object.assign({}, base, { id: 'w1', name: 'cut-off', |
| 127 | task: '@text carried-on', status: 'interrupted', text: 'one, two, three' })); |
| 128 | W.runs.push(Object.assign({}, base, { id: 'w2', name: 'cut-off-empty', |
| 129 | task: '@text from-scratch', status: 'interrupted', text: '' })); |
| 130 | W.runs.push(Object.assign({}, base, { id: 'w3', name: 'finished', |
| 131 | task: '@text done', status: 'done', text: 'one two three four' })); |
| 132 | W.persist(); |
| 133 | window.DaimondPanels.show('agents'); |
| 134 | W.render(); |
| 135 | await new Promise((r) => setTimeout(r, 400)); |
| 136 | }, chatId); |
| 137 | |
| 138 | /// What each tile offers, by the button's CLASS rather than its words, so a translation does |
| 139 | /// not decide whether this file passes. |
| 140 | const tiles = () => p.evaluate(() => { |
| 141 | const out = {}; |
| 142 | [...document.querySelectorAll('#agents-list .acard')].forEach((card) => { |
| 143 | const name = (card.querySelector('.an') || {}).textContent || ''; |
| 144 | out[name.trim()] = { |
| 145 | play: !!card.querySelector('.abtn.a-play'), |
| 146 | stop: !!card.querySelector('.abtn.a-cross'), |
| 147 | cls: card.className, |
| 148 | }; |
| 149 | }); |
| 150 | return out; |
| 151 | }); |
| 152 | |
| 153 | try { |
| 154 | const chatId = await newChat(s); |
| 155 | await seed(chatId); |
| 156 | const before = await tiles(); |
| 157 | await shot(s, 'workerdrop-tiles'); |
| 158 | |
| 159 | check(!!(before['cut-off'] && before['cut-off'].play), |
| 160 | 'AN INTERRUPTED WORKER IS OFFERED A CONTINUE', |
| 161 | before['cut-off'] ? '' : 'no tile drawn for it at all'); |
| 162 | check(!!(before['finished'] && !before['finished'].play), |
| 163 | 'AND A FINISHED ONE IS NOT — a Continue there would redo work already paid for', |
| 164 | before['finished'] && before['finished'].play ? 'a done run offers Continue' : ''); |
| 165 | |
| 166 | // ── 2. It carries on rather than starting again ────────────── |
| 167 | // |
| 168 | // `resume` is the flag that decides which of two things `start` does: seed the session |
| 169 | // with the transcript and send `RESUME_NUDGE`, or send the task afresh. The run is then |
| 170 | // let go all the way to the mock, because the property worth having is not the flag but |
| 171 | // what happens to the report -- it must be ADDED TO, not replaced. |
| 172 | const after = await p.evaluate(async () => { |
| 173 | const W = window.DaimondWorkers; |
| 174 | const card = [...document.querySelectorAll('#agents-list .acard')].find( |
| 175 | (c) => ((c.querySelector('.an') || {}).textContent || '').trim() === 'cut-off'); |
| 176 | const btn = card && card.querySelector('.abtn.a-play'); |
| 177 | const resumed = W.runs.find((r) => r.id === 'w1') || {}; |
| 178 | if (btn) btn.click(); |
| 179 | await new Promise((r) => setTimeout(r, 300)); |
| 180 | const queued = { status: resumed.status, resume: !!resumed.resume, |
| 181 | prior: resumed.priorPrompt | 0 }; |
| 182 | for (let i = 0; i < 60; i++) { |
| 183 | if (resumed.status === 'done' || resumed.status === 'error') break; |
| 184 | await new Promise((r) => setTimeout(r, 250)); |
| 185 | } |
| 186 | return Object.assign(queued, { ended: resumed.status, text: resumed.text || '' }); |
| 187 | }); |
| 188 | check(after.status !== 'interrupted', |
| 189 | 'and pressing it takes the run out of `interrupted`', |
| 190 | `status went ${JSON.stringify(after.status)} then ${JSON.stringify(after.ended)}`); |
| 191 | check(after.resume === true, |
| 192 | 'AND CARRIES THE WORK ON — the session is seeded with what the worker had said', |
| 193 | after.resume ? '' : 'it was queued to start the task over from scratch'); |
| 194 | // APPENDED, not rewritten: the partial is still at the FRONT of the report and there is |
| 195 | // more after it. Asserted on the shape rather than on the resumed leg's words, because |
| 196 | // the mock reads its directive from the last user message and on a resume that message |
| 197 | // is `RESUME_NUDGE` -- so what the second leg says is the mock's default, not the |
| 198 | // task's. What matters is that the first leg was kept and built on. |
| 199 | const PARTIAL = 'one, two, three'; |
| 200 | check(after.text.indexOf(PARTIAL) === 0 && after.text.length > PARTIAL.length, |
| 201 | 'AND THE REPORT IS ADDED TO, NOT REPLACED — the partial is still at the front of it', |
| 202 | after.text.indexOf(PARTIAL) === 0 |
| 203 | ? `${after.text.length - PARTIAL.length} further character(s) after it` |
| 204 | : JSON.stringify(after.text.slice(0, 90))); |
| 205 | check(after.prior === 40, |
| 206 | 'and what it had already spent is carried across rather than billed again', |
| 207 | `priorPrompt ${after.prior}`); |
| 208 | |
| 209 | // ── 3. Nothing to carry on from ────────────────────────────── |
| 210 | const empty = await p.evaluate(async () => { |
| 211 | const W = window.DaimondWorkers; |
| 212 | W.resume(W.runs.find((r) => r.id === 'w2')); |
| 213 | await new Promise((r) => setTimeout(r, 300)); |
| 214 | const r2 = W.runs.find((r) => r.id === 'w2') || {}; |
| 215 | return { status: r2.status, resume: !!r2.resume }; |
| 216 | }); |
| 217 | check(empty.status !== 'interrupted' && empty.resume === false, |
| 218 | 'A WORKER THAT PRODUCED NOTHING IS STARTED OVER, not asked to continue an empty answer', |
| 219 | empty.resume ? 'it was told to carry on from nothing' : ''); |
| 220 | } finally { |
| 221 | await s.close(); |
| 222 | } |
| 223 | |
| 224 | console.log(bad ? `\n${bad} check(s) FAILED` : '\nall checks passed'); |
| 225 | process.exit(bad ? 1 : 0); |