fix(qc): QC取证预算4→16轮+删默认通过静默放行+占位文档硬门禁(2026-09-15 pbls停摆根治)

三处机制缺陷,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模式)。
This commit is contained in:
ymq 2026-09-16 08:34:18 +08:00
parent ecc113f9b3
commit 02c54264c8

View File

@ -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):
# 轮次预算 162026-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 当轮输出不可解析的垃圾
# (连续 <tool_call> 标记、无动作 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_approvecomment/score/passed/total"
"review_rejectcomment/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_taskfailed→submittedQC 任务只被 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