# 42. Org/Work Graph 可靠性收尾 —— 39 号文档真跑复盘的待办 > **状态**:Phase 1-4 全部 ✅ 已实现(2026-08-30)。Phase 3 前端缺一次真实浏览器验证(见该节说明),其余无已知缺口。 > **日期**:2026-08-30 > **背景**:[39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md) 的 P1-P5 落地后,用它做了一次真实端到端测试(建公司 → 下 goal → `ceo` 监督 → 真实跑出一个可玩的贪吃蛇游戏,Playwright 5/5 验证通过)。**1469 个单测全绿,这次真跑却测出至少 3 个单测完全没覆盖到的真问题**——这份文档是那次复盘沉淀下来的待办,按开工顺序分四个 phase。 > **读者须知**:每个 phase 的现状描述都来自那次真实测试里实际撞到的场景,不是推测;具体文件/行号落地前按惯例重新核实。 --- ## 一、为什么排这个顺序 四个 phase 分两类:**Phase 1-2 是"让已经在跑的东西更可靠"**(不改变行为,只是补上恢复路径和验证),**Phase 3-4 是"补功能缺口"**(新增能力)。前者优先——真跑复盘里代价最大的坑(僵尸节点、鉴权漏挂)都是"东西本来能跑,只是卡住了没人能救"这一类,越早补上,后面几个 phase 开工时越不容易被同样的坑绊到。Phase 5(timeline 承接规划轨迹)单独放最后,因为它是结构性改动,需要先摸清 `org-coder` 之外还有哪些角色也在打那条断线的 `/task/transition`,工作量比其余四个 phase 加起来还大。 ``` Phase 1:卡住恢复闭环(僵尸节点检测 + 强制判失败端点)——两个互补的口子一起补 Phase 2:§四.4 闸门穿透性回归测试 —— 纯测试,零设计风险 Phase 3:target_dir 的 API/UI 入口 —— 纯加法 Phase 4:timeline.ndjson 承接 planning/reflection 轨迹 —— 结构性改动,排最后 ``` --- ## 二、Phase 1:卡住恢复闭环 —— ✅ 已实现(2026-08-30) **落地结果**(与下面的设计一一对应,实现时没有偏离): - 1a:`graph-engine.js` 新增 `sweepOrphanedRunningNodes(cutoffIso)`,复用 `routeFailure` 的 `infra_flake` 分支;`server/index.js` 在 `PROCESS_STARTED_AT`(模块顶层捕获的本次进程启动时刻)挂上开机一次性调用,排在其余「开机补一次全量扫描」钩子之前。测试:`server/tests/unit/graph-engine-orphan-sweep.test.js`(4 例:孤儿转 infra_flake 原地重跑、晚于 cutoff 的不受影响、连续判孤儿撞硬顶转 blocked、无孤儿时安静 no-op)。 - 1b:`graph-engine.js` 新增 `forceFailNode(runId, nodeRunId, failReason='operator_intervention')`;`graph-run.js` 新增 `POST /:id/nodes/:nodeId/force-fail`,只接受 `status='running'`。测试:`server/tests/unit/graph-run-force-fail.test.js`(8 例:正常转 fail 并可被 retry 接上、自定义 failReason 透传、五种非 running 状态各返 400、节点不存在返 404)。 - 全量测试 1483/1487,零回归(剩余 4 个是核实过的 pre-existing 失败)。 --- ### 1a. 服务器重启的僵尸节点检测 **现状(真跑撞到的)**:`graph_run_nodes.status='running'` 这一行,没有任何机制确认"背后真的还有个活的进程"。真实测试里为了让 `graph-run.js` 鉴权修复生效重启了一次服务器,正在跑 `org-planner-sdd` 的 Claude CLI 子进程被连带杀掉,但那一行的状态永远停在 `running`——不会自己变成 `blocked`,`ceo` 的两条唤醒路径(事件驱动 + 30 分钟兜底扫描)判据都是 `status='blocked'`,对这个状态完全不可见。 查过现成的"重启后清理残留"机制:`server/index.js` 的 `resumeIncompleteTasks()` 只处理 ArenaSK 的 Skill 训练任务(`skill-task-manager`),跟 org/work graph 毫无关系;`shutdown()`(`SIGTERM`/`SIGINT` 处理)只关插件管理器,不碰 `graph_run_nodes`。两处都不覆盖这个场景。 **设计**:启动时扫一遍 `graph_run_nodes`,`status='running'` 且 `updated_at`(或 `startedAt`)早于**本次进程启动时间**的行,判定为孤儿(整个进程树都被换过一轮,不可能有真实进程还在跑它)。判定后按 **`infra_flake` 语义**处理,不发明新状态——原地重跑逻辑本来就是为"基础设施打断,跟设计/实现对不对无关"这类场景设计的,服务器重启只是这个桶里更严重的一个成员。复用 `graph-engine.js` 已有的 `routeFailure` 里 `INFRA_FLAKE_REASON` 那条分支,不用另起一套判定/熔断逻辑。 **落点**:`server/index.js` 启动流程(挂在 `resumeIncompleteTasks()`/`sweepStuckActiveGoals()` 同一批"开机补一次全量扫描"的地方,跟这些既有钩子形状一致),加一个 `sweepOrphanedRunningNodes()`;判定逻辑放 `graph-engine.js` 或 `work-graph-db.js`(参照 `infra_flake` 现有实现挂在哪个文件)。 **验收**:新增测试——起一个 `status='running'`、`startedAt` 早于"当前进程启动时间"的节点,跑一次 `sweepOrphanedRunningNodes()`,断言它被转成 `infra_flake` 路径能处理的状态,且不影响 `startedAt` 晚于启动时间的正常运行节点。 ### 1b. "强制判失败以便恢复"的 REST 端点 **现状**:`POST /:id/nodes/:nodeId/retry` 只接受 `status ∈ {fail, blocked}` 的节点。真实测试里遇到一个卡住的 `running` 节点,唯一的恢复手段是**直接写一段脚本调用 `graph-run-sse.transitionNode` 内部函数**,绕过了任何 REST 接口——这对人类操作者或 `ceo` 都不现实,两者都只能走 HTTP。 **设计**:新增 `POST /api/graph-runs/:id/nodes/:nodeId/force-fail`,语义是"我确认这个节点已经不可能再自己完成了,把它判成 `fail`(`failReason` 可选,默认某个通用值如 `operator_intervention`),交给既有的 fail→retry/backward-edge 路径处理"。**只接受 `status='running'` 的节点**(`fail`/`blocked` 已经有 `retry` 端点了,不重复造轮子);调用方是人类还是 `ceo` 不做区分,鉴权走已有的 `requireAuthOrInternal`(跟着 Phase 1a 一起补,`graph-run.js` 这次已经全部换过了)。1a 落地后,这个端点的日常触发频率应该会显著下降(大部分"卡住"场景 1a 已经自动兜底),但保留它作为人工/`ceo` 判断"这个不是重启造成的,就是卡了"时的手动出口。 **验收**:新增测试——`running` 节点调 `force-fail` 转 `fail` 且可被 `retry` 端点接上;`fail`/`blocked`/`pending` 状态调用返回 400(沿用 `ensureStatus` 现成的错误形状)。 --- ## 三、Phase 2:§四.4 闸门穿透性回归测试 —— ✅ 已实现(2026-08-30) **落地结果**:新建 `server/tests/unit/ceo-created-task-gates.test.js`,3 例全部走 `ceo` 实际会用的通路(`POST /tasks` 的 `draft:true` + `created_by:'ceo'` → `/enqueue`,不抄近道直接摆数据库行): 1. `max_parallel_tasks=1` 时坑已占满,`ceo` 建的第二条任务落 `queued`,不插队。 2. 预算给 `total_agent_calls:0`,`engine.checkBudget()` 照样判耗尽,`created_by='ceo'` 不改变结果。 3. `ceo` 建的两条 task 挂在同一个 goal 下,全部 `completed` 后 `goal-validation` 的 `validateGoal` 钩子只被调一次(用 `validation.setValidateGoal()` 注入假验收,照抄 `goal-task-acceptance.test.js` 的既有手法)。 全量测试 1486/1490,零回归(剩余 4 个是核实过的 pre-existing 失败)。 **过程小插曲**:驱动第二条 task 的 run 出队时,先试了直接调 `task-scheduler.tick()`——撞上一个跟这次改动无关的既有工具链毛刺(`require.cache` 手工失效对 vitest 的 CJS 模块图不完全生效,`task-scheduler.js` 惰性 `require` 的 `work-graph-db` 引用有时会绑定到清缓存之前的实例,触发 `graph-db.js` 的"测试进程别碰生产库"守卫)。绕开的办法是不依赖 `scheduleTick` 的 `setImmediate` 时序,坑空出来后直接调 `updateRunStatus(runB,'queued','ready')`——这条路径本来就是准入事务的真实实现,只是不经过调度器这层编排,测试意图("闸门拦不拦得住")不受影响。 **现状**:39 号文档 §四.4 已拍板"`ceo` 全权自主 `enqueue`,但 `max_parallel_tasks`/`DEFAULT_TASK_BUDGET`/goal 级 `org-validator` 这几道闸门必须照样拦住它",落地时没有补这条测试(§六"已知遗留"记过一笔)。理论上这几道闸门挂在数据层/调度器层,不认"谁创建的",天然覆盖,但没有测试钉死就不能说"已确认"。 **设计**:不涉及任何设计决策,纯粹是补测试。新增用例(大致形状,落地时按现有测试文件的辅助函数抄写法): 1. 把公司 `max_parallel_tasks` 设成 1,已有一条 task 占着坑,`ceo` 走 `draft`+`enqueue` 建第二条——断言它排队而不是插队执行。 2. 把公司 `DEFAULT_TASK_BUDGET` 设小,`ceo` 建的任务跑到预算耗尽——断言按现有 `budget-exhausted` 路径 `blocked`,不会因为"是 agent 建的"就获得额外预算。 3. `ceo` 建的多 task goal 全部 `completed` 后,断言 goal 级 `org-validator` 照样被调一次(不因创建者是 agent 而跳过收敛验收)。 **落点**:新建 `server/tests/unit/ceo-created-task-gates.test.js`,或并入现有 `task-blocked-ceo-wake.test.js`(按当时哪个更顺手)。 --- ## 四、Phase 3:`target_dir` 的 API/UI 入口 —— ✅ 已实现(2026-08-30) **落地结果**: - 后端复用了已存在的 `PUT /api/company/:companyId`(`companyService.update()`),没有新建端点——设计里"如果已存在就扩展"这条命中了。`update()` 新增 `target_dir`/`target_mode` 分支,校验直接调 `path-resolver.validateLinkedTarget`(39 号文档 P5 抽出来的共享函数,和 `graph-engine.js` 运行时校验同一份判据),校验失败抛出,路由层 `try/catch` 转 400;`target_dir` 传空字符串清空配置;不传 `target_mode` 时默认 `linked-existing`,与 `graph-engine.js` 的 `readCompanyTargetConfig` 同一口径。测试:`server/tests/unit/company-target-dir-config.test.js`(8 例:`linked-existing`/`linked-new` 各自成功路径、默认 mode、敏感路径黑名单、`linked-new` 非空目录、`linked-existing` 不存在、清空配置、公司不存在)。 - 前端:`CompanyDashboard.vue` 的"概览"tab 加一张卡片(目标目录输入框 + 模式下拉 + 保存按钮),本地表单状态不直接双向绑定 `selectedCompany`(保存失败时不该让用户刚打的字被冲掉)。 **验证方式的取舍**:`npm run web:build` 编译通过 + 后端测试直接打真实路由层(不 mock service)。**没有做真实浏览器点击验证**——当时服务器上有其它并行会话真实在跑的 agent 进程(`ps` 能看到 3 个 `claude` 子进程),而重启服务器才能让后端改动生效,42 号文档 §二 已经记过一次"重启服务器杀掉别人真实进程"的教训,这次选择不重复。**这是一个已知的验证缺口,不是"做完了"**,下次服务器有安全窗口(没有并行会话在跑真实进程)时应该补一次真实点击验证。 --- **现状**:P5 落地的是"配了 `company.json.target_dir` 就生效"这一半,"人怎么把这两个字段填进去"这一半没做——真实测试里是手工编辑公司态目录下的 JSON 文件配的。 **设计**: - **后端**:`PATCH /api/company/:companyId`(如果这个端点已存在就扩展,不存在就新建)接受 `target_dir`/`target_mode`,写回 `company.json`。校验复用 `path-resolver.js` 的 `validateLinkedTarget`(39 号文档 P5 已经抽出来的共享校验函数),配置阶段就能提前报错(不用等到第一次 `ensureProjectRoot` 才发现路径不对)。 - **前端**:公司设置页面(或 `TaskBoard.vue`/`GraphConsole.vue` 附近,具体挂哪个页面待定)加一个表单,填目录路径 + 选 `linked-new`/`linked-existing`。 **验收**:新增测试覆盖 `PATCH` 端点的校验分支(合法路径写入成功、`isSensitivePath` 命中黑名单拒绝、`linked-new` 目录非空拒绝);前端至少过一次真实点击验证表单能提交成功。 --- ## 五、Phase 4:`timeline.ndjson` 承接 planning/reflection 轨迹 —— ✅ 已实现(2026-08-30) **调研结论(落地前按设计要求先做的那一步)**:`grep` 全仓库 `system/agents/*/agent.md`,引用 `task transition` 的有 9 个真实角色(`_base`、`code-planner`、`coder`、`org-coder`、`org-planner`、`org-writer`、`pricing-api-integrator`、`unit-tester`、`workgraph-planning`,另有几个 `zz-*` 测试夹具不算)。**`_base` 在列**——这是所有新建 agent 复制的骨架模板,意味着这条能力管的不是几个角色的特例,是整个"多阶段流转"这一类角色的通用约定,往后任何继承 `_base` 的新角色自动适用。 **关键发现,决定了整个实现方式**:`AgentManager.callAgent()` 的 `metadata`(含 `taskId`/`nodeRunId`/`rootRunId`)**只进服务端日志和 hook,agent 读不到**(代码原文注释)。但 agent 的节点私有产物目录本来就以 `data/companies/{cid}/runs/{rootRunId}/nodes/{nodeRunId}/` 这个形状写进它的提示词(`describeArtifactContract`),而 `--artifact` 参数本来就是这个目录下的文件路径——**两个 ID 已经现成地编码在调用方唯一知道、唯一会传的那个参数里**,不需要给 `AgentManager`/子进程塞任何新的环境变量,也不需要改一个字的 agent.md 提示词。 **实现**(与设计里"两种做法二选一"对应:选的是**不改 `bin/lemon.js` 的 CLI 签名、不碰 `agent-chat.js`**这条更保守的路): - `bin/lemon.js` 的 `task transition` 命令保持原有对 `/agent/:agentId/task/transition`(legacy)的调用完全不变,额外 best-effort 打一次新端点 `POST /api/graph-runs/phase-note`(body 只传 `artifact`/`phase`/`agentId`),失败静默吞掉,不影响命令本身的返回值/退出码。 - 新端点从 `artifact` 路径正则解出 `rootRunId`/`nodeRunId`,校验 `rootRunId` 对应真实存在的 run(不存在就是 legacy 会话式调用的路径形状,安静返回 `not-a-graph-artifact`,不是错误),校验通过后调 `graph-run-sse.emitEvent()` 追加一条 `kind:'agent.phase'` 的 timeline 记录,`ref` 是 `nodeRunId`。 - **`agent-chat.js` 完全没有改动**——这次特意避开了它:落地当天这个文件正被另一个并行会话实时编辑(`git status` 显示未提交修改),去碰一个正在被别人改的共享文件风险太高,改成把新能力放进这次 Phase 1-3 已经在用的 `graph-run.js`,物理上不产生交集。 - **"不改的部分"边界确认成立**:新端点只调 `emitEvent`(纯追加 timeline,`apply` 传 `null`),不碰 `graph_run_nodes`/`graph_runs` 任何一行——测试里断言了这一点(节点状态在调用前后不变)。 - **设计里 open 的第三个问题(P0 拦截/OKR 更新怎么处理)就此关闭**:答案是"完全不动"——这两段逻辑挂在 legacy `/task/transition` 里,图执行场景下(`sessions` Map 找不到会话)本来就从没触发过,这次也没打算让它们触发;新端点是完全独立的第二条路径,不是对旧端点的迁移或替换。 **测试**:`server/tests/unit/graph-run-phase-note.test.js`(4 例:合法节点产物路径写进 timeline 且节点状态不受影响、路径形状不对时安静跳过、路径形状对但 run 不存在时安静跳过、没传 artifact 时报 `no-artifact`)。全量测试 1503/1506,零回归(剩余 3 个失败全部来自另一个并行会话正在进行中、还没提交的工作)。 **现状(真跑撞到的,这是四个 phase 里最大的一个)**:`org-coder` 的四段式(RESEARCH→PLAN→EXECUTE→VERIFY)流转靠调 `bin/lemon.js task transition`,打到 `agent-chat.js` 的 `/task/transition` 端点——这个端点靠自己的 `sessions` Map 找"当前活跃会话",但 `org-coder` 作为 work graph 节点跑的时候走的是 `graph-engine.js` → `AgentManager` → `session-pool.js`,是 CLAUDE.md 里早就记录过的"两个互不相干的会话池"那个坑的又一个受害者。结果:`org-coder` 在 work graph 里调这条流转指令时,`sessions.entries()` 根本找不到它的会话,阶段流转的业务逻辑(P0 审批拦截、OKR 子状态更新)**静默跳过**;它的中间产物(`_research.md`/`_plan.md`/`_lessons.md`)不进 `graph_files` 契约(这个是刻意的,见下"不改的部分"),但也**不进任何跨节点/跨角色共享的轨迹**,人类或 `ceo` 事后想搞清楚"这一步当时是怎么想的",只能去读原始的 agent 会话 transcript(`GET /:id/nodes/:nodeRunId/transcript`),没有结构化的入口。 **不改的部分(上一轮已经讨论定案,写在这里防止实现时跑偏)**: - `_plan.md` 这类中间产物**不进 `needs`/`produces` 契约**,`org-validator` 判定依据仍然只认 `acceptance.md` + `artifact`/`log`。理由:validator 判定"代码对不对",不该依赖"coder 计划写得像不像样",这条边界一旦模糊,两个坏结果都会发生——coder 学会把计划写漂亮而不是把代码写对;`graph_files` 的契约面失控(这个仓库反复栽在"两套体系不统一"上)。 - 本 phase 只解决**可见性**(人类/`ceo` 事后能不能追溯),不解决**契约**(下游节点该不该依赖)——这是两件事,上一轮讨论已经拆开过。 **设计**(需要先做的调研,落地前必做): 1. **调研范围**:`org-coder` 是这次撞到的一个,不代表只有它——扫一遍 `system/agents/*/agent.md` 里还有哪些角色的提示词也在调 `bin/lemon.js task transition`,把范围摸清楚再动手,不要只按 `org-coder` 这一个样本改。 2. **迁移方向**:把"阶段流转"这个动作,从打 `agent-chat.js` 的 `/task/transition`(legacy,不认 graph run),改成写一条 `timelineNote()`(`graph-engine.js` 已有机制,per-run、append-only、永不改已写行),带上 `phase`(research/plan/execute/verify 或角色自定义的阶段名)+ `artifact` 路径。**具体是改 `bin/lemon.js task transition` 这条命令本身去打新端点,还是给它接一条新命令**,两种做法都要过一遍现有 agent 提示词的改动成本再定——这些提示词文件散在几十个 `agent.md` 里,改一条命令的调用方式等于牵动所有引用它的本体。 3. **P0 审批拦截 / OKR 子状态更新这两块旧逻辑怎么处理**:`/task/transition` 里现在还挂着 P0 任务拦截、OKR 子状态更新这两段业务逻辑——这两者本身是不是还需要(尤其 OKR 那段,跟 P3C 的新 goal/task 模型是不是还有效对应),需要单独判断,不能假设"迁移流转机制"就等于"这两段逻辑原样照抄"。 **验收**(待设计细化后补全,这里先占位):至少要有一条测试证明——`org-coder`(或调研出的任意一个角色)在 work graph 里完成一次阶段流转后,`timeline.ndjson` 里能查到对应的 `phase`+`artifact` 记录,且这条记录不影响 `graph_files`/`needs`/`produces` 的既有行为(回归测试守住"不改的部分"那条边界)。 --- ## 六、开放问题 1. Phase 1a 的孤儿判定条件(`startedAt` 早于进程启动时间)在多进程/多副本部署下是否还成立?现在是单进程部署,这条先按当前架构写,多副本场景不在这次范围内。 2. Phase 3 的 `PATCH /api/company/:companyId` 端点如果本来就不存在,是新建这一个端点,还是这次顺手看一下公司配置类的改动有没有更合适的既有入口——落地前查一遍 `company.js` 现状再定,不在这里预判。 3. Phase 4 的调研如果发现有 10+ 个角色都在用旧的 `/task/transition`,工作量会明显超出"一个 phase"的量级,届时可能需要再拆细——先按"调研完再评估"处理,不在这里预先估工作量。