42-org-work-graph-reliability-followups.md 13.2 KB

42. Org/Work Graph 可靠性收尾 —— 39 号文档真跑复盘的待办

状态:Phase 1 ✅ 已实现(2026-08-30);Phase 2-4 📝 待开工。 日期:2026-08-30 背景: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 闸门穿透性回归测试

现状: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 入口

现状: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 轨迹

现状(真跑撞到的,这是四个 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"的量级,届时可能需要再拆细——先按"调研完再评估"处理,不在这里预先估工作量。