first commit

This commit is contained in:
魏天炜
2026-11-12 15:29:12 +08:00
commit 5dabea54ab
93 changed files with 12170 additions and 0 deletions

1
demo/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
runs/

80
demo/cloud/loop-worker.js Normal file
View File

@@ -0,0 +1,80 @@
// 云端 Agent Loop Worker:无状态、用完即退。
// 每次被调度器拉起时:读 Session 日志 → 判断下一步 → 调模型或把工具调用发给设备 → 结果写回 Session。
// 进程内存里没有任何必须保存的东西:随时 kill -9,下一个 Worker 读日志就能接上。
const { tracer, pendingWork, rebuildMessages } = require('../shared');
const SESSION = process.env.SESSION_ID;
const CLOUD = process.env.CLOUD_URL;
const GATEWAY = process.env.GATEWAY_URL; // 私有网关,Anthropic Messages 协议
const MODEL = process.env.GATEWAY_MODEL;
const trace = tracer('cloud-worker');
const SYSTEM = [
'你是运行在云端的编码 Agent。用户的代码仓库在用户自己的电脑上,你只能通过工具在那台电脑上读写文件、执行命令。',
'完整测试 `npm test` 含约 12 秒的集成测试,必须用 start_background 在后台运行,启动后直接结束本轮回复、简短告诉用户已启动即可,不要等待;结果会以“[后台任务完成]”消息送回给你。',
'单元测试可以用 run_command 执行 `node --test test/token.test.js`。',
'修改要最小。不要提交 git。回复用中文,简短。',
].join('\n');
const TOOLS = [
{ name: 'read_file', description: '读取用户电脑上仓库里的文件(带行号)', input_schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
{ name: 'edit_file', description: '把文件中唯一出现的 old_text 替换为 new_text', input_schema: { type: 'object', properties: { path: { type: 'string' }, old_text: { type: 'string' }, new_text: { type: 'string' } }, required: ['path', 'old_text', 'new_text'] } },
{ name: 'write_file', description: '创建或覆盖文件', input_schema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] } },
{ name: 'run_command', description: '在仓库根目录同步执行一条 shell 命令(15 秒超时),返回退出码和输出', input_schema: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } },
{ name: 'start_background', description: '在仓库根目录后台启动一条命令,立即返回 job_id;完成后结果会作为新消息送回', input_schema: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } },
];
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();
const append = (ev) => post(`/sessions/${SESSION}/events`, { ...ev, by: `worker:${process.pid}` });
async function callModel(events) {
const messages = rebuildMessages(events);
const reqBody = { model: MODEL, max_tokens: 2000, system: SYSTEM, tools: TOOLS, messages };
const started = Date.now();
trace('model_request', { session: SESSION, url: GATEWAY + '/v1/messages', model: MODEL, message_count: messages.length, request_bytes: JSON.stringify(reqBody).length, request: reqBody });
const r = await fetch(GATEWAY + '/v1/messages', {
method: 'POST',
headers: { 'content-type': 'application/json', 'anthropic-version': '2023-06-01', Authorization: `Bearer ${process.env.GATEWAY_KEY}` },
body: JSON.stringify(reqBody),
});
const text = await r.text();
if (!r.ok) throw new Error(`gateway ${r.status}: ${text.slice(0, 300)}`);
const d = JSON.parse(text);
trace('model_response', { session: SESSION, ms: Date.now() - started, status: r.status, stop_reason: d.stop_reason, usage: d.usage, response_id: d.id, content: d.content });
return d;
}
async function main() {
trace('worker_boot', { session: SESSION, holds_repo: false, cwd: process.cwd() });
for (let step = 0; step < 12; step++) {
const events = await get(`/sessions/${SESSION}/events`);
const work = pendingWork(events);
trace('worker_decide', { session: SESSION, event_count: events.length, decision: work.kind, open_calls: work.calls?.map((c) => c.id) });
if (work.kind === 'idle') break;
if (work.kind === 'dispatch') {
const { device } = await get(`/sessions/${SESSION}/device`);
for (const call of work.calls) {
const started = Date.now();
const r = await post('/dispatch', { device, session: SESSION, call_id: call.id, tool: call.name, input: call.input });
if (r.offline) {
await append({ type: 'waiting_device', device, call_id: call.id, tool: call.name });
trace('worker_park', { session: SESSION, reason: 'device offline', call_id: call.id });
return; // 不空等:直接退出,设备回来时调度器会再拉起一个 Worker
}
trace('tool_roundtrip', { session: SESSION, call_id: call.id, tool: call.name, ms: Date.now() - started, ran_on: r.host, ran_pid: r.pid });
await append({ type: 'tool_result', call_id: call.id, tool: call.name, output: r.output, ran_on: r.host, ran_pid: r.pid, repo_after: r.repo_after });
}
continue;
}
const d = await callModel(events);
await append({ type: 'model_response', content: d.content, stop_reason: d.stop_reason, usage: d.usage, model: MODEL });
const textOut = d.content.filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
if (d.stop_reason !== 'tool_use' && textOut) await append({ type: 'reply_to_user', text: textOut });
}
trace('worker_done', { session: SESSION });
}
main().catch((e) => { trace('worker_error', { session: SESSION, error: String(e) }); process.exit(1); });

136
demo/cloud/server.js Normal file
View File

@@ -0,0 +1,136 @@
// 云端控制面:Session 存储 + 设备通道 + 调度器。
// 它不调模型、不碰代码,只做三件事:
// 1. 把事件追加进 Session(写到 cloud/sessions/<id>.jsonl,相当于数据库)
// 2. 让本地守护进程长轮询领取工具调用、回传结果
// 3. 看到 Session 有活要干、又没有 Worker 在跑时,拉起一个新的 Worker 进程
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { tracer, pendingWork } = require('../shared');
const PORT = Number(process.env.PORT || 7700);
const DATA = process.env.CLOUD_DATA;
const trace = tracer('cloud-server');
fs.mkdirSync(path.join(DATA, 'sessions'), { recursive: true });
// ---------- Session 存储 ----------
const sessFile = (id) => path.join(DATA, 'sessions', `${id}.jsonl`);
function readEvents(id) {
if (!fs.existsSync(sessFile(id))) return [];
return fs.readFileSync(sessFile(id), 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
}
function appendEvent(id, ev) {
const events = readEvents(id);
const rec = { seq: events.length + 1, at: Date.now(), ...ev };
fs.appendFileSync(sessFile(id), JSON.stringify(rec) + '\n');
trace('session_append', { session: id, seq: rec.seq, type: rec.type, bytes: JSON.stringify(rec).length, by: ev.by });
setTimeout(() => schedule(id), 0);
return rec;
}
// ---------- 设备通道 ----------
const devices = {}; // id -> { waiting: res|null, queue: [], pending: Map(reqId -> res), lastSeen }
const dev = (id) => (devices[id] ||= { waiting: null, queue: [], pending: new Map(), lastSeen: 0, online: false });
function isOnline(id) {
const d = devices[id];
return !!(d && d.online && (d.waiting || Date.now() - d.lastSeen < 20000));
}
function flush(d) {
if (d.waiting && d.queue.length) {
const res = d.waiting; d.waiting = null; d.lastSeen = Date.now();
res.end(JSON.stringify(d.queue.shift()));
}
}
// ---------- 调度器 ----------
const running = {}; // session -> pid
const sessionDevice = {}; // session -> device
function schedule(id) {
if (running[id]) return;
const work = pendingWork(readEvents(id));
if (work.kind === 'idle') return;
if (work.kind === 'dispatch' && !isOnline(sessionDevice[id])) return; // 设备不在线:不拉起 Worker,等它回来
const child = spawn(process.execPath, [path.join(__dirname, 'loop-worker.js')], {
cwd: path.join(DATA, 'worker-scratch'),
env: { ...process.env, SESSION_ID: id, CLOUD_URL: `http://127.0.0.1:${PORT}`, HOST_LABEL: 'cloud-worker' },
stdio: ['ignore', 'inherit', 'inherit'],
});
running[id] = child.pid;
trace('worker_spawn', { session: id, worker_pid: child.pid, reason: work.kind });
child.on('exit', (code, signal) => {
trace('worker_exit', { session: id, worker_pid: child.pid, code, signal });
delete running[id];
setTimeout(() => schedule(id), signal ? 800 : 0); // 被杀掉的话稍等一下再看,模拟“故障检测”
});
}
fs.mkdirSync(path.join(DATA, 'worker-scratch'), { recursive: true });
// ---------- HTTP ----------
const body = (req) => new Promise((ok) => { let s = ''; req.on('data', (c) => (s += c)); req.on('end', () => ok(s ? JSON.parse(s) : {})); });
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
const p = url.pathname.split('/').filter(Boolean);
const handled = (req.method === 'POST' && p[0] === 'dispatch') || (req.method === 'GET' && p[0] === 'devices' && p[2] === 'poll');
try {
// 用户 / 外部事件 / Worker 往 Session 里追加事件
if (req.method === 'POST' && p[0] === 'sessions' && p[2] === 'events') {
const ev = await body(req);
if (ev.device) sessionDevice[p[1]] = ev.device;
return json(res, 200, appendEvent(p[1], ev));
}
if (req.method === 'GET' && p[0] === 'sessions' && p[2] === 'events') return json(res, 200, readEvents(p[1]));
if (req.method === 'GET' && p[0] === 'sessions' && p[2] === 'device') return json(res, 200, { device: sessionDevice[p[1]], online: isOnline(sessionDevice[p[1]]) });
// Worker 把一次工具调用交给设备,同步等结果;设备不在线立刻返回 offline
if (req.method === 'POST' && p[0] === 'dispatch') {
const cmd = await body(req);
const d = dev(cmd.device);
if (!isOnline(cmd.device)) {
trace('dispatch_offline', { device: cmd.device, call_id: cmd.call_id, tool: cmd.tool });
return json(res, 200, { offline: true });
}
cmd.req_id = `${cmd.call_id}`;
d.pending.set(cmd.req_id, res);
d.queue.push(cmd);
trace('dispatch', { device: cmd.device, call_id: cmd.call_id, tool: cmd.tool, input: cmd.input });
flush(d);
return void 0;
}
// 守护进程长轮询领取命令(设备在 NAT 后面,只能它主动连云端)
if (req.method === 'GET' && p[0] === 'devices' && p[2] === 'poll') {
const d = dev(p[1]);
const wasOnline = isOnline(p[1]);
d.online = true; d.lastSeen = Date.now(); d.waiting = res;
if (!wasOnline) {
trace('device_online', { device: p[1] });
for (const s of Object.keys(sessionDevice)) if (sessionDevice[s] === p[1]) setTimeout(() => schedule(s), 50);
}
req.on('close', () => { if (d.waiting === res) { d.waiting = null; d.lastSeen = Date.now(); } });
flush(d);
setTimeout(() => { if (d.waiting === res) { d.waiting = null; d.lastSeen = Date.now(); res.end('null'); } }, 15000);
return;
}
if (req.method === 'POST' && p[0] === 'devices' && p[2] === 'result') {
const r = await body(req);
const d = dev(p[1]); d.lastSeen = Date.now();
const waiter = d.pending.get(r.req_id);
d.pending.delete(r.req_id);
if (waiter) waiter.end(JSON.stringify(r));
return json(res, 200, { ok: true });
}
if (req.method === 'POST' && p[0] === 'devices' && p[2] === 'bye') {
const d = dev(p[1]); d.online = false;
trace('device_offline', { device: p[1] });
return json(res, 200, { ok: true });
}
if (req.method === 'GET' && p[0] === 'status') return json(res, 200, { running, devices: Object.fromEntries(Object.keys(devices).map((k) => [k, isOnline(k)])) });
if (!res.headersSent && !res.writableEnded && !handled) json(res, 404, { error: 'not found' });
} catch (e) {
console.error('[cloud-server]', e);
if (!res.headersSent) json(res, 500, { error: String(e) });
}
}).listen(PORT, () => trace('server_start', { port: PORT, data_dir: DATA }));

2
demo/fixture/auth-service/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
node_modules

View File

@@ -0,0 +1,3 @@
# auth-service
`npm test` 运行全部测试(含约 12 秒的集成测试,需要本机 .env 与内网数据库)。

View File

@@ -0,0 +1,8 @@
{
"name": "auth-service",
"version": "1.0.0",
"private": true,
"scripts": {
"test": "node --test 'test/*.test.js'"
}
}

View File

@@ -0,0 +1,12 @@
// 校验登录令牌:exp 为过期时间(秒级 Unix 时间戳)
function verifyToken(token, now = Math.floor(Date.now() / 1000)) {
if (!token || typeof token.exp !== 'number') {
return { ok: false, reason: 'malformed' };
}
if (now > token.exp) {
return { ok: false, reason: 'expired' };
}
return { ok: true, userId: token.sub };
}
module.exports = { verifyToken };

View File

@@ -0,0 +1,13 @@
// 模拟依赖本机数据库 / 内网服务的集成测试:读本地 .env,耗时约 12 秒
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const { verifyToken } = require('../src/token');
test('集成:用本地配置签发并校验会话令牌', async () => {
const env = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
assert.match(env, /INTERNAL_DB_URL=/, '需要本机的 .env');
await new Promise((r) => setTimeout(r, 12000));
assert.ok(verifyToken({ sub: 'svc', exp: 2000 }, 1500).ok);
});

View File

@@ -0,0 +1,15 @@
const test = require('node:test');
const assert = require('node:assert');
const { verifyToken } = require('../src/token');
test('未过期的令牌可用', () => {
assert.deepStrictEqual(verifyToken({ sub: 'u1', exp: 1000 }, 999), { ok: true, userId: 'u1' });
});
test('过期之后的令牌被拒绝', () => {
assert.strictEqual(verifyToken({ sub: 'u1', exp: 1000 }, 1001).ok, false);
});
test('格式不对的令牌被拒绝', () => {
assert.strictEqual(verifyToken({ sub: 'u1' }, 1).reason, 'malformed');
});

110
demo/laptop/daemon.js Normal file
View File

@@ -0,0 +1,110 @@
// 本地守护进程:跑在用户电脑上,持有仓库、.env、工具链。
// 它不调模型、不存会话,只做一件事:领取工具调用 → 在仓库里执行 → 回传结果。
// 为了演示,每次执行后顺手拍一下 git 快照(status / diff),写进 trace。
const fs = require('node:fs');
const path = require('node:path');
const { spawn, execFileSync } = require('node:child_process');
const { tracer } = require('../shared');
const CLOUD = process.env.CLOUD_URL;
const DEVICE = process.env.DEVICE_ID;
const REPO = process.env.REPO_DIR;
const trace = tracer('laptop-daemon');
let jobSeq = Number(process.env.JOB_SEQ_START || 1);
const done = new Map(); // call_id -> 已执行结果(同一个调用被重发时不重复执行)
const git = (...a) => { try { return execFileSync('git', a, { cwd: REPO, encoding: 'utf8' }); } catch (e) { return e.stdout || ''; } };
function repoSnapshot() {
return { status: git('status', '--short').trim(), diff: git('diff').trim(), head: git('rev-parse', '--short', 'HEAD').trim() };
}
function sh(command, timeoutMs) {
return new Promise((ok) => {
const child = spawn('/bin/sh', ['-c', command], { cwd: REPO, env: process.env });
let out = '';
child.stdout.on('data', (c) => (out += c));
child.stderr.on('data', (c) => (out += c));
const timer = timeoutMs ? setTimeout(() => child.kill('SIGKILL'), timeoutMs) : null;
child.on('exit', (code) => { if (timer) clearTimeout(timer); ok({ code, out, pid: child.pid }); });
});
}
async function runTool(cmd) {
const { tool, input } = cmd;
const file = input.path ? path.join(REPO, input.path) : null;
if (tool === 'read_file') {
const lines = fs.readFileSync(file, 'utf8').split('\n');
return { output: lines.map((l, i) => `${String(i + 1).padStart(3)}| ${l}`).join('\n') };
}
if (tool === 'edit_file') {
const src = fs.readFileSync(file, 'utf8');
const n = src.split(input.old_text).length - 1;
if (n !== 1) return { output: `错误:old_text 出现了 ${n} 次,需要恰好 1 次` };
fs.writeFileSync(file, src.replace(input.old_text, input.new_text));
return { output: `已修改 ${input.path}` };
}
if (tool === 'write_file') {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, input.content);
return { output: `已写入 ${input.path}(${input.content.length} 字节)` };
}
if (tool === 'run_command') {
const r = await sh(input.command, 15000);
trace('process_exit', { command: input.command, child_pid: r.pid, exit_code: r.code });
return { output: `退出码 ${r.code}\n${r.out.slice(-3000)}`, child_pid: r.pid };
}
if (tool === 'start_background') {
const jobId = `job-${jobSeq++}`;
const started = Date.now();
const child = spawn('/bin/sh', ['-c', input.command], { cwd: REPO, env: process.env });
let out = '';
child.stdout.on('data', (c) => (out += c));
child.stderr.on('data', (c) => (out += c));
trace('job_start', { job_id: jobId, command: input.command, child_pid: child.pid, session: cmd.session });
child.on('exit', async (code) => {
trace('job_exit', { job_id: jobId, child_pid: child.pid, exit_code: code, ms: Date.now() - started });
const tail = out.trim().split('\n').slice(-12).join('\n');
await postRetry(`/sessions/${cmd.session}/events`, { type: 'job_finished', job_id: jobId, command: input.command, exit_code: code, tail, ran_on: DEVICE, ran_pid: child.pid, by: `daemon:${process.pid}` });
});
return { output: `已在后台启动 ${jobId}(pid ${child.pid})`, child_pid: child.pid };
}
return { output: `未知工具 ${tool}` };
}
async function postRetry(p, b) {
for (let i = 0; i < 30; i++) {
try { return await (await fetch(CLOUD + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) })).json(); }
catch { await new Promise((r) => setTimeout(r, 1000)); }
}
}
async function handle(cmd) {
const started = Date.now();
let result = done.get(cmd.call_id);
const replay = !!result;
if (!result) {
try { result = await runTool(cmd); } catch (e) { result = { output: `错误:${e.message}` }; }
done.set(cmd.call_id, result);
}
const repo_after = repoSnapshot();
trace('tool_exec', { call_id: cmd.call_id, tool: cmd.tool, input: cmd.input, cwd: REPO, ms: Date.now() - started, replay, output: result.output, child_pid: result.child_pid, repo_after });
await postRetry(`/devices/${DEVICE}/result`, { req_id: cmd.req_id, output: result.output, host: DEVICE, pid: process.pid, repo_after: { status: repo_after.status } });
}
let stopping = false;
async function loop() {
trace('daemon_start', { device: DEVICE, repo: REPO, repo_state: repoSnapshot() });
while (!stopping) {
let cmd = null;
try { cmd = await (await fetch(`${CLOUD}/devices/${DEVICE}/poll`)).json(); }
catch { await new Promise((r) => setTimeout(r, 1000)); continue; }
if (cmd) handle(cmd);
}
}
process.on('SIGTERM', async () => {
stopping = true;
trace('daemon_stop', { device: DEVICE, reason: 'laptop lid closed (SIGTERM)' });
try { await fetch(`${CLOUD}/devices/${DEVICE}/bye`, { method: 'POST' }); } catch {}
process.exit(0);
});
loop();

142
demo/run-demo.js Normal file
View File

@@ -0,0 +1,142 @@
// 演示编排:真实起进程、真实调私有网关、真实改仓库、真实跑测试。
// 剧本:
// 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);
});

60
demo/shared.js Normal file
View File

@@ -0,0 +1,60 @@
// 三个角色共用的小工具:trace 记录、Session 规则。
// trace 是这次演示的“证据”:每个进程把自己做了什么追加到 TRACE_DIR/<角色>.jsonl。
const fs = require('node:fs');
const path = require('node:path');
function tracer(role) {
const dir = process.env.TRACE_DIR;
const file = dir ? path.join(dir, `${role}.jsonl`) : null;
return (kind, data = {}) => {
const rec = { t: Date.now(), role, pid: process.pid, host: process.env.HOST_LABEL || role, kind, ...data };
if (file) fs.appendFileSync(file, JSON.stringify(rec) + '\n');
return rec;
};
}
// 只看事件日志,判断这个 Session 下一步要做什么。
// 云端 Worker 和调度器都用它:Worker 不需要记住任何东西,日志就是全部状态。
// { kind: 'idle' } 没事可做
// { kind: 'dispatch', calls: [...] } 模型要的工具调用还没有结果,要发给设备
// { kind: 'model' } 有新输入(用户消息 / 工具结果 / 后台完成),该调模型了
function pendingWork(events) {
let lastModel = -1;
for (let i = events.length - 1; i >= 0; i--) {
if (events[i].type === 'model_response') { lastModel = i; break; }
}
if (lastModel >= 0) {
const calls = events[lastModel].content.filter((b) => b.type === 'tool_use');
const done = new Set(events.filter((e) => e.type === 'tool_result').map((e) => e.call_id));
const open = calls.filter((c) => !done.has(c.id));
if (open.length) return { kind: 'dispatch', calls: open };
}
const fresh = events.slice(lastModel + 1).some((e) => ['user_message', 'tool_result', 'job_finished'].includes(e.type));
return fresh ? { kind: 'model' } : { kind: 'idle' };
}
// 把事件日志翻译成模型 API 的 messages。每次调模型都从头重建,所以换一个进程来跑结果完全一样。
function rebuildMessages(events) {
const msgs = [];
const pushUser = (block) => {
const last = msgs[msgs.length - 1];
if (last && last.role === 'user') last.content.push(block);
else msgs.push({ role: 'user', content: [block] });
};
for (const e of events) {
if (e.type === 'user_message') pushUser({ type: 'text', text: e.text });
else if (e.type === 'model_response') msgs.push({ role: 'assistant', content: e.content });
else if (e.type === 'tool_result') {
const block = { type: 'tool_result', tool_use_id: e.call_id, content: e.output };
const last = msgs[msgs.length - 1];
// tool_result 必须紧跟在对应的 assistant 之后,排在同一轮里其它输入的前面
if (last && last.role === 'user') last.content.unshift(block);
else msgs.push({ role: 'user', content: [block] });
} else if (e.type === 'job_finished') {
pushUser({ type: 'text', text: `[后台任务完成] ${e.job_id}:\`${e.command}\` 退出码 ${e.exit_code}\n输出末尾:\n${e.tail}` });
}
}
return msgs;
}
module.exports = { tracer, pendingWork, rebuildMessages };

View File

@@ -0,0 +1,97 @@
// 把 runs/<run>/ 里的真实 trace 整理成回放页面需要的数据,并算出“证据”。
// 用法:node tools/build-viewer.js → 生成 ../explainers/real-run-replay.html
const fs = require('node:fs');
const path = require('node:path');
const DEMO = path.join(__dirname, '..');
const RUNS = path.join(DEMO, 'runs');
const OUT = path.join(DEMO, '..', 'explainers', 'real-run-replay.html');
const SECRET = 'LOCAL-ONLY-SECRET';
const readJsonl = (f) => (fs.existsSync(f) ? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []);
function build(runName) {
const dir = path.join(RUNS, runName);
const meta = JSON.parse(fs.readFileSync(path.join(dir, 'meta.json'), 'utf8'));
let recs = fs.readdirSync(path.join(dir, 'trace')).flatMap((f) => readJsonl(path.join(dir, 'trace', f)));
recs.sort((a, b) => a.t - b.t);
const t0 = recs[0].t;
// 路径里的本机用户目录对观众没有意义,换成相对路径
const strip = (s) => s.split(dir + '/').join('').split(dir).join('<run>');
recs = JSON.parse(strip(JSON.stringify(recs)));
recs.forEach((r) => { r.s = (r.t - t0) / 1000; });
const session = JSON.parse(strip(JSON.stringify(readJsonl(path.join(dir, 'session.jsonl')))));
session.forEach((e) => { e.s = (e.at - t0) / 1000; });
const end = recs[recs.length - 1].s;
// ---- 证据 ----
const workers = [];
for (const r of recs) {
if (r.kind === 'worker_spawn') workers.push({ pid: r.worker_pid, start: r.s, end: null, signal: null, reason: r.reason, models: 0, tools: 0 });
if (r.kind === 'worker_exit') { const w = workers.find((x) => x.pid === r.worker_pid); if (w) { w.end = r.s; w.signal = r.signal; } }
if (r.role === 'cloud-worker' && r.kind === 'model_request') { const w = workers.find((x) => x.pid === r.pid); if (w) w.models++; }
if (r.role === 'cloud-worker' && r.kind === 'tool_roundtrip') { const w = workers.find((x) => x.pid === r.pid); if (w) w.tools++; }
}
workers.forEach((w) => { if (w.end == null) w.end = end; });
const workerAlive = workers.reduce((a, w) => a + (w.end - w.start), 0);
const reqs = recs.filter((r) => r.kind === 'model_request');
const resps = recs.filter((r) => r.kind === 'model_response');
const kill = recs.find((r) => r.kind === 'inject_kill');
let killEv = null;
if (kill) {
const killedReq = reqs.filter((r) => r.pid === kill.worker_pid).pop();
const nextReq = reqs.find((r) => r.s > kill.s);
killEv = {
at: kill.s, pid: kill.worker_pid, after_seq: kill.after_seq,
lostRequest: !!killedReq && !resps.some((x) => x.pid === kill.worker_pid && x.s > killedReq.s),
nextPid: nextReq?.pid, nextAt: nextReq?.s,
identical: !!(killedReq && nextReq && JSON.stringify(killedReq.request) === JSON.stringify(nextReq.request)),
bytes: killedReq?.request_bytes,
};
}
const offline = recs.find((r) => r.kind === 'device_offline');
const onlineAgain = offline && recs.find((r) => r.kind === 'device_online' && r.s > offline.s);
let offEv = null;
if (offline && onlineAgain) {
const firstExec = recs.find((r) => r.kind === 'tool_exec' && r.s > onlineAgain.s);
const park = recs.find((r) => r.kind === 'worker_park');
offEv = {
from: offline.s, to: onlineAgain.s,
workersDuring: workers.filter((w) => w.start < onlineAgain.s && w.end > (park ? park.s + 0.1 : offline.s)).length,
parkedTool: park ? recs.find((r) => r.kind === 'dispatch_offline')?.tool : null,
parkAt: park?.s,
resumeMs: firstExec ? Math.round((firstExec.s - onlineAgain.s) * 1000) : null,
};
}
const gatewayText = reqs.map((r) => JSON.stringify(r.request)).join('\n');
const uploaded = [];
for (const r of recs.filter((x) => x.kind === 'tool_exec')) {
if (r.tool === 'read_file') uploaded.push(`文件 ${r.input.path}`);
if (r.tool === 'run_command') uploaded.push(`命令输出 \`${r.input.command}\``);
}
const jobs = recs.filter((r) => r.kind === 'job_start').map((j) => {
const x = recs.find((r) => r.kind === 'job_exit' && r.job_id === j.job_id);
return { id: j.job_id, start: j.s, end: x ? x.s : end, code: x?.exit_code, pid: j.child_pid };
});
const replies = recs.filter((r) => r.kind === 'session_append' && r.type === 'reply_to_user');
const answeredDuringJob = jobs.length && replies.some((r) => r.s > jobs[0].start + 0.5 && r.s < jobs[0].end);
const evidence = {
wall: end, workerCount: workers.length, workerAlive, workers,
gateway: { count: reqs.length, bytes: reqs.reduce((a, r) => a + r.request_bytes, 0), ms: resps.reduce((a, r) => a + r.ms, 0), secretInGateway: gatewayText.includes(SECRET) },
secretInSession: JSON.stringify(session).includes(SECRET),
uploaded,
kill: killEv, offline: offEv, jobs, answeredDuringJob,
toolExecs: recs.filter((r) => r.kind === 'tool_exec').length,
sessionEvents: session.length,
};
return { name: runName, meta, recs, session, evidence, finalDiff: fs.readFileSync(path.join(dir, 'final.diff'), 'utf8'), end };
}
const runs = fs.readdirSync(RUNS).filter((d) => fs.existsSync(path.join(RUNS, d, 'meta.json'))).sort().map(build);
const tpl = fs.readFileSync(path.join(__dirname, 'viewer-template.html'), 'utf8');
const data = JSON.stringify(runs).replace(/</g, '\\u003c');
fs.writeFileSync(OUT, tpl.replace('/*__DATA__*/null', data));
console.log(`写入 ${OUT},${runs.length} 次运行,${(fs.statSync(OUT).size / 1024).toFixed(0)} KB`);
for (const r of runs) console.log(r.name, JSON.stringify({ ...r.evidence, workers: r.evidence.workers.length }));

View File

@@ -0,0 +1,430 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>真实回放:云端 Agent Loop 改一次 bug</title>
<style>
:root{--bg:#f5f6f8;--card:#fff;--ink:#1f2328;--mut:#656d76;--line:#d8dee4;
--user:#b7791f;--gw:#c2410c;--worker:#2563eb;--sess:#7c3aed;--lap:#15803d;--job:#0f766e;--bad:#dc2626}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif}
main{max-width:1320px;margin:0 auto;padding:22px 22px 60px}
h1{font-size:24px;margin:0}
h2{font-size:18px;margin:30px 0 10px}
.mut{color:var(--mut)}
code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px}
code{background:#eef1f4;padding:1px 4px;border-radius:4px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
.top{display:flex;justify-content:space-between;align-items:flex-end;gap:16px;flex-wrap:wrap}
select,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:7px;border:1px solid var(--line);background:#fff;cursor:pointer}
button.on{background:var(--ink);color:#fff;border-color:var(--ink)}
.setup{margin-top:10px;font-size:14px}
.setup b{font-weight:600}
/* 角色 */
.roles{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
.role{border-top:4px solid}
.role h3{margin:0;font-size:15px}
.role .where{font-size:12.5px;color:var(--mut);margin-bottom:6px}
.role ul{margin:0;padding-left:17px;font-size:13.5px}
.role .no{color:var(--mut)}
.role .pid{font-family:ui-monospace,monospace;font-size:12px;color:var(--mut)}
/* 时间线 */
.scenes{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
.scenes button{font-size:13px;padding:4px 10px}
.tl{position:relative;user-select:none}
.lane{display:grid;grid-template-columns:190px 1fr;border-bottom:1px dashed #e5e8eb;min-height:38px}
.lane .lbl{font-size:13px;padding:8px 8px 0 0;font-weight:600}
.lane .lbl small{display:block;font-weight:400;color:var(--mut);font-size:11.5px;line-height:1.3}
.track{position:relative;height:38px;cursor:crosshair}
.axis .track{height:22px}
.tick{position:absolute;top:2px;font-size:11px;color:var(--mut);transform:translateX(-50%)}
.bar{position:absolute;top:9px;height:20px;border-radius:5px;font-size:11.5px;color:#fff;padding:1px 6px;white-space:nowrap;overflow:hidden;cursor:pointer;min-width:3px}
.bar.aborted{background:repeating-linear-gradient(45deg,var(--bad),var(--bad) 5px,#f87171 5px,#f87171 10px)!important}
.pt{position:absolute;top:12px;width:12px;height:12px;border-radius:50%;transform:translateX(-50%);cursor:pointer;border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.25)}
.pt.big{width:16px;height:16px;top:10px}
.pt.x{border-radius:2px;transform:translateX(-50%) rotate(45deg)}
.sel{outline:3px solid #facc15;outline-offset:1px;z-index:5}
.future{opacity:.22}
.playhead{position:absolute;top:0;bottom:0;width:2px;background:#111;pointer-events:none;z-index:6}
.scenemark{position:absolute;top:0;bottom:0;border-left:1px dotted #9aa4ae;pointer-events:none}
.scenemark span{position:absolute;top:-1px;left:3px;font-size:11px;color:#57606a;background:var(--card);padding:0 3px;white-space:nowrap}
.ctrl{display:flex;gap:8px;align-items:center;margin-top:10px;flex-wrap:wrap}
.ctrl input[type=range]{flex:1;min-width:240px}
.clock{font-family:ui-monospace,monospace;min-width:70px}
.narr{margin-top:12px;background:#fffdf3;border:1px solid #f0e2a8}
.narr h3{margin:0 0 4px;font-size:16px}
.narr p{margin:4px 0}
/* 下半区 */
.grid2{display:grid;grid-template-columns:1.05fr 1fr;gap:12px;margin-top:12px}
.snap{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
.snap .col{border-radius:8px;padding:10px;font-size:13px;min-height:200px}
.snap h4{margin:0 0 6px;font-size:13.5px}
.c-w{background:#eaf1fe}.c-s{background:#f3edfd}.c-l{background:#e7f5ec}
.chip{display:inline-block;background:#fff;border:1px solid rgba(0,0,0,.08);border-radius:6px;padding:2px 7px;margin:0 4px 4px 0;font-size:12.5px}
.empty{color:var(--mut);font-style:italic}
pre.box{background:#0d1117;color:#e6edf3;border-radius:8px;padding:10px;overflow:auto;max-height:420px;white-space:pre-wrap;word-break:break-all;margin:6px 0}
pre.mini{max-height:160px}
.diff .a{color:#7ee787}.diff .d{color:#ff7b72}.diff .h{color:#79c0ff}
.insp h3{margin:0;font-size:16px}
.insp .who{font-size:12.5px;color:var(--mut);margin:2px 0 8px}
.insp .why{background:#f6f8fa;border-radius:6px;padding:8px 10px;font-size:14px;margin-bottom:8px}
.msg{border-left:3px solid #d0d7de;padding:2px 8px;margin:4px 0;font-size:13px}
.msg .r{font-weight:600;font-size:12px;color:var(--mut)}
.blk{background:#f6f8fa;border-radius:4px;padding:3px 6px;margin:3px 0;white-space:pre-wrap;word-break:break-all;font-family:ui-monospace,monospace;font-size:12px;max-height:150px;overflow:auto}
.blk.tu{background:#fff4e5}.blk.tr{background:#e7f5ec}
details summary{cursor:pointer;color:var(--mut);font-size:13px}
/* 证据 */
.ev{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}
.ev .card h3{margin:0 0 6px;font-size:16px}
.ev .num{font-size:22px;font-weight:700}
.ev .card p{margin:5px 0;font-size:14px}
.ev .cmp{font-size:13px;color:var(--mut);border-top:1px dashed var(--line);padding-top:6px;margin-top:8px}
.ok{color:var(--lap);font-weight:600}.bad{color:var(--bad);font-weight:600}
.caveat{font-size:13.5px}
.caveat li{margin:3px 0}
@media(max-width:1000px){.roles,.grid2,.ev,.snap{grid-template-columns:1fr}}
</style>
</head>
<body>
<main>
<div class="top">
<div>
<h1>真实回放:一次改 bug,计算 / 状态 / 执行环境各在哪儿、各做了什么</h1>
<div class="mut">下面每一个点都是一条真实记录:真实的进程、真实的私有网关请求、真实的 git 仓库和测试。</div>
</div>
<div>运行:<select id="runSel"></select></div>
</div>
<div class="card setup" id="setup"></div>
<h2>四个角色:谁在哪儿、手里有什么、没有什么</h2>
<div class="roles" id="roles"></div>
<h2>时间线(点任意色块看原始数据;拖动或播放看每一刻的状态)</h2>
<div class="card">
<div class="scenes" id="scenes"></div>
<div class="tl" id="tl"></div>
<div class="ctrl">
<button id="play">▶ 播放</button>
<select id="speed"><option value="2">2×</option><option value="5" selected>5×</option><option value="12">12×</option></select>
<input type="range" id="scrub" min="0" max="100" step="0.01" value="0">
<span class="clock" id="clock">0.00 s</span>
</div>
<div class="card narr" id="narr"></div>
</div>
<div class="grid2">
<div class="card">
<h3 style="margin:0 0 8px;font-size:16px">此刻快照 <span class="mut" id="snapT" style="font-weight:400"></span></h3>
<div class="snap">
<div class="col c-w"><h4>☁️ 云端 · 活着的 Worker 进程</h4><div id="snapW"></div></div>
<div class="col c-s"><h4>☁️ 云端 · Session(已持久化)</h4><div id="snapS"></div></div>
<div class="col c-l"><h4>💻 笔记本 · 仓库与进程</h4><div id="snapL"></div></div>
</div>
</div>
<div class="card insp" id="insp"><span class="mut">点时间线上的任意色块,这里显示它的原始记录和解释。</span></div>
</div>
<h2>这次运行证明了什么(数字全部来自上面的 trace)</h2>
<div class="ev" id="ev"></div>
<h2>最终留在笔记本上的改动(未提交)</h2>
<div class="card"><pre class="box diff" id="diff"></pre></div>
<h2>需要说清楚的边界</h2>
<div class="card caveat">
<ul>
<li>“云端”在这里是本机上的独立进程和独立目录(<code>cloud-data/</code>),用来演示进程与数据的边界;笔记本仓库在 <code>laptop-home/</code>,云端进程的工作目录是空的 <code>worker-scratch/</code>。没有真的部署到云服务器。</li>
<li>模型调用是真的:走 <code>~/.pi/agent/models.json</code> 里的私有网关(Anthropic Messages 协议)。当前令牌只放行 DeepSeek-V4-Flash,其它模型返回 403。Authorization 头没有写进 trace。</li>
<li>“环境不上云”不等于“代码不出本机”:模型读过的文件、命令输出都会作为工具结果进入 Session 和网关请求(见上面“上云了什么”)。没被工具读到的东西(例如 .env 里的密码)才不会出去。</li>
<li>Worker 被杀时正在等的那次模型回应丢了,新 Worker 会重发同一请求,所以模型调用是“至少一次”。工具调用这边,守护进程按 call_id 去重;但如果守护进程本身在执行中途重启,非幂等操作(如 git push、发 PR)仍需要额外的幂等设计——这正是组会可以讨论的点。</li>
<li>这是对文章 §5.1/§5.2 架构思路的最小复现,不代表 DeepSeek Harness 或其公开原型的实现。</li>
</ul>
</div>
</main>
<script>
const RUNS = /*__DATA__*/null;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const fmt = (s) => s.toFixed(2) + ' s';
const short = (s, n = 160) => { s = String(s ?? ''); return s.length > n ? s.slice(0, n) + '…' : s; };
const COLORS = { user: 'var(--user)', gw: 'var(--gw)', worker: 'var(--worker)', session: 'var(--sess)', laptop: 'var(--lap)', jobs: 'var(--job)' };
const TYPE_COLOR = { user_message: '#b7791f', model_response: '#c2410c', tool_result: '#15803d', job_finished: '#0f766e', reply_to_user: '#b7791f', waiting_device: '#dc2626' };
const TYPE_ZH = { user_message: '用户消息', model_response: '模型回应', tool_result: '工具结果', job_finished: '后台任务完成', reply_to_user: '回复用户', waiting_device: '等待设备' };
let R, items = [], view = [0, 1], T = 0, selected = null, playing = null;
function diffHTML(d) {
if (!d) return '<span class="empty">(工作区干净)</span>';
return d.split('\n').map((l) => {
const c = l.startsWith('+') && !l.startsWith('+++') ? 'a' : l.startsWith('-') && !l.startsWith('---') ? 'd' : l.startsWith('@@') ? 'h' : '';
return `<span class="${c}">${esc(l)}</span>`;
}).join('\n');
}
// ---------- 从 trace 构建时间线元素 ----------
function buildItems() {
const recs = R.recs, ev = R.evidence;
const out = [];
const sessAt = (seq) => R.session.find((e) => e.seq === seq);
for (const r of recs) {
if (r.kind === 'note' && r.text.startsWith('用户:')) out.push({ lane: 'user', s: r.s, kind: 'pt', big: true, color: 'var(--user)', title: '你发消息', rec: r });
if (r.kind === 'session_append') {
const e = sessAt(r.seq);
out.push({ lane: 'session', s: r.s, kind: 'pt', color: TYPE_COLOR[r.type] || '#7c3aed', x: r.type === 'waiting_device', title: `#${r.seq} ${TYPE_ZH[r.type] || r.type}`, rec: r, sess: e });
if (r.type === 'reply_to_user') out.push({ lane: 'user', s: r.s, kind: 'pt', color: '#fff', ring: true, title: 'Agent 回复你', rec: r, sess: e });
}
if (r.kind === 'model_request') {
const resp = recs.find((x) => x.kind === 'model_response' && x.pid === r.pid && x.s >= r.s);
const killed = !resp && recs.find((x) => x.kind === 'inject_kill' && x.worker_pid === r.pid);
out.push({ lane: 'gw', s: r.s, e: resp ? resp.s : killed ? killed.s : r.s + 0.3, kind: 'bar', color: 'var(--gw)', aborted: !resp, label: resp ? `${resp.ms}ms` : '结果丢失', title: resp ? `模型调用(Worker ${r.pid})` : `模型调用被中断(Worker ${r.pid} 被杀)`, rec: r, resp });
}
if (r.kind === 'tool_exec') {
out.push({ lane: 'laptop', s: r.s, kind: 'pt', big: r.tool === 'edit_file' || r.tool === 'write_file', color: r.tool.includes('edit') || r.tool.includes('write') ? '#b91c1c' : 'var(--lap)', title: `执行 ${r.tool}`, rec: r });
if (r.tool === 'run_command') out.push({ lane: 'jobs', s: r.s - r.ms / 1000, e: r.s, kind: 'bar', color: '#64748b', label: short(r.input.command, 24), title: `同步命令:${r.input.command}`, rec: r });
}
if (r.kind === 'inject_kill') out.push({ lane: 'worker', s: r.s, kind: 'pt', big: true, x: true, color: 'var(--bad)', title: `kill -9 Worker ${r.worker_pid}`, rec: r });
if (r.kind === 'dispatch_offline') out.push({ lane: 'laptop', s: r.s, kind: 'pt', x: true, big: true, color: 'var(--bad)', title: '派发失败:设备离线', rec: r });
}
for (const w of ev.workers) out.push({ lane: 'worker', s: w.start, e: w.end, kind: 'bar', color: w.signal ? 'var(--bad)' : 'var(--worker)', label: `pid ${w.pid}`, title: `Worker 进程 pid ${w.pid}${w.signal ? '(被 ' + w.signal + ')' : ''}`, worker: w });
const starts = recs.filter((r) => r.kind === 'daemon_start');
for (const d of starts) {
const stop = recs.find((r) => r.kind === 'daemon_stop' && r.pid === d.pid);
out.push({ lane: 'laptop', s: d.s, e: stop ? stop.s : R.end, kind: 'bar', color: 'rgba(21,128,61,.18)', fg: '#14532d', label: `守护进程 pid ${d.pid}`, title: `守护进程 pid ${d.pid}(在线)`, rec: d, under: true });
}
for (const j of ev.jobs) out.push({ lane: 'jobs', s: j.start, e: j.end, kind: 'bar', color: 'var(--job)', label: `${j.id} npm test · pid ${j.pid}`, title: `后台任务 ${j.id}:npm test`, job: j, rec: recs.find((r) => r.kind === 'job_start' && r.job_id === j.id) });
out.forEach((it, i) => (it.id = i));
return out;
}
const LANES = [
['user', '你', 'IM / 网页'],
['gw', '私有网关', '模型推理(dd-ai-api)'],
['worker', '云端 · Loop Worker', '无状态进程,用完即退'],
['session', '云端 · Session', '只追加的事件日志'],
['laptop', '笔记本 · 守护进程', '在仓库里执行工具'],
['jobs', '笔记本 · 子进程', '测试 / 命令'],
];
const pos = (s) => ((s - view[0]) / (view[1] - view[0])) * 100;
function renderTL() {
const tl = $('tl');
const span = view[1] - view[0];
const step = span > 40 ? 5 : span > 15 ? 2 : span > 6 ? 1 : 0.5;
let html = '<div class="lane axis"><div class="lbl"></div><div class="track">';
for (let t = Math.ceil(view[0] / step) * step; t <= view[1]; t += step) html += `<span class="tick" style="left:${pos(t)}%">${+t.toFixed(1)}s</span>`;
html += '</div></div>';
for (const [id, name, sub] of LANES) {
html += `<div class="lane"><div class="lbl" style="color:${COLORS[id]}">${name}<small>${sub}</small></div><div class="track" data-lane="${id}">`;
for (const it of items.filter((x) => x.lane === id).sort((a, b) => (b.under ? 1 : 0) - (a.under ? 1 : 0))) {
const end = it.e ?? it.s;
if (end < view[0] || it.s > view[1]) continue;
const fut = it.s > T ? ' future' : '';
const sel = selected === it.id ? ' sel' : '';
if (it.kind === 'bar') {
const l = Math.max(0, pos(it.s)), r = Math.min(100, pos(end));
html += `<div class="bar${it.aborted ? ' aborted' : ''}${fut}${sel}" data-id="${it.id}" title="${esc(it.title)}" style="left:${l}%;width:${Math.max(r - l, 0.25)}%;background:${it.color};color:${it.fg || '#fff'};${it.under ? 'z-index:0;' : 'z-index:2;'}">${esc(it.label || '')}</div>`;
} else {
html += `<div class="pt${it.big ? ' big' : ''}${it.x ? ' x' : ''}${fut}${sel}" data-id="${it.id}" title="${esc(it.title)} @ ${fmt(it.s)}" style="left:${pos(it.s)}%;background:${it.color};${it.ring ? 'box-shadow:0 0 0 2px var(--user);' : ''}z-index:3"></div>`;
}
}
html += '</div></div>';
}
html += `<div class="playhead" style="left:calc(190px + (100% - 190px) * ${Math.min(Math.max(pos(T), 0), 100) / 100})"></div>`;
for (const sc of R.recs.filter((r) => r.kind === 'scene' && r.id !== 'END')) {
if (sc.s < view[0] || sc.s > view[1]) continue;
html += `<div class="scenemark" style="left:calc(190px + (100% - 190px) * ${pos(sc.s) / 100})"><span>${sc.id}</span></div>`;
}
tl.innerHTML = html;
}
$('tl').addEventListener('click', (e) => {
const el = e.target.closest('[data-id]');
if (el) { select(+el.dataset.id); return; }
const tr = e.target.closest('.track');
if (tr) { const b = tr.getBoundingClientRect(); setT(view[0] + ((e.clientX - b.left) / b.width) * (view[1] - view[0])); }
});
// ---------- 场景 ----------
function scenes() {
const sc = R.recs.filter((r) => r.kind === 'scene');
return sc.filter((s) => s.id !== 'END' && s.id !== 'D2').map((s, i, arr) => {
const next = sc.find((x) => x.s > s.s && x.id !== 'D2');
return { ...s, start: s.s, stop: next ? next.s : R.end };
});
}
function narrative(id) {
const ev = R.evidence, w = ev.workers, k = ev.kill, o = ev.offline, j = ev.jobs;
const first = w[0];
const N = {
ALL: ['全程', `一共 ${fmt(ev.wall)}。云端先后起了 <b>${ev.workerCount} 个</b> Worker 进程,加起来只活了 <b>${fmt(ev.workerAlive)}</b>;${ev.gateway.count} 次网关调用;${ev.toolExecs} 次工具执行全部发生在笔记本上。点上面的场景按钮放大看每一段。`],
A: ['A. 提需求:云端起 Worker,工具穿到笔记本执行', `你的消息先写进 Session(紫色点)。调度器发现“有新输入、没有 Worker 在跑”,起了 <b>Worker pid ${first?.pid}</b>,它的工作目录是空的 <code>worker-scratch/</code>——<b>云端手上没有代码</b>。Worker 把日志翻译成 messages 发给私有网关(橙色条),模型回 <code>tool_use: read_file</code>;这个调用被派给你的笔记本,<b>守护进程在仓库里读文件</b>(绿点),内容回到云端写进 Session,再进入下一次模型请求。红色大点是 <code>edit_file</code>:改动落在笔记本的仓库里。`],
B: ['B. kill -9 云端 Worker:进程丢了,什么都没丢', k ? `${fmt(k.at)} 时脚本对 <b>Worker pid ${k.pid}</b> 发 SIGKILL。它当时${k.lostRequest ? '<b>正在等网关回应,这次请求的结果丢了</b>(橙色斜纹)' : '没有在途请求'}。${fmt(k.nextAt - k.at)} 后调度器从日志判断“还有没处理的工具结果”,起了 <b>Worker pid ${k.nextPid}</b>。新 Worker 重建出的请求体与被杀的那个${k.identical ? '<b>逐字节相同</b>' : '不同'}(${k.bytes} 字节)——因为它知道的一切都来自 Session。笔记本上的改动、守护进程都不受影响。` : '本次运行没有触发崩溃注入。'],
C: ['C. 回归在笔记本后台跑,云端不陪着等', j[0] ? `<code>npm test</code> 在笔记本上作为子进程 pid ${j[0].pid} 跑了 ${fmt(j[0].end - j[0].start)}。启动后 Worker 立刻结束本轮并<b>退出</b>——测试期间云端没有为它驻留进程。你追问 diff,调度器起了新 Worker,<code>git diff</code> 在笔记本上执行,${ev.answeredDuringJob ? '<b>回答时测试还没跑完</b>' : '回答在测试结束之后'}。测试结束,守护进程把 <code>job_finished</code> 写回 Session,又唤醒一个 Worker 汇报结果。` : ''],
D: ['D. 合上笔记本:任务挂在日志里,云端零进程', o ? `守护进程退出(模拟合盖)。新需求到达,Worker 调模型拿到 <code>${o.parkedTool}</code>,派发时发现设备离线:写下 <code>waiting_device</code>,<b>Worker 直接退出,不空等</b>。离线 ${fmt(o.to - o.from)} 期间,Worker 进程数 = <b>${o.workersDuring}</b>,模型调用 = 0。没执行的工具调用就是日志里一个“还没有结果的 tool_use”。` : ''],
E: ['E. 打开笔记本:自动续上', o ? `守护进程重新连上(新 pid)。调度器读日志发现那个悬着的 <code>${o.parkedTool}</code>,起 Worker 后<b>不重新问模型</b>,直接派发给笔记本执行,从上线到工具执行用了 <b>${o.resumeMs} ms</b>。之后补测试、后台回归、汇报。` : ''],
};
return N[id] || N.ALL;
}
function renderScenes() {
const sc = scenes();
$('scenes').innerHTML = `<button data-sc="ALL" class="on">全程</button>` + sc.map((s) => `<button data-sc="${s.id}">${esc(s.id)} · ${esc(s.title)}</button>`).join('');
$('scenes').onclick = (e) => {
const b = e.target.closest('button'); if (!b) return;
[...$('scenes').children].forEach((x) => x.classList.toggle('on', x === b));
const id = b.dataset.sc;
if (id === 'ALL') { view = [0, R.end + 0.5]; setNarr('ALL'); setT(T); return; }
const s = sc.find((x) => x.id === id);
const pad = (s.stop - s.start) * 0.04 + 0.2;
view = [Math.max(0, s.start - pad), s.stop + pad];
setNarr(id); setT(s.start + 0.01);
};
}
function setNarr(id) { const [h, p] = narrative(id); $('narr').innerHTML = `<h3>${h}</h3><p>${p}</p>`; }
// ---------- 快照 ----------
function renderSnap() {
$('snapT').textContent = `t = ${fmt(T)}`;
const ws = R.evidence.workers.filter((w) => w.start <= T && w.end > T);
$('snapW').innerHTML = ws.length
? ws.map((w) => `<div class="chip">Worker pid ${w.pid}</div>`).join('') + `<p class="mut" style="font-size:12px">内存里只有从日志重建的 messages。工作目录:空。持有:网关密钥。</p>`
: `<div class="empty">0 个进程。没有任何东西在等。</div>`;
const evs = R.session.filter((e) => e.s <= T);
$('snapS').innerHTML = `<div><b>${evs.length}</b> 条事件</div>` + (evs.slice(-6).reverse().map((e) => `<div class="chip" style="display:block">#${e.seq} ${TYPE_ZH[e.type] || e.type}<br><span class="mut">${esc(short(e.type === 'model_response' ? e.content.map((b) => b.type === 'tool_use' ? '→ ' + b.name : b.type === 'text' ? b.text : '').filter(Boolean).join(' ') : e.text || e.output || e.tail || e.tool || '', 70))}</span></div>`).join('') || '<div class="empty">空</div>');
const daemons = items.filter((i) => i.lane === 'laptop' && i.under);
const on = daemons.find((d) => d.s <= T && d.e > T);
const snap = [...R.recs].reverse().find((r) => r.s <= T && (r.kind === 'tool_exec' || r.kind === 'daemon_start'));
const repo = snap ? (snap.repo_after || snap.repo_state) : null;
const jobs = R.evidence.jobs.filter((j) => j.start <= T && j.end > T);
$('snapL').innerHTML = `<div>${on ? `<span class="ok">● 在线</span> 守护进程 pid ${on.rec.pid}` : '<span class="bad">● 离线(合盖)</span>'}</div>` +
`<div style="margin-top:6px"><b>git status</b></div><pre class="box mini">${esc(repo?.status || '(干净)')}</pre>` +
(jobs.length ? jobs.map((j) => `<div class="chip">运行中:${j.id} npm test(pid ${j.pid})</div>`).join('') : '<div class="mut" style="font-size:12px">没有子进程在跑</div>') +
`<div class="mut" style="font-size:12px;margin-top:4px">一直在这里:仓库、.env(含内网密码)、node 工具链</div>`;
}
// ---------- 详情 ----------
function msgsHTML(messages) {
return messages.map((m) => `<div class="msg"><div class="r">${m.role}</div>${m.content.map((b) => {
if (b.type === 'text') return `<div class="blk">${esc(short(b.text, 400))}</div>`;
if (b.type === 'thinking') return `<div class="blk" style="opacity:.7">💭 ${esc(short(b.thinking, 200))}</div>`;
if (b.type === 'tool_use') return `<div class="blk tu">→ ${esc(b.name)} ${esc(short(JSON.stringify(b.input), 300))}</div>`;
if (b.type === 'tool_result') return `<div class="blk tr">← 工具结果(${b.tool_use_id.slice(-6)})\n${esc(short(b.content, 500))}</div>`;
return `<div class="blk">${esc(b.type)}</div>`;
}).join('')}</div>`).join('');
}
function select(id) {
selected = id;
const it = items[id];
const r = it.rec || {};
let who = '', why = '', body = '';
if (it.lane === 'gw') {
who = `发起方:云端 Worker pid ${r.pid} → ${esc(r.url)}`;
why = it.resp ? `Worker 从 Session 的 ${R.session.filter((e) => e.s <= r.s).length} 条事件重建出 ${r.message_count} 条 messages(${r.request_bytes} 字节)发给私有网关,${it.resp.ms} ms 后拿到回应:<b>${esc(it.resp.stop_reason)}</b>。注意请求里包含之前工具读到的代码——这就是“上云的部分”。` : `这次请求发出后,Worker 被 kill -9,回应没人接收。下一个 Worker 会从日志重建并重发同一请求。`;
body = `<b>请求 messages</b>${msgsHTML(r.request.messages)}` + (it.resp ? `<b>模型回应</b>${msgsHTML([{ role: 'assistant', content: it.resp.content }])}<div class="mut" style="font-size:12px">usage: ${esc(JSON.stringify(it.resp.usage))}</div>` : '') +
`<details><summary>原始请求体 JSON(不含 Authorization 头)</summary><pre class="box">${esc(JSON.stringify(r.request, null, 1))}</pre></details>`;
} else if (it.lane === 'laptop' && r.kind === 'tool_exec') {
who = `执行方:笔记本守护进程 pid ${r.pid},目录 <code>${esc(r.cwd)}</code>`;
why = `云端只发来了 <code>${esc(r.tool)}</code> 和参数,真正读写文件、跑命令的是你电脑上的这个进程,耗时 ${r.ms} ms。${r.replay ? '(重复派发,直接返回缓存结果,没有重复执行)' : ''}执行后仓库状态如下。`;
body = `<b>参数</b><pre class="box mini">${esc(JSON.stringify(r.input, null, 1))}</pre><b>返回给云端的结果</b><pre class="box mini">${esc(r.output)}</pre><b>执行后 git diff</b><pre class="box diff">${diffHTML(r.repo_after?.diff)}</pre>`;
} else if (it.worker) {
const w = it.worker;
who = `云端 Worker pid ${w.pid},${fmt(w.start)} → ${fmt(w.end)},活了 ${fmt(w.end - w.start)}`;
why = `拉起原因:<code>${w.reason}</code>(${w.reason === 'model' ? '日志里有新输入,该调模型' : '日志里有没结果的工具调用,该派发'})。期间调了 ${w.models} 次模型、派发了 ${w.tools} 次工具。${w.signal ? '<b>被 ' + w.signal + ' 杀掉。</b>' : '干完就自己退出了。'}`;
const recs = R.recs.filter((x) => x.pid === w.pid && x.role === 'cloud-worker').map((x) => `${fmt(x.s)} ${x.kind} ${x.decision || x.tool || x.stop_reason || x.reason || ''}`);
body = `<b>这个进程的全部动作</b><pre class="box">${esc(recs.join('\n'))}</pre>`;
} else if (it.lane === 'session' || it.sess) {
const e = it.sess;
who = `写入方:${esc(r.by || '')} · 存储:cloud-data/sessions/*.jsonl`;
why = `Session 是唯一需要持久保存的东西。Worker 每做一步都先写到这里;任何新 Worker 都从这里读出完整历史。`;
body = `<pre class="box">${esc(JSON.stringify(e, null, 1))}</pre>`;
} else if (it.job) {
who = `笔记本子进程 pid ${it.job.pid},${fmt(it.job.start)} → ${fmt(it.job.end)}`;
why = `后台跑的完整回归(含读本地 .env 的集成测试)。启动时守护进程立刻返回 job_id,Worker 不用等;结束时守护进程把退出码和输出尾部写进 Session,成为新的输入事件。退出码:${it.job.code}。`;
const fin = R.session.find((e) => e.type === 'job_finished' && e.job_id === it.job.id);
body = fin ? `<pre class="box">${esc(fin.tail)}</pre>` : '';
} else if (it.under) {
who = `笔记本守护进程 pid ${r.pid}`;
why = `只负责领取工具调用、在仓库里执行、回传结果。不存会话、没有模型密钥。它主动长轮询连接云端(笔记本在 NAT 后面,云端连不进来)。`;
body = `<b>启动时仓库状态</b><pre class="box mini">${esc(r.repo_state?.status || '(干净)')} HEAD ${esc(r.repo_state?.head)}</pre>`;
} else {
who = esc(r.role || '');
why = r.kind === 'inject_kill' ? '演示脚本模拟云端机器故障 / 发版 / 缩容:直接 SIGKILL 正在运行的 Worker。' : r.kind === 'dispatch_offline' ? '云端想把工具调用发给笔记本,但设备不在线。Worker 记下 waiting_device 后退出。' : r.kind === 'note' ? esc(r.text) : '';
body = `<pre class="box">${esc(JSON.stringify(r, null, 1))}</pre>`;
}
$('insp').innerHTML = `<h3>${esc(it.title)} <span class="mut" style="font-weight:400;font-size:13px">@ ${fmt(it.s)}</span></h3><div class="who">${who}</div><div class="why">${why}</div>${body}`;
renderTL();
}
// ---------- 证据 ----------
function renderEvidence() {
const ev = R.evidence, k = ev.kill, o = ev.offline;
const card = (h, num, p, cmp) => `<div class="card"><h3>${h}</h3><div class="num">${num}</div><p>${p}</p><div class="cmp">本地 CLI 对照:${cmp}</div></div>`;
$('ev').innerHTML = [
card('① 计算可以随时丢', k ? (k.identical ? '<span class="ok">请求逐字节一致</span>' : '<span class="bad">请求不一致</span>') : '—',
k ? `Worker pid ${k.pid} 被 kill -9,${fmt(k.nextAt - k.at)} 后 pid ${k.nextPid} 接手,重建的模型请求与被杀前发出的完全相同(${k.bytes} 字节)。任务照常完成。` : '',
'Loop 进程就是会话本身。进程被杀,内存里的上下文跟着没了,要靠手动 resume 从本地会话文件恢复,后台测试通常也被一起清理。'),
card('② 云端不为“等待”驻留', `${fmt(ev.workerAlive)} / ${fmt(ev.wall)}`,
`整个任务 ${fmt(ev.wall)},云端先后 ${ev.workerCount} 个 Worker,累计只活了 ${fmt(ev.workerAlive)}(${Math.round((ev.workerAlive / ev.wall) * 100)}%)。等测试、等你、等笔记本开机时都是 0 个进程。`,
'进程从开始到结束一直开着(这里就是 ' + fmt(ev.wall) + '),终端关了就停。'),
card('③ 设备离线不丢任务', o ? `离线 ${fmt(o.to - o.from)},上线后 ${o.resumeMs} ms 续上` : '—',
o ? `合盖期间 ${o.parkedTool} 挂在日志里,Worker 数 = ${o.workersDuring}。守护进程一上线,调度器直接派发这个悬着的调用,没有重新问模型。` : '',
'合盖等于 Agent 暂停;在途的网络请求可能超时报错,需要你回来重新驱动。'),
card('④ 环境不搬家(但看过的会上云)', ev.gateway.secretInGateway || ev.secretInSession ? '<span class="bad">.env 密码出现在云端</span>' : '<span class="ok">.env 密码 0 次出现在云端</span>',
`${ev.gateway.count} 次网关请求共 ${(ev.gateway.bytes / 1024).toFixed(1)} KB。上云的只有工具结果:${ev.uploaded.map(esc).join('、')}。仓库、.env、node_modules、git 历史都没离开 <code>laptop-home/</code>;集成测试读 .env 是在笔记本上发生的。`,
'这一点和本地 CLI 一样:工具也在本地跑,读到的内容同样会发给模型 API。差别在于 Loop 和会话也在本地。'),
].join('') + (ev.answeredDuringJob ? `<div class="card" style="grid-column:1/-1"><h3>附带:异步工具让追问不用排队</h3><p>完整回归在笔记本上跑的时候,你追问的 diff 已经被回答(见场景 C)。这正好把上一轮讨论的“异步 Agent Loop”和“计算与状态分离”连起来:工具结果本来就是写进日志的事件,所以谁来处理、什么时候处理都可以解耦。</p></div>` : '');
}
// ---------- 播放 ----------
function setT(t) {
T = Math.max(0, Math.min(R.end, t));
$('scrub').value = T; $('clock').textContent = fmt(T);
renderTL(); renderSnap();
}
$('scrub').oninput = (e) => { setT(+e.target.value); };
$('play').onclick = () => {
if (playing) { clearInterval(playing); playing = null; $('play').textContent = '▶ 播放'; return; }
if (T >= R.end - 0.05) setT(view[0]);
$('play').textContent = '⏸ 暂停';
let last = performance.now();
playing = setInterval(() => {
const now = performance.now(); const dt = (now - last) / 1000 * +$('speed').value; last = now;
setT(T + dt);
if (T >= Math.min(R.end, view[1])) { clearInterval(playing); playing = null; $('play').textContent = '▶ 播放'; }
}, 50);
};
function loadRun(i) {
R = RUNS[i];
items = buildItems();
view = [0, R.end + 0.5]; selected = null;
$('scrub').max = R.end;
const m = R.meta, ev = R.evidence;
$('setup').innerHTML = `<b>这次运行:</b>${new Date(m.finished).toLocaleString('zh-CN')} · 模型 <code>${esc(m.model)}</code> 经私有网关 <code>${esc(m.gateway)}</code>(Anthropic Messages 协议,配置来自 <code>~/.pi/agent/models.json</code>) · 会话 <code>${esc(m.session)}</code> · 设备 <code>${esc(m.device)}</code><br>
<b>剧本:</b>A 提需求 → B 改完文件后 kill -9 云端 Worker → C 回归在后台跑时追问 → D 合上笔记本再提新需求 → E 打开笔记本。剧本只控制“什么时候发消息、什么时候杀进程/断设备”,模型说什么、调什么工具都是真实返回的。`;
const pids = (role) => [...new Set(R.recs.filter((r) => r.role === role).map((r) => r.pid))];
const role = (color, h, where, has, not, pidline) => `<div class="card role" style="border-top-color:${color}"><h3>${h}</h3><div class="where">${where}</div><ul>${has.map((x) => `<li>${x}</li>`).join('')}${not.map((x) => `<li class="no">✗ ${x}</li>`).join('')}</ul><div class="pid">${pidline}</div></div>`;
$('roles').innerHTML = [
role('var(--sess)', '☁️ 云端控制面 + Session', 'cloud/server.js · 常驻', ['把事件追加进 Session(唯一持久的东西)', '调度:有活且没人在干 → 起 Worker', '转发工具调用给设备'], ['不调模型', '不碰代码'], `pid ${pids('cloud-server').join(', ')}`),
role('var(--worker)', '☁️ 云端 Loop Worker', 'cloud/loop-worker.js · 按需拉起', ['读日志 → 重建 messages → 调网关', '把 tool_use 派给设备,结果写回日志', '持有网关密钥'], ['没有仓库(cwd 是空目录)', '不保存任何状态,随时可杀'], `本次共 ${ev.workerCount} 个进程:pid ${ev.workers.map((w) => w.pid).join(', ')}`),
role('var(--gw)', '🌐 私有网关 · 模型', esc(m.gateway), ['只做推理:收 messages,回 text / tool_use'], ['不知道会话存在哪', '不执行任何工具'], `${ev.gateway.count} 次请求`),
role('var(--lap)', '💻 笔记本守护进程', 'laptop/daemon.js · 在你电脑上', ['在仓库里读写文件、跑命令、跑测试', '主动连云端领任务(NAT 友好)', '仓库 / .env / 工具链都在这'], ['不存会话', '没有模型密钥'], `pid ${pids('laptop-daemon').join(', ')}(合盖前后各一个)`),
].join('');
renderScenes(); setNarr('ALL'); renderEvidence();
$('diff').innerHTML = diffHTML(R.finalDiff);
$('insp').innerHTML = '<span class="mut">点时间线上的任意色块,这里显示它的原始记录和解释。</span>';
setT(R.end);
}
$('runSel').innerHTML = RUNS.map((r, i) => `<option value="${i}">${r.name}(${r.meta.ok ? '成功' : '失败'},${r.end.toFixed(0)} s)</option>`).join('');
$('runSel').onchange = (e) => loadRun(+e.target.value);
loadRun(0);
</script>
</body>
</html>