From ced0268b29d021888ed1a57cd58f09970ef811c2 Mon Sep 17 00:00:00 2001 From: yibo Date: Sun, 30 Aug 2026 23:37:18 +0800 Subject: [PATCH] docs: 已完成的 5 份 todo 文档挪进 done/,修复索引与交叉引用 --- docs/README.md | 10 +++++----- docs/done/system-design/29-dream-design.md | 488 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/done/system-design/33-needs-gate-role-filter-bug.md | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/done/system-design/35-validation-rigor-and-body-readiness-proposal.md | 233 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/done/system-design/39-boss-agent-task-supervision.md | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/done/system-design/40-agent-chat-streaming-interrupt.md | 213 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/todo/system-design/28-followups.md | 2 +- docs/todo/system-design/29-dream-design.md | 488 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- docs/todo/system-design/31-work-graph-fail-fallback-coverage.md | 2 +- docs/todo/system-design/33-needs-gate-role-filter-bug.md | 165 --------------------------------------------------------------------------------------------------------------------------------------------------------------------- docs/todo/system-design/34-validator-scored-without-real-deliverable.md | 2 +- docs/todo/system-design/35-validation-rigor-and-body-readiness-proposal.md | 233 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- docs/todo/system-design/39-boss-agent-task-supervision.md | 192 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ docs/todo/system-design/40-agent-chat-streaming-interrupt.md | 206 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- docs/todo/system-design/41-graph-vs-fde-reliability-discussion.md | 2 +- 15 files changed, 1300 insertions(+), 1293 deletions(-) create mode 100644 docs/done/system-design/29-dream-design.md create mode 100644 docs/done/system-design/33-needs-gate-role-filter-bug.md create mode 100644 docs/done/system-design/35-validation-rigor-and-body-readiness-proposal.md create mode 100644 docs/done/system-design/39-boss-agent-task-supervision.md create mode 100644 docs/done/system-design/40-agent-chat-streaming-interrupt.md delete mode 100644 docs/todo/system-design/29-dream-design.md delete mode 100644 docs/todo/system-design/33-needs-gate-role-filter-bug.md delete mode 100644 docs/todo/system-design/35-validation-rigor-and-body-readiness-proposal.md delete mode 100644 docs/todo/system-design/39-boss-agent-task-supervision.md delete mode 100644 docs/todo/system-design/40-agent-chat-streaming-interrupt.md diff --git a/docs/README.md b/docs/README.md index 700ba7b..48758f0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,11 @@ | [system-design/27-p3c-batch3-handover.md](./done/system-design/27-p3c-batch3-handover.md) | 批 3 交接 —— ✅ 已收口(2026-08-20)。**事故记录归档**:一份未经复核的代码被第三方会话提交进 main。留着是因为 §六 那几条多会话协作规则是真踩出来的 | | [system-design/38-auth-multi-tenant-isolation.md](./done/system-design/38-auth-multi-tenant-isolation.md) | 登录鉴权 + 公司级多租户隔离 —— ✅ Phase 0/1/2/3 全部完成(2026-08-25)。种子账号 `admin`/`hex1234` + 登录页;全站挂 `requireAuth`(`agent-chat.js` 用 `requireAuthOrInternal` 放行三处服务端自环调用);`company.json` 加 `ownerId`,`canAccessCompany` 是全仓库唯一归属判据,`company.js`/`graph-run.js`/`devloop.js` 分别接入(`graph-run.js` 双挂载点参数形状不同,设计阶段描述不准确,实现时才发现);`okr.js` 因是零导航入口的孤儿页面改挂 `requireAdmin` 而非归属校验。**Phase 3 验证阶段真查出一个漏洞**:`agent-chat.js` 靠 `:agentId` 的 `"companyId:role"` 前缀区分会话归属却从没做过归属校验,任意登录用户可跨租户读别家公司会话,已修复。全量 `vitest` 1433 passed | | [system-design/01-26](./done/system-design/) | 系统设计正文 01~26 章(14、25-harness-engineering-improvements 例外,见 todo/future/;27 例外,见 todo/),各章节状态见文内「状态」标记 | +| [system-design/29-dream-design.md](./done/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) | +| [system-design/33-needs-gate-role-filter-bug.md](./done/system-design/33-needs-gate-role-filter-bug.md) | ✅ **已修复**。`executor.json`/`executor-fanout.json` 里 `produces:["log"]` 声明了一个 test/integration 类节点永远不会被索引到的 kind,导致 `org-coder-validator` 被 `missing_required_input` 反复误判 blocked;改成 `required:false`(对齐 `validator.json` 既有写法)。文档记录了一条**走过的弯路**(按 role 过滤 `inboundContracts` 会削弱 T-mt25ecbd 的产出闸门,已撤回),发现于 31 号文档的真实回归验证过程中,与兜底边问题无关 | +| [system-design/35-validation-rigor-and-body-readiness-proposal.md](./done/system-design/35-validation-rigor-and-body-readiness-proposal.md) | 📝 **复盘 + 改进方案,四条提案全部已实施**。承接 31/32/33/34 号的复盘结论:任务结果差主要是架构问题(完整性闸门 `missingBodyDefinition` 只在招聘时查、图执行侧完全没有第二道防线——新发现)+ 一个具体的招聘装配可靠性 bug,不是 agent 能力问题。提案 A:本体不完整时图执行前置判 `fail_reason=body_not_ready`,不扣预算。提案 D:`goal-contract.js` 的 `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则判不过。提案 C:新增 `scripts/visual-acceptance-check.mjs` 复用检查工具。提案 B:控制台「验收证据」小节展示每个维度有没有证据支撑 | +| [system-design/39-boss-agent-task-supervision.md](./done/system-design/39-boss-agent-task-supervision.md) | ✅ **P1-P5 已实现**(2026-08-30,commit `a00590a` P1/P3/P4 后端、`5108d67` P2 前端、`d967ac0` P5)。boss agent 监督 goal/task 看板 + 独立任务看板页 + 心跳调度 + org/work graph 产出回写看板 + 公司级目标代码目录。本文档不再更新,仅存档决策过程,**已知未覆盖的后续工作见文内「六、已知遗留」** | +| [system-design/40-agent-chat-streaming-interrupt.md](./done/system-design/40-agent-chat-streaming-interrupt.md) | ✅ **已实现**(commit `4d23f50`,2026-08-30)。回应"前端刷新不及时/没有打字机效果,要不要把 SSE 换成 WebSocket"——排查后结论是传输层不是瓶颈(对照 hermes-webui/hermes-agent/deepseek-harness 三个参考实现,全部走 SSE,不用 WebSocket)。三个独立缺口全部补上:① 打字机不流畅→后端按字符数切片 + 前端 `createTypewriterQueue` 匀速吐出;② 执行中无法打断并接上新消息→`stop` 端点改轮询确认锁真正释放,前端据此直接发送/强制发送兜底,顺带修了服务端对同 convId 并发缺闸门这个真实 bug;③ 串台加固→`handleEvent` 抽成纯函数并补齐 `9b1c3f1` 那条不变式的回归测试。已用真实 Claude CLI 会话在浏览器验证 | ## todo/(当前主线) @@ -48,17 +53,12 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | [27-goal-contract-format.md](./todo/system-design/27-goal-contract-format.md) | 验收契约格式(`goal-benchmark` / `goal-test-data` / `goal-test-script` 的产物形态与跨节点传递) | | [27-p4-parallel-and-artifacts.md](./todo/system-design/27-p4-parallel-and-artifacts.md) | **P4(回收编号)并行执行与产物索引** — 📝 设计中,未实现。fan-out 真并发 + 同 agent 多实例隔离 + `files.json` 产物索引。三个 Phase,C 可插队 | | [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(设计见 29 号,已实现,见下)、`source_kind`(✅已删)/`legacy_path`(不能删)删列、三项「已实施未验证」。任务态清理(✅已实现,见下)。§五 是**已立起来的守卫清单**(别重复造,也别删) | -| [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) | | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 | | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 | -| [39-boss-agent-task-supervision.md](./todo/system-design/39-boss-agent-task-supervision.md) | 📝 **设计评估中,未实现**。评估用户提出的五阶段方案(雇 boss agent 监督 goal/task 看板、独立任务看板页、心跳挨个调度、org/work graph 产出的 bug/新任务回写看板、公司级目标代码目录)。结论:P2 看板已实现、P1/P3 心跳与 `ceo` 本体基础设施已有大半、P5 的 `linked-existing` 模式已在 `path-resolver.js` 完整实现只差公司级默认值,**真正从零开始的是 P4(回写闭环)**——当前没有任何 agent 工具能读写 goal/task 板。建议顺序倒过来先建 P4 地基 | | [41-graph-vs-fde-reliability-discussion.md](./todo/system-design/41-graph-vs-fde-reliability-discussion.md) | 📝 **讨论记录 + 落地实测,非设计方案**。org/work graph 与 master-Innovator-FDE 两套工作流的稳定性对比:根因是"拓扑运行时生成 vs 写死"。问题 7 是真实执行记录——新建 `org-hunt/digest/evolve`(graph 原生协议,方法论抄 innovator-hunt/digest/evolver)跑 Hunt→Digest→Evolve,11 轮真跑中挖出并修复 5 个独立缺陷(建公司自动规划竞态、work-graph 模板库缺内容型交付物骨架、验收节点任务描述越权、两个规划 agent 的路径口径写错、R-19 产出索引 `indexNodeOutputs()` 不覆盖项目根——最后一条改了 `graph-engine.js` 核心逻辑,加 `contract.json` 的 `produces` 显式声明机制,全局生效,补了 2 条守卫测试)。最终产出与原生 FDE(`prd-methodology-trial`)对比:内容骨架可迁移、质量不打折,但过程严谨度不如原生 FDE,可靠性差距悬殊(原生一次跑通,graph 版 11 轮才稳定)。`routeFailure` 的 `infra_flake` 分支绕过跨层寻址(`target_node`)仍是唯一确认还没补的口子 | | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | -| [35-validation-rigor-and-body-readiness-proposal.md](./todo/system-design/35-validation-rigor-and-body-readiness-proposal.md) | 📝 **复盘 + 改进方案,四条提案全部已实施**。承接 31/32/33/34 号的复盘结论:任务结果差主要是架构问题(完整性闸门 `missingBodyDefinition` 只在招聘时查、图执行侧完全没有第二道防线——新发现)+ 一个具体的招聘装配可靠性 bug,不是 agent 能力问题。提案 A:本体不完整时图执行前置判 `fail_reason=body_not_ready`,不扣预算。提案 D:`goal-contract.js` 的 `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则判不过。提案 C:新增 `scripts/visual-acceptance-check.mjs` 复用检查工具。提案 B:控制台「验收证据」小节展示每个维度有没有证据支撑 | | [34-validator-scored-without-real-deliverable.md](./todo/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录,checklist 已查完**。最初以为 `org-coder-impl` 从未产出任何 HTML/CSS,后来发现真实网站其实存在于 `graph_files` 索引不覆盖的「项目根」目录(已顺手修:控制台「产物」面板现在会展示项目根)。追加查清 validator 有没有真的做浏览器级检查——中途自我订正一次:不是"没工具做不到"(`bash` 能走到仓库自带的 Playwright,另一条对照任务证明可行),是**没人教它这么做**;抽查另外两个项目根后发现**验证严谨度完全不稳定**(同类任务有的做了真实 Playwright 级验证、有的只做静态正则、有的没验证就被取消),根因很可能是新招聘的 `unit-tester` 本体撞上了 [32 号文档](./todo/system-design/32-hire-body-fill-reliability.md) 那类"装配没填充完"的真实样本,不是平台工具限制 | -| [40-agent-chat-streaming-interrupt.md](./todo/system-design/40-agent-chat-streaming-interrupt.md) | 📝 **需求文档,讨论中,未实现**。回应"前端刷新不及时/没有打字机效果,要不要把 SSE 换成 WebSocket"——排查后结论是传输层不是瓶颈(对照 hermes-webui/hermes-agent/deepseek-harness 三个参考实现,全部走 SSE,不用 WebSocket)。真正的三个独立缺口:① 打字机不流畅(ACP chunk 粒度太粗,方案是前后端都做匀速切片/渲染缓冲);② 执行中无法打断并接上新消息(现状"先 Stop 再发"两步,方案是深度 1 的打断-排队,顺带发现服务端对同 convId 并发缺闸门这个现存 bug);③ 串台已在 `9b1c3f1` 修复但没有回归测试兜底 | -| [33-needs-gate-role-filter-bug.md](./todo/system-design/33-needs-gate-role-filter-bug.md) | ✅ **已修复**。`executor.json`/`executor-fanout.json` 里 `produces:["log"]` 声明了一个 test/integration 类节点永远不会被索引到的 kind,导致 `org-coder-validator` 被 `missing_required_input` 反复误判 blocked;改成 `required:false`(对齐 `validator.json` 既有写法)。文档记录了一条**走过的弯路**(按 role 过滤 `inboundContracts` 会削弱 T-mt25ecbd 的产出闸门,已撤回),发现于 31 号文档的真实回归验证过程中,与兜底边问题无关 | ## todo/future/(暂缓,非当前重心) diff --git a/docs/done/system-design/29-dream-design.md b/docs/done/system-design/29-dream-design.md new file mode 100644 index 0000000..8856d56 --- /dev/null +++ b/docs/done/system-design/29-dream-design.md @@ -0,0 +1,488 @@ +# 29-DREAM · 经验汇流(dream)—— 设计草案 + +> **所属**: [28 Agent 注册表统一](../../done/system-design/28-agent-registry-unification.md) §5.6b 锁住的那条约束的下游设计;未确认项来源 [28-followups §1.1](./28-followups.md)。 +> **日期**: 2026-08-20 / REST 与控制台入口 2026-08-21 +> **状态**: ✅ **四条取舍已拍板并已实现(五步 + 定时挂载 + 定义文件写入 + REST/控制台入口,均已落地并真跑验证,见 §四/§五/§5.7)。** +> 仍未做的只剩「结论对不对」的判据——机器判不了,只能靠人抽查(决策 D,见上)。 +> 标 ✅ 的是已生效的约束或有实测证据的事实;标 🔒 的是本轮拍板的决定。 + +--- + +## 〇、一句话 + +dream 是设想中定时跑的一套逻辑:整理**所有 agent 在所有公司**的 history 与 memory, +**显式提炼**后沉淀到 skill memory 上,让经验跨公司汇流,而**不泄漏任何一家公司的具体信息**。 + +--- + +## 一、开工前先看清原料:它现在是脏的 + +> 这一节全是**实查 2026-08-20 的仓库状态**得到的,不是推测。 +> 结论很直接:**在原料变干净之前,做提炼是没有意义的**——垃圾进,垃圾出。 + +### 1.1 现在写进 memory 的不是「事实」,是截断的日志行 + +`memory-service.appendToMemoryMd()` 的全部逻辑是: + +```js +const taskOneLine = (task || '').split('\n')[0].substring(0, 80); +const resultOneLine = (result || '').split('\n')[0].substring(0, 100); +const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; +``` + +**取第一行、砍到 80/100 字符**。这不是提炼,是搬运 + 截断。 +`cto/_global/memory/memory.md` 的实际内容: + +``` +## 关键事实 +- [2026-08-20] [TASK from ceo] → ..._global/` 下不新增 `callback-*` 目录。反向验证过:临时把 `isTestEnv` 改成 `() => false`,该用例 FAIL。 +- [2026-08-20] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01RsQ3gh5niCFyUZMFZj2hxF) +- [2026-08-20] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01MG6RqDgqcRCi5ageGk84Kt) +``` + +后三条是**工具调用 id**。它们作为「关键事实」被写下来、被注入下一轮提示词、 +并且会成为 dream 的原料。第一条更糟:它是一句话的**中段**(开头被截掉了), +读起来像事实,其实缺主语。 + +> §5.6b 说的错误版本是「每轮对话自动把原文写进共享 SKILL.md」。 +> **现在这套已经是那个错误版本的单公司版了** —— 只是还没跨公司共享,所以还没泄漏。 +> dream 如果直接消费它,等于把这个错误放大到跨公司。 + +### 1.2 长期记忆是一个 3000 字符的滚动窗口 + +`MAX_MEMORY_MD_SIZE = 3000`。超了就截。所以「长期记忆」实际是**最近若干条截断日志**, +没有任何按重要性保留的机制。 + +### 1.3 「公司态目录」里有不是公司的东西 + +`system/agents/{aid}/{cid}/` 是约定,不是校验。实查 96 个二级目录里混着: + +| 形态 | 例子 | 是什么 | +|---|---|---| +| 真公司 | `e2e-demo` / `mycompany` / `idearun` | ✅ dream 的原料 | +| 保留段 | `_global` | 全局 agent 的落点,算不算「一家公司」需要定 ❓ | +| ArenaSK 时代的运行态 | `train/` `workspace/` | ❌ 不是公司,是训练 / 工作目录 | +| **测试残留** | `tmp-w7-bootstrap-1786986291739-9n2xv`、`qroute-co-13/14`、`zz-*` | ❌ 夹具,且**带时间戳**,会无限增长 | + +**dream 的第一步「枚举所有公司」现在没有可靠答案。** +可靠的答案在库里:`company_agents` 表(28 批次 4 起「这家公司雇了谁」的唯一真相源)。 +目录只能用来定位文件,不能用来枚举公司。 + +### 1.4 绝大多数 memory 是空模板 + +75 份 `memory.md` 里,**只有 7 份**有非模板内容(`innovator-evolver` / `master` / +`org-innovator` / `innovator-digest` / `innovator-hunt` / `coo` / `qa-judge`), +其余全是 `_(刚入职,尚无记忆)_`。 + +> 这条对设计有直接影响:**dream 现在跑起来几乎没有原料**。 +> 它的价值随「真跑的量」增长,不是上线即有收益的东西。 + +--- + +## 二、已经生效的约束(不随设计变化) + +✅ **公司态目录是 dream 的唯一原料,不许被任何清理策略当垃圾扫掉**(§5.6b)。 +任务态(`{aid}/{cid}/{taskId}/`)可清,公司态(`{aid}/{cid}/memory/` 与 `conversations/`)不可清。 + +✅ **沉淀必须是提炼后的抽象,不是自动搬运原文。** +错误版本真的发生过(28 号第一轮验收挖出的最严重 blocker):直接灌原始对话 += 把 A 公司的机密喂给 B 公司。 + +✅ **目录布局本身就是 dream 的主查询索引**: +`某个 agent 在所有公司的全部记忆 == glob: system/agents/{aid}/*/memory/`,一个 glob 就够。 +(前提是 1.3 那个「二级目录不都是公司」的问题被解决。) + +--- + +## 三、需要定的取舍 + +### 🔒 决策 A —— 去敏的判据:**独立判定 agent + 写作要求**(2026-08-20 拍板) + +**定案 = A3 + A1 组合**:提炼 agent 按 A1 的写作要求产出,产出后由**另一个只负责判 +「这段话能不能给别家公司看」的 agent** 过闸门,判不准就拒绝沉淀。 + +理由(也是本仓库反复验证过的一条):**两种错的代价不对称时,判据要偏向贵的那一侧** +(P3B §八 关于 `FAILING_VERDICTS` 的那段)。「少沉淀一条经验」与「泄漏一次机密」显然不对称。 +失效方向因此是「少沉淀」,不是「泄漏」。 + +下面三种形态的对比留档,说明为什么选它。 + +### 决策 A 的备选(留档) + +这是**安全边界**,也是整个设计里唯一「做错了会造成真实损害」的一条。三种可选形态: + +| 方案 | 判据 | 代价 | +|---|---|---| +| **A1 白名单式**:只允许提炼成「不含任何专有名词的方法论」 | 产出里不许出现公司 id、人名、产品名、URL、文件路径 | 最安全;但也最容易把有用的经验一起滤掉(「给 X 公司做官网时发现静态站点要先定验收脚本」→ 只剩「要先定验收脚本」) | +| **A2 黑名单式**:过滤已知敏感模式后放行 | 正则 / LLM 扫一遍公司 id、密钥形态、路径 | 保留信息量更多;**漏一个模式就是一次泄漏**,而且是静默的 | +| **A3 二次判定**:提炼 agent 产出后,由**另一个** agent 只负责判「这段话能不能给别家公司看」 | 判定是一次独立的 agent 调用,双方不共享上下文 | 成本翻倍;但与本仓库既有的 validator / triager 模式同构,且**失效方向可控**(判不准就拒绝沉淀,宁可少沉淀) | + +### 🔒 决策 B —— 沉淀落点:**四个都要,按「改动的可逆性」分层**(2026-08-20 拍板) + +用户定的是:`system/agents/{aid}/memory/` **和** `SKILL.md` 都要写, +**必要时还要迭代 `soul.md` / `agent.md`**。 + +这比原来三选一的范围大得多,**因为它引入了「agent 改写自己的定义」这件事**。 +下面这一节是这个决定的直接后果,不是可选的补充说明。 + +#### B.1 四个落点不是一回事,风险差三个数量级 + +| 落点 | 是什么 | 谁受影响 | 可逆性 | +|---|---|---|---| +| `{aid}/memory/`(本体根) | **经验积累**,跨公司那一份 | 只影响这个 agent 读到的上下文 | 删掉即可,无副作用 | +| `SKILL.md` | **能力定义**:这个 agent 会什么 | 同上,但会进 Skill 市场的语义 | 覆盖前留旧版即可回滚 | +| `soul.md` | **人格**:它怎么说话、怎么取舍 | 所有雇了这个本体的公司 | 改坏了表现为「行为漂了」,**不报错** | +| `agent.md` | **行为契约**:它必须做什么、禁止做什么 | 所有雇了这个本体的公司 | 改坏了可能让整条链路静默失效 | + +**28 号的核心裁决是「本体只有一份」** —— 所以写 `agent.md` / `soul.md` 的影响面是 +**所有雇了它的公司**,而不是提炼出这条经验的那一家。这恰恰是 §5.6b 担心的那种扩散, +只不过现在是**有意为之**。因此它必须比前两个落点多两道东西: + +#### B.2 写定义文件的硬约束(这几条不是建议) + +1. ✅ **`agent.md` 有一处 2000 字符的截断,但作用点要说准**: + `graph-engine.js:915` 给**规划 agent** 看的「角色定义摘录」只取 + `agent.md` 的**前 2000 字符**(`readFileSync(f).slice(0, 2000)`), + 超出部分静默丢弃。agent 自己那条装配链(`prompt-builder`)**不截断**,读全文。 + → 后果是:dream 往 `agent.md` **末尾追加**内容,规划侧读到的画像不会变; + 但如果追加把重要约束**顶到 2000 之后**(例如重写时调整了顺序),规划侧会无声地少看到那一段。 + → 判据:写 `agent.md` 时**保证前 2000 字符仍然自足**(职能说清楚), + 新增内容往后放;改动后应当有一条检查盯住这个窗口。 +2. ✅ **这四个文件里有三个是 git 跟踪的定义**(`agent.md` / `soul.md` / `SKILL.md`), + 不是运行态。dream 改它们 = 改仓库里的文件 → 会被边界守卫看见,也会进 `git status`。 + → 需要明确:dream 的产出是**直接落盘**还是**提案 + 人工合并**。 +3. ✅ **毕业闸门读这些文件**(`missingBodyDefinition` / `promoteDraftIfComplete`)。 + 把 `agent.md` 写空 / 写坏会让本体退回「定义没填满」,**雇不进任何公司**。 +4. ✅ **必须可回滚且能分别回滚**:经验沉淀写坏了只影响上下文,`agent.md` 写坏了影响所有公司。 + 两者混在同一次提交里,回滚粒度就没了。 + +#### B.3 🔒 定案:**直接落盘,但写之前必须确认这份文件已经在 git 历史里**(2026-08-20 拍板) + +用户定的是直接写,不走提案;前置条件是「别导致直接修改而没有 git 历史」。 + +**已实现**:`server/services/definition-write-gate.js`,两条判据都满足才放行: +① 文件被 git 跟踪;② 该文件没有未提交改动。判不出来(不在仓库 / git 不可用)一律拒绝。 + +> **为什么是「拒绝并报告」,不是「脏就先替它提交一把」**: +> 后者看着更顺手,但那正是本仓库记过的一类事故 —— **多会话共用同一个 worktree** +> (27-P3C §15.4 / §17.2)。一份未提交的改动很可能是**另一个会话正在写的东西**, +> 替它提交等于把别人的半成品写进 main,还署错了名。所以把「要不要提交」留给人。 + +守卫 `definition-write-gate.test.js` 5 例,含「拒绝时必须给出下一步」那条 —— +撞上闸门的人(或 agent)要能照着做,否则只能猜。 + +### 决策 B 的备选(留档) + +「skill memory」目前**没有对应的实体**。候选落点: + +| 落点 | 形态 | 问题 | +|---|---|---| +| **B1** `system/agents/{aid}/memory/`(本体根,跨公司那一份) | 与公司态并列,一个 agent 一份跨公司经验 | 目前只有 `zz-scaffold-readonly-fresh` 有这个目录,等于新建一层;**但它天然与 §5.6b 的 glob 对齐**,读的时候本体根 + 各公司态一起加载 | +| **B2** 本体的 `SKILL.md` | 复用现有概念(5 个本体已有 SKILL.md) | **这正是 §5.6b 点名的错误版本的落点**;SKILL.md 是「能力定义」,不是「经验积累」,混在一起之后没法分别回滚 | +| **B3** `/../data/skills/` 那 27,059 条 Skill 仓库 | 与 ArenaSK 的 Skill 市场打通 | 那是**面向分发**的仓库,写进去等于对外发布;且跨了两个 data 根 | + +(拍板结果见上:B1 与 B2 都要,另加 `soul.md` / `agent.md` 两个定义落点。) + +### 🔒 决策 C —— 触发时机:**定时,每日一次**(2026-08-20 拍板) + +备选留档如下。 + +> ⚠️ **与 §1.4 的冲突要知道**:实查 75 份 `memory.md` 里只有 7 份有非模板内容, +> 所以每日跑起来**绝大多数 agent 会是空转**。这不是反对定时,是要求实现时做到: +> **没有新原料就不起 agent 调用**(判据:上次 dream 之后有没有新的 session 摘要), +> 否则每天会有几十次「读完发现没东西」的 LLM 调用在烧钱,而且日志里全是噪声。 + +| 方案 | 说明 | +|---|---| +| **C1 定时**(如每日一次) | 简单;但没有真跑的日子会空转,有大量真跑的日子又攒得太多 | +| **C2 按量**(某 agent 新增 N 条 session 摘要后触发) | 与实际产出量对齐;需要一个计数落点 | +| **C3 手动**(控制台一个按钮 + API) | 先手动跑一段时间看提炼质量,稳定了再自动 | + +### 🔒 决策 D —— 能自动查的自动查,不能自动查的把回查成本降到几秒(2026-08-20 落地) + +**「这条结论对不对」机器判不了**,需要理解内容。不假装有自动判据。 +能自动做的只有两件,两件都做了: + +1. **引用真伪可以自动查**。提炼产物的 `sources` 必须都出自这一轮真喂进去的原料, + 编一个出处直接拒(连去敏判定都不用调,省一次)。 + > 编了出处的经验是「无法验证」这件事最危险的形态:人抽查时顺着一个不存在的指针 + > 放弃,然后**默认它是真的**。 +2. **回查成本降到几秒**:`auditDistilled(agentId)` 把每条结论与它引用的**原料原文** + 并排列出来,指针悬空标 `null`(原料被清理过 / 出处是编的)。 + +真跑那 4 条实测:**8 条引用全部取得回原文**,模型没编出处。 + +> 仍然没有的是「结论对不对」的判据。`auditDistilled` 的输出**不是质量分**, +> 别当成那个用 —— 它只是把回查这件事变便宜了。 + +下面是原始的选项留档。 + +- 没有评价手段时,dream 的产出**无法验证对错**——它和 FDE 那边 + 「LLM 自己汇报分数」的问题是同一个(见 [architecture_todo_list](../future/architecture_todo_list.md) §1)。 +- 一个便宜的起步:dream 每次产出都**保留原料指针**(哪几条 session 提炼出这一条), + 人抽查时能顺着回去看。**可追溯**比「可评分」便宜得多,也更早有用。 + +--- + +## 四、推进顺序 🔒 **上游与 dream 一起做**(2026-08-20 拍板) + +> **进度**:五步全部实现、真 CLI 跑通一次(§5.5),定时挂载与定义文件写入也已落地。 +> 决策 D 落到「引用真伪自动查 + 回查成本降到几秒」。**未做的只剩「结论对不对」的判据** —— 那一条只能靠人。 + +用户定的是两件一起做,不分先后。拆成可验收的步骤: + +| 步 | 内容 | 为什么不能省 | +|---|---|---| +| 1 | ✅ **`appendToMemoryMd` 停止截断搬运**(§1.1)—— 已完成 | 它原来把工具调用 id 当「关键事实」写进 `memory.md` 并注入**每一轮**提示词。dream 直接消费它 = 把这个错误放大到跨公司 | +| 2 | ✅ **「有哪些公司」改查 `company_agents` 表**(§1.3)—— 已完成 | 目录里混着 `train/` `workspace/` 和带时间戳的测试残留,拿目录枚举公司必然把它们当原料 | +| 3 | ✅ **dream v0**:枚举 → 提炼 agent → 去敏判定 agent(决策 A)→ 写落点 —— 已完成 | 主体 | +| 4 | ✅ **定义文件的 git 历史闸门**(决策 B.3)—— 已完成 | 覆盖一份尚未进入 git 历史的内容 = 让上一版永久消失,且不报错 | +| 5 | ✅ **可追溯**:每条沉淀记住它是从哪几条记录提炼来的 —— 已完成 | 没有它,dream 的产出无法验证对错 —— 与 FDE 那边「LLM 自己汇报分数」是同一个问题 | + +**空转保护**(决策 C 的直接要求):没有新原料就不起 agent 调用。 + +> 本文档仍**不是实现规格**。决策 D(质量怎么评)未定;B.2 里「定义文件直接写还是走提案」 +> 也还需要你点头 —— 我按「走提案」写在了步骤 4,但那是我的建议,不是你拍的板。 + +--- + +## 五、实现记录 + +### 5.1 步骤 1:`memory.md` 的自动写入不再冒充「关键事实」(已完成) + +改法三条: + +1. **换段**:自动写入落到 `## 原始记录(未提炼)`,`## 关键事实` 从此**只由提炼产物填**。 + 读的人和 agent 都能一眼分清「这是日志」还是「这是结论」;这一段同时就是 **dream 的输入面**。 +2. **滤掉确定的噪声**:工具调用标记 / 空结果 / 只有心跳标记的任务。 + 判据刻意窄 —— **只滤明确的噪声,不猜「哪条重要」**。猜重要性需要理解内容, + 那正是 dream 的活,在这里做等于把提炼下放到一个正则里。 +3. **不再截成半句**:上限从 80/100 放宽到 200 并取完整第一行。 + 截断残渣读起来像事实、其实缺主语,比不留更坏。 + +> **踩到一条值得记的**:噪声判据初版有「任务与结果都短于 8 个字符就丢」。 +> 那是按英文长度想出来的阈值 —— 中文 8 个字是一句完整的话, +> 实测「结果甲甲甲甲甲」(7 字)直接被丢,整条记录凭空消失。已删掉该规则。 +> **凡是拿长度当信息量代理的判据,都要先问一句「这是按哪种文字算的」。** + +守卫 `memory-raw-section.test.js` 5 例:关键事实段一个字不动、噪声一条不写、 +长句不再被砍(带「样本确实超过旧上限」的前置断言)、多次写入累加在同一段、 +无 toolCall 不写(保住原有行为,别顺手放宽)。 + +### 5.2 步骤 2 / 5 与 3 的骨架(`dream-service.js`) + +**步骤 2 —— 公司清单只认 `company_agents` 表。** +`companiesOf(agentId)` 查表,另把保留段 `_global` 补上(全局 agent 的运行态落在那里, +没人「雇」它,所以不在表里)。目录从此**只用来定位文件,不用来枚举公司**。 +守卫里造了两个「看起来像公司」的目录(`train/` 与 `tmp-w7-bootstrap-1786986291739-9n2xv`), +断言它们不进原料 —— 那正是实查到的两种残留形态。 + +**空转保护 —— 水位线按「条数」而不是 mtime。** +mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md`),条数只在真的多出记录时增长。 +拿 mtime 当判据的话,空转保护会在「文件被碰过但内容没变」时失效。 +没有新原料时 `dreamOnce` 直接返回 `skipped: 'no-new-material'`,**一次 agent 调用都不起**。 + +**步骤 5 —— 可追溯。** +每条原料带 `source`(`{公司}:memory.md#{行号}`),提炼产物落盘时把 `sources` 一并写进 +`experience.md` 的引用行。人抽查时能顺着回去看。 + +**决策 A 的失效方向在代码里是硬的**:判定 agent **抛异常也算拒绝**, +并把「为什么」写进 `rejected[]` —— 否则现场只看到「少了一条」,查不出是判否还是判炸。 + +### 5.3 步骤 3:两个本体 + 默认实现(已完成) + +**两个本体**(都过得了毕业闸门,`missingBodyDefinition` 为空): + +| 本体 | 职责 | 产出契约 | +|---|---|---| +| `dream-distiller` | 把一批原始记录提炼成可复用经验 | ```json 数组,每条 `{title, body, sources}`;没有值得留的就交 `[]` | +| `dream-screener` | 逐条判「这条能不能给别家公司看」 | ```json 对象 `{shareable: boolean, reason}` | + +**决策 A 的独立性写在两处,缺一不可**: +- 代码层:判定**一条一次调用**,提示词里只放 `title` + `body`,**不放原料**; +- 本体层:`dream-screener/agent.md` 明写「你看不到原料,也看不到是哪家公司——这是故意的」。 + 守卫里有一条专门断言这句话在(`expect(screener).toContain('看不到原料')`)—— + 否则下一个人会顺手把原料塞进提示词「让它判得更准」,而那恰好把独立性拆掉了。 + +**两处失效方向都是硬的**: +- 提炼取不出 JSON → **抛**,不是静默返回空。「产出格式坏了」和「没提炼出东西」是两件事, + 混为一谈会让前者以后者的形式永远藏下去(每天显示「今天没经验可沉淀」,真相是解析一直在失败)。 +- 判定解析不了 / `shareable` 不是布尔 → **判否**,并把原文片段带进 `reason`, + 让人看得出是「判否」还是「判炸」。 + +> **测试写法上纠了自己一次**:第一版把解析与失效分支在测试里**重抄了一遍**再断言 —— +> 那是在验一份复制品,代码改了它照样绿。改成加一条 `setDreamAgentCaller` 缝, +> 只换掉「真起 CLI」那一层,解析、判空、失效方向全走真代码。 + +> **本体写错过一次,被守卫抓住**:`agent.md` 里的相对引用按 **cwd 深度 3** +> (`{aid}/{cid}/{taskId}/`)解析,不是按文件自身位置 —— 仓库根是 `../../../../../`。 +> 我按文件位置写了 `../../../`,`agent-md-refs.test.js` 当场判红。 + +### 5.4 还没做的(别当成 dream 上线了) + +1. ~~定时挂载没做~~ ✅ **已完成**(§5.6)—— `dream-service.startDaily()` 已挂在 + `server/index.js` 的 `DISABLE_BACKGROUND_AGENTS` 早退之后,每日一次、串行扫、 + 定时器 `unref`。~~仍然没有的是 REST / 控制台入口~~ ✅ **已完成**(§5.7)—— + `/api/dream/*` + `web/src/views/DreamConsole.vue`(导航栏 `🌙 Dream`)。 +2. ~~还没写定义文件~~ ✅ **已完成**(§5.6)—— `writeDefinitionFiles()` 已实现, + 落点是 `SKILL.md` / `soul.md` / `agent.md` 三个 git 跟踪的定义文件, + 形状是受管区块(``,区块外不碰、整体重写幂等), + 写之前过三道闸门(definition-write-gate → agent.md 前 2000 字符自足性 → + 写完复核毕业闸门,不过立刻还原),任何一道不过就不写、原因逐文件返回。 +3. ~~决策 D~~ ✅ 已落地(见上)—— 但「结论对不对」仍然只能靠人抽查。 + +--- + +## 5.5 第一次真 CLI 跑通(2026-08-20) + +### 前置:存量记忆的搬家 + +写入侧改了(§5.1),但**存量不会自己搬家**。实查现有 `## 关键事实` 里 +**没有一条真事实** —— 全是重复的心跳行、工具调用 id、和以 `...` 开头的半句: + +``` +- [2026-08-18] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01M1t4...) +- [2026-04-09] [TASK from master] → ...ates/community-manager/train/evolve/SKILL_v1.md` | +``` + +而 `memory.md` 会被注入**每一轮**提示词 —— 这些东西一直在冒充事实。 +`scripts/migrate-memory-raw-section.js` 把它们搬进「原始记录(未提炼)」: +**19 个文件 / 269 行**,先 dry-run 再 `--apply`,改前逐个 `.bak` +(这些是 gitignore 的运行态,没有 git 历史兜底)。 + +判据刻意窄:只搬 `- [YYYY-MM-DD] … → …` 这一种形状(旧自动追加的唯一形状)。 +人手写的事实不长这样,不动它 —— **搬错比留错更坏**:留下最多多一条噪声, +搬错是把人写的东西降级成日志。 + +### 真跑结果 + +对 `innovator-evolver`(40 条原料,本仓库最厚的一份)跑 `dreamOnce`: + +``` +{ "distilled": 4, "accepted": 4, "rejected": [] } 耗时 114 s +``` + +四条产出**都做到了抽象**,原料里的具体名字全被抽掉: +`yt-dlp` / `Invidious` → 「本地下载工具 → 备用镜像源」; +`scorer.js` / `SKILL_v1.md` / `system/templates/...` 一个都没出现。 + +**顺着 `sources` 抽查过**(可追溯的第一次实际用途):4 条引用全部对得上原料。 +例如「按关键词覆盖率打分会被刷到虚高分」那条引了 #21/#22/#27/#28, +而 #27 原文正是「从零重写,采用 `expected_output` 逐 case rubric + `dimCeil` 上限机制, +杜绝 coverage-based」。**它是跨 4 条记录归纳出来的**,不是逐条改写 —— +这正是「一次调用吃掉全部原料」那个选择要换来的东西。 + +### 闸门单独验过(这一轮零拒绝 = 没被考验) + +真跑里 4 条全过,说明**去敏闸门一次都没触发**。零拒绝不等于闸门有效, +所以单独喂了三条给 `dream-screener` 真跑: + +| 输入 | 判定 | 理由(原文) | +|---|---|---| +| 含公司名 + 域名 + 绝对路径 | ❌ false | 「出现了具体域名 lemonai.ai 和绝对路径 /Users/…,第三方能据此直接认出是哪家公司/哪台机器」 | +| 心跳日志原文摘录 | ❌ false | 「是心跳日志的原文摘录,不是重写过的经验,属于原文搬运;且工具调用 ID 是可能关联到具体运行实例的标识符」 | +| 干净的抽象经验 | ✅ true | 「没有公司名/路径/URL…读起来是重写过的原则而非原始对话摘录」 | + +三条判据(可识别主体 / 原文搬运 / 正常放行)都按 agent.md 写的那样生效了, +**而且拒绝理由都点到了具体是哪几个字** —— 那是 agent.md 里明确要求的。 + +### 空转保护实跑 + +第二次调用(原料没变):`{"skipped":"no-new-material"}`,**一次 agent 调用都没起**。 + +--- + +## 5.6 定时挂载(决策 C)+ 定义文件写入(决策 B)(2026-08-20,`e9f380a`) + +### 定时:每日一次,串行 + +`dreamSweep` 逐个本体扫,**不并发** —— 每轮可能起两次 LLM 调用,几十个本体并发 += 同时拉起几十个 claude 子进程,这个代价本仓库付过(2h43m 漏出 82 个进程 / 17.5 GB, +[28-followups §五第五条](./28-followups.md))。慢一点没有代价,空转保护已经把 +「读一遍全量本体」的成本压到「N 次目录读」。 + +挂点:`server/index.js` 里 `DISABLE_BACKGROUND_AGENTS` 早退之后,调 +`dream-service.startDaily()`。定时器 `unref`;只挂定时**不立刻跑一轮** +(启动那一刻跑没有额外收益,反而让每次重启都烧一遍)。一个本体抛异常不拖垮整轮, +但必须留下是谁抛的 —— 否则现场只看到「今天没沉淀」,查不出是没原料还是一直在抛。 + +**没做的**:REST / 控制台入口。现在触发只有两条路:等每日定时,或代码 / 脚本直接调 +`dreamOnce(agentId)`。人想立刻看一次提炼结果,目前没有 API/页面。 + +### 定义文件:受管区块 + 三道闸门 + +落点是 `SKILL.md` / `soul.md` / `agent.md`(决策 B 的另外两个落点,本体根 +`memory/experience.md` 在步骤 3 已经在写)。形状是一对 +`` 之间的**受管区块**,区块外一个字节不碰 —— 自由追加会让 +「哪些是人写的、哪些是 dream 写的」几轮之后再也分不开,分不开就没法分别回滚。 +区块整体重写还顺带**幂等**:同一批经验重复跑不会越写越长。 + +三道闸门,缺一不写: +1. **definition-write-gate**(§B.3):当前内容必须已在 git 历史里,且没有未提交改动; +2. **agent.md 的前 2000 字符必须仍然自足**:`graph-engine.js:915` 的「角色定义摘录」 + 只看这一段,改了等于悄悄换掉规划 agent 读到的画像; +3. **写完复核毕业闸门**(`missingBodyDefinition`),不过就**立刻还原** —— + `agent.md` 写坏会让本体退回「定义没填满」,雇不进任何公司。 + +任何一道不过:不写,原因逐文件返回(`definitions[]`)。**不写不该是静默的。** + +守卫:`dream-service.test.js` 新增 3 例,对真被 git 跟踪且干净的本体(`dream-screener`) +走真路径 —— 真写、验区块外字节不变 / 幂等 / 毕业闸门仍过,`finally` 还原并断言还原干净; +`agent.md` 那条验「不写 + 理由点名前 2000 字符 + 一个字节都没动」。`dreamSweep` 加了 +`agents` 参数,让错误隔离那条能真调而不是真扫全量本体。 + +全量:144 文件 / 1330 测试全绿。 + +--- + +## 5.7 REST / 控制台入口(2026-08-21) + +只做了这一件事,没有跟着做「dream 改成 org/work graph 来跑」—— +两者被单独评估过(见对话记录):org/work graph 的执行模型是**单公司作用域**的 +(org graph = 公司级 workflow,work graph = 公司内某任务的节点图),dream 却是 +**跨公司**扫描(按 agent 扫全部公司的记忆),套进去要么破坏这个作用域假设, +要么得新开一种「跨公司图」类型。而且 graph 引擎自带的「自动质量判断」 +(goal-contract 的 benchmark / test-script、triager 驳回重做)判的是「产物有没有 +过验收脚本」,对「这条提炼的经验对不对」这种自由文本判断没有对应机制—— +那道题不管包不包进 graph 都得单独设计一个 rubric,是同一件事,graph 化本身 +不会替你解决它。所以只做了用户点了头的这一半。 + +### REST(`server/routes/dream.js`,挂在 `/api/dream`) + +路由本身**没有新业务逻辑**,全部委托给已经测过的 `dream-service.js`: + +| 端点 | 做什么 | +|---|---| +| `GET /api/dream/agents` | 总览:每个可 dream 的本体,公司数、上次跑的时间、上次沉淀/拒绝了几条 | +| `GET /api/dream/agents/:agentId` | 详情:状态、定义文件(`agent.md`/`soul.md`/`SKILL.md`)现在能不能写及原因、`auditDistilled()` 的可回查列表 | +| `POST /api/dream/agents/:agentId/run` | 手动跑一次(`{dryRun}` 可选;`dryRun` 只看有没有新原料,不起 LLM 调用) | +| `POST /api/dream/sweep` | 手动触发全量扫描,202 立即返回,后台跑(串行扫全部本体可能要几分钟到几十分钟,不能塞进一次 HTTP 请求里等) | +| `GET /api/dream/sweep/status` | 轮询上面那个后台任务:`sweeping` / `results` / `error` | + +`sweep` 的并发锁是路由自己的状态(内存里一个 `sweepState`,不落库)—— +`dream-service.js` 没有「正在跑」这个概念,`dreamSweep()` 单纯是个 promise。 +两次并发 `POST /sweep` 第二次会拿到 409,防的是「人在控制台连点两下」而不是 +分布式场景(单进程部署,够用)。 + +守卫:`server/tests/unit/dream-routes.test.js`,monkeypatch 掉 +`dream-service` 导出对象上的六个函数(不走真文件系统,不会误扫真仓库的 +76 个本体),只验路由这一层独有的东西——六个端点接没接对、`sweep` 的并发锁 +状态机对不对。`dream-service.js` 自身的正确性由 `dream-service.test.js` 覆盖, +这里不重复验。 + +### 控制台(`web/src/views/DreamConsole.vue`,路由 `/dream`,导航栏 `🌙 Dream`) + +一个表格 + 两个弹窗,没有别的:本体列表(点「详情」看状态/定义文件可写性/ +经验审计,点「跑一次」或「dry-run」手动触发单个本体),页头一个「全量扫描」按钮 +(触发后台 sweep,15 秒轮询一次状态直到跑完)。 + +### 真跑验证(2026-08-21) + +起真服务器(`DISABLE_BACKGROUND_AGENTS=1 bash start.sh`),对真数据打过一轮: +`GET /api/dream/agents` 拿到全部 76 个本体真实的 `companies` / `lastRunAt`; +`GET /api/dream/agents/innovator-evolver` 的 `definitions` 如实报出 +`agent.md`/`soul.md` 可写、`SKILL.md` 不可写(没有 git 历史)及具体原因, +`experience` 把 §5.5 那次真跑沉淀的 4 条经验连同原料原文一起吐出来; +`dry-run` 对 `innovator-evolver` 正确报 `no-new-material`(材料已在上次真跑时被 +消费,本轮**没有起一次 LLM 调用**)。控制台页面在浏览器里过了一遍同样的路径 +(列表 → 详情弹窗 → dry-run 弹窗),数据与直接打 API 一致。 diff --git a/docs/done/system-design/33-needs-gate-role-filter-bug.md b/docs/done/system-design/33-needs-gate-role-filter-bug.md new file mode 100644 index 0000000..af89b4f --- /dev/null +++ b/docs/done/system-design/33-needs-gate-role-filter-bug.md @@ -0,0 +1,165 @@ +# 33-needs-gate-role-filter-bug. `produces:["log"]` 声明了一个从不会被索引到的 kind —— validator 被 missing_required_input 误判 + +> **日期**: 2026-08-21 发现,2026-08-22 修复 +> **状态**: ✅ **已修复**(发现于 31 号文档修复的真实 E2E 回归验证过程中,是一个独立的新 bug, +> 与 31 号文档的兜底边问题无关) +> **触发**: `e2e-demo` 公司真实回归任务 `T-mt2r6fz0-125b`(goal `G-mt2r62ia-73af`, +> root run `run_0e9074815130`)——为验证 [31 号文档](./31-work-graph-fail-fallback-coverage.md) +> 的兜底边修复而起的新任务,链路走到 `org-coder-validator` 时意外反复 blocked +> +> **2026-08-22 修订**:§2.3 最初写的根因假设(`inboundContracts` 该按 `role` 过滤只留 +> `needs`)**是错的**,动手改了之后跑单测直接炸了 9 个用例——`impl→test`、`brd→prd` +> 这类只声明 `produces`(没有配对 `needs`)的边占绝大多数,`inboundContracts` 把它们 +> 也算进「下游需要的输入」正是**设计如此**,还是 T-mt25ecbd(commit `aecf5c9`)那次 +> 产出闸门的直接依赖。已把那次改动撤回,真正的根因和修法见 §2.3(修订版)与 §四。 + +--- + +## 一、先说 31 号文档的验证结果:修复生效,有真实证据 + +这条任务本身是为了拿到 31 号文档 §六「尚未做」的那一步真实验收证据(当时因为原任务 +`T-mt1qx0c4-95bf` 状态不干净,改用确定性回归测试代替,见 31 号文档 §7.3)。这次补上了: + +- 新起的 `org-planner` 子图(`run_38b3df8fd228`)里,`org-planner-sdd` 真实自判失败 + (下游信息不足),沿新补的兜底边(`org-planner-sdd --fail--> org-planner-prd`, + edge `wge_ce0f7f05f6dc`)**自动回退了 3 次**(`graph_run_loop_records`: + `verdict=registered`,`loop_count` 1→2→3),全程**零次 `retryNode` 调用**,最终 + brd→prd→sdd→benchmark→validator 整条链路收敛到 `run_status=completed`。 +- 31 号文档的决策 A(除入口节点外强制兜底边)在真实 LLM 驱动下按设计工作,**结论确认**。 + +--- + +## 二、新发现:`org-coder-validator` 被 `missing_required_input` 反复误判 + +### 2.1 现象 + +同一条任务往下走到 `org-coder`(`run_21fd814c2f56`,用的是 `executor.json` 骨架)时, +`org-coder-validator` 连续两轮都以 `fail_reason=missing_required_input` 收场: + +- 第一轮(attempt 1,10:01-10:11):沿兜底边 `validator --fail--> impl` 回退两次 + (`wge_1bdb181b14ef` 一次、`wge_aa5bad61fe9a` 一次),第三次撞上 run 树级回退硬顶 + (`backward_count` 全树累计 >5,`LOOP_HARD_CAP_RUN=5`),`force_terminated`, + 节点 `blocked`。**这一段是硬顶熔断本身在正常工作**,不是这次要报的 bug。 +- 人工 `POST /nodes/:id/retry` 把 `org-coder-validator` 打回 `ready` 后, + **第二轮**(attempt 2,10:42:48-10:44:01)**又**判了一次 `missing_required_input`, + 这次连新的 `loop_record` 都没写(回退硬顶已越顶,直接静默 blocked)。 +- 节点自己的 `remediationNote` 写的是「无需整改。……本轮重新独立跑了一遍测试脚本与 + goal-contract,结果与 attempt-1 完全一致,判定 attempt-1 的 passed 结论有效」—— + **agent 自己认为通过了**,是引擎的产出/输入契约闸门(T-mt25ecbd 那次加的机制, + commit `aecf5c9`)把 success 强行改判成 fail。 + +### 2.2 复现证据:`graph_files` 里明明有 `artifact` 却仍报缺 + +查 `T-mt2r6fz0-125b` 的产物索引(`data/graph.sqlite` 的 `graph_files` 表), +`org-coder-impl`(`rn_e71be3641174`,10:06:51 完成)与 `org-coder-test` +(`rn_cd3e3a1c51e2`,10:08:52 完成)都成功登记了当前(`superseded_by IS NULL`) +`kind='artifact'` 的产物行(`_plan.md` / `_research.md` / `coder.log`)。 +`org-coder-validator` 的 attempt 2 在 10:42:48 才跑,**比这些产物晚了半小时以上**, +不是时序竞争,是稳定复现。 + +### 2.3 根因(修订版):`produces:["log"]` 声明的是一个永远认不出来的 kind + +`executor.json` 里 `test → validator` 这条边同时声明了两条契约(`work-graph-db.js` +`declareEdgeContract`): + +```json +{ "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success", + "produces": ["log"], "needs": ["artifact"] } +``` + +即:`kind=log, role=produces`(test 自己产出的是 log,required 默认 true)+ +`kind=artifact, role=needs`(validator 真正要吃的是 artifact,中继声明,产出方是 +更早的 impl,非相邻依赖,R-19.3 ②)。`executor.json` 头部 `dataContractNotes` +写明了设计意图——「validator 既要看测试记录(紧邻上游 produces log)也要看被验的 +交付物本身」,即:`produces` 与 `needs` **都该算进 validator 的输入需求**,这不是 +误用,`inboundContracts()` 把两者都吐出来是**故意的、正确的**(详见下方「走过的 +弯路」)。 + +真正的根因在**产出侧**:`test` 类节点(`org-coder-test`、`org-coder-test-a/b`、 +`org-coder-integration`)从来不会写出一个能被认作 `kind=log` 的文件。 +`indexNodeOutputs()`(`graph-engine.js:3835`)给文件定 kind 的规则是「文件名匹配 +声明的 kind → 已知文件名 → 唯一无歧义兜底」三级,而这几类节点实际落盘的是 +`_plan.md` / `_research.md` / `coder.log` 这类文件——`coder.log` 恰好属于 **impl** +节点(不是 test),test 自己写的两份文件文件名里都没有 "log",且同时有两个认不出 +kind 的文件,触发不了「一对一无歧义」的兜底认领。于是 `graph_files` 里对这个 +task **永远**不会出现一行 `kind='log'`——`unsatisfiedRequiredProduces()`(出边产出 +闸门,`graph-engine.js:3801`)本身对此是宽容的:它只检查节点私有目录里有没有 +**任何**候选文件,不检查文件是否精确匹配声明的 kind,所以 `test` 自己能顺利 +`success`;但 `requiredInputsMissing()`(`graph-engine.js:3945`)在 `validator` +结算时是严格的:`declared kind - 索引里查到的 kind` 非空就判 `missing_required_input`, +而 `log` 这个 kind 因为上面那条产出侧的缺口从未出现在索引里——**这不是一次性的 +时序问题,是这条边只要还这么声明就永远无法满足的硬伤,每次重试都复现**。 + +对照 `validator.json`(同一批模板):它的 `needs` 声明本来就写成 +`{ "kind": "artifact", "required": false }`——同一份代码库里已经有先例, +知道这类「声明了但未必查得到实例」的依赖要标 `required:false`,只是 +`executor.json` / `executor-fanout.json` 里的 `produces:["log"]` 用的是裸数组 +写法(`normalizeContracts` 对裸字符串项默认 `required:true`),漏了这一条。 + +### 2.4 走过的弯路:为什么「按 role 过滤 inboundContracts」是错的 + +最初的假设是「`inboundContracts()` 不该把 `produces` 也当成 `needs`,只留 +`role==='needs'` 的行」。改完发现 `work-graph-skeletons.test.js` / +`org-layer-contracts.test.js` 等 9 个用例炸了:`org-planner-sdd`(入边 +`prd→sdd: produces:["prd"]`,无配对 `needs`)、`org-coder-test`(入边 +`impl→test: produces:["artifact"]`,无配对 `needs`)等一大批节点的入边**只声明 +了 `produces`,从不显式写 `needs`**——这本来就是绝大多数模板边的写法(`declareEdgeContract` +注释原话:「出边声明产出是压倒性多数,needs 必须显式写」)。按 role 过滤会让这些 +节点的 `inboundContracts()` 变成空数组,`requiredInputsMissing()` 也就不再校验 +它们——而这条链路正是 T-mt25ecbd(commit `aecf5c9`)刚加固的产出闸门本身 +依赖的数据源(`org-planner-benchmark` 靠它校验 `needs:[brd,prd,sdd]` 真的都被 +索引到)。按 role 过滤表面上修好了 `log` 这一个 kind,实际上会**静默削弱** +T-mt25ecbd 那次修复要守住的保护——好在跑单测立刻炸了,没有带着这个错误方向走 +下去。**结论**:`inboundContracts()` 本身没有 bug,别再按这个方向改它。 + +### 2.5 实际修法 + +在**声明**这一侧把 `log` 标成 `required:false`,与 `validator.json` 的既有写法 +对齐——既保留「validator 能看到 test 的 log(如果将来真的被索引到)」这条文档 +价值,又不再把一个永远查不到实例的 kind 当硬闸门: + +```diff +- { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success", +- "produces": ["log"], "needs": ["artifact"] }, ++ { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success", ++ "produces": [{ "kind": "log", "required": false }], "needs": ["artifact"] }, +``` + +改了 4 处(`executor.json` 1 处,`executor-fanout.json` 3 处:`test-a→merge`、 +`test-b→merge`、`integration→validator`——同一种「produces:log 但 test/integration +类节点从不产出可识别的 log 文件」的形状)。真正必需的 `needs:artifact` +(`required:true`)保持不变,继续受硬闸门保护。 + +--- + +## 三、已完成 + +- [x] 撤回 §2.4 那次错误方向的引擎改动(`work-graph-db.js` 的 `inboundContracts` + 恢复原状,加注释记录这条弯路,防止以后有人重新踩一遍) +- [x] 按 §2.5 改 `executor.json` / `executor-fanout.json` 的 4 处 `produces:["log"]` + 声明为 `required:false` +- [x] 新增单测 `graph-files-index.test.js` ⑪:`produces` 声明 `required:false` + 且该 kind 从未被索引到时,不拦下游节点 success;同一条边上 `required:true` + 的 `needs:artifact` 依旧受硬闸门保护(未被这次改动削弱) +- [x] 全量单测 `npx vitest run server/tests/unit`:**998/998 通过**(含新增用例, + 零回归) +- [x] (2026-08-22 追加)用真实回归任务验证 `org-coder-validator` 不再假阳性 + blocked——**已验证**:直接把这次真实卡住的那条 run 的 `graph_edge_contracts` + 行(`log` 那条)手动 patch 成 `required=0`,重试 `org-coder-validator` 并 + 恢复自动驱动,节点从 `blocked` 转 `success`,root run 最终收敛到 + `completed`。唯一没有走到的是"由 `workgraph-planning` 全新规划一张图, + 模板里 `{kind:'log',required:false}` 的对象写法被正确解析落库"这一环—— + 理论上没有风险(`validator.json` 已经在用同样的对象写法且工作正常,见下一条), + 但没有用一次全新任务端到端跑过,只是手动 patch 了已有的行。 +- [x] (2026-08-22 追加)`validator.json` 的 `needs:[{kind:'artifact',required:false}]` + 复查过:**不是同一类潜伏问题,是刻意设计**。它的 `dataContractNotes` 原文 + 写明:被验的 `artifact` 由**另一张图**(org-coder 等)产出,这里的 `needs` + 只是文档意图声明,产物索引按 `company+task` 检索、不按图/边——`required:false` + 是因为这条 `artifact` 依赖压根不指望靠这条边找到实例(找的时候看的是任务级 + 共享索引,边上声明与否不影响检索结果),跟 33 号文档这次的 bug(`log` 声明了 + 却永远不会被索引到)成因完全不同,不用动。 + +## 四、留待下次 + +- [ ] "由 `workgraph-planning` 全新规划一张图" 这一环的端到端证据仍然缺失 + (见上面第五条最后一句)——理论风险低,但没有真跑过 diff --git a/docs/done/system-design/35-validation-rigor-and-body-readiness-proposal.md b/docs/done/system-design/35-validation-rigor-and-body-readiness-proposal.md new file mode 100644 index 0000000..bf4cf8e --- /dev/null +++ b/docs/done/system-design/35-validation-rigor-and-body-readiness-proposal.md @@ -0,0 +1,233 @@ +# 35-validation-rigor-and-body-readiness-proposal. 复盘结论 + 改进方案 + +> **日期**: 2026-08-22(复盘)/ 2026-08-22(提案 A 当天拍板并实施) +> **性质**: 复盘 + 改进方案提案。承接 +> [31](./31-work-graph-fail-fallback-coverage.md) / +> [32](./32-hire-body-fill-reliability.md) / +> [33](./33-needs-gate-role-filter-bug.md) / +> [34](./34-validator-scored-without-real-deliverable.md) 四份文档的真实回归验证 +> 与调研——那几份记的是"发现了什么",这份记的是"复盘下来该怎么办"。 +> **触发**:用户问"任务结果这么差,是 agent 能力问题、架构问题、还是 graph +> workflow 缺陷",本文档是那次复盘的书面结论 + 据此提出的改进方案。 +> **状态**:**四条提案全部已实施**。提案 A(commit `bab675c`);提案 B/C/D +> 一起做完(commit `fadb6fa`,全量单测 1012/1012 通过)。细节见各提案小节下方 +> 的"实现记录"。 + +--- + +## 一、复盘结论:四类原因,权重不均 + +用一条真实回归任务(`T-mt2r6fz0-125b`,"做一个 apple.com 风格的 lemonboss 官网") +撞见的问题,按发生顺序分类: + +### 1.1 图/模板设计缺陷(已修复)—— 31、33 号文档 + +- `org-planner-sdd` 没有兜底边,自判失败即卡死到人工重试(31 号)。 +- `test→validator` 边声明的 `produces:["log"]` 是一个 test 类节点永远写不出来的 + kind,导致 `missing_required_input` 假阳性、永久熔断(33 号)。 + +这两条是**真实的图/模板设计缺陷**,不是 agent 判断失误,也不是"能力"问题—— +图本身的结构不完整。**权重:中**(真实存在,但已经修好,且这类问题一旦发现, +定位和修法都很直接)。 + +### 1.2 招聘装配可靠性 bug(已知,这次是新样本)—— 32 号文档 + +`unit-tester` 本体的 `agent.md` 全程是空白的 `_base` 骨架(带 +`LEMONBOSS_BASE_SKELETON` 哨兵标记),隔壁 `coder` 却正常填充过。32 号文档已经 +分析过这类异步装配的可靠性问题并实施了方案 B(`6a18379`),但这次撞见的样本 +发生在修复提交**之前** 1 小时,且没有专门验证过新机制是否已经完全堵住这个口子。 +**权重:这次具体检查偏弱的最直接原因**——`unit-tester` 在没有任何角色指导的 +情况下,只能靠自己对"该怎么测试网页"的通用理解去写检查脚本,写出来的东西天然 +比有明确方法论指导的 `org-coder`/`coder-validator` 弱。 + +### 1.3 一个新发现的、更深的架构缺口:完整性闸门只在招聘时查,执行时不查 + +复盘时确认过一件事(`server/services/graph-engine.js` 全文搜索): +`missingBodyDefinition()`(检测本体是否还带 `_base` 哨兵/空白)只在 +**招聘/建公司/dream 沉淀**这几条路径上被调用(`graph-service.js` / +`graph-seed-service.js` / `company-service.js` / `dream-service.js`), +**图执行的核心路径(`advanceOne`/`defaultAgentExecutor`)一次都没有调用它**。 + +也就是说:即使 32 号文档的修复完全生效、招聘那一刻永远不会留下半成品——只要一个 +本体因为**任何其它原因**(手工改坏、迁移遗留、装配虽然完成了但内容本身依然是 +空骨架而系统判断不出来……)在 `system_agents` 目录里长期处于未填充状态, +**图执行侧完全没有第二道防线**,会原样把这份空白提示词交给真实 Claude CLI 去跑, +静默产出一份"看起来是回复、其实没有任何角色指导"的结果——这正是这次 +`unit-tester` 发生的事。**这是本次复盘里唯一一条原来没人查过、独立于 32 号文档 +既有分析的新架构缺口**。 + +### 1.4 不是 agent 能力问题——两次订正过这一点 + +- 最初以为"没有浏览器工具,物理上做不到视觉检查",后来发现同一批工具权限下 + 隔壁一条任务真的跑出了 Playwright 测试(见 34 号文档 §5.3 的订正)——**能力 + 和权限都够**,缺的是指导。 +- `unit-tester` 在零指导的情况下,还是主动写出了一份能真实解析 CSS `clamp()` + 算出字号比例、能识别锚点跳转反模式的静态断言脚本——这是**相当不错的即兴发挥**, + 不是能力不足的表现。 + +**结论:这次任务结果差,主要是架构 + 一个具体的可靠性 bug,不是模型能力问题。** +按影响力排序:①执行侧没有完整性闸门(新发现,架构缺口)> ②招聘装配可靠性 bug +(已知,这次撞上了修复前的窗口)> ③图模板设计缺陷(已修)> ④agent 能力(不是 +问题,甚至有正面例子)。 + +--- + +## 二、改进方案(分两档,供讨论) + +### 第一档:低成本、高置信度,建议直接做 + +#### 提案 A:把 `missingBodyDefinition()` 接进图执行的派发前置检查 + +**做什么**:`advanceOne`(或它调用 `defaultAgentExecutor` 之前的那一步)在真正 +派发一次 agent 调用前,对这个节点的 `systemAgentId` 跑一次 +`agentLocator.missingBodyDefinition(systemAgentId)`;非空就不派发真实调用, +直接按新 fail_reason(复用已有语义相近的命名,比如 `body_not_ready`,与招聘时 +挡重复招聘的判词同名同义)让节点走 fail 流程——**跟 31 号文档修的兜底边机制是 +同一条已有的选边逻辑**,不需要发明新的路由方式,只需要在这一个检查点新增一次 +判断和一个新的 fail_reason 取值。 + +**为什么现在做**: +- 判据函数已经存在、已经测过、语义已经很清楚(`agent-locator.test.js` 覆盖过), + 不是从零设计。 +- 改动面很小:一个检查点、一个新 fail_reason、复用现成的选边/兜底/熔断机制。 + 跟 31 号文档同一类"结构性、可验证、影响面可控"的修法。 +- 直接堵住了 §1.3 说的"唯一没有第二道防线"的洞——不管以后是招聘装配失败、 + 手工改坏、还是任何其它原因导致本体不完整,图执行到这一步都会被拦下来, + 而不是静默拿一份空提示词去跑真实 LLM。 +- 有真实的失败样本(`unit-tester`)可以直接改写成回归测试,验证成本低。 + +**代价 / 需要想清楚的点**: +- fail_reason 走兜底边意味着这个节点会被打回上游重跑——但重跑到同一个 + systemAgentId,body 还是没填,会不会陷入无意义的重试循环?需要确认 + `body_not_ready` 这类 fail_reason 是否应该被排除在正常回退计数(backward_count) + 之外,还是就该老实算一次退(让 §四.D.2.1 的熔断兜底),倾向于后者——不发明 + 第二套计数规则。 +- 需要决定:检查失败时是"这个节点 blocked 等人工重新触发装配",还是"引擎自动 + 触发一次装配再重试"(后者更省心但复杂度更高,涉及跟 32 号文档的 + `agent-body-filler.js` 打通)。**建议第一版先做前者(简单、可控),装配自愈 + 留给后续。** + +**实现记录(2026-08-22,commit `bab675c`)**:按"第一版先做前者"落地—— +`runClaimedNode` 派发前查 `agentLocator.missingBodyDefinition(systemAgentId)`, +非空就直接判 `fail`(`ENGINE_FAIL_REASONS.BODY_NOT_READY = 'body_not_ready'`), +走正常的选边/兜底/熔断(不豁免 backward_count,理由同上——重试到同一个空骨架 +本体不会自愈,就该老实退熔断等人工,不发明第二套计数规则);不扣预算、不起 +边界守卫(没有调用真的发生)。 + +**中途踩的一个坑,记录一下**:第一版没加"只在真实执行器下生效"这条判据, +直接把检查焊死在派发前——结果全仓库几百个用假 `systemAgentId`(`w-prd`/`impl` +这类夹具名,不对应 `system/agents/` 下任何真实目录)+ 注入假 `executor` 的单测 +一次性全部判成 `body_not_ready`,`npx vitest run` 从 1004 掉到 75 个用例失败、 +16 个文件。根因是 `missingBodyDefinition` 查的是**真实文件系统**,而这些用例 +的本体压根不存在于真实仓库里——按真实文件系统的判据去判一个不存在的虚构本体, +"缺失"永远成立,等于把闸门焊死在开着的状态。**改法**:借用已有的 +`boundaryGuardApplicable` 判据(`agentExecutor === defaultAgentExecutor` +时才生效,测试注入假 executor 时天然跳过),不是发明一条新判据——两处判的是 +同一件事:"这次会不会真的起一个 Claude CLI 子进程",答案会不会真起决定了 +两道闸门(边界守卫 / 本体完整性)该不该查。收窄之后单测回到 1004/1004, +新增 `graph-engine-body-not-ready.test.js`(4 例,覆盖闸门生效/预算不扣/ +注入假 executor 时不生效/subgraph 节点不受约束)+ 更新 +`graph-engine-fail-reason.test.js` 的取值域断言。 + +#### 提案 B:控制台展示"这份验收有没有证据支撑"(纯 UI,不改行为) + +**做什么**:在已有的 `TaskFilesPanel`/节点详情面板上,如果 validator 的 +`attempt-*.scores.json`(或对应产出)能关联上一份"真实检查证据"(比如同目录下 +有没有一份 `tests/*.mjs`/`*.check.js` 之类的脚本、或 `attempt-*.json` 里有没有 +`test_run.executed:true` 这类字段),标一个不起眼的小标签——不是要engine 判断 +"验证够不够严谨"(那是提案 D 的范畴,成本高很多),只是**把现在完全不可见的 +差异露出来**,让人在看"已完成"三个字时,能多看一眼"这条完成背后有没有真实证据"。 + +**为什么可以现在做**:纯前端 + 读现成数据,不改变任何执行行为,风险几乎为零, +且直接回应 34 号文档 §5.3 那条"三个任务验证严谨度天差地别、界面上完全看不出来" +的具体抱怨。 + +**实现记录(2026-08-22,commit `fadb6fa`)**:没有另做一套"有没有测试脚本"的 +启发式判断——直接读提案 D 已经算好的结论(`goal-contract.js` 判决里的 +`dimensions[].evidence_required`/`.ok`),比自己猜一遍"这份产出看起来严不严谨" +更准。`GET /api/graph-runs/:id` 新增 `benchmarkVerdicts`(只读 +`listVerdicts`/`readVerdict` 落盘的判决,不重新计算,读失败就跳过那一条,不拖垮 +整个响应);`NodeDetailPanel.vue` 概览 tab 新增"验收证据"小节,逐维度显示分数 + +是否有证据支撑,缺证据的标 ⚠️。只在这个节点真的有 benchmark 判决时才出现(同「输入 +输出」tab 的"不猜、不留空标题"口径)。已用 Chrome 实测过:点开 +`org-coder-validator` 节点,六个维度分数与 ⚠️ 标记正确渲染。 + +--- + +### 第二档:更大的设计决策,建议先讨论优先级再动手 + +#### 提案 C:给"视觉/UX 类"任务提供一套标准化、可复用的验收工具 + +**问题**:`p-bkhylu` 那条任务能做到 Playwright 级验证,纯粹是因为那次的 agent +会话自己想到了、自己写了 `verify.mjs`。换一次任务、换一次会话,不保证还会有人 +这么做——`unit-tester` 这次就没想到(或者说,没被指导要这么想)。 + +**候选做法**:在仓库里放一份可复用的验收脚本模板/skill(比如 +`scripts/visual-acceptance-check.mjs`,封装"读 index.html/css/js → 用 Playwright +量视口/对比度/字号"这类通用检查逻辑),并在 `coder`/`unit-tester`/ +`coder-validator` 这类角色的 `agent.md`(或它们的 `tools.md`)里显式点名:"遇到 +`goal-benchmark.json` 里要求视口/对比度/首屏这类视觉维度时,用这份工具,不要 +自己发明"。 + +**代价**:这不是"填一个空文件"那么简单——要设计这份工具的接口(读哪些文件、 +输出什么形态、怎么接进 `attempt-*.json`)、要决定哪些角色的 `agent.md` 需要 +更新、要不要影响现有已经在跑的任务。**这是一个需要先讨论范围的设计任务, +不建议直接动手**。 + +**实现记录(2026-08-22,commit `fadb6fa`)**:接口定成了四个独立子命令 +(`overflow`/`style`/`console`/`contrast`),各自吐一行 JSON 到 stdout,不做成 +"一次跑全部维度"的大而全脚本——`goal-benchmark.json` 的维度因任务而异,硬编码 +成一份不可复用。只更新了 `benchmark-validator/agent.md`(真正逐维度打分、写 +`evidence` 的那个角色),没有动 `coder`/`unit-tester`(当初以为的目标,后来发现 +写分数和证据的其实是另一个角色,见 §五.2 的角色梳理)。没有影响任何现有已经在跑 +的任务——纯新增文件 + 一段引用说明,agent.md 里其余内容一字未改。配一份不进 +`vitest` 快速循环的独立冒烟脚本(`visual-acceptance-check.smoke.mjs`,起真实 +Chromium 断言四个子命令,几秒量级)。 + +#### 提案 D:给"要求真实检查"的验收维度加引擎级证据闸门 + +**问题**:`goal-benchmark.json` 的 `evidence_hint` 目前只是**写给 agent 看的 +文本提示**,agent 信不信、照不照做,引擎完全不管——验证严不严谨完全靠自觉。 + +**候选做法**:给 `evidence_hint` 里出现"打开浏览器/截图/开发者工具"这类关键词 +的维度,要求打分时必须附带一个可核实的 `evidence_ref`(比如指向一份真实脚本的 +`stdout` 日志文件,或类似 `coder-validator` 已经在用的 `test_run.executed:true` ++ `output_excerpt` 那套惯例)——引擎收到一个没有 `evidence_ref` 的高分视觉维度时, +可以选择"降级为 inconclusive"或至少"打一个显式的 ⚠️ 标记",而不是原样采信。 + +**代价**:这个提案影响 `goal-benchmark.json` 的 schema、`goal-contract.js` 的 +判定逻辑、以及所有会产出这类分数的 validator 角色的 `agent.md`——影响面明显 +比提案 A/B 大,且需要先定"哪些关键词触发这道闸门""没有证据时降级还是拦截"这类 +细节,**建议留到提案 A/B 落地、有更多真实样本之后再决定要不要做,不是这次的 +优先级**。 + +**实现记录(2026-08-22,commit `fadb6fa`)**:关键词用了固定正则 +(`浏览器|viewport|视口|开发者工具|devtools|console|network|截图|screenshot`, +不分大小写);没有证据时选的是**拦截**(该维度直接判不过),不是"降级为 +inconclusive 但仍算过"——因为整个 §一复盘的教训就是"光有分数不该被当真", +选降级等于还是采信了那个分数,只是换了个说法。 + +**中途发现的一处设计对齐**:`goal-benchmark.json` 的 schema 完全不用改——`evidence` +不是新字段,`benchmark-validator/agent.md` 早就在教 agent 把 `dimensions[].evidence` +写进判词文件(`attempt-.json`),只是这份数据从来没被 `goal-contract.js run` +读过(它只读 `--target` 指向的 `attempt-.scores.json`,只有分数)。所以没有 +发明新格式,而是让 `runBenchmark` 多看一眼同目录的姊妹判词文件—— +`readSiblingEvidence()`:`attempt-.scores.json` → 同目录 `attempt-.json` → +取 `dimensions[].evidence`。`--target` 文件里如果也显式给了 `evidence.`, +优先用那份。6 条新单测覆盖:命中关键词但无证据判不过 / 给了证据后正常通过 / +普通维度不受约束 / 空字符串按没给处理 / 姊妹文件里的证据能读到 / 姊妹文件缺失 +不拖垮打分。 + +--- + +## 三、推进顺序(原提案,已按此顺序全部做完) + +1. **提案 A**(执行侧完整性闸门)——最推荐现在就做,闭环 §1.3 那个新发现的架构 + 缺口,改动小、可测、影响明确。✅ 已实施(commit `bab675c`) +2. **提案 B**(控制台证据可见性)——可以跟 A 一起做,纯 UI,风险低。✅ 已实施 +3. **提案 C / D**——原计划"先攒几个更多真实任务的样本再决定要不要做",用户直接 + 要求一起做完,所以没有等到攒够样本——两条都已实施(commit `fadb6fa`), + 实现记录见各自小节。样本量不足这条风险仍然存在:目前只有一次真实回归验证过 + 这套证据闸门,`evidence_hint` 关键词表是不是覆盖了所有该拦的场景、拦下来的 + 维度会不会引发新一轮无意义回退,都还没有大量真实任务的数据支撑,留意后续 + 真实使用中的反馈。 diff --git a/docs/done/system-design/39-boss-agent-task-supervision.md b/docs/done/system-design/39-boss-agent-task-supervision.md new file mode 100644 index 0000000..e0c05ad --- /dev/null +++ b/docs/done/system-design/39-boss-agent-task-supervision.md @@ -0,0 +1,192 @@ +# 39. Boss Agent 任务看板监督 + 公司目标代码目录 — 需求评估与设计草案 + +> **状态**:✅ P1-P5 已实现(2026-08-30,commit `a00590a` P1/P3/P4 后端、`5108d67` P2 前端、`d967ac0` P5)。本文档是落地前的设计草案,实现细节以代码为准,本文档不再更新,仅存档决策过程。**已知未覆盖的后续工作见文末新增的「六、已知遗留」**。 +> **日期**:2026-08-30 +> **背景**:承接对话讨论——goal/task 两级模型([27-P3C](../../done/system-design/27-p3c-goal-task-model.md))已落地,但推进它的是纯确定性代码(`task-scheduler.js` + `transitionRun` 钩子),没有任何 agent 在持续监督这块看板、处理异常、把执行中发现的新问题回写成新任务。用户提出五阶段方案补上这一层,并追加公司级目标代码目录需求。 +> **读者须知**:本文档大量引用现有代码路径核实"已有 vs 缺失",结论以文内给出的具体函数/文件为准;这些引用会随代码演进过期,落地前请重新核实。 + +--- + +## 一、先说结论 + +用户的五阶段里,**P2(任务看板)本质已完成,P1/P3 的基础设施已有大半,P4 是真正的新增核心,P5 的机制已经实现了 70%,只是没有暴露到公司层面。** 真正从零开始的工作量比五个"phase"暗示的要小得多,但顺序建议大改:**P4(回写闭环)应该提到最前面**,因为它才是这次讨论最初的诉求("自己发现问题、自己重新规划"),P1/P3 的 boss agent 只有在 P4 的工具/数据通路建好之后才有事可做。 + +| 用户的 phase | 名义工作量 | 实际现状 | 建议 | +|---|---|---|---| +| P1 雇佣通用 boss agent | 新建一个角色 + 训练 | 心跳基础设施、`ceo` 本体、Innovator 训练方法论**全部已有** | 复用 `ceo`,不新建角色;工作量收窄成"写 heartbeat.md + 工具" | +| P2 任务看板独立页面 | 一个新系统 | goal/task 两级看板**已实现**(`GoalTaskPane.vue`),只是嵌在控制台里 | 降级成 UI 改造,非必须,可排最后 | +| P3 心跳挨个决定下一个 task | 新的调度逻辑 | 挨个执行**已经是** `task-scheduler.js` 在做,且做得更好(事件驱动、零成本、已测试) | **不要重建调度**;心跳的职责收窄为"异常判断",且应事件触发而非纯轮询 | +| P4 bug/新任务回写看板 | 一条新闭环 | **不存在**——没有任何 agent 可读写 goal/task 板的工具,四分类失败归因(30 号文档)目前只导向"重试或人工介入",不导向"开新任务" | **真正的核心工作,优先做** | +| P5 公司目标代码目录 | 新能力 | DevLoop 的 `path-resolver.js` 已实现 `sandbox` / `linked-new` / `linked-existing` 三种模式,**`linked-existing`(接管已有目录)已经是完整实现**,只是没有公司级默认值,也没打通到 org/work graph(非 DevLoop)路径 | 补一个公司级默认配置 + 核实非 DevLoop 路径是否需要同样机制 | + +--- + +## 二、逐 phase 现状核实 + +### P1:每个公司雇佣 boss agent,system agent 层训练通用版本 + +**已有的基础设施**(全部实地核实过): + +1. **通用心跳机制**:`server/services/heartbeat-service.js`。状态落在 `system/agents/{aid}/{cid}/heartbeat.json`(公司态目录,不是共享本体——这条边界是踩过坑之后加的,见文件头注释),本体 `config.json.heartbeat` 只是只读默认建议间隔。`_triggerHeartbeat()` 按 `setInterval` 定时给 agent 发一条 `[SYSTEM HEARTBEAT]` 系统消息(走正常的 `/api/agent/{agentId}/chat`),要求 agent "**根据你的身份和 heartbeat.md 规则**……如果一切正常,无事发生,请直接回复『心跳正常』",带 30 分钟会话互斥防重叠。**这套机制现成可用,不用重建。** + - ✅ **修正一处之前的误判**:原以为 prompt 里"扫描全局 OKR 目标状态和节点"那句硬编码需要改代码才能适配新 goal/task 模型。查了 `system/agents/` 下已有 **35 个**本体带 `heartbeat.md`(如 `org-innovator/heartbeat.md`),内容五花八门、完全跟 OKR 无关,说明触发消息里那句"扫描 OKR"从来就只是**默认示例文案**,各 agent 早就按自己 `heartbeat.md` 里写的规则各干各的。**结论:`heartbeat-service.js` 大概率零改动**,只需要新建 `system/agents/ceo/heartbeat.md`,写清楚"去查 goal/task 板、按 §四.9 的规则判断",agent 读到自己的 `heartbeat.md` 就会照着做,不需要动服务端代码。落地时找一个现有 `heartbeat.md` 生效的真实案例复核一次这个推断,再动手写 `ceo` 的那份。 +2. **本体已存在**:`system/agents/ceo/`(`type: "cxo"`,"Chief Executive Officer",`heartbeat.enabled: false` 默认关)。同时存在 `system/agents/master/`(`type: "master"`,**全局唯一**,`/api/master/chat`,负责建公司/招聘等跨公司操作)。两者定位不同:`master` 是平台层面的单例助手,`ceo` 才是"每个公司一个"的角色。 +3. **训练方法论已验证**:`36-prd-agent-methodology-training-feasibility.md` 已经用 Innovator(Hunt→Digest→Evolve)训练出过一个方法论型 agent(`prd-methodology-trial`),2 轮收敛、96/97 分,方法论对这类"判断型"角色确实管用。可以照搬这条路径训练 `ceo` 的监督方法论,而不是手写一份 prompt 拍脑袋。 + +**结论**:不建议新建"boss"角色。**复用 `system/agents/ceo/`**——它的岗位定义(CEO)本来就该管"任务有没有卡住、要不要重新分解",另起一个语义重叠的角色只会重犯这个仓库反复踩过的"名字歧义"坑(`27-naming-conventions.md` 通篇在防这个)。P1 的实际工作量是:① 给 `ceo` 补一份 `heartbeat.md`(监督规则);② 给它挂上 P4 需要的工具(见下);③ 默认 `heartbeat.enabled: true`(但见 P3 的成本讨论,建议先不无脑轮询);④ 可选:用 Innovator 训练它的监督方法论。 + +**缺口 A**:目前**没有任何 agent 工具能读写 `goals`/`tasks` 表或操作 graph run**(搜了 `server/src/tools/` 全目录,零命中)。`ceo` 就算被唤醒也无事可做——这是 P1 能不能生效的真正前提,见 P4。 + +**缺口 B(新发现,之前漏了):`ceo` 现在没有被雇进任何公司,`hire()` 也没有任何自动触发点会调它**。查了 `hire(companyId, roleName)`(`company-service.js:422`)——它只查本体目录 + `company_agents` 表,**不查 org graph**,机制上雇一个不在图里的角色完全没问题(数据模型层面,"雇了"和"进图"是两件独立的事)。但目前**没有任何代码路径会主动调 `hire(companyId, 'ceo')`**——它不在三份 org-graph-templates 里,也没有类似 `orggraph-planning` 那样的自动招聘钩子替它兜底。`37-org-graph-department-scoping.md` 里记录过一模一样的处境:`hr/` 本体"已经真实存在……只是从没被装进任何图……今天完全是编外人员"——`ceo` 现在是同款处境,**不是有先例可抄,是同一个坑还没人填**。P1 必须显式加一步:① 公司创建流程里(不管选哪个 org-graph-template)固定 `hire(companyId, 'ceo')` 一次;② 存量公司需要一次性回填脚本,遍历现有公司补雇。这条不做,前面说的"复用 ceo"就是一句空话——压根没有 `ceo` 实例在跑。 + +--- + +### P2:任务看板独立页面 + +**已实现**([27-P3C](../../done/system-design/27-p3c-goal-task-model.md),✅ 2026-08-20):`web/src/components/graph-console/GoalTaskPane.vue` + `goal-task-model.js`,挂在 `/:companyId/graph-console` 左栏。goal/task 两级状态、优先级排队位次、颜色语义全部已有,REST 是 `server/routes/company.js` 的 `GET/POST /api/company/:cid/goals` 系列。 + +**用户要的"独立页面"** 目前是控制台的一个侧栏,不是路由级独立页面。这是纯 UI 改造,**不涉及数据模型或后端**,随时可做,风险最低。 + +**"subtask" 层**:当前**没有独立的 subtask 表**,`task` 就是看板能管理的最细颗粒度。task 再往下是 `graph_runs`(work graph 节点,可能含嵌套子图),这层在中间的执行画布(`WorkGraphCanvas`)里看,不在左栏。 + +> ⚠️ **不建议新增一张 subtask 表**去对应 work graph 节点。这个仓库已经因为"两套任务体系不统一"吃过至少两次苦头(P3C §十二"批 5"整章在处理这个债,`27-p3c-batch3-handover.md` 也记过一次事故)。如果要在看板上展示"这个 task 内部具体在跑哪几步",应该是**只读地把该 task 当前 `current_run_id` 对应的 `graph_run_nodes` 渲染成一份"子步骤列表"**,复用现有表和现有执行画布的数据源,不新建写路径。 + +#### P2 决定:与 OKR 页面的关系(已拍板,2026-08-30) + +排查发现现状比预期乱:**OKR 相关 UI 现在有两份几乎逐行相同的重复实现**——`web/src/views/OkrDashboard.vue`(路由 `/okr`,`requireAdmin`,零导航入口的孤儿页面)和 `CompanyDashboard.vue` 里挂着 "goals" 名字、实际渲染 "Goals (Full OKR Tree)" 的 tab——两者都是旧 OKR 树(`O-xxx`,owner + progress%),是"OKR 正名"(`27-naming-conventions.md` §六)前端没跟着改完的遗留物,前端两处都调 `/api/okr/*`,和 P3C 的新 goal/task 模型完全不是一回事。 + +**已拍板**:**不合并**,OKR 这套前端 UI**直接废弃**,新任务看板**另起一个独立页面**。具体动作: + +1. **新增路由**:仿照 `GraphConsole` 已经立好的先例(`/:id/graph-console` 是独立路由,不挤在 `CompanyDetail` 的 `:tab(...)` 白名单里),新增 `/:id/task-board`(命名待定)→ 新建 `web/src/views/TaskBoard.vue`。**不要塞进 `:tab(...)` 正则**,理由与 `graph-console` 当年的注释一致:任务看板信息密度高,值得一个整页。 +2. **复用现有逻辑,不重写数据层**:`GoalTaskPane.vue` + `goal-task-model.js` 现在已经封装了 goal/task 的状态映射、排序、颜色语义(且有专门的单测 `goal-task-model.test.js` 兜底)。新页面应该是把这个组件从 `GraphConsole` 的侧栏布局里"拔出来"放大铺满整页,**不是重新实现一遍**。`GraphConsole` 侧栏可以保留一份精简版(点某个 task 仍需跳转去看它的执行图),也可以改成跳转链接指向新页面——两种都行,实现时再定。 +3. **废弃 OKR 前端**:删 `/okr` 路由 + `OkrDashboard.vue`;删 `CompanyDashboard.vue` 里那个挂着 "goals" 名字实为 OKR 树的 tab;路由 `:tab(...)` 白名单里的 `goals`/`okr` 两个值一并摘掉,避免留死链接。 +4. **导航入口**:新页面必须挂真实导航入口(`AppNav` 或公司详情页的显眼位置),不能重蹈 `/okr` 那种"孤儿页面"的覆辙。 +5. **鉴权**:`okr.js` 当年挂 `requireAdmin` 是因为它是孤儿页面、没做归属校验的权宜之计(`38-auth-multi-tenant-isolation.md`),**不是"任务看板天生该管理员专属"**。新路由要按 `company.js` 等其他公司级路由的标准做法,走 `canAccessCompany` 归属校验,不能照抄 `requireAdmin`。 + +**范围之外、本次不动**(留作独立的后续决定,不在这次 P2 改造里顺手做):`/api/okr/*` 后端路由、`okr` 相关 SQLite 表、`goals.okr_node_id` 这个可选外键字段——前端 UI 废弃不等于后端子系统也要一起拆,那是一次单独的、影响面更大的退役决定(牵涉数据迁移/存量数据处理),需要单独评估,这里先只处理前端。 + +--- + +### P3:心跳挨个决定下一个 task + +**这条按用户原话实现是在重建一个已经存在、已经测试、且更优的系统。** `task-scheduler.js` 的队列排序口径是 `priority → seq → created_at`(`ORDER BY priority, seq, created_at`),出队靠 `transitionRun` 这个唯一漏斗事件触发,不是轮询;v1 约束 `max_parallel_tasks`(`company.json`,缺省 1)决定"挨个"还是"并发几个",`server/tests/unit/task-slots-parallel.test.js` 覆盖了 >1 的情况。这套机制零 LLM 成本、毫秒级触发、已经过多轮真实回归验证(`31-work-graph-fail-fallback-coverage.md` 的真实 LLM 端到端案例)。 + +**成本账**:`heartbeat-service.js` 现在的实现是**每一次 tick 都真的发一次 LLM 调用**,只是 prompt 要求"没事就回复 `[心跳正常]`"——省不了那次调用的 token/延迟。如果 N 家公司、每家每 5 分钟一次心跳,24 小时就是 N×288 次 LLM 调用,绝大多数只会换回一句"心跳正常"。 + +**修订建议**:boss agent 心跳**只负责调度器处理不了的判断题**,而且触发方式改成"事件优先、轮询兜底",不是纯定时轮询: + +1. 判断题清单(调度器管不了、需要 LLM 判断的): + - task 长时间 `blocked` 或 `failed`(预算耗尽/依赖死锁)——要不要重新分解、要不要升级给人类; + - 同一 goal 下多个 task 出现优先级冲突(业务变化导致原定顺序不再合理)——要不要调整 `priority`; + - `task-planning` 3 轮降级成单任务(P3C §四.9 的降级路径)——要不要人工介入重新审视这条 goal 的描述。 +2. **优先事件驱动**:在 `transitionRun`/`settleGoal` 这类既有钩子里,遇到上述判断题触发的状态变化(比如 task 转 `blocked`)时,**主动**唤醒 `ceo` 处理这一条,而不是让它等下一次心跳才发现。心跳退化成**低频兜底**(比如 30 分钟一次,只扫"有没有钩子漏掉的异常"),不是主决策路径——这与现有 `task-scheduler.js` 头部注释里"兜底扫描周期"的设计哲学完全一致,照抄这个模式即可。 + +--- + +### P4:org/work graph 产生的新 bug / 新 task 回写看板,形成闭环 + +**这是五个 phase 里唯一真正从零开始、也是这次讨论最初诉求("自己发现问题、自己重新规划")真正要落地的地方。** + +**现有的失败归因机制**(`30-task-delegation-reliability-gaps.md`):`BugTriager`/`validator-triager` 类节点已经在做四分类——`contract_mismatch` / `impl_bug` / `spec_ambiguous` / `infra_flake`——但**四条路径的终点目前都是"原地重试"或"预算耗尽后 blocked 等人工"**,没有一条走向"生成一条新 task/goal"。 + +**需要新增的两个通路**: + +1. **确定性通路(不需要 agent 判断)**:把四分类里"显然该开新任务"的情形直接在 `routeFailure`(`graph-engine.js`)里接一条规则,而不是等 boss agent 心跳才处理——比如 `spec_ambiguous` 在 goal 级预算内重试超过阈值,直接生成一条 `status='draft'`、`created_by='system'`、挂在同一 `goal_id` 下的新 task,附上失败节点的诊断产物作为新 task 的 `description` 素材,人工/`ceo` 再审。这条不需要 LLM 判断"要不要开新任务",只需要判断"开一条什么样的"——后者才是可选的 agent 介入点。 +2. **主观通路(需要 agent 判断,这是 P1 的 `ceo`/工具的用武之地)**:agent 在执行任务过程中发现的、**不在当前 task 描述范围内**的问题(比如 coder 顺手发现一个无关的历史 bug),需要一个**工具**让它"提一条新任务"而不是把不相关的修复硬塞进当前 task 的产物里。这是当前完全没有的能力。 + +**必须先建的地基**——查完代码,发现比"包一层工具"更底层的东西还没有,得先排清楚三件事,顺序不能反: + +#### 地基 0(阻塞项):`ceo` 现在物理上调不通这套 REST API + +`server/src/tools/tool-registry.js` 开头写得明明白白——**这是"Master Agent 工具注册中心"**,`[TOOL_CALL: xxx]` 这套 JSON 文本协议是 Master 专属的自定义解析层,`server/src/tools/devloop/` 下另有一套 DevLoop 专属的 tool-registry。`ceo` 这类普通 org node agent(走标准 Claude CLI 子进程)**两套都不属于**——它能用的只有 Claude CLI 原生的 Bash/curl,具体调用哪个接口靠 `tools.md` 里的**大白话 + curl 示例**(`task-planning/tools.md` 就是这个写法的范本)。所以"task-board-tools.js" 这个提法本身**不成立**——不需要新写一个 JS 工具模块,需要写的是一份 `system/agents/ceo/tools.md`(现在是空文件)。 + +但更麻烦的是:`server/routes/company.js` 顶部 `router.use(requireAuth)`(14 行),**goals/tasks 全系列端点只认人类 JWT**,没有走 `agent-chat.js` 那样的 `requireAuthOrInternal`。也就是说,就算 `ceo` 的 `tools.md` 写得再clear,它发出的每一条 curl **现在都会静默 401**——这正是最近几个 commit(`d48425a` selfChat 自环漏带内部令牌、`9e58656` bin/lemon.js task report/dispatch 401)反复修的那一类坑,company.js 这次还没打这个补丁。**这是全部 P4 工作的前提阻塞项**,必须先给 goals/tasks 这组路由加上 `requireAuthOrInternal`(或者更收紧一点,只给这几条挂内部令牌白名单),agent 才有物理上调得通的可能。 + +#### 地基 1:`POST /:companyId/tasks` 没有"草稿"模式,agent 提的任务会立刻烧预算 + +现有 `POST /:companyId/tasks`(`company.js:2077`)建完 task 行**无条件紧接着调 `enqueueTaskRun` + `startNextIfSlotFree`**——语义是"下达即执行",服务的是"人类/外部 agent 派工"这个存量场景(代码注释原话:"存量调用方的心智是下达即执行")。这条路径不适合 `ceo` 自主提的新任务:`task-planning` 拆出来的 task 尚且要先落 `draft`、人点"全部下达"才转 `queued`(P3C §四.9 的既定哲学——"一次 LLM 猜测直接烧 5 份预算"是要避免的事),`ceo` 一次判断失误直接把新任务怼进队列烧钱,风险比 task-planning 更高(task-planning 只在 goal 刚建时跑一次,`ceo` 是持续在线判断)。 + +**需要的改动很小**:给 `POST /:companyId/tasks` 加一个 `draft: true`(或 `status: 'draft'`)入参,为真时只调 `wdb.createTask()` 落库,跳过 `enqueueTaskRun`/`markGoalDispatched`/`startNextIfSlotFree` 三步,返回 `status: 'draft'`。已有的 `POST /:companyId/tasks/:taskId/enqueue`(`company.js:2275`,`draft → queued`)原样复用,不用新建。默认行为(不传这个参数)完全不变,纯加法,不影响存量调用方。 + +#### 地基 2:`ceo` 的 `tools.md` 写什么 + +在地基 0/1 落地后,照抄 `task-planning/tools.md` 的写法(先说"大概率不需要工具"、给出何时才用、给出 curl 示例、专辟一节"你不该用的工具"防止越权)给 `ceo` 写一份,覆盖: + +- **读**:`GET /:cid/goals?status=active`(扫哪些 goal 在跑)、`GET /:cid/tasks?status=blocked` / `?status=failed`(找卡住的)、`GET /:cid/goals/:goalId`(看某 goal 下全部 task + run 摘要) +- **写**:`PATCH /:cid/tasks/:taskId`(改 `priority`)、`POST /:cid/tasks`(带 `draft:true`,提新任务)、`POST /:cid/tasks/:taskId/enqueue`(把自己或别人提的草稿正式下达——这一步要不要也留给人类点,是个开放问题,见 §四) +- **明确不该用**:`org-graph/replan`、招聘类端点(这些是 HR/`orggraph-planning` 的事,`ceo` 判断"缺角色"之后应该是发通知/提 task,不是自己动手改组织结构) + +#### 确定性通路(routeFailure 里那条规则)不走这套 REST + +30 号文档四分类里"显然该开新任务"的情形(比如 `spec_ambiguous` 重试超阈值),是 `graph-engine.js` 服务端内部代码,**直接调 `wdb.createTask()`**(同进程函数调用),不必经过 HTTP、不必带内部令牌,落库同样是 `draft`。这条通路和 `ceo` 的 agent 通路是并行的两条腿,共享同一个"落 draft、不自动下达"的规则,但触发方式不同(一个是引擎钩子里的确定性规则,一个是 agent 主观判断)。 + +**先做地基 0(鉴权)→ 地基 1(draft 模式)→ 地基 2(`tools.md`),P1/P3 的 boss agent 才有事可干**——三步不做完,`ceo` 被心跳唤醒了也只能干瞪眼,或者干瞪眼都算好的,更可能是每次尝试都吃一个静默 401。 + +--- + +### P5:公司级目标代码目录("接管已有工作目录") + +**核实结论:这个能力在 DevLoop 层面已经完整实现,只是没有公司级默认值,也没有确认是否覆盖非 DevLoop 的执行路径。** + +`server/services/devloop/path-resolver.js` 的 `allocateProjectRoot()` 已支持三种模式(`server/routes/devloop.js` 建项目时传 `mode`/`target_path`): + +| mode | 语义 | 现状 | +|---|---|---| +| `sandbox` | 自动分配到 `data/companies/{cid}/projects/{pid}/`(lemonboss 内部) | ✅ 已实现,默认值 | +| `linked-new` | 用户指定一个**空/新建**的绝对路径作为项目根,登记进 `~/.devloop/linked-projects.json` | ✅ 已实现 | +| `linked-existing` | 用户指定一个**已存在**的目录,接管为项目根 | ✅ **已实现,不是文件头注释说的"预留接口拒收"**——代码实测有完整校验:路径必须存在且是目录、不能已经是别的 DevLoop 项目(`project.json` 冲突检测)、若有 `.git` 必须 working tree 干净(防止 agent 的 `git add -A` 把用户脏改动卷进自动 commit)。文件头那句注释是过期文档,**建议顺手修正**。 | + +这正是用户要的"像 Claude Code 选工作目录一样接管一个已有目录",**核心机制已经有**,且安全校验(黑名单敏感路径、git 干净树)已经比较完善。 + +**org/work graph(非 DevLoop)路径已核实**,结论比预期更好也更麻烦,分开说: + +**好消息:不是两套系统**。`graph-engine.js` 有自己的 `ensureProjectRoot(ctx)`(3476 行),文件头注释写明"位置的分配**复用 DevLoop 的 `path-resolver.allocateProjectRoot`(sandbox 模式)**,不新造一套"——`allocateRunProjectRoot()`(3458 行)内部真的就是调 `require('./devloop/path-resolver').allocateProjectRoot({ company_id, mode: 'sandbox' })`。org-coder 类节点的交付物落点、`graph_files` 索引、`34号文档` 提到的"项目根",指的都是这同一套机制,不是 `locator.taskDir`(那是 agent 自己的运行 cwd,`_applyRuntimeScope` 分配的,两者不是一回事——agent 在 `locator.taskDir` 里跑,但写产物是写去 `ctx.projectRoot`,这个区分在 `graph-engine.js` 3736 行的"越界写入"检测里能看到:写到项目根之外会被判越界)。**结论:P5 只需要改一处入口(`allocateRunProjectRoot`),不需要在 org graph 侧另建一套。** + +**坏消息:现在的分配粒度是"每个 root run 一份全新 sandbox",不是"每个公司一份持久目录"**。`allocateRunProjectRoot()` 硬编码 `mode: 'sandbox'`,且**没有任何"复用已有项目根"的入参**——`ensureProjectRoot()` 的幂等只在**同一个 root run 内**成立(先读 `project-root.json` 再决定要不要新分配),跨 run(也就是跨 task)**永远分配一个新 `pid`**,哪怕是同一个公司、同一个 goal 下的两个 task,也会落进两个互不相干的空目录。这比"没法接管外部目录"更根本——现在**同一家公司自己的两个任务都不共享代码**,遑论接管一个外部已有仓库。 + +**P5 真正要做的三件事**(比原先设想的"补个配置项"要深一层): + +1. **公司级配置**:`company.json` 加 `target_dir`(绝对路径,可空)+ `target_mode`(`linked-new` / `linked-existing`,配了 `target_dir` 才有意义)。 +2. **改 `allocateRunProjectRoot(run)`**:读 `run.company_id` 对应的 `company.json.target_dir`。**配了就不再"每 run 分配新 pid",而是所有该公司的 run 一律解析到同一个 `root_path`**(跳过 `path-resolver` 的 pid/registry 那一整套,直接返回固定路径,首次访问时按 `target_mode` 做一次 `linked-existing`/`linked-new` 的校验——现成的校验逻辑,`path-resolver.js` 261-310 行原样复用,只是只做一次,不是每个 run 各做一次);没配 `target_dir` 就走现在的 `sandbox` 老路径,行为不变。 +3. **⚠️ 并发风险,必须在设计里显式处理**:一旦多个 task 共享同一个 `target_dir`,就与前面讨论过的"跨 task 并发"(`max_parallel_tasks > 1`)正面相撞——两个 coder agent 同时写同一个 git 仓库会冲突,`linked-existing` 现有的"进入时 working tree 必须干净"只是一次性校验,不是运行期互斥锁。**建议 v1 做法**:`company.json.target_dir` 非空时,该公司的 `max_parallel_tasks` 强制钉死为 1(在 `maxActiveRootRuns()` 里加一条:读到 `target_dir` 就忽略/覆盖用户配的 `max_parallel_tasks`),从根上排除并发写冲突,比引入 git worktree/分支合并那一整套复杂度便宜得多。`ponytail`: 这是刻意的简化,天花板是"一家公司同一时间只能有一个任务真正动代码";真要多任务并发写同一个仓库,需要 git worktree + 完成时合并这一层,属于独立工作量,不在这次范围内。 + +**建议**:P5 独立于 P1-P4 推进(数据面完全不相关),优先级可以更高——但工作量比最初评估要大一档,不是"公司级配置 + 一次核实"就完,是"公司级配置 + 改分配粒度 + 并发约束联动"三件套。 + +--- + +## 三、修订后的建议顺序 + +用户原始顺序是 P1→P2→P3→P4,本文档建议倒过来做地基: + +``` +第一步:P4 地基 —— 建 task-board-tools(读写 goal/task 的 agent 工具) + + routeFailure 里接确定性的「四分类 → 自动开新 task」规则 +第二步:P1 收窄版 —— 给 ceo 配 heartbeat.md(监督规则,认新 goal/task 模型不是旧 OKR) + + 挂上第一步的工具 +第三步:P3 收窄版 —— 心跳只做异常判断,事件触发优先、轮询兜底、绝不重做调度 +第四步:P4 完整版 —— 把「主观判断要不要开新 task」的部分交给已经装好工具的 ceo +第五步:P2(UI) —— 独立任务看板页面,随时可插入,风险最低 +并行轨:P5 —— 公司级默认 target dir 配置 + 核实 org graph 交付物落点是否需要同样机制 +``` + +理由:P1/P3 的 boss agent 在 P4 地基建好之前没有可操作的对象;P2 是纯 UI,不卡任何人;P5 数据面独立,可以完全并行推进。 + +--- + +## 四、开放问题(已拍板,2026-08-30) + +1. ~~`ceo` 会不会和已有流水线角色冲突~~ **已核实排除**:`system/org-graph-templates/{dev,content,ops}.json` 三份模板全文搜索 `ceo`,零命中——`ceo` 现在没有出现在任何一张图里,是完全没被使用的孤儿本体。给它加监督职责没有冲突。 +2. **P4 确定性通路新开 task 的 `report_to`:继承原 goal 的 `report_to`**。`okr_node_id` 不用单独决定——它按 P3C §四.7 既定规则只挂在 `goals` 表,新 task 挂在同一个 `goal_id` 下自动沿用该 goal 的 OKR 归属,不存在"新开一份"的问题。 +3. **心跳成本策略:事件触发为主 + 低频兜底轮询**(不加"异常持续够久才唤醒"的抖动过滤,先简单落地)。主路径挂在 `transitionRun` 等既有钩子上,异常状态一出现就唤醒;另加一个量级在 30 分钟左右的低频轮询兜底,防钩子覆盖不到的漏网情况——直接照抄 `task-scheduler.js` 头部注释里"兜底扫描周期"那个既有设计哲学,不重新发明。 +4. **`ceo` 可以全权自主 `enqueue` 自己提的 draft task,不需要等人类点**——这是五个选项里最激进的一档,明确高于 `task-planning`(§四.9 定案:产出永远先落 draft,人点"全部下达")的自主权限。**这个选择意味着安全网必须落在别处,不能靠"人工审核"这一层**:落地时要确保 `max_parallel_tasks`(防止一次性并发太多)、`DEFAULT_TASK_BUDGET`(防止单任务无限烧钱)、goal 级 `org-validator` 收敛验收(防止"完成"了但没真正达成)这几道既有闸门都乘上了 `ceo` 自主创建的 task,不能因为创建路径变了就绕开——这几道闸门本来就是数据层/调度器层强制的(不挂在"谁创建的"这个字段上),照理天然覆盖,但落地时要专门写一条回归测试钉死这一点。 +5. **P5 org graph 交付物落点调研:已做完**,结论见 §二 P5 小节——不是两套系统(复用同一个 `path-resolver`),但分配粒度是"每个 task 一份全新 sandbox",同公司任务之间都不共享代码,比预想的工作量更大,且与"跨 task 并发"直接联动,需要一条并发约束兜底。 +6. **P4 确定性通路的实际挂载点:已实现并确认(2026-08-30)**。文档原先设想挂在 DevLoop 的四分类(`spec_ambiguous`/`contract_mismatch`)上——但那是 DevLoop 专属词汇,`graph-engine.js` 的 `routeFailure` 用的是另一套(`impl_bug`/`input_underspecified`/`skill_defect`/`infra_flake`),根本没有这两个取值。实现时改挂在 `handleBackwardEdge` 的双层硬顶熔断(单边 `loop_count>3` 或 run 级 `backward_count>5` → `force_terminated`)上——**任何**原因导致的反复回退不收敛都触发,不按 `fail_reason` 精筛。**已确认维持这个实现,不额外收窄**。副作用是良性的:`infra_flake` 走的是完全独立的原地重跑代码路径(不经过 `handleBackwardEdge`),结构上天然不会触发这条自动开新任务的规则,不需要专门加排除逻辑。落点函数:`graph-engine.js` 的 `maybeProposeReplanTask()`,挂在 `handleBackwardEdge` 熔断分支里(约 2131-2133 行)。 + +--- + +## 六、已知遗留(实现后核实,2026-08-30) + +代码落地后回查,发现两个当初设计里提过、但没有专门验证/没做完的点,记在这里而不是假装文档已经全部兑现: + +1. **§四.4 定案的"回归测试钉死闸门"没有单独落地**。`ceo` 自主 `draft` + `enqueue` 走的是和人类调用完全相同的 `POST /:cid/tasks` → `POST /:cid/tasks/:taskId/enqueue` 两个端点(`max_parallel_tasks`/`DEFAULT_TASK_BUDGET`/goal 级 `org-validator` 这几道闸门都挂在数据层/调度器层,不认"谁创建的",理论上自动覆盖),但没有一条测试专门断言"ceo 创建的 task 一样会被这几道闸门拦下"——现有测试(`task-entry-draft-mode.test.js`、`task-blocked-ceo-wake.test.js`)验的是 draft 模式本身和唤醒机制,不是闸门穿透性。理论上没有绕过的口子,但没有测试钉死就不能说"已确认"。 +2. **`company.json.target_dir`/`target_mode` 没有任何 API/UI 入口去设置**——现在只能手工编辑公司态目录下的 `company.json` 文件才能配置。P5 落地的是"读取并生效"这一半,"人怎么把这两个字段填进去"这一半还没做(哪怕只是一个 `PATCH /api/company/:cid` 端点)。 +3. **`server/routes/graph-run.js` 漏了同一处鉴权修复,已在真实跑动中撞出来并修复(2026-08-30)**。地基 0 当时只改了 `company.js`(goals/tasks 端点),`graph-run.js`(run 的 start/stop/retry/budget 等控制端点)还是纯 `requireAuth`。真实端到端测试里 `ceo` 被唤醒后能读写 goal/task 板,但调 `/api/graph-runs/*` 处理卡住的 run 时精确撞上 401,自己在对话记录里诊断出这一点并写出了人工需要跑的 curl 命令。已修:`router`/`companyRouter` 两个挂载点都改成 `requireAuthOrInternal`,两处 `:companyId`/`:id` 的 `param` 中间件加了与 `company.js` 一致的 `req.user?.internal` 旁路,`route-auth-gate.test.js` 同步更新断言,全量测试零回归(1469/1473,剩余 4 个是核实过的 pre-existing 失败)。**这次的教训**:地基 0 的"这一步做完了"判据不能只看 `company.js` 一个文件——`ceo` 需要触达的控制面横跨两个路由文件,遗漏了没有靠代码审查发现,是靠真实跑一遍才暴露的。 diff --git a/docs/done/system-design/40-agent-chat-streaming-interrupt.md b/docs/done/system-design/40-agent-chat-streaming-interrupt.md new file mode 100644 index 0000000..4c9a4a5 --- /dev/null +++ b/docs/done/system-design/40-agent-chat-streaming-interrupt.md @@ -0,0 +1,213 @@ +# 40-agent-chat-streaming-interrupt. Agent Chat 打字机平滑 + 打断-排队 + 串台加固 + +> **日期**: 2026-08-30 +> **状态**: ✅ 已实现(commit `4d23f50`,2026-08-30)。三个方案全部落地:后端按字符数切片 + +> 前端 `createTypewriterQueue` 匀速吐出(打字机平滑);`stop` 端点改轮询确认 +> `agentChatLocks` 真正释放后返回 `cleaned`,前端据此直接发送或走强制发送兜底,执行中解锁 +> 输入框(打断-排队);`handleEvent` 分发逻辑抽成不依赖组件实例的纯函数 +> (`agentChatEvents.js`),补上 `9b1c3f1` 那条不变式的回归测试(串台加固)。已用真实 +> Claude CLI 会话(非 mock)在浏览器里验证打字机效果与打断并发送。实现过程中顺手修了一个 +> 真实并发漏洞:同一 `agentId` + 同一 `convId` 的并发请求此前完全没有闸门。本文档不再更新, +> 仅存档设计过程,实现细节以代码为准。 +> **触发**: 用户反馈"前端刷新不及时、没有打字机效果",怀疑要不要把传输层从 HTTP 换成 WebSocket +> 来解决打字机效果、多 Agent/多聊天切换、执行中无法打断这几个问题。经排查现状代码 + +> 对照 [hermes-webui](file:///Users/yibo/Documents/lemonAI/hermes-webui)、 +> [hermes-agent](file:///Users/yibo/Documents/lemonAI/hermes-agent)、 +> [deepseek-harness](file:///Users/yibo/Documents/lemonAI/deepseek-harness) 三个参考实现, +> 结论是**传输层不是瓶颈,不需要引入 WebSocket**——三个参考项目全部走 SSE/HTTP 流式, +> 没有一个用 WebSocket。真正的差距是另外三件独立的事,本文档逐一给方案。 + +--- + +## 起因 + +现状排查发现:LemonBoss 的 Agent Chat **已经是 SSE 流式**,不是普通 HTTP 一问一答—— +`web/src/views/AgentChat.vue:941` 用 `fetch().body.getReader()` 逐行解析 +`event:`/`data:`,后端 `server/routes/agent-chat.js:2542`(`POST /:agentId/chat`)用 +`setupSSE(res)` 把 Claude CLI 子进程吐出的 ACP `agent_message_chunk` 事件实时转发给前端。 +多聊天"串台"(切换 Agent 时旧流写串到新 Agent 状态里)已经在 `9b1c3f1` 修复:每个 Agent +一份独立的 `chatStates` reactive 状态 + `requestAgentId` 快照,事件处理函数(`handleEvent`, +`AgentChat.vue:991`)全程用 `isCurrent()` 校验"这条流是不是还对应当前选中的 Agent"。 + +对照 hermes-webui(`api/gateway_chat.py` 的 `_stream_writeback_is_current`)和 +deepseek-harness(`interruptByParent`:中止当前 turn → 新消息进 FIFO → 排队执行), +确认这套"per-stream id + is-current 校验"就是业界通用解法,LemonBoss 已经用对了模式, +不需要换传输层来重新解决一遍。 + +真正剩下的三个独立问题: + +1. **打字机不流畅**——不是传输问题,是 ACP chunk 粒度太粗(Claude CLI 一次吐一大段,不是 + 逐字/逐 token),SSE 本身能把这段文本秒级送达前端,但前端拿到手就是一大坨,没有"逐字吐出" + 的观感。 +2. **执行中无法打断并接上新消息**——现状是"先 Stop 再发"两步操作,且发送框在 + `chatState.sending` 为真时整个被 `disabled`(`AgentChat.vue:192`),物理上打不了字。 + deepseek-harness 有专门的打断-排队状态机,LemonBoss 没有。 +3. **串台已修,但没有回归测试兜底**——`9b1c3f1` 是一次性修复,`isCurrent()` 校验散落在 + `handleEvent` 十几个分支里,没有测试盯着,将来改动容易在某个分支漏掉校验又退回去。 + +--- + +## 一、现状代码地图(后续设计都基于这份) + +| 组件 | 位置 | 现状行为 | +|------|------|----------| +| 前端发送/接收 | `AgentChat.vue:908-969`(`handleSend`) | `fetch()` 开流,`st.sending=true` 期间 textarea `disabled`(`:192`),流结束才 `sending=false` | +| 前端事件分发 | `AgentChat.vue:991-1042`(`handleEvent`) | 按 `requestAgentId === agentId.value` 做 `isCurrent()` 校验,非当前 Agent 的流只落状态不刷 UI | +| 后端 SSE 入口 | `agent-chat.js:2542`(`POST /:agentId/chat`) | 每次请求 `while(loopCount<10)` 循环喂 prompt 给 ACP 协议,`onUpdate` 把 `agent_message_chunk` 原样透传成一条 SSE `agent_output` 事件 | +| 后端并发闸门 | `agent-chat.js:2552-2568`(`agentChatLocks`,键是 `agentId`) | 同一 Agent、**不同** convId 并发请求直接 `429` 拒绝;同一 Agent、**相同** convId 的并发请求**不会**被这段拒绝——第二个请求会直接覆盖锁往下跑,等于两个循环同时喂同一个 ACP 会话。目前靠前端 `disabled` 挡住,服务端没有闸门(见下文风险①) | +| 停止端点 | `agent-chat.js:1505`(`POST /:agentId/chat/:convId/stop`) | 调用 `closeSession(key, reason)`(`agent-chat.js:840`),即 `entry.protocol.disconnect()` 杀掉常驻 CLI 子进程,是"硬中止",不接排队 | +| 锁释放 | `agent-chat.js:2880`(`finally` 块) | 请求 handler 退出时统一 `agentChatLocks.delete(agentId)` + `res.end()`;中止后原请求是否会立刻走到这里,取决于 `protocol.disconnect()` 是否让 handler 内部 `await` 的 ACP 调用抛错——**需要在实现阶段用真实 CLI 验证一次**(见风险②) | + +--- + +## 二、方案一:打字机平滑(两端都做) + +**根因**:ACP `agent_message_chunk` 的粒度由 Claude CLI 决定,一次可能带几十到几百字符, +SSE 转发是逐 chunk 转发(`agent-chat.js:2628` `sendSSE('agent_output', ...)`),前端收到 +就是这一整段直接拼进 `st.streamContent`,没有二次拆分,所以观感是"一坨一坨蹦出来"而不是 +打字机。 + +### 2.1 后端:转发前按字符数切片 + +在 `agent-chat.js:2624` 的 `onUpdate` 里,把 `update.content.text` 按固定步长(比如 8-12 +字符一片)拆成多条 `sendSSE('agent_output', ...)`,而不是一次性整段发送。这是唯一需要碰 +后端的点,改动局限在这一个回调函数内,不影响日志落盘(`logMessageChunk` 仍然整段写,只是 +发给前端的部分被拆片)。 + +### 2.2 前端:渲染节流缓冲 + +在 `handleEvent` 收到 `agent_output` 时(`AgentChat.vue:1015`),不直接把整段 `data.message` +拼进 `st.streamContent`,改成推进一个逐字符渲染队列,用 `requestAnimationFrame` 或 +`setInterval` 按固定速率(比如 30-50 字符/秒)从队列取字符追加显示。这样即使后端某次真的 +一次性吐了一大段(比如网络抖动攒批),前端也能把它"抹平"成匀速的打字机效果,不依赖后端切片 +的粒度是否稳定。 + +两端都做的原因:后端切片解决"数据到达的最小单位太粗",前端缓冲解决"到达节奏不均匀"(网络 +延迟、CLI 输出突发)——只做一端在极端情况下(比如后端切了片但网络一次性把好几个 SSE 事件 +一起送到)还是会看起来一卡一卡。 + +--- + +## 三、方案二:打断-排队状态机 + +### 3.1 交互设计 + +保留现有 **Stop 按钮**(`AgentChat.vue:193`,⏹)不变——纯中止,点了就是杀会话,不接后续。 + +新增:**执行中解锁输入框**,允许在 `chatState.sending === true` 时继续打字。此时发送按钮 +的语义从"发送"变成"**打断并发送**",两个操作在视觉上要能一眼区分开——用户分不清点了哪个, +后果是"以为消息接上了,其实这轮直接被丢掉": + +- **Stop 按钮**(`AgentChat.vue:193`,⏹):图标/文案不变,补一句 tooltip「仅停止,不发送 + 新消息」。 +- **发送按钮在 sending 态下**:换一个视觉上明显不同的图标/配色(暖色调,区别于平时发送用的 + 主色),tooltip 改为「发送并打断当前回复」。 +- **打断成功后**,在对话记录里插入一条不打断阅读的**分隔标记**(细线 + 小字,类似 + "— 已打断上一条回复 —"),让用户回看聊天记录时能看懂"上一条为什么没说完"——这是 + ChatGPT / Claude.ai 自己在"stop and continue"场景下的通用做法。 + +点击"打断并发送"后的具体流程: + +1. 前端把发送按钮切到"打断中…"状态,锁住重复点击(防止手速快连点两次触发两轮打断); +2. 调用现有 `stop` 端点(复用 `agent-chat.js:1505`,不新增中止路由,但要改内部实现,见 + 3.1.1); +3. 根据 `stop` 返回的 `cleaned` 字段决定下一步(见 3.1.1),确认可以安全发送后,把这条新 + 消息按**正常发送流程**投出去,带**同一个 convId**,接续同一个对话历史,并在发送成功后 + 插入上面说的分隔标记。 + +这本质是"打断并立即顶替",但工程实现上做成一个**深度为 1 的排队**(先确认上一个 turn 真的 +已经退出,再发新的),不是前端拿到点击事件就立刻并发发第二个请求——原因见风险①: +服务端目前对"同一 convId 并发"没有闸门,真并发会导致两个循环同时写同一个 ACP 会话。 + +#### 3.1.1 `stop` 端点改造:后端确认清理完成,而不是前端猜时间 + +现状 `stop` 端点(`agent-chat.js:1505-1512`)调用 `closeSession` 后立刻同步返回,不代表 +原来那个 `/chat` 请求的 handler 已经真正退出(风险②)。改造成: + +- `stop` 端点内部在调用 `closeSession` 之后,轮询等待 `agentChatLocks` 里对应 `agentId` + 的锁被清掉(即原请求走到 `agent-chat.js:2880` 的 `finally`),**预算 3 秒**(`closeSession` + 杀子进程本身是毫秒级,慢的是原请求 `await` 被异常唤醒到走完 `finally` 这段,3 秒是给日志 + flush 这类边缘情况留的余量,正常路径应该用不到)。 +- 3 秒内确认清理完成 → 返回 `{ success: true, cleaned: true }`,前端立刻发新消息,用户基本 + 感觉不到延迟。 +- 3 秒仍未确认(极端情况) → 返回 `{ success: true, cleaned: false }`(尽力而为,不算失败), + 前端**不自动强发**(会撞回风险①的并发洞),而是在输入框附近给一条不遮挡输入的提示 + 「上一条还在收尾…」+ 一个「强制发送」按钮,把"要不要冒这个小概率风险"的决定权交还给用户, + 而不是系统默默赌一把。 + +### 3.2 需要新增/改动的点 + +| 位置 | 改动 | +|------|------| +| `AgentChat.vue:192` | textarea 的 `:disabled="chatState.sending"` 改成允许 sending 态下继续输入;`autoResize`/`handleKeydown` 不受影响 | +| `AgentChat.vue:194` | 发送按钮增加"sending 态下点击 = 打断并发送"分支,UI 上给一个过渡态(如按钮文案/图标切换) | +| `AgentChat.vue:handleSend`(`:908`) | 抽出"等待上一个 turn 收尾"的等待逻辑,供打断分支复用 | +| `agent-chat.js:2552-2568`(`agentChatLocks`) | **补上同 convId 并发闸门**(风险①,见下)——无论是否做打断功能,这都是一个已经存在、只是暂时没被前端触发到的并发 bug,建议顺带修 | +| `agent-chat.js:1505`(stop 端点) | 改成内部轮询确认清理完成再返回,加 `cleaned: true/false` 字段,见 3.1.1 | + +### 3.3 风险 / 需要在实现阶段验证的点 + +1. **同 convId 并发无闸门(现存 bug,非本次引入)**:`agent-chat.js:2554` 的判断是 + `existingLock.convId !== convId` 才拒绝,同 convId 的第二个请求会直接覆盖锁往下跑。 + 目前靠前端 `disabled` 属性挡住,从没被服务端真正堵过。一旦解锁输入框,这条路径变得 + 可达,必须先补服务端闸门,否则用户手速快一点就能让两个循环同时喂同一个 ACP 会话(未定义 + 行为,轻则消息乱序,重则 CLI 子进程状态损坏)。 +2. **中止后原请求是否会及时退出,还未验证**:`closeSession` 只是 `protocol.disconnect()`, + 原来那个还挂着 `await` 的 `POST /chat` 请求 handler 会不会因此立刻抛错走到 + `finally`(`:2880`)释放锁、结束响应,取决于 `ClaudeCliProtocol` 底层对子进程异常退出的 + 处理方式。**已拍板的应对方案见 3.1.1**(`stop` 端点内部轮询 + 3 秒预算 + `cleaned` 字段 + + 前端超时兜底按钮);实现阶段仍需要用一次真实 Claude CLI 会话验证「中止后原请求平均/最坏 + 多久会真正退出」,如果实测远超 3 秒(比如需要等底层网络超时),要回来调整这个预算数字, + 而不是改设计本身。 + +--- + +## 四、方案三:串台加固(回归测试) + +`9b1c3f1` 的修复方式是在十几个 `handleEvent` 分支里散落地加 `if (isCurrent) ...`,容易在 +未来新增分支时漏掉。建议补一个**前端组件级测试**(而不是走完整 E2E),思路: + +- 模拟两个 Agent 的 `chatStates`,对 Agent A 发起一次"慢"流(用假的 SSE 事件序列,中间插 + 延时),在流还没结束时把 `agentId.value` 切到 Agent B; +- 断言 Agent A 后续到达的事件(`thinking` / `agent_output` / `tool_call` 等每一种 SSE + 事件类型都要覆盖到)只更新了 `chatStates.get('A')`,不影响当前显示(Agent B 的视图); +- 反向断言:切回 Agent A 时,之前"憋住"没刷 UI 的内容应该已经正确落在 `chatStates.get('A')` + 里,切回来就能看到完整历史,不丢消息。 + +这条测试的价值不是证明现在没 bug(`9b1c3f1` 已经手工验证过),而是**卡住以后的回归**—— +后续如果给 `handleEvent` 加新的 SSE 事件类型,忘了包 `isCurrent()` 判断,测试会红。 + +--- + +## 五、不做的事(明确排除) + +- **不引入 WebSocket**。三个参考项目和 LemonBoss 现状都证明 SSE 足够,问题不在传输层。 + 真要引入 WebSocket 的场景是"没有聊天窗口打开时也要收到 Agent 的后台推送"(比如心跳异常 + 主动弹通知),这是一个不同的需求,不属于本文档范围,需要的话应该另开文档讨论。 +- **不做"多条消息同时排队"的完整 FIFO**。deepseek-harness 那套是为多 Agent + parent-child 场景设计的,LemonBoss 这里是单用户单会话打断,深度 1("打断当前的,接上 + 新的")已经覆盖需求,做完整队列是过度设计。 + +--- + +## 六、实施顺序建议 + +三个方案彼此独立,互不阻塞,建议顺序(从风险低/收益快到风险高): + +1. **方案三**(串台回归测试)——纯新增测试,零风险,先把安全网铺好。 +2. **方案一**(打字机平滑)——只碰渲染逻辑和一个转发回调,不改并发/状态机,风险最低。 +3. **方案二**(打断-排队)——涉及并发闸门(风险①必须先修)和状态机改造,且风险②需要真实 + CLI 验证,建议放最后,且实现前先用一次真实 Claude CLI 会话验证"中止后原请求多久会退出"。 + +## 七、已拍板的设计决策 + +以下两点原本是未决问题,已确认,写在这里避免以后重新讨论一遍: + +1. **超时策略**:由后端 `stop` 端点内部确认清理完成(轮询 `agentChatLocks`),预算 **3 秒**; + 超时不代表失败,返回 `cleaned: false`,前端不自动强发,展示「上一条还在收尾…」+ 手动 + 「强制发送」按钮,风险由用户显式承担,不做静默重试。详见 3.1.1。 +2. **UI 区分**:Stop 按钮和"sending 态下的发送按钮"必须视觉上可区分(图标/配色 + 不同 + tooltip),打断成功后在对话记录里插入一条分隔标记,便于事后回看。详见 3.1。 + +本文档目前没有遗留的未决问题;进入实现阶段后如果冒出新的(比如风险②实测的具体数字要调整 +预算),回来更新这一节。 diff --git a/docs/todo/system-design/28-followups.md b/docs/todo/system-design/28-followups.md index c3f9661..4a935e8 100644 --- a/docs/todo/system-design/28-followups.md +++ b/docs/todo/system-design/28-followups.md @@ -21,7 +21,7 @@ ### 1.1 dream 机制 **状态(2026-08-20 更新)**:设计已成文并已实现,见 -[29-dream-design.md](./29-dream-design.md)。四条取舍(去敏判据 / 沉淀落点 / 触发时机 / +[29-dream-design.md](../../done/system-design/29-dream-design.md)。四条取舍(去敏判据 / 沉淀落点 / 触发时机 / 质量评估)均已拍板,五步推进顺序 + 定时挂载 + 定义文件写入均已落地, `innovator-evolver` 上真 CLI 跑通一次(40 条原料 → 4 条抽象经验,8 条引用全部核对回原文)。 diff --git a/docs/todo/system-design/29-dream-design.md b/docs/todo/system-design/29-dream-design.md deleted file mode 100644 index 8856d56..0000000 --- a/docs/todo/system-design/29-dream-design.md +++ /dev/null @@ -1,488 +0,0 @@ -# 29-DREAM · 经验汇流(dream)—— 设计草案 - -> **所属**: [28 Agent 注册表统一](../../done/system-design/28-agent-registry-unification.md) §5.6b 锁住的那条约束的下游设计;未确认项来源 [28-followups §1.1](./28-followups.md)。 -> **日期**: 2026-08-20 / REST 与控制台入口 2026-08-21 -> **状态**: ✅ **四条取舍已拍板并已实现(五步 + 定时挂载 + 定义文件写入 + REST/控制台入口,均已落地并真跑验证,见 §四/§五/§5.7)。** -> 仍未做的只剩「结论对不对」的判据——机器判不了,只能靠人抽查(决策 D,见上)。 -> 标 ✅ 的是已生效的约束或有实测证据的事实;标 🔒 的是本轮拍板的决定。 - ---- - -## 〇、一句话 - -dream 是设想中定时跑的一套逻辑:整理**所有 agent 在所有公司**的 history 与 memory, -**显式提炼**后沉淀到 skill memory 上,让经验跨公司汇流,而**不泄漏任何一家公司的具体信息**。 - ---- - -## 一、开工前先看清原料:它现在是脏的 - -> 这一节全是**实查 2026-08-20 的仓库状态**得到的,不是推测。 -> 结论很直接:**在原料变干净之前,做提炼是没有意义的**——垃圾进,垃圾出。 - -### 1.1 现在写进 memory 的不是「事实」,是截断的日志行 - -`memory-service.appendToMemoryMd()` 的全部逻辑是: - -```js -const taskOneLine = (task || '').split('\n')[0].substring(0, 80); -const resultOneLine = (result || '').split('\n')[0].substring(0, 100); -const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; -``` - -**取第一行、砍到 80/100 字符**。这不是提炼,是搬运 + 截断。 -`cto/_global/memory/memory.md` 的实际内容: - -``` -## 关键事实 -- [2026-08-20] [TASK from ceo] → ..._global/` 下不新增 `callback-*` 目录。反向验证过:临时把 `isTestEnv` 改成 `() => false`,该用例 FAIL。 -- [2026-08-20] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01RsQ3gh5niCFyUZMFZj2hxF) -- [2026-08-20] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01MG6RqDgqcRCi5ageGk84Kt) -``` - -后三条是**工具调用 id**。它们作为「关键事实」被写下来、被注入下一轮提示词、 -并且会成为 dream 的原料。第一条更糟:它是一句话的**中段**(开头被截掉了), -读起来像事实,其实缺主语。 - -> §5.6b 说的错误版本是「每轮对话自动把原文写进共享 SKILL.md」。 -> **现在这套已经是那个错误版本的单公司版了** —— 只是还没跨公司共享,所以还没泄漏。 -> dream 如果直接消费它,等于把这个错误放大到跨公司。 - -### 1.2 长期记忆是一个 3000 字符的滚动窗口 - -`MAX_MEMORY_MD_SIZE = 3000`。超了就截。所以「长期记忆」实际是**最近若干条截断日志**, -没有任何按重要性保留的机制。 - -### 1.3 「公司态目录」里有不是公司的东西 - -`system/agents/{aid}/{cid}/` 是约定,不是校验。实查 96 个二级目录里混着: - -| 形态 | 例子 | 是什么 | -|---|---|---| -| 真公司 | `e2e-demo` / `mycompany` / `idearun` | ✅ dream 的原料 | -| 保留段 | `_global` | 全局 agent 的落点,算不算「一家公司」需要定 ❓ | -| ArenaSK 时代的运行态 | `train/` `workspace/` | ❌ 不是公司,是训练 / 工作目录 | -| **测试残留** | `tmp-w7-bootstrap-1786986291739-9n2xv`、`qroute-co-13/14`、`zz-*` | ❌ 夹具,且**带时间戳**,会无限增长 | - -**dream 的第一步「枚举所有公司」现在没有可靠答案。** -可靠的答案在库里:`company_agents` 表(28 批次 4 起「这家公司雇了谁」的唯一真相源)。 -目录只能用来定位文件,不能用来枚举公司。 - -### 1.4 绝大多数 memory 是空模板 - -75 份 `memory.md` 里,**只有 7 份**有非模板内容(`innovator-evolver` / `master` / -`org-innovator` / `innovator-digest` / `innovator-hunt` / `coo` / `qa-judge`), -其余全是 `_(刚入职,尚无记忆)_`。 - -> 这条对设计有直接影响:**dream 现在跑起来几乎没有原料**。 -> 它的价值随「真跑的量」增长,不是上线即有收益的东西。 - ---- - -## 二、已经生效的约束(不随设计变化) - -✅ **公司态目录是 dream 的唯一原料,不许被任何清理策略当垃圾扫掉**(§5.6b)。 -任务态(`{aid}/{cid}/{taskId}/`)可清,公司态(`{aid}/{cid}/memory/` 与 `conversations/`)不可清。 - -✅ **沉淀必须是提炼后的抽象,不是自动搬运原文。** -错误版本真的发生过(28 号第一轮验收挖出的最严重 blocker):直接灌原始对话 -= 把 A 公司的机密喂给 B 公司。 - -✅ **目录布局本身就是 dream 的主查询索引**: -`某个 agent 在所有公司的全部记忆 == glob: system/agents/{aid}/*/memory/`,一个 glob 就够。 -(前提是 1.3 那个「二级目录不都是公司」的问题被解决。) - ---- - -## 三、需要定的取舍 - -### 🔒 决策 A —— 去敏的判据:**独立判定 agent + 写作要求**(2026-08-20 拍板) - -**定案 = A3 + A1 组合**:提炼 agent 按 A1 的写作要求产出,产出后由**另一个只负责判 -「这段话能不能给别家公司看」的 agent** 过闸门,判不准就拒绝沉淀。 - -理由(也是本仓库反复验证过的一条):**两种错的代价不对称时,判据要偏向贵的那一侧** -(P3B §八 关于 `FAILING_VERDICTS` 的那段)。「少沉淀一条经验」与「泄漏一次机密」显然不对称。 -失效方向因此是「少沉淀」,不是「泄漏」。 - -下面三种形态的对比留档,说明为什么选它。 - -### 决策 A 的备选(留档) - -这是**安全边界**,也是整个设计里唯一「做错了会造成真实损害」的一条。三种可选形态: - -| 方案 | 判据 | 代价 | -|---|---|---| -| **A1 白名单式**:只允许提炼成「不含任何专有名词的方法论」 | 产出里不许出现公司 id、人名、产品名、URL、文件路径 | 最安全;但也最容易把有用的经验一起滤掉(「给 X 公司做官网时发现静态站点要先定验收脚本」→ 只剩「要先定验收脚本」) | -| **A2 黑名单式**:过滤已知敏感模式后放行 | 正则 / LLM 扫一遍公司 id、密钥形态、路径 | 保留信息量更多;**漏一个模式就是一次泄漏**,而且是静默的 | -| **A3 二次判定**:提炼 agent 产出后,由**另一个** agent 只负责判「这段话能不能给别家公司看」 | 判定是一次独立的 agent 调用,双方不共享上下文 | 成本翻倍;但与本仓库既有的 validator / triager 模式同构,且**失效方向可控**(判不准就拒绝沉淀,宁可少沉淀) | - -### 🔒 决策 B —— 沉淀落点:**四个都要,按「改动的可逆性」分层**(2026-08-20 拍板) - -用户定的是:`system/agents/{aid}/memory/` **和** `SKILL.md` 都要写, -**必要时还要迭代 `soul.md` / `agent.md`**。 - -这比原来三选一的范围大得多,**因为它引入了「agent 改写自己的定义」这件事**。 -下面这一节是这个决定的直接后果,不是可选的补充说明。 - -#### B.1 四个落点不是一回事,风险差三个数量级 - -| 落点 | 是什么 | 谁受影响 | 可逆性 | -|---|---|---|---| -| `{aid}/memory/`(本体根) | **经验积累**,跨公司那一份 | 只影响这个 agent 读到的上下文 | 删掉即可,无副作用 | -| `SKILL.md` | **能力定义**:这个 agent 会什么 | 同上,但会进 Skill 市场的语义 | 覆盖前留旧版即可回滚 | -| `soul.md` | **人格**:它怎么说话、怎么取舍 | 所有雇了这个本体的公司 | 改坏了表现为「行为漂了」,**不报错** | -| `agent.md` | **行为契约**:它必须做什么、禁止做什么 | 所有雇了这个本体的公司 | 改坏了可能让整条链路静默失效 | - -**28 号的核心裁决是「本体只有一份」** —— 所以写 `agent.md` / `soul.md` 的影响面是 -**所有雇了它的公司**,而不是提炼出这条经验的那一家。这恰恰是 §5.6b 担心的那种扩散, -只不过现在是**有意为之**。因此它必须比前两个落点多两道东西: - -#### B.2 写定义文件的硬约束(这几条不是建议) - -1. ✅ **`agent.md` 有一处 2000 字符的截断,但作用点要说准**: - `graph-engine.js:915` 给**规划 agent** 看的「角色定义摘录」只取 - `agent.md` 的**前 2000 字符**(`readFileSync(f).slice(0, 2000)`), - 超出部分静默丢弃。agent 自己那条装配链(`prompt-builder`)**不截断**,读全文。 - → 后果是:dream 往 `agent.md` **末尾追加**内容,规划侧读到的画像不会变; - 但如果追加把重要约束**顶到 2000 之后**(例如重写时调整了顺序),规划侧会无声地少看到那一段。 - → 判据:写 `agent.md` 时**保证前 2000 字符仍然自足**(职能说清楚), - 新增内容往后放;改动后应当有一条检查盯住这个窗口。 -2. ✅ **这四个文件里有三个是 git 跟踪的定义**(`agent.md` / `soul.md` / `SKILL.md`), - 不是运行态。dream 改它们 = 改仓库里的文件 → 会被边界守卫看见,也会进 `git status`。 - → 需要明确:dream 的产出是**直接落盘**还是**提案 + 人工合并**。 -3. ✅ **毕业闸门读这些文件**(`missingBodyDefinition` / `promoteDraftIfComplete`)。 - 把 `agent.md` 写空 / 写坏会让本体退回「定义没填满」,**雇不进任何公司**。 -4. ✅ **必须可回滚且能分别回滚**:经验沉淀写坏了只影响上下文,`agent.md` 写坏了影响所有公司。 - 两者混在同一次提交里,回滚粒度就没了。 - -#### B.3 🔒 定案:**直接落盘,但写之前必须确认这份文件已经在 git 历史里**(2026-08-20 拍板) - -用户定的是直接写,不走提案;前置条件是「别导致直接修改而没有 git 历史」。 - -**已实现**:`server/services/definition-write-gate.js`,两条判据都满足才放行: -① 文件被 git 跟踪;② 该文件没有未提交改动。判不出来(不在仓库 / git 不可用)一律拒绝。 - -> **为什么是「拒绝并报告」,不是「脏就先替它提交一把」**: -> 后者看着更顺手,但那正是本仓库记过的一类事故 —— **多会话共用同一个 worktree** -> (27-P3C §15.4 / §17.2)。一份未提交的改动很可能是**另一个会话正在写的东西**, -> 替它提交等于把别人的半成品写进 main,还署错了名。所以把「要不要提交」留给人。 - -守卫 `definition-write-gate.test.js` 5 例,含「拒绝时必须给出下一步」那条 —— -撞上闸门的人(或 agent)要能照着做,否则只能猜。 - -### 决策 B 的备选(留档) - -「skill memory」目前**没有对应的实体**。候选落点: - -| 落点 | 形态 | 问题 | -|---|---|---| -| **B1** `system/agents/{aid}/memory/`(本体根,跨公司那一份) | 与公司态并列,一个 agent 一份跨公司经验 | 目前只有 `zz-scaffold-readonly-fresh` 有这个目录,等于新建一层;**但它天然与 §5.6b 的 glob 对齐**,读的时候本体根 + 各公司态一起加载 | -| **B2** 本体的 `SKILL.md` | 复用现有概念(5 个本体已有 SKILL.md) | **这正是 §5.6b 点名的错误版本的落点**;SKILL.md 是「能力定义」,不是「经验积累」,混在一起之后没法分别回滚 | -| **B3** `/../data/skills/` 那 27,059 条 Skill 仓库 | 与 ArenaSK 的 Skill 市场打通 | 那是**面向分发**的仓库,写进去等于对外发布;且跨了两个 data 根 | - -(拍板结果见上:B1 与 B2 都要,另加 `soul.md` / `agent.md` 两个定义落点。) - -### 🔒 决策 C —— 触发时机:**定时,每日一次**(2026-08-20 拍板) - -备选留档如下。 - -> ⚠️ **与 §1.4 的冲突要知道**:实查 75 份 `memory.md` 里只有 7 份有非模板内容, -> 所以每日跑起来**绝大多数 agent 会是空转**。这不是反对定时,是要求实现时做到: -> **没有新原料就不起 agent 调用**(判据:上次 dream 之后有没有新的 session 摘要), -> 否则每天会有几十次「读完发现没东西」的 LLM 调用在烧钱,而且日志里全是噪声。 - -| 方案 | 说明 | -|---|---| -| **C1 定时**(如每日一次) | 简单;但没有真跑的日子会空转,有大量真跑的日子又攒得太多 | -| **C2 按量**(某 agent 新增 N 条 session 摘要后触发) | 与实际产出量对齐;需要一个计数落点 | -| **C3 手动**(控制台一个按钮 + API) | 先手动跑一段时间看提炼质量,稳定了再自动 | - -### 🔒 决策 D —— 能自动查的自动查,不能自动查的把回查成本降到几秒(2026-08-20 落地) - -**「这条结论对不对」机器判不了**,需要理解内容。不假装有自动判据。 -能自动做的只有两件,两件都做了: - -1. **引用真伪可以自动查**。提炼产物的 `sources` 必须都出自这一轮真喂进去的原料, - 编一个出处直接拒(连去敏判定都不用调,省一次)。 - > 编了出处的经验是「无法验证」这件事最危险的形态:人抽查时顺着一个不存在的指针 - > 放弃,然后**默认它是真的**。 -2. **回查成本降到几秒**:`auditDistilled(agentId)` 把每条结论与它引用的**原料原文** - 并排列出来,指针悬空标 `null`(原料被清理过 / 出处是编的)。 - -真跑那 4 条实测:**8 条引用全部取得回原文**,模型没编出处。 - -> 仍然没有的是「结论对不对」的判据。`auditDistilled` 的输出**不是质量分**, -> 别当成那个用 —— 它只是把回查这件事变便宜了。 - -下面是原始的选项留档。 - -- 没有评价手段时,dream 的产出**无法验证对错**——它和 FDE 那边 - 「LLM 自己汇报分数」的问题是同一个(见 [architecture_todo_list](../future/architecture_todo_list.md) §1)。 -- 一个便宜的起步:dream 每次产出都**保留原料指针**(哪几条 session 提炼出这一条), - 人抽查时能顺着回去看。**可追溯**比「可评分」便宜得多,也更早有用。 - ---- - -## 四、推进顺序 🔒 **上游与 dream 一起做**(2026-08-20 拍板) - -> **进度**:五步全部实现、真 CLI 跑通一次(§5.5),定时挂载与定义文件写入也已落地。 -> 决策 D 落到「引用真伪自动查 + 回查成本降到几秒」。**未做的只剩「结论对不对」的判据** —— 那一条只能靠人。 - -用户定的是两件一起做,不分先后。拆成可验收的步骤: - -| 步 | 内容 | 为什么不能省 | -|---|---|---| -| 1 | ✅ **`appendToMemoryMd` 停止截断搬运**(§1.1)—— 已完成 | 它原来把工具调用 id 当「关键事实」写进 `memory.md` 并注入**每一轮**提示词。dream 直接消费它 = 把这个错误放大到跨公司 | -| 2 | ✅ **「有哪些公司」改查 `company_agents` 表**(§1.3)—— 已完成 | 目录里混着 `train/` `workspace/` 和带时间戳的测试残留,拿目录枚举公司必然把它们当原料 | -| 3 | ✅ **dream v0**:枚举 → 提炼 agent → 去敏判定 agent(决策 A)→ 写落点 —— 已完成 | 主体 | -| 4 | ✅ **定义文件的 git 历史闸门**(决策 B.3)—— 已完成 | 覆盖一份尚未进入 git 历史的内容 = 让上一版永久消失,且不报错 | -| 5 | ✅ **可追溯**:每条沉淀记住它是从哪几条记录提炼来的 —— 已完成 | 没有它,dream 的产出无法验证对错 —— 与 FDE 那边「LLM 自己汇报分数」是同一个问题 | - -**空转保护**(决策 C 的直接要求):没有新原料就不起 agent 调用。 - -> 本文档仍**不是实现规格**。决策 D(质量怎么评)未定;B.2 里「定义文件直接写还是走提案」 -> 也还需要你点头 —— 我按「走提案」写在了步骤 4,但那是我的建议,不是你拍的板。 - ---- - -## 五、实现记录 - -### 5.1 步骤 1:`memory.md` 的自动写入不再冒充「关键事实」(已完成) - -改法三条: - -1. **换段**:自动写入落到 `## 原始记录(未提炼)`,`## 关键事实` 从此**只由提炼产物填**。 - 读的人和 agent 都能一眼分清「这是日志」还是「这是结论」;这一段同时就是 **dream 的输入面**。 -2. **滤掉确定的噪声**:工具调用标记 / 空结果 / 只有心跳标记的任务。 - 判据刻意窄 —— **只滤明确的噪声,不猜「哪条重要」**。猜重要性需要理解内容, - 那正是 dream 的活,在这里做等于把提炼下放到一个正则里。 -3. **不再截成半句**:上限从 80/100 放宽到 200 并取完整第一行。 - 截断残渣读起来像事实、其实缺主语,比不留更坏。 - -> **踩到一条值得记的**:噪声判据初版有「任务与结果都短于 8 个字符就丢」。 -> 那是按英文长度想出来的阈值 —— 中文 8 个字是一句完整的话, -> 实测「结果甲甲甲甲甲」(7 字)直接被丢,整条记录凭空消失。已删掉该规则。 -> **凡是拿长度当信息量代理的判据,都要先问一句「这是按哪种文字算的」。** - -守卫 `memory-raw-section.test.js` 5 例:关键事实段一个字不动、噪声一条不写、 -长句不再被砍(带「样本确实超过旧上限」的前置断言)、多次写入累加在同一段、 -无 toolCall 不写(保住原有行为,别顺手放宽)。 - -### 5.2 步骤 2 / 5 与 3 的骨架(`dream-service.js`) - -**步骤 2 —— 公司清单只认 `company_agents` 表。** -`companiesOf(agentId)` 查表,另把保留段 `_global` 补上(全局 agent 的运行态落在那里, -没人「雇」它,所以不在表里)。目录从此**只用来定位文件,不用来枚举公司**。 -守卫里造了两个「看起来像公司」的目录(`train/` 与 `tmp-w7-bootstrap-1786986291739-9n2xv`), -断言它们不进原料 —— 那正是实查到的两种残留形态。 - -**空转保护 —— 水位线按「条数」而不是 mtime。** -mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md`),条数只在真的多出记录时增长。 -拿 mtime 当判据的话,空转保护会在「文件被碰过但内容没变」时失效。 -没有新原料时 `dreamOnce` 直接返回 `skipped: 'no-new-material'`,**一次 agent 调用都不起**。 - -**步骤 5 —— 可追溯。** -每条原料带 `source`(`{公司}:memory.md#{行号}`),提炼产物落盘时把 `sources` 一并写进 -`experience.md` 的引用行。人抽查时能顺着回去看。 - -**决策 A 的失效方向在代码里是硬的**:判定 agent **抛异常也算拒绝**, -并把「为什么」写进 `rejected[]` —— 否则现场只看到「少了一条」,查不出是判否还是判炸。 - -### 5.3 步骤 3:两个本体 + 默认实现(已完成) - -**两个本体**(都过得了毕业闸门,`missingBodyDefinition` 为空): - -| 本体 | 职责 | 产出契约 | -|---|---|---| -| `dream-distiller` | 把一批原始记录提炼成可复用经验 | ```json 数组,每条 `{title, body, sources}`;没有值得留的就交 `[]` | -| `dream-screener` | 逐条判「这条能不能给别家公司看」 | ```json 对象 `{shareable: boolean, reason}` | - -**决策 A 的独立性写在两处,缺一不可**: -- 代码层:判定**一条一次调用**,提示词里只放 `title` + `body`,**不放原料**; -- 本体层:`dream-screener/agent.md` 明写「你看不到原料,也看不到是哪家公司——这是故意的」。 - 守卫里有一条专门断言这句话在(`expect(screener).toContain('看不到原料')`)—— - 否则下一个人会顺手把原料塞进提示词「让它判得更准」,而那恰好把独立性拆掉了。 - -**两处失效方向都是硬的**: -- 提炼取不出 JSON → **抛**,不是静默返回空。「产出格式坏了」和「没提炼出东西」是两件事, - 混为一谈会让前者以后者的形式永远藏下去(每天显示「今天没经验可沉淀」,真相是解析一直在失败)。 -- 判定解析不了 / `shareable` 不是布尔 → **判否**,并把原文片段带进 `reason`, - 让人看得出是「判否」还是「判炸」。 - -> **测试写法上纠了自己一次**:第一版把解析与失效分支在测试里**重抄了一遍**再断言 —— -> 那是在验一份复制品,代码改了它照样绿。改成加一条 `setDreamAgentCaller` 缝, -> 只换掉「真起 CLI」那一层,解析、判空、失效方向全走真代码。 - -> **本体写错过一次,被守卫抓住**:`agent.md` 里的相对引用按 **cwd 深度 3** -> (`{aid}/{cid}/{taskId}/`)解析,不是按文件自身位置 —— 仓库根是 `../../../../../`。 -> 我按文件位置写了 `../../../`,`agent-md-refs.test.js` 当场判红。 - -### 5.4 还没做的(别当成 dream 上线了) - -1. ~~定时挂载没做~~ ✅ **已完成**(§5.6)—— `dream-service.startDaily()` 已挂在 - `server/index.js` 的 `DISABLE_BACKGROUND_AGENTS` 早退之后,每日一次、串行扫、 - 定时器 `unref`。~~仍然没有的是 REST / 控制台入口~~ ✅ **已完成**(§5.7)—— - `/api/dream/*` + `web/src/views/DreamConsole.vue`(导航栏 `🌙 Dream`)。 -2. ~~还没写定义文件~~ ✅ **已完成**(§5.6)—— `writeDefinitionFiles()` 已实现, - 落点是 `SKILL.md` / `soul.md` / `agent.md` 三个 git 跟踪的定义文件, - 形状是受管区块(``,区块外不碰、整体重写幂等), - 写之前过三道闸门(definition-write-gate → agent.md 前 2000 字符自足性 → - 写完复核毕业闸门,不过立刻还原),任何一道不过就不写、原因逐文件返回。 -3. ~~决策 D~~ ✅ 已落地(见上)—— 但「结论对不对」仍然只能靠人抽查。 - ---- - -## 5.5 第一次真 CLI 跑通(2026-08-20) - -### 前置:存量记忆的搬家 - -写入侧改了(§5.1),但**存量不会自己搬家**。实查现有 `## 关键事实` 里 -**没有一条真事实** —— 全是重复的心跳行、工具调用 id、和以 `...` 开头的半句: - -``` -- [2026-08-18] [SYSTEM HEARTBEAT] → 🔧 [TOOL_CALL] Bash (ID: toolu_01M1t4...) -- [2026-04-09] [TASK from master] → ...ates/community-manager/train/evolve/SKILL_v1.md` | -``` - -而 `memory.md` 会被注入**每一轮**提示词 —— 这些东西一直在冒充事实。 -`scripts/migrate-memory-raw-section.js` 把它们搬进「原始记录(未提炼)」: -**19 个文件 / 269 行**,先 dry-run 再 `--apply`,改前逐个 `.bak` -(这些是 gitignore 的运行态,没有 git 历史兜底)。 - -判据刻意窄:只搬 `- [YYYY-MM-DD] … → …` 这一种形状(旧自动追加的唯一形状)。 -人手写的事实不长这样,不动它 —— **搬错比留错更坏**:留下最多多一条噪声, -搬错是把人写的东西降级成日志。 - -### 真跑结果 - -对 `innovator-evolver`(40 条原料,本仓库最厚的一份)跑 `dreamOnce`: - -``` -{ "distilled": 4, "accepted": 4, "rejected": [] } 耗时 114 s -``` - -四条产出**都做到了抽象**,原料里的具体名字全被抽掉: -`yt-dlp` / `Invidious` → 「本地下载工具 → 备用镜像源」; -`scorer.js` / `SKILL_v1.md` / `system/templates/...` 一个都没出现。 - -**顺着 `sources` 抽查过**(可追溯的第一次实际用途):4 条引用全部对得上原料。 -例如「按关键词覆盖率打分会被刷到虚高分」那条引了 #21/#22/#27/#28, -而 #27 原文正是「从零重写,采用 `expected_output` 逐 case rubric + `dimCeil` 上限机制, -杜绝 coverage-based」。**它是跨 4 条记录归纳出来的**,不是逐条改写 —— -这正是「一次调用吃掉全部原料」那个选择要换来的东西。 - -### 闸门单独验过(这一轮零拒绝 = 没被考验) - -真跑里 4 条全过,说明**去敏闸门一次都没触发**。零拒绝不等于闸门有效, -所以单独喂了三条给 `dream-screener` 真跑: - -| 输入 | 判定 | 理由(原文) | -|---|---|---| -| 含公司名 + 域名 + 绝对路径 | ❌ false | 「出现了具体域名 lemonai.ai 和绝对路径 /Users/…,第三方能据此直接认出是哪家公司/哪台机器」 | -| 心跳日志原文摘录 | ❌ false | 「是心跳日志的原文摘录,不是重写过的经验,属于原文搬运;且工具调用 ID 是可能关联到具体运行实例的标识符」 | -| 干净的抽象经验 | ✅ true | 「没有公司名/路径/URL…读起来是重写过的原则而非原始对话摘录」 | - -三条判据(可识别主体 / 原文搬运 / 正常放行)都按 agent.md 写的那样生效了, -**而且拒绝理由都点到了具体是哪几个字** —— 那是 agent.md 里明确要求的。 - -### 空转保护实跑 - -第二次调用(原料没变):`{"skipped":"no-new-material"}`,**一次 agent 调用都没起**。 - ---- - -## 5.6 定时挂载(决策 C)+ 定义文件写入(决策 B)(2026-08-20,`e9f380a`) - -### 定时:每日一次,串行 - -`dreamSweep` 逐个本体扫,**不并发** —— 每轮可能起两次 LLM 调用,几十个本体并发 -= 同时拉起几十个 claude 子进程,这个代价本仓库付过(2h43m 漏出 82 个进程 / 17.5 GB, -[28-followups §五第五条](./28-followups.md))。慢一点没有代价,空转保护已经把 -「读一遍全量本体」的成本压到「N 次目录读」。 - -挂点:`server/index.js` 里 `DISABLE_BACKGROUND_AGENTS` 早退之后,调 -`dream-service.startDaily()`。定时器 `unref`;只挂定时**不立刻跑一轮** -(启动那一刻跑没有额外收益,反而让每次重启都烧一遍)。一个本体抛异常不拖垮整轮, -但必须留下是谁抛的 —— 否则现场只看到「今天没沉淀」,查不出是没原料还是一直在抛。 - -**没做的**:REST / 控制台入口。现在触发只有两条路:等每日定时,或代码 / 脚本直接调 -`dreamOnce(agentId)`。人想立刻看一次提炼结果,目前没有 API/页面。 - -### 定义文件:受管区块 + 三道闸门 - -落点是 `SKILL.md` / `soul.md` / `agent.md`(决策 B 的另外两个落点,本体根 -`memory/experience.md` 在步骤 3 已经在写)。形状是一对 -`` 之间的**受管区块**,区块外一个字节不碰 —— 自由追加会让 -「哪些是人写的、哪些是 dream 写的」几轮之后再也分不开,分不开就没法分别回滚。 -区块整体重写还顺带**幂等**:同一批经验重复跑不会越写越长。 - -三道闸门,缺一不写: -1. **definition-write-gate**(§B.3):当前内容必须已在 git 历史里,且没有未提交改动; -2. **agent.md 的前 2000 字符必须仍然自足**:`graph-engine.js:915` 的「角色定义摘录」 - 只看这一段,改了等于悄悄换掉规划 agent 读到的画像; -3. **写完复核毕业闸门**(`missingBodyDefinition`),不过就**立刻还原** —— - `agent.md` 写坏会让本体退回「定义没填满」,雇不进任何公司。 - -任何一道不过:不写,原因逐文件返回(`definitions[]`)。**不写不该是静默的。** - -守卫:`dream-service.test.js` 新增 3 例,对真被 git 跟踪且干净的本体(`dream-screener`) -走真路径 —— 真写、验区块外字节不变 / 幂等 / 毕业闸门仍过,`finally` 还原并断言还原干净; -`agent.md` 那条验「不写 + 理由点名前 2000 字符 + 一个字节都没动」。`dreamSweep` 加了 -`agents` 参数,让错误隔离那条能真调而不是真扫全量本体。 - -全量:144 文件 / 1330 测试全绿。 - ---- - -## 5.7 REST / 控制台入口(2026-08-21) - -只做了这一件事,没有跟着做「dream 改成 org/work graph 来跑」—— -两者被单独评估过(见对话记录):org/work graph 的执行模型是**单公司作用域**的 -(org graph = 公司级 workflow,work graph = 公司内某任务的节点图),dream 却是 -**跨公司**扫描(按 agent 扫全部公司的记忆),套进去要么破坏这个作用域假设, -要么得新开一种「跨公司图」类型。而且 graph 引擎自带的「自动质量判断」 -(goal-contract 的 benchmark / test-script、triager 驳回重做)判的是「产物有没有 -过验收脚本」,对「这条提炼的经验对不对」这种自由文本判断没有对应机制—— -那道题不管包不包进 graph 都得单独设计一个 rubric,是同一件事,graph 化本身 -不会替你解决它。所以只做了用户点了头的这一半。 - -### REST(`server/routes/dream.js`,挂在 `/api/dream`) - -路由本身**没有新业务逻辑**,全部委托给已经测过的 `dream-service.js`: - -| 端点 | 做什么 | -|---|---| -| `GET /api/dream/agents` | 总览:每个可 dream 的本体,公司数、上次跑的时间、上次沉淀/拒绝了几条 | -| `GET /api/dream/agents/:agentId` | 详情:状态、定义文件(`agent.md`/`soul.md`/`SKILL.md`)现在能不能写及原因、`auditDistilled()` 的可回查列表 | -| `POST /api/dream/agents/:agentId/run` | 手动跑一次(`{dryRun}` 可选;`dryRun` 只看有没有新原料,不起 LLM 调用) | -| `POST /api/dream/sweep` | 手动触发全量扫描,202 立即返回,后台跑(串行扫全部本体可能要几分钟到几十分钟,不能塞进一次 HTTP 请求里等) | -| `GET /api/dream/sweep/status` | 轮询上面那个后台任务:`sweeping` / `results` / `error` | - -`sweep` 的并发锁是路由自己的状态(内存里一个 `sweepState`,不落库)—— -`dream-service.js` 没有「正在跑」这个概念,`dreamSweep()` 单纯是个 promise。 -两次并发 `POST /sweep` 第二次会拿到 409,防的是「人在控制台连点两下」而不是 -分布式场景(单进程部署,够用)。 - -守卫:`server/tests/unit/dream-routes.test.js`,monkeypatch 掉 -`dream-service` 导出对象上的六个函数(不走真文件系统,不会误扫真仓库的 -76 个本体),只验路由这一层独有的东西——六个端点接没接对、`sweep` 的并发锁 -状态机对不对。`dream-service.js` 自身的正确性由 `dream-service.test.js` 覆盖, -这里不重复验。 - -### 控制台(`web/src/views/DreamConsole.vue`,路由 `/dream`,导航栏 `🌙 Dream`) - -一个表格 + 两个弹窗,没有别的:本体列表(点「详情」看状态/定义文件可写性/ -经验审计,点「跑一次」或「dry-run」手动触发单个本体),页头一个「全量扫描」按钮 -(触发后台 sweep,15 秒轮询一次状态直到跑完)。 - -### 真跑验证(2026-08-21) - -起真服务器(`DISABLE_BACKGROUND_AGENTS=1 bash start.sh`),对真数据打过一轮: -`GET /api/dream/agents` 拿到全部 76 个本体真实的 `companies` / `lastRunAt`; -`GET /api/dream/agents/innovator-evolver` 的 `definitions` 如实报出 -`agent.md`/`soul.md` 可写、`SKILL.md` 不可写(没有 git 历史)及具体原因, -`experience` 把 §5.5 那次真跑沉淀的 4 条经验连同原料原文一起吐出来; -`dry-run` 对 `innovator-evolver` 正确报 `no-new-material`(材料已在上次真跑时被 -消费,本轮**没有起一次 LLM 调用**)。控制台页面在浏览器里过了一遍同样的路径 -(列表 → 详情弹窗 → dry-run 弹窗),数据与直接打 API 一致。 diff --git a/docs/todo/system-design/31-work-graph-fail-fallback-coverage.md b/docs/todo/system-design/31-work-graph-fail-fallback-coverage.md index 313e58a..8106ca6 100644 --- a/docs/todo/system-design/31-work-graph-fail-fallback-coverage.md +++ b/docs/todo/system-design/31-work-graph-fail-fallback-coverage.md @@ -15,7 +15,7 @@ > (零 `retryNode` 调用),最终 planner 子图收敛到 `completed`。**修复确认生效,有真实证据**。 > 同一次验证顺带在 `org-coder` 子图撞见一个**独立的新 bug**(`inboundContracts` 未按 `role` > 过滤,把 `produces` 声明误当 `needs`,导致 `missing_required_input` 假阳性),与本文档无关, -> 已记录在 [33-needs-gate-role-filter-bug.md](./33-needs-gate-role-filter-bug.md)。 +> 已记录在 [33-needs-gate-role-filter-bug.md](../../done/system-design/33-needs-gate-role-filter-bug.md)。 --- diff --git a/docs/todo/system-design/33-needs-gate-role-filter-bug.md b/docs/todo/system-design/33-needs-gate-role-filter-bug.md deleted file mode 100644 index af89b4f..0000000 --- a/docs/todo/system-design/33-needs-gate-role-filter-bug.md +++ /dev/null @@ -1,165 +0,0 @@ -# 33-needs-gate-role-filter-bug. `produces:["log"]` 声明了一个从不会被索引到的 kind —— validator 被 missing_required_input 误判 - -> **日期**: 2026-08-21 发现,2026-08-22 修复 -> **状态**: ✅ **已修复**(发现于 31 号文档修复的真实 E2E 回归验证过程中,是一个独立的新 bug, -> 与 31 号文档的兜底边问题无关) -> **触发**: `e2e-demo` 公司真实回归任务 `T-mt2r6fz0-125b`(goal `G-mt2r62ia-73af`, -> root run `run_0e9074815130`)——为验证 [31 号文档](./31-work-graph-fail-fallback-coverage.md) -> 的兜底边修复而起的新任务,链路走到 `org-coder-validator` 时意外反复 blocked -> -> **2026-08-22 修订**:§2.3 最初写的根因假设(`inboundContracts` 该按 `role` 过滤只留 -> `needs`)**是错的**,动手改了之后跑单测直接炸了 9 个用例——`impl→test`、`brd→prd` -> 这类只声明 `produces`(没有配对 `needs`)的边占绝大多数,`inboundContracts` 把它们 -> 也算进「下游需要的输入」正是**设计如此**,还是 T-mt25ecbd(commit `aecf5c9`)那次 -> 产出闸门的直接依赖。已把那次改动撤回,真正的根因和修法见 §2.3(修订版)与 §四。 - ---- - -## 一、先说 31 号文档的验证结果:修复生效,有真实证据 - -这条任务本身是为了拿到 31 号文档 §六「尚未做」的那一步真实验收证据(当时因为原任务 -`T-mt1qx0c4-95bf` 状态不干净,改用确定性回归测试代替,见 31 号文档 §7.3)。这次补上了: - -- 新起的 `org-planner` 子图(`run_38b3df8fd228`)里,`org-planner-sdd` 真实自判失败 - (下游信息不足),沿新补的兜底边(`org-planner-sdd --fail--> org-planner-prd`, - edge `wge_ce0f7f05f6dc`)**自动回退了 3 次**(`graph_run_loop_records`: - `verdict=registered`,`loop_count` 1→2→3),全程**零次 `retryNode` 调用**,最终 - brd→prd→sdd→benchmark→validator 整条链路收敛到 `run_status=completed`。 -- 31 号文档的决策 A(除入口节点外强制兜底边)在真实 LLM 驱动下按设计工作,**结论确认**。 - ---- - -## 二、新发现:`org-coder-validator` 被 `missing_required_input` 反复误判 - -### 2.1 现象 - -同一条任务往下走到 `org-coder`(`run_21fd814c2f56`,用的是 `executor.json` 骨架)时, -`org-coder-validator` 连续两轮都以 `fail_reason=missing_required_input` 收场: - -- 第一轮(attempt 1,10:01-10:11):沿兜底边 `validator --fail--> impl` 回退两次 - (`wge_1bdb181b14ef` 一次、`wge_aa5bad61fe9a` 一次),第三次撞上 run 树级回退硬顶 - (`backward_count` 全树累计 >5,`LOOP_HARD_CAP_RUN=5`),`force_terminated`, - 节点 `blocked`。**这一段是硬顶熔断本身在正常工作**,不是这次要报的 bug。 -- 人工 `POST /nodes/:id/retry` 把 `org-coder-validator` 打回 `ready` 后, - **第二轮**(attempt 2,10:42:48-10:44:01)**又**判了一次 `missing_required_input`, - 这次连新的 `loop_record` 都没写(回退硬顶已越顶,直接静默 blocked)。 -- 节点自己的 `remediationNote` 写的是「无需整改。……本轮重新独立跑了一遍测试脚本与 - goal-contract,结果与 attempt-1 完全一致,判定 attempt-1 的 passed 结论有效」—— - **agent 自己认为通过了**,是引擎的产出/输入契约闸门(T-mt25ecbd 那次加的机制, - commit `aecf5c9`)把 success 强行改判成 fail。 - -### 2.2 复现证据:`graph_files` 里明明有 `artifact` 却仍报缺 - -查 `T-mt2r6fz0-125b` 的产物索引(`data/graph.sqlite` 的 `graph_files` 表), -`org-coder-impl`(`rn_e71be3641174`,10:06:51 完成)与 `org-coder-test` -(`rn_cd3e3a1c51e2`,10:08:52 完成)都成功登记了当前(`superseded_by IS NULL`) -`kind='artifact'` 的产物行(`_plan.md` / `_research.md` / `coder.log`)。 -`org-coder-validator` 的 attempt 2 在 10:42:48 才跑,**比这些产物晚了半小时以上**, -不是时序竞争,是稳定复现。 - -### 2.3 根因(修订版):`produces:["log"]` 声明的是一个永远认不出来的 kind - -`executor.json` 里 `test → validator` 这条边同时声明了两条契约(`work-graph-db.js` -`declareEdgeContract`): - -```json -{ "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success", - "produces": ["log"], "needs": ["artifact"] } -``` - -即:`kind=log, role=produces`(test 自己产出的是 log,required 默认 true)+ -`kind=artifact, role=needs`(validator 真正要吃的是 artifact,中继声明,产出方是 -更早的 impl,非相邻依赖,R-19.3 ②)。`executor.json` 头部 `dataContractNotes` -写明了设计意图——「validator 既要看测试记录(紧邻上游 produces log)也要看被验的 -交付物本身」,即:`produces` 与 `needs` **都该算进 validator 的输入需求**,这不是 -误用,`inboundContracts()` 把两者都吐出来是**故意的、正确的**(详见下方「走过的 -弯路」)。 - -真正的根因在**产出侧**:`test` 类节点(`org-coder-test`、`org-coder-test-a/b`、 -`org-coder-integration`)从来不会写出一个能被认作 `kind=log` 的文件。 -`indexNodeOutputs()`(`graph-engine.js:3835`)给文件定 kind 的规则是「文件名匹配 -声明的 kind → 已知文件名 → 唯一无歧义兜底」三级,而这几类节点实际落盘的是 -`_plan.md` / `_research.md` / `coder.log` 这类文件——`coder.log` 恰好属于 **impl** -节点(不是 test),test 自己写的两份文件文件名里都没有 "log",且同时有两个认不出 -kind 的文件,触发不了「一对一无歧义」的兜底认领。于是 `graph_files` 里对这个 -task **永远**不会出现一行 `kind='log'`——`unsatisfiedRequiredProduces()`(出边产出 -闸门,`graph-engine.js:3801`)本身对此是宽容的:它只检查节点私有目录里有没有 -**任何**候选文件,不检查文件是否精确匹配声明的 kind,所以 `test` 自己能顺利 -`success`;但 `requiredInputsMissing()`(`graph-engine.js:3945`)在 `validator` -结算时是严格的:`declared kind - 索引里查到的 kind` 非空就判 `missing_required_input`, -而 `log` 这个 kind 因为上面那条产出侧的缺口从未出现在索引里——**这不是一次性的 -时序问题,是这条边只要还这么声明就永远无法满足的硬伤,每次重试都复现**。 - -对照 `validator.json`(同一批模板):它的 `needs` 声明本来就写成 -`{ "kind": "artifact", "required": false }`——同一份代码库里已经有先例, -知道这类「声明了但未必查得到实例」的依赖要标 `required:false`,只是 -`executor.json` / `executor-fanout.json` 里的 `produces:["log"]` 用的是裸数组 -写法(`normalizeContracts` 对裸字符串项默认 `required:true`),漏了这一条。 - -### 2.4 走过的弯路:为什么「按 role 过滤 inboundContracts」是错的 - -最初的假设是「`inboundContracts()` 不该把 `produces` 也当成 `needs`,只留 -`role==='needs'` 的行」。改完发现 `work-graph-skeletons.test.js` / -`org-layer-contracts.test.js` 等 9 个用例炸了:`org-planner-sdd`(入边 -`prd→sdd: produces:["prd"]`,无配对 `needs`)、`org-coder-test`(入边 -`impl→test: produces:["artifact"]`,无配对 `needs`)等一大批节点的入边**只声明 -了 `produces`,从不显式写 `needs`**——这本来就是绝大多数模板边的写法(`declareEdgeContract` -注释原话:「出边声明产出是压倒性多数,needs 必须显式写」)。按 role 过滤会让这些 -节点的 `inboundContracts()` 变成空数组,`requiredInputsMissing()` 也就不再校验 -它们——而这条链路正是 T-mt25ecbd(commit `aecf5c9`)刚加固的产出闸门本身 -依赖的数据源(`org-planner-benchmark` 靠它校验 `needs:[brd,prd,sdd]` 真的都被 -索引到)。按 role 过滤表面上修好了 `log` 这一个 kind,实际上会**静默削弱** -T-mt25ecbd 那次修复要守住的保护——好在跑单测立刻炸了,没有带着这个错误方向走 -下去。**结论**:`inboundContracts()` 本身没有 bug,别再按这个方向改它。 - -### 2.5 实际修法 - -在**声明**这一侧把 `log` 标成 `required:false`,与 `validator.json` 的既有写法 -对齐——既保留「validator 能看到 test 的 log(如果将来真的被索引到)」这条文档 -价值,又不再把一个永远查不到实例的 kind 当硬闸门: - -```diff -- { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success", -- "produces": ["log"], "needs": ["artifact"] }, -+ { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success", -+ "produces": [{ "kind": "log", "required": false }], "needs": ["artifact"] }, -``` - -改了 4 处(`executor.json` 1 处,`executor-fanout.json` 3 处:`test-a→merge`、 -`test-b→merge`、`integration→validator`——同一种「produces:log 但 test/integration -类节点从不产出可识别的 log 文件」的形状)。真正必需的 `needs:artifact` -(`required:true`)保持不变,继续受硬闸门保护。 - ---- - -## 三、已完成 - -- [x] 撤回 §2.4 那次错误方向的引擎改动(`work-graph-db.js` 的 `inboundContracts` - 恢复原状,加注释记录这条弯路,防止以后有人重新踩一遍) -- [x] 按 §2.5 改 `executor.json` / `executor-fanout.json` 的 4 处 `produces:["log"]` - 声明为 `required:false` -- [x] 新增单测 `graph-files-index.test.js` ⑪:`produces` 声明 `required:false` - 且该 kind 从未被索引到时,不拦下游节点 success;同一条边上 `required:true` - 的 `needs:artifact` 依旧受硬闸门保护(未被这次改动削弱) -- [x] 全量单测 `npx vitest run server/tests/unit`:**998/998 通过**(含新增用例, - 零回归) -- [x] (2026-08-22 追加)用真实回归任务验证 `org-coder-validator` 不再假阳性 - blocked——**已验证**:直接把这次真实卡住的那条 run 的 `graph_edge_contracts` - 行(`log` 那条)手动 patch 成 `required=0`,重试 `org-coder-validator` 并 - 恢复自动驱动,节点从 `blocked` 转 `success`,root run 最终收敛到 - `completed`。唯一没有走到的是"由 `workgraph-planning` 全新规划一张图, - 模板里 `{kind:'log',required:false}` 的对象写法被正确解析落库"这一环—— - 理论上没有风险(`validator.json` 已经在用同样的对象写法且工作正常,见下一条), - 但没有用一次全新任务端到端跑过,只是手动 patch 了已有的行。 -- [x] (2026-08-22 追加)`validator.json` 的 `needs:[{kind:'artifact',required:false}]` - 复查过:**不是同一类潜伏问题,是刻意设计**。它的 `dataContractNotes` 原文 - 写明:被验的 `artifact` 由**另一张图**(org-coder 等)产出,这里的 `needs` - 只是文档意图声明,产物索引按 `company+task` 检索、不按图/边——`required:false` - 是因为这条 `artifact` 依赖压根不指望靠这条边找到实例(找的时候看的是任务级 - 共享索引,边上声明与否不影响检索结果),跟 33 号文档这次的 bug(`log` 声明了 - 却永远不会被索引到)成因完全不同,不用动。 - -## 四、留待下次 - -- [ ] "由 `workgraph-planning` 全新规划一张图" 这一环的端到端证据仍然缺失 - (见上面第五条最后一句)——理论风险低,但没有真跑过 diff --git a/docs/todo/system-design/34-validator-scored-without-real-deliverable.md b/docs/todo/system-design/34-validator-scored-without-real-deliverable.md index e04fdd4..8c4c549 100644 --- a/docs/todo/system-design/34-validator-scored-without-real-deliverable.md +++ b/docs/todo/system-design/34-validator-scored-without-real-deliverable.md @@ -17,7 +17,7 @@ > 前置闸门统一验证严谨度,留给下次设计。 > **触发**: `e2e-demo` 公司真实回归任务 `T-mt2r6fz0-125b`(goal `G-mt2r62ia-73af`, > root run `run_0e9074815130`)——[31 号文档](./31-work-graph-fail-fallback-coverage.md) -> 兜底边修复 + [33 号文档](./33-needs-gate-role-filter-bug.md) 契约修复之后,把这条任务 +> 兜底边修复 + [33 号文档](../../done/system-design/33-needs-gate-role-filter-bug.md) 契约修复之后,把这条任务 > 真正推到 `completed`,回头查"最终产出在哪"时发现的 --- diff --git a/docs/todo/system-design/35-validation-rigor-and-body-readiness-proposal.md b/docs/todo/system-design/35-validation-rigor-and-body-readiness-proposal.md deleted file mode 100644 index bf4cf8e..0000000 --- a/docs/todo/system-design/35-validation-rigor-and-body-readiness-proposal.md +++ /dev/null @@ -1,233 +0,0 @@ -# 35-validation-rigor-and-body-readiness-proposal. 复盘结论 + 改进方案 - -> **日期**: 2026-08-22(复盘)/ 2026-08-22(提案 A 当天拍板并实施) -> **性质**: 复盘 + 改进方案提案。承接 -> [31](./31-work-graph-fail-fallback-coverage.md) / -> [32](./32-hire-body-fill-reliability.md) / -> [33](./33-needs-gate-role-filter-bug.md) / -> [34](./34-validator-scored-without-real-deliverable.md) 四份文档的真实回归验证 -> 与调研——那几份记的是"发现了什么",这份记的是"复盘下来该怎么办"。 -> **触发**:用户问"任务结果这么差,是 agent 能力问题、架构问题、还是 graph -> workflow 缺陷",本文档是那次复盘的书面结论 + 据此提出的改进方案。 -> **状态**:**四条提案全部已实施**。提案 A(commit `bab675c`);提案 B/C/D -> 一起做完(commit `fadb6fa`,全量单测 1012/1012 通过)。细节见各提案小节下方 -> 的"实现记录"。 - ---- - -## 一、复盘结论:四类原因,权重不均 - -用一条真实回归任务(`T-mt2r6fz0-125b`,"做一个 apple.com 风格的 lemonboss 官网") -撞见的问题,按发生顺序分类: - -### 1.1 图/模板设计缺陷(已修复)—— 31、33 号文档 - -- `org-planner-sdd` 没有兜底边,自判失败即卡死到人工重试(31 号)。 -- `test→validator` 边声明的 `produces:["log"]` 是一个 test 类节点永远写不出来的 - kind,导致 `missing_required_input` 假阳性、永久熔断(33 号)。 - -这两条是**真实的图/模板设计缺陷**,不是 agent 判断失误,也不是"能力"问题—— -图本身的结构不完整。**权重:中**(真实存在,但已经修好,且这类问题一旦发现, -定位和修法都很直接)。 - -### 1.2 招聘装配可靠性 bug(已知,这次是新样本)—— 32 号文档 - -`unit-tester` 本体的 `agent.md` 全程是空白的 `_base` 骨架(带 -`LEMONBOSS_BASE_SKELETON` 哨兵标记),隔壁 `coder` 却正常填充过。32 号文档已经 -分析过这类异步装配的可靠性问题并实施了方案 B(`6a18379`),但这次撞见的样本 -发生在修复提交**之前** 1 小时,且没有专门验证过新机制是否已经完全堵住这个口子。 -**权重:这次具体检查偏弱的最直接原因**——`unit-tester` 在没有任何角色指导的 -情况下,只能靠自己对"该怎么测试网页"的通用理解去写检查脚本,写出来的东西天然 -比有明确方法论指导的 `org-coder`/`coder-validator` 弱。 - -### 1.3 一个新发现的、更深的架构缺口:完整性闸门只在招聘时查,执行时不查 - -复盘时确认过一件事(`server/services/graph-engine.js` 全文搜索): -`missingBodyDefinition()`(检测本体是否还带 `_base` 哨兵/空白)只在 -**招聘/建公司/dream 沉淀**这几条路径上被调用(`graph-service.js` / -`graph-seed-service.js` / `company-service.js` / `dream-service.js`), -**图执行的核心路径(`advanceOne`/`defaultAgentExecutor`)一次都没有调用它**。 - -也就是说:即使 32 号文档的修复完全生效、招聘那一刻永远不会留下半成品——只要一个 -本体因为**任何其它原因**(手工改坏、迁移遗留、装配虽然完成了但内容本身依然是 -空骨架而系统判断不出来……)在 `system_agents` 目录里长期处于未填充状态, -**图执行侧完全没有第二道防线**,会原样把这份空白提示词交给真实 Claude CLI 去跑, -静默产出一份"看起来是回复、其实没有任何角色指导"的结果——这正是这次 -`unit-tester` 发生的事。**这是本次复盘里唯一一条原来没人查过、独立于 32 号文档 -既有分析的新架构缺口**。 - -### 1.4 不是 agent 能力问题——两次订正过这一点 - -- 最初以为"没有浏览器工具,物理上做不到视觉检查",后来发现同一批工具权限下 - 隔壁一条任务真的跑出了 Playwright 测试(见 34 号文档 §5.3 的订正)——**能力 - 和权限都够**,缺的是指导。 -- `unit-tester` 在零指导的情况下,还是主动写出了一份能真实解析 CSS `clamp()` - 算出字号比例、能识别锚点跳转反模式的静态断言脚本——这是**相当不错的即兴发挥**, - 不是能力不足的表现。 - -**结论:这次任务结果差,主要是架构 + 一个具体的可靠性 bug,不是模型能力问题。** -按影响力排序:①执行侧没有完整性闸门(新发现,架构缺口)> ②招聘装配可靠性 bug -(已知,这次撞上了修复前的窗口)> ③图模板设计缺陷(已修)> ④agent 能力(不是 -问题,甚至有正面例子)。 - ---- - -## 二、改进方案(分两档,供讨论) - -### 第一档:低成本、高置信度,建议直接做 - -#### 提案 A:把 `missingBodyDefinition()` 接进图执行的派发前置检查 - -**做什么**:`advanceOne`(或它调用 `defaultAgentExecutor` 之前的那一步)在真正 -派发一次 agent 调用前,对这个节点的 `systemAgentId` 跑一次 -`agentLocator.missingBodyDefinition(systemAgentId)`;非空就不派发真实调用, -直接按新 fail_reason(复用已有语义相近的命名,比如 `body_not_ready`,与招聘时 -挡重复招聘的判词同名同义)让节点走 fail 流程——**跟 31 号文档修的兜底边机制是 -同一条已有的选边逻辑**,不需要发明新的路由方式,只需要在这一个检查点新增一次 -判断和一个新的 fail_reason 取值。 - -**为什么现在做**: -- 判据函数已经存在、已经测过、语义已经很清楚(`agent-locator.test.js` 覆盖过), - 不是从零设计。 -- 改动面很小:一个检查点、一个新 fail_reason、复用现成的选边/兜底/熔断机制。 - 跟 31 号文档同一类"结构性、可验证、影响面可控"的修法。 -- 直接堵住了 §1.3 说的"唯一没有第二道防线"的洞——不管以后是招聘装配失败、 - 手工改坏、还是任何其它原因导致本体不完整,图执行到这一步都会被拦下来, - 而不是静默拿一份空提示词去跑真实 LLM。 -- 有真实的失败样本(`unit-tester`)可以直接改写成回归测试,验证成本低。 - -**代价 / 需要想清楚的点**: -- fail_reason 走兜底边意味着这个节点会被打回上游重跑——但重跑到同一个 - systemAgentId,body 还是没填,会不会陷入无意义的重试循环?需要确认 - `body_not_ready` 这类 fail_reason 是否应该被排除在正常回退计数(backward_count) - 之外,还是就该老实算一次退(让 §四.D.2.1 的熔断兜底),倾向于后者——不发明 - 第二套计数规则。 -- 需要决定:检查失败时是"这个节点 blocked 等人工重新触发装配",还是"引擎自动 - 触发一次装配再重试"(后者更省心但复杂度更高,涉及跟 32 号文档的 - `agent-body-filler.js` 打通)。**建议第一版先做前者(简单、可控),装配自愈 - 留给后续。** - -**实现记录(2026-08-22,commit `bab675c`)**:按"第一版先做前者"落地—— -`runClaimedNode` 派发前查 `agentLocator.missingBodyDefinition(systemAgentId)`, -非空就直接判 `fail`(`ENGINE_FAIL_REASONS.BODY_NOT_READY = 'body_not_ready'`), -走正常的选边/兜底/熔断(不豁免 backward_count,理由同上——重试到同一个空骨架 -本体不会自愈,就该老实退熔断等人工,不发明第二套计数规则);不扣预算、不起 -边界守卫(没有调用真的发生)。 - -**中途踩的一个坑,记录一下**:第一版没加"只在真实执行器下生效"这条判据, -直接把检查焊死在派发前——结果全仓库几百个用假 `systemAgentId`(`w-prd`/`impl` -这类夹具名,不对应 `system/agents/` 下任何真实目录)+ 注入假 `executor` 的单测 -一次性全部判成 `body_not_ready`,`npx vitest run` 从 1004 掉到 75 个用例失败、 -16 个文件。根因是 `missingBodyDefinition` 查的是**真实文件系统**,而这些用例 -的本体压根不存在于真实仓库里——按真实文件系统的判据去判一个不存在的虚构本体, -"缺失"永远成立,等于把闸门焊死在开着的状态。**改法**:借用已有的 -`boundaryGuardApplicable` 判据(`agentExecutor === defaultAgentExecutor` -时才生效,测试注入假 executor 时天然跳过),不是发明一条新判据——两处判的是 -同一件事:"这次会不会真的起一个 Claude CLI 子进程",答案会不会真起决定了 -两道闸门(边界守卫 / 本体完整性)该不该查。收窄之后单测回到 1004/1004, -新增 `graph-engine-body-not-ready.test.js`(4 例,覆盖闸门生效/预算不扣/ -注入假 executor 时不生效/subgraph 节点不受约束)+ 更新 -`graph-engine-fail-reason.test.js` 的取值域断言。 - -#### 提案 B:控制台展示"这份验收有没有证据支撑"(纯 UI,不改行为) - -**做什么**:在已有的 `TaskFilesPanel`/节点详情面板上,如果 validator 的 -`attempt-*.scores.json`(或对应产出)能关联上一份"真实检查证据"(比如同目录下 -有没有一份 `tests/*.mjs`/`*.check.js` 之类的脚本、或 `attempt-*.json` 里有没有 -`test_run.executed:true` 这类字段),标一个不起眼的小标签——不是要engine 判断 -"验证够不够严谨"(那是提案 D 的范畴,成本高很多),只是**把现在完全不可见的 -差异露出来**,让人在看"已完成"三个字时,能多看一眼"这条完成背后有没有真实证据"。 - -**为什么可以现在做**:纯前端 + 读现成数据,不改变任何执行行为,风险几乎为零, -且直接回应 34 号文档 §5.3 那条"三个任务验证严谨度天差地别、界面上完全看不出来" -的具体抱怨。 - -**实现记录(2026-08-22,commit `fadb6fa`)**:没有另做一套"有没有测试脚本"的 -启发式判断——直接读提案 D 已经算好的结论(`goal-contract.js` 判决里的 -`dimensions[].evidence_required`/`.ok`),比自己猜一遍"这份产出看起来严不严谨" -更准。`GET /api/graph-runs/:id` 新增 `benchmarkVerdicts`(只读 -`listVerdicts`/`readVerdict` 落盘的判决,不重新计算,读失败就跳过那一条,不拖垮 -整个响应);`NodeDetailPanel.vue` 概览 tab 新增"验收证据"小节,逐维度显示分数 + -是否有证据支撑,缺证据的标 ⚠️。只在这个节点真的有 benchmark 判决时才出现(同「输入 -输出」tab 的"不猜、不留空标题"口径)。已用 Chrome 实测过:点开 -`org-coder-validator` 节点,六个维度分数与 ⚠️ 标记正确渲染。 - ---- - -### 第二档:更大的设计决策,建议先讨论优先级再动手 - -#### 提案 C:给"视觉/UX 类"任务提供一套标准化、可复用的验收工具 - -**问题**:`p-bkhylu` 那条任务能做到 Playwright 级验证,纯粹是因为那次的 agent -会话自己想到了、自己写了 `verify.mjs`。换一次任务、换一次会话,不保证还会有人 -这么做——`unit-tester` 这次就没想到(或者说,没被指导要这么想)。 - -**候选做法**:在仓库里放一份可复用的验收脚本模板/skill(比如 -`scripts/visual-acceptance-check.mjs`,封装"读 index.html/css/js → 用 Playwright -量视口/对比度/字号"这类通用检查逻辑),并在 `coder`/`unit-tester`/ -`coder-validator` 这类角色的 `agent.md`(或它们的 `tools.md`)里显式点名:"遇到 -`goal-benchmark.json` 里要求视口/对比度/首屏这类视觉维度时,用这份工具,不要 -自己发明"。 - -**代价**:这不是"填一个空文件"那么简单——要设计这份工具的接口(读哪些文件、 -输出什么形态、怎么接进 `attempt-*.json`)、要决定哪些角色的 `agent.md` 需要 -更新、要不要影响现有已经在跑的任务。**这是一个需要先讨论范围的设计任务, -不建议直接动手**。 - -**实现记录(2026-08-22,commit `fadb6fa`)**:接口定成了四个独立子命令 -(`overflow`/`style`/`console`/`contrast`),各自吐一行 JSON 到 stdout,不做成 -"一次跑全部维度"的大而全脚本——`goal-benchmark.json` 的维度因任务而异,硬编码 -成一份不可复用。只更新了 `benchmark-validator/agent.md`(真正逐维度打分、写 -`evidence` 的那个角色),没有动 `coder`/`unit-tester`(当初以为的目标,后来发现 -写分数和证据的其实是另一个角色,见 §五.2 的角色梳理)。没有影响任何现有已经在跑 -的任务——纯新增文件 + 一段引用说明,agent.md 里其余内容一字未改。配一份不进 -`vitest` 快速循环的独立冒烟脚本(`visual-acceptance-check.smoke.mjs`,起真实 -Chromium 断言四个子命令,几秒量级)。 - -#### 提案 D:给"要求真实检查"的验收维度加引擎级证据闸门 - -**问题**:`goal-benchmark.json` 的 `evidence_hint` 目前只是**写给 agent 看的 -文本提示**,agent 信不信、照不照做,引擎完全不管——验证严不严谨完全靠自觉。 - -**候选做法**:给 `evidence_hint` 里出现"打开浏览器/截图/开发者工具"这类关键词 -的维度,要求打分时必须附带一个可核实的 `evidence_ref`(比如指向一份真实脚本的 -`stdout` 日志文件,或类似 `coder-validator` 已经在用的 `test_run.executed:true` -+ `output_excerpt` 那套惯例)——引擎收到一个没有 `evidence_ref` 的高分视觉维度时, -可以选择"降级为 inconclusive"或至少"打一个显式的 ⚠️ 标记",而不是原样采信。 - -**代价**:这个提案影响 `goal-benchmark.json` 的 schema、`goal-contract.js` 的 -判定逻辑、以及所有会产出这类分数的 validator 角色的 `agent.md`——影响面明显 -比提案 A/B 大,且需要先定"哪些关键词触发这道闸门""没有证据时降级还是拦截"这类 -细节,**建议留到提案 A/B 落地、有更多真实样本之后再决定要不要做,不是这次的 -优先级**。 - -**实现记录(2026-08-22,commit `fadb6fa`)**:关键词用了固定正则 -(`浏览器|viewport|视口|开发者工具|devtools|console|network|截图|screenshot`, -不分大小写);没有证据时选的是**拦截**(该维度直接判不过),不是"降级为 -inconclusive 但仍算过"——因为整个 §一复盘的教训就是"光有分数不该被当真", -选降级等于还是采信了那个分数,只是换了个说法。 - -**中途发现的一处设计对齐**:`goal-benchmark.json` 的 schema 完全不用改——`evidence` -不是新字段,`benchmark-validator/agent.md` 早就在教 agent 把 `dimensions[].evidence` -写进判词文件(`attempt-.json`),只是这份数据从来没被 `goal-contract.js run` -读过(它只读 `--target` 指向的 `attempt-.scores.json`,只有分数)。所以没有 -发明新格式,而是让 `runBenchmark` 多看一眼同目录的姊妹判词文件—— -`readSiblingEvidence()`:`attempt-.scores.json` → 同目录 `attempt-.json` → -取 `dimensions[].evidence`。`--target` 文件里如果也显式给了 `evidence.`, -优先用那份。6 条新单测覆盖:命中关键词但无证据判不过 / 给了证据后正常通过 / -普通维度不受约束 / 空字符串按没给处理 / 姊妹文件里的证据能读到 / 姊妹文件缺失 -不拖垮打分。 - ---- - -## 三、推进顺序(原提案,已按此顺序全部做完) - -1. **提案 A**(执行侧完整性闸门)——最推荐现在就做,闭环 §1.3 那个新发现的架构 - 缺口,改动小、可测、影响明确。✅ 已实施(commit `bab675c`) -2. **提案 B**(控制台证据可见性)——可以跟 A 一起做,纯 UI,风险低。✅ 已实施 -3. **提案 C / D**——原计划"先攒几个更多真实任务的样本再决定要不要做",用户直接 - 要求一起做完,所以没有等到攒够样本——两条都已实施(commit `fadb6fa`), - 实现记录见各自小节。样本量不足这条风险仍然存在:目前只有一次真实回归验证过 - 这套证据闸门,`evidence_hint` 关键词表是不是覆盖了所有该拦的场景、拦下来的 - 维度会不会引发新一轮无意义回退,都还没有大量真实任务的数据支撑,留意后续 - 真实使用中的反馈。 diff --git a/docs/todo/system-design/39-boss-agent-task-supervision.md b/docs/todo/system-design/39-boss-agent-task-supervision.md deleted file mode 100644 index e0c05ad..0000000 --- a/docs/todo/system-design/39-boss-agent-task-supervision.md +++ /dev/null @@ -1,192 +0,0 @@ -# 39. Boss Agent 任务看板监督 + 公司目标代码目录 — 需求评估与设计草案 - -> **状态**:✅ P1-P5 已实现(2026-08-30,commit `a00590a` P1/P3/P4 后端、`5108d67` P2 前端、`d967ac0` P5)。本文档是落地前的设计草案,实现细节以代码为准,本文档不再更新,仅存档决策过程。**已知未覆盖的后续工作见文末新增的「六、已知遗留」**。 -> **日期**:2026-08-30 -> **背景**:承接对话讨论——goal/task 两级模型([27-P3C](../../done/system-design/27-p3c-goal-task-model.md))已落地,但推进它的是纯确定性代码(`task-scheduler.js` + `transitionRun` 钩子),没有任何 agent 在持续监督这块看板、处理异常、把执行中发现的新问题回写成新任务。用户提出五阶段方案补上这一层,并追加公司级目标代码目录需求。 -> **读者须知**:本文档大量引用现有代码路径核实"已有 vs 缺失",结论以文内给出的具体函数/文件为准;这些引用会随代码演进过期,落地前请重新核实。 - ---- - -## 一、先说结论 - -用户的五阶段里,**P2(任务看板)本质已完成,P1/P3 的基础设施已有大半,P4 是真正的新增核心,P5 的机制已经实现了 70%,只是没有暴露到公司层面。** 真正从零开始的工作量比五个"phase"暗示的要小得多,但顺序建议大改:**P4(回写闭环)应该提到最前面**,因为它才是这次讨论最初的诉求("自己发现问题、自己重新规划"),P1/P3 的 boss agent 只有在 P4 的工具/数据通路建好之后才有事可做。 - -| 用户的 phase | 名义工作量 | 实际现状 | 建议 | -|---|---|---|---| -| P1 雇佣通用 boss agent | 新建一个角色 + 训练 | 心跳基础设施、`ceo` 本体、Innovator 训练方法论**全部已有** | 复用 `ceo`,不新建角色;工作量收窄成"写 heartbeat.md + 工具" | -| P2 任务看板独立页面 | 一个新系统 | goal/task 两级看板**已实现**(`GoalTaskPane.vue`),只是嵌在控制台里 | 降级成 UI 改造,非必须,可排最后 | -| P3 心跳挨个决定下一个 task | 新的调度逻辑 | 挨个执行**已经是** `task-scheduler.js` 在做,且做得更好(事件驱动、零成本、已测试) | **不要重建调度**;心跳的职责收窄为"异常判断",且应事件触发而非纯轮询 | -| P4 bug/新任务回写看板 | 一条新闭环 | **不存在**——没有任何 agent 可读写 goal/task 板的工具,四分类失败归因(30 号文档)目前只导向"重试或人工介入",不导向"开新任务" | **真正的核心工作,优先做** | -| P5 公司目标代码目录 | 新能力 | DevLoop 的 `path-resolver.js` 已实现 `sandbox` / `linked-new` / `linked-existing` 三种模式,**`linked-existing`(接管已有目录)已经是完整实现**,只是没有公司级默认值,也没打通到 org/work graph(非 DevLoop)路径 | 补一个公司级默认配置 + 核实非 DevLoop 路径是否需要同样机制 | - ---- - -## 二、逐 phase 现状核实 - -### P1:每个公司雇佣 boss agent,system agent 层训练通用版本 - -**已有的基础设施**(全部实地核实过): - -1. **通用心跳机制**:`server/services/heartbeat-service.js`。状态落在 `system/agents/{aid}/{cid}/heartbeat.json`(公司态目录,不是共享本体——这条边界是踩过坑之后加的,见文件头注释),本体 `config.json.heartbeat` 只是只读默认建议间隔。`_triggerHeartbeat()` 按 `setInterval` 定时给 agent 发一条 `[SYSTEM HEARTBEAT]` 系统消息(走正常的 `/api/agent/{agentId}/chat`),要求 agent "**根据你的身份和 heartbeat.md 规则**……如果一切正常,无事发生,请直接回复『心跳正常』",带 30 分钟会话互斥防重叠。**这套机制现成可用,不用重建。** - - ✅ **修正一处之前的误判**:原以为 prompt 里"扫描全局 OKR 目标状态和节点"那句硬编码需要改代码才能适配新 goal/task 模型。查了 `system/agents/` 下已有 **35 个**本体带 `heartbeat.md`(如 `org-innovator/heartbeat.md`),内容五花八门、完全跟 OKR 无关,说明触发消息里那句"扫描 OKR"从来就只是**默认示例文案**,各 agent 早就按自己 `heartbeat.md` 里写的规则各干各的。**结论:`heartbeat-service.js` 大概率零改动**,只需要新建 `system/agents/ceo/heartbeat.md`,写清楚"去查 goal/task 板、按 §四.9 的规则判断",agent 读到自己的 `heartbeat.md` 就会照着做,不需要动服务端代码。落地时找一个现有 `heartbeat.md` 生效的真实案例复核一次这个推断,再动手写 `ceo` 的那份。 -2. **本体已存在**:`system/agents/ceo/`(`type: "cxo"`,"Chief Executive Officer",`heartbeat.enabled: false` 默认关)。同时存在 `system/agents/master/`(`type: "master"`,**全局唯一**,`/api/master/chat`,负责建公司/招聘等跨公司操作)。两者定位不同:`master` 是平台层面的单例助手,`ceo` 才是"每个公司一个"的角色。 -3. **训练方法论已验证**:`36-prd-agent-methodology-training-feasibility.md` 已经用 Innovator(Hunt→Digest→Evolve)训练出过一个方法论型 agent(`prd-methodology-trial`),2 轮收敛、96/97 分,方法论对这类"判断型"角色确实管用。可以照搬这条路径训练 `ceo` 的监督方法论,而不是手写一份 prompt 拍脑袋。 - -**结论**:不建议新建"boss"角色。**复用 `system/agents/ceo/`**——它的岗位定义(CEO)本来就该管"任务有没有卡住、要不要重新分解",另起一个语义重叠的角色只会重犯这个仓库反复踩过的"名字歧义"坑(`27-naming-conventions.md` 通篇在防这个)。P1 的实际工作量是:① 给 `ceo` 补一份 `heartbeat.md`(监督规则);② 给它挂上 P4 需要的工具(见下);③ 默认 `heartbeat.enabled: true`(但见 P3 的成本讨论,建议先不无脑轮询);④ 可选:用 Innovator 训练它的监督方法论。 - -**缺口 A**:目前**没有任何 agent 工具能读写 `goals`/`tasks` 表或操作 graph run**(搜了 `server/src/tools/` 全目录,零命中)。`ceo` 就算被唤醒也无事可做——这是 P1 能不能生效的真正前提,见 P4。 - -**缺口 B(新发现,之前漏了):`ceo` 现在没有被雇进任何公司,`hire()` 也没有任何自动触发点会调它**。查了 `hire(companyId, roleName)`(`company-service.js:422`)——它只查本体目录 + `company_agents` 表,**不查 org graph**,机制上雇一个不在图里的角色完全没问题(数据模型层面,"雇了"和"进图"是两件独立的事)。但目前**没有任何代码路径会主动调 `hire(companyId, 'ceo')`**——它不在三份 org-graph-templates 里,也没有类似 `orggraph-planning` 那样的自动招聘钩子替它兜底。`37-org-graph-department-scoping.md` 里记录过一模一样的处境:`hr/` 本体"已经真实存在……只是从没被装进任何图……今天完全是编外人员"——`ceo` 现在是同款处境,**不是有先例可抄,是同一个坑还没人填**。P1 必须显式加一步:① 公司创建流程里(不管选哪个 org-graph-template)固定 `hire(companyId, 'ceo')` 一次;② 存量公司需要一次性回填脚本,遍历现有公司补雇。这条不做,前面说的"复用 ceo"就是一句空话——压根没有 `ceo` 实例在跑。 - ---- - -### P2:任务看板独立页面 - -**已实现**([27-P3C](../../done/system-design/27-p3c-goal-task-model.md),✅ 2026-08-20):`web/src/components/graph-console/GoalTaskPane.vue` + `goal-task-model.js`,挂在 `/:companyId/graph-console` 左栏。goal/task 两级状态、优先级排队位次、颜色语义全部已有,REST 是 `server/routes/company.js` 的 `GET/POST /api/company/:cid/goals` 系列。 - -**用户要的"独立页面"** 目前是控制台的一个侧栏,不是路由级独立页面。这是纯 UI 改造,**不涉及数据模型或后端**,随时可做,风险最低。 - -**"subtask" 层**:当前**没有独立的 subtask 表**,`task` 就是看板能管理的最细颗粒度。task 再往下是 `graph_runs`(work graph 节点,可能含嵌套子图),这层在中间的执行画布(`WorkGraphCanvas`)里看,不在左栏。 - -> ⚠️ **不建议新增一张 subtask 表**去对应 work graph 节点。这个仓库已经因为"两套任务体系不统一"吃过至少两次苦头(P3C §十二"批 5"整章在处理这个债,`27-p3c-batch3-handover.md` 也记过一次事故)。如果要在看板上展示"这个 task 内部具体在跑哪几步",应该是**只读地把该 task 当前 `current_run_id` 对应的 `graph_run_nodes` 渲染成一份"子步骤列表"**,复用现有表和现有执行画布的数据源,不新建写路径。 - -#### P2 决定:与 OKR 页面的关系(已拍板,2026-08-30) - -排查发现现状比预期乱:**OKR 相关 UI 现在有两份几乎逐行相同的重复实现**——`web/src/views/OkrDashboard.vue`(路由 `/okr`,`requireAdmin`,零导航入口的孤儿页面)和 `CompanyDashboard.vue` 里挂着 "goals" 名字、实际渲染 "Goals (Full OKR Tree)" 的 tab——两者都是旧 OKR 树(`O-xxx`,owner + progress%),是"OKR 正名"(`27-naming-conventions.md` §六)前端没跟着改完的遗留物,前端两处都调 `/api/okr/*`,和 P3C 的新 goal/task 模型完全不是一回事。 - -**已拍板**:**不合并**,OKR 这套前端 UI**直接废弃**,新任务看板**另起一个独立页面**。具体动作: - -1. **新增路由**:仿照 `GraphConsole` 已经立好的先例(`/:id/graph-console` 是独立路由,不挤在 `CompanyDetail` 的 `:tab(...)` 白名单里),新增 `/:id/task-board`(命名待定)→ 新建 `web/src/views/TaskBoard.vue`。**不要塞进 `:tab(...)` 正则**,理由与 `graph-console` 当年的注释一致:任务看板信息密度高,值得一个整页。 -2. **复用现有逻辑,不重写数据层**:`GoalTaskPane.vue` + `goal-task-model.js` 现在已经封装了 goal/task 的状态映射、排序、颜色语义(且有专门的单测 `goal-task-model.test.js` 兜底)。新页面应该是把这个组件从 `GraphConsole` 的侧栏布局里"拔出来"放大铺满整页,**不是重新实现一遍**。`GraphConsole` 侧栏可以保留一份精简版(点某个 task 仍需跳转去看它的执行图),也可以改成跳转链接指向新页面——两种都行,实现时再定。 -3. **废弃 OKR 前端**:删 `/okr` 路由 + `OkrDashboard.vue`;删 `CompanyDashboard.vue` 里那个挂着 "goals" 名字实为 OKR 树的 tab;路由 `:tab(...)` 白名单里的 `goals`/`okr` 两个值一并摘掉,避免留死链接。 -4. **导航入口**:新页面必须挂真实导航入口(`AppNav` 或公司详情页的显眼位置),不能重蹈 `/okr` 那种"孤儿页面"的覆辙。 -5. **鉴权**:`okr.js` 当年挂 `requireAdmin` 是因为它是孤儿页面、没做归属校验的权宜之计(`38-auth-multi-tenant-isolation.md`),**不是"任务看板天生该管理员专属"**。新路由要按 `company.js` 等其他公司级路由的标准做法,走 `canAccessCompany` 归属校验,不能照抄 `requireAdmin`。 - -**范围之外、本次不动**(留作独立的后续决定,不在这次 P2 改造里顺手做):`/api/okr/*` 后端路由、`okr` 相关 SQLite 表、`goals.okr_node_id` 这个可选外键字段——前端 UI 废弃不等于后端子系统也要一起拆,那是一次单独的、影响面更大的退役决定(牵涉数据迁移/存量数据处理),需要单独评估,这里先只处理前端。 - ---- - -### P3:心跳挨个决定下一个 task - -**这条按用户原话实现是在重建一个已经存在、已经测试、且更优的系统。** `task-scheduler.js` 的队列排序口径是 `priority → seq → created_at`(`ORDER BY priority, seq, created_at`),出队靠 `transitionRun` 这个唯一漏斗事件触发,不是轮询;v1 约束 `max_parallel_tasks`(`company.json`,缺省 1)决定"挨个"还是"并发几个",`server/tests/unit/task-slots-parallel.test.js` 覆盖了 >1 的情况。这套机制零 LLM 成本、毫秒级触发、已经过多轮真实回归验证(`31-work-graph-fail-fallback-coverage.md` 的真实 LLM 端到端案例)。 - -**成本账**:`heartbeat-service.js` 现在的实现是**每一次 tick 都真的发一次 LLM 调用**,只是 prompt 要求"没事就回复 `[心跳正常]`"——省不了那次调用的 token/延迟。如果 N 家公司、每家每 5 分钟一次心跳,24 小时就是 N×288 次 LLM 调用,绝大多数只会换回一句"心跳正常"。 - -**修订建议**:boss agent 心跳**只负责调度器处理不了的判断题**,而且触发方式改成"事件优先、轮询兜底",不是纯定时轮询: - -1. 判断题清单(调度器管不了、需要 LLM 判断的): - - task 长时间 `blocked` 或 `failed`(预算耗尽/依赖死锁)——要不要重新分解、要不要升级给人类; - - 同一 goal 下多个 task 出现优先级冲突(业务变化导致原定顺序不再合理)——要不要调整 `priority`; - - `task-planning` 3 轮降级成单任务(P3C §四.9 的降级路径)——要不要人工介入重新审视这条 goal 的描述。 -2. **优先事件驱动**:在 `transitionRun`/`settleGoal` 这类既有钩子里,遇到上述判断题触发的状态变化(比如 task 转 `blocked`)时,**主动**唤醒 `ceo` 处理这一条,而不是让它等下一次心跳才发现。心跳退化成**低频兜底**(比如 30 分钟一次,只扫"有没有钩子漏掉的异常"),不是主决策路径——这与现有 `task-scheduler.js` 头部注释里"兜底扫描周期"的设计哲学完全一致,照抄这个模式即可。 - ---- - -### P4:org/work graph 产生的新 bug / 新 task 回写看板,形成闭环 - -**这是五个 phase 里唯一真正从零开始、也是这次讨论最初诉求("自己发现问题、自己重新规划")真正要落地的地方。** - -**现有的失败归因机制**(`30-task-delegation-reliability-gaps.md`):`BugTriager`/`validator-triager` 类节点已经在做四分类——`contract_mismatch` / `impl_bug` / `spec_ambiguous` / `infra_flake`——但**四条路径的终点目前都是"原地重试"或"预算耗尽后 blocked 等人工"**,没有一条走向"生成一条新 task/goal"。 - -**需要新增的两个通路**: - -1. **确定性通路(不需要 agent 判断)**:把四分类里"显然该开新任务"的情形直接在 `routeFailure`(`graph-engine.js`)里接一条规则,而不是等 boss agent 心跳才处理——比如 `spec_ambiguous` 在 goal 级预算内重试超过阈值,直接生成一条 `status='draft'`、`created_by='system'`、挂在同一 `goal_id` 下的新 task,附上失败节点的诊断产物作为新 task 的 `description` 素材,人工/`ceo` 再审。这条不需要 LLM 判断"要不要开新任务",只需要判断"开一条什么样的"——后者才是可选的 agent 介入点。 -2. **主观通路(需要 agent 判断,这是 P1 的 `ceo`/工具的用武之地)**:agent 在执行任务过程中发现的、**不在当前 task 描述范围内**的问题(比如 coder 顺手发现一个无关的历史 bug),需要一个**工具**让它"提一条新任务"而不是把不相关的修复硬塞进当前 task 的产物里。这是当前完全没有的能力。 - -**必须先建的地基**——查完代码,发现比"包一层工具"更底层的东西还没有,得先排清楚三件事,顺序不能反: - -#### 地基 0(阻塞项):`ceo` 现在物理上调不通这套 REST API - -`server/src/tools/tool-registry.js` 开头写得明明白白——**这是"Master Agent 工具注册中心"**,`[TOOL_CALL: xxx]` 这套 JSON 文本协议是 Master 专属的自定义解析层,`server/src/tools/devloop/` 下另有一套 DevLoop 专属的 tool-registry。`ceo` 这类普通 org node agent(走标准 Claude CLI 子进程)**两套都不属于**——它能用的只有 Claude CLI 原生的 Bash/curl,具体调用哪个接口靠 `tools.md` 里的**大白话 + curl 示例**(`task-planning/tools.md` 就是这个写法的范本)。所以"task-board-tools.js" 这个提法本身**不成立**——不需要新写一个 JS 工具模块,需要写的是一份 `system/agents/ceo/tools.md`(现在是空文件)。 - -但更麻烦的是:`server/routes/company.js` 顶部 `router.use(requireAuth)`(14 行),**goals/tasks 全系列端点只认人类 JWT**,没有走 `agent-chat.js` 那样的 `requireAuthOrInternal`。也就是说,就算 `ceo` 的 `tools.md` 写得再clear,它发出的每一条 curl **现在都会静默 401**——这正是最近几个 commit(`d48425a` selfChat 自环漏带内部令牌、`9e58656` bin/lemon.js task report/dispatch 401)反复修的那一类坑,company.js 这次还没打这个补丁。**这是全部 P4 工作的前提阻塞项**,必须先给 goals/tasks 这组路由加上 `requireAuthOrInternal`(或者更收紧一点,只给这几条挂内部令牌白名单),agent 才有物理上调得通的可能。 - -#### 地基 1:`POST /:companyId/tasks` 没有"草稿"模式,agent 提的任务会立刻烧预算 - -现有 `POST /:companyId/tasks`(`company.js:2077`)建完 task 行**无条件紧接着调 `enqueueTaskRun` + `startNextIfSlotFree`**——语义是"下达即执行",服务的是"人类/外部 agent 派工"这个存量场景(代码注释原话:"存量调用方的心智是下达即执行")。这条路径不适合 `ceo` 自主提的新任务:`task-planning` 拆出来的 task 尚且要先落 `draft`、人点"全部下达"才转 `queued`(P3C §四.9 的既定哲学——"一次 LLM 猜测直接烧 5 份预算"是要避免的事),`ceo` 一次判断失误直接把新任务怼进队列烧钱,风险比 task-planning 更高(task-planning 只在 goal 刚建时跑一次,`ceo` 是持续在线判断)。 - -**需要的改动很小**:给 `POST /:companyId/tasks` 加一个 `draft: true`(或 `status: 'draft'`)入参,为真时只调 `wdb.createTask()` 落库,跳过 `enqueueTaskRun`/`markGoalDispatched`/`startNextIfSlotFree` 三步,返回 `status: 'draft'`。已有的 `POST /:companyId/tasks/:taskId/enqueue`(`company.js:2275`,`draft → queued`)原样复用,不用新建。默认行为(不传这个参数)完全不变,纯加法,不影响存量调用方。 - -#### 地基 2:`ceo` 的 `tools.md` 写什么 - -在地基 0/1 落地后,照抄 `task-planning/tools.md` 的写法(先说"大概率不需要工具"、给出何时才用、给出 curl 示例、专辟一节"你不该用的工具"防止越权)给 `ceo` 写一份,覆盖: - -- **读**:`GET /:cid/goals?status=active`(扫哪些 goal 在跑)、`GET /:cid/tasks?status=blocked` / `?status=failed`(找卡住的)、`GET /:cid/goals/:goalId`(看某 goal 下全部 task + run 摘要) -- **写**:`PATCH /:cid/tasks/:taskId`(改 `priority`)、`POST /:cid/tasks`(带 `draft:true`,提新任务)、`POST /:cid/tasks/:taskId/enqueue`(把自己或别人提的草稿正式下达——这一步要不要也留给人类点,是个开放问题,见 §四) -- **明确不该用**:`org-graph/replan`、招聘类端点(这些是 HR/`orggraph-planning` 的事,`ceo` 判断"缺角色"之后应该是发通知/提 task,不是自己动手改组织结构) - -#### 确定性通路(routeFailure 里那条规则)不走这套 REST - -30 号文档四分类里"显然该开新任务"的情形(比如 `spec_ambiguous` 重试超阈值),是 `graph-engine.js` 服务端内部代码,**直接调 `wdb.createTask()`**(同进程函数调用),不必经过 HTTP、不必带内部令牌,落库同样是 `draft`。这条通路和 `ceo` 的 agent 通路是并行的两条腿,共享同一个"落 draft、不自动下达"的规则,但触发方式不同(一个是引擎钩子里的确定性规则,一个是 agent 主观判断)。 - -**先做地基 0(鉴权)→ 地基 1(draft 模式)→ 地基 2(`tools.md`),P1/P3 的 boss agent 才有事可干**——三步不做完,`ceo` 被心跳唤醒了也只能干瞪眼,或者干瞪眼都算好的,更可能是每次尝试都吃一个静默 401。 - ---- - -### P5:公司级目标代码目录("接管已有工作目录") - -**核实结论:这个能力在 DevLoop 层面已经完整实现,只是没有公司级默认值,也没有确认是否覆盖非 DevLoop 的执行路径。** - -`server/services/devloop/path-resolver.js` 的 `allocateProjectRoot()` 已支持三种模式(`server/routes/devloop.js` 建项目时传 `mode`/`target_path`): - -| mode | 语义 | 现状 | -|---|---|---| -| `sandbox` | 自动分配到 `data/companies/{cid}/projects/{pid}/`(lemonboss 内部) | ✅ 已实现,默认值 | -| `linked-new` | 用户指定一个**空/新建**的绝对路径作为项目根,登记进 `~/.devloop/linked-projects.json` | ✅ 已实现 | -| `linked-existing` | 用户指定一个**已存在**的目录,接管为项目根 | ✅ **已实现,不是文件头注释说的"预留接口拒收"**——代码实测有完整校验:路径必须存在且是目录、不能已经是别的 DevLoop 项目(`project.json` 冲突检测)、若有 `.git` 必须 working tree 干净(防止 agent 的 `git add -A` 把用户脏改动卷进自动 commit)。文件头那句注释是过期文档,**建议顺手修正**。 | - -这正是用户要的"像 Claude Code 选工作目录一样接管一个已有目录",**核心机制已经有**,且安全校验(黑名单敏感路径、git 干净树)已经比较完善。 - -**org/work graph(非 DevLoop)路径已核实**,结论比预期更好也更麻烦,分开说: - -**好消息:不是两套系统**。`graph-engine.js` 有自己的 `ensureProjectRoot(ctx)`(3476 行),文件头注释写明"位置的分配**复用 DevLoop 的 `path-resolver.allocateProjectRoot`(sandbox 模式)**,不新造一套"——`allocateRunProjectRoot()`(3458 行)内部真的就是调 `require('./devloop/path-resolver').allocateProjectRoot({ company_id, mode: 'sandbox' })`。org-coder 类节点的交付物落点、`graph_files` 索引、`34号文档` 提到的"项目根",指的都是这同一套机制,不是 `locator.taskDir`(那是 agent 自己的运行 cwd,`_applyRuntimeScope` 分配的,两者不是一回事——agent 在 `locator.taskDir` 里跑,但写产物是写去 `ctx.projectRoot`,这个区分在 `graph-engine.js` 3736 行的"越界写入"检测里能看到:写到项目根之外会被判越界)。**结论:P5 只需要改一处入口(`allocateRunProjectRoot`),不需要在 org graph 侧另建一套。** - -**坏消息:现在的分配粒度是"每个 root run 一份全新 sandbox",不是"每个公司一份持久目录"**。`allocateRunProjectRoot()` 硬编码 `mode: 'sandbox'`,且**没有任何"复用已有项目根"的入参**——`ensureProjectRoot()` 的幂等只在**同一个 root run 内**成立(先读 `project-root.json` 再决定要不要新分配),跨 run(也就是跨 task)**永远分配一个新 `pid`**,哪怕是同一个公司、同一个 goal 下的两个 task,也会落进两个互不相干的空目录。这比"没法接管外部目录"更根本——现在**同一家公司自己的两个任务都不共享代码**,遑论接管一个外部已有仓库。 - -**P5 真正要做的三件事**(比原先设想的"补个配置项"要深一层): - -1. **公司级配置**:`company.json` 加 `target_dir`(绝对路径,可空)+ `target_mode`(`linked-new` / `linked-existing`,配了 `target_dir` 才有意义)。 -2. **改 `allocateRunProjectRoot(run)`**:读 `run.company_id` 对应的 `company.json.target_dir`。**配了就不再"每 run 分配新 pid",而是所有该公司的 run 一律解析到同一个 `root_path`**(跳过 `path-resolver` 的 pid/registry 那一整套,直接返回固定路径,首次访问时按 `target_mode` 做一次 `linked-existing`/`linked-new` 的校验——现成的校验逻辑,`path-resolver.js` 261-310 行原样复用,只是只做一次,不是每个 run 各做一次);没配 `target_dir` 就走现在的 `sandbox` 老路径,行为不变。 -3. **⚠️ 并发风险,必须在设计里显式处理**:一旦多个 task 共享同一个 `target_dir`,就与前面讨论过的"跨 task 并发"(`max_parallel_tasks > 1`)正面相撞——两个 coder agent 同时写同一个 git 仓库会冲突,`linked-existing` 现有的"进入时 working tree 必须干净"只是一次性校验,不是运行期互斥锁。**建议 v1 做法**:`company.json.target_dir` 非空时,该公司的 `max_parallel_tasks` 强制钉死为 1(在 `maxActiveRootRuns()` 里加一条:读到 `target_dir` 就忽略/覆盖用户配的 `max_parallel_tasks`),从根上排除并发写冲突,比引入 git worktree/分支合并那一整套复杂度便宜得多。`ponytail`: 这是刻意的简化,天花板是"一家公司同一时间只能有一个任务真正动代码";真要多任务并发写同一个仓库,需要 git worktree + 完成时合并这一层,属于独立工作量,不在这次范围内。 - -**建议**:P5 独立于 P1-P4 推进(数据面完全不相关),优先级可以更高——但工作量比最初评估要大一档,不是"公司级配置 + 一次核实"就完,是"公司级配置 + 改分配粒度 + 并发约束联动"三件套。 - ---- - -## 三、修订后的建议顺序 - -用户原始顺序是 P1→P2→P3→P4,本文档建议倒过来做地基: - -``` -第一步:P4 地基 —— 建 task-board-tools(读写 goal/task 的 agent 工具) - + routeFailure 里接确定性的「四分类 → 自动开新 task」规则 -第二步:P1 收窄版 —— 给 ceo 配 heartbeat.md(监督规则,认新 goal/task 模型不是旧 OKR) - + 挂上第一步的工具 -第三步:P3 收窄版 —— 心跳只做异常判断,事件触发优先、轮询兜底、绝不重做调度 -第四步:P4 完整版 —— 把「主观判断要不要开新 task」的部分交给已经装好工具的 ceo -第五步:P2(UI) —— 独立任务看板页面,随时可插入,风险最低 -并行轨:P5 —— 公司级默认 target dir 配置 + 核实 org graph 交付物落点是否需要同样机制 -``` - -理由:P1/P3 的 boss agent 在 P4 地基建好之前没有可操作的对象;P2 是纯 UI,不卡任何人;P5 数据面独立,可以完全并行推进。 - ---- - -## 四、开放问题(已拍板,2026-08-30) - -1. ~~`ceo` 会不会和已有流水线角色冲突~~ **已核实排除**:`system/org-graph-templates/{dev,content,ops}.json` 三份模板全文搜索 `ceo`,零命中——`ceo` 现在没有出现在任何一张图里,是完全没被使用的孤儿本体。给它加监督职责没有冲突。 -2. **P4 确定性通路新开 task 的 `report_to`:继承原 goal 的 `report_to`**。`okr_node_id` 不用单独决定——它按 P3C §四.7 既定规则只挂在 `goals` 表,新 task 挂在同一个 `goal_id` 下自动沿用该 goal 的 OKR 归属,不存在"新开一份"的问题。 -3. **心跳成本策略:事件触发为主 + 低频兜底轮询**(不加"异常持续够久才唤醒"的抖动过滤,先简单落地)。主路径挂在 `transitionRun` 等既有钩子上,异常状态一出现就唤醒;另加一个量级在 30 分钟左右的低频轮询兜底,防钩子覆盖不到的漏网情况——直接照抄 `task-scheduler.js` 头部注释里"兜底扫描周期"那个既有设计哲学,不重新发明。 -4. **`ceo` 可以全权自主 `enqueue` 自己提的 draft task,不需要等人类点**——这是五个选项里最激进的一档,明确高于 `task-planning`(§四.9 定案:产出永远先落 draft,人点"全部下达")的自主权限。**这个选择意味着安全网必须落在别处,不能靠"人工审核"这一层**:落地时要确保 `max_parallel_tasks`(防止一次性并发太多)、`DEFAULT_TASK_BUDGET`(防止单任务无限烧钱)、goal 级 `org-validator` 收敛验收(防止"完成"了但没真正达成)这几道既有闸门都乘上了 `ceo` 自主创建的 task,不能因为创建路径变了就绕开——这几道闸门本来就是数据层/调度器层强制的(不挂在"谁创建的"这个字段上),照理天然覆盖,但落地时要专门写一条回归测试钉死这一点。 -5. **P5 org graph 交付物落点调研:已做完**,结论见 §二 P5 小节——不是两套系统(复用同一个 `path-resolver`),但分配粒度是"每个 task 一份全新 sandbox",同公司任务之间都不共享代码,比预想的工作量更大,且与"跨 task 并发"直接联动,需要一条并发约束兜底。 -6. **P4 确定性通路的实际挂载点:已实现并确认(2026-08-30)**。文档原先设想挂在 DevLoop 的四分类(`spec_ambiguous`/`contract_mismatch`)上——但那是 DevLoop 专属词汇,`graph-engine.js` 的 `routeFailure` 用的是另一套(`impl_bug`/`input_underspecified`/`skill_defect`/`infra_flake`),根本没有这两个取值。实现时改挂在 `handleBackwardEdge` 的双层硬顶熔断(单边 `loop_count>3` 或 run 级 `backward_count>5` → `force_terminated`)上——**任何**原因导致的反复回退不收敛都触发,不按 `fail_reason` 精筛。**已确认维持这个实现,不额外收窄**。副作用是良性的:`infra_flake` 走的是完全独立的原地重跑代码路径(不经过 `handleBackwardEdge`),结构上天然不会触发这条自动开新任务的规则,不需要专门加排除逻辑。落点函数:`graph-engine.js` 的 `maybeProposeReplanTask()`,挂在 `handleBackwardEdge` 熔断分支里(约 2131-2133 行)。 - ---- - -## 六、已知遗留(实现后核实,2026-08-30) - -代码落地后回查,发现两个当初设计里提过、但没有专门验证/没做完的点,记在这里而不是假装文档已经全部兑现: - -1. **§四.4 定案的"回归测试钉死闸门"没有单独落地**。`ceo` 自主 `draft` + `enqueue` 走的是和人类调用完全相同的 `POST /:cid/tasks` → `POST /:cid/tasks/:taskId/enqueue` 两个端点(`max_parallel_tasks`/`DEFAULT_TASK_BUDGET`/goal 级 `org-validator` 这几道闸门都挂在数据层/调度器层,不认"谁创建的",理论上自动覆盖),但没有一条测试专门断言"ceo 创建的 task 一样会被这几道闸门拦下"——现有测试(`task-entry-draft-mode.test.js`、`task-blocked-ceo-wake.test.js`)验的是 draft 模式本身和唤醒机制,不是闸门穿透性。理论上没有绕过的口子,但没有测试钉死就不能说"已确认"。 -2. **`company.json.target_dir`/`target_mode` 没有任何 API/UI 入口去设置**——现在只能手工编辑公司态目录下的 `company.json` 文件才能配置。P5 落地的是"读取并生效"这一半,"人怎么把这两个字段填进去"这一半还没做(哪怕只是一个 `PATCH /api/company/:cid` 端点)。 -3. **`server/routes/graph-run.js` 漏了同一处鉴权修复,已在真实跑动中撞出来并修复(2026-08-30)**。地基 0 当时只改了 `company.js`(goals/tasks 端点),`graph-run.js`(run 的 start/stop/retry/budget 等控制端点)还是纯 `requireAuth`。真实端到端测试里 `ceo` 被唤醒后能读写 goal/task 板,但调 `/api/graph-runs/*` 处理卡住的 run 时精确撞上 401,自己在对话记录里诊断出这一点并写出了人工需要跑的 curl 命令。已修:`router`/`companyRouter` 两个挂载点都改成 `requireAuthOrInternal`,两处 `:companyId`/`:id` 的 `param` 中间件加了与 `company.js` 一致的 `req.user?.internal` 旁路,`route-auth-gate.test.js` 同步更新断言,全量测试零回归(1469/1473,剩余 4 个是核实过的 pre-existing 失败)。**这次的教训**:地基 0 的"这一步做完了"判据不能只看 `company.js` 一个文件——`ceo` 需要触达的控制面横跨两个路由文件,遗漏了没有靠代码审查发现,是靠真实跑一遍才暴露的。 diff --git a/docs/todo/system-design/40-agent-chat-streaming-interrupt.md b/docs/todo/system-design/40-agent-chat-streaming-interrupt.md deleted file mode 100644 index b9572c3..0000000 --- a/docs/todo/system-design/40-agent-chat-streaming-interrupt.md +++ /dev/null @@ -1,206 +0,0 @@ -# 40-agent-chat-streaming-interrupt. Agent Chat 打字机平滑 + 打断-排队 + 串台加固 - -> **日期**: 2026-08-30 -> **状态**: 📝 需求文档,讨论中,尚未实现 -> **触发**: 用户反馈"前端刷新不及时、没有打字机效果",怀疑要不要把传输层从 HTTP 换成 WebSocket -> 来解决打字机效果、多 Agent/多聊天切换、执行中无法打断这几个问题。经排查现状代码 + -> 对照 [hermes-webui](file:///Users/yibo/Documents/lemonAI/hermes-webui)、 -> [hermes-agent](file:///Users/yibo/Documents/lemonAI/hermes-agent)、 -> [deepseek-harness](file:///Users/yibo/Documents/lemonAI/deepseek-harness) 三个参考实现, -> 结论是**传输层不是瓶颈,不需要引入 WebSocket**——三个参考项目全部走 SSE/HTTP 流式, -> 没有一个用 WebSocket。真正的差距是另外三件独立的事,本文档逐一给方案。 - ---- - -## 起因 - -现状排查发现:LemonBoss 的 Agent Chat **已经是 SSE 流式**,不是普通 HTTP 一问一答—— -`web/src/views/AgentChat.vue:941` 用 `fetch().body.getReader()` 逐行解析 -`event:`/`data:`,后端 `server/routes/agent-chat.js:2542`(`POST /:agentId/chat`)用 -`setupSSE(res)` 把 Claude CLI 子进程吐出的 ACP `agent_message_chunk` 事件实时转发给前端。 -多聊天"串台"(切换 Agent 时旧流写串到新 Agent 状态里)已经在 `9b1c3f1` 修复:每个 Agent -一份独立的 `chatStates` reactive 状态 + `requestAgentId` 快照,事件处理函数(`handleEvent`, -`AgentChat.vue:991`)全程用 `isCurrent()` 校验"这条流是不是还对应当前选中的 Agent"。 - -对照 hermes-webui(`api/gateway_chat.py` 的 `_stream_writeback_is_current`)和 -deepseek-harness(`interruptByParent`:中止当前 turn → 新消息进 FIFO → 排队执行), -确认这套"per-stream id + is-current 校验"就是业界通用解法,LemonBoss 已经用对了模式, -不需要换传输层来重新解决一遍。 - -真正剩下的三个独立问题: - -1. **打字机不流畅**——不是传输问题,是 ACP chunk 粒度太粗(Claude CLI 一次吐一大段,不是 - 逐字/逐 token),SSE 本身能把这段文本秒级送达前端,但前端拿到手就是一大坨,没有"逐字吐出" - 的观感。 -2. **执行中无法打断并接上新消息**——现状是"先 Stop 再发"两步操作,且发送框在 - `chatState.sending` 为真时整个被 `disabled`(`AgentChat.vue:192`),物理上打不了字。 - deepseek-harness 有专门的打断-排队状态机,LemonBoss 没有。 -3. **串台已修,但没有回归测试兜底**——`9b1c3f1` 是一次性修复,`isCurrent()` 校验散落在 - `handleEvent` 十几个分支里,没有测试盯着,将来改动容易在某个分支漏掉校验又退回去。 - ---- - -## 一、现状代码地图(后续设计都基于这份) - -| 组件 | 位置 | 现状行为 | -|------|------|----------| -| 前端发送/接收 | `AgentChat.vue:908-969`(`handleSend`) | `fetch()` 开流,`st.sending=true` 期间 textarea `disabled`(`:192`),流结束才 `sending=false` | -| 前端事件分发 | `AgentChat.vue:991-1042`(`handleEvent`) | 按 `requestAgentId === agentId.value` 做 `isCurrent()` 校验,非当前 Agent 的流只落状态不刷 UI | -| 后端 SSE 入口 | `agent-chat.js:2542`(`POST /:agentId/chat`) | 每次请求 `while(loopCount<10)` 循环喂 prompt 给 ACP 协议,`onUpdate` 把 `agent_message_chunk` 原样透传成一条 SSE `agent_output` 事件 | -| 后端并发闸门 | `agent-chat.js:2552-2568`(`agentChatLocks`,键是 `agentId`) | 同一 Agent、**不同** convId 并发请求直接 `429` 拒绝;同一 Agent、**相同** convId 的并发请求**不会**被这段拒绝——第二个请求会直接覆盖锁往下跑,等于两个循环同时喂同一个 ACP 会话。目前靠前端 `disabled` 挡住,服务端没有闸门(见下文风险①) | -| 停止端点 | `agent-chat.js:1505`(`POST /:agentId/chat/:convId/stop`) | 调用 `closeSession(key, reason)`(`agent-chat.js:840`),即 `entry.protocol.disconnect()` 杀掉常驻 CLI 子进程,是"硬中止",不接排队 | -| 锁释放 | `agent-chat.js:2880`(`finally` 块) | 请求 handler 退出时统一 `agentChatLocks.delete(agentId)` + `res.end()`;中止后原请求是否会立刻走到这里,取决于 `protocol.disconnect()` 是否让 handler 内部 `await` 的 ACP 调用抛错——**需要在实现阶段用真实 CLI 验证一次**(见风险②) | - ---- - -## 二、方案一:打字机平滑(两端都做) - -**根因**:ACP `agent_message_chunk` 的粒度由 Claude CLI 决定,一次可能带几十到几百字符, -SSE 转发是逐 chunk 转发(`agent-chat.js:2628` `sendSSE('agent_output', ...)`),前端收到 -就是这一整段直接拼进 `st.streamContent`,没有二次拆分,所以观感是"一坨一坨蹦出来"而不是 -打字机。 - -### 2.1 后端:转发前按字符数切片 - -在 `agent-chat.js:2624` 的 `onUpdate` 里,把 `update.content.text` 按固定步长(比如 8-12 -字符一片)拆成多条 `sendSSE('agent_output', ...)`,而不是一次性整段发送。这是唯一需要碰 -后端的点,改动局限在这一个回调函数内,不影响日志落盘(`logMessageChunk` 仍然整段写,只是 -发给前端的部分被拆片)。 - -### 2.2 前端:渲染节流缓冲 - -在 `handleEvent` 收到 `agent_output` 时(`AgentChat.vue:1015`),不直接把整段 `data.message` -拼进 `st.streamContent`,改成推进一个逐字符渲染队列,用 `requestAnimationFrame` 或 -`setInterval` 按固定速率(比如 30-50 字符/秒)从队列取字符追加显示。这样即使后端某次真的 -一次性吐了一大段(比如网络抖动攒批),前端也能把它"抹平"成匀速的打字机效果,不依赖后端切片 -的粒度是否稳定。 - -两端都做的原因:后端切片解决"数据到达的最小单位太粗",前端缓冲解决"到达节奏不均匀"(网络 -延迟、CLI 输出突发)——只做一端在极端情况下(比如后端切了片但网络一次性把好几个 SSE 事件 -一起送到)还是会看起来一卡一卡。 - ---- - -## 三、方案二:打断-排队状态机 - -### 3.1 交互设计 - -保留现有 **Stop 按钮**(`AgentChat.vue:193`,⏹)不变——纯中止,点了就是杀会话,不接后续。 - -新增:**执行中解锁输入框**,允许在 `chatState.sending === true` 时继续打字。此时发送按钮 -的语义从"发送"变成"**打断并发送**",两个操作在视觉上要能一眼区分开——用户分不清点了哪个, -后果是"以为消息接上了,其实这轮直接被丢掉": - -- **Stop 按钮**(`AgentChat.vue:193`,⏹):图标/文案不变,补一句 tooltip「仅停止,不发送 - 新消息」。 -- **发送按钮在 sending 态下**:换一个视觉上明显不同的图标/配色(暖色调,区别于平时发送用的 - 主色),tooltip 改为「发送并打断当前回复」。 -- **打断成功后**,在对话记录里插入一条不打断阅读的**分隔标记**(细线 + 小字,类似 - "— 已打断上一条回复 —"),让用户回看聊天记录时能看懂"上一条为什么没说完"——这是 - ChatGPT / Claude.ai 自己在"stop and continue"场景下的通用做法。 - -点击"打断并发送"后的具体流程: - -1. 前端把发送按钮切到"打断中…"状态,锁住重复点击(防止手速快连点两次触发两轮打断); -2. 调用现有 `stop` 端点(复用 `agent-chat.js:1505`,不新增中止路由,但要改内部实现,见 - 3.1.1); -3. 根据 `stop` 返回的 `cleaned` 字段决定下一步(见 3.1.1),确认可以安全发送后,把这条新 - 消息按**正常发送流程**投出去,带**同一个 convId**,接续同一个对话历史,并在发送成功后 - 插入上面说的分隔标记。 - -这本质是"打断并立即顶替",但工程实现上做成一个**深度为 1 的排队**(先确认上一个 turn 真的 -已经退出,再发新的),不是前端拿到点击事件就立刻并发发第二个请求——原因见风险①: -服务端目前对"同一 convId 并发"没有闸门,真并发会导致两个循环同时写同一个 ACP 会话。 - -#### 3.1.1 `stop` 端点改造:后端确认清理完成,而不是前端猜时间 - -现状 `stop` 端点(`agent-chat.js:1505-1512`)调用 `closeSession` 后立刻同步返回,不代表 -原来那个 `/chat` 请求的 handler 已经真正退出(风险②)。改造成: - -- `stop` 端点内部在调用 `closeSession` 之后,轮询等待 `agentChatLocks` 里对应 `agentId` - 的锁被清掉(即原请求走到 `agent-chat.js:2880` 的 `finally`),**预算 3 秒**(`closeSession` - 杀子进程本身是毫秒级,慢的是原请求 `await` 被异常唤醒到走完 `finally` 这段,3 秒是给日志 - flush 这类边缘情况留的余量,正常路径应该用不到)。 -- 3 秒内确认清理完成 → 返回 `{ success: true, cleaned: true }`,前端立刻发新消息,用户基本 - 感觉不到延迟。 -- 3 秒仍未确认(极端情况) → 返回 `{ success: true, cleaned: false }`(尽力而为,不算失败), - 前端**不自动强发**(会撞回风险①的并发洞),而是在输入框附近给一条不遮挡输入的提示 - 「上一条还在收尾…」+ 一个「强制发送」按钮,把"要不要冒这个小概率风险"的决定权交还给用户, - 而不是系统默默赌一把。 - -### 3.2 需要新增/改动的点 - -| 位置 | 改动 | -|------|------| -| `AgentChat.vue:192` | textarea 的 `:disabled="chatState.sending"` 改成允许 sending 态下继续输入;`autoResize`/`handleKeydown` 不受影响 | -| `AgentChat.vue:194` | 发送按钮增加"sending 态下点击 = 打断并发送"分支,UI 上给一个过渡态(如按钮文案/图标切换) | -| `AgentChat.vue:handleSend`(`:908`) | 抽出"等待上一个 turn 收尾"的等待逻辑,供打断分支复用 | -| `agent-chat.js:2552-2568`(`agentChatLocks`) | **补上同 convId 并发闸门**(风险①,见下)——无论是否做打断功能,这都是一个已经存在、只是暂时没被前端触发到的并发 bug,建议顺带修 | -| `agent-chat.js:1505`(stop 端点) | 改成内部轮询确认清理完成再返回,加 `cleaned: true/false` 字段,见 3.1.1 | - -### 3.3 风险 / 需要在实现阶段验证的点 - -1. **同 convId 并发无闸门(现存 bug,非本次引入)**:`agent-chat.js:2554` 的判断是 - `existingLock.convId !== convId` 才拒绝,同 convId 的第二个请求会直接覆盖锁往下跑。 - 目前靠前端 `disabled` 属性挡住,从没被服务端真正堵过。一旦解锁输入框,这条路径变得 - 可达,必须先补服务端闸门,否则用户手速快一点就能让两个循环同时喂同一个 ACP 会话(未定义 - 行为,轻则消息乱序,重则 CLI 子进程状态损坏)。 -2. **中止后原请求是否会及时退出,还未验证**:`closeSession` 只是 `protocol.disconnect()`, - 原来那个还挂着 `await` 的 `POST /chat` 请求 handler 会不会因此立刻抛错走到 - `finally`(`:2880`)释放锁、结束响应,取决于 `ClaudeCliProtocol` 底层对子进程异常退出的 - 处理方式。**已拍板的应对方案见 3.1.1**(`stop` 端点内部轮询 + 3 秒预算 + `cleaned` 字段 - + 前端超时兜底按钮);实现阶段仍需要用一次真实 Claude CLI 会话验证「中止后原请求平均/最坏 - 多久会真正退出」,如果实测远超 3 秒(比如需要等底层网络超时),要回来调整这个预算数字, - 而不是改设计本身。 - ---- - -## 四、方案三:串台加固(回归测试) - -`9b1c3f1` 的修复方式是在十几个 `handleEvent` 分支里散落地加 `if (isCurrent) ...`,容易在 -未来新增分支时漏掉。建议补一个**前端组件级测试**(而不是走完整 E2E),思路: - -- 模拟两个 Agent 的 `chatStates`,对 Agent A 发起一次"慢"流(用假的 SSE 事件序列,中间插 - 延时),在流还没结束时把 `agentId.value` 切到 Agent B; -- 断言 Agent A 后续到达的事件(`thinking` / `agent_output` / `tool_call` 等每一种 SSE - 事件类型都要覆盖到)只更新了 `chatStates.get('A')`,不影响当前显示(Agent B 的视图); -- 反向断言:切回 Agent A 时,之前"憋住"没刷 UI 的内容应该已经正确落在 `chatStates.get('A')` - 里,切回来就能看到完整历史,不丢消息。 - -这条测试的价值不是证明现在没 bug(`9b1c3f1` 已经手工验证过),而是**卡住以后的回归**—— -后续如果给 `handleEvent` 加新的 SSE 事件类型,忘了包 `isCurrent()` 判断,测试会红。 - ---- - -## 五、不做的事(明确排除) - -- **不引入 WebSocket**。三个参考项目和 LemonBoss 现状都证明 SSE 足够,问题不在传输层。 - 真要引入 WebSocket 的场景是"没有聊天窗口打开时也要收到 Agent 的后台推送"(比如心跳异常 - 主动弹通知),这是一个不同的需求,不属于本文档范围,需要的话应该另开文档讨论。 -- **不做"多条消息同时排队"的完整 FIFO**。deepseek-harness 那套是为多 Agent - parent-child 场景设计的,LemonBoss 这里是单用户单会话打断,深度 1("打断当前的,接上 - 新的")已经覆盖需求,做完整队列是过度设计。 - ---- - -## 六、实施顺序建议 - -三个方案彼此独立,互不阻塞,建议顺序(从风险低/收益快到风险高): - -1. **方案三**(串台回归测试)——纯新增测试,零风险,先把安全网铺好。 -2. **方案一**(打字机平滑)——只碰渲染逻辑和一个转发回调,不改并发/状态机,风险最低。 -3. **方案二**(打断-排队)——涉及并发闸门(风险①必须先修)和状态机改造,且风险②需要真实 - CLI 验证,建议放最后,且实现前先用一次真实 Claude CLI 会话验证"中止后原请求多久会退出"。 - -## 七、已拍板的设计决策 - -以下两点原本是未决问题,已确认,写在这里避免以后重新讨论一遍: - -1. **超时策略**:由后端 `stop` 端点内部确认清理完成(轮询 `agentChatLocks`),预算 **3 秒**; - 超时不代表失败,返回 `cleaned: false`,前端不自动强发,展示「上一条还在收尾…」+ 手动 - 「强制发送」按钮,风险由用户显式承担,不做静默重试。详见 3.1.1。 -2. **UI 区分**:Stop 按钮和"sending 态下的发送按钮"必须视觉上可区分(图标/配色 + 不同 - tooltip),打断成功后在对话记录里插入一条分隔标记,便于事后回看。详见 3.1。 - -本文档目前没有遗留的未决问题;进入实现阶段后如果冒出新的(比如风险②实测的具体数字要调整 -预算),回来更新这一节。 diff --git a/docs/todo/system-design/41-graph-vs-fde-reliability-discussion.md b/docs/todo/system-design/41-graph-vs-fde-reliability-discussion.md index fefa08f..c66782d 100644 --- a/docs/todo/system-design/41-graph-vs-fde-reliability-discussion.md +++ b/docs/todo/system-design/41-graph-vs-fde-reliability-discussion.md @@ -348,7 +348,7 @@ FDE 无关,纯粹为了让 org-hunt 做真调研)反复 `rerun` 同一个 ta requireAuthOrInternal`,本次合并已带入)。核实过 `server/routes/company.js:18` 现在是 `router.use(requireAuthOrInternal)`,不再是 `requireAuth`。 - [x] ~~boss agent(暂定挂在 CEO 身份下):主动巡检 goal/task/work 状态~~ —— - **已被另一并行会话实现**,见 [39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md) + **已被另一并行会话实现**,见 [39-boss-agent-task-supervision.md](../../done/system-design/39-boss-agent-task-supervision.md) (P1-P5,commit `a00590a`/`5108d67`/`d967ac0`),本文档这条 TODO 作废,后续跟进请去那份 文档的「六、已知遗留」一节。 - [ ] `routeFailure` 的 `infra_flake` 分支补上 `target_node` 路由(30 号文档遗留的"刻意没做②")。 -- libgit2 0.21.4