Files
agent-loop/demo/run-demo.js
2026-11-12 15:29:12 +08:00

143 lines
7.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 演示编排:真实起进程、真实调私有网关、真实改仓库、真实跑测试。
// 剧本:
// A. 用户提需求 → 云端 Worker 调模型 → 工具在“笔记本”上执行
// B. 改完文件后 kill -9 正在跑的 Worker → 调度器拉起新 Worker 从日志接着跑
// C. 回归测试在笔记本后台跑;跑的时候用户追问,云端照常回答
// D. 合上笔记本(停掉守护进程)→ 新需求到来,Worker 发现设备离线就退出
// E. 打开笔记本 → 设备上线 → 调度器自动拉起 Worker 把挂起的活干完
// 用法:node run-demo.js <run目录名>
const fs = require('node:fs');
const path = require('node:path');
const { spawn, execFileSync } = require('node:child_process');
const ROOT = __dirname;
const RUN = path.join(ROOT, 'runs', process.argv[2] || `run-${Date.now()}`);
const PORT = 7700 + Math.floor(Math.random() * 200);
const CLOUD = `http://127.0.0.1:${PORT}`;
const SESSION = 'sess-fix-token';
const DEVICE = 'zhangsan-macbook';
const pi = JSON.parse(fs.readFileSync(path.join(process.env.HOME, '.pi/agent/models.json'), 'utf8')).providers['em-coding-messages'];
const GATEWAY_URL = pi.baseUrl;
const GATEWAY_MODEL = process.env.GATEWAY_MODEL || 'DeepSeek-V4-Flash';
fs.rmSync(RUN, { recursive: true, force: true });
const TRACE = path.join(RUN, 'trace');
const CLOUD_DATA = path.join(RUN, 'cloud-data');
const REPO = path.join(RUN, 'laptop-home', 'auth-service');
fs.mkdirSync(TRACE, { recursive: true });
fs.cpSync(path.join(ROOT, 'fixture', 'auth-service'), REPO, { recursive: true });
const g = (...a) => execFileSync('git', a, { cwd: REPO, encoding: 'utf8' });
g('init', '-q'); g('add', '-A');
g('-c', 'user.name=dev', '-c', 'user.email=dev@example.com', 'commit', '-qm', 'init auth-service');
const log = (msg) => {
const line = `[${new Date().toISOString().slice(11, 23)}] ${msg}`;
console.log(line);
fs.appendFileSync(path.join(TRACE, 'driver.jsonl'), JSON.stringify({ t: Date.now(), role: 'driver', pid: process.pid, host: 'driver', kind: 'note', text: msg }) + '\n');
};
const mark = (kind, data) => fs.appendFileSync(path.join(TRACE, 'driver.jsonl'), JSON.stringify({ t: Date.now(), role: 'driver', pid: process.pid, host: 'driver', kind, ...data }) + '\n');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const get = async (p) => (await fetch(CLOUD + p)).json();
const post = async (p, b) => (await fetch(CLOUD + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) })).json();
async function waitFor(desc, pred, timeoutMs = 120000) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
const events = await get(`/sessions/${SESSION}/events`);
const status = await get('/status');
const hit = pred(events, status);
if (hit) return hit;
await sleep(150);
}
throw new Error(`超时:${desc}`);
}
const procs = [];
function start(role, file, env) {
const p = spawn(process.execPath, [file], { env: { ...process.env, ...env, TRACE_DIR: TRACE, CLOUD_URL: CLOUD }, stdio: ['ignore', 'inherit', 'inherit'] });
procs.push(p);
mark('proc_start', { what: role, child_pid: p.pid });
return p;
}
const startDaemon = (jobStart) => start('laptop-daemon', path.join(ROOT, 'laptop', 'daemon.js'), { DEVICE_ID: DEVICE, REPO_DIR: REPO, HOST_LABEL: DEVICE, JOB_SEQ_START: String(jobStart) });
async function say(text) {
log(`用户:${text}`);
return post(`/sessions/${SESSION}/events`, { type: 'user_message', text, device: DEVICE, by: 'user' });
}
const replyAfter = (seq) => (ev) => ev.find((e) => e.seq > seq && e.type === 'reply_to_user');
async function main() {
log(`运行目录 ${RUN},网关 ${GATEWAY_URL},模型 ${GATEWAY_MODEL}`);
start('cloud-server', path.join(ROOT, 'cloud', 'server.js'), { PORT: String(PORT), CLOUD_DATA, GATEWAY_URL, GATEWAY_MODEL, GATEWAY_KEY: pi.apiKey, HOST_LABEL: 'cloud' });
await sleep(500);
let daemon = startDaemon(1);
await sleep(500);
// ---- A + B ----
mark('scene', { id: 'A', title: '提需求:修复令牌过期边界' });
const m1 = await say('线上反馈:登录令牌在过期的那一秒(now 恰好等于 exp)还能用。请修复 src/token.js,跑一下单元测试,然后在后台跑完整回归 npm test。');
try {
const killed = await waitFor('第一次修改文件', (ev, st) => {
const edit = ev.find((e) => e.seq > m1.seq && e.type === 'tool_result' && ['edit_file', 'write_file'].includes(e.tool));
return edit && st.running[SESSION] ? { pid: st.running[SESSION], seq: edit.seq } : null;
}, 90000);
mark('scene', { id: 'B', title: '云端 Worker 被 kill -9' });
log(`改完文件(事件 #${killed.seq}),kill -9 正在运行的云端 Worker pid=${killed.pid}`);
process.kill(killed.pid, 'SIGKILL');
mark('inject_kill', { worker_pid: killed.pid, after_seq: killed.seq });
} catch (e) { log(`注入崩溃未触发:${e.message}`); }
const r1 = await waitFor('第一轮回复', replyAfter(m1.seq), 150000);
log(`Agent:${r1.text}`);
// ---- C ----
mark('scene', { id: 'C', title: '回归在笔记本后台跑,用户追问' });
const evs = await get(`/sessions/${SESSION}/events`);
const jobRunning = !evs.some((e) => e.type === 'job_finished');
log(`后台回归${jobRunning ? '仍在运行' : '已结束'},此刻云端 Worker:${JSON.stringify((await get('/status')).running)}`);
const m2 = await say('趁测试在跑,告诉我你具体改了什么?用 git diff 看一下。');
const r2 = await waitFor('追问回复', replyAfter(m2.seq), 90000);
log(`Agent:${r2.text}`);
const fin = await waitFor('后台回归结束', (ev) => ev.find((e) => e.type === 'job_finished'), 90000);
log(`后台 ${fin.job_id} 退出码 ${fin.exit_code}`);
const r3 = await waitFor('回归结果汇报', replyAfter(fin.seq), 90000);
log(`Agent:${r3.text}`);
// ---- D ----
mark('scene', { id: 'D', title: '合上笔记本' });
await sleep(1500);
daemon.kill('SIGTERM');
await new Promise((r) => daemon.on('exit', r));
log('合上笔记本:守护进程已退出');
const m4 = await say('再给 test/token.test.js 补一个用例:now 恰好等于 exp 时必须被拒绝。补完后在后台再跑一遍 npm test。');
const park = await waitFor('设备离线挂起', (ev) => ev.find((e) => e.seq > m4.seq && e.type === 'waiting_device'), 90000);
log(`Worker 发现设备离线,挂起工具调用 ${park.tool},Worker 退出`);
await sleep(1500);
log(`离线期间云端运行中的 Worker:${JSON.stringify((await get('/status')).running)}`);
mark('scene', { id: 'D2', title: '离线期间:云端零进程,日志在等' });
await sleep(8000);
// ---- E ----
mark('scene', { id: 'E', title: '打开笔记本,自动续跑' });
log('打开笔记本:守护进程重新上线');
daemon = startDaemon(100);
const fin2 = await waitFor('第二次回归结束', (ev) => ev.find((e) => e.seq > park.seq && e.type === 'job_finished'), 150000);
log(`后台 ${fin2.job_id} 退出码 ${fin2.exit_code}`);
const r5 = await waitFor('最终汇报', replyAfter(fin2.seq), 90000);
log(`Agent:${r5.text}`);
mark('scene', { id: 'END', title: '结束' });
}
main()
.catch((e) => { log(`失败:${e.message}`); process.exitCode = 1; })
.finally(async () => {
await sleep(800);
fs.copyFileSync(path.join(CLOUD_DATA, 'sessions', `${SESSION}.jsonl`), path.join(RUN, 'session.jsonl'));
fs.writeFileSync(path.join(RUN, 'final.diff'), g('diff'));
fs.writeFileSync(path.join(RUN, 'meta.json'), JSON.stringify({ gateway: GATEWAY_URL, model: GATEWAY_MODEL, session: SESSION, device: DEVICE, port: PORT, finished: Date.now(), ok: !process.exitCode }, null, 1));
for (const p of procs) try { p.kill('SIGTERM'); } catch {}
setTimeout(() => process.exit(), 500);
});