Commit 14cbaf539ac31df9b110bd25edbe169bce34e015

Authored by yibo
1 parent 4d23f506

fix(graph-run): ceo 心跳自环调用补上 requireAuthOrInternal

39 号文档地基 0 当时只改了 company.js(goals/tasks 端点),graph-run.js
(run 的 start/stop/retry/budget 控制端点)漏了同一处修复,还是纯
requireAuth。真实端到端测试里 ceo 被唤醒后能读写 goal/task 板,但调
/api/graph-runs/* 处理卡住的 run 时精确撞上 401——ceo 自己在对话记录里
诊断出这一点并写出了人工需要跑的 curl 命令,间接验证了这条修复的必要性。

两个挂载点(router + companyRouter)都改成 requireAuthOrInternal,两处
:companyId/:id 的 param 中间件加了与 company.js 一致的 req.user?.internal
旁路。route-auth-gate.test.js 同步更新断言。全量测试 1469/1473,零回归。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwG4b7Nq1NLR9eeE3M9Us9
docs/todo/system-design/39-boss-agent-task-supervision.md
1 # 39. Boss Agent 任务看板监督 + 公司目标代码目录 — 需求评估与设计草案 1 # 39. Boss Agent 任务看板监督 + 公司目标代码目录 — 需求评估与设计草案
2 2
3 -> **状态**:📝 设计评估中,未实现。本文档是对用户口述五阶段方案的落地可行性评估 + 修订后的设计草案,不是最终定案。 3 +> **状态**:✅ P1-P5 已实现(2026-08-30,commit `a00590a` P1/P3/P4 后端、`5108d67` P2 前端、`d967ac0` P5)。本文档是落地前的设计草案,实现细节以代码为准,本文档不再更新,仅存档决策过程。**已知未覆盖的后续工作见文末新增的「六、已知遗留」**。
4 > **日期**:2026-08-30 4 > **日期**:2026-08-30
5 > **背景**:承接对话讨论——goal/task 两级模型([27-P3C](../../done/system-design/27-p3c-goal-task-model.md))已落地,但推进它的是纯确定性代码(`task-scheduler.js` + `transitionRun` 钩子),没有任何 agent 在持续监督这块看板、处理异常、把执行中发现的新问题回写成新任务。用户提出五阶段方案补上这一层,并追加公司级目标代码目录需求。 5 > **背景**:承接对话讨论——goal/task 两级模型([27-P3C](../../done/system-design/27-p3c-goal-task-model.md))已落地,但推进它的是纯确定性代码(`task-scheduler.js` + `transitionRun` 钩子),没有任何 agent 在持续监督这块看板、处理异常、把执行中发现的新问题回写成新任务。用户提出五阶段方案补上这一层,并追加公司级目标代码目录需求。
6 > **读者须知**:本文档大量引用现有代码路径核实"已有 vs 缺失",结论以文内给出的具体函数/文件为准;这些引用会随代码演进过期,落地前请重新核实。 6 > **读者须知**:本文档大量引用现有代码路径核实"已有 vs 缺失",结论以文内给出的具体函数/文件为准;这些引用会随代码演进过期,落地前请重新核实。
@@ -180,3 +180,13 @@ @@ -180,3 +180,13 @@
180 4. **`ceo` 可以全权自主 `enqueue` 自己提的 draft task,不需要等人类点**——这是五个选项里最激进的一档,明确高于 `task-planning`(§四.9 定案:产出永远先落 draft,人点"全部下达")的自主权限。**这个选择意味着安全网必须落在别处,不能靠"人工审核"这一层**:落地时要确保 `max_parallel_tasks`(防止一次性并发太多)、`DEFAULT_TASK_BUDGET`(防止单任务无限烧钱)、goal 级 `org-validator` 收敛验收(防止"完成"了但没真正达成)这几道既有闸门都乘上了 `ceo` 自主创建的 task,不能因为创建路径变了就绕开——这几道闸门本来就是数据层/调度器层强制的(不挂在"谁创建的"这个字段上),照理天然覆盖,但落地时要专门写一条回归测试钉死这一点。 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 并发"直接联动,需要一条并发约束兜底。 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 行)。 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 行)。
  183 +
  184 +---
  185 +
  186 +## 六、已知遗留(实现后核实,2026-08-30)
  187 +
  188 +代码落地后回查,发现两个当初设计里提过、但没有专门验证/没做完的点,记在这里而不是假装文档已经全部兑现:
  189 +
  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 +2. **`company.json.target_dir`/`target_mode` 没有任何 API/UI 入口去设置**——现在只能手工编辑公司态目录下的 `company.json` 文件才能配置。P5 落地的是"读取并生效"这一半,"人怎么把这两个字段填进去"这一半还没做(哪怕只是一个 `PATCH /api/company/:cid` 端点)。
  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` 需要触达的控制面横跨两个路由文件,遗漏了没有靠代码审查发现,是靠真实跑一遍才暴露的。
server/routes/graph-run.js
@@ -41,7 +41,7 @@ const { @@ -41,7 +41,7 @@ const {
41 RunSlotBusyError, 41 RunSlotBusyError,
42 } = require('../services/work-graph-db'); 42 } = require('../services/work-graph-db');
43 const { companyRootDir } = require('../services/company-paths'); 43 const { companyRootDir } = require('../services/company-paths');
44 -const { requireAuth } = require('../middleware/jwt-auth'); 44 +const { requireAuthOrInternal } = require('../middleware/jwt-auth');
45 const { CompanyService, canAccessCompany } = require('../services/company-service'); 45 const { CompanyService, canAccessCompany } = require('../services/company-service');
46 46
47 const router = express.Router(); 47 const router = express.Router();
@@ -50,8 +50,11 @@ const companyService = new CompanyService(); @@ -50,8 +50,11 @@ const companyService = new CompanyService();
50 50
51 // 38 号文档 Phase 1:全站要求登录(两个挂载点都要挂,§六风险 2 —— 这个文件以两种方式 51 // 38 号文档 Phase 1:全站要求登录(两个挂载点都要挂,§六风险 2 —— 这个文件以两种方式
52 // 挂载到 index.js,改的时候容易漏一个)。 52 // 挂载到 index.js,改的时候容易漏一个)。
53 -router.use(requireAuth);  
54 -companyRouter.use(requireAuth); 53 +// 39 号文档:agent 的自环调用(ceo 心跳查/停/重试 run)没有用户 cookie,改用
  54 +// requireAuthOrInternal(放行带对 INTERNAL_TOKEN 的自环调用),与 company.js 同一处理方式
  55 +// —— ceo 能读写 goal/task 板却摸不到这里的 run 控制端点,是真实撞出来的缺口,不是预防性加固。
  56 +router.use(requireAuthOrInternal);
  57 +companyRouter.use(requireAuthOrInternal);
55 58
56 // 38 号文档 Phase 2:公司归属校验。两个挂载点的参数形状不一样,不能抄同一份 companyId 收口: 59 // 38 号文档 Phase 2:公司归属校验。两个挂载点的参数形状不一样,不能抄同一份 companyId 收口:
57 // - companyRouter 的唯一路由是 /:companyId/graph-runs,直接按 companyId 查。 60 // - companyRouter 的唯一路由是 /:companyId/graph-runs,直接按 companyId 查。
@@ -60,6 +63,13 @@ companyRouter.use(requireAuth); @@ -60,6 +63,13 @@ companyRouter.use(requireAuth);
60 // 两处判据都调 company-service.js 的 canAccessCompany,不各写一份。 63 // 两处判据都调 company-service.js 的 canAccessCompany,不各写一份。
61 companyRouter.param('companyId', (req, res, next, companyId) => { 64 companyRouter.param('companyId', (req, res, next, companyId) => {
62 const company = companyService.get(companyId); 65 const company = companyService.get(companyId);
  66 + // 39 号文档:内部自环调用(req.user.internal)没有"当前登录用户"这个概念,不能对它
  67 + // 做归属校验——与 company.js 的 :companyId param 中间件是同一处理方式。
  68 + if (req.user?.internal) {
  69 + if (!company) return res.status(404).json({ error: `公司 ${companyId} 不存在` });
  70 + req.company = company;
  71 + return next();
  72 + }
63 if (!canAccessCompany(company, req.user)) { 73 if (!canAccessCompany(company, req.user)) {
64 return res.status(404).json({ error: `公司 ${companyId} 不存在` }); 74 return res.status(404).json({ error: `公司 ${companyId} 不存在` });
65 } 75 }
@@ -71,6 +81,10 @@ router.param(&#39;id&#39;, (req, res, next, runId) =&gt; { @@ -71,6 +81,10 @@ router.param(&#39;id&#39;, (req, res, next, runId) =&gt; {
71 const run = getRun(runId); 81 const run = getRun(runId);
72 if (!run) return res.status(404).json({ error: `run ${runId} 不存在` }); 82 if (!run) return res.status(404).json({ error: `run ${runId} 不存在` });
73 const company = companyService.get(run.company_id); 83 const company = companyService.get(run.company_id);
  84 + if (req.user?.internal) {
  85 + req.run = run;
  86 + return next();
  87 + }
74 if (!canAccessCompany(company, req.user)) { 88 if (!canAccessCompany(company, req.user)) {
75 // run 存在但公司不是你的:仍然回 run 不存在,不暴露"有这条 run,只是不是你的"。 89 // run 存在但公司不是你的:仍然回 run 不存在,不暴露"有这条 run,只是不是你的"。
76 return res.status(404).json({ error: `run ${runId} 不存在` }); 90 return res.status(404).json({ error: `run ${runId} 不存在` });
server/tests/unit/route-auth-gate.test.js
@@ -56,10 +56,10 @@ describe(&#39;route-auth-gate(38 号文档 Phase 1)&#39;, () =&gt; { @@ -56,10 +56,10 @@ describe(&#39;route-auth-gate(38 号文档 Phase 1)&#39;, () =&gt; {
56 expectGated(require('../../routes/devloop.js'), requireAuth, 'devloop.js'); 56 expectGated(require('../../routes/devloop.js'), requireAuth, 'devloop.js');
57 }); 57 });
58 58
59 - it('graph-run.js 两个挂载点都挂了 requireAuth(router + companyRouter)', () => { 59 + it('graph-run.js 两个挂载点都挂了 requireAuthOrInternal(39 号文档:ceo 心跳要能读/停/重试 run,不能只有 company.js 一侧放行 agent 自环调用)', () => {
60 const graphRun = require('../../routes/graph-run.js'); 60 const graphRun = require('../../routes/graph-run.js');
61 - expectGated(graphRun, requireAuth, 'graph-run.js router');  
62 - expectGated(graphRun.companyRouter, requireAuth, 'graph-run.js companyRouter'); 61 + expectGated(graphRun, requireAuthOrInternal, 'graph-run.js router');
  62 + expectGated(graphRun.companyRouter, requireAuthOrInternal, 'graph-run.js companyRouter');
63 }); 63 });
64 64
65 it('okr.js 挂了 requireAdmin(孤儿页面,前端无入口,收紧到 admin-only,见 38 号文档 Phase 2)', () => { 65 it('okr.js 挂了 requireAdmin(孤儿页面,前端无入口,收紧到 admin-only,见 38 号文档 Phase 2)', () => {