51-ceo-autonomous-goal-refill-design.md 21.1 KB

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(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 的检查清单前面插入一条:

  1. 查 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):

curl -s -X POST "${LEMON_API_BASE}/company/<companyId>/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:本设计完全复用它定义的 goal draft/dispatch 生命周期,不新增字段、不新增状态。
  • system/agents/ceo/heartbeat.md / tools.md:本设计的落地点就是这两份文档——三、3.1/3.3 节描述的改动直接对应它们需要新增的具体规则和端点说明。
  • 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"检查之前)新增一段判断:

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 的行为要求。