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

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>