From ab43ffce5d96d26bf636f99b80c6df69be78348a Mon Sep 17 00:00:00 2001 From: yibo Date: Fri, 18 Sep 2026 23:12:23 +0800 Subject: [PATCH] feat(goals): dispatch 拒绝未经人工审阅的 CEO 自主立项草稿 --- docs/README.md | 2 +- docs/todo/system-design/51-ceo-autonomous-goal-refill-design.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++------ server/routes/company.js | 16 ++++++++++++++++ server/tests/unit/task-queue-routes.test.js | 27 +++++++++++++++++++++++++++ system/agents/ceo/tools.md | 3 ++- 5 files changed, 97 insertions(+), 8 deletions(-) diff --git a/docs/README.md b/docs/README.md index 24475b1..5bd7185 100644 --- a/docs/README.md +++ b/docs/README.md @@ -70,7 +70,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | | [49-antigravity-inspired-parallel-verification-proposal.md](./todo/system-design/49-antigravity-inspired-parallel-verification-proposal.md) | 📝 **设计讨论,不代表已拍板**(2026-09-04)。对照 Google Antigravity「Teamwork」多智能体框架博客文章,给 27 号 P4「真并行」与 34/35 号「验证严谨度不稳定」两个议题出的方案草稿。**先纠正一个前提**:重读 27-P4 全文后发现阶段 A/B/C 早已全部实现(又一次「文档落后于代码」),当时判断真并行只剩 T2/role 列/`advanceTree` 兄弟子 run 并发三件小事——2026-09-08 收口时又发现这个判断本身有两处误判(role 列其实早就落库、T1 的✅遗漏了 scope 参数的生产接线),三件小事(连同两处误判一起)已全部核实/补齐,见 [27-P4](./done/system-design/27-p4-parallel-and-artifacts.md)。**新提案 A**:借鉴长证明模式的「竞争候选+对抗批评+综合」,区别于现有 `executor-fanout.json` 的分解型 fan-out——需要先有真实场景验证价值,不建议凭空立项,详细设计见 [50 号文档](./todo/system-design/50-competing-candidates-critic-synthesis-design.md)。**新提案 B**:把 35 号提案 D「浏览器关键词命中才要证据」泛化成 benchmark 段全维度默认要求执行级证据(**唯一建议现在推进的部分**);实地核实过把验收路径引导到 `test_script` 段的工作量后**结论是不做**——撞上 `test_script.path` 解析根不含 `org-coder` 实际写代码的项目根这个结构性冲突,改造面远超预期;新增「陷阱注册表」,把 validator 判卷失败的结构化原因喂回 Dream 机制,落点已定案为独立文件 `known_pitfalls.md`(不复用 `memory.md`,避免语义混淆)。建议推进顺序:陷阱注册表最低成本先做,证据泛化次之,竞争候选需要真实场景才立项 | | [50-competing-candidates-critic-synthesis-design.md](./todo/system-design/50-competing-candidates-critic-synthesis-design.md) | 📝 **设计草稿,不代表已拍板**(2026-09-04)。49 号文档提案 A 的展开:竞争候选骨架 JSON 形状草稿(`candidate-council.json`,未定案)、命名(按 27-NAMING 节点带前缀/本体不带前缀两轴)、`workgraph-planning` 选型判据方向。**关键设计约束**:critic(评判)与 synthesis(择优/融合)必须拆成两个节点,依据是仓库真实事故——`qa-judge` 早期"评判+终态判决合一"设计导致过一次无限循环(20 次调用/31 分钟才熔断,记录在 `deliverable.json` 的反模式说明里),新骨架延续 `validator-triager`/`benchmark-validator` 已验证的"归因/打分/判定三者分离"范式。**预算风险量化**:N 路候选并行消耗 N 倍预算,且 `checkBudget` 只在批量拾取前查一次,实际可能超支到 `maxConcurrency-1`(默认最多多花 4 次调用)才被拦下,候选数 N 目前代码层没有硬顶 | -| [51-ceo-autonomous-goal-refill-design.md](./todo/system-design/51-ceo-autonomous-goal-refill-design.md) | ✅ **阶段 A 已实现**(2026-09-09 设计 / 2026-09-17 实现+实测)。回应"公司能否 7×24 自转"的提问——核实执行层(存活扫描/僵尸 goal 收口/CEO 阻塞任务兜底心跳)已经扎实,真正缺口在决策层:`ceo/heartbeat.md` 与 `tools.md` 原本"该提新任务"判断全部要求先有一个 goal/task 存在,没有一条覆盖"公司没有任何 active/draft/planning goal"这种完全空转场景;CEO 也原本**没有创建 goal 的工具**(`POST /:companyId/goals` 存在于路由层但从未暴露给它)。已接线:心跳先查 `GET /goals` 判空转 → 查公司使命("待定义"时拒绝立项转人工)→ 去重 → `POST /goals` 建 `split:false` + `created_by:'ceo'` 的草稿。**起服务实测(不只是读代码)纠正了两处判断**:① 原设计的多状态/`created_by` 查询参数服务端不支持,改客户端过滤;② 最初以为零 task 的草稿会让 `/dispatch` 天然 409、这就是审批闸门——实测发现 `split:false` 其实自动带 1 条 draft task,`dispatch` 技术上立刻能成功,**没有代码层闸门**,阶段 A 的安全性改为完全依赖 `ceo/tools.md` 里"不许自己调 dispatch"这条提示词约束,是当前最脆弱的一环(见四)。对所有公司默认生效,未改任何路由/服务层代码,纯 agent 人设文档改动(见七)。阶段 B(CEO 自动 dispatch + guardrail + 公司级开关)仍是设计草稿,未实现 | +| [51-ceo-autonomous-goal-refill-design.md](./todo/system-design/51-ceo-autonomous-goal-refill-design.md) | ✅ **阶段 A 已实现 + 代码闸门已补上**(2026-09-09 设计 / 2026-09-17 实现+实测 / 2026-09-18 代码闸门)。回应"公司能否 7×24 自转"的提问——核实执行层已经扎实,真正缺口在决策层:`ceo/heartbeat.md`/`tools.md` 原本没有一条覆盖"公司没有任何 active/draft/planning goal"这种完全空转场景,CEO 也原本没有创建 goal 的工具。已接线:心跳查空转 → 查公司使命("待定义"拒绝转人工)→ 去重 → `POST /goals` 建 `split:false` + `created_by:'ceo'` 的草稿。**实测(不只是读代码)纠正了两处判断**:多状态/`created_by` 查询参数服务端不支持,改客户端过滤;`split:false` 其实自动带 1 条 draft task、`dispatch` 技术上立刻能成功——最初设想的"零 task 闸门"不存在。**2026-09-18 补上真正的代码闸门**:`POST /goals/:goalId/dispatch` 新增判断,`created_by==='ceo'` 且 `updated_at===created_at`(从没被 `PATCH` 过)直接 409,用既有字段代理"有没有人碰过",不加新列(已知简化,见文内 ponytail 说明,阶段 B 会换成更精确的 `reviewed_at`);`task-queue-routes.test.js` 新增 2 条用例,相关 4 个测试文件 84 条用例全绿无回归。阶段 B(CEO 自动 dispatch + guardrail + 公司级开关)仍是设计草稿,未实现 | ## todo/future/(暂缓,非当前重心) diff --git a/docs/todo/system-design/51-ceo-autonomous-goal-refill-design.md b/docs/todo/system-design/51-ceo-autonomous-goal-refill-design.md index 97d67bb..f141089 100644 --- a/docs/todo/system-design/51-ceo-autonomous-goal-refill-design.md +++ b/docs/todo/system-design/51-ceo-autonomous-goal-refill-design.md @@ -1,8 +1,9 @@ # 51 · CEO 心跳自主立项设计——补齐"目标耗尽后无人接续"的缺口 -> **状态**: ✅ **阶段 A 已实现**(2026-09-17,见七)。阶段 B(CEO 自动 enqueue + guardrail) -> 仍是设计草稿,不代表已拍板,公司级配置字段形状、guardrail 数值需要单独立项再定案。 -> **日期**: 2026-09-09(设计)/ 2026-09-17(阶段 A 实现) +> **状态**: ✅ **阶段 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 阻塞任务兜底心跳)已经相当扎实, > 真正的缺口在**决策层**:没有任何机制在"公司没有任何活着的目标"时提出下一个目标。 @@ -189,9 +190,13 @@ task"这道技术闸门**不存在**——`split:false` 唯一确定省下的是 `POST /goals {split:false}` 才发现同步路径其实自动带了 1 条 `draft` task (`company.js:1859-1862`),`dispatch` 判的是"有没有 `draft` 状态的 task",1 条就满足, **技术上立刻能跑,没有任何接口层面的东西挡着**。原先"这就是审批闸门本身"的结论是错的, - 已重写进 3.3/3.5 节:阶段 A 的唯一把关变成了"`ceo/tools.md` 明确写死 CEO 不许自己调 - `dispatch`",一条纯提示词约束,不是代码层面的硬闸门。**这是本文档目前最大的剩余风险**—— - 这条约束是否真的会被 CEO 稳定遵守,只能靠实际跑几轮观察,读代码/读提示词都验证不了。 + 已重写进 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 多久该重新评估一次 公司使命有没有新方向"这个更长期的问题——目前设计只覆盖"完全空转"这个最紧迫场景,节奏性的 战略复盘不在本文档范围(见二、非目标)。 @@ -251,3 +256,43 @@ task"这道技术闸门**不存在**——`split:false` 唯一确定省下的是 - 未改动任何路由 / 服务层代码——`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 的行为要求。 diff --git a/server/routes/company.js b/server/routes/company.js index 9e06bf1..cf06ac1 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -1982,6 +1982,22 @@ router.post('/:companyId/goals/:goalId/dispatch', (req, res) => { const goal = requireGoal(req, res); if (!goal) return; + // 51 号文档「CEO 自主立项」:CEO 心跳在公司空转时会自己起草一条 `created_by:'ceo'` 的 + // goal 草稿,设计上这条草稿必须等人审过才能跑——但草稿本身(`split:false`)建出来就已经 + // 带着 1 条 draft task,技术上立刻能 dispatch,原设计以为的「零 task 挡住」并不存在(见 + // 51 号 §四)。这里补上真正的代码闸门:`updated_at === created_at` 说明这一行自建成后 + // 从没被 PATCH 过,没人碰过。 + // ponytail: 用「有没有被 PATCH 过」代理「有没有被人审过」,是省一次 schema 改动的简化—— + // 天花板是「PATCH 一次同值字段」也会被判定为「审过」,判不出内容有没有真被看懂。真要分清 + // 「审过」和「随手碰过」,需要专门的 `reviewed_by`/`reviewed_at` 列,留给阶段 B 一起做。 + if (goal.created_by === 'ceo' && goal.updated_at === goal.created_at) { + return res.status(409).json({ + error: `goal ${goal.id} 是 CEO 自主立项的草稿,还没有人工审阅过,不能直接下达`, + hint: `先 PATCH /api/company/${companyId}/goals/${goal.id}(哪怕只是确认一下 title/priority)` + + `做一次人工确认,再重试 dispatch`, + }); + } + const drafts = wdb.listGoalTasks(goal.id).filter((t) => t.status === 'draft'); if (!drafts.length) { return res.status(409).json({ diff --git a/server/tests/unit/task-queue-routes.test.js b/server/tests/unit/task-queue-routes.test.js index 38f7ca9..17a1101 100644 --- a/server/tests/unit/task-queue-routes.test.js +++ b/server/tests/unit/task-queue-routes.test.js @@ -176,6 +176,33 @@ describe('批 3 排队 · HTTP 端点(27-P3C §六.3)', () => { }); // ──────────────────────────────────────────────────────────────── + // 51 号文档:CEO 自主立项的草稿,没人碰过不许 dispatch + // ──────────────────────────────────────────────────────────────── + + it('created_by=ceo 且没被 PATCH 过的草稿,dispatch 直接 409;PATCH 一次之后放行', async () => { + const cid = newCompany(); + const goal = await newGoal(cid, [{ description: '干活' }], { created_by: 'ceo' }); + + const [blockedStatus, blockedBody] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); + expect(blockedStatus).toBe(409); + expect(blockedBody.error).toMatch(/CEO 自主立项/); + + const [patchStatus] = await call('PATCH', `/api/company/${cid}/goals/${goal.goalId}`, { priority: 'P0' }); + expect(patchStatus).toBe(200); + + const [dispatchStatus, dispatchBody] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); + expect(dispatchStatus).toBe(201); + expect(dispatchBody.started.taskId).toBe(goal.tasks[0].taskId); + }); + + it('created_by=human(或不填)的草稿不受这道闸门影响,照常直接 dispatch', async () => { + const cid = newCompany(); + const goal = await newGoal(cid, [{ description: '干活' }]); // 不传 created_by + const [status] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`); + expect(status).toBe(201); + }); + + // ──────────────────────────────────────────────────────────────── // 验收 3:409 消失 // ──────────────────────────────────────────────────────────────── diff --git a/system/agents/ceo/tools.md b/system/agents/ceo/tools.md index 7fd2f25..7e8f51d 100644 --- a/system/agents/ceo/tools.md +++ b/system/agents/ceo/tools.md @@ -124,7 +124,8 @@ draft task 挂在这个 goal 下——不是零 task。这意味着这个 goal æ `POST /goals//dispatch`**——立项模式下你的职责到"建完草稿"为止,`dispatch` 这一步 永远留给人类去按。别把"提一条新任务"那节里"你被授权可以自主 enqueue"的权限类比到这里: 那是对着人类已经看过的 goal 下加的具体 task,这里是你自己凭使命猜出来的新方向,两件事 -风险级别不一样,不能套用同一条权限。 +风险级别不一样,不能套用同一条权限。(2026-09-18 起 `dispatch` 端点自己也会拒绝这种 +没被 `PATCH` 过的 `created_by:'ceo'` 草稿——但这是兜底,不是允许你去试的理由。) **`decomposed_work` 子任务走同一个端点**——`subtask-planning` 已经把内容拆好了,你不用再判断 "清不清楚",只判断节奏(现在放还是等)。同一条 task 下的子任务可以一次性全部 enqueue, -- libgit2 0.21.4