Commit a00590ad9451cf1f6137ea97dd3cbf9dd49b06e3

Authored by yibo
1 parent ec5a805c

feat(company): boss agent 任务看板监督闭环(P1/P3/P4 后端)

按 39 号设计文档落地 ceo 常驻监督:company.js 的 goals/tasks 端点
放行内部令牌自环调用,POST /tasks 支持 draft 模式(agent 自主提任务
不再立刻烧预算),ceo 补雇进公司创建流程;task-settle 新增事件驱动
唤醒 + 30 分钟兜底轮询;graph-engine 的回退熔断收敛时顺手回写一条
draft task,形成"卡住 → 自动/ceo 判断 → 重新规划"闭环。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwG4b7Nq1NLR9eeE3M9Us9
docs/README.md
... ... @@ -51,10 +51,12 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
51 51 | [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) |
52 52 | [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 交给人工 |
53 53 | [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 次,零人工介入),修复确认生效 |
  54 +| [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 地基 |
54 55 | [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 人设文档的自我认知改写 |
55 56 | [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(训练产物 → 雇佣本体转换)尚未开工 |
56 57 | [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:控制台「验收证据」小节展示每个维度有没有证据支撑 |
57 58 | [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) 那类"装配没填充完"的真实样本,不是平台工具限制 |
  59 +| [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` 修复但没有回归测试兜底 |
58 60 | [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 号文档的真实回归验证过程中,与兜底边问题无关 |
59 61  
60 62 ## todo/future/(暂缓,非当前重心)
... ...
docs/todo/system-design/39-boss-agent-task-supervision.md 0 → 100644
  1 +# 39. Boss Agent 任务看板监督 + 公司目标代码目录 — 需求评估与设计草案
  2 +
  3 +> **状态**:📝 设计评估中,未实现。本文档是对用户口述五阶段方案的落地可行性评估 + 修订后的设计草案,不是最终定案。
  4 +> **日期**:2026-08-30
  5 +> **背景**:承接对话讨论——goal/task 两级模型([27-P3C](../../done/system-design/27-p3c-goal-task-model.md))已落地,但推进它的是纯确定性代码(`task-scheduler.js` + `transitionRun` 钩子),没有任何 agent 在持续监督这块看板、处理异常、把执行中发现的新问题回写成新任务。用户提出五阶段方案补上这一层,并追加公司级目标代码目录需求。
  6 +> **读者须知**:本文档大量引用现有代码路径核实"已有 vs 缺失",结论以文内给出的具体函数/文件为准;这些引用会随代码演进过期,落地前请重新核实。
  7 +
  8 +---
  9 +
  10 +## 一、先说结论
  11 +
  12 +用户的五阶段里,**P2(任务看板)本质已完成,P1/P3 的基础设施已有大半,P4 是真正的新增核心,P5 的机制已经实现了 70%,只是没有暴露到公司层面。** 真正从零开始的工作量比五个"phase"暗示的要小得多,但顺序建议大改:**P4(回写闭环)应该提到最前面**,因为它才是这次讨论最初的诉求("自己发现问题、自己重新规划"),P1/P3 的 boss agent 只有在 P4 的工具/数据通路建好之后才有事可做。
  13 +
  14 +| 用户的 phase | 名义工作量 | 实际现状 | 建议 |
  15 +|---|---|---|---|
  16 +| P1 雇佣通用 boss agent | 新建一个角色 + 训练 | 心跳基础设施、`ceo` 本体、Innovator 训练方法论**全部已有** | 复用 `ceo`,不新建角色;工作量收窄成"写 heartbeat.md + 工具" |
  17 +| P2 任务看板独立页面 | 一个新系统 | goal/task 两级看板**已实现**(`GoalTaskPane.vue`),只是嵌在控制台里 | 降级成 UI 改造,非必须,可排最后 |
  18 +| P3 心跳挨个决定下一个 task | 新的调度逻辑 | 挨个执行**已经是** `task-scheduler.js` 在做,且做得更好(事件驱动、零成本、已测试) | **不要重建调度**;心跳的职责收窄为"异常判断",且应事件触发而非纯轮询 |
  19 +| P4 bug/新任务回写看板 | 一条新闭环 | **不存在**——没有任何 agent 可读写 goal/task 板的工具,四分类失败归因(30 号文档)目前只导向"重试或人工介入",不导向"开新任务" | **真正的核心工作,优先做** |
  20 +| P5 公司目标代码目录 | 新能力 | DevLoop 的 `path-resolver.js` 已实现 `sandbox` / `linked-new` / `linked-existing` 三种模式,**`linked-existing`(接管已有目录)已经是完整实现**,只是没有公司级默认值,也没打通到 org/work graph(非 DevLoop)路径 | 补一个公司级默认配置 + 核实非 DevLoop 路径是否需要同样机制 |
  21 +
  22 +---
  23 +
  24 +## 二、逐 phase 现状核实
  25 +
  26 +### P1:每个公司雇佣 boss agent,system agent 层训练通用版本
  27 +
  28 +**已有的基础设施**(全部实地核实过):
  29 +
  30 +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 分钟会话互斥防重叠。**这套机制现成可用,不用重建。**
  31 + - ✅ **修正一处之前的误判**:原以为 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` 的那份。
  32 +2. **本体已存在**:`system/agents/ceo/`(`type: "cxo"`,"Chief Executive Officer",`heartbeat.enabled: false` 默认关)。同时存在 `system/agents/master/`(`type: "master"`,**全局唯一**,`/api/master/chat`,负责建公司/招聘等跨公司操作)。两者定位不同:`master` 是平台层面的单例助手,`ceo` 才是"每个公司一个"的角色。
  33 +3. **训练方法论已验证**:`36-prd-agent-methodology-training-feasibility.md` 已经用 Innovator(Hunt→Digest→Evolve)训练出过一个方法论型 agent(`prd-methodology-trial`),2 轮收敛、96/97 分,方法论对这类"判断型"角色确实管用。可以照搬这条路径训练 `ceo` 的监督方法论,而不是手写一份 prompt 拍脑袋。
  34 +
  35 +**结论**:不建议新建"boss"角色。**复用 `system/agents/ceo/`**——它的岗位定义(CEO)本来就该管"任务有没有卡住、要不要重新分解",另起一个语义重叠的角色只会重犯这个仓库反复踩过的"名字歧义"坑(`27-naming-conventions.md` 通篇在防这个)。P1 的实际工作量是:① 给 `ceo` 补一份 `heartbeat.md`(监督规则);② 给它挂上 P4 需要的工具(见下);③ 默认 `heartbeat.enabled: true`(但见 P3 的成本讨论,建议先不无脑轮询);④ 可选:用 Innovator 训练它的监督方法论。
  36 +
  37 +**缺口 A**:目前**没有任何 agent 工具能读写 `goals`/`tasks` 表或操作 graph run**(搜了 `server/src/tools/` 全目录,零命中)。`ceo` 就算被唤醒也无事可做——这是 P1 能不能生效的真正前提,见 P4。
  38 +
  39 +**缺口 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` 实例在跑。
  40 +
  41 +---
  42 +
  43 +### P2:任务看板独立页面
  44 +
  45 +**已实现**([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` 系列。
  46 +
  47 +**用户要的"独立页面"** 目前是控制台的一个侧栏,不是路由级独立页面。这是纯 UI 改造,**不涉及数据模型或后端**,随时可做,风险最低。
  48 +
  49 +**"subtask" 层**:当前**没有独立的 subtask 表**,`task` 就是看板能管理的最细颗粒度。task 再往下是 `graph_runs`(work graph 节点,可能含嵌套子图),这层在中间的执行画布(`WorkGraphCanvas`)里看,不在左栏。
  50 +
  51 +> ⚠️ **不建议新增一张 subtask 表**去对应 work graph 节点。这个仓库已经因为"两套任务体系不统一"吃过至少两次苦头(P3C §十二"批 5"整章在处理这个债,`27-p3c-batch3-handover.md` 也记过一次事故)。如果要在看板上展示"这个 task 内部具体在跑哪几步",应该是**只读地把该 task 当前 `current_run_id` 对应的 `graph_run_nodes` 渲染成一份"子步骤列表"**,复用现有表和现有执行画布的数据源,不新建写路径。
  52 +
  53 +#### P2 决定:与 OKR 页面的关系(已拍板,2026-08-30)
  54 +
  55 +排查发现现状比预期乱:**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 模型完全不是一回事。
  56 +
  57 +**已拍板**:**不合并**,OKR 这套前端 UI**直接废弃**,新任务看板**另起一个独立页面**。具体动作:
  58 +
  59 +1. **新增路由**:仿照 `GraphConsole` 已经立好的先例(`/:id/graph-console` 是独立路由,不挤在 `CompanyDetail` 的 `:tab(...)` 白名单里),新增 `/:id/task-board`(命名待定)→ 新建 `web/src/views/TaskBoard.vue`。**不要塞进 `:tab(...)` 正则**,理由与 `graph-console` 当年的注释一致:任务看板信息密度高,值得一个整页。
  60 +2. **复用现有逻辑,不重写数据层**:`GoalTaskPane.vue` + `goal-task-model.js` 现在已经封装了 goal/task 的状态映射、排序、颜色语义(且有专门的单测 `goal-task-model.test.js` 兜底)。新页面应该是把这个组件从 `GraphConsole` 的侧栏布局里"拔出来"放大铺满整页,**不是重新实现一遍**。`GraphConsole` 侧栏可以保留一份精简版(点某个 task 仍需跳转去看它的执行图),也可以改成跳转链接指向新页面——两种都行,实现时再定。
  61 +3. **废弃 OKR 前端**:删 `/okr` 路由 + `OkrDashboard.vue`;删 `CompanyDashboard.vue` 里那个挂着 "goals" 名字实为 OKR 树的 tab;路由 `:tab(...)` 白名单里的 `goals`/`okr` 两个值一并摘掉,避免留死链接。
  62 +4. **导航入口**:新页面必须挂真实导航入口(`AppNav` 或公司详情页的显眼位置),不能重蹈 `/okr` 那种"孤儿页面"的覆辙。
  63 +5. **鉴权**:`okr.js` 当年挂 `requireAdmin` 是因为它是孤儿页面、没做归属校验的权宜之计(`38-auth-multi-tenant-isolation.md`),**不是"任务看板天生该管理员专属"**。新路由要按 `company.js` 等其他公司级路由的标准做法,走 `canAccessCompany` 归属校验,不能照抄 `requireAdmin`。
  64 +
  65 +**范围之外、本次不动**(留作独立的后续决定,不在这次 P2 改造里顺手做):`/api/okr/*` 后端路由、`okr` 相关 SQLite 表、`goals.okr_node_id` 这个可选外键字段——前端 UI 废弃不等于后端子系统也要一起拆,那是一次单独的、影响面更大的退役决定(牵涉数据迁移/存量数据处理),需要单独评估,这里先只处理前端。
  66 +
  67 +---
  68 +
  69 +### P3:心跳挨个决定下一个 task
  70 +
  71 +**这条按用户原话实现是在重建一个已经存在、已经测试、且更优的系统。** `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 端到端案例)。
  72 +
  73 +**成本账**:`heartbeat-service.js` 现在的实现是**每一次 tick 都真的发一次 LLM 调用**,只是 prompt 要求"没事就回复 `[心跳正常]`"——省不了那次调用的 token/延迟。如果 N 家公司、每家每 5 分钟一次心跳,24 小时就是 N×288 次 LLM 调用,绝大多数只会换回一句"心跳正常"。
  74 +
  75 +**修订建议**:boss agent 心跳**只负责调度器处理不了的判断题**,而且触发方式改成"事件优先、轮询兜底",不是纯定时轮询:
  76 +
  77 +1. 判断题清单(调度器管不了、需要 LLM 判断的):
  78 + - task 长时间 `blocked` 或 `failed`(预算耗尽/依赖死锁)——要不要重新分解、要不要升级给人类;
  79 + - 同一 goal 下多个 task 出现优先级冲突(业务变化导致原定顺序不再合理)——要不要调整 `priority`;
  80 + - `task-planning` 3 轮降级成单任务(P3C §四.9 的降级路径)——要不要人工介入重新审视这条 goal 的描述。
  81 +2. **优先事件驱动**:在 `transitionRun`/`settleGoal` 这类既有钩子里,遇到上述判断题触发的状态变化(比如 task 转 `blocked`)时,**主动**唤醒 `ceo` 处理这一条,而不是让它等下一次心跳才发现。心跳退化成**低频兜底**(比如 30 分钟一次,只扫"有没有钩子漏掉的异常"),不是主决策路径——这与现有 `task-scheduler.js` 头部注释里"兜底扫描周期"的设计哲学完全一致,照抄这个模式即可。
  82 +
  83 +---
  84 +
  85 +### P4:org/work graph 产生的新 bug / 新 task 回写看板,形成闭环
  86 +
  87 +**这是五个 phase 里唯一真正从零开始、也是这次讨论最初诉求("自己发现问题、自己重新规划")真正要落地的地方。**
  88 +
  89 +**现有的失败归因机制**(`30-task-delegation-reliability-gaps.md`):`BugTriager`/`validator-triager` 类节点已经在做四分类——`contract_mismatch` / `impl_bug` / `spec_ambiguous` / `infra_flake`——但**四条路径的终点目前都是"原地重试"或"预算耗尽后 blocked 等人工"**,没有一条走向"生成一条新 task/goal"。
  90 +
  91 +**需要新增的两个通路**:
  92 +
  93 +1. **确定性通路(不需要 agent 判断)**:把四分类里"显然该开新任务"的情形直接在 `routeFailure`(`graph-engine.js`)里接一条规则,而不是等 boss agent 心跳才处理——比如 `spec_ambiguous` 在 goal 级预算内重试超过阈值,直接生成一条 `status='draft'`、`created_by='system'`、挂在同一 `goal_id` 下的新 task,附上失败节点的诊断产物作为新 task 的 `description` 素材,人工/`ceo` 再审。这条不需要 LLM 判断"要不要开新任务",只需要判断"开一条什么样的"——后者才是可选的 agent 介入点。
  94 +2. **主观通路(需要 agent 判断,这是 P1 的 `ceo`/工具的用武之地)**:agent 在执行任务过程中发现的、**不在当前 task 描述范围内**的问题(比如 coder 顺手发现一个无关的历史 bug),需要一个**工具**让它"提一条新任务"而不是把不相关的修复硬塞进当前 task 的产物里。这是当前完全没有的能力。
  95 +
  96 +**必须先建的地基**——查完代码,发现比"包一层工具"更底层的东西还没有,得先排清楚三件事,顺序不能反:
  97 +
  98 +#### 地基 0(阻塞项):`ceo` 现在物理上调不通这套 REST API
  99 +
  100 +`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`(现在是空文件)。
  101 +
  102 +但更麻烦的是:`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 才有物理上调得通的可能。
  103 +
  104 +#### 地基 1:`POST /:companyId/tasks` 没有"草稿"模式,agent 提的任务会立刻烧预算
  105 +
  106 +现有 `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` 是持续在线判断)。
  107 +
  108 +**需要的改动很小**:给 `POST /:companyId/tasks` 加一个 `draft: true`(或 `status: 'draft'`)入参,为真时只调 `wdb.createTask()` 落库,跳过 `enqueueTaskRun`/`markGoalDispatched`/`startNextIfSlotFree` 三步,返回 `status: 'draft'`。已有的 `POST /:companyId/tasks/:taskId/enqueue`(`company.js:2275`,`draft → queued`)原样复用,不用新建。默认行为(不传这个参数)完全不变,纯加法,不影响存量调用方。
  109 +
  110 +#### 地基 2:`ceo` 的 `tools.md` 写什么
  111 +
  112 +在地基 0/1 落地后,照抄 `task-planning/tools.md` 的写法(先说"大概率不需要工具"、给出何时才用、给出 curl 示例、专辟一节"你不该用的工具"防止越权)给 `ceo` 写一份,覆盖:
  113 +
  114 +- **读**:`GET /:cid/goals?status=active`(扫哪些 goal 在跑)、`GET /:cid/tasks?status=blocked` / `?status=failed`(找卡住的)、`GET /:cid/goals/:goalId`(看某 goal 下全部 task + run 摘要)
  115 +- **写**:`PATCH /:cid/tasks/:taskId`(改 `priority`)、`POST /:cid/tasks`(带 `draft:true`,提新任务)、`POST /:cid/tasks/:taskId/enqueue`(把自己或别人提的草稿正式下达——这一步要不要也留给人类点,是个开放问题,见 §四)
  116 +- **明确不该用**:`org-graph/replan`、招聘类端点(这些是 HR/`orggraph-planning` 的事,`ceo` 判断"缺角色"之后应该是发通知/提 task,不是自己动手改组织结构)
  117 +
  118 +#### 确定性通路(routeFailure 里那条规则)不走这套 REST
  119 +
  120 +30 号文档四分类里"显然该开新任务"的情形(比如 `spec_ambiguous` 重试超阈值),是 `graph-engine.js` 服务端内部代码,**直接调 `wdb.createTask()`**(同进程函数调用),不必经过 HTTP、不必带内部令牌,落库同样是 `draft`。这条通路和 `ceo` 的 agent 通路是并行的两条腿,共享同一个"落 draft、不自动下达"的规则,但触发方式不同(一个是引擎钩子里的确定性规则,一个是 agent 主观判断)。
  121 +
  122 +**先做地基 0(鉴权)→ 地基 1(draft 模式)→ 地基 2(`tools.md`),P1/P3 的 boss agent 才有事可干**——三步不做完,`ceo` 被心跳唤醒了也只能干瞪眼,或者干瞪眼都算好的,更可能是每次尝试都吃一个静默 401。
  123 +
  124 +---
  125 +
  126 +### P5:公司级目标代码目录("接管已有工作目录")
  127 +
  128 +**核实结论:这个能力在 DevLoop 层面已经完整实现,只是没有公司级默认值,也没有确认是否覆盖非 DevLoop 的执行路径。**
  129 +
  130 +`server/services/devloop/path-resolver.js` 的 `allocateProjectRoot()` 已支持三种模式(`server/routes/devloop.js` 建项目时传 `mode`/`target_path`):
  131 +
  132 +| mode | 语义 | 现状 |
  133 +|---|---|---|
  134 +| `sandbox` | 自动分配到 `data/companies/{cid}/projects/{pid}/`(lemonboss 内部) | ✅ 已实现,默认值 |
  135 +| `linked-new` | 用户指定一个**空/新建**的绝对路径作为项目根,登记进 `~/.devloop/linked-projects.json` | ✅ 已实现 |
  136 +| `linked-existing` | 用户指定一个**已存在**的目录,接管为项目根 | ✅ **已实现,不是文件头注释说的"预留接口拒收"**——代码实测有完整校验:路径必须存在且是目录、不能已经是别的 DevLoop 项目(`project.json` 冲突检测)、若有 `.git` 必须 working tree 干净(防止 agent 的 `git add -A` 把用户脏改动卷进自动 commit)。文件头那句注释是过期文档,**建议顺手修正**。 |
  137 +
  138 +这正是用户要的"像 Claude Code 选工作目录一样接管一个已有目录",**核心机制已经有**,且安全校验(黑名单敏感路径、git 干净树)已经比较完善。
  139 +
  140 +**org/work graph(非 DevLoop)路径已核实**,结论比预期更好也更麻烦,分开说:
  141 +
  142 +**好消息:不是两套系统**。`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 侧另建一套。**
  143 +
  144 +**坏消息:现在的分配粒度是"每个 root run 一份全新 sandbox",不是"每个公司一份持久目录"**。`allocateRunProjectRoot()` 硬编码 `mode: 'sandbox'`,且**没有任何"复用已有项目根"的入参**——`ensureProjectRoot()` 的幂等只在**同一个 root run 内**成立(先读 `project-root.json` 再决定要不要新分配),跨 run(也就是跨 task)**永远分配一个新 `pid`**,哪怕是同一个公司、同一个 goal 下的两个 task,也会落进两个互不相干的空目录。这比"没法接管外部目录"更根本——现在**同一家公司自己的两个任务都不共享代码**,遑论接管一个外部已有仓库。
  145 +
  146 +**P5 真正要做的三件事**(比原先设想的"补个配置项"要深一层):
  147 +
  148 +1. **公司级配置**:`company.json` 加 `target_dir`(绝对路径,可空)+ `target_mode`(`linked-new` / `linked-existing`,配了 `target_dir` 才有意义)。
  149 +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` 老路径,行为不变。
  150 +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 + 完成时合并这一层,属于独立工作量,不在这次范围内。
  151 +
  152 +**建议**:P5 独立于 P1-P4 推进(数据面完全不相关),优先级可以更高——但工作量比最初评估要大一档,不是"公司级配置 + 一次核实"就完,是"公司级配置 + 改分配粒度 + 并发约束联动"三件套。
  153 +
  154 +---
  155 +
  156 +## 三、修订后的建议顺序
  157 +
  158 +用户原始顺序是 P1→P2→P3→P4,本文档建议倒过来做地基:
  159 +
  160 +```
  161 +第一步:P4 地基 —— 建 task-board-tools(读写 goal/task 的 agent 工具)
  162 + + routeFailure 里接确定性的「四分类 → 自动开新 task」规则
  163 +第二步:P1 收窄版 —— 给 ceo 配 heartbeat.md(监督规则,认新 goal/task 模型不是旧 OKR)
  164 + + 挂上第一步的工具
  165 +第三步:P3 收窄版 —— 心跳只做异常判断,事件触发优先、轮询兜底、绝不重做调度
  166 +第四步:P4 完整版 —— 把「主观判断要不要开新 task」的部分交给已经装好工具的 ceo
  167 +第五步:P2(UI) —— 独立任务看板页面,随时可插入,风险最低
  168 +并行轨:P5 —— 公司级默认 target dir 配置 + 核实 org graph 交付物落点是否需要同样机制
  169 +```
  170 +
  171 +理由:P1/P3 的 boss agent 在 P4 地基建好之前没有可操作的对象;P2 是纯 UI,不卡任何人;P5 数据面独立,可以完全并行推进。
  172 +
  173 +---
  174 +
  175 +## 四、开放问题(已拍板,2026-08-30)
  176 +
  177 +1. ~~`ceo` 会不会和已有流水线角色冲突~~ **已核实排除**:`system/org-graph-templates/{dev,content,ops}.json` 三份模板全文搜索 `ceo`,零命中——`ceo` 现在没有出现在任何一张图里,是完全没被使用的孤儿本体。给它加监督职责没有冲突。
  178 +2. **P4 确定性通路新开 task 的 `report_to`:继承原 goal 的 `report_to`**。`okr_node_id` 不用单独决定——它按 P3C §四.7 既定规则只挂在 `goals` 表,新 task 挂在同一个 `goal_id` 下自动沿用该 goal 的 OKR 归属,不存在"新开一份"的问题。
  179 +3. **心跳成本策略:事件触发为主 + 低频兜底轮询**(不加"异常持续够久才唤醒"的抖动过滤,先简单落地)。主路径挂在 `transitionRun` 等既有钩子上,异常状态一出现就唤醒;另加一个量级在 30 分钟左右的低频轮询兜底,防钩子覆盖不到的漏网情况——直接照抄 `task-scheduler.js` 头部注释里"兜底扫描周期"那个既有设计哲学,不重新发明。
  180 +4. **`ceo` 可以全权自主 `enqueue` 自己提的 draft task,不需要等人类点**——这是五个选项里最激进的一档,明确高于 `task-planning`(§四.9 定案:产出永远先落 draft,人点"全部下达")的自主权限。**这个选择意味着安全网必须落在别处,不能靠"人工审核"这一层**:落地时要确保 `max_parallel_tasks`(防止一次性并发太多)、`DEFAULT_TASK_BUDGET`(防止单任务无限烧钱)、goal 级 `org-validator` 收敛验收(防止"完成"了但没真正达成)这几道既有闸门都乘上了 `ceo` 自主创建的 task,不能因为创建路径变了就绕开——这几道闸门本来就是数据层/调度器层强制的(不挂在"谁创建的"这个字段上),照理天然覆盖,但落地时要专门写一条回归测试钉死这一点。
  181 +5. **P5 org graph 交付物落点调研:已做完**,结论见 §二 P5 小节——不是两套系统(复用同一个 `path-resolver`),但分配粒度是"每个 task 一份全新 sandbox",同公司任务之间都不共享代码,比预想的工作量更大,且与"跨 task 并发"直接联动,需要一条并发约束兜底。
  182 +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 行)。
... ...
scripts/backfill-ceo-hire.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * backfill-ceo-hire.js —— 存量公司补雇 ceo
  4 + *
  5 + * 依据: docs/todo/system-design/39-boss-agent-task-supervision.md §二 P1「缺口 B」
  6 + *
  7 + * `ceo` 不在任何 org-graph-template 里,也没有自动招聘钩子——POST /api/company/create
  8 + * 这次起改成新建公司时固定雇一次,但存量公司不会补跑那条创建流程,需要这个脚本单独补雇。
  9 + *
  10 + * 幂等: hire() 本身按 company_agents 表判重(见 company-service.js:429),已经雇过的公司
  11 + * 会拿到 status='already_exists',跳过不算失败,可重复执行。
  12 + *
  13 + * 用法:
  14 + * node scripts/backfill-ceo-hire.js
  15 + */
  16 +
  17 +const fs = require('fs');
  18 +const path = require('path');
  19 +
  20 +const DATA_DIR = path.resolve(__dirname, '../data/companies');
  21 +
  22 +function main() {
  23 + const { CompanyService } = require('../server/services/company-service');
  24 + const companyService = new CompanyService();
  25 +
  26 + if (!fs.existsSync(DATA_DIR)) {
  27 + console.log('[backfill-ceo-hire] data/companies/ 不存在,无需处理');
  28 + return;
  29 + }
  30 +
  31 + const dirs = fs.readdirSync(DATA_DIR, { withFileTypes: true }).filter((d) => d.isDirectory());
  32 + let hired = 0;
  33 + let skipped = 0;
  34 + let failed = 0;
  35 +
  36 + for (const d of dirs) {
  37 + const fp = path.join(DATA_DIR, d.name, 'company.json');
  38 + if (!fs.existsSync(fp)) continue;
  39 +
  40 + try {
  41 + const result = companyService.hire(d.name, 'ceo', {});
  42 + if (result.status === 'already_exists') {
  43 + skipped += 1;
  44 + } else {
  45 + console.log(`[backfill-ceo-hire] ${d.name} → ceo(${result.status})`);
  46 + hired += 1;
  47 + }
  48 + } catch (e) {
  49 + console.error(`[backfill-ceo-hire] ${d.name} 雇 ceo 失败: ${e.message}`);
  50 + failed += 1;
  51 + }
  52 + }
  53 +
  54 + console.log(`[backfill-ceo-hire] 完成:新雇 ${hired} 家,跳过(已雇过)${skipped} 家,失败 ${failed} 家`);
  55 + if (failed > 0) process.exitCode = 1;
  56 +}
  57 +
  58 +main();
... ...
server/index.js
... ... @@ -173,6 +173,13 @@ async function start() {
173 173 } catch (err) {
174 174 console.error('[Startup] 僵尸 goal 扫描启动失败:', err.message);
175 175 }
  176 + // 39 号文档 P3:ceo 低频兜底扫描(30 分钟一次),补事件驱动唤醒(settleRootRun 里
  177 + // 那条)漏掉的 blocked task。同 startZombieGoalSweeper 的形状,挂在同一处。
  178 + try {
  179 + taskSettle.startCeoSweeper();
  180 + } catch (err) {
  181 + console.error('[Startup] ceo 兜底扫描启动失败:', err.message);
  182 + }
176 183 // 27-P3C 批 3:任务队列。进程重启会丢掉所有在途的 setImmediate 唤醒
177 184 // (队列钩子挂在 graph-run-sse.transitionRun 上,那是进程内的),
178 185 // 于是重启后队里的任务会一直躺着 —— 界面上和「正在排队」一模一样,正是本批要消灭的症状。
... ...
server/routes/company.js
... ... @@ -8,10 +8,14 @@ const express = require(&#39;express&#39;);
8 8 const path = require('path');
9 9 const fs = require('fs');
10 10 const router = express.Router();
11   -const { requireAuth, INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../middleware/jwt-auth');
  11 +const { requireAuth, requireAuthOrInternal, INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../middleware/jwt-auth');
12 12  
13 13 // 38 号文档 Phase 1:全站要求登录。
14   -router.use(requireAuth);
  14 +// 39 号文档:goals/tasks 系列端点要给 agent(如 ceo 的心跳监督循环)用 curl 直接调,
  15 +// agent 子进程没有用户 cookie,改用 requireAuthOrInternal(放行带对 INTERNAL_TOKEN 的自环
  16 +// 调用,其余情况仍退回 requireAuth)——与 agent-chat.js 同一个模式,理由也相同:
  17 +// 这个内部令牌只在本机进程树内有效,不落盘、不外泄,安全模型早已被 agent-chat.js 验证过。
  18 +router.use(requireAuthOrInternal);
15 19  
16 20 const { CompanyService, listCompanyAgents, buildAgentDefinitionBrief, canAccessCompany } = require('../services/company-service');
17 21 const { ScheduleService } = require('../services/schedule-service');
... ... @@ -32,6 +36,14 @@ const scheduleService = new ScheduleService();
32 36 // 404 而不是 403:不告诉调用方"这个 id 存在、只是不是你的",直接当不存在处理。
33 37 router.param('companyId', (req, res, next, companyId) => {
34 38 const company = companyService.get(companyId);
  39 + // 39 号文档:内部自环调用(agent 用 INTERNAL_TOKEN 发起,比如 ceo 心跳查看/操作
  40 + // goal/task 板)没有"当前登录用户"这个概念,不能对它做归属校验——与
  41 + // agent-chat.js 的 :agentId param 中间件是同一处理方式(req.user.internal 直接放行)。
  42 + if (req.user?.internal) {
  43 + if (!company) return res.status(404).json({ error: `公司 ${companyId} 不存在` });
  44 + req.company = company;
  45 + return next();
  46 + }
35 47 if (!canAccessCompany(company, req.user)) {
36 48 return res.status(404).json({ error: `公司 ${companyId} 不存在` });
37 49 }
... ... @@ -146,6 +158,15 @@ router.post(&#39;/create&#39;, async (req, res) =&gt; {
146 158 console.error('[Create] orggraph-planning 自动招聘失败(不影响公司创建):', e.message);
147 159 }
148 160  
  161 + // 39 号文档 P1:固定雇一个 ceo,负责监督 goal/task 板(不进任何 org graph,
  162 + // 与 org-graph-template 选哪张无关,所以独立于上面那段的 bootstrap.status)。
  163 + // 同样遵守「任何失败都不能影响公司创建」的降级约定。
  164 + try {
  165 + companyService.hire(id, 'ceo', {});
  166 + } catch (e) {
  167 + console.error('[Create] ceo 自动招聘失败(不影响公司创建):', e.message);
  168 + }
  169 +
149 170 res.json({ success: true, company, message: '公司已创建,正在由 orggraph-planning 自动规划组织结构(异步进行中)。' });
150 171 } catch (err) {
151 172 res.status(400).json({ error: err.message });
... ... @@ -2146,6 +2167,22 @@ router.post(&#39;/:companyId/tasks&#39;, (req, res) =&gt; {
2146 2167 throw err;
2147 2168 }
2148 2169  
  2170 + // 39 号文档地基 1:draft:true 时只落库,不入队、不下达。服务的是 agent(如 ceo)
  2171 + // 自主提的新任务——task-planning 拆出来的 task 尚且要先落 draft 等人点「全部下达」
  2172 + // 才转 queued(P3C §四.9),agent 在线判断的风险不该比一次性拆分低。
  2173 + // 落库后走既有 `POST /:companyId/tasks/:taskId/enqueue` 正式下达,不新建端点。
  2174 + if (body.draft === true) {
  2175 + return res.status(201).json({
  2176 + success: true,
  2177 + status: 'draft',
  2178 + taskId: taskRow.id,
  2179 + goalId: goal.id,
  2180 + companyId,
  2181 + task: description,
  2182 + hint: `草稿已创建,未入队。调用 POST /api/company/${companyId}/tasks/${taskRow.id}/enqueue 正式下达。`,
  2183 + });
  2184 + }
  2185 +
2149 2186 const launched = enqueueTaskRun({ companyId, taskRow, budget: body.budget || DEFAULT_TASK_BUDGET });
2150 2187  
2151 2188 markGoalDispatched(goal);
... ...
server/services/graph-engine.js
... ... @@ -59,6 +59,9 @@ const {
59 59 recordFile,
60 60 // 27-P4 T4/T5:advanceBatch 批量拾取宽度的缺省值
61 61 DEFAULT_MAX_CONCURRENCY,
  62 + // 39 号文档「确定性通路」:回退熔断收敛时顺手开一条 draft task,见 maybeProposeReplanTask
  63 + createTask,
  64 + getGoal,
62 65 } = require('./work-graph-db');
63 66  
64 67 /**
... ... @@ -2125,6 +2128,9 @@ function handleBackwardEdge(runId, def, nodeRun, edge, out, remediationNote = nu
2125 2128 verdict: 'force_terminated', cap: out.loop.cap,
2126 2129 }, { ref: edge.id });
2127 2130 blockNode(runId, nodeRun.id, remediationNote);
  2131 + // 39 号文档「确定性通路」:熔断收敛之后顺手把这个卡点回写成 goal/task 板上一条 draft
  2132 + // task,不等 ceo 心跳才发现。best-effort,绝不能反过来影响上面已经做完的熔断收敛。
  2133 + maybeProposeReplanTask(runId, rootRunId, nodeRun, def, remediationNote);
2128 2134 return true;
2129 2135 }
2130 2136  
... ... @@ -2155,6 +2161,52 @@ function handleBackwardEdge(runId, def, nodeRun, edge, out, remediationNote = nu
2155 2161 }
2156 2162  
2157 2163 /**
  2164 + * 39 号文档「确定性通路」:run 树级/单边回退熔断(force_terminated)收敛时,顺手在同一个
  2165 + * goal 下开一条 draft task,把卡住的诊断信息交回 goal/task 板,而不是只留一个 blocked
  2166 + * 节点等人翻控制台才发现。
  2167 + *
  2168 + * 只在根 run 触发一次(`runId === rootRunId`):嵌套子图自己的回退熔断会先在子层
  2169 + * blockNode,结论再沿子 run 边界冒泡成父层(org)节点的失败——冒泡到根时同一次「卡住」
  2170 + * 只应该开一条 task,不是每层各开一条。
  2171 + *
  2172 + * 落库时机是 `draft`,不自动 enqueue(与 §四.9 task-planning 同一条哲学:这是一次
  2173 + * 「猜」,不该自动花钱);`report_to` 继承原 goal 的 `report_to`(§四.2 已拍板)。
  2174 + *
  2175 + * 纯 best-effort:任何一步查不到/写不进都吞掉,不能让「顺手开个 task」反过来打断
  2176 + * 熔断收敛本身——熔断收敛是安全关键路径,这个不是。
  2177 + */
  2178 +function maybeProposeReplanTask(runId, rootRunId, nodeRun, def, remediationNote) {
  2179 + if (runId !== rootRunId) return;
  2180 + try {
  2181 + const db = getDb();
  2182 + const task = db.prepare(`SELECT * FROM tasks WHERE current_run_id = ?`).get(rootRunId);
  2183 + if (!task) return; // 这条 run 树当前不是任何 task 的 current_run_id(例如已被重跑顶替),不开
  2184 + const goal = getGoal(task.goal_id);
  2185 + if (!goal) return;
  2186 +
  2187 + const d = def.nodes.get(nodeRun.def_node_id) || {};
  2188 + const nodeName = d.name || nodeRun.def_node_id;
  2189 + const reason = remediationNote || `节点 ${nodeName} 反复回退撞了熔断硬顶,未能收敛`;
  2190 +
  2191 + createTask({
  2192 + goalId: goal.id,
  2193 + companyId: goal.company_id,
  2194 + title: `[待重新规划] ${(task.title || task.description || goal.title || '').slice(0, 160)}`,
  2195 + description: `任务 ${task.id}(${task.title || task.description || ''})执行时在节点 `
  2196 + + `${nodeName} 反复回退未能收敛,已停止自动重试。\n\n诊断:${reason}\n\n`
  2197 + + `此任务由系统自动生成(草稿),需要人工或 ceo 审阅后决定:调整原任务描述重新下达,还是关闭。`,
  2198 + executionMode: 'manual',
  2199 + priority: goal.priority || 'P1',
  2200 + reportTo: goal.report_to || null,
  2201 + createdBy: 'system',
  2202 + });
  2203 + } catch (err) {
  2204 + // best-effort:吞掉。上一步的熔断收敛(blockNode)已经做完,这里出错不影响主路径。
  2205 + console.error('[graph-engine] maybeProposeReplanTask 失败(不影响熔断收敛):', err.message);
  2206 + }
  2207 +}
  2208 +
  2209 +/**
2158 2210 * 解析跨层地址 → 「该退到哪个子 run 的哪一行」(**纯查询,不改任何状态**)。
2159 2211 *
2160 2212 * 输入的地址来自失败节点行的 `fail_node_ref`(三值透传的第四值,见 normalizeNodeRef 那段)。
... ...
server/services/task-settle.js
... ... @@ -192,6 +192,12 @@ function settleRootRun(run, to) {
192 192 if (taskStatus === 'blocked') {
193 193 const why = describeRunBlock(run.id);
194 194 if (why) wdb.noteTaskBlockedReason(run.task_id, run.id, why);
  195 +
  196 + // 39 号文档 P3:blocked **不是**终态(下面 settleGoalIfAllTasksDone 也不会碰它),
  197 + // goal 因此不会收敛、`reportGoalOutcome` 那条唤醒 report_to 的路径也不会触发——
  198 + // 卡住的任务本来会一直静默躺着,直到有人自己点进控制台才发现(30 号文档点过名的
  199 + // 那个空白)。这里主动叫醒 ceo,不等它下一次心跳。best-effort,失败只打日志。
  200 + wakeCeo(run.company_id, why);
195 201 }
196 202  
197 203 const task = wdb.getTask(run.task_id);
... ... @@ -199,6 +205,39 @@ function settleRootRun(run, to) {
199 205 }
200 206  
201 207 /**
  208 + * 39 号文档 P3:唤醒本公司的 ceo(事件驱动的主路径)。
  209 + * 与 company.js 的 triggerOrgGraphPlanning / 本文件 reportGoalOutcome 里唤醒 report_to
  210 + * 同一个模式:公司限定地址(`{companyId}:ceo`,本体只有一份,公司态按公司分)+
  211 + * INTERNAL_TOKEN 自环调用。发射后不等结果——这条不是收敛链路的一部分,晚几秒钟被
  212 + * ceo 看到不影响正确性,不值得为它拖慢 settleRootRun 的返回。
  213 + */
  214 +function wakeCeo(companyId, reason) {
  215 + if (!companyId) return;
  216 + try {
  217 + const PORT = require('../config').port || 3010;
  218 + const { INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../middleware/jwt-auth');
  219 + const namespacedId = `${companyId}:ceo`;
  220 + fetch(`http://127.0.0.1:${PORT}/api/agent/${encodeURIComponent(namespacedId)}/chat`, {
  221 + method: 'POST',
  222 + headers: { 'Content-Type': 'application/json', [INTERNAL_TOKEN_HEADER]: INTERNAL_TOKEN },
  223 + body: JSON.stringify({
  224 + message: `[SYSTEM] 有一条 task 刚转入 blocked 状态:${reason || '(无详情)'}\n`
  225 + + `请查看 tools.md 里的读接口确认情况,判断是否需要提一条新任务或调整优先级。`,
  226 + conversationId: `ceo-wake-${companyId}-${Date.now().toString(36)}`,
  227 + isHeartbeat: true,
  228 + }),
  229 + })
  230 + .then((res) => {
  231 + if (!res.ok) console.error(`[CeoWake] 唤醒 ${namespacedId} 失败: HTTP ${res.status}`);
  232 + })
  233 + .catch((e) => console.error(`[CeoWake] 唤醒 ${namespacedId} 失败(网络): ${e.message}`));
  234 + } catch (e) {
  235 + // best-effort:唤醒失败不能影响调用方(settleRootRun)已经做完的状态回写。
  236 + console.error('[CeoWake] 唤醒 ceo 异常(不影响状态回写):', e.message);
  237 + }
  238 +}
  239 +
  240 +/**
202 241 * goal 级收敛:这个 goal 的 task 是不是都到终态了?是则定判词并做**一次**对外汇报。
203 242 *
204 243 * 状态口径:
... ... @@ -642,6 +681,50 @@ function stopZombieGoalSweeper() {
642 681 zombieSweepTimer = null;
643 682 }
644 683  
  684 +/**
  685 + * 39 号文档 P3:ceo 低频兜底扫描。`settleRootRun` 里的事件驱动唤醒(task 转 blocked 那一刻)
  686 + * 是主路径,这里只补漏——进程重启、fetch 失败、或本来就没接住那次转换。量级放到 30 分钟,
  687 + * 不是主决策路径,照抄 `task-scheduler.js` 头部注释里"兜底扫描周期"那个设计哲学。
  688 + * 按 company_id 分组只叫醒一次,不逐条 task 叫(否则一家公司卡 5 条 task 就是 5 次心跳)。
  689 + */
  690 +const CEO_SWEEP_INTERVAL_MS = Number(process.env.CEO_SWEEP_INTERVAL_MS) || 30 * 60 * 1000;
  691 +let ceoSweepTimer = null;
  692 +
  693 +function sweepBlockedTasksForCeo() {
  694 + try {
  695 + const wdb = require('./work-graph-db');
  696 + const rows = wdb.getDb()
  697 + .prepare(`
  698 + SELECT company_id, COUNT(*) AS cnt, GROUP_CONCAT(id) AS ids
  699 + FROM tasks
  700 + WHERE archived_at IS NULL AND status = 'blocked'
  701 + GROUP BY company_id
  702 + `)
  703 + .all();
  704 + for (const r of rows) {
  705 + wakeCeo(r.company_id, `兜底扫描:本公司当前有 ${r.cnt} 条 blocked task(${r.ids}),`
  706 + + `事件驱动那条唤醒可能漏了,请查看。`);
  707 + }
  708 + return rows;
  709 + } catch (err) {
  710 + console.error(`[CeoWake] sweepBlockedTasksForCeo 异常: ${err.message}`);
  711 + return [];
  712 + }
  713 +}
  714 +
  715 +/** 启动周期性 ceo 兜底扫描(unref,不拖着进程不退出)。幂等:重复调用只返回已有的计时器 */
  716 +function startCeoSweeper(intervalMs = CEO_SWEEP_INTERVAL_MS) {
  717 + if (ceoSweepTimer) return ceoSweepTimer;
  718 + ceoSweepTimer = setInterval(sweepBlockedTasksForCeo, intervalMs);
  719 + if (typeof ceoSweepTimer.unref === 'function') ceoSweepTimer.unref();
  720 + return ceoSweepTimer;
  721 +}
  722 +
  723 +function stopCeoSweeper() {
  724 + if (ceoSweepTimer) clearInterval(ceoSweepTimer);
  725 + ceoSweepTimer = null;
  726 +}
  727 +
645 728 module.exports = {
646 729 settleRootRun,
647 730 settleGoalIfAllTasksDone,
... ... @@ -649,6 +732,10 @@ module.exports = {
649 732 sweepStuckActiveGoals,
650 733 startZombieGoalSweeper,
651 734 stopZombieGoalSweeper,
  735 + wakeCeo,
  736 + sweepBlockedTasksForCeo,
  737 + startCeoSweeper,
  738 + stopCeoSweeper,
652 739 RUN_TO_TASK_STATUS,
653 740 // 占位值不再是常量而是「前缀 + 唯一 token」,所以对外给的是前缀和判定函数:
654 741 // 任何地方想认出「这条还没有结论」,都必须走 isPendingVerdict,别再去比字符串相等。
... ...
server/tests/unit/graph-engine.test.js
... ... @@ -752,6 +752,90 @@ describe(&#39;graph-engine&#39;, () =&gt; {
752 752 });
753 753  
754 754 // ──────────────────────────────────────────────────────────────
  755 + // 39 号文档 P4「确定性通路」:回退熔断收敛时顺手开一条 draft task
  756 + // ──────────────────────────────────────────────────────────────
  757 + describe('回退熔断 · 确定性通路自动开新 task(39 号文档 §二 P4)', () => {
  758 + it('单边硬顶 force_terminated 时,会在同一个 goal 下新开一条 draft task,report_to 继承自 goal', async () => {
  759 + const companyId = nextCompany();
  760 + const goal = wdb.createGoal({
  761 + companyId,
  762 + title: '测试 goal',
  763 + description: '测试 goal 的描述',
  764 + priority: 'P2',
  765 + createdBy: 'human',
  766 + reportTo: 'some-upstream-agent',
  767 + });
  768 + const task = wdb.createTask({
  769 + goalId: goal.id,
  770 + companyId,
  771 + title: '测试 task',
  772 + description: '测试 task 的描述',
  773 + executionMode: 'auto',
  774 + priority: 'P2',
  775 + createdBy: 'human',
  776 + });
  777 +
  778 + const { runId } = buildWorkRun({
  779 + companyId,
  780 + nodes: ['impl', 'check'],
  781 + edges: [
  782 + { from: 'impl', to: 'check' },
  783 + { from: 'check', to: 'impl', condition: 'fail', edgeKind: 'backward' },
  784 + ],
  785 + mode: 'auto',
  786 + });
  787 + // 让这条 run 看起来是「这条 task 当前的 run」——只有 runId === rootRunId 且
  788 + // current_run_id 指回它,maybeProposeReplanTask 才会触发(避免嵌套子图重复开)。
  789 + wdb.updateTask(task.id, { currentRunId: runId });
  790 +
  791 + fakeExecutor({
  792 + impl: { status: 'success' },
  793 + check: { status: 'fail', remediationNote: '反复修不好' },
  794 + });
  795 +
  796 + await engine.startRun(runId, { mode: 'auto' }).driver;
  797 +
  798 + // 熔断收敛本身不受影响(既有断言的等价物)
  799 + expect(wdb.getRun(runId).run_status).toBe('blocked');
  800 +
  801 + const tasksInGoal = wdb.listCompanyTasks(companyId, { goalId: goal.id, includeArchived: true });
  802 + expect(tasksInGoal).toHaveLength(2);
  803 + const created = tasksInGoal.find((t) => t.id !== task.id);
  804 + expect(created).toBeTruthy();
  805 + expect(created.status).toBe('draft');
  806 + expect(created.created_by).toBe('system');
  807 + // report_to 继承自 goal(39 号文档 §四.2 已拍板),不是继承自触发它的 task
  808 + expect(created.report_to).toBe('some-upstream-agent');
  809 + expect(created.description).toContain('反复修不好');
  810 + });
  811 +
  812 + it('嵌套子图自己的回退熔断(runId !== rootRunId)不会新开 task——只在根 run 触发一次', async () => {
  813 + // buildWorkRun 建出的是顶层 run,rootRunId 本来就等于自己;这里改用
  814 + // 「run 不是任何 task 的 current_run_id」这条更好触发的早退路径验证同一条防重复保证:
  815 + // 没有匹配的 task 行,maybeProposeReplanTask 必须安静地什么都不做,不能抛错。
  816 + const companyId = nextCompany();
  817 + const { runId } = buildWorkRun({
  818 + companyId,
  819 + nodes: ['impl', 'check'],
  820 + edges: [
  821 + { from: 'impl', to: 'check' },
  822 + { from: 'check', to: 'impl', condition: 'fail', edgeKind: 'backward' },
  823 + ],
  824 + mode: 'auto',
  825 + });
  826 + fakeExecutor({
  827 + impl: { status: 'success' },
  828 + check: { status: 'fail', remediationNote: '再修一遍' },
  829 + });
  830 +
  831 + await expect(engine.startRun(runId, { mode: 'auto' }).driver).resolves.not.toThrow();
  832 + expect(wdb.getRun(runId).run_status).toBe('blocked');
  833 + // 没有任何 task 挂这条 run,不应该凭空生出一条 task 来(company 下 tasks 表应仍为空)
  834 + expect(wdb.listCompanyTasks(companyId, {})).toHaveLength(0);
  835 + });
  836 + });
  837 +
  838 + // ──────────────────────────────────────────────────────────────
755 839 // §六 验收 9b:多边不架空熔断(R-16 发现的 3×N 漏洞)
756 840 // ──────────────────────────────────────────────────────────────
757 841 describe('回退熔断 · 多边不架空(§六 验收 9b / R-16)', () => {
... ...
server/tests/unit/route-auth-gate.test.js
... ... @@ -44,8 +44,8 @@ describe(&#39;route-auth-gate(38 号文档 Phase 1)&#39;, () =&gt; {
44 44 delete process.env.FORCE_REAL_AUTH;
45 45 });
46 46  
47   - it('company.js 挂了 requireAuth', () => {
48   - expectGated(require('../../routes/company.js'), requireAuth, 'company.js');
  47 + it('company.js 挂了 requireAuthOrInternal(39 号文档:goals/tasks 系列端点要给 agent 用 curl 直接调,比如 ceo 的心跳监督循环,不是普通 requireAuth)', () => {
  48 + expectGated(require('../../routes/company.js'), requireAuthOrInternal, 'company.js');
49 49 });
50 50  
51 51 it('tasks.js 挂了 requireAuth', () => {
... ...
server/tests/unit/task-blocked-ceo-wake.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — task 转 blocked 时主动唤醒 ceo(39 号文档 §二 P3,事件驱动主路径)
  3 + *
  4 + * 背景:blocked 不是终态,`settleGoalIfAllTasksDone` 不会碰它,goal 因此不会收敛,
  5 + * `reportGoalOutcome` 那条唤醒 report_to 的路径也不会触发——卡住的任务本来会一直静默
  6 + * 躺着,直到有人自己点进控制台才发现(30 号文档点过名的空白)。`settleRootRun` 现在
  7 + * 会在 blocked 回写之后顺手唤醒本公司的 ceo。
  8 + *
  9 + * 只验唤醒这一跳的逻辑(URL / header / body 形状),不验 agent-chat 真的收到消息之后
  10 + * 会做什么——那是 ceo 自己 tools.md / heartbeat.md 的行为,不在引擎测试射程内。
  11 + *
  12 + * 隔离手法与 task-blocked-reason.test.js 一致。
  13 + */
  14 +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
  15 +import fs from 'fs';
  16 +
  17 +describe('task 转 blocked 唤醒 ceo(39 号文档 P3)', () => {
  18 + let wdb;
  19 + let settle;
  20 + const TEST_DB_PATH = '/tmp/test-blocked-ceo-wake.sqlite';
  21 + const files = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
  22 +
  23 + beforeAll(() => {
  24 + for (const f of files) { try { fs.unlinkSync(f); } catch { /* 没有就算了 */ } }
  25 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  26 + for (const m of [
  27 + '../../services/graph-db',
  28 + '../../services/org-graph-service',
  29 + '../../services/work-graph-db',
  30 + '../../services/graph-run-sse',
  31 + '../../services/task-settle',
  32 + ]) {
  33 + delete require.cache[require.resolve(m)];
  34 + }
  35 + wdb = require('../../services/work-graph-db');
  36 + settle = require('../../services/task-settle');
  37 + });
  38 +
  39 + afterAll(() => {
  40 + for (const f of files) { try { fs.unlinkSync(f); } catch { /* 同上 */ } }
  41 + delete process.env.GRAPH_DB_PATH;
  42 + });
  43 +
  44 + beforeEach(() => {
  45 + const db = wdb.getDb();
  46 + db.pragma('foreign_keys = OFF');
  47 + db.exec(`
  48 + DELETE FROM graph_run_nodes;
  49 + DELETE FROM graph_runs;
  50 + DELETE FROM tasks;
  51 + DELETE FROM goals;
  52 + DELETE FROM work_graph_edges;
  53 + DELETE FROM work_graph_nodes;
  54 + DELETE FROM work_graphs;
  55 + DELETE FROM system_agents;
  56 + `);
  57 + db.pragma('foreign_keys = ON');
  58 + });
  59 +
  60 + let seq = 0;
  61 +
  62 + /** 与 task-blocked-reason.test.js 的 scene() 同构:造出「一条 root run 正卡在某节点」 */
  63 + function scene() {
  64 + const companyId = `ceowake-co-${++seq}`;
  65 + const now = new Date().toISOString();
  66 + const db = wdb.getDb();
  67 + db.prepare(`
  68 + INSERT OR IGNORE INTO system_agents
  69 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  70 + VALUES ('sdd-planning', 'sdd-planning', 'public', NULL, 'system/agents/sdd-planning', 'active', ?, ?)
  71 + `).run(now, now);
  72 +
  73 + const graph = wdb.createWorkGraph({ companyId, nodes: ['sdd-planning'], edges: [] });
  74 + const goal = wdb.createGoal({ companyId, title: '做一个官网', description: '测试用' });
  75 + const task = wdb.createTask({
  76 + goalId: goal.id, companyId, title: '做一个官网', description: '测试用', executionMode: 'manual',
  77 + });
  78 + const run = wdb.createRun({
  79 + graphKind: 'work', graphId: graph.id, companyId, goalId: goal.id, taskId: task.id,
  80 + executionMode: 'manual', runStatus: 'running',
  81 + });
  82 + wdb.syncRunNodesToDef(run.id);
  83 + wdb.updateTask(task.id, { status: 'running', currentRunId: run.id, startedAt: now });
  84 +
  85 + const nodeRow = db.prepare(`SELECT id FROM graph_run_nodes WHERE run_id = ?`).get(run.id);
  86 + db.prepare(`UPDATE graph_run_nodes SET status = 'blocked', fail_reason = 'boundary_escape', updated_at = ? WHERE id = ?`)
  87 + .run(now, nodeRow.id);
  88 +
  89 + return { companyId, taskId: task.id, run: wdb.getRun(run.id) };
  90 + }
  91 +
  92 + it('blocked 回写时会 fetch 唤醒 `{companyId}:ceo`,带上正确的内部令牌头', async () => {
  93 + const { companyId, run } = scene();
  94 + const { INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../../middleware/jwt-auth');
  95 +
  96 + const calls = [];
  97 + const originalFetch = global.fetch;
  98 + global.fetch = vi.fn((url, opts) => {
  99 + calls.push({ url, opts });
  100 + return Promise.resolve({ ok: true, status: 200 });
  101 + });
  102 +
  103 + try {
  104 + settle.settleRootRun(run, 'blocked');
  105 + // wakeCeo 是发射后不等结果,让微任务队列跑一轮再断言
  106 + await new Promise((r) => setImmediate(r));
  107 + } finally {
  108 + global.fetch = originalFetch;
  109 + }
  110 +
  111 + expect(calls).toHaveLength(1);
  112 + const [{ url, opts }] = calls;
  113 + expect(url).toContain(`/api/agent/${companyId}%3Aceo/chat`);
  114 + expect(opts.method).toBe('POST');
  115 + expect(opts.headers[INTERNAL_TOKEN_HEADER]).toBe(INTERNAL_TOKEN);
  116 + const body = JSON.parse(opts.body);
  117 + expect(body.message).toContain('blocked');
  118 + expect(body.isHeartbeat).toBe(true);
  119 + });
  120 +
  121 + it('completed 回写不会唤醒 ceo(只有 blocked 才需要主动叫人)', async () => {
  122 + const { run } = scene();
  123 + const calls = [];
  124 + const originalFetch = global.fetch;
  125 + global.fetch = vi.fn((url, opts) => { calls.push({ url, opts }); return Promise.resolve({ ok: true }); });
  126 +
  127 + try {
  128 + // 先把节点状态改成 success,让 settleTaskFromRun 认成 completed
  129 + const db = wdb.getDb();
  130 + db.prepare(`UPDATE graph_run_nodes SET status = 'success', fail_reason = NULL WHERE run_id = ?`).run(run.id);
  131 + settle.settleRootRun(run, 'completed');
  132 + await new Promise((r) => setImmediate(r));
  133 + } finally {
  134 + global.fetch = originalFetch;
  135 + }
  136 +
  137 + expect(calls).toHaveLength(0);
  138 + });
  139 +
  140 + it('fetch 唤醒失败(网络异常)不影响 settleRootRun 的返回值 —— best-effort,不能拖垮主路径', () => {
  141 + const { run } = scene();
  142 + const originalFetch = global.fetch;
  143 + global.fetch = vi.fn(() => Promise.reject(new Error('网络挂了')));
  144 +
  145 + let result;
  146 + try {
  147 + result = settle.settleRootRun(run, 'blocked');
  148 + } finally {
  149 + global.fetch = originalFetch;
  150 + }
  151 +
  152 + expect(result).toEqual({ task: 'blocked', goal: null });
  153 + });
  154 +
  155 + it('低频兜底扫描 sweepBlockedTasksForCeo:按公司分组只唤醒一次,不逐条 task 叫', async () => {
  156 + const a = scene();
  157 + const b = scene();
  158 + // 同一家公司再卡一条 —— a.companyId 应该只被唤醒一次,不是两次
  159 + const db = wdb.getDb();
  160 + const now = new Date().toISOString();
  161 + const graph2 = wdb.createWorkGraph({ companyId: a.companyId, nodes: ['sdd-planning'], edges: [] });
  162 + const goal2 = wdb.createGoal({ companyId: a.companyId, title: '第二条', description: '测试用' });
  163 + const task2 = wdb.createTask({
  164 + goalId: goal2.id, companyId: a.companyId, title: '第二条', description: '测试用', executionMode: 'manual',
  165 + });
  166 + wdb.updateTask(task2.id, { status: 'blocked' });
  167 + // scene() 已经把 a/b 的 task 结算成 blocked(settleRootRun 还没跑,这里直接跑一次让状态落到位)
  168 + settle.settleRootRun(a.run, 'blocked');
  169 + settle.settleRootRun(b.run, 'blocked');
  170 +
  171 + const calls = [];
  172 + const originalFetch = global.fetch;
  173 + global.fetch = vi.fn((url) => { calls.push(url); return Promise.resolve({ ok: true }); });
  174 +
  175 + try {
  176 + const rows = settle.sweepBlockedTasksForCeo();
  177 + await new Promise((r) => setImmediate(r));
  178 + const companiesSwept = rows.map((r) => r.company_id).sort();
  179 + expect(companiesSwept).toEqual([a.companyId, b.companyId].sort());
  180 + const aRow = rows.find((r) => r.company_id === a.companyId);
  181 + expect(aRow.cnt).toBe(2); // a 公司这次有 2 条 blocked task,但下面只应该 fetch 一次
  182 + } finally {
  183 + global.fetch = originalFetch;
  184 + }
  185 +
  186 + const aCalls = calls.filter((u) => u.includes(`${a.companyId}%3Aceo`));
  187 + expect(aCalls).toHaveLength(1);
  188 + });
  189 +});
... ...
server/tests/unit/task-entry-draft-mode.test.js 0 → 100644
  1 +/**
  2 + * task-entry-draft-mode.test.js —— POST /:companyId/tasks 的 draft:true 分支(39 号文档地基 1)
  3 + *
  4 + * 现有 `POST /:companyId/tasks` 建完任务无条件立刻入队执行("下达即执行")。
  5 + * `draft:true` 时只应该落库,不应该 enqueue/start——服务的是 agent(如 ceo)自主提的新任务,
  6 + * 不该像 task-planning 一样先经过 draft 就自动花预算。
  7 + *
  8 + * 隔离手法与 graph-engine.test.js / company-goal-task-routes.test.js 一致:先设
  9 + * GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,真实的 data/graph.sqlite、data/companies/ 全程不被碰。
  10 + */
  11 +const fs = require('fs');
  12 +const os = require('os');
  13 +const path = require('path');
  14 +
  15 +const TEST_DB_PATH = '/tmp/test-task-entry-draft-mode.sqlite';
  16 +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
  17 +const MODULES = [
  18 + '../../services/graph-db',
  19 + '../../services/work-graph-db',
  20 + '../../services/org-graph-service',
  21 + '../../services/graph-run-sse',
  22 + '../../routes/company',
  23 +];
  24 +
  25 +const CID = 'draft-mode-test-co';
  26 +
  27 +let wdb;
  28 +let orgSvc;
  29 +let server;
  30 +let base;
  31 +let origCompanyGet;
  32 +
  33 +async function call(method, url, body) {
  34 + const r = await fetch(base + url, {
  35 + method,
  36 + headers: { 'Content-Type': 'application/json' },
  37 + body: body === undefined ? undefined : JSON.stringify(body),
  38 + });
  39 + return [r.status, await r.json()];
  40 +}
  41 +
  42 +function ensureAgents(ids) {
  43 + const db = wdb.getDb();
  44 + const now = new Date().toISOString();
  45 + const ins = db.prepare(`
  46 + INSERT OR IGNORE INTO system_agents
  47 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  48 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
  49 + `);
  50 + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
  51 +}
  52 +
  53 +describe('POST /:companyId/tasks draft 模式(39 号文档地基 1)', () => {
  54 + beforeAll(async () => {
  55 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } }
  56 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  57 + process.env.GRAPH_RUNS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'draft-mode-runs-'));
  58 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  59 +
  60 + wdb = require('../../services/work-graph-db');
  61 + orgSvc = require('../../services/org-graph-service');
  62 +
  63 + const { CompanyService } = require('../../services/company-service');
  64 + origCompanyGet = CompanyService.prototype.get;
  65 + CompanyService.prototype.get = (companyId) =>
  66 + (companyId === CID ? { id: CID, name: CID, status: 'running' } : null);
  67 +
  68 + // 一张最小 org graph:POST /tasks 建行前会先查「公司有没有 org graph」,没有直接 409,
  69 + // 这一步不是本文件要测的东西,先把它铺好。
  70 + ensureAgents(['org-planner', 'org-coder', 'org-validator']);
  71 + expect(
  72 + orgSvc.writeGraph(CID, {
  73 + nodes: ['org-planner', 'org-coder', 'org-validator'],
  74 + edges: [
  75 + { from: 'org-planner', to: 'org-coder', condition: 'success' },
  76 + { from: 'org-coder', to: 'org-validator', condition: 'success' },
  77 + ],
  78 + }).ok
  79 + ).toBe(true);
  80 +
  81 + const express = require('express');
  82 + const app = express();
  83 + app.use(express.json());
  84 + app.use('/api/company', require('../../routes/company'));
  85 +
  86 + await new Promise((resolve) => { server = app.listen(0, resolve); });
  87 + base = `http://127.0.0.1:${server.address().port}/api/company/${CID}`;
  88 + });
  89 +
  90 + afterAll(() => {
  91 + if (server) server.close();
  92 + if (origCompanyGet) require('../../services/company-service').CompanyService.prototype.get = origCompanyGet;
  93 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } }
  94 + try { fs.rmSync(process.env.GRAPH_RUNS_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
  95 + delete process.env.GRAPH_DB_PATH;
  96 + delete process.env.GRAPH_RUNS_DIR;
  97 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  98 + });
  99 +
  100 + it('draft:true → 201 + status draft,不落 runId,task 不入队(current_run_id 仍为空)', async () => {
  101 + const [status, body] = await call('POST', '/tasks', {
  102 + task: '(草稿)先别执行的一条任务',
  103 + draft: true,
  104 + created_by: 'ceo',
  105 + });
  106 +
  107 + expect(status).toBe(201);
  108 + expect(body.status).toBe('draft');
  109 + expect(body.runId).toBeUndefined();
  110 + expect(body.taskId).toBeTruthy();
  111 + expect(body.hint).toContain(`/tasks/${body.taskId}/enqueue`);
  112 +
  113 + const row = wdb.getTask(body.taskId);
  114 + expect(row.status).toBe('draft');
  115 + expect(row.current_run_id).toBeFalsy();
  116 + expect(row.created_by).toBe('ceo');
  117 + });
  118 +
  119 + it('draft:true 建出的任务,之后走既有 /enqueue 端点能正常下达(不是死路)', async () => {
  120 + const [, created] = await call('POST', '/tasks', { task: '(草稿)待下达', draft: true });
  121 + const [status, body] = await call('POST', `/tasks/${created.taskId}/enqueue`);
  122 + expect(status).toBe(201);
  123 + expect(['queued', 'running'].includes(body.status)).toBe(true);
  124 +
  125 + const row = wdb.getTask(created.taskId);
  126 + expect(['queued', 'running'].includes(row.status)).toBe(true);
  127 + });
  128 +
  129 + it('默认行为不变:不传 draft 时仍然立刻入队/起跑(存量调用方不受影响)', async () => {
  130 + const [status, body] = await call('POST', '/tasks', { task: '正常下达一条任务' });
  131 + expect(status).toBe(201);
  132 + expect(body.status).not.toBe('draft');
  133 + expect(body.runId).toBeTruthy();
  134 + });
  135 +});
... ...
system/agents/ceo/heartbeat.md 0 → 100644
  1 +# 心跳任务
  2 +
  3 +你收到的心跳消息会提"扫描全局 OKR 目标状态"——那是通用触发文案,对你不适用。
  4 +你要看的是本公司的 **goal/task 板**(`tools.md` 里的读接口),不是 OKR。
  5 +
  6 +## 每次心跳
  7 +
  8 +- [ ] 查 `GET /tasks?status=blocked` 和 `?status=failed`,看有没有需要处理的
  9 +- [ ] 查 `GET /goals?status=active`,扫一眼有没有明显该调整优先级的
  10 +- [ ] 都正常就直接回复"心跳正常",不要为了"用一下工具"而用工具
  11 +
  12 +## 发现问题时
  13 +
  14 +- 具体判断依据见 `tools.md` 的"什么时候该提新任务、什么时候该改优先级"一节
  15 +- 新提的任务一律带 `draft:true`,判断确实清楚、值得马上跑再 `enqueue`
  16 +- 不确定的情况,把观察写进正文回复,不要瞎猜着动手改数据
  17 +
  18 +## 不用管的
  19 +
  20 +- 正在排队 / 正在跑但没超时的 task——那是调度器的活,不用你盯
  21 +- 纯技术性的失败(比如基础设施抖动)——引擎自己有重试和熔断机制
... ...
system/agents/ceo/tools.md 0 → 100644
  1 +# CEO 工具箱
  2 +
  3 +> 你的岗位是监督本公司的 goal/task 板(不是执行任务——`org-planner`/`org-coder`/`org-validator`
  4 +> 那条流水线干活,你只看板、判断、必要时提一条新任务或调一下优先级)。
  5 +>
  6 +> 大部分心跳触发下,你**只需要读**:看一眼有没有 `blocked`/`failed` 太久的 task,正常就
  7 +> 回复"心跳正常",什么都不用调用。只有真发现问题时才用下面的写工具。
  8 +
  9 +---
  10 +
  11 +## 认证:每次调用都要带这个头
  12 +
  13 +```bash
  14 +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/goals" \
  15 + -H "x-internal-token: $LEMON_INTERNAL_TOKEN"
  16 +```
  17 +
  18 +`LEMON_INTERNAL_TOKEN` 是你的进程环境变量里现成的(服务端 spawn 你的时候自动带上),
  19 +不用去别处找、也不用自己生成。**忘了带这个头,所有请求都会 401**——先检查这个,
  20 +不要一看到 401 就怀疑接口路径写错了。
  21 +
  22 +`<companyId>` 换成你自己所在的公司 id(就是你运行时坐标里那个 cid)。
  23 +
  24 +---
  25 +
  26 +## 读:先看板子上有什么
  27 +
  28 +### 看哪些 goal 在跑
  29 +```bash
  30 +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/goals?status=active" \
  31 + -H "x-internal-token: $LEMON_INTERNAL_TOKEN"
  32 +```
  33 +
  34 +### 找卡住的 task(这是你心跳时主要要查的)
  35 +```bash
  36 +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/tasks?status=blocked" \
  37 + -H "x-internal-token: $LEMON_INTERNAL_TOKEN"
  38 +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/tasks?status=failed" \
  39 + -H "x-internal-token: $LEMON_INTERNAL_TOKEN"
  40 +```
  41 +
  42 +### 看某个 goal 下全部 task + 每条 task 的当前 run 摘要
  43 +```bash
  44 +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/goals/<goalId>" \
  45 + -H "x-internal-token: $LEMON_INTERNAL_TOKEN"
  46 +```
  47 +
  48 +---
  49 +
  50 +## 写:只在真正判断需要时才用
  51 +
  52 +### 改一条 task 的优先级
  53 +```bash
  54 +curl -s -X PATCH "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/tasks/<taskId>" \
  55 + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" \
  56 + -H "Content-Type: application/json" \
  57 + -d '{"priority":"P0"}'
  58 +```
  59 +
  60 +### 提一条新任务(草稿,不会立刻执行)
  61 +```bash
  62 +curl -s -X POST "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/tasks" \
  63 + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" \
  64 + -H "Content-Type: application/json" \
  65 + -d '{"goal_id":"<goalId>","task":"<这条新任务要做什么,写清楚,别人要能看懂>","draft":true}'
  66 +```
  67 +**必须带 `"draft":true`。** 不带的话这条任务会立刻入队执行——你的判断和 `task-planning`
  68 +拆分一样,都是一次"猜",不该自己直接烧预算。落库后返回一个 `taskId`。
  69 +
  70 +### 把你(或别人)提的草稿正式下达
  71 +```bash
  72 +curl -s -X POST "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId>/tasks/<taskId>/enqueue" \
  73 + -H "x-internal-token: $LEMON_INTERNAL_TOKEN"
  74 +```
  75 +你被授权可以自主调这一步(不需要等人类点),但前提是你已经确认过这条草稿的描述是清楚、
  76 +必要的——`enqueue` 之后就会真的排队执行、真的烧预算。判断依据不够就先别 enqueue,
  77 +留着草稿等人类看到也行。
  78 +
  79 +---
  80 +
  81 +## 什么时候该提新任务、什么时候该改优先级
  82 +
  83 +- **一条 task 长时间 `blocked` 或 `failed`**(预算耗尽 / 依赖死锁 / 反复回退熔断):看它的
  84 + `blocked_reason` 或诊断信息,判断是「原任务描述本身有问题,需要重新拆」还是「暂时的技术
  85 + 问题,等修复」。前者提一条新任务说明清楚问题、把原 task 的教训写进 description;
  86 + 后者不用你管,等基础设施问题解决会自动恢复。
  87 + (注意:某些"卡住已回退熔断"的情形系统会**自动**先开一条草稿任务——你去看板前可能已经
  88 + 有一条 `created_by: 'system'` 的草稿在等你审了,先看有没有再决定要不要自己再开一条。)
  89 +- **同一 goal 下多个 task 优先级冲突**(业务变化导致原定顺序不再合理):改 `priority`,
  90 + 不需要新建任务。
  91 +- **`task-planning` 降级成单任务**(goal 描述里能看出"自动拆分失败,已按单任务下达"的
  92 + 标注):这种情况不要自己再拆一次——把情况记下来,正文回复里说明,等人类看一眼原始
  93 + goal 描述是不是本身就有歧义。
  94 +
  95 +---
  96 +
  97 +## 你不该用的工具
  98 +
  99 +| 工具 / 动作 | 为什么不该 |
  100 +|---|---|
  101 +| `POST /:companyId/org-graph/replan` | 改组织结构是 `orggraph-planning` 的事,不是你的判断范围 |
  102 +| 任何招聘 / 解雇端点 | 公司缺角色是 HR 与 `orggraph-planning` 的活,你发现"缺人"应该提一条 task 说明缺口,不是自己动手改编制 |
  103 +| 直接改 `graph_runs` / `graph_run_nodes`(没有这类端点,但如果你找到裸 SQL 或内部函数也一样不许碰) | 执行状态由引擎维护,你只通过 goals/tasks 这层 REST 接口操作 |
  104 +| `POST /:companyId/tasks`(不带 `draft:true`) | 会立刻烧预算,见上面的强调 |
... ...