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