fix(orchestration): 环境信息只卡部署,不卡需求/设计/开发

用户 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 缺失不阻断。
This commit is contained in:
ymq 2026-08-26 12:09:23 +08:00
parent 31eab89234
commit ccba6f470e

View File

@ -555,7 +555,9 @@ __ROLE_SKILLS__
## 回退规则(test 阶段) ## 回退规则(test 阶段)
- 测试提交 Bug 后,零散 Bug → 走 Bug 闭环(report_bug→fix_bug→verify_bug),不打断任务链。 - 测试提交 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)。对交付件做合规检查和质量检查,不合规直接退回重做。 QC_SYSTEM_PROMPT = """你是质量控制工程师(QC)。对交付件做合规检查和质量检查,不合规直接退回重做。
@ -658,6 +660,118 @@ async def _task_deps_satisfied(sor, depends_on_raw, task_id=''):
return True, '' 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): 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', '') _tid = getattr(rec, 'id', '')
ok_dep, why = await _task_deps_satisfied( ok_dep, why = await _task_deps_satisfied(
sor, getattr(rec, 'depends_on', '') or '', task_id=_tid) sor, getattr(rec, 'depends_on', '') or '', task_id=_tid)
if ok_dep: if not ok_dep:
task = rec blocked.append((_tid, getattr(rec, 'title', '') or '', why))
break continue
blocked.append((_tid, getattr(rec, 'title', '') or '', why)) # 进入条件(部署类):真实部署环境信息必须齐备。
# 环境信息属外部输入,只卡部署、不卡需求/设计/开发(用户 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 且无人知晓, # 逃逸阀:依赖「永不可达」(不存在/已作废/自依赖)的任务会永久 submitted 且无人知晓,
# 与「自动 pause 无出口」是同一类活性缺陷。这里冒泡人工任务,让 owner 能看到并修正。 # 与「自动 pause 无出口」是同一类活性缺陷。这里冒泡人工任务,让 owner 能看到并修正。
for _tid, _ti, _why in blocked: for _tid, _ti, _why in blocked: