42. Org/Work Graph 可靠性收尾 —— 39 号文档真跑复盘的待办
状态:Phase 1-4 全部 ✅ 已实现(2026-08-30)。Phase 3 前端缺一次真实浏览器验证(见该节说明),其余无已知缺口。 日期: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 闸门穿透性回归测试 —— ✅ 已实现(2026-08-30)
落地结果:新建 server/tests/unit/ceo-created-task-gates.test.js,3 例全部走 ceo 实际会用的通路(POST /tasks 的 draft:true + created_by:'ceo' → /enqueue,不抄近道直接摆数据库行):
max_parallel_tasks=1时坑已占满,ceo建的第二条任务落queued,不插队。- 预算给
total_agent_calls:0,engine.checkBudget()照样判耗尽,created_by='ceo'不改变结果。 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 这几道闸门必须照样拦住它",落地时没有补这条测试(§六"已知遗留"记过一笔)。理论上这几道闸门挂在数据层/调度器层,不认"谁创建的",天然覆盖,但没有测试钉死就不能说"已确认"。
设计:不涉及任何设计决策,纯粹是补测试。新增用例(大致形状,落地时按现有测试文件的辅助函数抄写法):
- 把公司
max_parallel_tasks设成 1,已有一条 task 占着坑,ceo走draft+enqueue建第二条——断言它排队而不是插队执行。 - 把公司
DEFAULT_TASK_BUDGET设小,ceo建的任务跑到预算耗尽——断言按现有budget-exhausted路径blocked,不会因为"是 agent 建的"就获得额外预算。 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里,图执行场景下(sessionsMap 找不到会话)本来就从没触发过,这次也没打算让它们触发;新端点是完全独立的第二条路径,不是对旧端点的迁移或替换。
测试: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事后能不能追溯),不解决契约(下游节点该不该依赖)——这是两件事,上一轮讨论已经拆开过。
设计(需要先做的调研,落地前必做):
- 调研范围:
org-coder是这次撞到的一个,不代表只有它——扫一遍system/agents/*/agent.md里还有哪些角色的提示词也在调bin/lemon.js task transition,把范围摸清楚再动手,不要只按org-coder这一个样本改。 - 迁移方向:把"阶段流转"这个动作,从打
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里,改一条命令的调用方式等于牵动所有引用它的本体。 - P0 审批拦截 / OKR 子状态更新这两块旧逻辑怎么处理:
/task/transition里现在还挂着 P0 任务拦截、OKR 子状态更新这两段业务逻辑——这两者本身是不是还需要(尤其 OKR 那段,跟 P3C 的新 goal/task 模型是不是还有效对应),需要单独判断,不能假设"迁移流转机制"就等于"这两段逻辑原样照抄"。
验收(待设计细化后补全,这里先占位):至少要有一条测试证明——org-coder(或调研出的任意一个角色)在 work graph 里完成一次阶段流转后,timeline.ndjson 里能查到对应的 phase+artifact 记录,且这条记录不影响 graph_files/needs/produces 的既有行为(回归测试守住"不改的部分"那条边界)。
六、开放问题
- Phase 1a 的孤儿判定条件(
startedAt早于进程启动时间)在多进程/多副本部署下是否还成立?现在是单进程部署,这条先按当前架构写,多副本场景不在这次范围内。 - Phase 3 的
PATCH /api/company/:companyId端点如果本来就不存在,是新建这一个端点,还是这次顺手看一下公司配置类的改动有没有更合适的既有入口——落地前查一遍company.js现状再定,不在这里预判。 - Phase 4 的调研如果发现有 10+ 个角色都在用旧的
/task/transition,工作量会明显超出"一个 phase"的量级,届时可能需要再拆细——先按"调研完再评估"处理,不在这里预先估工作量。