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 ⛔ 真跑不成立(§六bis 缺陷③readme-writer混进 org graph,未修); 第三批 8/9 真 CLI 未验。 §六bis 缺陷② 的根因(边界守卫误判并行会话的改动)已于 2026-08-20 修掉, 见 27-P3C §17.2 —— 但根因修了不等于验收点验了,那几条仍要重跑一遍。
一、为什么需要这份文档
P3 主体七轮做下来,引擎编排层是扎实的(状态机、双层熔断、三层级联、跨层回退,都在脚本 executor 下逐环验过),但两件事一直没解决:
- 验证一直建立在 mock 之上。
ClaudeCliProtocol._sendMockPrompt完全不读提示词,只按 agent 名回放 fixture。所以前六轮报的「端到端生产成立」,验的是引擎编排,不是 agent 链路。第七轮换真 CLI 后,链路在第一个 work 节点就断。 - agent 供给链没有执行者。 work graph 节点引用的本体,没有任何流程会把它雇进公司。
本文档收敛这两件事,目标是:能在控制台上点一下,让真实 Claude CLI 跑完一个最小任务,且不写坏宿主仓库、验收不假放行。
二、目标与非目标
目标
workgraph-planning规划完图后自己雇齐引用到的本体(R-18.2 方案 a)。- 真实 Claude CLI 能跑完一个单 org node 的最小任务,产出落在分配好的项目根内。
- 验收链路不会「假放行」。
- 界面上有入口触发
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_hired11→0,template37→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 条已修复。三处改动 + 一道防线:
benchmark-validator/agent.mdStep 1 表、testdata-validator/agent.mdStep 1 表:verdict: "fail"→verdict: "attributed",各补一段红字说明「写"fail"引擎读成没失败」。org-validator/agent.md的审计字段verdict: pass|fail→audit_verdict。 它本来就是审计产物(org 节点是subgraph,成败由deriveChildOutcome从子 run 推出), 但它写的文件路径与 work 层判词完全相同(nodes/<nodeRunId>/attempt-<n>.json), 只靠「本节点恰好是 subgraph」才没被readNodeProduct当判词读。改名后两套词彻底分开; 即便哪天真被当判词读,引擎也会退到「有fail_reason就算失败」的缺省规则上,仍然判对。- 防线
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独占创建,并发下先到先得)→ 读回(下一个节点 / 子 runreadProjectRootRecord)。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,引擎不可能在写发生的那一刻
拦住它。所以本轮落地的是「提示词声明(调用前)+ 事后取证 + 结算拒收」三件,能力边界如下,
不要当成「产出目录已被隔离」。
能拦住的
- 宿主仓库内可见的越界写:
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 抛异常那条路径同样查(先写坏再崩,痕迹一样在)。 - 敏感路径:项目根分配后过
path-resolver.isSensitivePath,命中则拒用并留痕 (这是唯一一道调用前就强制的守卫)。 - mock 产物越界:
mock/files事件里逃出节点产物目录的路径照旧被丢弃(原有行为)。
拦不住的(明确未解决)
- 宿主仓库之外的写完全看不见:
~/Documents/别的项目、/tmp、$HOME下任何路径。 - 被
.gitignore忽略的路径看不见(含data/companies/、*.log)。 - 调用前就已 modified、调用中又被改一次的文件:porcelain 行不变,看不见。
- 越界的写已经发生,守卫只拒收结果,绝不自动回滚——回滚等于替开发者丢弃未提交的 改动,代价方向比误判更贵。清理靠人工看 timeline 里的清单。
- 误判:开发者本人或并行的另一个 agent 在同一时刻改仓库,会被算到本节点头上。
- 守卫只在会真起 CLI 的执行器下开(默认 executor / 默认规划器);测试注入的假 executor 不扫(进程内函数,扫仓库既无意义又要给每个用例加两次 git 调用)。自定义 executor 若自己 起子进程,不在守卫范围内。
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 隔离做掉之前,真跑仍应在可丢弃的工作副本里做」。与代码一致。
两点补充:
- 「正好覆盖已发生过的那次事故」这句在当前工作树里只成立一半。 事故是
写
server/routes/echo.js(未跟踪)+ 改server/index.js(已跟踪)。 而server/index.js此刻本来就是 modified(W1 的改动),命中「拦不住」第 3 条 (调用前已 modified、调用中再改一次 → porcelain 行不变)。所以现状下守卫只看得见?? server/routes/echo.js那一半。这不是代码 bug,是两条自述互相打架,措辞该收敛。 - 降级路径零测试覆盖。
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:unit786/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):
- 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恒真)。 - mock 没有落库环:真实 agent 是 Step 6 自己
curl -d @/tmp/x.jsonPOST/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 那一轮验。
- fixture 命不中:
- 顺带确认:
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_escapefail。 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}):
- 粒度块的键表加了「必填?」一列,
target_node标 ✅ 必填、target_org_node标 ⭕ 可选, 并把「两个都给全」的措辞整段换掉(那句话会诱导 agent 去编 org node 名, 恰是同一份 agent.md 自己那条红线); - 明写反向那种写法是「全链路唯一一处完全无声的退化」,要删就两个键一起删;
- 四道关的第 ④ 关改成「若你写了
target_org_node」,与if (ref.orgNode && …)对齐; - 补上原本缺的那一环:
<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.jsHot 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 上。这两条应挪到第二批之后一起验。
第二批
- 新建公司 → 界面点「重新规划组织」→
orggraph-planning真跑 → org graph 出现,且它雇的三个 org 节点都在。 - 下达任务 →
workgraph-planning真跑 → 它引用的本体被自己雇进公司 → 图提交成功(不再外键挂)。 - 上条跑完后,控制台左栏 org graph 仍然只有 org 节点,没有混进
prd-planning这类 work 层本体。 ⛔ 真跑不成立(2026-08-17 第三次真 CLI 终验,见 §六bis 缺陷③):三条判据都盖不住 「零条边的最小 org 图 + 一个既非meta-*、又没在 work graph 骨架里点过名的本体」,readme-writer被真的写进了org_graph_nodes,GET /org-graph返回两个节点, 下一次任务的左栏并排显示两张 org 卡片。 服务层那 9 例测试仍然全绿——它们造的是有边的成型图,正好绕开了这个形状。
第三批
validator-triager的提示词里能看到「哪个节点失败、第几 attempt、失败摘要」。 🟡 代码已成立,真 CLI 未验:describeFailureContext已接进advanceOne, 6 例测试(注入 executor 抓ctx.task原文)覆盖 forward fail 边、backward 回退、 subgraph 规划、超长截断、以及「无前序失败时一个字都不塞」。见上方 W9 落地记录。 真 CLI 下 agent 是否照着 (a) 取名,归真跑那一轮。- 一次真实的跨层回退:
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落库) 已由它满足,不必再造一个。
七、风险
- 成本不可控:真 CLI 下一次跑错方向就是几美元 + 半小时。缓解:调试期强制单 org node;任务级预算的
total_agent_calls压到 15 以内先跑通。 - W7 的语义决定要谨慎:
hire()写org_graph_nodes是 P2 §4.2 定的(「hire 成功后写一行,幂等」),当时的前提是只有 org 节点会被雇。方案 a 打破了这个前提。改动要确认不破 P2 已有的 24 条测试。 - 真 CLI 会暴露一批模板质量问题:前七轮所有 agent 模板都只在 mock 下「跑过」,而 mock 不读提示词——这些提示词实际上从未被任何模型读过。第一次真跑大概率会集中暴露表述矛盾(W4 就是一例,
benchmark-validator的 Step 表与自己的产出格式表就互相打架)。 - 不要在没有 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 的处理对称
理由
- 只补
"fail"是打补丁,漏的是一整类。 判据的形状才是缺陷:失败侧是白名单, 于是failed/rejected/not_met/error/no/不通过—— 任何一个模型 临场想出来的失败词,引擎都读成「没失败」。补一个词只堵一个洞,洞的数量是开放的。 - 模型不受模板约束,这一点 W4 本身就是证据。 mock 从不读提示词,所以这批提示词 在第一次真跑前从未被任何模型读过(§七.3)。模板只能提高写对的概率,不能保证; 引擎是最后一道,它必须对没见过的词取安全的那一侧。
- 安全侧是「判失败」而不是「判通过」。 判错成失败的代价是多跑一轮(烧计数、走兜底边,
熔断有
LOOP_HARD_CAP_EDGE/RUN兜底);判错成通过的代价是不合格产物直接放行, 而且不留任何痕迹——这正是 W4 的失效方向。两种错代价不对称,判据就该偏向贵的那侧。 - 引擎里已有同形状的正确先例。
fail_reason那一侧是封闭域(ATTRIBUTION_REASONS), 自创取值 → 显式降级成null+ 一条 timeline,不静默相信。verdict应当对称处理。 verdict === null那条缺省分支不用动:缺verdict但有fail_reason判失败, 本来就是 fail-closed 的,改动只影响「写了verdict但引擎不认识」这一支。- 代价接近零。 通过词一共四个,现有模板全部命中(
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的组成对应改一行即可;不要把断言删掉了事。