aboutsummaryrefslogtreecommitdiff
path: root/modules/test/test-orchestrator-parallel-harvest.js
blob: 4381699740542e2d931aef493aa1034f23e3804e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// node modules/test/test-orchestrator-parallel-harvest.js
// Defect 2b (2026-11-02/03): orchestrate-parallel reported success it could not prove.
//
// Three separate lies, all in one block:
//  1. The harvest re-found each sub-task's agent by task STRING (`currentTask === sub.goal ||
//     lastTask === sub.goal`). An agent that had already moved on to another sub-task no longer
//     carried the old goal, so a task that had demonstrably finished was dropped as
//     "agent not found — result unverifiable".
//  2. The "N/N succeeded" header counted Promise.allSettled resolutions, not outcomes — a spawn
//     refusal returns terminal.print() (undefined), which FULFILS, so a batch where nothing ran
//     printed green.
//  3. At pool cap, delegateTask stole a BUSY agent, so two sub-tasks overwrote
//     currentTask/leaseId and erased each other's results.
//
// This file pins the contract: results are harvested by sub-task id, the tally comes from the
// summaries, and a sub-task that could not get an agent is a failure, never a green row.
const fs = require('fs'), path = require('path');
const ROOT = path.join(__dirname, '..', 'core');
// Point ORCH_SRC at modules/core/core-bundle.js to run these assertions against the DEPLOYED
// artifact instead of the source file — a green source test says nothing about the bundle.
const SRC_FILE = process.env.ORCH_SRC ? path.resolve(process.env.ORCH_SRC) : path.join(ROOT, 'module-orchestrator.js');

const store = {};
global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } };
global.window = global;
global.addEventListener = () => {};

const Orch = new Function(fs.readFileSync(SRC_FILE, 'utf8') + '\nreturn OrchestratorModule;')();

let fails = 0, passes = 0;
const ok = (n, c, d) => { if (c) { passes++; return; } fails++; console.log('FAIL ' + n + (d ? ' — ' + d : '')); };

// A VFS stub that behaves like the real one where it matters: paths normalise, and
// fsReadJSON returns null (never throws) for a missing/corrupt file.
function mkEnv(opts) {
    const prints = [];
    const files = new Map();
    const norm = p => String(p).replace(/^\/+/, '');
    const term = {
        prints,
        print(msg, type) { prints.push({ msg: String(msg), type: type || '' }); },
        fs: {
            readFile: p => (files.has(norm(p)) ? files.get(norm(p)) : null),
            writeFile: (p, c) => { files.set(norm(p), c); return true; },
            readDir: () => true,
            mkdir: () => true,
            rm: () => true,
            fsReadJSON: p => { try { const raw = files.get(norm(p)); return raw === undefined ? null : JSON.parse(raw); } catch (e) { return null; } },
        },
        moduleManager: { modules: {}, _setInterval: () => 0, _setTimeout: () => 0 },
        ollamaManager: null,
    };
    const ai = { _sessions: new Map(), chat: opts.chat, terminal: term };
    term.moduleManager.modules['WebX11AIAssistantModule'] = ai;

    const orch = Object.create(Orch.prototype);
    orch.terminal = term;
    orch.commands = {};
    orch._agents = {};
    orch._a2aMessages = {};
    orch._eventBus = {};
    orch._restartBackoffs = {};
    orch._delegations = 0;
    orch._SPAWN_LIMIT = opts.spawnLimit || 5;
    orch._RESTART_POLICY = 'none';
    return { orch, term, files, prints };
}

const GOAL = 'read alpha log and write beta report and check gamma status';
const tick = (ms) => new Promise(r => setTimeout(r, ms || 5));

(async () => {
    // ── T1: three independent sub-tasks are harvested by identity, not by task string ──
    {
        const { orch, files, prints } = mkEnv({
            chat: async (msgs) => { await tick(); return { content: 'ANSWER[' + msgs[0] + ']', toolTurns: 0 }; },
        });
        const merged = await orch.delegateTask(GOAL, { parallel: true });
        const decomposed = prints.some(p => /Decomposed into 3 sub-tasks/.test(p.msg));
        ok('T1a the goal decomposes into 3 sub-tasks (harness precondition)', decomposed, prints.map(p => p.msg).join(' | '));
        ok('T1b all three sub-tasks are harvested', merged.summaries.length === 3, 'summaries=' + merged.summaries.length);
        ok('T1c every sub-task carries its own answer', merged.summaries.every(s => s.status === 'ok' && /^ANSWER\[\[DELEGATED\]/.test(s.result || '')), JSON.stringify(merged.summaries.map(s => [s.status, s.result])));
        ok('T1d no sub-task is discarded as unverifiable', !merged.summaries.some(s => /agent not found/.test(s.error || '')), JSON.stringify(merged.summaries.map(s => s.error)));
        ok('T1e successes come from the summaries', merged.successes === 3 && merged.failures === 0, 'successes=' + merged.successes + ' failures=' + merged.failures);
        const onDisk = JSON.parse(files.get('orchestrator/parallel-results.json'));
        ok('T1f the persisted payload agrees with the returned tally', onDisk.successes === merged.successes && onDisk.summaries.length === 3, JSON.stringify({ onDisk: onDisk.successes, merged: merged.successes }));
        ok('T1g each summary is keyed by its own sub-task id', new Set(merged.summaries.map(s => s.taskId)).size === 3, JSON.stringify(merged.summaries.map(s => s.taskId)));
    }

    // ── T2: at pool cap a sub-task is REFUSED, never run on a busy agent ──
    // Old behaviour: all three ran on the one agent, clobbering each other, and the header
    // still read "3/3 succeeded" while two summaries said failed.
    {
        const { orch, prints } = mkEnv({
            spawnLimit: 1,
            chat: async () => { await tick(10); return { content: 'single-agent answer', toolTurns: 0 }; },
        });
        const merged = await orch.delegateTask(GOAL, { parallel: true });
        const oks = merged.summaries.filter(s => s.status === 'ok').length;
        ok('T2a only the one agent that was free produced a result', oks === 1 && merged.successes === 1, 'ok=' + oks + ' successes=' + merged.successes);
        ok('T2b the refused sub-tasks are reported as failures with a reason', merged.failures === 2 && merged.summaries.filter(s => s.status === 'failed').every(s => /idle|busy|limit/i.test(s.error || '')), JSON.stringify(merged.summaries.map(s => [s.status, s.error])));
        ok('T2c the tally never exceeds the green rows', !merged.summaries.some(s => s.status !== 'ok') || merged.successes < merged.total, 'successes=' + merged.successes + '/' + merged.total);
        const header = prints.map(p => p.msg).find(m => /Parallel batch:/.test(m)) || '';
        ok('T2d the header reports the real outcome', /1\/3 succeeded, 2 failed/.test(header), header);
        ok('T2e a batch with failures is not printed as a green header', !/^✅/.test(header), header);
    }

    // ── T3: a worker that answers nothing is a failure, not a success ──
    {
        const { orch } = mkEnv({
            chat: async (msgs) => { await tick(); return { content: /beta/.test(msgs[0]) ? '' : 'ok answer', toolTurns: 0 }; },
        });
        const merged = await orch.delegateTask(GOAL, { parallel: true });
        const silent = merged.summaries.filter(s => /beta/.test(s.task));
        ok('T3a the silent worker is a failed row', silent.length === 1 && silent[0].status === 'failed', JSON.stringify(silent));
        ok('T3b successes count the real answers only', merged.successes === 2 && merged.failures === 1, 'successes=' + merged.successes + ' failures=' + merged.failures);
    }

    // ── T4: a thrown sub-task is not swallowed, and does not inflate the tally ──
    {
        const { orch } = mkEnv({
            chat: async (msgs) => { await tick(); if (/gamma/.test(msgs[0])) throw new Error('worker exploded'); return { content: 'fine', toolTurns: 0 }; },
        });
        const merged = await orch.delegateTask(GOAL, { parallel: true });
        const bad = merged.summaries.filter(s => /gamma/.test(s.task));
        ok('T4a the throwing sub-task is a failed row with its error', bad.length === 1 && bad[0].status === 'failed' && /worker exploded/.test(bad[0].error || ''), JSON.stringify(bad));
        ok('T4b the tally reflects 2 real answers out of 3', merged.successes === 2 && merged.failures === 1, 'successes=' + merged.successes + ' failures=' + merged.failures);
    }

    console.log('test-orchestrator-parallel-harvest: ' + passes + ' passed, ' + fails + ' failed');
    process.exit(fails ? 1 : 0);
})();