Commit 14cbaf539ac31df9b110bd25edbe169bce34e015
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
Showing
3 changed files
with
31 additions
and
7 deletions
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('id', (req, res, next, runId) => { | @@ -71,6 +81,10 @@ router.param('id', (req, res, next, runId) => { | ||
| 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('route-auth-gate(38 号文档 Phase 1)', () => { | @@ -56,10 +56,10 @@ describe('route-auth-gate(38 号文档 Phase 1)', () => { | ||
| 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)', () => { |
-
mentioned in commit 853d2f0b843141b9463253fdcfd0d8c33967fc18