27-p3b-real-run-runway.md 43.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 全绿


一、为什么需要这份文档

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 守卫下的静默空转

四、工作项

第一批 · 真跑的最小前提

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

# 内容 文件 量
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,不是接线。

阻断真跑的真正断点(§三 第 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):
    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、失败摘要」,全仓库没有代码提供 中
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 上。这两条应挪到第二批之后一起验。

第二批

  1. 新建公司 → 界面点「重新规划组织」→ orggraph-planning 真跑 → org graph 出现,且它雇的三个 org 节点都在。
  2. 下达任务 → workgraph-planning 真跑 → 它引用的本体被自己雇进公司 → 图提交成功(不再外键挂)。
  3. 上条跑完后,控制台左栏 org graph 仍然只有 org 节点,没有混进 prd-planning 这类 work 层本体。 🟡 服务层已成立,真跑未验:hire() 三条判据 + 9 例测试(含「雇一轮 work 层本体后 getGraph 仍只剩三个 org 节点」这条直接对应本标准),见上方 W7 落地记录; 真 CLI 下的实况归第三阶段。

第三批

  1. validator-triager 的提示词里能看到「哪个节点失败、第几 attempt、失败摘要」。
  2. 一次真实的跨层回退:target_org_node + target_node 两半齐全,只有被点名的节点被重置。

七、风险

  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 的组成对应改一行即可;不要把断言删掉了事。