From 38efb3eb17bd9de25109e98e151ea7a42770da1a Mon Sep 17 00:00:00 2001 From: ymq Date: Wed, 16 Sep 2026 12:37:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(deliver):=20git=E6=94=B6=E5=8F=A3=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E5=8C=96=E2=80=94=E2=80=94deliver=E6=97=B6=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E4=BB=A3=E4=B8=BAadd+commit=E6=9C=AC=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=86=99=E5=85=A5=E7=9A=84apps/modules=E4=BB=93?= =?UTF-8?q?=E5=BA=93,=E6=94=B6=E5=8F=A3=E6=A0=B8=E9=AA=8C=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=9B=9E=E5=A1=AB=E4=BA=A4=E4=BB=98=E4=BB=B6(2026-09-?= =?UTF-8?q?16=20pbls=20M1a=E7=BC=96=E9=80=A0git=E8=AF=81=E6=8D=AE=E4=BA=8B?= =?UTF-8?q?=E6=95=85=E6=A0=B9=E6=B2=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 事故:M1a develop在交付摘要声称'git status --porcelain为空已push',QC实测modules/pbl_blueprint约60项未提交→证据链不可信→连退3次达上限→fault→项目paused。deliver入口此前只有占位文档/配图/类型三门禁,无git收口核验,靠QC事后抽查→退回后agent照样编造→死循环(LLM编造工具结果高发形态)。 修法(机制化,不靠LLM自觉):_enforce_git_closure在deliverable files落盘后对本任务写入过的apps/{x},modules/{x}仓库根引擎代为add+commit(无远程只commit,非git自动init);projects/过程仓库跳过(多角色并发git锁)。收口核验记录(含'agent未提交,引擎代为收口N项')回填交付件正文——给QC/PM真实仪表盘,agent的git口头声明从此可对照引擎事实。收口失败不阻断deliver(环境原因卡死任务更糟),回填警告让QC按事实核验。 --- pipeline_service/agent_loop.py | 95 ++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/pipeline_service/agent_loop.py b/pipeline_service/agent_loop.py index d2d1591..b0290f9 100644 --- a/pipeline_service/agent_loop.py +++ b/pipeline_service/agent_loop.py @@ -2396,6 +2396,86 @@ def _validate_stub_docs(space_dir, written_files): "若因信息缺失或上游故障无法产出正文,用 ask_question 如实冒泡等待回答,禁止交付占位文档。") +async def _enforce_git_closure(space_dir, written_files, do_commit=True): + """git 收口硬门禁(2026-09-16 pbls M1a「编造 git 证据」事故根治)。 + + 事故形状:develop 在交付摘要里声称「git add -A 全量收口 + commit + push, + git status --porcelain 为空、git log origin/main..HEAD 为空」,QC 用 run_shell + 实测 modules/pbl_blueprint 有约 60 项未提交变更(M/D/??)→ 声明与磁盘事实矛盾 + → 证据链不可信 → 连退 3 次达上限 → fault → 项目 paused。 + 这是「LLM 编造工具结果」的高发形态,而 deliver 入口此前只有占位文档/配图形态/ + 交付类型三个门禁,**没有 git 收口核验**——只能靠 QC 事后 run_shell 抽查, + QC 轮次有限、退回后 agent 下次照样编造 → 死循环。 + + 机制处置(确定性,不靠 LLM 自觉;提交时机铁律:模块/应用仓库 develop 阶段必须 + 本地提交给 QC 留证据,项目过程仓库才走「审核后统一提交」): + · 只处理本任务实际写入过的 apps/{x}、modules/{x} 仓库根(projects/ 过程仓库跳过, + 避免多角色并发写的 git 锁争用); + · do_commit=True 时由引擎代为 add -A + commit(无远程只 commit,非 git 目录自动 init), + 把「已提交」从 agent 的口头声明变成引擎保证的事实; + · 收口失败(git 命令 rc≠0)→ 返回 ok=False + 可行动原因,由调用方拒绝 deliver。 + + Returns: (ok: bool, report: str) report 为空=本任务未触及代码仓库;否则为核验记录 + (调用方回填交付件,给 QC/PM 当仪表盘:agent 声称的收口是否属实)。 + """ + touched = set() + for f in written_files or []: + rp = f if os.path.isabs(f) else os.path.join(space_dir, f) + try: + rel = os.path.relpath(rp, space_dir) + except ValueError: + continue + parts = rel.split(os.sep) + # 只对仓库根(apps/{name}、modules/{name})收口;projects/ 过程仓库不在此提交 + if len(parts) >= 2 and parts[0] in ('apps', 'modules'): + touched.add(os.path.join(space_dir, parts[0], parts[1])) + if not touched: + return True, '' + + lines, ok = [], True + for rp in sorted(touched): + label = os.path.relpath(rp, space_dir) + if not os.path.isdir(rp): + continue + was_git = os.path.isdir(os.path.join(rp, '.git')) + try: + st = await _run_shell('git status --porcelain', rp, 20) + except Exception as e: + st = {'rc': -1, 'stdout': '', 'stderr': str(e)} + dirty = (st.get('stdout') or '').strip() + if st.get('rc', -1) != 0 and was_git: + ok = False + lines.append(f"{label}: git status 执行失败({str(st.get('stderr', ''))[:120]}),无法核验收口") + continue + if not dirty and was_git: + continue # 本来就干净,无需记录(避免噪音) + if not do_commit: + if dirty: + ok = False + n = len(dirty.splitlines()) + lines.append(f"{label}: 工作区有 {n} 项未提交变更,交付前必须 git_commit_push 收口") + continue + # 引擎代为收口:add -A + commit(无远程只 commit;非 git 目录自动 init) + n_dirty = len(dirty.splitlines()) if dirty else 0 + r = await _git_commit_push(rp, 'deliver: 交付收口(引擎代为提交)') + rc = r.get('rc', -1) + if rc != 0: + ok = False + lines.append(f"{label}: 引擎收口失败——{str(r.get('message', ''))[:160]}") + continue + st2 = await _run_shell('git status --porcelain', rp, 20) + still = (st2.get('stdout') or '').strip() + if still: + ok = False + lines.append(f"{label}: 收口后仍有 {len(still.splitlines())} 项未提交变更(可能被并发写入)") + continue + if n_dirty: + lines.append(f"{label}: agent 未提交,引擎代为收口 {n_dirty} 项变更({str(r.get('message',''))[:80]})") + elif not was_git: + lines.append(f"{label}: 非 git 目录,引擎已 git init 并首次提交") + return ok, "\n".join(lines) + + def _validate_deliverable_type(role, deliverable_type, allowed_types): """deliver 类型守卫:角色声明了合法类型清单时,非法类型拒绝并返回清单(可行动报错)。 @@ -2921,6 +3001,21 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): else: logger.error(f"code file failed: {abs_path} err={msg}") + # git 收口硬门禁(2026-09-16 pbls M1a「编造 git 证据」根治):在 deliverable files + # 全部落盘之后执行(此前收口会漏提交 files 内容)。引擎代为 add+commit 本任务 + # 写入过的 apps/modules 仓库——「已提交」从 agent 口头声明变成引擎保证的事实; + # 核验记录(含「agent 未提交,引擎代为收口」)回填交付件正文,QC/PM 拿真实仪表盘。 + _gc_ok, _gc_report = await _enforce_git_closure( + space_dir, list(written_files) + files_written, do_commit=True) + if _gc_report: + result_text += ("\n\n---\n## git 收口核验(引擎自动执行,非 agent 声明)\n" + + _gc_report) + if not _gc_ok: + result_text += ("\n⚠️ 收口未完全成功,QC 请按上述事实核验,勿轻信交付摘要中的" + " git 声明。") + logger.info(f"git closure enforced: task={task_id} ok={_gc_ok} " + f"report={_gc_report[:200]}") + # 写交付件文档(result 摘要快照,评审留痕用;交付件本体是 docs/ 编号树 + files 清单) file_path = '' if result_text: