_cancel_active_tasks 作废任务时,一并把该迭代的 pending 问题置 answered(随迭代作废)。 否则旧迭代问题持续 pending 冒泡、阻塞诊断视图,需手动 SQL 清理。
299 lines
14 KiB
Python
299 lines
14 KiB
Python
"""迭代能力 — 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:
|
||
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 {}
|