You need to sign in before continuing.

Commit fdeafcdb8fe3ed7631388db77d76e34e4f8c6434

Authored by yibo
2 parents ced0268b 6ae1f765

Merge branch 'main' of gitlab-wuwei.hexpm.com:yibo/lemonboss

冲突在 docs/README.md:并行会话在真实端到端测试里发现 boss agent(39 号文档)还有
三个单测没覆盖的真问题,把 39 号重新留在 todo/ 并拆出 42 号跟进文档;与我这边把
39 号挪进 done/ 的判断冲突——采纳对方判断(39 号文档物理路径挪回 todo/system-design/,
докс/README.md 用对方更新的行 + 补 42 号一行),因为他们是基于更新的真实测试结果。

顺带把 29-dream-design.md 这次会话移到 done/ 后遗漏的引用修掉:
system/agents/{dream-distiller,dream-screener}/agent.md、
server/{routes/dream.js,services/dream-service.js}、
server/tests/unit/dream-routes.test.js 六处 docs/todo/system-design/29-dream-design.md
路径改成 docs/done/system-design/29-dream-design.md(no-dead-path-references.test.js /
agent-md-refs.test.js 两条守卫测试之前没扫到这几处,这次真跑测试才暴露)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CLAUDE.md
... ... @@ -110,7 +110,7 @@ npm run okr:migrate # goals → okr 存量迁移
110 110 | `/ws/claude` | Claude 实时对话(仅 `index.js`) |
111 111 | `/api/health` `/api/plugins` | 健康检查 / 插件状态 |
112 112 | `/api/devloop/projects` | DevLoop:创建项目 + 跑 PM/Architect(POST)、列项目 / 查状态(GET,见下) |
113   -| `/api/dream/*` | 经验汇流(29-DREAM):`dream-service.js` 能力的 REST 封装,只读查询 + 触发 `dreamOnce`/`dreamSweep`,不新增业务逻辑,见 [`docs/todo/system-design/29-dream-design.md`](docs/todo/system-design/29-dream-design.md) |
  113 +| `/api/dream/*` | 经验汇流(29-DREAM):`dream-service.js` 能力的 REST 封装,只读查询 + 触发 `dreamOnce`/`dreamSweep`,不新增业务逻辑,见 [`docs/done/system-design/29-dream-design.md`](docs/done/system-design/29-dream-design.md) |
114 114  
115 115 ### DevLoop 子系统
116 116  
... ...
docs/README.md
... ... @@ -34,7 +34,6 @@
34 34 | [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) |
35 35 | [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 号文档的真实回归验证过程中,与兜底边问题无关 |
36 36 | [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:控制台「验收证据」小节展示每个维度有没有证据支撑 |
37   -| [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 产出回写看板 + 公司级目标代码目录。本文档不再更新,仅存档决策过程,**已知未覆盖的后续工作见文内「六、已知遗留」** |
38 37 | [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 会话在浏览器验证 |
39 38  
40 39 ## todo/(当前主线)
... ... @@ -55,6 +54,8 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
55 54 | [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(设计见 29 号,已实现,见下)、`source_kind`(✅已删)/`legacy_path`(不能删)删列、三项「已实施未验证」。任务态清理(✅已实现,见下)。§五 是**已立起来的守卫清单**(别重复造,也别删) |
56 55 | [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 交给人工 |
57 56 | [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 次,零人工介入),修复确认生效 |
  57 +| [39-boss-agent-task-supervision.md](./todo/system-design/39-boss-agent-task-supervision.md) | ✅ **P1-P5 已实现**(2026-08-30,`a00590a`/`5108d67`/`d967ac0`/`14cbaf5`)。boss agent(复用 `ceo`)监督 goal/task 看板闭环:draft task 回写、事件驱动+低频兜底唤醒、独立任务看板页、公司级 `target_dir`。**真实端到端测试**(建公司→下 goal→跑出一个 Playwright 5/5 验证通过的贪吃蛇游戏)撞出并记录了三个单测没覆盖的真问题,见文内「七、真跑复盘」;沉淀的待办见 [42-org-work-graph-reliability-followups.md](./todo/system-design/42-org-work-graph-reliability-followups.md) |
  58 +| [42-org-work-graph-reliability-followups.md](./todo/system-design/42-org-work-graph-reliability-followups.md) | 📝 **Phase 1 已实现,Phase 2-4 待开工**(Phase 1:卡住恢复闭环——僵尸节点检测 + 强制判失败端点,`6ae1f76`)。39 号文档真跑复盘沉淀的四 phase 可靠性待办:卡住恢复闭环 → `ceo` 自建 task 的闸门穿透性回归测试 → `target_dir` 的 API/UI 配置入口 → `timeline.ndjson` 承接 planning/reflection 轨迹(结构性改动,排最后) |
58 59 | [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`)仍是唯一确认还没补的口子 |
59 60 | [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 人设文档的自我认知改写 |
60 61 | [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:<reason>` 写进本体 `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(训练产物 → 雇佣本体转换)尚未开工 |
... ...
docs/done/system-design/39-boss-agent-task-supervision.md renamed to docs/todo/system-design/39-boss-agent-task-supervision.md
1 1 # 39. Boss Agent 任务看板监督 + 公司目标代码目录 — 需求评估与设计草案
2 2  
3   -> **状态**:✅ P1-P5 已实现(2026-08-30,commit `a00590a` P1/P3/P4 后端、`5108d67` P2 前端、`d967ac0` P5)。本文档是落地前的设计草案,实现细节以代码为准,本文档不再更新,仅存档决策过程。**已知未覆盖的后续工作见文末新增的「六、已知遗留」**。
  3 +> **状态**:✅ P1-P5 已实现(2026-08-30,commit `a00590a` P1/P3/P4 后端、`5108d67` P2 前端、`d967ac0` P5、`14cbaf5` graph-run.js 鉴权补丁)。本文档是落地前的设计草案,实现细节以代码为准,仅存档决策过程。**已知未覆盖的后续工作见「六、已知遗留」,真实端到端跑测的复盘见「七、真跑复盘」,复盘沉淀的待办已拆成独立文档 [42-org-work-graph-reliability-followups.md](./42-org-work-graph-reliability-followups.md)(按 phase 排开工顺序)**。
4 4 > **日期**:2026-08-30
5 5 > **背景**:承接对话讨论——goal/task 两级模型([27-P3C](../../done/system-design/27-p3c-goal-task-model.md))已落地,但推进它的是纯确定性代码(`task-scheduler.js` + `transitionRun` 钩子),没有任何 agent 在持续监督这块看板、处理异常、把执行中发现的新问题回写成新任务。用户提出五阶段方案补上这一层,并追加公司级目标代码目录需求。
6 6 > **读者须知**:本文档大量引用现有代码路径核实"已有 vs 缺失",结论以文内给出的具体函数/文件为准;这些引用会随代码演进过期,落地前请重新核实。
... ... @@ -190,3 +190,29 @@
190 190 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 模式本身和唤醒机制,不是闸门穿透性。理论上没有绕过的口子,但没有测试钉死就不能说"已确认"。
191 191 2. **`company.json.target_dir`/`target_mode` 没有任何 API/UI 入口去设置**——现在只能手工编辑公司态目录下的 `company.json` 文件才能配置。P5 落地的是"读取并生效"这一半,"人怎么把这两个字段填进去"这一半还没做(哪怕只是一个 `PATCH /api/company/:cid` 端点)。
192 192 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` 需要触达的控制面横跨两个路由文件,遗漏了没有靠代码审查发现,是靠真实跑一遍才暴露的。
  193 +
  194 +---
  195 +
  196 +## 七、真跑复盘(2026-08-30,`snakegame-co` 端到端测试)
  197 +
  198 +P1-P5 落地后,用它做了一次真实端到端测试:建公司 → 手工配 `target_dir` 指向一个真实的空目录 → 下一个"做 HTML 版贪吃蛇游戏"的 goal → `task-planning` 真实拆分 → 全程真实 Claude CLI 调用把 org-planner(PRD/SDD/验收标准)→ org-coder(RESEARCH/PLAN/EXECUTE/VERIFY,含真实的 test/validator/impl 修正迭代)跑完,最终 `goal status: done`、`task status: completed`,产出的 `index.html` 用独立跑的 Playwright 测试验证 5/5 通过(一个可以在浏览器里直接玩的贪吃蛇游戏)。
  199 +
  200 +**核心结论:1469 个单测全绿,这一次真实端到端跑动却测出了至少三个单测完全没覆盖到的真问题。** "全部测试通过"不能当"系统能用"的证据——这次最有价值的发现,没有一个是单测挑出来的,全是真实跑一遍撞出来的。
  201 +
  202 +### 7.1 真跑撞出的技术缺口
  203 +
  204 +按发现顺序:
  205 +
  206 +1. **服务器重启会让 in-flight 的 graph 节点变僵尸,没有任何机制能自动发现**。为了让 `graph-run.js` 鉴权修复生效重启了一次服务器,正在跑 `org-planner-sdd` 的真实 Claude CLI 子进程被连带杀掉,但 `graph_run_nodes.status` 永远停在 `running`——`ceo` 的两条唤醒路径(事件驱动 + 30 分钟兜底扫描)判据都只认 `status='blocked'`,对"运行中但背后进程已死"这个状态完全不可见。查过现成的"重启后清理残留"机制(`server/index.js` 的 `resumeIncompleteTasks()`),发现它只管 ArenaSK 的 Skill 训练任务,跟 org/work graph 毫无关系;`shutdown()` 也只关插件管理器,不碰 `graph_run_nodes`。这是这次代价最大的一个坑,恢复时手写了一段脚本直接调 `graph-run-sse.transitionNode` 强制把节点判成 `fail`(走的是引擎自己的转换机制,不是绕过系统直接改数据库),才能接上标准的 `retry` 端点继续推进——但这条恢复路径本身也暴露了:**"这个节点看着卡住了,强制判失败以便恢复"这个动作现在没有任何 REST 入口**,只能改代码。
  207 +2. **`graph-run.js` 的鉴权缺口**(见「六、已知遗留」第 3 条,已修复推送)。
  208 +3. **规划/行动/反思没有统一的可追溯位置**。`org-coder` 的四段式阶段流转(RESEARCH→PLAN→EXECUTE→VERIFY)靠调 `bin/lemon.js task transition`,打到 `agent-chat.js` 的 `/task/transition` 端点——这个端点靠自己的 `sessions` Map 找"当前活跃会话",但 `org-coder` 作为 work graph 节点跑的时候走的是 `graph-engine.js` → `AgentManager` → `session-pool.js`,是 CLAUDE.md 里早就记录过的"两个互不相干的会话池"那个坑的又一个受害者:阶段流转的业务逻辑(P0 审批拦截、OKR 子状态更新)在这条路径上**静默跳过**,`_plan.md` 这类中间产物既不进 `graph_files` 契约(这个是刻意的——validator 判定不该依赖"coder 计划写得像不像样"),也不进任何跨节点共享的轨迹,人类/`ceo` 事后想知道"这一步当时怎么想的",只能去读原始的 agent transcript。
  209 +
  210 +这三条已经拆成具体的开发计划:[42-org-work-graph-reliability-followups.md](./42-org-work-graph-reliability-followups.md)。
  211 +
  212 +### 7.2 过程方法上的教训
  213 +
  214 +1. **三个并行 fork 分工做 P1-P5 这个模式本身是对的**——文件边界分得干净,`git diff` 核实过没有意外重叠,最后能精确按 hunk(不是整文件)拆成三个独立 commit。值得作为以后类似规模改动的默认工作方式。
  215 +2. **"全部测试通过"不能替代真实跑一遍**——见上,这是本次复盘最核心的教训,直接决定了 41 号文档 Phase 1-2 优先做"让已经在跑的东西更可靠"而不是急着加新功能。
  216 +3. **"这一步做完了"的判据容易只验证了子集**——`graph-run.js` 的鉴权缺口就是典型:地基 0 当时的验证只覆盖了 `company.js`,`ceo` 实际需要触达的控制面横跨两个路由文件。以后给 agent 开权限类的工作,落地前要先列一份"这个角色实际会调哪些端点"的清单,逐条核对,不能默认"改了一个入口就等于都改了"。
  217 +4. **重启共享的开发服务器要谨慎**——为了让代码改动生效重启了一次服务器,没意识到这会杀掉正在跑的真实 Claude CLI 子进程,浪费了一整轮真实 LLM 调用(真金白银)。以后类似场景应该先确认有没有 in-flight 的真实执行,能等就等它到一个安全点再重启。
  218 +5. **"为什么不能自动恢复"这类追问是复盘里最值钱的部分**——第一反应往往是"手动修一下继续跑",是持续追问"这本该由谁负责、为什么没发生"才把表面的一次性意外,看清楚成系统性盲区(僵尸节点检测缺失)。这次几个最高价值的发现,都来自这种不满足于表面解释、继续往下问一层的模式,值得作为以后排查问题的默认姿势,不只是这一次的运气。
... ...
docs/todo/system-design/41-graph-vs-fde-reliability-discussion.md
... ... @@ -348,7 +348,7 @@ FDE 无关,纯粹为了让 org-hunt 做真调研)反复 `rerun` 同一个 ta
348 348 requireAuthOrInternal`,本次合并已带入)。核实过 `server/routes/company.js:18` 现在是
349 349 `router.use(requireAuthOrInternal)`,不再是 `requireAuth`。
350 350 - [x] ~~boss agent(暂定挂在 CEO 身份下):主动巡检 goal/task/work 状态~~ ——
351   - **已被另一并行会话实现**,见 [39-boss-agent-task-supervision.md](../../done/system-design/39-boss-agent-task-supervision.md)
  351 + **已被另一并行会话实现**,见 [39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md)
352 352 (P1-P5,commit `a00590a`/`5108d67`/`d967ac0`),本文档这条 TODO 作废,后续跟进请去那份
353 353 文档的「六、已知遗留」一节。
354 354 - [ ] `routeFailure` 的 `infra_flake` 分支补上 `target_node` 路由(30 号文档遗留的"刻意没做②")。
... ...
docs/todo/system-design/42-org-work-graph-reliability-followups.md 0 → 100644
  1 +# 42. Org/Work Graph 可靠性收尾 —— 39 号文档真跑复盘的待办
  2 +
  3 +> **状态**:Phase 1 ✅ 已实现(2026-08-30);Phase 2-4 📝 待开工。
  4 +> **日期**:2026-08-30
  5 +> **背景**:[39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md) 的 P1-P5 落地后,用它做了一次真实端到端测试(建公司 → 下 goal → `ceo` 监督 → 真实跑出一个可玩的贪吃蛇游戏,Playwright 5/5 验证通过)。**1469 个单测全绿,这次真跑却测出至少 3 个单测完全没覆盖到的真问题**——这份文档是那次复盘沉淀下来的待办,按开工顺序分四个 phase。
  6 +> **读者须知**:每个 phase 的现状描述都来自那次真实测试里实际撞到的场景,不是推测;具体文件/行号落地前按惯例重新核实。
  7 +
  8 +---
  9 +
  10 +## 一、为什么排这个顺序
  11 +
  12 +四个 phase 分两类:**Phase 1-2 是"让已经在跑的东西更可靠"**(不改变行为,只是补上恢复路径和验证),**Phase 3-4 是"补功能缺口"**(新增能力)。前者优先——真跑复盘里代价最大的坑(僵尸节点、鉴权漏挂)都是"东西本来能跑,只是卡住了没人能救"这一类,越早补上,后面几个 phase 开工时越不容易被同样的坑绊到。Phase 5(timeline 承接规划轨迹)单独放最后,因为它是结构性改动,需要先摸清 `org-coder` 之外还有哪些角色也在打那条断线的 `/task/transition`,工作量比其余四个 phase 加起来还大。
  13 +
  14 +```
  15 +Phase 1:卡住恢复闭环(僵尸节点检测 + 强制判失败端点)——两个互补的口子一起补
  16 +Phase 2:§四.4 闸门穿透性回归测试 —— 纯测试,零设计风险
  17 +Phase 3:target_dir 的 API/UI 入口 —— 纯加法
  18 +Phase 4:timeline.ndjson 承接 planning/reflection 轨迹 —— 结构性改动,排最后
  19 +```
  20 +
  21 +---
  22 +
  23 +## 二、Phase 1:卡住恢复闭环 —— ✅ 已实现(2026-08-30)
  24 +
  25 +**落地结果**(与下面的设计一一对应,实现时没有偏离):
  26 +- 1a:`graph-engine.js` 新增 `sweepOrphanedRunningNodes(cutoffIso)`,复用 `routeFailure` 的 `infra_flake` 分支;`server/index.js` 在 `PROCESS_STARTED_AT`(模块顶层捕获的本次进程启动时刻)挂上开机一次性调用,排在其余「开机补一次全量扫描」钩子之前。测试:`server/tests/unit/graph-engine-orphan-sweep.test.js`(4 例:孤儿转 infra_flake 原地重跑、晚于 cutoff 的不受影响、连续判孤儿撞硬顶转 blocked、无孤儿时安静 no-op)。
  27 +- 1b:`graph-engine.js` 新增 `forceFailNode(runId, nodeRunId, failReason='operator_intervention')`;`graph-run.js` 新增 `POST /:id/nodes/:nodeId/force-fail`,只接受 `status='running'`。测试:`server/tests/unit/graph-run-force-fail.test.js`(8 例:正常转 fail 并可被 retry 接上、自定义 failReason 透传、五种非 running 状态各返 400、节点不存在返 404)。
  28 +- 全量测试 1483/1487,零回归(剩余 4 个是核实过的 pre-existing 失败)。
  29 +
  30 +---
  31 +
  32 +### 1a. 服务器重启的僵尸节点检测
  33 +
  34 +**现状(真跑撞到的)**:`graph_run_nodes.status='running'` 这一行,没有任何机制确认"背后真的还有个活的进程"。真实测试里为了让 `graph-run.js` 鉴权修复生效重启了一次服务器,正在跑 `org-planner-sdd` 的 Claude CLI 子进程被连带杀掉,但那一行的状态永远停在 `running`——不会自己变成 `blocked`,`ceo` 的两条唤醒路径(事件驱动 + 30 分钟兜底扫描)判据都是 `status='blocked'`,对这个状态完全不可见。
  35 +
  36 +查过现成的"重启后清理残留"机制:`server/index.js` 的 `resumeIncompleteTasks()` 只处理 ArenaSK 的 Skill 训练任务(`skill-task-manager`),跟 org/work graph 毫无关系;`shutdown()`(`SIGTERM`/`SIGINT` 处理)只关插件管理器,不碰 `graph_run_nodes`。两处都不覆盖这个场景。
  37 +
  38 +**设计**:启动时扫一遍 `graph_run_nodes`,`status='running'` 且 `updated_at`(或 `startedAt`)早于**本次进程启动时间**的行,判定为孤儿(整个进程树都被换过一轮,不可能有真实进程还在跑它)。判定后按 **`infra_flake` 语义**处理,不发明新状态——原地重跑逻辑本来就是为"基础设施打断,跟设计/实现对不对无关"这类场景设计的,服务器重启只是这个桶里更严重的一个成员。复用 `graph-engine.js` 已有的 `routeFailure` 里 `INFRA_FLAKE_REASON` 那条分支,不用另起一套判定/熔断逻辑。
  39 +
  40 +**落点**:`server/index.js` 启动流程(挂在 `resumeIncompleteTasks()`/`sweepStuckActiveGoals()` 同一批"开机补一次全量扫描"的地方,跟这些既有钩子形状一致),加一个 `sweepOrphanedRunningNodes()`;判定逻辑放 `graph-engine.js` 或 `work-graph-db.js`(参照 `infra_flake` 现有实现挂在哪个文件)。
  41 +
  42 +**验收**:新增测试——起一个 `status='running'`、`startedAt` 早于"当前进程启动时间"的节点,跑一次 `sweepOrphanedRunningNodes()`,断言它被转成 `infra_flake` 路径能处理的状态,且不影响 `startedAt` 晚于启动时间的正常运行节点。
  43 +
  44 +### 1b. "强制判失败以便恢复"的 REST 端点
  45 +
  46 +**现状**:`POST /:id/nodes/:nodeId/retry` 只接受 `status ∈ {fail, blocked}` 的节点。真实测试里遇到一个卡住的 `running` 节点,唯一的恢复手段是**直接写一段脚本调用 `graph-run-sse.transitionNode` 内部函数**,绕过了任何 REST 接口——这对人类操作者或 `ceo` 都不现实,两者都只能走 HTTP。
  47 +
  48 +**设计**:新增 `POST /api/graph-runs/:id/nodes/:nodeId/force-fail`,语义是"我确认这个节点已经不可能再自己完成了,把它判成 `fail`(`failReason` 可选,默认某个通用值如 `operator_intervention`),交给既有的 fail→retry/backward-edge 路径处理"。**只接受 `status='running'` 的节点**(`fail`/`blocked` 已经有 `retry` 端点了,不重复造轮子);调用方是人类还是 `ceo` 不做区分,鉴权走已有的 `requireAuthOrInternal`(跟着 Phase 1a 一起补,`graph-run.js` 这次已经全部换过了)。1a 落地后,这个端点的日常触发频率应该会显著下降(大部分"卡住"场景 1a 已经自动兜底),但保留它作为人工/`ceo` 判断"这个不是重启造成的,就是卡了"时的手动出口。
  49 +
  50 +**验收**:新增测试——`running` 节点调 `force-fail` 转 `fail` 且可被 `retry` 端点接上;`fail`/`blocked`/`pending` 状态调用返回 400(沿用 `ensureStatus` 现成的错误形状)。
  51 +
  52 +---
  53 +
  54 +## 三、Phase 2:§四.4 闸门穿透性回归测试
  55 +
  56 +**现状**:39 号文档 §四.4 已拍板"`ceo` 全权自主 `enqueue`,但 `max_parallel_tasks`/`DEFAULT_TASK_BUDGET`/goal 级 `org-validator` 这几道闸门必须照样拦住它",落地时没有补这条测试(§六"已知遗留"记过一笔)。理论上这几道闸门挂在数据层/调度器层,不认"谁创建的",天然覆盖,但没有测试钉死就不能说"已确认"。
  57 +
  58 +**设计**:不涉及任何设计决策,纯粹是补测试。新增用例(大致形状,落地时按现有测试文件的辅助函数抄写法):
  59 +1. 把公司 `max_parallel_tasks` 设成 1,已有一条 task 占着坑,`ceo` 走 `draft`+`enqueue` 建第二条——断言它排队而不是插队执行。
  60 +2. 把公司 `DEFAULT_TASK_BUDGET` 设小,`ceo` 建的任务跑到预算耗尽——断言按现有 `budget-exhausted` 路径 `blocked`,不会因为"是 agent 建的"就获得额外预算。
  61 +3. `ceo` 建的多 task goal 全部 `completed` 后,断言 goal 级 `org-validator` 照样被调一次(不因创建者是 agent 而跳过收敛验收)。
  62 +
  63 +**落点**:新建 `server/tests/unit/ceo-created-task-gates.test.js`,或并入现有 `task-blocked-ceo-wake.test.js`(按当时哪个更顺手)。
  64 +
  65 +---
  66 +
  67 +## 四、Phase 3:`target_dir` 的 API/UI 入口
  68 +
  69 +**现状**:P5 落地的是"配了 `company.json.target_dir` 就生效"这一半,"人怎么把这两个字段填进去"这一半没做——真实测试里是手工编辑公司态目录下的 JSON 文件配的。
  70 +
  71 +**设计**:
  72 +- **后端**:`PATCH /api/company/:companyId`(如果这个端点已存在就扩展,不存在就新建)接受 `target_dir`/`target_mode`,写回 `company.json`。校验复用 `path-resolver.js` 的 `validateLinkedTarget`(39 号文档 P5 已经抽出来的共享校验函数),配置阶段就能提前报错(不用等到第一次 `ensureProjectRoot` 才发现路径不对)。
  73 +- **前端**:公司设置页面(或 `TaskBoard.vue`/`GraphConsole.vue` 附近,具体挂哪个页面待定)加一个表单,填目录路径 + 选 `linked-new`/`linked-existing`。
  74 +
  75 +**验收**:新增测试覆盖 `PATCH` 端点的校验分支(合法路径写入成功、`isSensitivePath` 命中黑名单拒绝、`linked-new` 目录非空拒绝);前端至少过一次真实点击验证表单能提交成功。
  76 +
  77 +---
  78 +
  79 +## 五、Phase 4:`timeline.ndjson` 承接 planning/reflection 轨迹
  80 +
  81 +**现状(真跑撞到的,这是四个 phase 里最大的一个)**:`org-coder` 的四段式(RESEARCH→PLAN→EXECUTE→VERIFY)流转靠调 `bin/lemon.js task transition`,打到 `agent-chat.js` 的 `/task/transition` 端点——这个端点靠自己的 `sessions` Map 找"当前活跃会话",但 `org-coder` 作为 work graph 节点跑的时候走的是 `graph-engine.js` → `AgentManager` → `session-pool.js`,是 CLAUDE.md 里早就记录过的"两个互不相干的会话池"那个坑的又一个受害者。结果:`org-coder` 在 work graph 里调这条流转指令时,`sessions.entries()` 根本找不到它的会话,阶段流转的业务逻辑(P0 审批拦截、OKR 子状态更新)**静默跳过**;它的中间产物(`_research.md`/`_plan.md`/`_lessons.md`)不进 `graph_files` 契约(这个是刻意的,见下"不改的部分"),但也**不进任何跨节点/跨角色共享的轨迹**,人类或 `ceo` 事后想搞清楚"这一步当时是怎么想的",只能去读原始的 agent 会话 transcript(`GET /:id/nodes/:nodeRunId/transcript`),没有结构化的入口。
  82 +
  83 +**不改的部分(上一轮已经讨论定案,写在这里防止实现时跑偏)**:
  84 +- `_plan.md` 这类中间产物**不进 `needs`/`produces` 契约**,`org-validator` 判定依据仍然只认 `acceptance.md` + `artifact`/`log`。理由:validator 判定"代码对不对",不该依赖"coder 计划写得像不像样",这条边界一旦模糊,两个坏结果都会发生——coder 学会把计划写漂亮而不是把代码写对;`graph_files` 的契约面失控(这个仓库反复栽在"两套体系不统一"上)。
  85 +- 本 phase 只解决**可见性**(人类/`ceo` 事后能不能追溯),不解决**契约**(下游节点该不该依赖)——这是两件事,上一轮讨论已经拆开过。
  86 +
  87 +**设计**(需要先做的调研,落地前必做):
  88 +1. **调研范围**:`org-coder` 是这次撞到的一个,不代表只有它——扫一遍 `system/agents/*/agent.md` 里还有哪些角色的提示词也在调 `bin/lemon.js task transition`,把范围摸清楚再动手,不要只按 `org-coder` 这一个样本改。
  89 +2. **迁移方向**:把"阶段流转"这个动作,从打 `agent-chat.js` 的 `/task/transition`(legacy,不认 graph run),改成写一条 `timelineNote()`(`graph-engine.js` 已有机制,per-run、append-only、永不改已写行),带上 `phase`(research/plan/execute/verify 或角色自定义的阶段名)+ `artifact` 路径。**具体是改 `bin/lemon.js task transition` 这条命令本身去打新端点,还是给它接一条新命令**,两种做法都要过一遍现有 agent 提示词的改动成本再定——这些提示词文件散在几十个 `agent.md` 里,改一条命令的调用方式等于牵动所有引用它的本体。
  90 +3. **P0 审批拦截 / OKR 子状态更新这两块旧逻辑怎么处理**:`/task/transition` 里现在还挂着 P0 任务拦截、OKR 子状态更新这两段业务逻辑——这两者本身是不是还需要(尤其 OKR 那段,跟 P3C 的新 goal/task 模型是不是还有效对应),需要单独判断,不能假设"迁移流转机制"就等于"这两段逻辑原样照抄"。
  91 +
  92 +**验收**(待设计细化后补全,这里先占位):至少要有一条测试证明——`org-coder`(或调研出的任意一个角色)在 work graph 里完成一次阶段流转后,`timeline.ndjson` 里能查到对应的 `phase`+`artifact` 记录,且这条记录不影响 `graph_files`/`needs`/`produces` 的既有行为(回归测试守住"不改的部分"那条边界)。
  93 +
  94 +---
  95 +
  96 +## 六、开放问题
  97 +
  98 +1. Phase 1a 的孤儿判定条件(`startedAt` 早于进程启动时间)在多进程/多副本部署下是否还成立?现在是单进程部署,这条先按当前架构写,多副本场景不在这次范围内。
  99 +2. Phase 3 的 `PATCH /api/company/:companyId` 端点如果本来就不存在,是新建这一个端点,还是这次顺手看一下公司配置类的改动有没有更合适的既有入口——落地前查一遍 `company.js` 现状再定,不在这里预判。
  100 +3. Phase 4 的调研如果发现有 10+ 个角色都在用旧的 `/task/transition`,工作量会明显超出"一个 phase"的量级,届时可能需要再拆细——先按"调研完再评估"处理,不在这里预先估工作量。
... ...
server/index.js
... ... @@ -18,6 +18,11 @@ const app = express();
18 18 const server = http.createServer(app);
19 19 const pluginManager = new PluginManager();
20 20  
  21 +// 42 号文档 Phase 1a:本次进程的启动时刻,早于它的 graph_run_nodes.status='running' 一律是
  22 +// 上一个进程实例留下的孤儿(这个新进程刚起,不可能自己已经有节点在跑)。模块顶层捕获一次,
  23 +// 不要在 sweepOrphanedRunningNodes 调用点现取——那样每次重试都会把截止线往后挪。
  24 +const PROCESS_STARTED_AT = new Date().toISOString();
  25 +
21 26 // ─── 中间件 ───
22 27 app.use(cors(config.cors));
23 28 app.use(cookieParser());
... ... @@ -160,6 +165,17 @@ async function start() {
160 165 const taskSettle = require('./services/task-settle');
161 166 taskSettle.resumePendingGoalValidations()
162 167 .catch(err => console.error('[Startup] 重推 goal 验收失败:', err.message));
  168 + // 42 号文档 Phase 1a:上一个进程实例被换代码重启杀掉时,正在跑的 graph_run_nodes
  169 + // 会留在 status='running'(背后进程已死,行本身不会自己变)——ceo 的两条唤醒路径都
  170 + // 只认 status='blocked',对这个状态完全不可见。开机先清一遍,早于下面几个「补一次
  171 + // 全量扫描」的钩子,让被它们观察的下游(task/goal 是不是卡住了)看到的是清好的状态。
  172 + try {
  173 + const engine = require('./services/graph-engine');
  174 + const { scanned, swept } = engine.sweepOrphanedRunningNodes(PROCESS_STARTED_AT);
  175 + console.log(`[Startup] 🧟 孤儿 graph 节点清扫:扫到 ${scanned} 条,处理 ${swept.length} 条`);
  176 + } catch (err) {
  177 + console.error('[Startup] 孤儿 graph 节点清扫失败:', err.message);
  178 + }
163 179 // T-mt622nil:上面那条只补「收敛钩子已经跑过、卡在验收/汇报半路」的。更前面那一步——
164 180 // 钩子压根没被触发过(进程重启 / 异常吞掉 / 事件丢失)——task 早到终态但 goal 永远停在
165 181 // active,只能靠人工 OKR 巡检才发现(e2e-demo 公司 G-mt2r62ia-73af 卡了 48 小时)。
... ...
server/routes/dream.js
1 1 /**
2   - * dream —— 经验汇流的 REST 入口([29-DREAM](../../docs/todo/system-design/29-dream-design.md))。
  2 + * dream —— 经验汇流的 REST 入口([29-DREAM](../../docs/done/system-design/29-dream-design.md))。
3 3 *
4 4 * 只做一件事:把 `dream-service.js` 已有的能力(`dreamOnce` / `dreamSweep` /
5 5 * `auditDistilled` / `definitionWritability`)从「只能靠脚本或定时器触发」
... ...
server/routes/graph-run.js
... ... @@ -377,6 +377,40 @@ router.post(&#39;/:id/nodes/:nodeId/retry&#39;, h((req, res) =&gt; {
377 377 }));
378 378  
379 379 /**
  380 + * POST /api/graph-runs/:id/nodes/:nodeId/force-fail — 「强制判失败」(42 号文档 Phase 1b)
  381 + * body: { failReason?: string }(默认 'operator_intervention',刻意不是 'infra_flake'——
  382 + * 那个值有特殊语义:原地重跑、不烧计数,人工判定不该被误当成一次性抖动)
  383 + * 可用条件:节点 status = 'running'
  384 + *
  385 + * `retry` 端点管不到 running 的节点(那是它自己的既有约定,不是这次要改的)——这个端点补的
  386 + * 正是那个缺口:人类或 ceo 判断"这个节点已经不可能再自己完成了"时的手动出口。转 fail 之后
  387 + * 交给引擎既有的失败路由(selectFailEdge/handleBackwardEdge),不发明新路径。
  388 + */
  389 +router.post('/:id/nodes/:nodeId/force-fail', h((req, res) => {
  390 + const run = loadRun(req, res);
  391 + if (!run) return;
  392 +
  393 + const { nodeId } = req.params;
  394 + let nodeRun = getRunNode(nodeId);
  395 + if (!nodeRun || nodeRun.run_id !== run.id) nodeRun = getRunNodeByDefId(run.id, nodeId);
  396 + if (!nodeRun) {
  397 + return res.status(404).json({ error: `节点 ${nodeId} 不属于 run ${run.id}` });
  398 + }
  399 +
  400 + if (nodeRun.status !== 'running') {
  401 + return res.status(400).json({
  402 + error: `「强制判失败」在节点 status='${nodeRun.status}' 下不可用,仅当节点 status = 'running' 时可用(fail/blocked 请用 retry 端点)`,
  403 + op: 'force-fail',
  404 + nodeStatus: nodeRun.status,
  405 + allowed: ['running'],
  406 + });
  407 + }
  408 +
  409 + const { failReason } = req.body || {};
  410 + res.json(engine.forceFailNode(run.id, nodeRun.id, failReason || undefined));
  411 +}));
  412 +
  413 +/**
380 414 * POST /api/graph-runs/:id/budget — 「追加预算」(运维操作,不在 §四.C 五个操作之列)
381 415 * body: { total_agent_calls?, total_tokens?, wallclock_ms?, resume?: boolean }
382 416 * 可用条件:run_status = 'blocked'
... ...
server/services/dream-service.js
1 1 /**
2   - * dream —— 经验汇流([29-DREAM](../../docs/todo/system-design/29-dream-design.md))。
  2 + * dream —— 经验汇流([29-DREAM](../../docs/done/system-design/29-dream-design.md))。
3 3 *
4 4 * 定时把**所有 agent 在所有公司**的原始记录提炼成不含公司特定信息的经验,
5 5 * 沉淀到本体上,让经验跨公司汇流而不泄漏任何一家的具体信息。
... ...
server/services/graph-engine.js
... ... @@ -2905,6 +2905,53 @@ function stopWallclockWatchdog() {
2905 2905 }
2906 2906  
2907 2907 /**
  2908 + * 42 号文档 Phase 1a:开机孤儿节点清扫。
  2909 + *
  2910 + * `graph_run_nodes.status='running'` 这一行不代表背后真的有个活的进程——它只是「上次问到的
  2911 + * 状态」。服务器重启(比如换代码生效)会把正在跑的 Claude CLI 子进程连带杀掉,但这一行留在
  2912 + * `running`,永远不会自己变成 `blocked`:`ceo` 的两条唤醒路径(事件驱动 + 30 分钟兜底扫描)
  2913 + * 判据都只认 `status='blocked'`,对这个状态完全不可见(39 号文档 §七.1 真实撞出来的)。
  2914 + *
  2915 + * 不发明新状态:这本质上跟 `infra_flake`(基础设施打断,跟设计/实现对不对无关)是同一类事,
  2916 + * 服务器重启只是这个桶里更严重的一个成员——原地重跑的熔断线(`INFRA_FLAKE_HARD_CAP`)现成能用。
  2917 + * 复用 `routeFailure` 的 infra_flake 分支,不另起一套判定/熔断逻辑;调用形状照抄 `advanceOne`
  2918 + * 里「先 `transitionNode` 落 fail_reason,再取新鲜的 `nodeRun`/`def` 传给 `routeFailure`」那一段。
  2919 + *
  2920 + * @param {string} cutoffIso 判定孤儿的时间线——`started_at` 早于这个时刻的 running 节点判定为
  2921 + * 孤儿(本次进程启动前就已经在跑,不可能是本进程自己起的)。调用方传「本次进程启动时间」;
  2922 + * 测试用任意时刻构造场景。
  2923 + * @returns {{scanned:number, swept:Array<{runId:string, nodeRunId:string, infraRerun:boolean}>}}
  2924 + */
  2925 +function sweepOrphanedRunningNodes(cutoffIso) {
  2926 + const rows = getDb()
  2927 + .prepare(`SELECT id, run_id FROM graph_run_nodes WHERE status = 'running' AND started_at IS NOT NULL AND started_at < ?`)
  2928 + .all(cutoffIso);
  2929 +
  2930 + const swept = [];
  2931 + for (const row of rows) {
  2932 + try {
  2933 + const run = getRun(row.run_id);
  2934 + if (!run) continue; // run 行本身没了(不该发生,防御性跳过)
  2935 + const def = defIndex(run);
  2936 +
  2937 + const changed = sse.transitionNode(row.run_id, row.id, 'running', 'fail', {
  2938 + failReason: INFRA_FLAKE_REASON,
  2939 + failNodeRef: null, outputRef: null, agentRunId: null, childRunId: null, completedAt: now(),
  2940 + }, { summary: `开机孤儿节点清扫:服务器重启前处于 running,背后进程已不存在,判 infra_flake 交给既有熔断/重跑逻辑` });
  2941 + if (!changed) continue; // 0 行 = 已经被别的路径结算过了,不重复处理
  2942 +
  2943 + const nodeRun = getRunNode(row.id);
  2944 + const out = { activated: [], backwardEdgeId: null, loop: null, contractDiff: null, crossLayer: null, reset: [], infraRerun: false };
  2945 + routeFailure(row.run_id, def, nodeRun, out, null);
  2946 + swept.push({ runId: row.run_id, nodeRunId: row.id, infraRerun: out.infraRerun });
  2947 + } catch (err) {
  2948 + console.error(`[graph-engine] sweepOrphanedRunningNodes node=${row.id} 异常: ${err.message}`);
  2949 + }
  2950 + }
  2951 + return { scanned: rows.length, swept };
  2952 +}
  2953 +
  2954 +/**
2908 2955 * 扣减(「只增不减」,§四.A.3)。始终写 root run 那一行。
2909 2956 * ponytail: 读-改-写不是原子的,当前单进程串行驱动(runAuto 一次只推一个节点)下够用;
2910 2957 * 升级路径是把自增下沉成一条 `UPDATE ... SET budget_json = json_set(...)` 的 SQL。
... ... @@ -3439,6 +3486,52 @@ function retryNode(runId, nodeRunId, { reopenRun = true, remediationNote = null
3439 3486 };
3440 3487 }
3441 3488  
  3489 +/**
  3490 + * 42 号文档 Phase 1b:「强制判失败以便恢复」。
  3491 + *
  3492 + * `retryNode` 只接受 `fail`/`blocked` 的节点——但真实运维里会遇到一种它管不到的情形:节点
  3493 + * `status='running'`,人(或 `ceo`)判断它已经不可能再自己完成了(比如背后进程确实卡死,
  3494 + * 不是 Phase 1a 那种"重启后一定是孤儿",而是进程本身还在但没有任何进展)。在这条修复之前,
  3495 + * 唯一的恢复手段是直接调 `graph-run-sse.transitionNode` 这个内部函数——对人类操作者或 `ceo`
  3496 + * 都不现实,两者都只能走 HTTP。
  3497 + *
  3498 + * 语义:"我确认这个节点救不回来了,把它判 fail,交给既有的 fail→retry/backward-edge 路径
  3499 + * 处理"——不发明新的路由逻辑,`routeFailure` 该怎么选边还是怎么选边(默认原因不是
  3500 + * `infra_flake`,所以不会走原地重跑那条特判,是走 `selectFailEdge`/`handleBackwardEdge`
  3501 + * 的常规路径)。
  3502 + *
  3503 + * @param {string} runId
  3504 + * @param {string} nodeRunId
  3505 + * @param {string} [failReason='operator_intervention'] 默认值刻意不是 `infra_flake`——那个
  3506 + * 值有特殊语义(原地重跑、不烧计数),人工判定"这个真的救不回来了"不该被误当成一次性抖动。
  3507 + * @returns {{ok:boolean, reason?:string, nodeStatus?:string, runId?:string, nodeRunId?:string}}
  3508 + */
  3509 +function forceFailNode(runId, nodeRunId, failReason = 'operator_intervention') {
  3510 + const nodeRun = getRunNode(nodeRunId);
  3511 + if (!nodeRun || nodeRun.run_id !== runId) throw new Error(`节点不属于该 run: ${nodeRunId}`);
  3512 + if (nodeRun.status !== 'running') {
  3513 + return { ok: false, reason: 'not-force-failable', nodeStatus: nodeRun.status };
  3514 + }
  3515 +
  3516 + const changed = sse.transitionNode(runId, nodeRunId, 'running', 'fail', {
  3517 + failReason,
  3518 + failNodeRef: null, outputRef: null, agentRunId: null, childRunId: null, completedAt: now(),
  3519 + }, { summary: `人工「强制判失败」:running → fail(failReason=${failReason}),交给既有失败路由处理` });
  3520 + if (!changed) return { ok: false, reason: 'taken-by-other', nodeStatus: (getRunNode(nodeRunId) || {}).status };
  3521 +
  3522 + const run = getRun(runId);
  3523 + const def = defIndex(run);
  3524 + const fresh = getRunNode(nodeRunId);
  3525 + const out = { activated: [], backwardEdgeId: null, loop: null, contractDiff: null, crossLayer: null, reset: [], infraRerun: false };
  3526 + routeFailure(runId, def, fresh, out, null);
  3527 +
  3528 + return {
  3529 + ok: true, runId, nodeRunId,
  3530 + nodeStatus: (getRunNode(nodeRunId) || {}).status,
  3531 + runStatus: (getRun(runId) || {}).run_status,
  3532 + };
  3533 +}
  3534 +
3442 3535 // ────────────────────────────────────────────────────────────────
3443 3536 // §四.C 末段:重试后级联下游的判据 = contract_hash 变没变
3444 3537 // ────────────────────────────────────────────────────────────────
... ... @@ -4775,6 +4868,9 @@ module.exports = {
4775 4868 descendantRuns,
4776 4869 landPause,
4777 4870  
  4871 + // 42 号文档 Phase 1b:running 节点的人工/ceo 强制判失败出口(retryNode 管不到 running)
  4872 + forceFailNode,
  4873 +
4778 4874 // 任务级总预算(§四.A.3)
4779 4875 checkBudget,
4780 4876 spendBudget,
... ... @@ -4793,6 +4889,9 @@ module.exports = {
4793 4889 startWallclockWatchdog,
4794 4890 stopWallclockWatchdog,
4795 4891  
  4892 + // 42 号文档 Phase 1a:开机孤儿节点清扫(服务器重启杀掉 in-flight 进程,节点行留在 running)
  4893 + sweepOrphanedRunningNodes,
  4894 +
4796 4895 // 重试级联判据(§四.C 末段)
4797 4896 diffContracts,
4798 4897 applyRetryCascade,
... ...
server/tests/unit/dream-routes.test.js
1 1 /**
2   - * dream 的 REST 入口(29-DREAM,见 docs/todo/system-design/29-dream-design.md)。
  2 + * dream 的 REST 入口(29-DREAM,见 docs/done/system-design/29-dream-design.md)。
3 3 *
4 4 * 路由本身没有业务逻辑 —— 全部委托给已经测过的 `dream-service.js`。
5 5 * 这份只验两件路由层独有的东西:
... ...
server/tests/unit/graph-engine-orphan-sweep.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — graph-engine 的开机孤儿节点清扫(42 号文档 Phase 1a)
  3 + *
  4 + * 场景:服务器进程被重启(比如换代码生效),正在跑某个 work graph 节点的真实 Claude CLI
  5 + * 子进程被连带杀掉,但 `graph_run_nodes.status` 永远停在 'running'——不会自己变成 'blocked',
  6 + * ceo 的两条唤醒路径都只认 'blocked',对这个状态完全不可见。
  7 + *
  8 + * `sweepOrphanedRunningNodes(cutoffIso)` 扫一遍 `started_at` 早于 cutoff 的 running 节点,
  9 + * 判定为孤儿,走 infra_flake 既有的原地重跑/熔断逻辑(不发明新状态)。
  10 + *
  11 + * 隔离手法与 graph-engine.test.js 一致:独立 GRAPH_DB_PATH + GRAPH_RUNS_DIR,真实
  12 + * data/graph.sqlite / data/companies/ 全程不被碰到。
  13 + */
  14 +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
  15 +import fs from 'fs';
  16 +import os from 'os';
  17 +import path from 'path';
  18 +
  19 +describe('graph-engine 开机孤儿节点清扫', () => {
  20 + let wdb;
  21 + let engine;
  22 + let runsDir;
  23 + const TEST_DB_PATH = '/tmp/test-graph-engine-orphan-sweep.sqlite';
  24 +
  25 + beforeAll(() => {
  26 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  27 + try { fs.unlinkSync(f); } catch {}
  28 + }
  29 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  30 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-orphan-sweep-'));
  31 + process.env.GRAPH_RUNS_DIR = runsDir;
  32 + for (const m of [
  33 + '../../services/graph-db',
  34 + '../../services/graph-service',
  35 + '../../services/org-graph-service',
  36 + '../../services/work-graph-db',
  37 + '../../services/graph-run-sse',
  38 + '../../services/graph-engine',
  39 + ]) {
  40 + delete require.cache[require.resolve(m)];
  41 + }
  42 + wdb = require('../../services/work-graph-db');
  43 + engine = require('../../services/graph-engine');
  44 + });
  45 +
  46 + afterAll(() => {
  47 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  48 + try { fs.unlinkSync(f); } catch {}
  49 + }
  50 + delete process.env.GRAPH_DB_PATH;
  51 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
  52 + delete process.env.GRAPH_RUNS_DIR;
  53 + });
  54 +
  55 + beforeEach(() => {
  56 + const db = wdb.getDb();
  57 + db.pragma('foreign_keys = OFF');
  58 + db.exec(`
  59 + DELETE FROM graph_run_loop_records;
  60 + DELETE FROM graph_run_nodes;
  61 + DELETE FROM graph_runs;
  62 + DELETE FROM work_graph_edges;
  63 + DELETE FROM work_graph_nodes;
  64 + DELETE FROM work_graphs;
  65 + DELETE FROM system_agents;
  66 + `);
  67 + db.pragma('foreign_keys = ON');
  68 + engine.setAgentExecutor(null);
  69 + });
  70 +
  71 + let seq = 0;
  72 + const nextCompany = () => `co-orphan-${++seq}`;
  73 +
  74 + function ensureAgents(ids) {
  75 + const db = wdb.getDb();
  76 + const now = new Date().toISOString();
  77 + const ins = db.prepare(`
  78 + INSERT OR IGNORE INTO system_agents
  79 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  80 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
  81 + `);
  82 + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
  83 + }
  84 +
  85 + function buildWorkRun({ companyId, nodes, edges }) {
  86 + ensureAgents(nodes);
  87 + const g = wdb.createWorkGraph({ companyId, nodes, edges });
  88 + expect(g.errors || []).toEqual([]);
  89 + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' });
  90 + engine.markRunReady(run.id);
  91 + return { runId: run.id };
  92 + }
  93 +
  94 + const nodeByName = (runId, name) => wdb.getRunState(runId).nodes.find((n) => n.name === name);
  95 +
  96 + /** 把某个节点直接摁成「孤儿」的静止态:status=running + 指定的 started_at,不走任何转换。
  97 + * 这就是真实场景本身——进程被杀时节点行不会经过任何转换,就是停在这个样子。 */
  98 + function markAsOrphan(runId, nodeName, startedAtIso) {
  99 + const node = nodeByName(runId, nodeName);
  100 + wdb.getDb()
  101 + .prepare(`UPDATE graph_run_nodes SET status = 'running', started_at = ?, updated_at = ? WHERE id = ?`)
  102 + .run(startedAtIso, startedAtIso, node.id);
  103 + return node.id;
  104 + }
  105 +
  106 + it('started_at 早于 cutoff 的 running 节点被判定为孤儿,走 infra_flake 原地重跑', () => {
  107 + const companyId = nextCompany();
  108 + const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] });
  109 + markAsOrphan(runId, 'w', '2020-01-01T00:00:00.000Z');
  110 +
  111 + const result = engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
  112 +
  113 + expect(result.scanned).toBe(1);
  114 + expect(result.swept).toHaveLength(1);
  115 + expect(result.swept[0]).toMatchObject({ runId, infraRerun: true });
  116 + // infra_flake 的原地重跑:转回 ready,不烧 attempt(§四.D.2.1 末段既有语义)
  117 + const fresh = nodeByName(runId, 'w');
  118 + expect(fresh.status).toBe('ready');
  119 + expect(fresh.attempt).toBe(0);
  120 + // 不写回退记录、不加 run 级 backward_count —— infra_flake 分支的既有保证,孤儿清扫复用
  121 + // 的是同一条分支,这两条也该原样成立
  122 + expect(wdb.listLoopRecords(runId)).toHaveLength(0);
  123 + expect(wdb.getRun(runId).backward_count).toBe(0);
  124 + });
  125 +
  126 + it('started_at 晚于 cutoff 的 running 节点不受影响(真的还在跑,不是孤儿)', () => {
  127 + const companyId = nextCompany();
  128 + const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] });
  129 + markAsOrphan(runId, 'w', '2026-06-01T00:00:00.000Z');
  130 +
  131 + const result = engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
  132 +
  133 + expect(result.scanned).toBe(0);
  134 + expect(result.swept).toHaveLength(0);
  135 + expect(nodeByName(runId, 'w').status).toBe('running');
  136 + });
  137 +
  138 + it('连续判定为孤儿超过 infra_flake 硬顶就转 blocked,不会无限原地重跑', () => {
  139 + const companyId = nextCompany();
  140 + const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] });
  141 +
  142 + // 连续三次「摁成孤儿再扫」,模拟每次重启后又立刻被杀的极端情形
  143 + for (let i = 0; i < 3; i++) {
  144 + markAsOrphan(runId, 'w', '2020-01-01T00:00:00.000Z');
  145 + engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
  146 + }
  147 + // 第四次撞硬顶(INFRA_FLAKE_HARD_CAP = 3),应改判 blocked 而不是继续 ready
  148 + markAsOrphan(runId, 'w', '2020-01-01T00:00:00.000Z');
  149 + engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
  150 +
  151 + expect(nodeByName(runId, 'w').status).toBe('blocked');
  152 + });
  153 +
  154 + it('没有任何孤儿节点时是安静的 no-op', () => {
  155 + const companyId = nextCompany();
  156 + buildWorkRun({ companyId, nodes: ['w'], edges: [] }); // 节点是 ready,不是 running
  157 + const result = engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
  158 + expect(result).toEqual({ scanned: 0, swept: [] });
  159 + });
  160 +});
... ...
server/tests/unit/graph-run-force-fail.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — POST /api/graph-runs/:id/nodes/:nodeId/force-fail(42 号文档 Phase 1b)
  3 + *
  4 + * `retry` 端点只接受 status ∈ {fail, blocked} 的节点,管不到 'running'——真实运维/ceo 遇到
  5 + * "这个节点已经不可能再自己完成了"这种情形,此前唯一的恢复手段是直接调内部函数,绕过了
  6 + * 任何 REST 接口。这条测试钉住新端点的可用条件与转移语义,不重复测 routeFailure 内部选边
  7 + * 逻辑(那部分已经在 graph-engine.test.js / graph-engine-orphan-sweep.test.js 里覆盖过)。
  8 + *
  9 + * 走真入口:把 router 挂到真 express 上 listen(0) 再 fetch,与 graph-run-route-io.test.js 同法。
  10 + */
  11 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  12 +import fs from 'fs';
  13 +import os from 'os';
  14 +import path from 'path';
  15 +import express from 'express';
  16 +
  17 +const TEST_DB_PATH = '/tmp/test-graph-run-force-fail.sqlite';
  18 +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
  19 +const MODULES = [
  20 + '../../services/graph-db',
  21 + '../../services/graph-service',
  22 + '../../services/org-graph-service',
  23 + '../../services/work-graph-db',
  24 + '../../services/company-paths',
  25 + '../../services/graph-run-sse',
  26 + '../../services/graph-engine',
  27 + '../../routes/graph-run',
  28 +];
  29 +
  30 +describe('POST /api/graph-runs/:id/nodes/:nodeId/force-fail', () => {
  31 + let wdb;
  32 + let srv;
  33 + let base;
  34 + let runsDir;
  35 + let seq = 0;
  36 + // 每个用例一个独立 company:一家公司同时只有一条活跃 root run(run-slot 约束),
  37 + // 同一个 company 起第二条 run 会撞 RunSlotBusyError。
  38 + const nextCompany = () => `co-force-fail-${++seq}`;
  39 +
  40 + beforeAll(async () => {
  41 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
  42 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  43 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'force-fail-runs-'));
  44 + process.env.GRAPH_RUNS_DIR = runsDir;
  45 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  46 + wdb = require('../../services/work-graph-db');
  47 +
  48 + const app = express();
  49 + app.use(express.json());
  50 + app.use('/api/graph-runs', require('../../routes/graph-run'));
  51 + await new Promise((resolve) => {
  52 + srv = app.listen(0, '127.0.0.1', () => {
  53 + base = `http://127.0.0.1:${srv.address().port}`;
  54 + resolve();
  55 + });
  56 + });
  57 + });
  58 +
  59 + afterAll(async () => {
  60 + if (srv) await new Promise((r) => srv.close(r));
  61 + const { removeTestCompany } = require('../helpers/ensure-test-company');
  62 + for (let i = 1; i <= seq; i++) removeTestCompany(`co-force-fail-${i}`);
  63 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
  64 + delete process.env.GRAPH_DB_PATH;
  65 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
  66 + delete process.env.GRAPH_RUNS_DIR;
  67 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  68 + });
  69 +
  70 + function ensureAgents(ids) {
  71 + const db = wdb.getDb();
  72 + const now = new Date().toISOString();
  73 + const ins = db.prepare(`
  74 + INSERT OR IGNORE INTO system_agents
  75 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  76 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
  77 + `);
  78 + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
  79 + }
  80 +
  81 + /** 建一条只有一个节点的 work run,直接把节点摁成指定状态(不关心怎么走到这个状态,
  82 + * 只关心端点在这个状态下的行为——执行语义已经在别的测试文件覆盖过)。 */
  83 + function buildRunWithNodeStatus(status) {
  84 + const companyId = nextCompany();
  85 + require('../helpers/ensure-test-company').ensureTestCompany(companyId);
  86 + ensureAgents(['w']);
  87 + const g = wdb.createWorkGraph({ companyId, nodes: ['w'], edges: [] });
  88 + expect(g.errors || []).toEqual([]);
  89 + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' });
  90 + require('../../services/graph-engine').markRunReady(run.id);
  91 + const node = wdb.getRunState(run.id).nodes[0];
  92 + const now = new Date().toISOString();
  93 + wdb.getDb()
  94 + .prepare(`UPDATE graph_run_nodes SET status = ?, started_at = ?, updated_at = ? WHERE id = ?`)
  95 + .run(status, now, now, node.id);
  96 + return { runId: run.id, nodeRunId: node.id };
  97 + }
  98 +
  99 + it('running 节点调用成功,转 fail 后交给既有失败路由(无边可走 → blocked,可被 retry 接上)', async () => {
  100 + const { runId, nodeRunId } = buildRunWithNodeStatus('running');
  101 + const r = await fetch(`${base}/api/graph-runs/${runId}/nodes/${nodeRunId}/force-fail`, {
  102 + method: 'POST',
  103 + headers: { 'Content-Type': 'application/json' },
  104 + body: JSON.stringify({}),
  105 + });
  106 + expect(r.status).toBe(200);
  107 + const body = await r.json();
  108 + expect(body.ok).toBe(true);
  109 + // 单节点图没有 fail 边可走 → blockNode,落在人工可 retry 的终点
  110 + expect(body.nodeStatus).toBe('blocked');
  111 +
  112 + // 验证确实接得上 retry 端点(这才是「可被 retry 端点接上」这条验收的真实含义)
  113 + const retryRes = await fetch(`${base}/api/graph-runs/${runId}/nodes/${nodeRunId}/retry`, { method: 'POST' });
  114 + expect(retryRes.status).toBe(200);
  115 + const retryBody = await retryRes.json();
  116 + expect(retryBody.ok).toBe(true);
  117 + expect(retryBody.nodeStatus).toBe('ready');
  118 + });
  119 +
  120 + it('自定义 failReason 会原样落进 fail_reason(走常规选边,不是 infra_flake 特判)', async () => {
  121 + const { runId, nodeRunId } = buildRunWithNodeStatus('running');
  122 + const r = await fetch(`${base}/api/graph-runs/${runId}/nodes/${nodeRunId}/force-fail`, {
  123 + method: 'POST',
  124 + headers: { 'Content-Type': 'application/json' },
  125 + body: JSON.stringify({ failReason: 'stuck_no_progress' }),
  126 + });
  127 + expect(r.status).toBe(200);
  128 + // 单节点图没有 fail 边,无论什么 fail_reason 都落 blocked——这里只确认端点把自定义原因
  129 + // 传下去了没炸,选边逻辑本身已经在别处测过
  130 + const body = await r.json();
  131 + expect(body.ok).toBe(true);
  132 + expect(body.nodeStatus).toBe('blocked');
  133 + });
  134 +
  135 + for (const status of ['fail', 'blocked', 'pending', 'ready', 'success']) {
  136 + it(`节点 status='${status}' 时调用返回 400(不是 running 就不该走这个端点)`, async () => {
  137 + const { runId, nodeRunId } = buildRunWithNodeStatus(status);
  138 + const r = await fetch(`${base}/api/graph-runs/${runId}/nodes/${nodeRunId}/force-fail`, {
  139 + method: 'POST',
  140 + headers: { 'Content-Type': 'application/json' },
  141 + body: JSON.stringify({}),
  142 + });
  143 + expect(r.status).toBe(400);
  144 + const body = await r.json();
  145 + expect(body.op).toBe('force-fail');
  146 + expect(body.nodeStatus).toBe(status);
  147 + expect(body.allowed).toEqual(['running']);
  148 + });
  149 + }
  150 +
  151 + it('节点不存在返回 404', async () => {
  152 + const { runId } = buildRunWithNodeStatus('running');
  153 + const r = await fetch(`${base}/api/graph-runs/${runId}/nodes/nope/force-fail`, {
  154 + method: 'POST',
  155 + headers: { 'Content-Type': 'application/json' },
  156 + body: JSON.stringify({}),
  157 + });
  158 + expect(r.status).toBe(404);
  159 + });
  160 +});
... ...
system/agents/dream-distiller/agent.md
... ... @@ -3,7 +3,7 @@
3 3 ## 你是谁
4 4  
5 5 你是 **dream** 这套「经验汇流」机制里的**提炼**一环,本体名 `dream-distiller`。
6   -设计见 [29-DREAM](../../../../../docs/todo/system-design/29-dream-design.md)。
  6 +设计见 [29-DREAM](../../../../../docs/done/system-design/29-dream-design.md)。
7 7  
8 8 dream 每天跑一次,把**某一个 agent 在所有公司**的原始记录整理成
9 9 **不含任何公司特定信息**的可复用经验,沉淀到那个 agent 的本体上。
... ...
system/agents/dream-screener/agent.md
... ... @@ -3,7 +3,7 @@
3 3 ## 你是谁
4 4  
5 5 你是 **dream** 这套「经验汇流」机制里的**闸门**,本体名 `dream-screener`。
6   -设计见 [29-DREAM](../../../../../docs/todo/system-design/29-dream-design.md) 决策 A。
  6 +设计见 [29-DREAM](../../../../../docs/done/system-design/29-dream-design.md) 决策 A。
7 7  
8 8 `dream-distiller` 把某个 agent 在各家公司的原始记录提炼成一条条「经验」,
9 9 你逐条判断:**这条能不能给别家公司看。**
... ...
web/src/components/WorkGraphCanvas.vue
... ... @@ -214,10 +214,12 @@ const waves = computed(() =&gt; {
214 214 for (const id of wave) remaining.delete(id);
215 215 out.push(wave);
216 216 }
217   - if (remaining.size) out.push([...remaining]); // 兜底:环里的节点单独摆一列,别让它们消失
  217 + if (remaining.size) out.push([...remaining]); // 兜底:环里的节点单独摆一行,别让它们消失
218 218 return out;
219 219 });
220 220  
  221 +// 布局方向:wave(依赖深度)走 y 轴自上而下,同波内的并行节点走 x 轴左右排开——
  222 +// 节点一多,从左到右排会把屏幕撑成很宽的 4 栏,改成自上而下更贴合竖直滚动的画布容器。
221 223 const laidOut = computed(() => {
222 224 const byId = new Map(props.nodes.map((n) => [String(n.defNodeId), n]));
223 225 const out = [];
... ... @@ -231,8 +233,8 @@ const laidOut = computed(() =&gt; {
231 233 status: n.status || 'pending',
232 234 nodeKind: n.nodeKind,
233 235 attempt: n.attempt || 0,
234   - x: PAD + wi * (NW + GAP_X),
235   - y: PAD + ni * (NH + GAP_Y),
  236 + x: PAD + ni * (NW + GAP_X),
  237 + y: PAD + wi * (NH + GAP_Y),
236 238 });
237 239 });
238 240 });
... ... @@ -255,16 +257,16 @@ const forwardEdges = computed(() =&gt;
255 257 + (cond === 'fail' ? `|fail_reason=${e.failReason || '(兜底边:任何未匹配的失败)'}` : '')
256 258 // 这条边传的是什么(graph_edge_contracts)。悬停在线上就能看全,不占布局
257 259 + (c ? `|数据契约 ${c.title}(? = 可选)` : ''),
258   - x1: a.x + NW, y1: a.y + NH / 2,
259   - x2: b.x, y2: b.y + NH / 2,
  260 + x1: a.x + NW / 2, y1: a.y + NH,
  261 + x2: b.x + NW / 2, y2: b.y,
260 262 contract: c,
261 263 }];
262 264 }),
263 265 );
264 266  
265 267 /**
266   - * 契约药丸的位置 = 边的中点(两列之间的空白带),宽度按字数估。
267   - * kind 名最长按 7 字符截断(`acceptance` → `accepta…`):GAP_X 只有 52px,
  268 + * 契约药丸的位置 = 边的中点(两行之间的空白带),宽度按字数估。
  269 + * kind 名最长按 7 字符截断(`acceptance` → `accepta…`):药丸宽度封顶到节点列宽,
268 270 * 不截会盖到相邻节点上——完整名字在 <title> 里。
269 271 */
270 272 const contractPills = computed(() =>
... ... @@ -278,7 +280,8 @@ const contractPills = computed(() =&gt;
278 280 key: e.key,
279 281 x: (e.x1 + e.x2) / 2,
280 282 y: (e.y1 + e.y2) / 2,
281   - w: Math.min(GAP_X + 10, text.length * 5.9 + 12),
  283 + // 边现在竖着走(同列上下相邻),药丸宽度受节点列宽约束,不再是 GAP_X
  284 + w: Math.min(NW - 16, text.length * 5.9 + 12),
282 285 text,
283 286 title: `数据契约:${e.contract.title}(? = 可选输入,缺了不算错)`,
284 287 optionalOnly: e.contract.optionalOnly,
... ... @@ -290,12 +293,12 @@ const contractPills = computed(() =&gt;
290 293 * backward 边的曲线路由(§八.5 点名的「真实手写前端工程量」)。
291 294 *
292 295 * 重叠问题拆成两半,各用一招解决,不引图形库:
293   - * ① 与节点重叠 —— 回退边一律走**节点带下方**的空白区,起终点都取节点底边中点,
294   - * 三次贝塞尔两个控制点都压在同一条泳道高度上 → 曲线永远不穿过任何节点矩形;
295   - * ② 与其它回退边重叠 —— 按 x 区间做贪心分道(区间重叠的边必然分到不同泳道),
296   - * 泳道纵向拉开 LANE_H,画布高度随泳道数增长。区间不重叠的边共用一道,不浪费高度。
297   - * 代价:泳道数 = 最大同时重叠的回退边数,多到十几条时画布会明显变高;
298   - * 升级路径是改成上下双侧分流(奇偶道走节点带上方),本版不做。
  296 + * ① 与节点重叠 —— 回退边一律走**节点带右侧**的空白区,起终点都取节点右边中点,
  297 + * 三次贝塞尔两个控制点都压在同一条泳道横坐标上 → 曲线永远不穿过任何节点矩形;
  298 + * ② 与其它回退边重叠 —— 按 y 区间做贪心分道(区间重叠的边必然分到不同泳道),
  299 + * 泳道横向拉开 LANE_H,画布宽度随泳道数增长。区间不重叠的边共用一道,不浪费宽度。
  300 + * 代价:泳道数 = 最大同时重叠的回退边数,多到十几条时画布会明显变宽;
  301 + * 升级路径是改成左右双侧分流(奇偶道走节点带左侧),本版不做。
299 302 */
300 303 const backwardEdges = computed(() => {
301 304 const raw = [];
... ... @@ -304,13 +307,13 @@ const backwardEdges = computed(() =&gt; {
304 307 const a = posById.value.get(String(e.from));
305 308 const b = posById.value.get(String(e.to));
306 309 if (!a || !b) return;
307   - const sx = a.x + NW / 2, sy = a.y + NH;
308   - const tx = b.x + NW / 2, ty = b.y + NH;
  310 + const sx = a.x + NW, sy = a.y + NH / 2;
  311 + const tx = b.x + NW, ty = b.y + NH / 2;
309 312 raw.push({ key: e.id || `${e.from}-${e.to}-${i}`, edgeId: e.id, sx, sy, tx, ty,
310   - lo: Math.min(sx, tx), hi: Math.max(sx, tx) });
  313 + lo: Math.min(sy, ty), hi: Math.max(sy, ty) });
311 314 });
312 315  
313   - // 贪心分道:按区间左端排序,塞进第一条不与已占区间重叠的泳道
  316 + // 贪心分道:按区间上端排序,塞进第一条不与已占区间重叠的泳道
314 317 const lanes = [];
315 318 raw.sort((p, q) => p.lo - q.lo);
316 319 for (const e of raw) {
... ... @@ -321,13 +324,13 @@ const backwardEdges = computed(() =&gt; {
321 324 }
322 325  
323 326 return raw.map((e) => {
324   - const laneY = nodeBandH.value + PAD + (e.lane + 1) * LANE_H;
  327 + const laneX = nodeBandW.value + PAD + (e.lane + 1) * LANE_H;
325 328 return {
326 329 ...e,
327   - d: `M${e.sx},${e.sy} C${e.sx},${laneY} ${e.tx},${laneY} ${e.tx},${e.ty}`,
328   - // 三次贝塞尔 t=0.5 处的坐标(两个控制点同 y,纵向化简为 (sy+ty+6*laneY)/8)
329   - mx: (e.sx + e.tx) / 2,
330   - my: (e.sy + e.ty + 6 * laneY) / 8,
  330 + d: `M${e.sx},${e.sy} C${laneX},${e.sy} ${laneX},${e.ty} ${e.tx},${e.ty}`,
  331 + // 三次贝塞尔 t=0.5 处的坐标(两个控制点同 x,横向化简为 (sx+tx+6*laneX)/8)
  332 + mx: (e.sx + e.tx + 6 * laneX) / 8,
  333 + my: (e.sy + e.ty) / 2,
331 334 loopCount: latestLoopCount(e.edgeId),
332 335 };
333 336 });
... ... @@ -350,21 +353,21 @@ const anomalyNodes = computed(() =&gt; laidOut.value.filter((n) =&gt; n.status === &#39;bl
350 353 const anomalyBand = computed(() => {
351 354 if (!backwardEdges.value.length) return null;
352 355 return {
353   - x: PAD / 2,
354   - y: nodeBandH.value + PAD / 2,
355   - w: W.value - PAD,
356   - h: laneCount.value * LANE_H + LANE_H / 2,
  356 + x: nodeBandW.value + PAD / 2,
  357 + y: PAD / 2,
  358 + w: laneCount.value * LANE_H + LANE_H / 2,
  359 + h: H.value - PAD,
357 360 };
358 361 });
359 362  
360   -const nodeBandH = computed(() => {
  363 +const nodeBandW = computed(() => {
361 364 const maxRow = Math.max(...waves.value.map((w) => w.length), 1);
362   - return PAD + maxRow * (NH + GAP_Y) - GAP_Y;
  365 + return PAD + maxRow * (NW + GAP_X) - GAP_X;
363 366 });
364 367  
365   -const W = computed(() =>
366   - Math.max(320, PAD * 2 + Math.max(waves.value.length, 1) * (NW + GAP_X) - GAP_X));
367   -const H = computed(() => nodeBandH.value + PAD + laneCount.value * LANE_H + (laneCount.value ? LANE_H : 0));
  368 +const W = computed(() => nodeBandW.value + PAD + laneCount.value * LANE_H + (laneCount.value ? LANE_H : 0));
  369 +const H = computed(() =>
  370 + Math.max(320, PAD * 2 + Math.max(waves.value.length, 1) * (NH + GAP_Y) - GAP_Y));
368 371  
369 372 function trim(s) {
370 373 const str = String(s ?? '');
... ...