143 lines
7.7 KiB
JavaScript
143 lines
7.7 KiB
JavaScript
// 演示编排:真实起进程、真实调私有网关、真实改仓库、真实跑测试。
|
||
// 剧本:
|
||
// 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);
|
||
});
|