#!/usr/bin/env node /** * selfcheck.mjs — ai-group-chat 的离线端到端自检。 * * 全程使用临时 AIGROUP_HOME,**不触碰真实房间、不调用任何模型**(唤醒只走 --dry-run * 和故意失败的分支)。任何一条不过就以非零码退出。 * * 用法:node selfcheck.mjs [--keep] */ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const AIG = path.join(HERE, 'aig.mjs'); const ROOM = 'selfcheck'; const HOME_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'aig-selfcheck-')); const KEEP = process.argv.includes('--keep'); const ENV = { ...process.env, AIGROUP_HOME: HOME_ROOT, AIGROUP_ROOM: ROOM, NO_COLOR: '1' }; delete ENV.AIGROUP_DEPTH; delete ENV.AIGROUP_NO_WAKE; let pass = 0; const failures = []; function record(ok, name, detail = '') { if (ok) { pass++; process.stdout.write(` ✔ ${name}${detail ? ` ${detail}` : ''}\n`); } else { failures.push({ name, detail }); process.stdout.write(` ✘ ${name}${detail ? ` ${detail}` : ''}\n`); } } function aig(args, env = {}) { const r = spawnSync(process.execPath, [AIG, ...args], { encoding: 'utf8', windowsHide: true, env: { ...ENV, ...env }, timeout: 60000 }); let json = null; try { json = JSON.parse(r.stdout); } catch {} return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim(), json }; } function aigAsync(args, env = {}) { return new Promise((resolve) => { const child = spawn(process.execPath, [AIG, ...args], { windowsHide: true, env: { ...ENV, ...env } }); let out = '', err = ''; child.stdout.on('data', (d) => { out += d; }); child.stderr.on('data', (d) => { err += d; }); child.on('close', (code) => resolve({ code, out: out.trim(), err: err.trim() })); }); } // 直接读日志文件,绕开 CLI —— 否则 `read --all --as dsh` 会顺手推进 dsh 的游标, // 把后面「唤醒 dsh 应该有未读」的用例打空。 const msgs = () => { const file = path.join(HOME_ROOT, 'rooms', ROOM, 'messages.jsonl'); let raw = ''; try { raw = fs.readFileSync(file, 'utf8'); } catch { return []; } return raw.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); }; const unreadOf = (m) => aig(['read', '--json', '--as', m]); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /* ------------------------------------------------------------------ 各阶段 */ function phaseRooms() { process.stdout.write('【房间与成员】\n'); const r = aig(['init']); record(r.code === 0, 'init 建房间', r.out.split('\n')[0]); const members = JSON.parse(fs.readFileSync(path.join(HOME_ROOT, 'rooms', ROOM, 'members.json'), 'utf8')); record(['dsh', 'opencode', 'workbuddy'].every((m) => members[m]), '预置三个成员', Object.keys(members).join(',')); record(fs.existsSync(path.join(HOME_ROOT, 'rooms', ROOM, 'messages.jsonl')), '消息日志已创建'); const m = aig(['members', '--json']); record(m.code === 0 && Array.isArray(m.json) && m.json.length === 3, 'members --json 可用', `${m.json?.length} 个成员`); const wakable = (m.json || []).filter((x) => x.wakable).map((x) => x.member); record(wakable.length >= 2, '至少两个成员的唤醒通道能解析', `可唤醒:${wakable.join(',') || '无'}`); } function phaseMessages() { process.stdout.write('\n【发消息 / 读消息 / 游标】\n'); const s = aig(['send', '大家好,我是 DSH @opencode 先看下这个', '--as', 'dsh']); record(s.code === 0, 'send 成功'); const all = msgs(); record(all.length === 1 && all[0].from === 'dsh', '消息落库', `#${all[0]?.seq} ${all[0]?.from}`); record(['id', 'seq', 'ts', 'from', 'to', 'text'].every((k) => all[0][k] !== undefined), '消息 schema 齐全'); record(all[0].to.includes('opencode'), '正文里的 @opencode 自动定向', JSON.stringify(all[0].to)); const u1 = unreadOf('opencode'); record(u1.json?.length === 1, 'opencode 有 1 条未读', String(u1.json?.length)); const u2 = unreadOf('opencode'); record(u2.json?.length === 0, '读完后游标推进(第二次读为空)', String(u2.json?.length)); aig(['send', '给 workbuddy 的一条,用来验 --peek', '--to', 'workbuddy', '--as', 'dsh']); const peek = aig(['read', '--peek', '--json', '--as', 'workbuddy']); record(peek.json?.length === 1, '--peek 能看到未读', `peek=${peek.json?.length}`); const peek2 = aig(['read', '--peek', '--json', '--as', 'workbuddy']); record(peek2.json?.length === 1, '--peek 不推进游标(连读两次都有)', `第二次 peek=${peek2.json?.length}`); const consumed = aig(['read', '--json', '--as', 'workbuddy']); const after = aig(['read', '--json', '--as', 'workbuddy']); record(consumed.json?.length === 1 && after.json?.length === 0, '真读一次后游标才推进', `${consumed.json?.length} → ${after.json?.length}`); aig(['read', '--as', 'dsh']); } function phaseTargeting() { process.stdout.write('\n【定向与广播隔离】\n'); aig(['send', '这条只给 opencode', '--to', 'opencode', '--as', 'dsh']); const dub = unreadOf('workbuddy'); record(dub.json?.length === 0, '定向消息不会漏给第三方', `workbuddy 未读 ${dub.json?.length}`); const dob = unreadOf('opencode'); record(dob.json?.length === 1 && dob.json[0].text.includes('只给 opencode'), '定向消息送达本人'); aig(['send', '这条是广播', '--as', 'dsh']); const b1 = unreadOf('opencode'); const b2 = unreadOf('workbuddy'); record(b1.json?.length === 1 && b2.json?.length === 1, '广播双方都收到', `opencode=${b1.json?.length} workbuddy=${b2.json?.length}`); } async function phaseConcurrency() { process.stdout.write('\n【并发写入】\n'); const before = msgs().length; const jobs = []; for (let i = 0; i < 8; i++) jobs.push(aigAsync(['send', `并发消息 ${i}`, '--as', i % 2 ? 'opencode' : 'workbuddy'])); const results = await Promise.all(jobs); const okCount = results.filter((r) => r.code === 0).length; const all = msgs(); const seqs = all.map((m) => m.seq); record(okCount === 8, '8 个并发 send 全部成功', `成功 ${okCount}/8`); record(all.length === before + 8, '并发后消息数正确', `${before} → ${all.length}`); record(new Set(seqs).size === seqs.length, '序号无重复(锁有效)', `唯一 ${new Set(seqs).size}/${seqs.length}`); record(seqs.every((s, i) => i === 0 || s > seqs[i - 1]), '序号严格递增'); } async function phaseWait() { process.stdout.write('\n【wait 阻塞等待】\n'); aig(['read', '--as', 'workbuddy']); const waiting = aigAsync(['wait', '--timeout', '20', '--interval', '0.3', '--as', 'workbuddy']); await sleep(900); aig(['send', '给等待中的 workbuddy 的一条', '--as', 'dsh']); const w = await waiting; record(w.code === 0, 'wait 被新消息唤醒', `exit=${w.code}`); record(w.out.includes('给等待中的 workbuddy'), 'wait 打印了消息内容'); const t = aig(['wait', '--timeout', '1', '--interval', '0.3', '--as', 'workbuddy']); record(t.code === 3, '无消息时 wait 超时返回 3', `exit=${t.code}`); } function phaseWakeGuards() { process.stdout.write('\n【唤醒:dry-run 与护栏】\n'); // 由 human 发广播:这样 dsh 自己也有未读(自己发的消息对自己是不可见的) aig(['send', '广播给所有人的一条,用于唤醒测试', '--as', 'human']); for (const [member, needle] of [['dsh', '--profile'], ['opencode', 'run'], ['workbuddy', '-p']]) { const r = aig(['wake', member, '--dry-run']); record(r.code === 0 && r.out.includes(needle), `wake ${member} --dry-run 组装命令`, r.code === 0 ? '' : r.err); } const selfWake = aig(['wake', 'human', '--dry-run']); record(selfWake.code === 4 || selfWake.code === 0, 'human 没有唤醒通道时不崩', `exit=${selfWake.code}`); const deep = aig(['wake', 'opencode'], { AIGROUP_DEPTH: '3', AIGROUP_MAX_DEPTH: '3' }); record(deep.code === 5, '超过最大深度时拒绝唤醒(防死循环)', `exit=${deep.code}`); const nowake = aig(['wake', 'opencode'], { AIGROUP_NO_WAKE: '1' }); record(nowake.code === 5, 'NO_WAKE 链条里拒绝再唤醒', `exit=${nowake.code}`); const model = aig(['wake', 'opencode', '--dry-run', '--model', 'opencode/muse-spark-1.3-contributor-free']); record(model.out.includes('muse-spark-1.3-contributor-free'), '--model 覆盖生效'); } function phaseFailurePath() { process.stdout.write('\n【失败必须进群 / 备用模型】\n'); // 用「一定能失败」的通道:把一个必定非零退出的 JS 文件当成 dsh 的入口 const broken = path.join(HOME_ROOT, 'broken-transport.js'); fs.writeFileSync(broken, 'process.stderr.write("boom: 通道故意失败\\n"); process.exit(3);\n', 'utf8'); aig(['send', '这条会触发一次注定失败的唤醒', '--as', 'human']); const before = msgs().length; const bad = aig(['wake', 'dsh', '--timeout', '30'], { AIGROUP_DSH_BIN: broken }); record(bad.code === 4, '唤起失败返回 4', `exit=${bad.code}`); const after = msgs(); const lastMsg = after[after.length - 1]; record(after.length === before + 1 && lastMsg.kind === 'dispatch-error', '失败被写成 dispatch-error 消息进群', String(lastMsg?.kind)); record(String(lastMsg?.text || '').includes('boom'), '失败原因带上真实 stderr', String(lastMsg?.text || '').slice(0, 60).replace(/\n/g, ' ')); // 备用模型:第一次调用失败、第二次成功 —— 模拟「隐身模型下线后自动退档」 const flaky = path.join(HOME_ROOT, 'flaky-transport.js'); const flagFile = path.join(HOME_ROOT, 'flaky.flag'); fs.writeFileSync(flaky, [ "import fs from 'node:fs';", `const flag = ${JSON.stringify(flagFile)};`, "if (!fs.existsSync(flag)) { fs.writeFileSync(flag, '1'); process.stderr.write('model unavailable\\n'); process.exit(3); }", "process.stdout.write('来自备用模型的回答');", ].join('\n'), 'utf8'); aig(['join', 'dsh', '--model', 'opencode/primary-x', '--fallback-model', 'opencode/backup-y']); aig(['send', '这条模拟主模型下线', '--as', 'human']); const fb = aig(['wake', 'dsh', '--timeout', '30', '--json'], { AIGROUP_DSH_BIN: flaky }); const fbReply = fb.json?.reply; record(fb.code === 0 && String(fbReply?.text || '').includes('备用模型'), '主模型失败后自动退到备用模型', `exit=${fb.code}`); record(fbReply?.meta?.model === 'opencode/backup-y' && fbReply?.meta?.fallback_note, '消息里记录了换档事实', String(fbReply?.meta?.fallback_note || '').slice(0, 50)); } function phaseDiagnostics() { process.stdout.write('\n【诊断命令】\n'); for (const [cmd, args] of [['status', ['--json']], ['doctor', ['--json']], ['detect', ['--json']], ['members', ['--json']], ['tail', ['--json', '--limit', '3']], ['rooms', ['--json']]]) { const r = aig([cmd, ...args]); record(r.code === 0 && r.json !== null, `${cmd} --json 可取`, `exit=${r.code}`); } const d = aig(['detect', '--json']); record(typeof d.json?.resolved === 'string' && d.json.resolved.length > 0, 'detect 给出身份判断', String(d.json?.resolved)); } function phaseBadLines() { process.stdout.write('\n【坏行不静默(workbuddy 在群里的建议)】\n'); const log = path.join(HOME_ROOT, 'rooms', ROOM, 'messages.jsonl'); const marker = '{"seq": 这个不是合法 JSON'; fs.appendFileSync(log, `${marker}\n`, 'utf8'); const r = aig(['read', '--all', '--as', 'dsh']); record(r.code === 0 && r.out.includes('已发送') === false && r.out.length > 0, '有坏行时仍然读得出好消息', `exit=${r.code}`); record(r.err.includes('跳过') && r.err.includes('1 行'), 'stderr 明确警告跳过了坏行', r.err.split('\n').filter((l) => l.includes('跳过'))[0] || '(无警告)'); const badLog = path.join(HOME_ROOT, 'rooms', ROOM, 'bad-lines.log'); const dumped = fs.existsSync(badLog) ? fs.readFileSync(badLog, 'utf8') : ''; record(dumped.includes('这个不是合法 JSON'), '坏行原文落盘到 bad-lines.log', `${dumped.length}B`); // 清掉坏行,免得影响后面的用例 fs.writeFileSync(log, fs.readFileSync(log, 'utf8').split('\n').filter((l) => !l.includes('这个不是合法 JSON')).join('\n'), 'utf8'); } function phaseReset() { process.stdout.write('\n【默认房间与清理】\n'); aig(['use', ROOM]); const s = aig(['send', '不指定 --room 时进默认房间', '--as', 'dsh']); record(s.code === 0 && s.out.includes('已发送'), 'use 设的默认房间生效'); const resetNo = aig(['reset']); record(resetNo.code === 2, 'reset 缺 --yes 时拒绝', `exit=${resetNo.code}`); const resetYes = aig(['reset', '--yes']); record(resetYes.code === 0 && !fs.existsSync(path.join(HOME_ROOT, 'rooms', ROOM)), 'reset --yes 清空房间'); } /* ------------------------------------------------------------------ main */ async function main() { process.stdout.write(`\nai-group-chat 自检 · 临时总线 ${HOME_ROOT}\n\n`); phaseRooms(); phaseMessages(); phaseTargeting(); await phaseConcurrency(); await phaseWait(); phaseWakeGuards(); phaseFailurePath(); phaseDiagnostics(); phaseBadLines(); phaseReset(); if (!KEEP) { try { fs.rmSync(HOME_ROOT, { recursive: true, force: true }); } catch {} } const total = pass + failures.length; process.stdout.write(`\n${'─'.repeat(52)}\n`); process.stdout.write(failures.length ? `✘ ${pass}/${total} 通过,失败 ${failures.length} 项:\n` : `✔ 全部通过(${pass}/${total})\n`); for (const f of failures) process.stdout.write(` · ${f.name} ${f.detail}\n`); if (KEEP) process.stdout.write(`临时总线保留在 ${HOME_ROOT}\n`); process.exitCode = failures.length ? 1 : 0; } await main();