Oregami
Repositories/oxedyne/daimond

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.
49import fs from 'node:fs';
50import path from 'node:path';
51import { fileURLToPath } from 'node:url';
52import { open, newChat, scratch, shot } from './harness.mjs';
53
54const HERE = path.dirname(fileURLToPath(import.meta.url));
55const WWW = path.join(HERE, '..', 'www');
56
57const BREAK = (() => {
58 const i = process.argv.indexOf('--break');
59 return i > 0 ? String(process.argv[i + 1] || '') : '';
60})();
61
62const 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};
75if (BREAK && !BREAKS[BREAK]) {
76 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
77 process.exit(2);
78}
79
80let bad = 0;
81const check = (pass, name, detail) => {
82 if (!pass) bad++;
83 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
84};
85
86const SRC = fs.readFileSync(path.join(WWW, 'js/daimond.js'), 'utf8');
87for (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
95const 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});
107const { page: p } = s;
108if (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`.
113const 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.
140const 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
153try {
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
224console.log(bad ? `\n${bad} check(s) FAILED` : '\nall checks passed');
225process.exit(bad ? 1 : 0);