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:
parent
ecc113f9b3
commit
02c54264c8
@ -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 当轮输出不可解析的垃圾
|
||||
# (连续 <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_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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user