// 演示编排:真实起进程、真实调私有网关、真实改仓库、真实跑测试。 // 剧本: // A. 用户提需求 → 云端 Worker 调模型 → 工具在“笔记本”上执行 // B. 改完文件后 kill -9 正在跑的 Worker → 调度器拉起新 Worker 从日志接着跑 // C. 回归测试在笔记本后台跑;跑的时候用户追问,云端照常回答 // D. 合上笔记本(停掉守护进程)→ 新需求到来,Worker 发现设备离线就退出 // E. 打开笔记本 → 设备上线 → 调度器自动拉起 Worker 把挂起的活干完 // 用法:node run-demo.js 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); });