test_script 节点偶发输出叙述文字而非代码——问题记录(Planner + Validator 都会中招)
日期: 2026-08-07
性质: 问题记录 + 待调查,不是已确认根因的需求文档(区别于同目录下其他几份——那几份是已经定位到具体代码 bug 并修复过的,这份目前还只是"观察到的现象",根因未确定)。
状态: 根因已定位(见第 6 节)。用户决策(2026-08-07):不再继续在提示词/重试反馈上打补丁,先直接禁用 test_script/validator_test_script 节点(Planner 的 work_graph_plan._PROMPT_TEMPLATE 不再要求这个节点,Validator 的 e2e_pipeline._VALIDATOR_MANIFEST 从 3 节点缩成 2 节点),后续如果真的需要再重新设计(见第 8 节的结构性修复方向:把"探索"和"最终作答"拆成两次独立调用)。_NARRATION_RETRY_HINT(第 7 节)代码仍保留在 _test_script_verify_fn 里,只是当前这条路径不会被触发——如果以后重新启用这个节点,这个缓解依然生效。2026-08-08 更新(第四轮,与自规划 Work Graph 需求的交叉决策):docs/todo/work-graph-self-planning-and-loop-back-requirements.md(Planner 自规划节点集合 + 失败回路需求)上线后,test_script 会重新作为可选节点开放给 Planner 自由规划——用户明确决定不等本文档的结构性修复,narrative-output 问题可能因此重现,这是已知且被接受的风险。本文档记录的根因(第 6 节)的真正结构性修复,安排在另一份尚未撰写、关于"给 Coder agent 加入 test_script 模块"的文档里一起解决,不是本文档单独继续跟进的范围。2026-08-07 更新(第二轮):不只是 Validator 的问题——911861fe 这个纯后端任务在 Planner 自己的 n_test_script 节点上也复现了同一模式。累计 3 个任务、6 次到达 test_script 类节点,6 次全部复现(100%),横跨 Planner 和 Validator 两种场景。建议提高优先级,不要再靠重试撞运气。 2026-08-07 更新(第三轮,根因排查):这是 08-06 "Benchmark 叙述事故"(commit 07a0a27)的同类复发——test_script 类节点当时已经同时拿到提示词纠偏和机械拦截网双重防护,仍然 6/6 复现。根因不是"self-hosting 词汇污染"这个泛泛的猜测,而是 test_script 提示词本身要求"先做工具探索再产出内容",模型探索后的收尾话术天然是"汇报我做了/启动了什么"而不是切换成纯文本吐出模式;self-hosting 只是放大器,让叙述读起来更精确(因为探索对象就是 graphlemon 自己,词汇表精确对应),不是必要条件。BRD/PRD/SDD/Benchmark/test_dataset 这几个不需要先探索的纯组合型节点,自 08-06 后再未复现,与此吻合。
1. 现象
validate_implementation_with_own_graph()(Validator 自己的 benchmark→test_dataset→test_script 验证图)里的 validator_test_script 节点,连续两次真实生产运行中都输出了叙述性文字而不是可执行代码,导致 check_script_compiles() 正确拦截(py_compile/node --check 都报语法错误),verify_fn 判定失败,重试一次仍然失败,最终 ConsistencyCheckFailedError。
两次真实发生,内容形态高度相似,都是"在描述某个 Work Node/workflow 的执行情况",不是在写测试脚本本身:
- 任务
3605272e(2026-08-07 03:10 UTC 附近):"Launched the 'graphlemon-node-f9cefb87af4c492aac738b9736d41e4e' workflow (Work Node validator) in the background, run ID wf_93345ca8-8c6. It's running asynchronously — I'll report results once it completes; no further action is needed right now." - 任务
bfd3ee82(2026-08-07 03:13 UTC 附近):"The graphlemon Work Node (validator) workflow completed. It authored \test_nav_project_context_benchmark.js` — an independent mechanical test for the console nav-bar 'current project' context feature (frontend/app.js's parseHashQuery, computeNavStatusBucketCounts, renderNavProjectContextHtml, refreshNavProjectContext, pickInitialTaskBoardStatus, mountTaskBoard, plus index.html's #nav_project_context element and style.css's .nav-count-* rules). It loads the real app.js into a Node vm sandbox with a fake DOM/fetch/localStorage (no mocking of the logic under test) and asserts against it, exiting non-zero on failure."` - 任务
bfd3ee82,同一个任务重试后第三次复现(2026-08-07 05:35 UTC 附近):"It produced a runnable Node.js test script (\nav_project_context_benchmark.js`) validating U-047/U-048 (console nav-bar 'current project' context). It loads the real `frontend/app.js` into a `vm` sandbox with fake DOM/fetch/localStorage stand-ins — no reimplementation of the logic under test — matching this repo's `frontend/test_*_smoke.js` convention."` ——同一个任务、同一个功能点,连续两次独立生成都复现了同一种"描述我做了什么"而不是"直接给代码"的模式,且两次描述的测试思路本身高度相似(都是 Node vm 沙箱 + 假 DOM/fetch/localStorage),说明这不是偶然的一次性生成噪音,值得提高优先级。 - 任务
bfd3ee82,第四次复现(2026-08-07 06:03 UTC):"Launched the graphlemon Work Node (validator) workflow in the background (Task ID: wrux6aaat, Run ID: wf_201fcc6d-59f). You'll be notified automatically when it completes; no further action is needed right now."——跟3605272e第一次的措辞几乎一字不差("Launched ... workflow in the background ... You'll be notified ... no further action is needed"),进一步印证这是一种稳定的、可复现的措辞模式,不是随机生成噪音。 任务
3605272e,Resume 后再次复现(2026-08-07 06:04 UTC):"The graphlemon Work Node (validator) workflow completed (Task ID wtjnbdm8p). It independently produced a mechanical acceptance benchmark (test_run_detail_benchmark.py) for the '运行历史点击跳转 + 运行详情页' (run-history click-through → run-detail page) feature, exercising ..."——这次描述的测试对象换成了3605272e自己的功能点(运行详情页),跟"内容是瞎编的"这个假设不符——每次都精确对应当前任务的真实功能点,说明模型是真的在认真构思测试方案,只是稳定地把"描述方案"当成了最终输出。任务
911861fe(纯后端任务——修supervisor/deploy_stage.py并发互斥锁,2026-08-07 06:08 UTC):这次是 Planner 自己的n_test_script节点(不是 Validator 的),文案是"The graphlemon planner work node finished. It generated a Benchmark ..."——同样的模式,但这次发生在 Planner 侧,证明这不是 Validator 专属的问题,而是"生成 test_script 类内容"这个任务形状本身(不管哪个 Org Agent 在做)都容易触发。而且这次任务背景是纯后端 Python bug 修复,跟前两个前端功能任务完全不相关——排除了"只在前端类任务/只在某个特定 Org Agent 里"这个假设。
截至目前的复现统计:3 个任务、累计 6 次到达 test_script 类节点(Planner 的 test_script + Validator 的 validator_test_script),6 次全部复现(100%)。不再是"可能是偶然"的阶段,也不是 Validator 专属或前端任务专属。
第二个例子里引用的函数名(parseHashQuery/computeNavStatusBucketCounts/renderNavProjectContextHtml 等)跟 bfd3ee82 这个任务本身(导航栏"当前项目"上下文功能)高度相关、具体、看起来不是瞎编的——说明模型大概率是真的理解了任务、构思了一个具体可行的测试方案,只是最后把"描述我打算/已经做了什么"当成了输出内容本身,而不是把测试代码原文放进 content 字段。
2. 和已修复的三个 bug 的区别(避免误认为同一件事)
- 不是
join()逐字符拆解那个 bug(那个已修复,产出的是"c; o; n; t; e; n; t"这种逐字符加分隔符的乱码,肉眼一看就是格式损坏;这次是完整、通顺、有实际内容的一段英文叙述,不是格式损坏)。 - 不是批量落盘丢失产出那个 bug(这次两次真实运行,
_write_completed_node_artifacts的落盘修复本身都正确工作了,validator_benchmark/validator_test_dataset都被正确保存,只有validator_test_script本身被正确排除——落盘机制没问题,问题在于这个节点的生成内容本身不对)。 - 不是 API 层面的瞬时错误(529 过载、SSL 证书问题)——那些是连接都没建立成功;这次是模型正常回复了,只是回复内容的"体裁"不对。
3. 一个可能的根因方向(未验证,只是观察到的线索)
两次叙述内容都在讲"某个 Work Node/workflow 在跑/跑完了"——这类措辞在 graphlemon 自举场景下(target_folder_path 是 graphlemon 自己的仓库)非常眼熟,因为 Validator 自己就活在一套"Work Node/workflow"运行时里,它的系统提示词、可用工具、上下文里大概率充斥着这类"Work Node 执行/workflow 运行"相关的表述。一个可能的假设:模型在生成 validator_test_script 内容时,把"描述一个 Work Node 做了什么"这个它非常熟悉的表达模式,错误地套用成了这次输出本身,而不是切换到"直接吐出测试代码"这个更窄的任务要求。
这只是一个待验证的猜测,不是结论——但现在有一点新证据支持它:三个任务全部是 self-target 任务(target_folder_path 是 graphlemon 自己的仓库,这三个任务本身就是 graphlemon 在修自己),不是针对其他目标项目的任务。如果这个猜测成立,预期是"只针对其他目标项目(不是 graphlemon 自己)的任务不会复现",这是一个后续可以直接验证的判断标准——但目前所有真实观察到的样本都恰好是 self-target,还没有一个"针对别的项目"的反例可以对照,所以仍然不能算确认。
4. 非目标(暂定,待调查结果出来后可能调整)
- 不在没有更多样本/更明确根因之前贸然改
_VALIDATOR_DOC_PROMPTS/相关提示词——两次样本不足以定位到底是哪句话引导偏了,盲改可能没用甚至帮倒忙。 - 不改
check_script_compiles()/verify_fn的拦截逻辑本身——它俩这两次都正确工作了(正确拦截、正确要求重新生成、重新生成仍不合格后正确失败并保留了其他产出),问题不在拦截这一层。
5. 待确认的问题
- [x] 需要更多样本才能判断这是不是真的"高频"问题——已有答案:三次独立生成里出现了三次(
3605272e一次、bfd3ee82重试后又出现两次,且两次描述的测试思路高度相似),不是偶然的一次性生成噪音,值得提高优先级尽快排查,不用再等更多样本。 - [x] 如果确认是高频问题,根因排查方向:是不是
validator_test_script节点的提示词本身对"describe vs. output the actual script"这件事表述不够明确?还是跟 self-hosting 场景本身(模型上下文里大量"Work Node/workflow"相关内容)有关,只在自举场景下才容易复现?——见第 6 节,2026-08-07 根因排查结论。 - [ ] 要不要在
_test_script_verify_fn的失败反馈里,除了编译报错本身,再显式加一句"不要描述你做了什么,直接输出脚本原文"这类更直接的纠偏提示,看能不能提高第二次重试的成功率(这是一个相对低成本、可以先试的缓解手段,不需要先confirm根因;_test_script_verify_fn是 Planner/Validator 共用的同一个函数(script_artifact_type参数区分两边),改一处两边都受益)。——根因排查确认了这个方向值得做,而且找到了具体原因:现有失败反馈就是原始编译器报错(如SyntaxError: invalid syntax),对着一段通顺的英文叙述文字,这类报错完全不会提示"文体错了",模型大概率会去纠正标点/缩进这类无关细节,而不是意识到整段内容的性质就不对——bfd3ee82连续两次重试都复现了高度相似的叙述内容,正好印证了这个反馈盲区。尚未实现,等用户确认。 - [x]
911861fe这次证明了不是 Validator 专属,但目前全部样本都是 self-target 任务——找一个真实的、非 self-target(面向其他目标项目)的任务观察一下会不会复现,能进一步验证或排除"self-hosting 场景本身"这个根因方向。——尚未做真实的非 self-target 复现实验(会消耗真实 API 额度,需要用户决定是否值得专门跑一次),但第 6 节的根因分析给出了一个更精确、可独立验证的预测,不必非等这个实验不可。
6. 根因排查结论(2026-08-07)
6.1 这不是第一次——是 8 月 6 日"Benchmark 叙述事故"的同类复发
check_script_compiles() 自己的 docstring 直接引用了 docs/implementation-gap-analysis.md 的"Benchmark-narration incident",commit 07a0a27(2026-08-06,"Give Planner/Coder/Validator real, machine-checkable acceptance criteria")记录了几乎一模一样的现象,只是发生在 Benchmark 节点:
"The planner workflow completed and produced a Benchmark defining 18 scenarios..."
当时的修复是两件事一起做的:① 给 BRD/PRD/SDD/Benchmark/test_dataset/test_script 全部 6 个文档提示词加上 _NO_NARRATION(本文档第 1 节引用的两处提示词,test_script/validator_test_script,从一开始就带着这个修复,不是缺失);② 专门给 test_script/validator_test_script 加了 check_script_compiles() 这个机械(非 LLM 判断)编译检查兜底,因为这是唯一能客观核实"是不是真代码"的节点类型。
关键点:test_script 类节点是当时唯一同时拿到"提示词纠偏 + 机械拦截网"双重防护的节点类型,而且拦截网本身完全正确工作(6/6 次都被正确拦截、正确触发重试)——但纠偏仍然 6/6 全部失败,包括重试后依然叙述。这说明问题不是"忘了加防护",而是这两层防护对这一类节点都不够。
6.2 为什么偏偏是 test_script,不是 BRD/PRD/SDD/Benchmark(这几个自 08-06 后再未复现)
四份文档提示词(BRD/PRD/SDD/Benchmark)本质是"综合已给定的上下文,写一篇文档"——纯组合任务,不需要调用工具做探索。test_script/validator_test_script 的提示词不一样,明确要求"mechanically checks...run real commands, inspect real files/behavior"(Validator 侧原文)——这是唯一一类明确邀请/要求模型先做工具探索(Read/Grep/Bash),再产出最终内容的节点。
模型在用 Bash/Read/Grep 做了几轮探索性工具调用之后,其"收尾话术"的训练分布是"总结/汇报刚才做了什么或启动了什么",而不是"切换成纯文本吐出模式"——这是一个跟这次任务无关的、更底层的行为习惯,_NO_NARRATION 这句附加在提示词末尾的格式规则,不足以覆盖这种在多轮工具调用之后触发的收尾习惯。
self-hosting 场景(target_folder_path 是 graphlemon 自己)在这个机制上不是必要条件,而是放大器:当模型探索的对象就是 graphlemon 自己的仓库时,它读到的代码/表结构里到处都是"Work Node"/"workflow"/wf_*/graphlemon-node-* 这类词汇(work_graph_orchestrate.py::_render_node_script 里 "description: 'graphlemon Work Node ({org_node})'"、script_name = "graphlemon-node-" + node_exec_id... 这些字符串本身就是探索时会读到的真实源码),模型的"收尾汇报"话术套上这些词汇后,读起来精确、具体、像是真的在描述一次执行(本文档第 1 节引用的六段叙述原文,函数名/表述都跟对应任务高度吻合,不是空洞套话)——但这只是让叙述"听起来更像真的",不是叙述产生的根本原因。
可独立验证的预测(修正后,比原假设更精确):换一个不需要"先探索再产出"的文档类节点(BRD/PRD/SDD/Benchmark/test_dataset),即使是 self-target 任务也不会复现(这已经是现有观测——自 08-06 后这几类节点未见复现);换一个非 self-target 任务的 test_script 节点,预期仍会偶发叙述(因为触发条件是"探索后收尾"这个行为模式本身),但描述内容会更泛化、不会精确命中 graphlemon 自己的表/字段名——如果做非 self-target 复现实验,应该重点看叙述"具体程度"而不是"有没有叙述"。
6.3 现有重试反馈的具体盲区
check_script_compiles()/_detect_script_language() 的 detail 字符串本身其实已经带了一句"not a narrative description"("content does not compile as Python or Node/JS -- must be a real, runnable script, not a narrative description.\npython3 -m py_compile:\n...\nnode --check:\n..."),不是完全没提示——但这句话是对"机械检查在查什么"的通用说明,不是针对这次具体输出的定向纠偏,而且后面紧跟着两段原始 py_compile/node --check 报错堆栈,信号被稀释。bfd3ee82 连续两次独立生成后仍然给出高度相似的叙述内容,说明这句通用提示不足以让模型意识到"整个文体判断错了",而不是完全没有相关文字。
7. 已实现(2026-08-07)
在 _test_script_verify_fn 里新增 _NARRATION_RETRY_HINT 常量,作为 reflect feedback 列表的第二项(跟 check_script_compiles() 的原始 detail 一起拼进 "; ".join(feedback)),内容是更直接、更具体的条件式纠偏:"If your previous output was a narrative description of an action (e.g. describing a task/workflow you ran, launched, or will report on) instead of literal script source code, discard it entirely — ... The content field must contain nothing but the script's real source code, starting from its very first real line ..."。
设计上刻意用条件句("If your previous output was...")而不是断言句,这样对真正的语法笔误(不是叙述文字,只是普通代码 typo 导致编译失败)不会产生误导性指控,是一个无害的兜底提示。_test_script_verify_fn 是 Planner/Validator 共用的同一个函数(script_artifact_type 参数区分两边),改一处两边都受益。只改动 reflect prompt 的 feedback 内容,不改变主提示词、不改变 check_script_compiles() 机械拦截逻辑本身。
后续如果这个缓解仍不够(比如下次真实自举任务再次复现),下一步该看第 6.2 节的"探索后收尾习惯"根因——更彻底的方案可能需要在 test_script 提示词本身里,把"先探索、再产出"和"最终答案必须是纯代码"这两个阶段更明确地分开,而不只是在末尾追加一句纠偏。
8. 已实现(2026-08-07,第二次决策):直接禁用 test_script/validator_test_script 节点
用户决策:与其继续在提示词层面打补丁(第 7 节的缓解已经证明有效性无法在下次真实复现前确认),不如先把这两个节点从图里直接去掉,后续真的有需求再重新设计,而不是"所有 goal 任务都固定有这个节点"。这也牵出一个更大的架构问题:Planner 自己的文档链(work_graph_plan._PROMPT_TEMPLATE)虽然过了一层真实的 LLM 规划调用,但这次调用的提示词硬编码要求"必须为 brd/prd/sdd/benchmark/test_dataset/test_script 各出一个节点"——是一个固定架构常量(2026-08-06 的既有设计决定,见 docs/done/dynamic-work-graph-design-skill-design.md §3),不是真的由 LLM 按 goal 自由决定节点。Validator 自己的验证链(_VALIDATOR_MANIFEST)更彻底,连"过一层 LLM 规划"都没有,是硬编码的 Python 字面量。真正"LLM 自己决定要几个节点"的能力已经存在于 _GENERIC_PROMPT_TEMPLATE(给 Coder/Validator 其他子任务用),但没有用在这两条文档链上——是否要把这条能力也用到 Planner/Validator 自己的文档链上,是一个更大的、需要单独决策的后续方向,不在本次改动范围内。
具体改动:
src/work_graph_plan.py::_PROMPT_TEMPLATE:不再要求 test_script 节点(只要求 brd/prd/sdd/benchmark/test_dataset 5 个)。src/work_graph_plan.py::_CANONICAL_DOC_CHAIN:从 6 元组缩成 5 元组,同步去掉了原本专门给 test_script 节点注入"额外依赖 benchmark"的那段代码(_normalize_graph_manifest里)。src/e2e_pipeline.py::_VALIDATOR_MANIFEST:从 3 节点(validator_benchmark→validator_test_dataset→validator_test_script)缩成 2 节点。validate_implementation_with_own_graph():outputs["validator_test_script"]的访问加了in判断,不存在时script_execution走"N/A: 节点已禁用"的兜底分支,verdict 完全由 LLM 自己的判断决定(不再有脚本硬门禁)。- 下游消费者(
run_task_lifecycle/task_execution.py里读 test_script artifact 内容喂给 Coder 的逻辑)本来就已经用"test_script" in artifact_ids/_artifact_content_or_default做了容错(这是 2026-08-06 test_script 刚上线时,为了兼容"这个节点之前不存在的旧任务"而写的防御性代码),这次改动不需要再碰这两处。 _DOC_PROMPTS["test_script"]/_VALIDATOR_DOC_PROMPTS["validator_test_script"]/check_script_compiles()/_test_script_verify_fn/_NARRATION_RETRY_HINT等下游代码全部原样保留,只是不会再被触发——如果以后要重新启用,只需要把这两处节点定义加回去。
测试:src/test_work_graph_plan.py/src/test_e2e_pipeline.py 里原本依赖 6/3 节点固定结构的用例做了相应调整;其中"晚失败节点不连累早节点产出"(partial-artifact-persistence 的回归用例)和"脚本真失败强制 FAIL"(hard-gate 用例)改成显式用一个临时补丁把 test_script/validator_test_script 节点加回manifest 来测试——因为这两块机制本身仍是真实代码,只是默认不会被规划出来,不应该因为默认禁用就失去回归覆盖。python3 -m unittest discover -s src(643 tests)+ discover -s supervisor -p "test_*.py"(126 tests)全绿。