# 51 · CEO 心跳自主立项设计——补齐"目标耗尽后无人接续"的缺口 > **状态**: ✅ **阶段 A 已实现 + 代码闸门已补上**(2026-09-17 实现,2026-09-18 加代码闸门,见七/八)。 > 阶段 B(CEO 自动 dispatch + guardrail)仍是设计草稿,不代表已拍板,公司级配置字段形状、 > guardrail 数值需要单独立项再定案。 > **日期**: 2026-09-09(设计)/ 2026-09-17(阶段 A 实现)/ 2026-09-18(代码闸门) > **背景**: 回应"设计一个 goal,让 company 能 7×24 小时自我运行,差距还有多远"的提问—— > 结论是执行层(graph 引擎的存活扫描 / 僵尸 goal 收口 / CEO 阻塞任务兜底心跳)已经相当扎实, > 真正的缺口在**决策层**:没有任何机制在"公司没有任何活着的目标"时提出下一个目标。 > **前置阅读**: [27-goal-contract-format.md](./27-goal-contract-format.md)(goal/task 数据模型) --- ## 一、问题现状(已核实的证据链) ### 1.1 心跳机制只监督"已存在的目标",不处理"目标列表是空的" `server/services/heartbeat-service.js:221-229` 发给每个 agent 的通用心跳文案是: > "请根据你的身份和 heartbeat.md 规则,扫描当前最新的全局 OKR 目标状态和节点。如果发现某个 > 负责人超时、卡点或产出需要仲裁,请主动使用 dispatch 工具给他们发送通知或驳回。如果一切正常, > 无事发生,请直接回复'[心跳正常]'" 这条文案的前提是"存在目标/节点可扫描"。CEO 把它覆写成了具体清单(`system/agents/ceo/heartbeat.md`): - 查 `GET /tasks?status=blocked` 和 `?status=failed` - 查 `GET /goals?status=active`,看有没有该调优先级的 - 查 `GET /tasks?subtaskKind=decomposed_work&status=draft`,看有没有等派发的子任务 - 都正常就回复"心跳正常" 四条全部是**对已存在目标/任务的处理**。没有一条覆盖"`active`/`planning`/`draft` 状态的 goal 一条都没有"这种情况——按现有规则,这种情况下 CEO 会在第一步查询就拿到空列表,然后直接走到 "都正常,回复心跳正常",公司就此长期停摆,没有任何信号会升级给人类。 ### 1.2 "该提新任务"的判断范围明确限定在"已有 goal 出问题" `system/agents/ceo/tools.md:143-155`「什么时候该提新任务、什么时候该改优先级」一节列出的 三种触发条件——task 长期 blocked/failed、同 goal 下多个 task 优先级冲突、task-planning 降级成 单任务——全部要求**先有一个 goal/task 存在**才能触发判断。没有"公司层面没有任何 active goal 时该怎么办"这一条。 ### 1.3 CEO 目前没有创建 goal 的工具,只有创建 task 的工具 `system/agents/ceo/tools.md` 里对 `/goals` 的全部使用示例都是 `GET`(第 14/30/44 行),写操作 只有 `POST /:companyId/tasks`(给已存在的 goal 加草稿任务)和 `POST /goals/:goalId/dispatch` (入队)。`POST /:companyId/goals`(`server/routes/company.js:1794`,建全新 goal)这个端点**存在 于路由层,但从未出现在 CEO 的工具文档里**——即便 CEO 的心跳会话拿着有效的 `x-internal-token`(`ceo/tools.md:11-16`,认证机制已就绪),它也不知道这个端点存在,更不知道 该怎么用。这是比"决策逻辑缺失"更基础的一层缺口:**能力本身没有暴露给它**。 ### 1.4 没有"公司使命"之外的任何立项锚点,且默认值是"待定义" `system/agents/company-context.md`(每个 agent 上下文自动注入,见 `server/services/company-service.js:1149`)里"公司使命"字段直接就是 `company.description`, 没有默认值时会渲染成"待定义"。公司创建时没有任何 founding-goal 引导——`company-service.js` 全文搜不到任何"建司即建 goal"的 bootstrap 逻辑。也就是说,即便补上了 1.1/1.3 的缺口,如果 `company.description` 从未被认真填写过,CEO 自主立项时除了一句"待定义"没有任何可以依据的锚点, 强行立项等于让它凭空编目标。 ### 1.5 好消息:数据层的地基已经齐了,不需要新状态机 - `goals` 表本就有 `status='draft'` 默认值(`work-graph-db.js:499`)和 `created_by`(`work-graph-db.js:505`,注释直接写着 "`'human' | agent id(Master/CEO 代下单)`")。 - "建一个 goal 产出一律是 draft,入队是另一个动作"是既有明文约定 (`work-graph-db.js:1796`),`POST /:companyId/goals` 的实现完全符合这条约定 (`createGoal` 的 status 恒为 draft,见 `company.js:1836` 注释)。 - CEO 心跳里已经在用同一套"系统自动开草稿,人/CEO 再决定要不要 enqueue"的模式处理别的场景 (`ceo/heartbeat.md:49`提到的 `created_by: 'system'` 草稿任务)——本设计只是把这个已经验证过的 模式,从"task 级"复用到"goal 级"。 **一个需要在设计里明确处理的成本细节**:`POST /:companyId/goals` 默认路径 (`body.split !== false`)会立刻用 `setImmediate` 在后台跑 `runGoalSplit` (`company.js:1832-1852`),也就是说**即便只是"建一条草稿 goal",默认调用方式也会立刻烧 task-planning 的 token**,而不是零成本的占位。CEO 自主立项如果用默认参数,"提案还没被人看到" 之前就已经产生了真实开销。 --- ## 二、设计目标与非目标 **目标**:公司在没有任何 `draft`/`planning`/`active` goal 时,CEO 心跳能够依据公司使命提出一条 新的候选 goal(草稿态),而不是无限期地回复"心跳正常"、让公司永久停摆。 **非目标**(本文档不覆盖): - 不解决"OKR 周期性续期"(比如季度目标到期后自动开下一季度)——那是不同的触发条件, 本文档只覆盖"完全空转"这一种更紧迫的场景。 - 不改变"目标耗尽 → 无人接续"之外的任何执行层机制(存活扫描、僵尸收口等已经在正常工作, 不在本次范围)。 - 不实现具体的 guardrail 数值(预算上限、连续自动立项次数上限)——只给出这些参数应该存在, 具体数字留给立项时结合真实场景定。 --- ## 三、设计方案 ### 3.1 触发条件 **实现时核实(2026-09-17)**:`listCompanyGoals` 的 `status` 过滤是精确匹配单个值 (`work-graph-db.js:1836-1848`,`WHERE status = @status`),不支持逗号分隔的多状态查询—— `GET /goals?status=active,planning,draft` 会被当成字面量 `"active,planning,draft"`, 永远匹配不到任何行,等于查询恒为空、每次心跳都会被误判成"公司空转"。实际接线改成:查 `GET /goals`(不带 `status`,默认已排除 `archived`),CEO 自己看返回列表里每条的 `status` 是否都落在终态集合 `done`/`failed`/`cancelled`(与 `GOAL_TERMINAL_STATUS` 语义一致,见 `work-graph-db.js:2218`,只是不含 `archived`——那一档已经被 `includeArchived` 默认过滤掉了)。 在 `ceo/heartbeat.md` 的检查清单前面插入一条: > 0. 查 `GET /goals`(不加 `status`)。**全部都是终态、或列表为空**时,本次心跳进入 > "立项模式"(见下),跳过后面几条常规巡检(反正没有目标可巡);只要有一条落在 > `draft`/`planning`/`active`,就按原清单走。 ### 3.2 锚点:公司使命前置闸门 立项模式的第一步不是编目标,是检查锚点是否可用: - 读 `company-context.md` 的"公司使命"字段。 - 如果是空字符串或字面量"待定义":**不生成任何草稿 goal**,直接在心跳回复里写明"公司使命未 设置,无法自主立项,需要人类补充"。这条回复走现有的通知路径(心跳失败/异常场景本就会被人 看到),本质上是把"要不要开自转"这个决定权,通过"必须先填一句真实的使命"这个动作,留在人类 手里——不填使命,系统就不会替你决定公司该做什么。 - 使命存在时,CEO 依据使命 + 历史完成情况(`GET /goals?status=done`,看过去做过什么、还差什么, 避免重复立项或跟已完成的工作方向冲突)起草候选 goal 的 title/description。 ### 3.3 提案机制 调用 `POST /:companyId/goals`,需要显式传 `split:false`(对应 1.5 节的成本细节,见 `company.js:1830`——不传这个参数会立刻在草稿还没被人看到前烧掉拆分 token): ```bash curl -s -X POST "${LEMON_API_BASE}/company//goals" \ -H "x-internal-token: $LEMON_INTERNAL_TOKEN" -H "Content-Type: application/json" \ -d '{"description":"...","title":"...","created_by":"ceo","split":false}' ``` `created_by` 填 `"ceo"`(或更细粒度的 `"ceo:auto-refill"`,命名留给立项时按 `27-naming-conventions.md` 定),保证审计轨迹上能一眼区分"人类下的单"和"CEO 自主立项的单"—— 这是复用 `goals.created_by` 已有的语义(`work-graph-db.js:505` 注释本就写了 "agent id"), 不需要加新列。 对应地,需要在 `ceo/tools.md` 补上这个端点的使用说明——1.3 节确认了这是当前真正缺失的能力, 不只是加一条规则那么简单。 **`split:false` 的直接后果——实测跑过一遍,推翻了本文档最初的判断**:`company.js:1859-1862` 的同步路径写着"没给 `tasks` 就退化成 1 条同描述的 task",之前读这行注释时误判成"那就是 0 条", 实际起服务真调了一次 `POST /goals {split:false}` 才发现**建出来的是 1 条 `draft` task,不是 零条**——`goal` 和这条 task 都停在 `draft`,但 `POST /goals/:goalId/dispatch`(`company.js: 1978-1985`)判的是"这个 goal 下有没有 `draft` 状态的 task",1 条也满足,**直接就能 dispatch 成功、真的跑起来,没有任何接口会挡住**。也就是说 3.5 节最初设想的"零 task 逼审批人先显式加 task"这道技术闸门**不存在**——`split:false` 唯一确定省下的是 task-planning 的 token(同步 路径不调 agent,也已实测核实),不是"强制多一道人工步骤"。真正的、也是唯一的把关手段回落到 3.5 节重写后的版本:CEO 自己不许调用 `dispatch`。 ### 3.4 去重/节流 立项前先查一遍有没有已经存在、还没被处理的系统草稿。同 3.1 节的核实结果,`created_by` 同样 不是可查询过滤字段(`listCompanyGoals` 只认 `status`),实际查的是 `GET /goals?status=draft` (这个单值过滤支持),CEO 自己看返回列表里每条的 `createdBy` 字段是不是 `"ceo"`——命中就不 重复开,否则每次心跳间隔(默认 15 分钟一次)都会叠一条新草稿,草稿越堆越多、人类审查成本 线性上升,而不是"公司在自转"。 ### 3.5 两阶段放权 不建议一步做到"CEO 自己 enqueue"——那等于把"公司该做什么"这个最大的方向性决策完全交给一次 心跳里的一次性判断,没有任何回滚窗口。分两阶段: - **阶段 A(默认,安全)**:草稿建完后只通知(复用现有的 dispatch/通知机制),停在 `draft`。 3.3 节实测确认了 `split:false` 建出的草稿其实已经带着 1 条 `draft` task、技术上立刻就能 `dispatch` 成功——**没有任何接口层面的东西会替这一步把关**。所以阶段 A 的安全性完全押在 一条行为约束上:`ceo/tools.md` 明确写死"这条草稿建完,你不许自己调 `dispatch`",等人类 审过、人类自己按 `dispatch`。这比"技术闸门"弱——是纯提示词层面的约束,CEO 是否真的每次 都守规矩,需要观察真实运行几轮才能有把握。先跑几轮,观察提案质量、方向是否贴合真实使命、 以及这条"不许自己 dispatch"的约束有没有被绕过,再决定要不要开阶段 B。这是当前应该实现 的部分。 - **阶段 B(每公司显式开关,非默认,本文档不实现)**:CEO 自己完成 `dispatch` 这一步, 但需要 guardrail——单周期预算上限、连续 N 次自动立项后强制退回阶段 A 等人工看一眼、以及一个 能立刻关掉的 kill switch。这个开关本质上就是"这家公司要不要真正无人值守"的开关,需要在 company 级配置里加一个字段(具体形状留给立项时定,可能是 `companies` 表新列,也可能是 company-context 里的一个 flag——两种都不需要新状态机,只是存储位置的选择)。 --- ## 四、开放问题 / 风险 - **立项质量没有验证机制**:CEO 起草的候选 goal 目前只能靠人工审草稿判断"是不是瞎编", 没有类似 `org-validator` 那样的自动质检。阶段 A 阶段人工审查本身就是这道质检,但如果要推进 阶段 B,"提案质量怎么自动把关"是必须先解决的前置问题,不能跳过。 - ~~`split:false` 之后草稿的"入队即拆分"路径需要确认~~ **已实测(2026-09-17),且推翻了 2026-09-09 当天的初步判断**:最初以为 `/dispatch`(`company.js:1978-1985`)对 `split:false` 建出的 draft goal 会因为"零 task"直接 409,读代码判断如此;起服务真跑了一遍 `POST /goals {split:false}` 才发现同步路径其实自动带了 1 条 `draft` task (`company.js:1859-1862`),`dispatch` 判的是"有没有 `draft` 状态的 task",1 条就满足, **技术上立刻能跑,没有任何接口层面的东西挡着**。原先"这就是审批闸门本身"的结论是错的, 已重写进 3.3/3.5 节。 - ~~阶段 A 的唯一把关是提示词约束,不是代码闸门,是当前最大的剩余风险~~ **已加代码闸门 (2026-09-18,见七)**:`dispatch` 路由现在会判 `goal.created_by === 'ceo' && goal.updated_at === goal.created_at`(自建成后从没被 PATCH 过),命中直接 409。 `ceo/tools.md` 里"不许自己调 dispatch"那条提示词约束继续保留(纵深防御,不指望单一层), 但已经不是唯一的把关手段了。这道闸门本身是一个已知的简化(见七的 ponytail 说明), 不是万能的"确实被人认真审过"判据。 - **心跳频率与立项频率耦合**:3.4 节的去重能防止无限堆草稿,但没有解决"CEO 多久该重新评估一次 公司使命有没有新方向"这个更长期的问题——目前设计只覆盖"完全空转"这个最紧迫场景,节奏性的 战略复盘不在本文档范围(见二、非目标)。 --- ## 五、明确不做 - 不实现阶段 B(自动 enqueue + guardrail 数值)——只给出应该存在,具体数字留给有阶段 A 真实 运行数据之后再定。 - 不实现"公司级自转开关"字段的具体存储形状——留给立项时决定放在 `companies` 表还是 company-context。 - 不解决 OKR 周期性续期(非目标已声明)。 - 不实现提案质量的自动质检机制(四、开放问题已指出这是阶段 B 的前置阻塞项,不是本文档范围)。 --- ## 六、与既有文档的关系 - [27-goal-contract-format.md](./27-goal-contract-format.md):本设计完全复用它定义的 goal draft/dispatch 生命周期,不新增字段、不新增状态。 - `system/agents/ceo/heartbeat.md` / `tools.md`:本设计的落地点就是这两份文档——三、3.1/3.3 节描述的改动直接对应它们需要新增的具体规则和端点说明。 - [30-task-delegation-reliability-gaps.md](./30-task-delegation-reliability-gaps.md):该文档 记录的是"任务卡住后没人主动通知"的可靠性缺口,与本文档"没有任务时没人开新任务"是同一类 "系统该主动但目前被动"问题的两个不同表现,修复思路(草稿 + 通知,而非静默自动执行)是一致的。 --- ## 七、阶段 A 实现记录(2026-09-17) 对所有公司默认生效(用户拍板:不加公司级开关,`ceo/heartbeat.md` 直接对全量公司生效)。 - `system/agents/ceo/heartbeat.md`:心跳清单最前面插入空转检测(`GET /goals` 不带 `status`, 自己判每条 `status` 是否全落终态),命中就转入 `tools.md` 的立项步骤、跳过常规巡检; 未命中按原清单走,行为不变。 - `system/agents/ceo/tools.md`:新增「立项:公司没有任何活跃目标时才用」一节——使命前置闸门 (读 `company-context.md` 拼进提示词的"公司使命",空/"待定义"直接拒绝并转人工)、去重检查 (`GET /goals?status=draft`,client 侧过滤 `createdBy==='ceo'`)、`POST /goals` 建草稿(`split:false` + `created_by:'ceo'`),并在「你不该用的工具」表里明确加了一行: CEO 不许对自己刚建的 goal 草稿调 `dispatch`。 - **实现后又用真实起服务的方式核实了一遍(2026-09-17,不只是读代码)**,推翻/修正了三处 设计草稿阶段的判断,均已同步回本文档正文,设计文档与实现保持一致: 1. `listCompanyGoals` 不支持逗号分隔多状态查询、也不支持按 `created_by` 过滤——原设计草稿里 `status=active,planning,draft` 和 `status=draft&created_by=ceo` 两个查询实际都会静默 失效(前者恒空、会把"公司正常运转"误判成"公司空转";后者的 `created_by` 参数被服务端 忽略)。改为查询后 CEO 在自己的推理里做客户端过滤。 2. **最重要的一处**:`split:false` 建出的草稿其实自动带 1 条 `draft` task,不是零 task——`dispatch` 技术上立刻能成功,"零 task 逼人工先加 task"这道闸门根本不存在(见四)。 阶段 A 的安全性因此改为完全依赖 `ceo/tools.md` 里"不许自己调 dispatch"这条提示词约束, 不是代码层闸门——这是当前实现里最脆弱的一环,需要靠观察真实运行验证,读代码/读提示词 都验证不了。 3. 上述第 2 点发现前,`ceo/tools.md` 的编辑一度把"把你(或别人)提的草稿正式下达" (task 级 `enqueue` 的既有说明)误删了——一次 `Edit` 的 `old_string` 边界没圈对, 已在同一轮修回,顺带在它和新「立项」一节之间加了一句显式区分:"task 级草稿的自主 enqueue 权限不能类比到 goal 级草稿",避免 CEO 把两条权限混为一谈。 - 未改动任何路由 / 服务层代码——`POST /:companyId/goals`、`.../dispatch`、`listCompanyGoals` 均按现状直接复用,阶段 A 完全是 agent 人设文档层面的改动。 - 阶段 B(公司级自转开关、guardrail 数值、CEO 自动 enqueue)未实现,见五、明确不做。 --- ## 八、代码闸门实现记录(2026-09-18) 补上七、第 2 点标出的缺口——把"CEO 不许自己调 dispatch"从纯提示词约束升级成真正的代码闸门。 **改动**:`server/routes/company.js` 的 `POST /:companyId/goals/:goalId/dispatch` (`requireGoal` 之后、原有"没有待下达的 task"检查之前)新增一段判断: ```js if (goal.created_by === 'ceo' && goal.updated_at === goal.created_at) { return res.status(409).json({ error: `goal ${goal.id} 是 CEO 自主立项的草稿,还没有人工审阅过,不能直接下达`, ... }); } ``` **判据选择**:`updated_at === created_at`(自建成后从没被 `PATCH` 过)代理"有没有人碰过", 不新增数据库列。依据: - `createGoal`(`work-graph-db.js:1800-1825`)在 INSERT 时把 `created_at`/`updated_at` 设成同一个 `now` 值;`updateGoal`(走既有的 `PATCH /:companyId/goals/:goalId` 端点)每次 调用都会把 `updated_at` 刷新——两者结合,"从没被 PATCH 过"和"两个时间戳相等"严格等价。 - 已核实 `split:false` 同步创建 goal+task 的路径(`insertTasksForGoal`)只写 `tasks` 表, 不会碰 `goals` 行——不会出现"建完就已经被判定为碰过"的假阳性。 - `ponytail`:这是"有没有被 PATCH 过"代理"有没有被认真审过"的简化,天花板是"PATCH 一次 同值字段"也会被判定为"审过",分不清"真看懂了"和"随手碰一下解锁"。要分清这两者需要专门的 `reviewed_by`/`reviewed_at` 列(谁在什么时候明确按了"我看过了"),留给阶段 B 一起做——阶段 B 本来就要给"CEO 自己 dispatch"设计更细的 guardrail,那时候一并设计更精确的审阅判据更划算, 不为这一步单独开一次 schema 改动。 **测试**:`server/tests/unit/task-queue-routes.test.js` 新增两条用例(复用文件既有的 org graph + `newGoal` 测试夹具,无需新建 harness):`created_by:'ceo'` 且未 `PATCH` 过的草稿 直接 dispatch → 409,`PATCH` 一次后 dispatch → 201;`created_by` 是 `'human'`/不填的草稿不受 这道闸门影响,照常直接 dispatch(防止误伤既有行为)。相关四个测试文件(`task-queue-routes`/ `company-goal-task-routes`/`goal-task-acceptance`/`task-dispatch-store`,共 84 条用例)全部 通过,无回归。 **对三、3.5 节的影响**:阶段 A 现在是纵深防御——`ceo/tools.md` 的提示词约束是第一层(CEO 按设计根本不该去调 `dispatch`),本节的代码闸门是第二层(万一提示词约束失效,`created_by` 匹配的草稿在没被 `PATCH` 过之前技术上也跑不起来)。`ceo/tools.md` 的措辞保留不变,不因为 有了代码闸门就放松对 CEO 的行为要求。