From ccba6f470ee3293492d3f90eeb4c8b81a24797c4 Mon Sep 17 00:00:00 2001 From: ymq Date: Wed, 26 Aug 2026 12:09:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(orchestration):=20=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=8F=AA=E5=8D=A1=E9=83=A8=E7=BD=B2=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=8D=A1=E9=9C=80=E6=B1=82/=E8=AE=BE=E8=AE=A1/?= =?UTF-8?q?=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-08-26 定调纠正了我原方案的错误。我原本提议把「env/*.json 无占位符」做成 requirement 阶段的结束条件(把缺陷堵在最便宜的阶段)——这是错的,会把死循环制度化: 主机/SSH账号/密码/部署路径是**外部输入**,requirement agent 根本产不出来。若要求它 填全才能通过,agent 就卡在无法解决的事情上无限重试。正是 2026-08-25 hrs7 的死循环成因: deploy_test 因 env 占位符失败 → PM 判「根因在 requirement」→ 回退整链并作废 owner 已 人工确认的三模块设计 → 重做需求后 env 依然是占位符 → 再次失败。 实际上 hrs7 的 requirement agent 做得对:填了能确定的(port 9187/dbname hrs7),未知项 标「待明确」并列入 pending 清单 —— 这是正确履职,被回退是冤枉的。 实现: 1. _check_deploy_env_ready 只对 agent.deploy_test/deploy_prod 生效,其余角色一律放行。 检查 ssh.host/user、deploy.path、db.host/user/dbname + ssh 凭据(password 或 key_file 二者其一) + requirement 自列的 pending 清单;domain/status 等非必需项不阻断。 2. 认领路径加此进入条件:不齐备 → 不进 running,任务置 waiting(界面可见,不在 submitted 静默打转) + 冒泡 deploy_env_required 人工任务给 owner,写清缺哪些字段、文件在哪, 并说明「补齐后自动继续,无需重跑上游阶段」。按 task_id 去重。 3. PM prompt 加硬约束:外部输入缺失禁止回退上游。回退前先自问「这个缺陷是上游 agent 能做对却做错了,还是本来就产不出?」——前者才回退,后者一律冒泡等人补。 验证:11 例离线用例全过(用 hrs7 线上真实 test.json 结构),覆盖 requirement/design/develop/test 四阶段占位符必须放行、部署阶段必须阻塞并列缺失字段、 补齐后放行、密钥文件替代密码、空密码无密钥阻塞、domain 缺失不阻断。 --- pipeline_service/agent_loop.py | 142 +++++++++++++++++++++++++++++++-- 1 file changed, 137 insertions(+), 5 deletions(-) diff --git a/pipeline_service/agent_loop.py b/pipeline_service/agent_loop.py index dfe7a5d..17b5593 100644 --- a/pipeline_service/agent_loop.py +++ b/pipeline_service/agent_loop.py @@ -555,7 +555,9 @@ __ROLE_SKILLS__ ## 回退规则(test 阶段) - 测试提交 Bug 后,零散 Bug → 走 Bug 闭环(report_bug→fix_bug→verify_bug),不打断任务链。 -- 系统性缺陷(Bug 多/严重、设计有缺陷、需求理解错、部署有问题)→ review_rollback,rollback_role 指定回退目标阶段(develop/design/requirement/deploy_test),回退点之后的关联任务将全部作废。""" +- 系统性缺陷(Bug 多/严重、设计有缺陷、需求理解错、部署有问题)→ review_rollback,rollback_role 指定回退目标阶段(develop/design/requirement/deploy_test),回退点之后的关联任务将全部作废。 +- ⚠️ **外部输入缺失禁止回退上游**:部署主机/SSH账号/密码/部署路径/域名这类信息是**外部输入**,requirement/design/develop 三个阶段的 agent 根本产不出来(它们只能诚实标注「待明确」并列入 env/*.json 的 pending 清单,这是正确履职、不是缺陷)。这类阻塞由系统在部署任务的进入条件处自动冒泡人工任务给 owner 补齐,**你不要因此 review_rollback 到 requirement/design** —— 那会作废下游全部成果(含 owner 已人工确认的设计),而重做一遍后环境信息依然不会有,形成死循环(2026-08-25 hrs7 实际发生过)。正确处置:如实记录阻塞原因、raise_problem 冒泡,任务等 owner 补齐后自动继续。 +- 回退前先自问:**这个缺陷是上游 agent「能做对却做错了」,还是「本来就产不出」?** 前者才回退;后者一律冒泡等人补。""" QC_SYSTEM_PROMPT = """你是质量控制工程师(QC)。对交付件做合规检查和质量检查,不合规直接退回重做。 @@ -658,6 +660,118 @@ async def _task_deps_satisfied(sor, depends_on_raw, task_id=''): return True, '' +PLACEHOLDER_MARKS = ('待明确', '待确认', '待补充', 'TODO', 'todo', 'xxx', 'XXX', '占位', '<', '未知') + + +async def _check_deploy_env_ready(sor, project_id, role): + """部署类任务的进入条件:env/<环境>.json 的真实环境信息是否齐备。 + + ⚠️ 设计定调(用户 2026-08-26 明确纠正,很重要): + **环境信息只影响部署任务,不该阻断需求和开发。** + + 理由:主机/SSH账号/密码/部署路径是**外部输入**,requirement agent 根本产不出来。 + 若把「env 必须填全」做成 requirement 的结束条件,agent 就会卡在它无法解决的事情上 + 无限重试 —— 正是 2026-08-25 hrs7 的死循环成因(deploy_test 因 env 占位符失败 → PM + 判「根因在 requirement」→ 回退整链并作废 owner 已确认的设计 → 重做需求后 env 依然 + 是占位符 → 再次失败)。把门禁前移会把这个死循环制度化。 + + 正确处置: + · requirement/design/develop 一律不检查 env 完整性,占位符可正常通过; + requirement 只需诚实标注(hrs7 的 requirement agent 做对了:填了能确定的 + port/dbname,未知项标「待明确」并列入 pending 清单)。 + · 只有 deploy_test / deploy_prod 进入前检查;不齐备 → 不进 running, + 冒泡人工任务给能提供信息的人(owner/运维),任务置 waiting。 + · PM **不得**因外部输入缺失而回退上游阶段 —— 那不是任何 agent 阶段的产出缺陷。 + + 返回 (ready: bool, missing: [字段名], env_file: str) + """ + if role not in ('agent.deploy_test', 'agent.deploy_prod'): + return True, [], '' + env_name = 'test' if role == 'agent.deploy_test' else 'prod' + pdir = await _get_project_dir(sor, project_id) + if not pdir: + return True, [], '' # 取不到项目目录不阻断(宁放过不误杀) + import os as _os + env_file = _os.path.join(pdir, 'env', f'{env_name}.json') + if not _os.path.isfile(env_file): + return False, [f'env/{env_name}.json 文件不存在'], env_file + try: + with open(env_file, encoding='utf-8') as f: + cfg = json.load(f) + except Exception as e: + return False, [f'env/{env_name}.json 解析失败:{e}'], env_file + + missing = [] + + def _bad(v): + if v is None: + return False # null 合法(如 key_file=null 表示用密码登录) + if isinstance(v, str): + s = v.strip() + if not s: + return True + return any(m in s for m in PLACEHOLDER_MARKS) + return False + + # 部署真正必需的字段(其余如 domain/status 缺失不阻断部署) + REQUIRED = (('ssh', 'host'), ('ssh', 'user'), ('deploy', 'path'), + ('db', 'host'), ('db', 'user'), ('db', 'dbname')) + for path in REQUIRED: + cur = cfg + for k in path: + cur = cur.get(k) if isinstance(cur, dict) else None + if cur is None or _bad(cur): + missing.append('.'.join(path)) + # SSH 凭据:password 与 key_file 二者其一即可(key_file=null 表示用密码登录) + _ssh = cfg.get('ssh') or {} + _pwd_ok = not _bad(_ssh.get('password')) and bool(str(_ssh.get('password') or '').strip()) + _key = _ssh.get('key_file') + _key_ok = bool(str(_key).strip()) and not _bad(_key) if isinstance(_key, str) else False + if not (_pwd_ok or _key_ok): + missing.append('ssh.password 或 ssh.key_file(二者其一)') + # requirement 自己列的 pending 清单也纳入(它最清楚哪些没定) + for p in (cfg.get('pending') or []): + if isinstance(p, str) and p.strip() and p.strip() not in missing: + _k = p.strip() + if any(_k.startswith(x) for x in ('ssh.', 'deploy.', 'db.')): + if _k not in missing: + missing.append(_k) + return (not missing), missing, env_file + + +async def _bubble_deploy_env_missing(sor, project_id, task_id, task_title, missing, env_file): + """部署环境信息缺失 → 冒泡给 owner 补齐(去重)。 + + 这是「逃逸阀」而非「门禁」:任务不会静默死等,owner 待办里会出现一条明确的 + 「补齐部署环境信息」任务,写清缺哪些字段、文件在哪。 + """ + from .human_task_capability import create_human_task + exists = await sor.sqlExe( + "SELECT id FROM pipeline_human_tasks WHERE project_id=${pid}$ AND task_id=${tid}$ " + "AND task_type='deploy_env_required' AND status='pending' LIMIT 1", + {"pid": project_id, "tid": task_id}) + await sor.sqlExe("COMMIT", {}) + if exists: + return + _own = await sor.sqlExe("SELECT created_by FROM sd_projects WHERE id=${pid}$", {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + owner_id = (getattr(_own[0], 'created_by', '') if _own else '') or 'user-01' + await create_human_task( + project_id, + f"补齐部署环境信息:{task_title or task_id[:8]}", + "部署任务无法开始:真实部署环境信息尚未提供(这属于外部输入,agent 无法自行产出," + "因此不回退需求/设计/开发阶段,只等这里补齐)。\n\n" + f"配置文件:{env_file}\n\n" + "缺失字段:\n" + "\n".join(f" · {m}" for m in missing) + + "\n\n补齐后本任务会自动继续(无需重跑上游阶段)。", + task_type='deploy_env_required', + assignee_id=owner_id, + created_by='system.orchestrator', + task_id=task_id, + ) + logger.warning(f"部署环境信息缺失冒泡: task={task_id} missing={missing}") + + async def _bubble_blocked_dependency(sor, project_id, task_id, task_title, reason): """逃逸阀:任务依赖「永不可达」时冒泡人工任务,避免静默死锁。 @@ -713,10 +827,28 @@ async def _claim_task(sor, tenant_id, role, state='submitted', match_role=True, _tid = getattr(rec, 'id', '') ok_dep, why = await _task_deps_satisfied( sor, getattr(rec, 'depends_on', '') or '', task_id=_tid) - if ok_dep: - task = rec - break - blocked.append((_tid, getattr(rec, 'title', '') or '', why)) + if not ok_dep: + blocked.append((_tid, getattr(rec, 'title', '') or '', why)) + continue + # 进入条件(部署类):真实部署环境信息必须齐备。 + # 环境信息属外部输入,只卡部署、不卡需求/设计/开发(用户 2026-08-26 定调)。 + _rrole = getattr(rec, 'role', '') or role + env_ok, env_missing, env_file = await _check_deploy_env_ready(sor, tenant_id, _rrole) + if not env_ok: + _rtitle = getattr(rec, 'title', '') or '' + try: + await _bubble_deploy_env_missing( + sor, tenant_id, _tid, _rtitle, env_missing, env_file) + # 置 waiting:让「卡住」在界面上可见,不在 submitted 里静默打转 + await sor.sqlExe( + "UPDATE pipeline_tasks SET state='waiting', updated_at=NOW() " + "WHERE id=${i}$ AND state='submitted'", {"i": _tid}) + await sor.sqlExe("COMMIT", {}) + except Exception as e: + logger.warning(f"_bubble_deploy_env_missing failed task={_tid}: {e}") + continue + task = rec + break # 逃逸阀:依赖「永不可达」(不存在/已作废/自依赖)的任务会永久 submitted 且无人知晓, # 与「自动 pause 无出口」是同一类活性缺陷。这里冒泡人工任务,让 owner 能看到并修正。 for _tid, _ti, _why in blocked: