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 阶段)
- 测试提交 Bug 零散 Bug Bug 闭环report_bugfix_bugverify_bug不打断任务链
- 系统性缺陷Bug /严重设计有缺陷需求理解错部署有问题 review_rollbackrollback_role 指定回退目标阶段develop/design/requirement/deploy_test回退点之后的关联任务将全部作废"""
- 系统性缺陷Bug /严重设计有缺陷需求理解错部署有问题 review_rollbackrollback_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: