Oregami
Repositories/oxedyne/daimond

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
16import { open, shot, mockLog, clearMockLog } from './harness.mjs';
17
18const BREAK = (() => {
19 const i = process.argv.indexOf('--break');
20 return (i >= 0 && process.argv[i + 1]) ? process.argv[i + 1] : '';
21})();
22const BREAKS = ['nogather', 'nodispatch', 'nonotice'];
23if (BREAK && !BREAKS.includes(BREAK)) {
24 console.error(`unknown break '${BREAK}'; one of: ${BREAKS.join(', ')}`);
25 process.exit(2);
26}
27
28const ok = [], bad = [];
29const check = (name, pass, detail) => {
30 (pass ? ok : bad).push(name);
31 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
32};
33
34const s = await open({ name: 'gather' + (BREAK ? '-' + BREAK : '') });
35const { page } = s;
36
37await page.click('#new-diamond-btn');
38await page.waitForSelector('.dlg-input', { timeout: 8000 });
39await page.fill('.dlg-input', 'Gather Test');
40await page.click('.dlg-ok');
41await 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.
47if (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.
65if (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.
81if (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
93clearMockLog();
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.
97await page.fill('#chat-input',
98 '@tools spawn_agent {"name":"alpha","task":"say ALPHAFOUND"} '
99 + ';; spawn_agent {"name":"beta","task":"say BETAFOUND"}');
100await page.keyboard.press('Enter');
101await page.waitForTimeout(2000);
102
103// Let both workers run to a terminal state, then let the gather round go out.
104await page.waitForTimeout(9000);
105await shot(s, 'gather-1-dispatched');
106
107const runs = await page.evaluate(() =>
108 [...document.querySelectorAll('#panel-agents .acard')].length);
109
110const 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.
114const carries = (m, needle) => (m.messages || [])
115 .some(x => typeof x.content === 'string' && x.content.includes(needle));
116const gatherReqs = wire.filter(m => carries(m, 'alpha') && carries(m, 'beta')
117 && carries(m, 'finished'));
118
119check('the dispatch produced two agent tiles (nothing below is true of an empty pane)',
120 runs >= 2, `${runs} tile(s)`);
121check('a further request reached the model after the batch finished',
122 gatherReqs.length >= 1, `${gatherReqs.length} of ${wire.length} request(s) carry both names`);
123check('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');
126check('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.
136const 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.
140const workerReqs = wire.filter((m) => {
141 const j = JSON.stringify(m.messages || []);
142 return j.includes('ALPHAFOUND') && !j.includes('spawn_agent');
143});
144check('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)`);
146check('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');
149check('it was told WHEN the worker starts, so it does not spend the turn waiting',
150 said('begins when the turn ends'));
151
152await 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.
174const 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});
179check('the burst is over budget, so the next fan-out must be asked about',
180 armed, armed ? 'charged $9' : 'no governor to charge');
181
182clearMockLog();
183await page.fill('#chat-input',
184 '@tools spawn_agent {"name":"gamma","task":"say GAMMAFOUND"} '
185 + ';; spawn_agent {"name":"delta","task":"say DELTAFOUND"}');
186await 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.
189await page.waitForSelector('.dlg-card .dlg-cancel', { timeout: 20000 });
190await shot(s, 'gather-3-gate');
191await page.click('.dlg-card .dlg-cancel');
192await page.waitForTimeout(3000);
193
194const wire2 = mockLog();
195const ranGamma = wire2.some((m) => {
196 const j = JSON.stringify(m.messages || []);
197 return j.includes('GAMMAFOUND') && !j.includes('spawn_agent');
198});
199check('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.
206clearMockLog();
207await page.fill('#chat-input', 'carry on');
208await page.keyboard.press('Enter');
209await page.waitForTimeout(6000);
210const wire3 = mockLog();
211const told = (needle) => wire3.some((m) =>
212 JSON.stringify(m.messages || []).includes(needle));
213check('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`);
216check('and it says the reason, so the daimon is not left guessing at silence',
217 told('told no'));
218check('and that nothing was spent, so it does not report a cost it never paid',
219 told('nothing was spent'));
220check('and that there is nothing to wait for, which is what ends the lost turn',
221 told('nothing to wait for'));
222await 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.
233const 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});
239check('the depth cap is at zero, so no round may follow this batch', reset);
240
241clearMockLog();
242await page.fill('#chat-input',
243 '@tools spawn_agent {"name":"epsilon","task":"say EPSILONFOUND"}');
244await page.keyboard.press('Enter');
245await page.waitForTimeout(11000);
246const wire4 = mockLog();
247check('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.
256const 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};
262check('and no round followed it, which is what the depth cap is for',
263 !wire4.some(isRound), `${wire4.filter(isRound).length} round(s)`);
264
265clearMockLog();
266await page.fill('#chat-input', 'what did it find');
267await page.keyboard.press('Enter');
268await page.waitForTimeout(6000);
269const 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.
275check('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`);
280check('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')));
282await shot(s, 'gather-5-undelivered');
283
284await s.close();
285
286if (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}
291console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`);
292process.exit(bad.length === 0 ? 0 : 1);