fix(agent): G2缺口指纹超长静默丢失+fallback交付件跨仓库污染+setup_repos空目录崩溃

- _check_orchestration_gaps: G1环/G2指纹改短哈希(_short_fingerprint)——原实现拼接全部未依赖任务ID(15任务≈400字符)超pipeline_pm_notices.fingerprint VARCHAR(255)→INSERT DataError(1406)被catch记debug→PM告警静默丢失,脚手架先批deploy_test提前启动(pbls 09-15实测)
- _build_fallback_deliverable: git扫描只报本任务write_file写入过的仓库——原实现扫全工作空间apps/modules所有git仓库,16天前元景遗留的apps/scense脏工作区被算进M1a交付件→QC误判越权改动构成退回理由(pbls实测)
- _setup_repos: project_dir空串守卫——os.makedirs('')抛Errno 2且中断clone,日志高频刷屏
This commit is contained in:
ymq 2026-09-15 16:43:23 +08:00
parent 5fe5c238ec
commit e774cb238b

View File

@ -1418,7 +1418,12 @@ async def _commit_repos_after_approve(space_dir, project_name='', title=''):
async def _setup_repos(sor, space_dir, project_id): async def _setup_repos(sor, space_dir, project_id):
"""clone 项目关联仓库到机构工作空间:应用→apps/、模块→modules/;项目目录→projects/{项目名}/。""" """clone 项目关联仓库到机构工作空间:应用→apps/、模块→modules/;项目目录→projects/{项目名}/。"""
project_dir = await _get_project_dir(sor, project_id) project_dir = await _get_project_dir(sor, project_id)
await _ensure_project_repo(project_dir) # 守卫:取不到项目目录(sd_projects 记录缺 directory_name/name,或 space_dir 解析失败)时
# 原实现把空串传进 _ensure_project_repo → os.makedirs('') 抛
# FileNotFoundError [Errno 2] No such file or directory: ''(日志高频刷屏,
# 且整个 setup_repos 中断,后续 clone 也不执行)。空目录直接跳过项目仓库步骤。
if project_dir:
await _ensure_project_repo(project_dir)
repos = await _get_project_repos(sor, project_id) repos = await _get_project_repos(sor, project_id)
results = [] results = []
for repo in repos: for repo in repos:
@ -1587,7 +1592,7 @@ async def _check_orchestration_gaps(sor, project_id, task, next_role='', next_ta
continue continue
if color[m] == GRAY: if color[m] == GRAY:
cyc = path[path.index(m):] if m in path else [m] cyc = path[path.index(m):] if m in path else [m]
fp = "G1:cycle:" + ",".join(sorted(cyc)) fp = _short_fingerprint("G1:cycle", *sorted(cyc))
if fp in _cyc_seen: if fp in _cyc_seen:
continue continue
_cyc_seen.add(fp) _cyc_seen.add(fp)
@ -1617,7 +1622,7 @@ async def _check_orchestration_gaps(sor, project_id, task, next_role='', next_ta
if miss_tasks: if miss_tasks:
miss_ids = [m['id'] for m in miss_tasks] miss_ids = [m['id'] for m in miss_tasks]
gaps.append({ gaps.append({
'kind': 'G2', 'fingerprint': "G2:" + a['id'] + ":" + ",".join(sorted(miss_ids)), 'kind': 'G2', 'fingerprint': _short_fingerprint("G2", a['id'], *sorted(miss_ids)),
'task_id': a['id'], 'fix_ids': miss_ids, 'task_id': a['id'], 'fix_ids': miss_ids,
'text': (f"G2 应用级 develop「{a['title']}」({a['id']}) 未依赖以下未完成的模块任务:" 'text': (f"G2 应用级 develop「{a['title']}」({a['id']}) 未依赖以下未完成的模块任务:"
+ "、".join(f"{m['title']}({m['id']})" for m in miss_tasks) + "、".join(f"{m['title']}({m['id']})" for m in miss_tasks)
@ -1654,6 +1659,21 @@ async def _check_orchestration_gaps(sor, project_id, task, next_role='', next_ta
return gaps return gaps
def _short_fingerprint(prefix, *parts):
"""缺口指纹定长化:prefix + 内容 md5 前 16 位。
2026-09-15 pbls 实测:G2 指纹把全部未依赖任务 ID 拼进字符串(15 个任务 ≈ 400 字符),
超出 pipeline_pm_notices.fingerprint VARCHAR(255) → INSERT DataError(1406),
而 _save_gap_notices 只 catch 记 debug → 告警静默丢失,PM 从未看到
「应用级 develop 未依赖模块任务」→ 脚手架先批、deploy_test 在编码未完成时启动
(正是 G2 要防的事故)。指纹只服务 (tenant_id, fingerprint) 去重,不需要可读,
哈希后长度恒定,与依赖数量无关。
"""
import hashlib
body = ":".join(str(p) for p in parts if p)
return f"{prefix}:{hashlib.md5(body.encode('utf-8')).hexdigest()[:16]}"
def _gap_texts(gaps): def _gap_texts(gaps):
"""结构化缺口 → 纯文本列表(日志用)。""" """结构化缺口 → 纯文本列表(日志用)。"""
return [g['text'] for g in (gaps or []) if isinstance(g, dict) and g.get('text')] return [g['text'] for g in (gaps or []) if isinstance(g, dict) and g.get('text')]
@ -2263,32 +2283,41 @@ def _validate_deliverable_type(role, deliverable_type, allowed_types):
f"(如 {allowed_types[0]})。") f"(如 {allowed_types[0]})。")
async def _build_fallback_deliverable(space_dir, written_files, role): async def _build_fallback_deliverable(space_dir, written_files, role, task_id=''):
"""循环结束未 deliver 时,检测 write_file/git 实际产出,构造真实交付件给 QC(而非占位符「Agent未产出交付件」)。""" """循环结束未 deliver 时,检测 write_file/git 实际产出,构造真实交付件给 QC(而非占位符「Agent未产出交付件」)。
2026-09-15 pbls 修复:git 扫描只报告「本任务 write_file 实际写入过的仓库」。
原实现把机构工作空间 apps/ + modules/ 下所有 git 仓库的脏状态都塞进交付件,
历史遗留的脏工作区(实测 apps/scense 的 12 项变更全是 16 天前别的任务留下的)
被当成本任务产出 → QC 误判「越权改动其它应用仓库」构成独立退回理由。
本任务没写过文件的仓库与本任务无关,不进交付件。
"""
lines = [] lines = []
if written_files: if written_files:
lines.append("本任务执行期间 write_file 实际写入的文件:") lines.append("本任务执行期间 write_file 实际写入的文件:")
for f in sorted(set(written_files)): for f in sorted(written_files):
rel = os.path.relpath(f, space_dir) if f.startswith(space_dir) else f rel = os.path.relpath(f, space_dir) if f.startswith(space_dir) else f
lines.append("- " + rel) lines.append("- " + rel)
# 扫描 apps/ + modules/ + projects/ 下的 git 仓库工作区变更 # 只扫描本任务实际写入过的仓库(write_file 落点所属),不扫全工作空间
for sub in ('apps', 'modules', 'projects'): touched_repos = set()
base = os.path.join(space_dir, sub) for f in written_files or []:
if not os.path.isdir(base): rp = f if os.path.isabs(f) else os.path.join(space_dir, f)
rel = os.path.relpath(rp, space_dir)
parts = rel.split(os.sep)
if len(parts) >= 2 and parts[0] in ('apps', 'modules', 'projects'):
touched_repos.add(os.path.join(space_dir, parts[0], parts[1]))
for rp in sorted(touched_repos):
if not os.path.isdir(os.path.join(rp, '.git')):
continue continue
entries = ([(p, p) for p in sorted(os.listdir(base))] if sub == 'projects' try:
else [(f"{sub}/{n}", n) for n in sorted(os.listdir(base))]) r = await _run_shell('git status --short', rp, 10)
for label, name in entries: out = (r.get('stdout') or '').strip()
rp = os.path.join(base, name) if out:
if os.path.isdir(os.path.join(rp, '.git')): label = os.path.relpath(rp, space_dir)
try: lines.append(f"git 仓库 [{label}] 工作区变更(仅本任务写入过的仓库):")
r = await _run_shell('git status --short', rp, 10) lines.append(out[:2000])
out = (r.get('stdout') or '').strip() except Exception:
if out: pass
lines.append(f"git 仓库 [{label}] 工作区变更:")
lines.append(out[:2000])
except Exception:
pass
if not lines: if not lines:
lines.append("(本轮未检测到 write_file 或 git 变更——agent 确实未产出)") lines.append("(本轮未检测到 write_file 或 git 变更——agent 确实未产出)")
return { return {
@ -2718,7 +2747,7 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
if not deliverable: if not deliverable:
# 兜底:循环结束未 deliver,检测 write_file/git 实际产出构造真实交付件(而非占位符) # 兜底:循环结束未 deliver,检测 write_file/git 实际产出构造真实交付件(而非占位符)
deliverable = await _build_fallback_deliverable(space_dir, written_files, role) deliverable = await _build_fallback_deliverable(space_dir, written_files, role, task_id=task_id)
# ── 处理产出 ── # ── 处理产出 ──
from appPublic.uniqueID import getID from appPublic.uniqueID import getID