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 全绿
一、为什么需要这份文档
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 守卫下的静默空转 |
四、工作项
第一批 · 真跑的最小前提
做完这四件就能真跑,且不会写坏仓库、验收不会假放行。
| # | 内容 | 文件 | 量 |
|---|---|---|---|
| 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,不是接线。
阻断真跑的真正断点(§三 第 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 例)。
第二批 · 招聘走方案 a
| # | 内容 | 文件 | 量 |
|---|---|---|---|
| W5 ✅ | workgraph-planning 模板改语义:撤掉「你不招人也不辞人」,加招聘步骤,tools.md 给 company hire 用法 |
system/templates/workgraph-planning/** |
小 |
| W6 ✅ | 写死顺序:先雇、再提交图(反了必外键挂);雇失败的重试与降级 | 同上 | 小 |
| W7 ✅ 已改,🟡 真跑未验 | 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、失败摘要」,全仓库没有代码提供 |
中 |
| W10 | 跨层地址要 target_org_node + target_node 两半都写才合法;模板要写清(只写一半会静默退化成整张图重跑) |
小 |
| W11 | system/skills/meta-* 缺失导致 base agent 的 Hot Swap 静默空转,「EXECUTE 阶段才拿破坏性权限」只剩话术。补技能包,或如实降级并改文档 |
中 |
五、成本预估
一次 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 在宿主仓库无任何新增/修改 |
⛔ 未验,且暂不该验。链路先断在 §三 第 6 条(Agent not found,见上方断点一节),真跑到不了产出这一步。mock + 默认执行器下项目根的分配/落库/读回已验 |
| 3 | 故意让节点往产物目录外写,被守卫拒绝并在 timeline 留痕 | ⛔ 未验。降级分支只在 defaultAgentExecutor 下可达,单测注入假 executor 走不到;判据 diffRepoStatus 单独验过 |
| 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 层本体。 🟡 服务层已成立,真跑未验:hire()三条判据 + 9 例测试(含「雇一轮 work 层本体后getGraph仍只剩三个 org 节点」这条直接对应本标准),见上方 W7 落地记录; 真 CLI 下的实况归第三阶段。
第三批
validator-triager的提示词里能看到「哪个节点失败、第几 attempt、失败摘要」。- 一次真实的跨层回退:
target_org_node+target_node两半齐全,只有被点名的节点被重置。
七、风险
- 成本不可控:真 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的组成对应改一行即可;不要把断言删掉了事。