From 02c54264c8ea4fbe75f8967cc90a57fab00147ff Mon Sep 17 00:00:00 2001 From: ymq Date: Wed, 16 Sep 2026 08:34:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(qc):=20QC=E5=8F=96=E8=AF=81=E9=A2=84?= =?UTF-8?q?=E7=AE=974=E2=86=9216=E8=BD=AE+=E5=88=A0=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E9=80=9A=E8=BF=87=E9=9D=99=E9=BB=98=E6=94=BE=E8=A1=8C+?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E6=96=87=E6=A1=A3=E7=A1=AC=E9=97=A8=E7=A6=81?= =?UTF-8?q?(2026-09-15=20pbls=E5=81=9C=E6=91=86=E6=A0=B9=E6=B2=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处机制缺陷,pbls项目停摆的完整根因链: ① QC循环预算与评分协议结构性矛盾(qc_review_run): 原range(4)+turn>=2禁工具, QC实际只有1~2次取证机会,而协议要求逐项read_file核验未读取一律判不过—— 物理上不可能完成核验→必然reject。pbls M1a四轮qc_reject原文全是 '未能完成逐个read_file核验',develop做得再真也被退到上限→项目pause。 修: 预算16轮,强制决策点第12轮,收尾阶段硬拒探索类工具(对齐PM循环模式)。 ② '无明确决策→默认通过'是静默放行制造机: design连挂3次上游Timeout后交付 26个~100B自引用占位文档,QC输出不可解析垃圾命中else分支被默认通过, 8秒过门禁→PM按文件存在性approve→36表DDL设计正文不存在,16个模块任务 在空地基上开发。修: 不可解析输出纠错重试;轮次耗尽上限内回置qc_review 重新认领(刻意不走retry_task——QC任务只被qc poller按state认领,回submitted 会搁浅),达上限mark_failed→failed_poller冒泡人工。绝不静默approve。 ③ 新增_validate_stub_docs占位文档硬门禁: deliver入口(native+文本兜底两路径) 确定性拦截空文件/单行自引用指针文档,回填可行动FAIL(补真正文或ask_question 冒泡)。'占位不算交付'从口号变成代码门禁(对齐diagram_gate/deliverable_type模式)。 --- pipeline_service/agent_loop.py | 118 +++++++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 7 deletions(-) diff --git a/pipeline_service/agent_loop.py b/pipeline_service/agent_loop.py index fd8bc37..226ba3a 100644 --- a/pipeline_service/agent_loop.py +++ b/pipeline_service/agent_loop.py @@ -2287,6 +2287,47 @@ def _validate_diagram_form(role, deliverable, space_dir, written_files): return None +def _validate_stub_docs(space_dir, written_files): + """占位文档硬门禁(2026-09-15 pbls design 占位事故):占位不算交付。 + + 事故形状:design 连挂 3 次上游 Timeout 后,把 26 个设计文件全写成 ~100-300B 的 + 自引用指针(data-model.md 正文=「见工作空间文件 …/data-model.md(…)」——指向它自己), + QC 当轮输出垃圾被「默认通过」静默放行 → 36 表 DDL 设计正文从此不存在, + 16 个模块任务在没有设计正文的地基上开发。 + + 本门禁在 deliver 入口做确定性拦截(对齐 diagram_gate/deliverable_type 守卫模式): + 检测本任务 write_file 实写的 .md——空文件、或全文单行且以「见」开头并引用自身路径 + (自引用占位指针)→ 拒绝 deliver,回填可行动 FAIL(补真正文或 ask_question 冒泡)。 + 真实文档不会全文只有一行指向自己的指针,零误报。 + 返回 None=通过;字符串=拒绝原因。 + """ + stubs = [] + for f in dict.fromkeys(written_files or []): + if not f.lower().endswith(('.md', '.markdown')): + continue + try: + with open(f, encoding='utf-8', errors='ignore') as fh: + txt = fh.read() + except Exception: + continue + rel = os.path.relpath(f, space_dir) if f.startswith(space_dir) else f + s = txt.strip() + if not s: + stubs.append(f"{rel}(空文件)") + elif ('\n' not in s and len(s) <= 500 + and (s.startswith('见') or s.startswith('詳')) + and (rel in s or os.path.basename(rel) in s)): + stubs.append(f"{rel}(自引用占位指针,无正文)") + if not stubs: + return None + return ("FAIL: 交付含占位空文档——占位不算交付(2026-09-15 硬规定:缺失的信息要冒泡问人," + "不能用指向自己的指针充数)。以下文件没有实际内容:" + + ";".join(stubs[:10]) + + ("等共 %d 个" % len(stubs) if len(stubs) > 10 else "") + + "。请写出真实正文(按角色规范:架构/DDL/接口契约/验收锚点等)后重新 deliver;" + "若因信息缺失或上游故障无法产出正文,用 ask_question 如实冒泡等待回答,禁止交付占位文档。") + + def _validate_deliverable_type(role, deliverable_type, allowed_types): """deliver 类型守卫:角色声明了合法类型清单时,非法类型拒绝并返回清单(可行动报错)。 @@ -2672,6 +2713,12 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): msgs.append({"role": "tool", "tool_call_id": tc.get("id", ""), "content": _dg_err}) logger.info(f"role_agent deliver diagram rejected: task={task_id} {_dg_err[:160]}") continue + # 占位文档门禁(同 diagram_gate 模式,2026-09-15 pbls design 占位事故) + _st_err = _validate_stub_docs(space_dir, written_files) + if _st_err: + msgs.append({"role": "tool", "tool_call_id": tc.get("id", ""), "content": _st_err}) + logger.info(f"role_agent deliver stub-docs rejected: task={task_id} {_st_err[:160]}") + continue deliverable = {"action": "deliver", **params} break if tool == "ask_question": @@ -2728,6 +2775,13 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): msgs.append({"role": "user", "content": _dg_err}) logger.info(f"role_agent deliver diagram rejected(text): task={task_id} {_dg_err[:160]}") continue + # 占位文档门禁(文本兜底路径同款,2026-09-15) + _st_err = _validate_stub_docs(space_dir, written_files) + if _st_err: + msgs.append({"role": "assistant", "content": raw}) + msgs.append({"role": "user", "content": _st_err}) + logger.info(f"role_agent deliver stub-docs rejected(text): task={task_id} {_st_err[:160]}") + continue deliverable = act break elif act.get('action') == 'ask': @@ -3629,12 +3683,16 @@ async def qc_review_run(project_id, agent_id=None, model_name=None): from .llm_bridge import llm_call_msgs decision = None - for turn in range(4): + # 轮次预算 16(2026-09-15 pbls M1a 事故根治):原 4 轮 + turn>=2 就禁工具, + # QC 实际只有 1~2 次取证机会,而评分协议要求「逐项 read_file 核验、未读取一律判不过」 + # ——预算与协议结构性矛盾,QC 物理上无法完成核验 → 必然 reject → develop 做得再真 + # 也被退到上限(pbls M1a 四轮退回全在说「未能完成逐个 read_file 核验」)。 + for turn in range(16): await sor.sqlExe( "UPDATE pipeline_tasks SET updated_at=NOW() WHERE id=${tid}$ AND state='qc_review'", {"tid": task_id}) await sor.sqlExe("COMMIT", {}) - if turn >= 2: + if turn >= 12: msgs.append({"role": "user", "content": "已检查足够信息。现在必须立即输出 review_approve 或 review_reject,禁止再调用其它工具。"}) try: @@ -3656,19 +3714,65 @@ async def qc_review_run(project_id, agent_id=None, model_name=None): elif act.get('action') == 'tool_call': tool = act.get('tool', '') params = act.get('params', {}) - if tool == 'load_skill': + if turn >= 12 and tool != 'load_skill': + # 硬约束(对齐 PM 循环成熟模式):收尾阶段拒绝执行探索类工具, + # 强制 QC 立即输出决策——软提示实测会被无视、耗尽轮次。 + result = (f"已到最后收尾阶段(第 {turn + 1}/16 轮),拒绝执行探索类工具 {tool}。" + f"请立即输出 review_approve 或 review_reject(含 score/passed/total 与未过项清单),不要再调用工具。") + elif tool == 'load_skill': result = await _load_skill_by_name(sor, project_id, 'qc', org_id, params.get('name', ''), params.get('file_path') or None) else: result = await _exec_agent_tool(tool, params, space_dir, capability_ctx) msgs.append({"role": "assistant", "content": raw}) msgs.append({"role": "user", "content": f"工具 {tool} 结果:\n{result}"}) else: - # 无明确决策 → 默认通过(避免 QC 卡死阻塞流程) - decision = {'status': 'approved', 'comment': raw[:200]} - break + # 无明确决策 → 纠错重试(2026-09-15 pbls design 占位文档事故根治)。 + # 原实现「默认通过」是静默放行制造机:design 连挂 3 次上游 Timeout 后 + # 交付 26 个 ~100B 自引用占位文档,QC 当轮输出不可解析的垃圾 + # (连续 标记、无动作 JSON),命中 else 分支被「默认通过」 + # 静默放行 → 占位设计过门禁 → PM 按文件存在性 approve → 16 个模块任务 + # 在没有设计正文的地基上开发。QC 是质量权威,放行必须是显式决策; + # 不可解析输出按 PM 循环同款处理:纠错提示重试,轮次耗尽转 failed + # 由 failed_poller 冒泡人工——绝不静默 approve。 + logger.warning(f"qc_review_run 未识别 QC 输出 turn={turn}: {raw[:200]}") + msgs.append({"role": "assistant", "content": raw}) + msgs.append({"role": "user", "content": + "你的输出无法解析。请严格按格式输出单个 JSON:" + "review_approve(comment/score/passed/total)或 " + "review_reject(comment/score/passed/total/questions)之一,不要输出其它内容。"}) if not decision: - decision = {'status': 'approved', 'comment': 'QC 检查超时,默认通过'} + # 轮次耗尽仍无有效决策 → 绝不「默认通过」(2026-09-15 pbls design 占位事故根治)。 + # 上限内回置 qc_review 重新认领(垃圾输出多为上游 LLM 瞬时异常,重审大概率恢复); + # 达上限 mark_failed → failed_poller 判 fault → pause + fault_report 冒泡人工。 + # ⚠️ 不走 retry_task(failed→submitted):QC 任务只被 qc poller 按 state='qc_review' + # 认领,回 submitted 会搁浅(agent poller 不管 agent.qc 角色)。 + from .workspace import get_max_task_retry + _rc = await sor.sqlExe( + "SELECT retry_count FROM pipeline_tasks WHERE id=${tid}$", {"tid": task_id}) + await sor.sqlExe("COMMIT", {}) + rc = 0 + try: + rc = int(getattr(_rc[0], 'retry_count', 0) or 0) if _rc else 0 + except (TypeError, ValueError): + rc = 0 + max_retry = await get_max_task_retry(sor) + err = ("QC 审核循环耗尽 16 轮仍未输出有效决策(review_approve/review_reject)," + "疑似上游模型输出异常") + if rc < max_retry: + await sor.sqlExe( + "UPDATE pipeline_tasks SET state='qc_review', claimed_by=NULL, " + "retry_count=retry_count+1, last_error=${e}$, updated_at=NOW() " + "WHERE id=${tid}$", {"e": err, "tid": task_id}) + await sor.sqlExe("COMMIT", {}) + logger.warning(f"qc_review_run no decision: task={task_id} requeued " + f"(retry {rc + 1}/{max_retry})") + return {"status": "requeued", "task_id": task_id, "error": err} + from .task_capability import mark_failed + await mark_failed(task_id, project_id, who="agent.qc", agent_id=agent_id, + error=err + f";重审 {rc} 次仍无有效决策,需人工介入") + logger.error(f"qc_review_run no decision: task={task_id} -> failed (retry exhausted)") + return {"status": "failed", "task_id": task_id, "error": err} if decision['status'] == 'approved': from .task_capability import qc_approve_task