27-p3b-real-run-runway.md 88.2 KB

27-P3B. 真实运行前置 —— 从 mock 走到真 Claude CLI

所属: 27 Graph Agent 架构 的 P3 收尾。P3 主体见 27-P3/P4。 版本: v0.1 日期: 2026-08-16 起因: 27-REQ R-18(招聘归 workgraph-planning;停用 mock 改真 CLI) 前置: P3 主体已实现并提交(c3abf4a 起七轮),测试 719/719 全绿 状态(2026-08-20 校准): 📝 仍在推进。第一批验收 1/3 已验、2 的后半与 4 未在干净环境重验; 第二批验收 7 ✅ 已修(58a2865「W7 零边图」,守卫 company-hire-org-layer.test.js 的 CO_ZEROEDGE + W7-fix)—— 下面 §六bis 缺陷③ 那段是当时的现场记录,不是当前状态; 验收 2 ✅、验收 8 ✅ 由 2026-08-20 的真跑验掉,验收 4 ✅ 由零注入链路验掉, 验收 9 仍开(产出端已通、消费端没等到那个形状)—— 逐条证据见 §六ter。 §六bis 缺陷② 的根因(边界守卫误判并行会话的改动)已于 2026-08-20 修掉, 见 27-P3C §17.2 —— 但根因修了不等于验收点验了,那几条仍要重跑一遍。


一、为什么需要这份文档

P3 主体七轮做下来,引擎编排层是扎实的(状态机、双层熔断、三层级联、跨层回退,都在脚本 executor 下逐环验过),但两件事一直没解决:

  1. 验证一直建立在 mock 之上。 ClaudeCliProtocol._sendMockPrompt 完全不读提示词,只按 agent 名回放 fixture。所以前六轮报的「端到端生产成立」,验的是引擎编排,不是 agent 链路。第七轮换真 CLI 后,链路在第一个 work 节点就断。
  2. agent 供给链没有执行者。 work graph 节点引用的本体,没有任何流程会把它雇进公司。

本文档收敛这两件事,目标是:能在控制台上点一下,让真实 Claude CLI 跑完一个最小任务,且不写坏宿主仓库、验收不假放行。


二、目标与非目标

目标

  1. workgraph-planning 规划完图后自己雇齐引用到的本体(R-18.2 方案 a)。
  2. 真实 Claude CLI 能跑完一个单 org node 的最小任务,产出落在分配好的项目根内。
  3. 验收链路不会「假放行」。
  4. 界面上有入口触发 orggraph-planning 真跑(R-18.1),不再靠脚本代劳。

非目标

  • 不做 fan-out 并行(沿用 P3 §一:卡在实例隔离)。
  • 不做 L4。
  • 不追求一次跑通复杂任务:第一批的验收标准是「单 org node 的最小图能真跑完」,不是「开发型公司三节点全流程」。前七轮的教训是没跑起来之前的判断经常是错的,先拿到真实结果再排后面。

三、现状核实(决定工作量的证据)

# 事实 证据
1 seedTemplates() 没有任何运行时调用方 只有测试与手动的 scripts/graph-seed.js 引用它
2 因此 system/templates/ 新增的本体进不了 system_agents 表 而 work_graph_nodes.system_agent_id 是指向该表的外键
3 库里有 10 条悬空 company_hired 行,legacy_path 指向已删目录 含 validator-triager / prd-planning / sdd-planning / brd-planning / benchmark-validator / testdata-validator / workgraph-planning / org-validator
4 seedTemplates 遇到 id 已被别的 source_kind 占用会 skip 所以第 3 条那些永远变不回 template
5 后果链:lookupSharedAgentMd 恒 null → hire 静默退回全量拷贝 模板改动对已雇公司不生效
6 resolveAgentName 只查目录存不存在,不做按需 hire 同文件的 resolvePlanningAgentName 对 workgraph-planning 倒是做了
7 path-resolver.allocateProjectRoot 已实现,sandbox 模式给 data/companies/{cid}/projects/{pid},另有 isSensitivePath 黑名单 图执行侧没有调用它
8 图执行侧的 agent_call 节点没有产出位置也没有沙箱 真 CLI 实测已往宿主仓库写入 server/routes/echo.js 等三处(已复原)
9 FAILING_VERDICTS = {attributed, inconclusive},不含 "fail" 而 benchmark-validator / testdata-validator 的 Step 表教 agent 写 verdict: "fail"
10 system/skills/ 目录不存在 base agent 的 Hot Swap 是 existsSync 守卫下的空转(W11 后仍空转,但已改成显式 console.warn)。W11 另查出:工具权限来自 config.json 的 cli.toolPolicy(建协议时读一次,与 phase 无关),Hot Swap 换的只是提示词文本 —— 补技能包也关不上权限闸

四、工作项

第一批 · 真跑的最小前提

做完这四件就能真跑,且不会写坏仓库、验收不会假放行。

# 内容 文件 量
W1 ✅ 清 10 条悬空 company_hired 行;把 seedTemplates() 接进服务启动流程(或让 hire 顺手 upsert system_agents)。这是 W5/W6 的前置——不修,agent 雇完人提交图照样外键挂 server/services/graph-seed-service.js、server/index.js 小
W2 ✅ 每个 root run 分配一次项目根(复用 path-resolver.allocateProjectRoot sandbox 模式),落在 runs/{rootRunId}/artifacts/project-root.json(未加 schema 列),进 agent_call 与 planning 两种提示词 + callAgent metadata server/services/graph-engine.js、server/tests/unit/graph-boundary.test.js 小
W3 ⚠️ 部分 边界守卫落地为「提示词声明 + 事后取证 + 结算拒收」,不是沙箱:能拦住宿主仓库内可见的越界写(判 fail + timeline 留痕),拦不住仓库外与被 gitignore 的写。详见下方「W3 能力边界」 server/services/graph-engine.js、server/tests/unit/graph-boundary.test.js 小
W4 ✅ 修 fail-open 的验收 bug:benchmark-validator / testdata-validator 的 Step 表与引擎 FAILING_VERDICTS 对齐。planner-validator/agent.md 已有一段红字警告这一点,另两份 Step 表恰恰在教 agent 犯它 system/templates/{benchmark,testdata,org}-validator/agent.md、server/tests/unit/template-verdicts.test.js 小改动,性质严重

W4 的失效方向是放行不是拦截:契约非法的产物会被当合格通过。真跑之前不修,验收形同虚设。

W1 落地记录(已完成)

§三 第 1/3/4/5 条已修复,现状核实表里那条失效链断掉了:

  • 启动接线:graph-seed-service.runStartupSeedSafely() 由 server/index.js 的 start()(listen() 之前)与 server/agent-server.js(同上)调用。跑的是 reconcileDanglingRows() → seedGlobalAgents() → seedTemplates()。不跑 seedCompanyAgents():那一步的数据源是运行时状态 company.json,归 hire()/fire() 管,启动时反推 org_graph_nodes 会跟它们抢所有权(也正是 W7 要收敛的地方);它继续留在 scripts/graph-seed.js 手动跑。全同步幂等,失败只打日志降级,不挡启动。
  • 悬空行回收规则(三条同时成立才回收,任一不成立就保留):source_kind='company_hired' + legacy_path 指向的目录已不存在 + 没有任何活着的公司在 company.json.agents[] 里点名这个 id。附带清掉「磁盘上已无 company.json」的公司遗留的 org_graph_nodes/org_graph_edges(data/companies/ 整个目录缺失时跳过这一步,分不清「没有公司」和「盘没挂上」)。删不掉(被 work_graph_nodes 等外键引着)的行进 retained,不抛错。
  • 占位 id 转正:seedTemplates() 撞上被占用的 id 时,只有当那行是悬空的 company_hired 才就地转正(source_kind/scope/owner_company_id/legacy_path 一并改写,保留行 id 让外键继续有效);global_agent 占位照旧 skip。
  • 实测结果:生产 data/graph.sqlite 上跑完 → company_hired 11→0,template 37→45,org_graph_nodes 清掉 4 家已删公司的 7 行,verifyCounts() 由 false 转 true;lookupSharedAgentMd('workgraph-planning' / 'prd-planning') 从恒 null 变成能解析到 system/templates/**/agent.md,hire() 不再静默退回全量拷贝。
  • 测试:server/tests/unit/graph-seed-service.test.js 已 hermetic 化(新增 GRAPH_SEED_ROOT 测试缝,夹具根建在 /tmp,行数改为写死的精确值),2 条 → 13 条。

W4 落地记录(已完成)

§三 第 9 条已修复。三处改动 + 一道防线:

  1. benchmark-validator/agent.md Step 1 表、testdata-validator/agent.md Step 1 表: verdict: "fail" → verdict: "attributed",各补一段红字说明「写 "fail" 引擎读成没失败」。
  2. org-validator/agent.md 的审计字段 verdict: pass|fail → audit_verdict。 它本来就是审计产物(org 节点是 subgraph,成败由 deriveChildOutcome 从子 run 推出), 但它写的文件路径与 work 层判词完全相同(nodes/<nodeRunId>/attempt-<n>.json), 只靠「本节点恰好是 subgraph」才没被 readNodeProduct 当判词读。改名后两套词彻底分开; 即便哪天真被当判词读,引擎也会退到「有 fail_reason 就算失败」的缺省规则上,仍然判对。
  3. 防线 server/tests/unit/template-verdicts.test.js(对标 template-refs.test.js): 从 graph-engine.js 源码文本里取 FAILING_VERDICTS / ATTRIBUTION_REASONS (两者都没 export,import 整个模块会把 graph db 拉起来;取不到就抛,不静默空跑), 扫全部 system/templates/**/*.md 的赋值形态(verdict: "X")与判词表整格取值, 再加扫 system/{work,org}-graph-templates/*.json 的边 failReason(不在域内 = 这条边永远匹配不上)。 两条扫描路径都做过变异验证:改回 "fail" / 把表格取值改成 "ok",都会炸。

FAILING_VERDICTS 要不要放宽收 "fail":不要那样补,要把判据反过来 (失败词白名单 → 通过词白名单,不认识的一律判失败)。理由与改法见 §八。 graph-engine.js 归 W2/W3 那一轮,本轮不动它。

W2 落地记录(已完成)

§三 第 7/8 条的前半段(缺产出位置)已修复。

  • 分配:graph-engine.advanceOne 在发起调用之前调 ensureProjectRoot(ctx),内部复用 devloop/path-resolver.allocateProjectRoot({company_id, mode:'sandbox'}) → data/companies/{cid}/projects/{pid}/。每个 root run 一次,子 run / 后续节点读回同一个 (runRootDir 本来就按 root_run_id 拼,天然共享)。GRAPH_RUNS_DIR 覆盖时项目根跟着挪到 {GRAPH_RUNS_DIR}/{rootRunId}/project,理由与 run 目录那处 override 完全一样:否则测试每跑 一次就往仓库 data/companies/ 堆一个空项目目录。
  • 存哪:没有改 schema。记录落在 runs/{rootRunId}/artifacts/project-root.json ({schema:"graph-run-project-root/v1", pid, root_path, mode, root_run_id, company_id, allocated_by, allocated_at}),与 artifacts/task-goal.json 同类、同目录,读回路径已经通。 graph_runs 不需要加列——这条信息按 root_run_id 唯一,加列反而要在每一行上对齐一次。
  • 进提示词(两种节点都进,参照 describeArtifactContract 的做法):agent_call 节点由 describeWriteBoundary 在任务描述头部拼一段 [写入边界 —— 硬约束,越界会被判失败]; subgraph 节点(起的是 workgraph-planning,同样是一次真 CLI 调用、同样会写盘)由 buildPlanningPrompt 的「## 写入边界(硬约束)」拼同一份约束。两处都给绝对路径,并明说 「进程 cwd 不是项目根,别用相对路径」。callAgent 的 metadata.projectRoot 是它的机器可读副本。
  • 四环闭合:分配(ensureProjectRoot)→ 传递(ctx.projectRoot → 提示词 + metadata)→ 落库(project-root.json,wx 独占创建,并发下先到先得)→ 读回(下一个节点 / 子 run readProjectRootRecord)。timeline 上有 node.boundary 一条「本次任务的项目根:…(sandbox)」。
  • 实测(mock 模式 + 默认执行器,未起真 CLI):单节点 run 跑完 → data/companies/{cid}/projects/p-xxxxxx/ 被建出、project-root.json 内容正确、节点提示词里 有该绝对路径、宿主仓库 git status 无新增。测试 server/tests/unit/graph-boundary.test.js。

W3 落地记录(部分完成,不是沙箱)

真 CLI 是子进程直接写盘、还带 --dangerously-skip-permissions,引擎不可能在写发生的那一刻 拦住它。所以本轮落地的是「提示词声明(调用前)+ 事后取证 + 结算拒收」三件,能力边界如下, 不要当成「产出目录已被隔离」。

能拦住的

  1. 宿主仓库内可见的越界写:advanceOne 在调用前后各跑一次 git status --porcelain (cwd=仓库根),差集非空 = 本次调用在仓库里留下了痕迹 → 记一条 node.boundary (violation:'boundary_escape' + 改动清单,最多 20 条)并把结果降级成 fail (success 也降级;fail_reason 保持 null,走兜底边——引擎不发明 fail_reason)。 这正好覆盖已经实际发生过的那次事故(写 server/routes/echo.js、改 server/index.js)。 executor 抛异常那条路径同样查(先写坏再崩,痕迹一样在)。
  2. 敏感路径:项目根分配后过 path-resolver.isSensitivePath,命中则拒用并留痕 (这是唯一一道调用前就强制的守卫)。
  3. mock 产物越界:mock/files 事件里逃出节点产物目录的路径照旧被丢弃(原有行为)。

拦不住的(明确未解决)

  1. 宿主仓库之外的写完全看不见:~/Documents/别的项目、/tmp、$HOME 下任何路径。
  2. 被 .gitignore 忽略的路径看不见(含 data/companies/、*.log)。
  3. 调用前就已 modified、调用中又被改一次的文件:porcelain 行不变,看不见。
  4. 越界的写已经发生,守卫只拒收结果,绝不自动回滚——回滚等于替开发者丢弃未提交的 改动,代价方向比误判更贵。清理靠人工看 timeline 里的清单。
  5. 误判:开发者本人或并行的另一个 agent 在同一时刻改仓库,会被算到本节点头上。
  6. 守卫只在会真起 CLI 的执行器下开(默认 executor / 默认规划器);测试注入的假 executor 不扫(进程内函数,扫仓库既无意义又要给每个用例加两次 git 调用)。自定义 executor 若自己 起子进程,不在守卫范围内。
  7. git 跑不起来(非 git 仓库 / 超时)时守卫自动降级为不检测,只留一条 degraded:'boundary_guard_unavailable'。

真正的隔离还没有做:最强的一招是让 CLI 的 cwd 落在项目根——但 agent-manager._runViaProtocol 的 effectiveWorkDir = agent.workDir || this.workDir,动态解析出来的 agent 一律用自己的目录, callAgent 没有透传 cwd 的口子;改它不在本轮范围。要么改 agent-manager 加 cwd 透传, 要么上容器 / 文件系统级沙箱。在这一条做掉之前,真跑仍应在可丢弃的工作副本里做,不要在主仓库上跑。

本轮只动了 graph-engine.js 的 W2/W3 部分;§八 提到的 FAILING_VERDICTS 白名单反转没有做, 那条仍然待办。

第一批终验记录(独立复核,未起真 CLI)

结论:W1 / W2 / W4 三项到位;W3 按其自述「部分完成」到位。但第一批合起来仍然 不足以支撑一次真 CLI 端到端跑 —— 断点不在第一批,在 §三 第 6 条(归 W5/W6)。

项 复核方式 结果
全量测试 npm run test:unit 736/736 绿,68 文件(起点 719/66,新增 17 条,无改期望)
前端 npm run web:build 通过
W1 启动接线 读 server/index.js start() / agent-server.js 均在 listen() 前调 runStartupSeedSafely() ✅
W1 库状态 生产 data/graph.sqlite 实查 company_hired 0 行,template 45,global_agent 8,verifyCounts().ok=true,PRAGMA foreign_key_check 空 ✅
W1 新模板自动进表 graph-seed-service.test.js hermetic 用例 + 生产库重跑 seedTemplates() 幂等 ✅
W1 占位 id 回收 三条判据 + 就地转正(保留行 id 保外键) ✅
W2 四环 读码 + graph-boundary.test.js 分配→提示词/metadata→project-root.json→读回,四环闭合 ✅。子 run 经 createRun 继承 root_run_id,runRootDir 同目录,跨层共享同一个根 —— 已核实
W3 守卫 读码 + 变异 判据 diffRepoStatus 有测试;降级路径(success→fail)无任何测试,见下
W4 模板改动 读三份 agent.md ✅ 无 verdict: "fail" 残留
W4 防线有效性 三次反例变异 全部变红且报出精确行号:① benchmark-validator 改回 "fail" → agent.md:60 → verdict: "fail" 不在引擎取值域内;② org-validator 的 audit_verdict 改回 verdict → agent.md:183;③ 造 verdict: "ok" → agent.md:59。变异后已逐字还原 ✅

W3 文档与代码一致性核查(本轮重点):§四 W3 与 applyBoundaryGuard 的注释 没有把做不到的写成已解决 —— 状态标 ⚠️ 部分,7 条「拦不住」逐条列明, 并写了「在 cwd 隔离做掉之前,真跑仍应在可丢弃的工作副本里做」。与代码一致。 两点补充:

  1. 「正好覆盖已发生过的那次事故」这句在当前工作树里只成立一半。 事故是 写 server/routes/echo.js(未跟踪)+ 改 server/index.js(已跟踪)。 而 server/index.js 此刻本来就是 modified(W1 的改动),命中「拦不住」第 3 条 (调用前已 modified、调用中再改一次 → porcelain 行不变)。所以现状下守卫只看得见 ?? server/routes/echo.js 那一半。这不是代码 bug,是两条自述互相打架,措辞该收敛。
  2. 降级路径零测试覆盖。 applyBoundaryGuard 有真实调用方(advanceOne),不属于 「零调用方」那一类;但 boundaryGuardApplicable 要求执行器 === defaultAgentExecutor, 而单测一律注入假 executor,applyBoundaryGuard 又没 export —— 「越界 → success 降级成 fail」这条唯一有实际后果的分支,只能靠真 CLI 才走得到。 graph-boundary.test.js 测的是纯函数 diffRepoStatus,不是接线。 → 已在「W12 补完」那一轮补上:applyBoundaryGuard 导出 + 多一个可选的 after 快照参数(生产路径仍只传三个参数、仍现拍 hostRepoStatus()),判罚三条分支各一例。 仍需真 CLI 验的只剩「git 扫描能不能真的扫到那次越界写」。

阻断真跑的真正断点(§三 第 6 条,归第二批 W5/W6)

AgentManager.getAgentConfig() 解析裸名时只查 system/agents/,从不查 system/templates/ (agent-manager.js:141);带公司前缀的 {cid}:{id} 才查 data/companies/{cid}/agents/。 而 resolveAgentName() 只做「目录存不存在」的判断,不做按需 hire,查不到就退回裸名。

实测(纯文件系统解析,未起 CLI):

workgraph-planning     THROWS: Agent not found: workgraph-planning
prd-planning           THROWS: Agent not found: prd-planning
sdd-planning           THROWS: Agent not found: sdd-planning
benchmark-validator    THROWS: Agent not found: benchmark-validator
testdata-validator     THROWS: Agent not found: testdata-validator
validator-triager      THROWS: Agent not found: validator-triager
org-validator          THROWS: Agent not found: org-validator
master                 RESOLVED

workgraph-planning 在真链路里能过,只因为 resolvePlanningAgentName() 单独给它做了 按需 hire;它规划出来的每一个 work 节点引用的本体都没有这层保护。 所以一次真跑的必然走向是:org 节点 → 规划成功 → 第一个 work 节点 Agent not found → 节点判失败。 这正是 W5/W6 要解决的问题,第一批四项做得再对也绕不过去。

W5/W6 落地后的复测:把 prd-planning / benchmark-validator / validator-triager 三个本体 hire 进一家探针公司后,getAgentConfig('<cid>:<role>') 由 THROWS 变 RESOLVED (裸名照旧 THROWS,符合预期——引擎走的是作用域名)。断链的解法确认就是「先雇」。 详见下方「W5 / W6 落地记录」。

推论:现在起真 CLI 不会产出新信息 —— 断点已被零成本静态证明, 而按 §五 的量级,跑一次是 $3-8 / 15-40 分钟。应先做 W5/W6 再真跑。

第 12 处「四环漏一环」:outcome.output 全程被丢

graph_run_nodes 没有 output 列(NODE_PATCH_COLUMNS 只有 9 个键,不含 output), settleNode() 也不把 outcome.output 传给 transitionNode,node.output 这条 SSE 事件的 data 里同样只有 status/fail_reason/output_ref/child_run_id。于是:

产生(executor / 守卫 banner)→ 传递(outcome.output)→ 落库(无)→ 读回(无),第三环断。

三个写入方全部落空:defaultAgentExecutor(:370/:386,存量)、W3 的越界清单 banner(:2538,本轮新增)、 以及最要命的那个——

} catch (err) {
  outcome = { status: 'fail', output: err.message, failReason: null, tokens: err.tokens || 0 };
}

advanceOne 里 executor 抛异常这条路径:err.message 写进 outcome.output 后人间蒸发, 既不落库、不进 SSE、也不写 timeline,fail_reason 还是 null。 操作者只会看到一个节点 fail、没有任何原因。

严重性判断:W3 banner 那一份是冗余的(越界清单在 node.boundary 的 timeline 里有 entries, 证据不丢);但异常那一份是净损失,而且正好砸在下一步最需要它的地方—— 上一节那个 Agent not found: prd-planning 就是从这条路径抛的,真跑起来将表现为 「节点失败,无原因,无痕迹」。建议在 W5/W6 之前先补一条 timeline(最小改动:catch 里加 timelineNote(ctx,'node.error',err.message,{...})),否则真跑的排障成本会被这一环吃掉。

W12 · 本轮状态:✅ 已修(选「只留痕,不加列」) — server/services/graph-engine.js

  • 不加 output 列(graph_run_nodes 保持 16 列不变)。理由:outcome.output 长度无上限, 而 getRunState 是 SELECT * 全表读回,控制台每次轮询都会把所有历史输出重传一遍; 完整产物已有地址(output_ref → nodes/{nodeRunId}/),再存一份就是第二个真相源; fail_reason 是选边的匹配键,更不能借来装自由文本。
  • catch 那份净损失补上:executor 抛异常时发 node.error(timelineNote),带 node_run_id / node_name / attempt / node_kind / system_agent_id / error / error_type / error_stack(栈顶 6 行)。 四环 = 产生(catch)→ 传递(timelineNote)→ 落库(timeline.ndjson + stream.log,append-only) → 读回(GET /api/graph-runs/:id/stream?lastEventId=0 全量重放)。
  • outcome.output 也终于有了消费方:node.output 事件 data 加定长 output_excerpt (≤600 字符,超长截断并注明「完整输出见 output_ref」),三个产生方(defaultAgentExecutor 的 res.output、W3 越界 banner、catch 的 err.message)一并接上。前端 NodeRunLog 已按 data.node_run_id 消费 node.output,无需改动即可看到。
  • W3 banner 依建议去掉重复的改动清单(清单仍在 node.boundary 事件的 entries 里), 只留「为什么被判 fail」一句,免得 20 行清单把 agent 真实输出挤出摘要窗口。
  • 仍需前端配合一行:web/src/components/graph-console/GraphRunPane.vue 的 es.addEventListener 白名单里没有 node.error(命名事件不会走 onmessage), 加上 'node.error' 这条错误才会出现在浏览器的事件轨;不加也不丢数据(在 timeline.ndjson / stream.log 里),只是控制台看不到。
  • 锁:server/tests/unit/graph-engine-node-error.test.js(4 例)。

W12 补完 · 本轮状态:✅ 已修(fail_reason 那一半) — server/services/graph-engine.js

上一轮只做了「原因文本进 timeline / output_excerpt」,graph_run_nodes.fail_reason 与 node.output 的 fail_reason 仍然恒 null。真跑与界面走查确认的后果:点开失败节点, 概览 tab 第一眼是「失败、原因 —」,要知道去翻第三个 tab(运行日志)才看得到 「Agent not found: prd-planning」。「引擎判定的失败」与「agent 归因产出的失败」在这一列上 被区别对待,没有道理——本轮把三类引擎自判失败都署上名。

  • 取值:另起一个集合 ENGINE_FAIL_REASONS,不动 ATTRIBUTION_REASONS ({node_error, boundary_escape, child_run_failed})。理由三条,逐条写在引擎的注释里: ① ATTRIBUTION_REASONS 是 readNodeProduct 的入口白名单,管的是「agent 在 attempt-{n}.json 里允许写哪几个词」;往里加取值等于放宽别人的取值域, 而本轮要落的是引擎自己的判定,两件事不共用一个白名单(也意味着 agent 仍然不能 自称 node_error,写了照旧被降级成 null + 一条 invalid_fail_reason)。 ② 复用现成四个取值更糟:infra_flake 有引擎特判(原地重跑、不烧任何计数), Agent not found 这种确定性失败判成它会原地重跑到预算耗尽;impl_bug / input_underspecified 是图上精确 fail 边的匹配键,典型图里指向「退回 impl 重写」 「退回 planner 补输入」,把「本体没雇进公司」判成 impl_bug 会让引擎真的退回去重写代码, 而根因一行代码都不在那儿——比 null 更坏。 ③ 新取值的路由行为与今天的 null 完全一致:selectFailEdge 精确匹配不到就退兜底边, 现有图骨架没有任何边写这三个词。只加信息,不改选边。
  • 三个产生点:advanceOne 的 executor catch → node_error;applyBoundaryGuard 判越界 → boundary_escape(agent 自己给了归因则尊重它,越界只是附加事实); deriveChildOutcome 子 run 收敛为 fail 而子图一个归因都没有 → child_run_failed (只是兜底,真有更具体的根因一律优先透传)。
  • 四环:产生(引擎判定)→ 传递(outcome.failReason → settleNode)→ 落库(graph_run_nodes.fail_reason,与状态同一次条件更新)→ 读回(getRunState → GET /api/graph-runs/:id → 概览 tab;同一份值也进 node.output 事件 data)。 读回这一环本来就通(getRunState 一直在读这一列),断的只有第一环。
  • 前端不需要改(名字订正:实现叫 failInfo,不是 failSignal;见下方 W12-UI 落地记录): NodeDetailPanel.vue 的 failInfo 第一条判据就是 node.failReason(W12-UI 那一轮已铺好),本轮起它会真的有值。可选优化:给这三个词 加中文标签(「执行器异常 / 越界写入 / 子图失败」),纯展示层,不加也不缺信息。
  • 顺带补上第一批终验点名的那处零覆盖:applyBoundaryGuard 现在导出,并多一个 可选的 after 快照参数(生产路径一字未变,advanceOne 仍只传三个参数)。 「越界 → success 降级成 fail」这条唯一有实际后果的分支,从此不必起真 CLI 就能验。
  • 改了两条既有断言(graph-engine-node-error.test.js:132/163: expect(node.failReason).toBeNull() → toBe('node_error'))。它们锁的正是本轮要改的行为, 不是被绕过的期望;两处都留了说明为什么改。
  • 锁:server/tests/unit/graph-engine-fail-reason.test.js(13 例,含四处产生点各自的变异验证)。

W12-UI 落地记录(✅ 已完成;mock 服务 + 浏览器实点走查,未起真 CLI)

只动前端四个文件 + 一个新模块:web/src/components/graph-console/{node-signals.js(新), GraphRunPane.vue, NodeDetailPanel.vue, NodeRunLog.vue}、web/src/components/WorkGraphCanvas.vue、web/src/views/GraphConsole.vue。

先修的是一个更靠前的断点:整类事件根本没到浏览器。 GraphRunPane.connect() 里只 addEventListener 了 6 个事件名,而 EventSource 的 onmessage 只接没有 event: 字段的帧。引擎实际发 16 种,于是 node.error(执行器异常的原因)/ node.triage(归因判词)/ node.boundary / node.contract / node.subgraph / loop.terminated / loop.cross_layer / plan.* 全部被浏览器丢弃 —— stream.log 里有、timeline 里有、界面上一条都看不到。名单收敛成 node-signals.js 的 RUN_EVENT_KINDS,由 GraphRunPane 唯一消费。

① 概览 tab 的失败原因:三级回落 + 来源标(NodeDetailPanel.failInfo)

级 来源标 取值 什么时候走这一条
1 引擎判定 node.failReason(graph_run_nodes.fail_reason) 引擎判出了根因(W12 补完那一轮起会真有值)
2 归因产出 本轮 node.triage 事件的 fail_reason 归因文件里有,但还没落到节点行上
3 摘自输出 node.output.output_excerpt / node.error.error / 越界横幅 fail_reason 合法地为 null(= 没匹配上精确 fail 边,走兜底边)时唯一能说清「到底出了什么事」的东西

fail_reason=null 在这套引擎里是常态而非异常,所以旧版「只读 node.failReason,取不到显示 —」 等于让失败节点常态显示成「失败、无原因」。现在 null 也会写明「没有匹配上精确 fail 边 → 走兜底边」, 并把 excerpt 以终端块展示。remediation_note 同款回落(节点行 → node.triage), 「输入输出」tab 的 consumedSource 一并接上这条链。 与并行的 W12 引擎侧解耦:引擎给了 fail_reason 走第 1 级,没给走 2/3 级,两条路都实测过。

② 单步进度感:只报真实信号,不画进度条

  • 画布节点卡片:RUNNING 节点右下角 ⏱ mm:ss 秒表(WorkGraphCanvas.elapsedLabels), 状态灯改成呼吸点。秒表单独一个 computed,不塞进 laidOut —— 否则整张图的拓扑排序 + 回退边分道 每秒重算一遍。ticker 只在真有节点 running 时转。
  • 详情面板:tabs 之上一条常驻「运行中 mm:ss + 最近事件摘要(N 前)」,四个 tab 都看得见。
  • duration 以前是死的:它用 Date.now(),而 Date.now() 不是响应式的, 节点在跑的那几分钟里「耗时」一格是冻住的。现在挂在只在 running 时转的 ticker 上。
  • 明确不做假进度条:引擎不知道 agent 还剩多久(CLI 不上报进度),面板上有一句 title 写明这件事。
  • 真 CLI 单节点期间常常几分钟一条事件都没有,所以「最近事件」多半停在 agent.call — 秒表 + 「N 前」正是为这种真空设计的,不是补充。

③ NodeDetailPanel 不跟 SSE 走(doc 记的已知缺陷):根因与修法

根因不在 SSE,在 GraphConsole.vue 的分发:onMidState 会把 selectedNode 指到最新快照, onRootState 完全不管。而左栏(org run)选中的节点走的正是 onRootState 这条路 —— 于是左栏已经 RUNNING·#2,右栏还是上次点进去时的那张快照,要点别的节点再点回来才刷新。 修法是加一个 selectedFrom: 'root' | 'mid' 记住选中节点属于哪一层,两栏的刷新各自喂回右栏。 同一个字段顺带修掉两处串层:loopRecords 与 events 以前恒取中栏那份, 选中 org node 时读的是另一张图的账本与事件轨。另外,org node 是被拾取时才现规划出 work graph 的, 子 run 一出现就把中栏指过去(仅在下钻栈为空时,不抢用户位置)。

顺带:NodeRunLog 的日志行以前回落成 JSON.stringify(data)(SSE 帧里没有 summary, 引擎只把它写进 timeline.ndjson),现在由 describeEvent 按事件类型拼人话。

四环自查(读回那一环是本轮的全部):产生(引擎 emitEvent/timelineNote)→ 传递(stream.log + SSE 具名事件,本轮修的就是这一环漏了 10 种事件名)→ 落库(stream.log,lastEventId=0 全量重放)→ 读回(node-signals → 面板三处)。

验证

  • npm run web:build 通过;npm run test:unit 786/786 绿,76 文件(本轮新增 4 例,无改期望)。
  • 锁:server/tests/unit/graph-console-node-signals.test.js。第一例是防线: 从 graph-engine.js / graph-run-sse.js 的源码文本扫出引擎实际发的事件名, 与 RUN_EVENT_KINDS 双向对账(扫不到 8 个以上直接抛,不静默空跑)。 变异验证:从名单里删掉 node.error → 立刻变红并报出精确名字。 ⚠️ 引擎将来新增事件名,这条会红 —— 那正是它的用途,修法是把新名字加进 RUN_EVENT_KINDS。
  • 浏览器实点走查(LEMONBOSS_CLAUDE_MODE=mock 起 3010 + vite,临时公司 w12ui-demo, 一个 running 节点 + 一个 fail 节点 + 手工写入的事件轨): 秒表 5m29s → 5m49s 真的在走;画布卡片上 ⏱ 5m 58s; 失败节点概览依次显示「摘自输出」→(补一条 node.triage 后)「归因产出 contract_violation」→ (落库 fail_reason 后)「引擎判定 infra_flake」,三级回落全部走到; POST /nodes/:id/retry 后右栏没有任何点击就从 FAIL·attempt 1 变成 READY·attempt 2 (即缺陷③已修);运行日志 tab 的 node.error / node.triage 两类第一次出现在界面上; 无 console 报错。走查完临时公司已按收尾要求删净(graph_runs / graph_run_nodes / org_graph_* / system_agents 全部 0 行,PRAGMA foreign_key_check 空,目录已 rm)。

未做 / 已知上限

  • agent.call 与 node.output 之间那几分钟仍然没有任何中间信号,因为引擎没把 onAcpEvent 转成 agent.chunk / agent.tool 事件(NodeRunLog 里那条黄框写的就是这件事)。 接上之后「最近事件」会自然变活,前端不用再改。
  • 事件缓冲 MAX_EVENTS=400,长 run 会丢最早的行(原有行为,未动)。

第二批 · 招聘走方案 a

# 内容 文件 量
W5 ✅ 真跑已验(§六bis) workgraph-planning 模板改语义:撤掉「你不招人也不辞人」,加招聘步骤,tools.md 给 company hire 用法 system/templates/workgraph-planning/** 小
W6 ✅ 真跑已验(§六bis:先雇后提交,图未外键挂) 写死顺序:先雇、再提交图(反了必外键挂);雇失败的重试与降级 同上 小
W7 ⚠️ 部分(真跑打脸:零边最小图上判据全空,见 §六bis 缺陷③) work 层招聘不得污染 org graph:hire() 现在会把任何被雇 agent upsert 成一行 org_graph_nodes。方案 a 下每次任务规划都雇人,org graph 会被越塞越满(界面走查已实际撞到)。让这类招聘不写 org 节点,或写了打标区分 server/services/company-service.js、server/services/graph-service.js 中
W8 ✅ UI / 🟡 mock 控制台加「重新规划组织」按钮,调 POST /:companyId/org-graph/replan(R-18.1)。该端点已实现,全仓库无前端调用方 web/src/views/GraphConsole.vue 或 CompanyDashboard.vue 小

W5 / W6 落地记录(模板层已改,真跑仍未验)

改的是 system/templates/workgraph-planning/ 三份:agent.md(主体)、tools.md、identity.md。

  • 语义反转:tools.md 的「你不招人也不辞人」那一行已删,替换成「你只招不辞」+ 一段明写 「旧提示词里那句已作废,以本节为准」的警示(老话可能还留在别处的上下文里)。 agent.md 身份段、角色边界表、identity.md 同步改成「图里引用到的本体由你雇」。
  • 顺序写死:工作流从七步改八步,Step 6 招聘 → Step 7 提交图,Step 6 开头一张表列出 「反了会怎样」的两条后果(work_graph_nodes.system_agent_id 外键 / 节点跑起来 resolveAgentName 找不到公司目录退回裸名),并写明「都发生在你已经下班之后,补不了」。八步表 + 思维隔离自查第 9 条 两处复述同一条顺序。
  • 失败分级:status 四档(hired_from_template / already_exists / hired_from_base 三档继续, training 一档不算成功——脚手架路径不写 system_agents,图提交必被 rule=reference 打回); 出错三级(瞬时故障重试 3 次 → 供不出来就换本体、同一本体最多换一次 → 都不行就停, 不许提交引用了没雇上本体的图)。
  • already_exists 的死角也写进去了:它是提前返回,不补 system_agents 那一行。 所以「目录在、表里没有」这种组合下重雇永远只返回 already_exists,rule=reference 修不好—— 模板要求直接换掉该本体并点名汇报,避免在这里死循环。
  • 提示词组装的实测结论(决定了内容该写在哪):图执行侧走 graph-engine → AgentManager.callAgent → PromptBuilder.build,而 build() 只拼 agent.md + company-context.md,tools.md / soul.md / identity.md 都不进这条提示词 (packages/agent-core/Agent.js 那套读六个文件的 bootstrap 是另一条链路)。 所以招聘所需的一切——命令行、参数表、curl 降级、四档判读、三级分级——全部内联在 agent.md, tools.md 只作为人类可读的重复件。
  • 实测(未起真 CLI,临时 GRAPH_DB_PATH + 用完即删的探针公司): hire 三个 work 层本体均 hired_from_template,重雇均 already_exists(幂等成立); 雇后 AgentManager.getAgentConfig('<cid>:<role>') RESOLVED,裸名仍 THROWS: Agent not found —— 与 §三 第 6 条的断链分析完全一致,「雇进公司」正是解法。 另用 PromptBuilder.build() 实拼一次 workgraph-planning 的 system prompt(20.8k 字符), company hire / --role / 先雇 / 三个 status 取值 / ls data/companies/ 全部命中。
  • 仍未验:真 CLI 下 agent 是否照着做(mock 不读提示词,验不了)。归第三阶段。
  • --layer 不是模板的入参:模板明写「别自己发明这个参数」(parseArgs 严格模式,多给未知选项 会让整条命令报错)。work 层招聘写不写 org 节点由服务端判据决定,见 W7。

W7 落地记录(选「不写 org 节点」,判据在服务端;真跑未验)

  • 选「不写」而不是「写了打标」:打标要么在 org_graph_nodes 加列(schema 归 graph-db.js)、 要么在 readGraph/getGraph 上加过滤(归 org-graph-service.js),两处都不在本轮可动的文件里; 更关键的是这行不只是画图用——work-graph-db.js:473 起 org run 时读的就是这张表的 active 行, 混进去的 work 层本体会变成一个真的会被执行的 org 节点。所以不是「显示时过滤掉」够用的问题。
  • 调用方不表达层级,判据只看数据:唯一会雇 work 层本体的真实调用方是 POST /api/company/:companyId/hire(workgraph-planning 在 CLI 里 curl 它), 该路由按字段解构 body、不透传未知字段,所以「加个 opts.layer 参数」在本轮会是一个 永远没人传的死开关(第一版就是这么写的,已删)。何况「谁雇的」本来也不该决定 「它是不是组织节点」。company-service.decideOrgNodeRecording() 三条判据,命中任一不写 org 节点: ① config.type ∈ {meta-bootstrap, meta-planning}(orggraph-planning / workgraph-planning / brd|prd|sdd-planning / goal-benchmark);② 本体在 system/work-graph-templates/*.json 里被 点名当节点(planner-validator / validator-triager / benchmark-validator / testdata-validator ——它们 type='validator',判据①盖不住;org-validator 只作为 ownerOrgNode 出现,不会被误伤); ③ 公司已有成型的 org graph(存在 active 的 org_graph_edges)而该角色不在图上。
  • 判据③为什么看边不看节点:P1/P2 的 hire 自己就会造节点,拿「有没有节点」当信号会把 「同一家公司连雇两个人,第二个不入图」——graph-agent-p1-hire.test.js 正是这么用的——直接废掉。 第一版就是拿节点当信号,跑全量测试当场红了这一条,才改成看边。
  • 不写 org 节点时仍然补 system_agents(graph-service.ensureSystemAgentRow(),从 upsertOrgGraphNode 里拆出来单独导出):work_graph_nodes.system_agent_id 是指向该表的外键, 少这行 W6 的「先雇再提交图」照样外键挂(W1 记过同一条失效链)。
  • 顺带修掉一个老问题:org 节点 role_name='org-coder' 绑定本体 coder 时,在成型图上再雇一次 coder 以前会多出一行同义节点,现在不会。
  • 默认行为 = 写,P1/P2 语义在「公司还没有成型的图」这个前提下原样保住。
  • 已知残留(不提前加规则):① 单 org node 的最小图没有边,判据③不生效,这时只有 ①② 认识的 本体会被拦下——executor.json 的 {{implAgent}} / {{validatorAgent}} 现填出来的本体仍会入图; ② hire() 的 already_exists 是提前返回,不补 system_agents(W5/W6 记录里那条死角), 本轮故意没补:目录在而表里没有时插一行 company_hired 会squat 掉模板 id, 正是 W1 清掉的那条失效链(seedTemplates 撞上被占用的 id 会 skip,且此时目录还在、 不满足悬空回收条件),代价比收益大;③ graph-seed-service.seedCompanyAgents()(手动脚本 scripts/graph-seed.js 才跑)只镜像了判据①的 meta-bootstrap 那半条,跑它仍会把 work 层本体 塞回 org graph——启动流程不跑它,暂时无害,等它有运行时调用方时必须同步这三条判据。
  • 测试:server/tests/unit/company-hire-org-layer.test.js(9 例,真 hire() + 从 org-graph-service.getGraph() 读回,即 GET org-graph 的同一条读路径)。三条判据各做过变异验证 (分别短路掉 → 红 3 / 1 / 3 例)。每条「不入图」都配了一条只换一个条件的正例, 免得测试退化成「这个角色被整体拉黑」。
  • 仍未验:真 CLI 下 workgraph-planning 雇完一轮后控制台左栏的实况(验收标准 7),归第三阶段。

W8 落地记录(按钮已有真实调用方;mock 演示仍差两环,两环都不在 UI 侧)

  • 入口两处,同一个组件 web/src/components/graph-console/ReplanOrgGraphButton.vue: GraphConsole 左栏(有图 → 标题栏小按钮;无图 → 左栏空态里的主操作,没有 org graph 的公司 整个控制台无事可做,入口在这里必须是主按钮而不是一句「先跑 orggraph-planning」的提示语); CompanyDashboard 的「🕸️ 组织流程」块(同样有图挂标题栏、空态给大按钮——新建公司后 用户最先看到的就是这一屏的「暂无组织流程」)。至此 POST /:companyId/org-graph/replan 有了前端调用方,不再只能 curl。
  • 过渡态:posting → planning mm:ss(按钮禁用防连点)→ done / timeout(6 分钟,可「继续等」)。 已有图时先 confirm:writeGraph 是整图覆盖,旧图作废。
  • 刷新:轮询 GET /:companyId/org-graph 的图指纹(4s),不是 SSE。replan 内部那条 agent 对话的 SSE 由服务端自己消费(triggerOrgGraphPlanning 的 fetch),浏览器没有把手,端点也不回 conversationId;控制台现成的 /graph-runs/:id/stream 是 graph run 的事件轨,而 org graph 规划不产生 graph run。指纹含节点 id(writeGraph 每次换新 id),否则「重规划出同一张图」 会被误判成超时。变化即 emit('planned') → 父组件重载左栏定义 + run 列表(旧 run 的节点与新图对不上,必须一起重拉)。
  • fixture:新增 fixtures/devloop-mock/orggraph-planning.json(此前回放 default.json, 建公司时的自动规划是空转)。产出 org-planner → org-coder → org-validator + 两条 fail 回退边, 形状对齐 system/org-graph-templates/dev.json。实测把它 chunks 里那段图 POST 给真正的写入方: {"ok":true,"nodeCount":3,"edgeCount":4},GET 读回四条边齐全(含 input_underspecified 那条)。 锁:server/tests/unit/orggraph-planning-fixture.test.js(4 例:图在 chunks 不在 result / 形状对齐 dev.json / 过五项校验 / mock 协议按 agent id 与 {cid}: 作用域名都能回放)。
  • 🟡 mock 下「点一下就出图」还差两环(真 CLI 模式不受影响,agent 自己跑 curl):
    1. fixture 命不中:_sendMockPrompt 用 agentInfo.name 冒号后那段查 fixture。 graph-engine 传 {cid}:{agentId}(命中),agent-chat.js:684 的 getOrCreateProtocol 传的是 identity.md 的显示名——orggraph-planning 的 identity.md 没有「名称:」行, 退化成字面量 'Agent' → 回放 default.json(实测就是那两块 38 / 83 字符)。 orggraph-planning 只有 chat 这一条入口。最小修法:把 agent id 传给协议 (同时协议侧 rawName 要改成 options.agentName 优先,否则 agentInfo.name 恒真)。
    2. mock 没有落库环:真实 agent 是 Step 6 自己 curl -d @/tmp/x.json POST /org-graph 写进去的,而 mock 只回放 chunks/files/result,不执行任何工具调用,HTTP 副作用为零。 work 层没这个问题(extractPlannedGraph 从 output 解析)。要让 mock 也能演示, 需在 triggerOrgGraphPlanning 消费完输出后 extractPlannedGraph → writeGraph(仅当图仍为空)。 实测:mock 下 POST .../org-graph/replan → {"status":"replanning"},日志 [Replan] ✅ 已触发 {cid}:orggraph-planning,但 GET /org-graph 仍是 {nodes:[],edges:[]}。 验收标准 5 仍要留到真 CLI 那一轮验。
  • 顺带确认:CompanyDashboard 的「🕸️ 执行控制台」入口仍在(goToGraphConsole)。
  • 附带发现(与 W1 相关,正面):writeGraph 会给未登记的 systemAgentId 自动补 company_hired 行; 本轮测试公司删掉后 graph-seed-service.reconcileDanglingRows() 一次收干净 (orgNodesRemoved:3 / orgEdgesRemoved:4 / agentsRemoved:[org-planner, org-coder])。

第三批 · 让结果可信

# 内容 量
W9 ✅ 已实现,🟡 真跑未验 失败上下文进提示词:validator-triager 的 agent.md 承诺「给你哪个节点失败、第几 attempt、失败摘要」,全仓库没有代码提供。已由 describeFailureContext 补上,详见下方「W9 落地记录」 中
W10 ✅ 跨层地址要 target_org_node + target_node 两半都写才合法 —— 原表述与引擎相反,已订正:target_node 是唯一必填半,target_org_node 可选。模板已按引擎逐字改写,并补上「org node 名从哪来」。详见下方「W10 落地记录」 小
W11 ✅ system/skills/meta-* 缺失导致 base agent 的 Hot Swap 静默空转。选方案 (b):不补技能包,改成显式告警 + 如实改文档。核实出更深一层:补技能包也关不上权限闸(工具权限来自 config.json 的 toolPolicy,与 phase 无关)。详见下方「W11 落地记录」 中
W13 ✅ 真跑已验(§六bis:整轮真跑 git status 与基线逐字相同) agent 运行态文件从 git 移出去(守卫误判的一半根因;另一半是并行 agent,见 §六bis 缺陷②,W13 管不到) 小

W9 落地记录(已实现;未起真 CLI,用注入 executor 验提示词文本)

  • 接在哪:graph-engine.advanceOne 组好 ctx 之后、拼产物契约之前, ctx.task += describeFailureContext(ctx)。两种节点都拼:agent_call 的 ctx.task 直接就是提示词正文;subgraph 的 ctx.task 被 buildPlanningPrompt 原样放进 「## ② 任务描述」,规划本体重规划时同样需要知道上一轮死在哪。
  • 段落形状(只在确实有前序失败时出现,没有就是空串,不塞空段落白烧上下文):
  [前序失败上下文 —— 引擎提供,本 run 里最近的一次节点失败]
  失败节点:**benchmark-validator**(node_run_id=grn_xxx)
  第几次尝试:attempt-1(人类计数第 2 次)
  当时落库的 fail_reason:impl_bug
  失败摘要(来自 node.output):契约非法:acceptance 缺字段…(已截断,完整输出见 output_ref)
  你若是归因节点(validator-triager):上面这个**节点名**就是 agent.md 里 `target_node`
  三个取名来源中的第 (a) 个(引擎点名的失败节点),可直接用;判断它不是真正的根因时再走 (b)/(c)。

这一段正是 agent.md 那三个取名来源里第 (a) 个的供给端 —— 在此之前它恒为空, 归因只能靠 (b)/(c) 猜。

  • 数据源是 timeline.ndjson,不是 graph_run_nodes(这是本项的关键判断): 直觉写法 SELECT … WHERE status IN ('fail','blocked') 在最需要它的那条路径上恰好取不到值—— benchmark-validator 失败 → backward 边退回 validator-triager → handleBackwardEdge 调 resetForwardDownstream,而 benchmark-validator 正是 validator-triager 的 forward 下游,被重置成 pending 且 fail_reason / remediation_note 一并 SET NULL。等归因节点真正跑起来时,库里已经没有任何一行记着刚才谁失败了。 timeline.ndjson 是 append-only、永不改写已有行的审计轨,失败那一刻的 node.output (带 W12 那一轮加的 output_excerpt)原样躺在里面。 测试里显式断言了那一刻库里确实零失败行、而提示词里仍然有上下文。
  • 顺带给 W12 的 output_excerpt 加了第二个消费方:它不再只是「给人看的」, 引擎自己会把它读回来喂给下一个节点。
  • 四环:产生(节点失败时的 node.output / node.error 事件,W12 那一轮落的)→ 落库(timeline.ndjson,append-only)→ 读回(readRunTimeline + findLastFailure)→ 传递(ctx.task → AgentManager.callAgent 的提示词 / buildPlanningPrompt 的 ②)。
  • 已知上限(ponytail):每个节点起跑前读一次整份 timeline.ndjson(切尾 2000 行)。 O(文件大小),而文件大小被任务级预算(十几到几十次调用)钳住,眼下无所谓; 真要拆的话升级路径是给 graph-run-sse 加「按 ref 倒读最近 N 条」的接口,不是加一列缓存。
  • 作用域是「本 run」:按 timeline 行的 run_id 过滤。子 run 里的失败不会串到父 run 的 提示词里(父层拿到的是 deriveChildOutcome 那条透传),反之亦然。
  • 仍未验:真 CLI 下 validator-triager 是否真的照着 (a) 取名(mock 不读提示词,验不了)。 对应验收标准 8。
  • 锁:server/tests/unit/graph-engine-fail-reason.test.js 后 6 例(含 backward 回退那条关键路径)。

W13 落地记录(已完成;未起真 CLI,用 mock + 服务层验)

根因:system agent 的 Claude CLI workDir 就是它自己的源码目录 system/agents/<id>/, 而那是被 git 跟踪的源码树。它每跑一次就重写自己的 todo.md / plan.md,Claude CLI 还改 .claude/scheduled_tasks.json;边界守卫扫的是整仓 git status,于是这些改动被归属到 「当时正在跑的那个 work 节点」头上 —— 一个真正成功的节点被判越界 fail(W3 那次真跑的断点)。

已移出 git 索引(git rm --cached + .gitignore),共 17 个文件:

路径模式 判据
system/agents/*/todo.md(8) 内容是 agent 自写的任务流水(最大 112KB / innovator-evolver),无人工维护内容
system/agents/*/plan.md(5) 同上
system/agents/*/.claude/scheduled_tasks.json(3) Claude CLI 自己写的 cron 状态(含 lastFiredAt 时间戳),必然每跑必变
system/agents/*/tasks/*.json(1) agent 自己落的任务快照,正规任务元数据在已忽略的 system/tasklog/

不移的(排查后确认是源码 / 人工维护): system/templates/** 全部(todo.md / plan.md 是 hire 的拷贝源, company-service.TEMPLATE_FILES 白名单从这里取)、cron.json(人工配置, 9f15ed3 特意把 enabled 关成 false 防夜间 IO 崩,且只由 PUT /api/agent/:id/cron 显式改写)、 config.json / agent.md / soul.md / identity.md / tools.md / user.md / heartbeat.md。 已经在 .gitignore 里的运行态目录(memory/ workspace/ conversations/ logs/ system/tasklog/)不需要动 —— memory.md 和 _lessons.md 分别落在 memory/、workspace/ 下,本来就没被跟踪。

缺省逻辑:读侧唯一入口是 agent-chat.js:loadFile()(readFileSync 包 try/catch → ''), 缺失只是提示词里少一段,不崩;_copyTemplate() 逐个文件 existsSync 后再拷; 新 agent 的这两个文件由 AgentScaffold / _copyTemplate 从模板生成。 新 clone 验证:把这 17 个文件移走后 LEMONBOSS_CLAUDE_MODE=mock node server/index.js 起服务, /api/health /api/agent/list /api/agent/:id/files /api/agent/:id/cron /api/company/templates 全部正常,服务端日志零 error / 零 ENOENT。

_registry.json 的 usedCount 同类问题:company-service._updateTemplateUsedCount() 每次 hire 都 原地 +1 改写这份人工维护的模板清单(template-refs.test.js 拿它当登记表校验),于是任何一次真跑 都留一条 M system/templates/_registry.json。现在计数搬到 data/template-usage.json(已 gitignore), registry 里的 usedCount 退化成冻结的人工基线,读回时基线 + 增量相加,前端 badge 数字不回退。 四环:hire() 命中模板分支 _updateTemplateUsedCount() → 写 data/template-usage.json → listTemplates() 合并 → GET /api/company/templates → CompanyDashboard.vue / TemplateAgents.vue 的 tpl.usedCount。跑完整套件后 registry 字节不变、usage 文件里有 11 个模板的真实计数 —— 四环 + 真调用方同时被证。

graph-engine 的守卫豁免保留(结论):根治只覆盖「这批已知文件」, agent 在自己目录里新写一个没进清单的文件仍会被扫到;而且 system/agents/*/ 里还有 system/agents/templates/、system/agents/innovator-hunt/system/templates/ 这类 agent 路径解析错误留下的历史目录,说明它确实会往意想不到的地方写。留作第二道防线, 但注释要从「缓解不是根治」改成「根治已做(W13),这里是兜底」。

28 号改造后已改名并放宽(旧名 AGENT_RUNTIME_STATE_RE → 现 AGENT_REGISTRY_TREE_RE = /^system\/agents\//): 本节写下这条结论时,豁免是「本体根上的五个固定文件名」;28 之后 system/agents/ 成了 agent 注册表而不是纯源码树 —— 新本体的落点从(被 ignore 的)data/companies/{cid}/agents/ 搬到了(被 git 跟踪的)system/agents/{id}/,于是 hireFromBaseMetaAgent / agent-creator 在运行期新建的目录会以 ?? system/agents/{newId}/ 一整条目录项进 git status, 五文件名的名单接不住 → 一个真正成功的招聘节点被判 boundary_escape fail。 gitignore 永远挡不住这一类(新本体的骨架本身就是要进版本库的),所以必须在守卫侧解决: 整棵注册表树归 unattributable(留痕不判罚),判罚留给 server/ web/ packages/ 这些真源码目录。 两道防线因此不重叠 —— gitignore 管「运行态不进 git」,守卫管「注册表树里的合法写入不判罚」。 详见 28 号设计 §5.5b。

测试:server/tests/unit/agent-runtime-state.test.js(6 例)—— 锁 git ls-files 不再返回这些路径、 git check-ignore 真的挡得住、模板本体仍被跟踪、usedCount 三环 + 坏文件降级。

W10 落地记录(已完成;只改模板 + 加一个测试文件,未起真 CLI)

先说结论:W10 原文写反了。 原文说「target_org_node + target_node 两半都写才合法, 只写一半会静默退化」。拿到引擎上实跑,只写 target_node 是完全有效的:

环节 代码(graph-engine.js) 实际行为
产生 readNodeProduct:normalizeNodeRef({ orgNode: doc.target_org_node, workNode: doc.target_node }) 读的就是这两个顶层键
合法性 normalizeNodeRef:if (!workNode) return null → orgNode 取不到就写死 null 只有 workNode 缺失才判非法;orgNode 缺省是合法的
落库 settleNode → failNodeRef 列(与 fail_reason 同一次条件更新) 存的是 {"orgNode":null,"workNode":"…"}
解析 resolveCrossLayerTarget 第 ④ 关:if (ref.orgNode && ref.orgNode !== orgNodeName) orgNode 为 null 时这一关根本不进,以回退边的目标为准
读回 NodeDetailPanel.vue 的 readRef(v.workNode 为真才算有地址)+ 概览渲染 `failRef.orgNode \ \

真正无声的是反过来那种写法:只写 target_org_node、不写 target_node。 此时 normalizeNodeRef 返回 null,而降级 timeline 的守卫是 if (doc.target_node != null && !failNodeRef) —— 你根本没写 target_node,一条记录都不会留,控制台只显示「整张 work graph 重跑」。 这是这条链上唯一一处完全没有痕迹的退化。

改了什么(system/templates/validator-triager/{agent.md,tools.md}):

  1. 粒度块的键表加了「必填?」一列,target_node 标 ✅ 必填、target_org_node 标 ⭕ 可选, 并把「两个都给全」的措辞整段换掉(那句话会诱导 agent 去编 org node 名, 恰是同一份 agent.md 自己那条红线);
  2. 明写反向那种写法是「全链路唯一一处完全无声的退化」,要删就两个键一起删;
  3. 四道关的第 ④ 关改成「若你写了 target_org_node」,与 if (ref.orgNode && …) 对齐;
  4. 补上原本缺的那一环:<rootRunId> 从哪来。agent 的提示词里没有 org 层上下文 (describeTask 只拼 task-goal + 任务标识),但 [节点产物目录] 那段给的是绝对路径 …/data/companies/<companyId>/runs/<rootRunId>/nodes/<nodeRunId>/(nodeArtifactDir → runRootDir,用的是 run.root_run_id,子 run 里的节点也落在根 run 目录下)。切出 runs/ 后面那一段就能 GET /api/graph-runs/<rootRunId> 拿到组织层那张图: run.graph_kind==="org" 确认层级,nodes[].name 是 org node 名、nodes[].nodeKind 筛出 subgraph、nodes[].childRunId 再查一层就是可填进 target_node 的候选节点名。 原 tools.md 里那两条 curl 是对的,但 <rootRunId> 一直没有出处,agent 填不出来。

测试:server/tests/unit/graph-cross-layer-address.test.js(3 例,全部走注入假 executor,不起 CLI) —— ① 只写 target_node → 定点重置生效、impl-b 一次都没重跑、账本 target_node_ref 里的 orgNode 由引擎补成 coder;② 只写 target_org_node → 地址整条没落库、整张子图重跑; ③ 模板里的键名与引擎逐字一致,且不许出现 targetNode / target_work_node 之类近似拼写、 不许再出现「两个都给全」「两半都写」这两句已被推翻的措辞。

顺带说明为什么不去修引擎那处无声降级(给「只写了 org 半」也补一条 timeline): graph-engine.js 本轮由别的并行线独占,本轮只在模板侧堵。要修的话是把 if (doc.target_node != null && !failNodeRef) 放宽成 if ((doc.target_node != null || doc.target_org_node != null) && !failNodeRef),一行,留给后续轮次。

W11 落地记录(已完成;选 方案 (b))

选 (b) 的论证 —— 关键证据是本轮新查出来的,不是原文里那条:

system/skills/ 目录确实不存在(agent-chat.js 的 existsSync 恒 false),这一点原文没错。 但补出四个 meta-* 技能包并不能兑现「EXECUTE 阶段才拿破坏性权限」:

  • Hot Swap 做的事只有 copyFileSync(sourceSkill, agentDir/.claude/skills/main_skill/SKILL.md); 那个文件的唯一消费者是 buildSystemPrompt,被当作提示词文本拼进「我的核心主技能」一段。 也就是说 meta-* 能改的只有话术,改不了权限。
  • 工具权限的唯一来源是 agent 自己 config.json 的 cli.toolPolicy, 由 getOrCreateProtocol 在建协议时读一次转成 --allowedTools / --disallowedTools, 与 phase 没有任何关联。
  • 更直接的反证:system/templates/base/config.json 现在是 {"mode":"restricted","allowedTools":["bash","read_file","str_replace"]} —— bash 和 str_replace 从 RESEARCH 第一秒就在手上。所以「RESEARCH 阶段照样能写文件」 的成因是 config.json 直接给全了工具,不是技能包缺失。

收尾补一处:agent 自己的提示词还在骗它自己(2026-08-17)

上面查清了「技能包改不了权限」,但 system/templates/base/agent.md 仍然在对 agent 本人 写着这三句:

  • 开场「你当前默认只被挂载了 meta-research 这个元技能」—— 那个包不存在,没挂载过;
  • 阶段三「系统给你换上 meta-execute 技能时,你才会拥有修改核心文件的破坏性权限」 —— 这是纯假承诺,权限自始至终由 config.json 决定,与 phase 无关;
  • 阶段一/二「给你换上 Plan 技能」—— 同上。

方案 (b) 的本意是「如实降级」,只把 agent-chat.js 的空转喊出来是喊给运维听的; agent 读的是 agent.md,那一侧不改,模型就会继续以为「前三阶段我写不动文件」—— 而它其实从第一秒就握着 bash 和 str_replace。一个以为自己被拦着的 agent, 比知道没人拦它的 agent 更危险。

已改成如实表述:开场加一段⚠️说明「技能包不存在、换装不会发生、权限全程不变、 四段式真正给的是上下文隔离而不是权限隔离」,并把三处「换上 X 技能」改成 「记忆已清空,你手上只有 」。阶段三那句改成 「你的写入权限从第一阶段起就没变过 —— 『前三步不落盘』是纪律要求,不是系统给你的限制」。

至此 W11 的 (b) 才算真的闭合:运维侧有告警、agent 侧不再被误导、文档侧记清了根因。 真要落地按阶段收权限,见 27-P3/P4 §九bis 的结论(必须让 transition 同期改写生效中的 toolPolicy,且它与「四段式改写成引擎上的标准图模板」是同一件事的两半)。

结论:走 (a) 会造出一个比现状更危险的状态 —— 四个文件躺着、日志说「已切换」、文档可以打勾, 而权限闸门一如既往地开着。这正是 T9 那次「落地了降级却既没核实也没回改文档」的同型。 真要落地阶段化权限,必须让 transition 同时按 phase 改写生效中的 toolPolicy, 那与 §九bis 已列为「P3 之后」的「四段式改写成引擎上的标准图模板」是同一件事 (改造后每个阶段是一个独立节点、独立一次 agent 调用,toolPolicy 才第一次有机会按阶段取值)。

改了什么:

  • server/routes/agent-chat.js Hot Swap 段补 else 分支,打一条 console.warn, 写明「元技能包不存在(附完整路径)→ 本次流转未换装 → 工具权限仍由 config.json 的 cli.toolPolicy 决定,与阶段无关」。只加了这一个 else 分支,别处未动。
  • 27-P3/P4 §九bis:Hot Swap 那行由「❌ 静默失效」改成「❌ 仍然失效(不再静默,改成显式告警)」, 新增一整块「为什么不选方案 a」的论证与证据;「另两处顺带发现」(phase 无校验、 可跳阶段、直接拼进路径)标注为均未修、仍然成立;§九bis 末尾那三条改造收益的第 2 条 补上「改造后 toolPolicy 才有机会按阶段取值」这半 —— 那正是方案 a 单独做不到的部分。

没做什么(明确不留第三种状态):system/skills/ 目录仍然不存在,四个 meta-* 技能包一个都没建。 这一维的能力没有变化,变的只是它不再无声。


五、成本预估

一次 3 个 org node 的任务:3 次规划调用 + 每张 work graph 3-4 个节点,不含回退约 15 次调用,带一两次回退到 20-25 次。按 plan-cost-concurrency.md 的实测量级推算,单次 $3-8、墙钟 15-40 分钟。

调试期一律用单 org node 的最小图(省约三分之二)。第一批的验收就按这个规模定。


六、验收标准

第一批

# 标准 状态
1 服务启动后 system_agents 与 system/templates/ 一致,无悬空 company_hired 行;新加一个模板目录、重启服务,它自动出现在表里 ✅ 已验(生产库实查 + hermetic 用例)。一处已知且合理的偏差:system/agents/ 与 system/templates/ 同名的 6 个 id(agent-creator / org-innovator / innovator-{digest,evolver,hunt} / evolver-youtube-crawler)按设计 skip,仍是 global_agent。图用到的本体都不在这 6 个里
2 起一个真 CLI 的 run,节点产出全部落在 data/companies/{cid}/projects/{pid}/ 内;git status 在宿主仓库无任何新增/修改 🟡 前半已验、后半被并行 agent 打破(2026-08-17,§六bis)。2026-08-20 补记:后半那个「被并行 agent 打破」本身已经不再导致误判 —— 守卫改成「节点开跑那一刻仓库就脏 → 只记证据、不判罚」,见 27-P3C §17.2。但这条验收标准本身仍未在干净环境下重验。当时的原始记录:workgraph-planning 的产物真的落在 data/companies/p3bfin/projects/p-24vxi8/workspace/ 内,project-root.json 四环闭合;宿主仓库出现的两处改动经查是并行的 Master agent 写的(缺陷②),已复原。被判罚的那个 work 节点自己一个字节都没往仓库写
3 故意让节点往产物目录外写,被守卫拒绝并在 timeline 留痕 ✅ 真 CLI 已验(2026-08-17,§六bis):git 扫描确实扫到了宿主仓库里的那处改动,node.boundary 事件带完整 entries,结果被降级判 fail 且 fail_reason='boundary_escape' 落库。但同一次也验出了误判:那处改动是并行的 Master agent 写的,不是被判罚的节点写的(W3「拦不住」第 5 条,见 §六bis 缺陷②)。判罚逻辑侧另有 W12 补完那一轮加的三条分支用例。2026-08-20 补记:误判那一半已修(27-P3C §17.2,`BOUNDARY_GUARD=strict
4 造一份非法契约跑 benchmark-validator:判决为失败且引擎按失败处理(不是 success) 🟡 静态成立,未真跑。模板已改写 verdict: "attributed",而 FAILING_VERDICTS={attributed,inconclusive} → readNodeProduct 置 failingVerdict=true → callAgentNode 判 fail(graph-engine.js:473/385),链路读通;防线测试三次反例变异全部变红

2 与 3 都要真 CLI 才验得了,而真 CLI 现在会先死在 W5/W6 上。这两条应挪到第二批之后一起验。

第二批

  1. 新建公司 → 界面点「重新规划组织」→ orggraph-planning 真跑 → org graph 出现,且它雇的三个 org 节点都在。
  2. 下达任务 → workgraph-planning 真跑 → 它引用的本体被自己雇进公司 → 图提交成功(不再外键挂)。
  3. 上条跑完后,控制台左栏 org graph 仍然只有 org 节点,没有混进 prd-planning 这类 work 层本体。 ✅ 已修(58a2865「W7 零边图」,2026-08-17 当天)。判据加了主判据 authored_by='planner' 的节点存在 —— 「orggraph-planning 提交过一张图」的直接证据, 零边也认;边数降为存量行的回落判据。守卫 company-hire-org-layer.test.js 的 CO_ZEROEDGE(零边图上 work 层本体不入图)+ W7-fix(零边图上图里点名的 org 节点 重复雇仍入图,P2 语义不被误伤)。 以下是修之前的现场记录,留档不是当前状态:三条判据都盖不住 「零条边的最小 org 图 + 一个既非 meta-*、又没在 work graph 骨架里点过名的本体」, readme-writer 被真的写进了 org_graph_nodes,GET /org-graph 返回两个节点, 下一次任务的左栏并排显示两张 org 卡片。 服务层那 9 例测试仍然全绿——它们造的是有边的成型图,正好绕开了这个形状。

第三批

  1. validator-triager 的提示词里能看到「哪个节点失败、第几 attempt、失败摘要」。 🟡 代码已成立,真 CLI 未验:describeFailureContext 已接进 advanceOne, 6 例测试(注入 executor 抓 ctx.task 原文)覆盖 forward fail 边、backward 回退、 subgraph 规划、超长截断、以及「无前序失败时一个字都不塞」。见上方 W9 落地记录。 真 CLI 下 agent 是否照着 (a) 取名,归真跑那一轮。
  2. 一次真实的跨层回退:target_org_node + target_node 两半齐全,只有被点名的节点被重置。

六bis、第三次真 CLI 终验(2026-08-17,未改一行代码)

跑法:主仓库 vite(5173,前端代码)+ worktree .claude/worktrees/wf_55ba803e-5ac-6 的真 CLI 服务 (3010,data/ 独立)。worktree 先 git reset --hard 3d9cb5b,再把主工作树里未提交的本轮改动 (14 改 + 5 新增 + W13 的 17 个 git rm --cached)拷进去。公司 p3bfin,单 org 节点最小图。 预算 total_agent_calls 实跑 2/15。跑完公司已删净(DB 四表 0 行、PRAGMA foreign_key_check 空、目录 rm)。

验收点 结果
建公司 → orggraph-planning 真跑落图并自雇(R-18.1) ✅ 成立。创建后 1 分钟内 org_graph_nodes 出现 org-writer,orggraph-planning / workgraph-planning 按 W7 判据①没有混进 org 节点(日志逐条留痕)
界面点「下达任务」→ 起 run(W14) ✅ 成立。点按钮 → 弹出「任务描述 + 手动/自动」→ 点「下达」→ 新 root run run_9da101273663 RUNNING、预算条 calls 0/100 出现、入口节点 READY
workgraph-planning 真跑规划 + 自雇(W5/W6) ✅ 成立。wg_55c7e53f959f 两节点(org-writer-impl→readme-writer、org-writer-validator→planner-validator)+ 正向边 + backward fail 兜底边;两个本体都被它自己 hire 进 data/companies/p3bfin/agents/,图提交没有外键挂。这一段是 Master 自主巡检下的单,不是我点的「下一步」——同一条链路、同一个端点
RUNNING 中的节点有进度感(本轮新增) ✅ 成立。org 节点卡片 ⏱ 2m 23s 秒表在走 + 呼吸点;详情面板常驻条「运行中 2m 40s · 最近事件 plan.call 调 workgraph-planning 规划子图(第 1 轮)(2m 40s 前)」;work 节点上换成 agent.call · 调用 readme-writer(attempt #1);概览 tab 的「耗时」不再冻结
跑一个真 work 节点 → success ⛔ 本轮没跑到。org-writer-impl 跑了 7m19s 后 fail,真实根因是 AgentManager._runViaProtocol 的 idleTimeoutMs 硬编码 120s:readme-writer 通读 graph-engine.js 期间静默超过 2 分钟被掐死(idle timeout: no stream data for 120000ms)。详见下方「本轮查出的三处缺陷」①
失败节点的概览 tab 能看到原因(不是 —) ✅ 成立。work 节点与 org 节点的概览都显示 fail_reason = [引擎判定] boundary_escape,下面一块终端样式 excerpt 写明「[边界守卫] …留下 1 处改动」+ agent 原始错误串;「运行日志」tab 里 node.boundary / node.output / node.status 七条事件都到了浏览器(W12-UI 的具名事件白名单在真 CLI 下生效)
fail_reason 真落库 ✅ 成立。graph_run_nodes.fail_reason='boundary_escape',work 节点与父 org 节点两行都有(子 run 终态回写父节点那个修复没被打破)。四环(产生→传递→落库→读回)在真 CLI 下走通
W13:真跑期间 git status 不再出现 agent 运行态条目 ✅ 成立。整轮真跑(含 agent-creator / master 各一次真 CLI)结束时 worktree git status 与基线逐字相同;git check-ignore -v 四条模式全部命中;system/templates/_registry.json 零 diff,3 次 hire 的计数落在已忽略的 data/template-usage.json。守卫这一轮没有用到 splitUnattributable 兜任何一条 agent 运行态文件
W3 守卫:真 CLI 下 git 扫描能不能扫到越界写 ✅ 扫得到(第一批验收标准 3 从 🟡 转 ✅)—— 但扫到的是并行的 Master agent 写的,不是本节点写的。见下方缺陷②
W10 跨层地址两半齐全 ⚪ 本轮无回退发生(没有节点走到归因产出这一步),未验
第一批验收标准 2(产出全落项目根、宿主仓库无新增) 🟡 一半成立:项目根 data/companies/p3bfin/projects/p-24vxi8/ 真的被分配并写入(workspace/workgraph-plan-T-*.md、workgraph-T-*.json),project-root.json 四环闭合;但宿主仓库有两处新增改动 —— 来源是并行的 Master agent(缺陷②)

本轮查出的三处缺陷(都未改代码)

① AgentManager 的 120s idle timeout 掐死长思考的 work 节点 — server/src/core/agent-manager.js:324

const idleTimeoutMs = this.config?.agents?.[agentName]?.idleTimeout || 120_000;

同进程里 ClaudeCliProtocol 早就把自己的 idle 上限放到 24h(日志里那句「🔧 工具执行中 → 超时切为 86400s」), stream-executor 的 JSDoc 默认值写的是 1_800_000(30 分钟)。这里的 120s 把两者都压掉一个数量级, 而且 ...options 里若透传 idleTimeoutMs 会被下一行覆盖掉 —— 调用方无法覆写。 真 CLI 下任何一个「先读几千行源码再动笔」的 work 节点都会在动笔前被杀,产物为空。 这是本轮「真 work 节点 → success」没跑成的唯一原因,与 W1-W14 无关,属新缺陷。

② 边界守卫在有并行 agent 时误判(W3「拦不住」第 5 条真实发生)

org-writer-impl 跑到第 7 分钟时,另一个进程里的 Master agent(retrain-watchdog 每 5 分钟一次的智能巡检唤起) 在同一个 worktree 里改了两个源文件:

  • server/tests/unit/company-hire-org-layer.test.js(+82/-6,给 W7 加了一条判据④和配套用例)
  • server/src/core/agent-manager.js(改上面那个 idle timeout;且引用了未定义的 DEFAULT_IDLE_TIMEOUT_MS, 真跑起来是 ReferenceError)

守卫的 git status 差集把第一处算到了正在跑的 work 节点头上 → boundary_escape → 该节点被降级判 fail。 两处改动已 git checkout 复原(diff 存档 /tmp/master-agent-pollution{,-2}.diff),主仓库全程零污染。 结论:真跑期间不能有任何别的 agent 在同一工作树里活动,否则守卫必误判; retrain-watchdog 的 Master 巡检就是这样一个 agent,它默认开着且没有开关。

③ W7 的「已知残留」在真跑里被踩中,且后果比注释写的更直接

decideOrgNodeRecording 判据③要求「公司已有 active 的 org_graph_edges」, 而单 org 节点的最小图零条边,判据③恒假;readme-writer 既不是 meta-*(判据①) 也不在 system/work-graph-templates/*.json 里(判据②)—— 于是它被写进 org_graph_nodes。 GET /api/company/p3bfin/org-graph 实测返回 两个 org 节点(org-writer + readme-writer), 下一次「下达任务」的控制台左栏就并排显示两张卡片、两个都 READY、都会被 advanceOne 当 org 节点执行 (截图 screenshot-1786937704828-44.jpg)。 → 验收标准 7 在「零边图」这一形状下不成立,状态应从 🟡 改为 ⛔(下方已改)。

④ 顺带:blocked 的 root run 让控制台变成死胡同(前端/后端取值域不一致)

web/src/views/GraphConsole.vue:183 的 ACTIVE_RUN 含 blocked, 而 server/routes/company.js 的 ACTIVE_ROOT_RUN_STATUS 只有 ['ready','running','paused']。 run 收敛成 blocked 后:「下达任务」按钮被前端藏起来(后端其实会放行), GraphRunPane 的「停止」也被 can('stop') 置灰(后端 POST /:id/stop 实测能把 blocked → stopped)。 一家公司只要失败过一次,控制台上就再也下不了第二个任务,只能走 API。 按 W14 那段注释自述的意图(「按钮的显隐要和端点的 409 条件一致」),这是它自己没对齐。

本轮没做的

  • 没点「下一步」:断点(缺陷①)在第一个 work 节点上已经明确,再花一次 3-5 分钟的规划调用 只是重跑一条同一轮里已经走通的路径,按 §七.1 的成本纪律停手。
  • 没验 W10(没有回退发生)、没验第一批标准 4(benchmark-validator 没进这张图)。
  • 没造「引用不存在本体」的失败节点:真跑自己给出了一个更真实的失败样本 (boundary_escape + idle timeout),第 6 项的两个验收点(概览有原因、fail_reason 落库) 已由它满足,不必再造一个。

七、风险

  1. 成本不可控:真 CLI 下一次跑错方向就是几美元 + 半小时。缓解:调试期强制单 org node;任务级预算的 total_agent_calls 压到 15 以内先跑通。
  2. W7 的语义决定要谨慎:hire() 写 org_graph_nodes 是 P2 §4.2 定的(「hire 成功后写一行,幂等」),当时的前提是只有 org 节点会被雇。方案 a 打破了这个前提。改动要确认不破 P2 已有的 24 条测试。
  3. 真 CLI 会暴露一批模板质量问题:前七轮所有 agent 模板都只在 mock 下「跑过」,而 mock 不读提示词——这些提示词实际上从未被任何模型读过。第一次真跑大概率会集中暴露表述矛盾(W4 就是一例,benchmark-validator 的 Step 表与自己的产出格式表就互相打架)。
  4. 不要在没有 W2/W3 的情况下跑真 CLI:已经发生过一次 agent 写入宿主仓库。

八、FAILING_VERDICTS 要不要放宽(W4 的附带结论)

graph-engine.js 归 W2/W3 那一轮改,本节只给结论与理由,不动代码。

结论

要动,但不要用「把 "fail" 加进 FAILING_VERDICTS」这个改法——应该把判据反过来。

// 现在(白名单记「失败词」,凡不认识的一律当通过 = fail-open)
const FAILING_VERDICTS = new Set(['attributed', 'inconclusive']);
failingVerdict = verdict === null ? raw !== null : FAILING_VERDICTS.has(verdict);

// 建议(白名单记「通过词」,凡不认识的一律当失败 = fail-closed)
const PASSING_VERDICTS = new Set(['passed', 'skipped', 'produced', 'no_prior_failure']);
failingVerdict = verdict === null ? raw !== null : !PASSING_VERDICTS.has(verdict);
// 并对不认识的取值记一条 timeline(degraded: 'unknown_verdict'),与 fail_reason 的处理对称

理由

  1. 只补 "fail" 是打补丁,漏的是一整类。 判据的形状才是缺陷:失败侧是白名单, 于是 failed / rejected / not_met / error / no / 不通过 —— 任何一个模型 临场想出来的失败词,引擎都读成「没失败」。补一个词只堵一个洞,洞的数量是开放的。
  2. 模型不受模板约束,这一点 W4 本身就是证据。 mock 从不读提示词,所以这批提示词 在第一次真跑前从未被任何模型读过(§七.3)。模板只能提高写对的概率,不能保证; 引擎是最后一道,它必须对没见过的词取安全的那一侧。
  3. 安全侧是「判失败」而不是「判通过」。 判错成失败的代价是多跑一轮(烧计数、走兜底边, 熔断有 LOOP_HARD_CAP_EDGE/RUN 兜底);判错成通过的代价是不合格产物直接放行, 而且不留任何痕迹——这正是 W4 的失效方向。两种错代价不对称,判据就该偏向贵的那侧。
  4. 引擎里已有同形状的正确先例。 fail_reason 那一侧是封闭域(ATTRIBUTION_REASONS), 自创取值 → 显式降级成 null + 一条 timeline,不静默相信。verdict 应当对称处理。
  5. verdict === null 那条缺省分支不用动:缺 verdict 但有 fail_reason 判失败, 本来就是 fail-closed 的,改动只影响「写了 verdict 但引擎不认识」这一支。
  6. 代价接近零。 通过词一共四个,现有模板全部命中(template-verdicts.test.js 的 PASSING_VERDICTS 与它逐字一致,实测扫描无一例外)。反转后 attributed / inconclusive 继续判失败,语义不变。

两点必须一起注意

  • 反转不能替代 W4 的模板改动。 反转之后 verdict: "fail" 会被判成失败, 但那是「猜对了」,不是「写对了」:产出仍然带着一个引擎不承认的词, org-validator 那种 pass/fail 审计词也仍然会串到判词字段上。模板必须写对,引擎兜底。
  • 反转会让 template-verdicts.test.js 报错,这是设计好的。 它按名字从 graph-engine.js 源码取 FAILING_VERDICTS,取不到就抛 (「找不到 FAILING_VERDICTS——取值域防线失去依据,不要让它静默空跑」)。 真去反转的话,把该测试里 engineSet('FAILING_VERDICTS') 换成 engineSet('PASSING_VERDICTS') 并把 VERDICT_DOMAIN 的组成对应改一行即可;不要把断言删掉了事。

六ter、第四次真跑复验(2026-08-20)

跑法:主仓库、真 Claude CLI、工作区干净(git status 空)。公司 e2e-demo, 把 2026-08-18 卡住的那条 run(run_9168bd24f46c,做 lemonai 官网)解开后逐节点 /next 推到收敛。 19 次真 agent 调用、177,462 tokens,全树 completed,goal → done。 过程记在 27-P3C §17.5。

逐条结果

验收 结果 证据
2 产出落在项目根内、宿主仓库 git status 无新增/修改 ✅ 成立 19 次真 agent 调用之后 git status --porcelain 完全为空;交付物(index.html / styles.css / app.js / assets/img/ / DELIVERY.md)都在 data/companies/e2e-demo/projects/p-bkhylu/ 内。这是 §六bis「后半被并行 agent 打破」那一条的干净环境重验
3 越界被守卫拒绝并留痕 ✅ 早已成立;误判那一半 2026-08-20 修掉 见 27-P3C §17.2
4 非法契约 → validator 判失败且引擎按失败处理 ✅ 成立,但不是真 CLI 验的 graph-real-chain-smoke.test.js ⑥(零注入链路,只换掉 CLI 子进程):fixture 产出 verdict: "attributed" → 节点 fail、fail_reason=input_underspecified、沿 fail 边点亮下游、判词落在下游节点行上。反向验过:verdict 改成 passed 立刻红
7 work 层本体不混进 org graph ✅ 已修(58a2865) CO_ZEROEDGE / W7-fix
8 validator-triager 的提示词里有「哪个节点失败、第几 attempt、失败摘要」 ✅ 成立(真 CLI) 判据是它引用了只可能来自提示词的内部 id:triager 的判词原文写着「org-coder-impl 私有目录 rn_96a8cd696dcc 下只有 _research.md」—— rn_96a8cd696dcc 是 graph_run_nodes.id,agent 没有任何别的途径知道它,只能来自 describeFailureContext 注入的那一段
9 一次真实的跨层回退,两半地址齐全、只重置被点名的节点 🟡 产出端通了,消费端没等到那个形状 见下

验收 9 的准确状态

真跑里 triager 确实产出了两半地址:

node.triage | 归因 verdict=attributed fail_reason=impl_bug → {"orgNode":"org-coder","workNode":"org-coder-impl"}

这一条本身有分量:27-P3/P4 §八.22 的第 13 处记的正是 「引擎四环全通,但产出方 validator-triager 的 agent.md 里根本没有这个键」—— 那半条现在通了,真实 agent 真的写出了 orgNode + workNode。

但紧接着的一条:

node.triage | 归因点名了 org-coder-impl,但回退目标 org-coder-impl 不是 subgraph 节点,忽略跨层地址

这不是缺陷,是判据在正常工作:这次回退发生在 work 层内部 (org-coder-validator → org-coder-impl),edge.to 本身就是 agent_call 节点, 已经是最细粒度,没有「跨层」可言 —— resolveCrossLayerTarget 第 ② 关就是拦这个的。

所以验收 9 缺的是那个形状本身:需要一次 org 层的回退边、且目标是 subgraph 节点。 今天这条 run 的三个 org 节点重试后一次过,没有 org 层失败。

别把「机制没验」和「机制没有」混为一谈:定点重置的逻辑侧有 graph-cross-layer-address.test.js 三例注入式覆盖(只写 target_node / 只写 target_org_node / 模板键名逐字对齐)。缺的只是「真跑里发生过一次」。

顺带发现的一条缺陷(未修)

真跑的 timeline 里,每个节点的产物索引写入都失败了,且被降级成一条日志:

node.output | 产物索引写入失败 contract.json: UNIQUE constraint failed: index 'idx_graph_files_path'
node.output | 产物索引写入失败 sdd.md:       UNIQUE constraint failed: index 'idx_graph_files_path'

根因已查明并修复(2026-08-20),而且它是个存量库专属缺陷:

生产库里还留着产物索引加「版本」之前的那条无条件唯一索引 idx_graph_files_path(UNIQUE(company_id, COALESCE(task_id,''), rel_path))。 加版本之后建表块换成了带谓词的 idx_graph_files_current(WHERE superseded_by IS NULL), 但没人删旧的那条 —— CREATE ... IF NOT EXISTS 只管不重复建,不会删已有的。

于是 recordFile 那套「先把旧行 superseded_by 指向新行、再插新行」的版本化写法 在存量库上整个失效:旧索引不认 superseded_by,同路径第二行直接撞。 新建库压根没有那条索引,所以只有存量库会踩,任何单测都照不到。

修法是迁移里一句 DROP INDEX IF EXISTS idx_graph_files_path, 守卫 graph-files-stale-index.test.js 走存量库重启那条路(反向验过:不删就红)。 生产库已实跑验证:删索引后同路径连产两次不再抛,当前版本仍只有一条。

这是同一类问题的第三次(tasks 加列的索引顺序、墙钟锚点迁移、这次)。 判据可以固定成一句话:凡是碰 schema,「存量库重启」那条路必须单独走一遍 —— 新建库的路径由单测天然覆盖,存量库的不会。