| 从未触发 |
| resp.write() 返回值 | 断开后仍恒为 true(拿不到背压信号) |
| resp.destroyed / req.destroyed | 全程 false |
结论:/trace/stream 无法在访客离开时自行结束,连"写失败探测"这条退路也没有。 因此流的生命周期必须由服务端强制上界兜底,否则被遗弃的连接会无限累积 (docs/security.md §0 威胁 3「资源滥用」)。本轮据此定下三条硬约束(见下「设计决策」D4)。 探针文件用后即删,记录留在本节。
D1 · 端点落在新建的 trace 服务(docs/architecture.md 总览图既定;dev.ps1 与 apps/api/trace/README.md 都已为其预留)。 Encore.ts 的服务由「含端点的顶层目录」推导(实测 get_services:当前 agent/spike/system 三个; 只有 README 的 trace/notes/admin/metrics 不算服务),故新增 trace/stream.ts 即自动成为 trace 服务。 代价必须说清:trace 与 agent 有两处真实耦合——共享 agent 库、共享进程内事件队列。 处理方式见 D2/D3,两者都不走「服务 A import 服务 B 内部实现」。
D2 · 进程内事件总线放中立共享模块 apps/api/shared/trace-bus.ts: agent 服务(生产者,runtime.ts 的 capture())与 trace 服务(消费者)都 import 它, 互不依赖对方目录。总线不含任何 Encore 基础设施声明,因此不会被推导成服务。 agent/sse.ts 同理移到 shared/sse.ts(两个服务都要写 SSE 帧)。
D3 · trace 服务读库走 SQLDatabase.named("agent")(Encore 官方的跨服务库引用 API, encore.dev/storage/sqldb 已有该静态方法)。trace 只读、不拥有 schema、不加迁移; trace_events 的建表与迁移仍归 agent。为此 trace 自带一个只读查询模块 trace/store.ts, 不 import agent/store。
D4 · 流的生命周期(由前置实测倒逼;实现中又被自测证伪一次,已改设计):
断开探测不到 ⇒ 服务端只有两种确定信息可用来结束一条流:
外加两条容量闸:MAX_STREAMS_PER_SESSION = 8(单会话公平上限)、MAX_TOTAL_STREAMS = 64(全站),超出 429。
本条最初写的是「同会话超额时逐出最旧的一条」,实现后被自己的实测证伪,已废弃。 现象:浏览器面板聊到第四轮就不再更新。日志显示它被 superseded 掉了。原因是这个启发式 在本场景里方向是反的——真正在看的那条连接恰恰是最旧的(访客一进来就连上了), 而 React 重挂载、调试探针、curl 这些短命连接都比它新;"越老越可能被遗弃"于是每次都 精准掐死唯一活着的观众,真正死掉的连接反而留到超时。教训:分不清死活时不要用时间序 猜,要让客户端给出确定信息(clientId)。回归用例见 trace/stream.test.ts。
D5 · 脱敏点仍然只有一个:采集时(agent/runtime.ts → sanitizeEvent)。 库里存的就是脱敏后的数据,SSE 是纯读取通道,不做二次处理。 docs/security.md §2 要求"SSE 推送前 sanitize",本设计以"入口即脱敏,库与 SSE 都不可能拿到原文"满足它—— 比在出口再洗一遍更强(否则库里会留着原文)。验收项 7 用 SQL + SSE 原始流双向抽查。
D6 · 回放与 live tail 的接缝:DB 只在水位 500 或每轮收尾时 flush,单靠库回放会漏掉"当前这一轮"。 故总线为每个会话保留 ring buffer(上限 1000 条,典型事件几百字节;硬上限 8KB/条见 events.ts)。 连接建立顺序固定为:先订阅(live 帧暂存本地)→ 读库回放 → 读 buffer 补齐 → 按 seq 去重合并发出 → 再放行暂存的 live 帧。 disposeSession 里 buffer 的丢弃必须排在最终 flush 之后,否则会出现"库里还没有、buffer 已经没了"的空窗。
D7 · 三视图 = 同一事件流的三种投影(docs/architecture.md 原话),投影逻辑集中在 apps/web/lib/trace-view.ts 纯函数里,组件只负责渲染。真实数据与演示数据的差异按下表处理 (都不改样式,只改喂进去的数据):
| 视图 | 演示数据 | 真实投影 |
|---|---|---|
| Timeline | 手写两个 turn | 按 turn_start 出现次数切组(标签仍是 Turn N);首个 turn_start 之前的事件并入 Turn 1;连续同类型事件折叠成一行(message_update ×183),否则一轮 180+ 个 delta 事件会把瀑布冲垮;ms = 到下一个事件的时间差,最后一行流式中显示 … + 脉动 |
| Timeline 详情卡(画板 1b) | 写死的 context-injector 演示 | INPUT = 该事件脱敏后 data 的单行预览;EXTENSION RETURNED = xray-observer;DIFF = (未改写)——本站唯一的扩展就是只观测不改写的观测者,如实呈现即可,不编造链式修改 |
| Chain View | 写死 truncator/annotator 两级 | 取会话内最近一个 chain 模式事件,RAW OUTPUT = 其 data 预览,链上一级 xray-observer 徽标"未修改";尚无 chain 事件时显示等待态 |
| Lifecycle Map | 写死 12 节点状态 | 节点骨架不变(设计稿终稿),fired/active/pending 与 ×N 全部由事件计数推导;tool_call/tool_execution/tool_result 在 noTools:'all' 下恒为 pending——这是实情,不造假 |
D8 · 顶栏 47 events 改真实。它与三视图同源(轨迹流),留着假数字与旁边的真实面板并列更糟。 tokens / cost / ctx 保持演示数据(计量与限额是 R7/R8 的事,本轮不碰)。
D9 · apps/api/spike/ 整体删除(rounds/BACKLOG.md 既定:R4 落地 trace 服务时移除)。 R1 的能力已全部转正:34 事件与脱敏在 agent/events.ts、SSE 工具在 shared/sse.ts、 两条流在 agent/ask.ts 与 trace/stream.ts。内存基线端点 spike/mem.ts 一并移除, 后续测量改用容器 stats(R9)。
| 路径 | 内容 |
|---|---|
| apps/api/shared/trace-bus.ts | 进程内事件总线:publish / subscribe / recent(afterSeq) / dropSession;每会话 ring buffer(上限 1000),无 Encore 声明 |
| apps/api/shared/sse.ts | 由 agent/sse.ts 原样迁入(两个服务共用) |
| apps/api/shared/redact.ts | 由 agent/events.ts 下沉的凭据脱敏原语(DROP_KEY/sanitizeValue/previewText/safeErrorText)。trace 也要按同一口径写日志,不能为一个工具函数去 import agent 的内部模块;agent/events.ts 保留 re-export,R3 调用点不改 |
| apps/api/shared/trace-bus.test.ts | 总线纯逻辑测试:环形缓冲上限、afterSeq 切片、订阅/退订、dropSession |
| apps/api/trace/stream.ts | GET /trace/stream(api.raw SSE):参数校验 → 会话存在性(404)→ 同 clientId 让位 + 名额(429)→ 订阅 → 库回放 → buffer 补齐 → 去重合并 → live tail;15s 心跳;MAX_STREAM_MS 到点 bye |
| apps/api/trace/store.ts | 只读查询(SQLDatabase.named("agent")):sessionExists() / listTraceEvents(sessionId, afterSeq, limit)(取最新 N 条,limit 兜住超长会话) |
| apps/api/trace/stream.test.ts | 纯逻辑测试:查询参数解析(含 clientId 形态)、回放/live 去重合并、让位判定(含「别再按最旧逐出」的回归用例) |
| apps/api/trace/README.md(改) | 「待实现」→ 实际契约与安全口径 |
| apps/api/agent/runtime.ts(改) | capture() 里事件入队后 publish 到总线;disposeSession 最终 flush 之后再 dropSession |
| apps/api/agent/ask.ts(改) | sse 导入路径改指 shared/sse |
| apps/api/agent/sse.ts | 删除(迁入 shared) |
| apps/api/spike/** | 整体删除(D9) |
| dev.ps1(改) | $hostedServices 补 trace(漏补的表现是端点静默 404) |
| apps/web/lib/sse-parse.ts | 由 agent-api.ts 抽出的 SSE 分帧器(两个流共用) |
| apps/web/lib/trace-api.ts | 轨迹流客户端:openTraceStream(),fetch + ReadableStream,afterSeq 续读 + 退避重连,404 停止 |
| apps/web/lib/trace-view.ts | 三视图投影纯函数:toTimelineTurns() / toChainView() / toLifecycleNodes() + 时长格式化 |
| apps/web/lib/types.ts(改) | 新增 TraceEvent / ChainView 数据类型;TraceRow 增加详情所需字段 |
| apps/web/components/workbench/{TimelineView,ChainView,LifecycleMap}.tsx(改) | 由 props 接收真实投影;样式、布局、className、token、动画参数零改动 |
| apps/web/components/workbench/Workbench.tsx(改) | 删除 usePlayback 演示回放,改为订阅轨迹流;事件计数接真实值 |
| apps/web/lib/demo-data.ts(改) | 移除已被真实数据取代且不再被引用的导出 |
| docs/architecture.md(改) | 事件清单指针 spike/events.ts → agent/events.ts;补记 trace 服务已落地与断连不可探测的约束 |
| .gitignore(改) | 忽略 bun.lock:bun 只是运行时不负责依赖解析(规则 11),它执行脚本时顺手生成的 lockfile 提交进去会造成「两份 lockfile」的错觉,还会让工作区每次 dev.ps1 test 后变脏、卡住 dev.ps1 build 的洁净检查 |
| rounds/BACKLOG.md(改) | 勾销 spike 移除条与两条 R-BUN 顺延条;补记本轮 3 条新发现 |
| ROUNDS.md(改) | 进度表 R4 收口 |
| # | 检查 | 命令 / 期望 |
|---|---|---|
| 1 | 编译 | dev.ps1 check 通过;apps/web npx tsc --noEmit 无错 |
| 2 | 测试 | dev.ps1 test 全绿(R2/R3 既有用例 + 本轮总线/合并/名额/投影用例) |
| 3 | 实时出事件 | 浏览器提问过程中右栏 Timeline 边聊边长;curl -N /trace/stream 与 /agent/ask 并行时观察到 event: trace 持续到达 |
| 4 | 历史回放 | 刷新页面 → 点开历史会话 → 三视图从库回放出该会话完整轨迹(seq 连续、turn 分组正确);encore 重启后仍可回放 |
| 5 | 断线续读 | 掐断 SSE 后带 afterSeq=<lastSeq> 重连 → 只补发之后的事件,无重复、无缺口;前端自动重连后 Timeline 不重影 |
| 6 | 跨回收边界 | 会话被空闲回收后再提问 → 轨迹 seq 跨边界连续,回放与 live 无缝(D6 的 buffer/flush 顺序生效) |
| 7 | SSE 脱敏 | 抓 /trace/stream 原始流全文,对 authorization|api[-_]?key|sk-[A-Za-z0-9]{10}|bearer 命中数 0;同一会话 SQL 侧 data::text 命中数同样为 0 |
| 8 | 名额与上界 | 同 clientId 重连 → 旧连接收到 bye{reason:"superseded"} 并结束,不同观众之间互不影响;同会话第 9 个观众 → 429;MAX_STREAM_MS 到点收到 bye{reason:"max-duration"}(实测时临时调小,测后改回并复跑 check+test) |
| 9 | 三视图投影正确 | Chain View 显示最近 chain 事件;Lifecycle 的 ×N 与 SQL GROUP BY event_type 计数一致;Timeline 折叠行的 ×N 与实际事件数一致 |
| 10 | 样式零改动 | git diff 中 apps/web 的 style 对象、className、token、动画参数逐字节一致(仅数据源与 props 接线) |
| 11 | 服务白名单 | 静态核验:dev.ps1 的 $hostedServices 含 trace,且 encore 认得的服务集合与白名单一致(get_services);spike 目录已删除故不可能再暴露。镜像形态的实跑冒烟交 R9(所有者裁定:镜像只在部署时打包) |
| 12 | gen client | dev.ps1 gen 重新生成后前端编译通过 |
审查方式:codex /codex:review --background --base 276dbe7(thread 01a05673-c6aa-72b1-81ec-5fd4491ed079)
初审 findings 3 条(2×P1 + 1×P2),全部采纳:
自查追加 1 条(不是 findings):Workbench 每次渲染都重算三个投影,输入框每敲一个字都会把 最多 5000 条事件重投影三遍 → 三个 useMemo。
整改后回归:dev.ps1 check 通过;dev.ps1 test 67/67 全绿;apps/web tsc --noEmit 通过。 真机复测:
初审结论:整改后待复审
复审第 1 轮 /codex:review --background --base 4e7411a(thread 见任务输出), 3 条 findings(全部 P2,全部指向上一轮整改本身),处理如下:
复审第 2 轮 /codex:review --background --base 236c428,1 条 finding(P2),采纳整改:
复审第 3 轮 /codex:review --background --base 3304195,1 条 finding(P2),采纳整改:
复审整改后回归:dev.ps1 check 通过;dev.ps1 test 69/69 全绿;apps/web tsc --noEmit 通过。 真机复测第 2 轮整改:健康流存在时,同 clientId 请求一个不存在的会话 → 该请求 404, 健康流未被误杀(superseded 命中 0);随后同 clientId 换到真实会话 → 旧流正常收到 bye{"reason":"superseded"}。 真机复测第 3 轮整改:8 个不同 clientId 占满单会话名额后 → 第 9 个新观众 429(上限仍生效), 而已有观众 cap3 重连 200(不再自己把自己 429 掉),其旧连接正常让位,对照观众 cap5 未被误伤。
复审第 4 轮 /codex:review --background --base 236c428(基线放宽到覆盖全部三轮整改, 避免只看最后一次 diff 而漏掉跨轮次的相互作用):零 findings—— 「槽位顺序、延后让位、容量计数、Timeline 分组四处改动与已写明的生命周期约束彼此自洽, 未发现由本次改动引入的缺陷」。阻塞性 / 明显 bug 类 findings 清零。
关于连续三轮都落在同一处机制:让位/名额这段共三轮 findings(P1 扩大让位范围 → P2 让位与 校验的先后 → P2 容量计数口径),每一条都真实且都是上一次整改带出来的。判断不属于 「以审查代替设计」:三次都是同一个小函数的收敛式细化,改完的形状比改之前更简单 (登记按到达顺序 → 校验 → 只让位比自己早的同客户端连接 → 开流),三个判定都拆成了纯函数并有 回归用例;根因(拿不到断开信号)是环境约束,已在 D4 与 BACKLOG 写明并挂到 R9 重估。 当时立了条界限:若下一轮仍在这段出新 finding,就停下回所有者层面重定方案,不再在循环里打补丁。 第 4 轮零 findings,界限未被触发,机制按现状收口。
收口结论:PASS——阻塞性/明显 bug 类 findings 清零(复审第 4 轮零 findings); dev.ps1 check 通过,dev.ps1 test 69/69 全绿,apps/web tsc --noEmit 通过。 初审 3 条(2×P1 + 1×P2)+ 复审三轮 5 条(全 P2)共 8 条 findings:7 条采纳整改, 1 条(复制标签页共用 clientId)写明理由记 rounds/BACKLOG.md——每个最小替代方案都是拿罕见 故障换更常见故障,根治需要「连接代次」这类协议字段,属机制类改动,按缺陷门禁规则不在 非阻塞 findings 的整改范围,已挂 R9 与「断开信号」一并重估。 真机复测:404 / 400 路径正常返回且名额被 finally 释放;正常流仍能回放 + ready; 跨会话让位仍生效(bye{"reason":"superseded"})。 分组用库里真实的 912 条事件复核 → 6 个 turn 每个都自成完整一轮 (开场 input/before_agent_start/agent_start → turn_start → 正文 → turn_end/agent_end/agent_settled), 开场与正文都不再串组。
同一验收项针对性整改后连续 2 次验证仍不过 → 写 rounds/round-04/BLOCKED.md,停下呼人。禁止放宽验收标准自我通过。
环境:本机 Windows,encore v1.57.13,bun 1.4.0,vitest 4.1.11,模型 deepseek/deepseek-v4-flash。 后端跑在 :4001(:4000 被另一个 checkout 的 encore run 占着,不去动别人的进程), 前端 Next dev :3000 期间把 next.config.ts 的 dev 代理临时指向 4001,测完已改回 4000。
验收逐项(2026-08-31):
实现要点与踩坑:
与计划的偏离: