pipeline-service/pipeline_service/iteration_capability.py
ymq d417772f42 feat: 项目owner+人类任务清单+bug验收+流转门禁
- check_project_owner: owner=created_by 统一校验
- human_task_capability: 派发/处理/QC/列表/计数/bug验收(复用pipeline_human_tasks)
- bug_flow: verified 分流(human报告→创建验收任务给报告人, agent→自动close)
- qc_review_run: human_task_qc 分支(检查对象=pipeline_human_tasks 结果)
- 流转门禁: agent/pm poller + start_next_iteration 检查未完成人类任务
- pipeline_human_tasks 加 project_id/iteration_id/bug_id/qc_status/qc_comment/title/description
2026-08-24 12:18:27 +08:00

303 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""迭代能力 — sd_iterations 的状态机语义化迁移(通用、产线无关)。
定位:迭代 CRUD 走 xls2ui 生成的端点;本模块只做「生命周期状态流转」——
每个迁移 CAS 原子(防并发/越权)+ 租户隔离 + 审计。
迭代的生命周期/流转规则在 iteration skill 里(LLM 读 skill 判断合法性);本模块只固化操作原语。
角色规范:role 参数用 agent.{role}(无前缀自动补 agent.);人角色用 {orgtype}.{role}。
scope 约定:sd_iterations 用 project_id 做范围校验(CAS 的 WHERE 里带 project_id),
与 pipeline_tasks 用 tenant_id 不同——sd_* 业务表用自己的归属列。
"""
import logging
from sqlor.dbpools import DBPools
from appPublic.uniqueID import getID
from .audit import record_audit
DBNAME = "pipeline"
logger = logging.getLogger("pipeline.iteration_capability")
TABLE = "sd_iterations"
# 迭代状态(SDLC 默认,状态机语义见 iteration skill)
S_PLANNING = "planning" # 规划中
S_IN_PROGRESS = "in_progress" # 进行中
S_COMPLETED = "completed" # 已完成
S_CANCELLED = "cancelled" # 已取消
def _get_db():
db = DBPools()
if not db.databases:
from appPublic.jsonConfig import getConfig
config = getConfig()
if config.databases:
db.databases = config.databases
return db, DBNAME
def _normalize_role(role):
"""角色规范:agent 角色补 agent. 前缀;人角色 {orgtype}.{role} 保留原样。"""
role = (role or "").strip()
if not role:
return ""
if "." in role:
return role
return f"agent.{role}"
async def _transition(iteration_id, project_id, from_state, to_state, action,
who=None, agent_id=None, detail=None):
"""CAS 状态迁移 + 审计。返回 (ok, message)。"""
if not iteration_id or not project_id:
return False, "缺少 iteration_id 或 project_id"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
await sor.sqlExe(
f"UPDATE {TABLE} SET status=${{to}}$, updated_at=NOW() "
"WHERE id=${iid}$ AND project_id=${pid}$ AND status=${from}$",
{"to": to_state, "iid": iteration_id, "pid": project_id, "from": from_state})
recs = await sor.R(TABLE, {'id': iteration_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "迭代不存在"
cur = getattr(recs[0], 'status', '')
if cur != to_state:
return False, f"状态迁移失败(CAS): 期望 from={from_state} 实际 status={cur}"
await record_audit(project_id, TABLE, iteration_id, action,
from_state=from_state, to_state=to_state,
who=who, agent_id=agent_id, detail=detail, sor=sor)
return True, to_state
async def create_iteration(project_id, iteration_name, iteration_type="new_feature",
scope="", priority=5, created_by="", who=None, agent_id=None):
"""创建迭代:新建记录,status=planning。返回 (ok, iteration_id_or_message)。"""
if not project_id:
return False, "缺少 project_id"
if not iteration_name or not iteration_name.strip():
return False, "缺少 iteration_name"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
# 迭代序号 seq_no = 该项目 max(seq_no) + 1(1,2,3... 顺序编号)
r = await sor.sqlExe(
f"SELECT COALESCE(MAX(seq_no),0) AS m FROM {TABLE} WHERE project_id=${{pid}}$",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
seq_no = int(getattr(r[0], 'm', 0) or 0) + 1
iid = getID()
try:
priority = int(priority)
except (TypeError, ValueError):
priority = 5
await sor.C(TABLE, {
'id': iid,
'project_id': project_id,
'iteration_name': iteration_name.strip(),
'iteration_type': iteration_type or 'new_feature',
'scope': scope or '',
'status': S_PLANNING,
'priority': priority,
'seq_no': seq_no,
'created_by': created_by or '',
})
await record_audit(project_id, TABLE, iid, 'create',
to_state=S_PLANNING, who=_normalize_role(who),
agent_id=agent_id, sor=sor)
logger.info("create_iteration: %s project=%s seq_no=%s", iid, project_id, seq_no)
return True, iid
async def start_iteration(iteration_id, project_id, who=None, agent_id=None):
"""开始迭代:planning → in_progress。"""
return await _transition(iteration_id, project_id, S_PLANNING, S_IN_PROGRESS, 'start',
who=who, agent_id=agent_id)
async def complete_iteration(iteration_id, project_id, who=None, agent_id=None):
"""完成迭代:in_progress → completed。"""
return await _transition(iteration_id, project_id, S_IN_PROGRESS, S_COMPLETED, 'complete',
who=who, agent_id=agent_id)
async def cancel_iteration(iteration_id, project_id, who=None, agent_id=None, comment=None):
"""取消迭代:planning/in_progress → cancelled。"""
ok, msg = await _transition(iteration_id, project_id, S_PLANNING, S_CANCELLED, 'cancel',
who=who, agent_id=agent_id, detail=comment)
if ok:
return ok, msg
return await _transition(iteration_id, project_id, S_IN_PROGRESS, S_CANCELLED, 'cancel',
who=who, agent_id=agent_id, detail=comment)
async def set_iteration_state(iteration_id, project_id, from_state, to_state,
who=None, agent_id=None, detail=None):
"""通用 CAS 状态迁移兜底(跨产线自定义状态机用)。"""
return await _transition(iteration_id, project_id, from_state, to_state, 'set_state',
who=who, agent_id=agent_id, detail=detail)
async def list_iterations(project_id, status=None, limit=50) -> list:
"""列出迭代(可按状态过滤)。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
conditions = ["project_id=${pid}$"]
params = {"pid": project_id}
if status:
conditions.append("status=${status}$")
params["status"] = status
where = " AND ".join(conditions)
try:
limit = int(limit)
except (TypeError, ValueError):
limit = 50
sql = (f"SELECT * FROM {TABLE} WHERE {where} "
f"ORDER BY seq_no ASC LIMIT {limit}")
recs = await sor.sqlExe(sql, params)
# 释放 SELECT 元数据锁
await sor.sqlExe("COMMIT", {})
result = []
for rec in (recs or []):
result.append(_rec_to_dict(rec))
return result
async def get_current_iteration(sor, project_id):
"""当前迭代 = status='in_progress' 的唯一迭代(seq_no 最小)。无则返回 None。
取代旧的「ORDER BY created_at DESC LIMIT 1」推断:当前迭代是显式状态,
不再靠时间戳猜。传入 sor(调用点已在 sqlorContext 内,避免嵌套开连接)。
"""
recs = await sor.sqlExe(
f"SELECT * FROM {TABLE} WHERE project_id=${{pid}}$ AND status='in_progress' "
"ORDER BY seq_no ASC LIMIT 1",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
if recs:
return _rec_to_dict(recs[0])
return None
async def _active_task_ids(sor, project_id, iteration_name):
"""某迭代内所有活跃任务的 ID 列表(非终态:submitted/running/review/qc_review/waiting)。
任务经 params.iteration_id(存迭代名)归属迭代;已终态(completed/approved/cancelled/failed)不计。
"""
if not iteration_name:
return []
recs = await sor.sqlExe(
"SELECT id FROM pipeline_tasks WHERE tenant_id=${pid}$ "
"AND state NOT IN ('completed','approved','cancelled','failed') "
"AND JSON_UNQUOTE(JSON_EXTRACT(params,'$.iteration_id'))=${nm}$",
{"pid": project_id, "nm": iteration_name})
await sor.sqlExe("COMMIT", {})
return [getattr(r, 'id', '') for r in (recs or []) if getattr(r, 'id', '')]
async def _cancel_active_tasks(sor, project_id, iteration_name):
"""作废某迭代内所有活跃任务(非终态 → cancelled + 清 claimed_by)+ 关闭其 pending 问题。返回作废数量。"""
ids = await _active_task_ids(sor, project_id, iteration_name)
if ids:
in_clause = ",".join(["'" + x.replace("'", "''") + "'" for x in ids])
await sor.sqlExe(
f"UPDATE pipeline_tasks SET state='cancelled', claimed_by=NULL, updated_at=NOW() "
f"WHERE id IN ({in_clause})", {})
# 关闭该迭代的 pending 问题(随迭代作废,不再冒泡):问题通过 task_id 关联任务,任务通过
# params.iteration_id 关联迭代。不关闭则旧迭代问题持续 pending 冒泡、阻塞诊断视图(2026-08 实测)。
await sor.sqlExe(
"UPDATE pipeline_agent_questions SET status='answered', "
"answer='旧迭代已作废,本问题随迭代关闭', answered_by='system', answer_source='main_agent', updated_at=NOW() "
"WHERE tenant_id=${pid}$ AND status='pending' AND task_id IN ("
"SELECT id FROM pipeline_tasks WHERE tenant_id=${pid}$ "
"AND JSON_UNQUOTE(JSON_EXTRACT(params,'$.iteration_id'))=${iter}$)",
{"pid": project_id, "iter": iteration_name})
return len(ids)
async def start_next_iteration(project_id, who=None, agent_id=None, confirm=False):
"""启动下一个迭代(用户指令推进):
1. 若存在当前迭代(in_progress)且有活跃任务,须 confirm=True 才强制完成它 + 作废活跃任务
(否则返回 CONFIRM 提示,等用户确认)。
2. 找下一个 planning 迭代(seq_no 最小且 > 已结束迭代的最大 seq_no),start 它。
返回 (ok, message)。
"""
if not project_id:
return False, "缺少 project_id"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
# 人类任务门禁:当前迭代有未完成人类任务时,阻止切迭代(须先完成 + QC 合格)
from .human_task_capability import has_blocking_human_task
if await has_blocking_human_task(sor, project_id):
return False, "当前迭代存在未完成的人类任务,须完成并通过 QC 后才能切换迭代"
cur = await get_current_iteration(sor, project_id)
# 二次确认:当前迭代有活跃任务时,须 confirm=True 才作废(否则返回确认提示)
if cur and not confirm:
cur_name = cur.get('iteration_name', '') or ''
active_ids = await _active_task_ids(sor, project_id, cur_name)
if active_ids:
return False, (f"CONFIRM: 当前迭代「{cur_name}」还有 {len(active_ids)} 个未完成任务,"
f"确认作废这些任务并启动下一迭代?请回复「确认」,agent 将传 confirm=true 执行")
# 1. 强制完成当前迭代 + 作废其活跃任务
if cur:
cur_id = cur.get('id', '')
cur_name = cur.get('iteration_name', '') or ''
if cur_name:
await _cancel_active_tasks(sor, project_id, cur_name)
await sor.sqlExe(
f"UPDATE {TABLE} SET status='completed', completed_at=NOW(), updated_at=NOW() "
f"WHERE id=${{iid}}$ AND project_id=${{pid}}$ AND status='in_progress'",
{"iid": cur_id, "pid": project_id})
await record_audit(project_id, TABLE, cur_id, 'complete',
from_state=S_IN_PROGRESS, to_state=S_COMPLETED,
who=_normalize_role(who), agent_id=agent_id,
detail='启动下一迭代时强制完成', sor=sor)
logger.info("start_next_iteration: 强制完成当前迭代 %s (%s)", cur_id, cur_name)
# 2. 找下一个 planning 迭代(seq_no > 已结束迭代的最大 seq_no)
nxt = await sor.sqlExe(
f"SELECT * FROM {TABLE} WHERE project_id=${{pid}}$ AND status='planning' "
f"AND seq_no > (SELECT COALESCE(MAX(seq_no),0) FROM {TABLE} "
f"WHERE project_id=${{pid}}$ AND status IN ('completed','cancelled')) "
"ORDER BY seq_no ASC LIMIT 1",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
if not nxt:
return False, "没有可启动的下一个迭代(请先 create_iteration)"
nxt_id = getattr(nxt[0], 'id', '')
nxt_name = getattr(nxt[0], 'iteration_name', '')
nxt_seq = getattr(nxt[0], 'seq_no', '')
# 3. start 它(planning → in_progress)
await sor.sqlExe(
f"UPDATE {TABLE} SET status='in_progress', started_at=NOW(), updated_at=NOW() "
f"WHERE id=${{iid}}$ AND project_id=${{pid}}$ AND status='planning'",
{"iid": nxt_id, "pid": project_id})
await record_audit(project_id, TABLE, nxt_id, 'start',
from_state=S_PLANNING, to_state=S_IN_PROGRESS,
who=_normalize_role(who), agent_id=agent_id, sor=sor)
logger.info("start_next_iteration: 启动 %s (seq_no=%s)", nxt_name, nxt_seq)
# 切迭代后自动恢复项目推进:旧迭代的 paused(任务达上限触发 failed_poller 自动 pause)诱因
# 已随活跃任务作废而消除,新迭代是新开始,项目应恢复 active——否则 PM poller 会因 paused
# 跳过新迭代的 review 任务,导致新迭代卡在 PM 审核无人认领(2026-08 实测:切迭代后 design
# 任务卡 review 27 分钟,根因就是旧迭代遗留的 paused 未清除)。
from .project_capability import resume_project
_rok, _rmsg = await resume_project(project_id, who=_normalize_role(who), agent_id=agent_id)
logger.info("start_next_iteration: resume_project ok=%s (%s)", _rok, _rmsg)
return True, f"已启动迭代「{nxt_name}」(seq_no={nxt_seq})"
def _rec_to_dict(rec):
"""把 sqlor 记录对象转成 dict(sqlor 行是 DictObject,必须 dict(rec) 取列)。"""
if isinstance(rec, dict):
return dict(rec)
try:
return dict(rec)
except (TypeError, ValueError):
if hasattr(rec, 'to_dict'):
try:
return rec.to_dict()
except Exception:
pass
return {}