pipeline-service/pipeline_service/task_capability.py

349 lines
18 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.

"""任务能力 — pipeline_tasks 的状态机语义化迁移(通用、产线无关)。
定位:storage.py 是数据 CRUD 层(create_task/list_tasks/get_task/update_task_state 低层改状态),
本模块是状态机语义化操作层 —— 每个迁移 CAS 原子(防并发/防越权覆盖)+ 租户隔离 + 审计。
任务的状态机/流转规则在 task skill 里(LLM 读 skill 判断合法性);本模块只固化操作原语。
角色规范:role 参数用 agent.{role}(无前缀自动补 agent.);人角色用 {orgtype}.{role}。
"""
import json
import logging
from sqlor.dbpools import DBPools
from appPublic.uniqueID import getID
from .audit import record_audit
DBNAME = "pipeline"
logger = logging.getLogger("pipeline.task_capability")
# 任务状态(SDLC 默认,状态机语义见 task skill)
S_SUBMITTED = "submitted" # 待认领
S_RUNNING = "running" # 角色 agent 执行中
S_QC_REVIEW = "qc_review" # 已交付,待 QC 合规/质量检查
S_REVIEW = "review" # QC 通过,待 PM 审核验收
S_APPROVED = "approved" # 审核通过
S_COMPLETED = "completed" # 全流程完成
S_WAITING = "waiting" # 挂起等回答
S_FAILED = "failed" # 失败
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(task_id, tenant_id, from_state, to_state, action,
who=None, agent_id=None, detail=None):
"""CAS 状态迁移(清 claimed_by)+ 审计。返回 (ok, message)。"""
if not task_id or not tenant_id:
return False, "缺少 task_id 或 tenant_id"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
await sor.sqlExe(
"UPDATE pipeline_tasks SET state=${to}$, claimed_by=NULL, updated_at=NOW() "
"WHERE id=${tid}$ AND tenant_id=${tn}$ AND state=${from}$",
{"to": to_state, "tid": task_id, "tn": tenant_id, "from": from_state})
recs = await sor.R('pipeline_tasks', {'id': task_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "任务不存在"
cur = getattr(recs[0], 'state', '')
if cur != to_state:
return False, f"状态迁移失败(CAS): 期望 from={from_state} 实际 state={cur}"
await record_audit(tenant_id, 'pipeline_tasks', task_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 claim_task(tenant_id, role, agent_id, from_state=S_SUBMITTED,
to_state=S_RUNNING):
"""认领任务:CAS claimed_by IS NULL 保证并发认领原子;设置 claimed_by=agent_id。
返回 (ok, task_id_or_message)。
"""
role_norm = _normalize_role(role) # 审计记录用规范名;查询用原样(兼容存量裸名数据)
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
claim_token = agent_id or getID()
recs = await sor.sqlExe(
"SELECT id FROM pipeline_tasks "
"WHERE tenant_id=${tn}$ AND state=${st}$ AND role=${role}$ "
"AND (claimed_by IS NULL OR claimed_by='') LIMIT 1",
{"tn": tenant_id, "st": from_state, "role": role})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, f"没有可认领的 {role} 任务(state={from_state})"
task_id = getattr(recs[0], 'id', '')
# CAS:claimed_by IS NULL 保证原子(并发 poller 不会双重认领)
await sor.sqlExe(
"UPDATE pipeline_tasks SET state=${to}$, claimed_by=${cb}$, updated_at=NOW() "
"WHERE id=${tid}$ AND tenant_id=${tn}$ AND state=${from}$ AND claimed_by IS NULL",
{"to": to_state, "cb": claim_token, "tid": task_id, "tn": tenant_id, "from": from_state})
chk = await sor.R('pipeline_tasks', {'id': task_id})
await sor.sqlExe("COMMIT", {})
if not chk or getattr(chk[0], 'state', '') != to_state:
return False, "认领竞争失败(已被他人认领)"
await record_audit(tenant_id, 'pipeline_tasks', task_id, 'claim',
from_state=from_state, to_state=to_state,
who=role_norm, agent_id=agent_id, sor=sor)
return True, task_id
async def _task_skip_generic_qc(task_id):
"""任务是否声明豁免通用 QC 门禁(params.skip_generic_qc,产线自带质量门禁时用)。"""
if not task_id:
return False
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT params FROM pipeline_tasks WHERE id=${tid}$", {"tid": task_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False
try:
p = json.loads(getattr(recs[0], 'params', '') or '{}')
except Exception:
return False
return isinstance(p, dict) and bool(p.get('skip_generic_qc'))
async def submit_task(task_id, tenant_id, who=None, agent_id=None):
"""提交产出:running → qc_review(清 claimed_by,先过 QC 合规/质量门禁)。
声明式豁免(2026-09-03):params.skip_generic_qc=true 的任务直接 running → approved,
跳过通用 QC/PM 门禁——该任务的质量判定由产线自身流转引擎负责
(如投标产线:章节评审打分 + 解析产出契合度审核),通用门禁不接管。
"""
if await _task_skip_generic_qc(task_id):
return await _transition(task_id, tenant_id, S_RUNNING, S_APPROVED, 'submit',
who=who, agent_id=agent_id,
detail='skip_generic_qc:产线自带质量门禁,豁免通用QC/PM门禁')
return await _transition(task_id, tenant_id, S_RUNNING, S_QC_REVIEW, 'submit',
who=who, agent_id=agent_id)
async def qc_approve_task(task_id, tenant_id, who=None, agent_id=None, comment=None):
"""QC 通过:qc_review → review(进入 PM 审核验收)。"""
return await _transition(task_id, tenant_id, S_QC_REVIEW, S_REVIEW, 'qc_approve',
who=who, agent_id=agent_id, detail=comment)
async def _reject_with_retry_limit(task_id, tenant_id, from_state, action,
who=None, agent_id=None, comment=None):
"""退回重做 + 重试上限:retry_count+1,超 task_max_retry 则转 failed(打破死循环)。
未超限:from_state → submitted(正常退回重做)。
超限:from_state → failed(释放并发名额,交 failed poller 报故障给人工)。
返回 (ok, message)。
"""
if not task_id or not tenant_id:
return False, "缺少 task_id 或 tenant_id"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
# 重复计数 +1(CAS:仅当任务仍在 from_state)
await sor.sqlExe(
"UPDATE pipeline_tasks SET retry_count=retry_count+1 "
"WHERE id=${tid}$ AND tenant_id=${tn}$ AND state=${from}$",
{"tid": task_id, "tn": tenant_id, "from": from_state})
recs = await sor.sqlExe(
"SELECT retry_count FROM pipeline_tasks WHERE id=${tid}$", {"tid": task_id})
await sor.sqlExe("COMMIT", {})
rc = int(getattr(recs[0], 'retry_count', 0) or 0) if recs else 0
from .workspace import get_max_task_retry
max_retry = await get_max_task_retry(sor)
if rc >= max_retry:
err = f"{action} 退回 {rc} 次已达上限({max_retry}):{comment or '交付件不合规'}"
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='failed', claimed_by=NULL, "
"last_error=${err}$, updated_at=NOW() "
"WHERE id=${tid}$ AND state=${from}$",
{"tid": task_id, "from": from_state, "err": err})
await record_audit(tenant_id, 'pipeline_tasks', task_id, action,
from_state=from_state, to_state=S_FAILED,
who=who, agent_id=agent_id, detail=err, sor=sor)
logger.info("reject retry limit hit: task=%s rc=%s -> failed", task_id, rc)
return False, f"退回 {rc} 次已达上限({max_retry}),任务已标记 failed,等待人工介入"
# 未超限:正常退回 submitted
return await _transition(task_id, tenant_id, from_state, S_SUBMITTED, action,
who=who, agent_id=agent_id, detail=comment)
async def qc_reject_task(task_id, tenant_id, who=None, agent_id=None, comment=None):
"""QC 退回:qc_review → submitted(重复计数+1,超上限转 failed 打破死循环)。"""
return await _reject_with_retry_limit(task_id, tenant_id, S_QC_REVIEW, 'qc_reject',
who=who, agent_id=agent_id, comment=comment)
async def qc_exempt_task(task_id, tenant_id, who=None, agent_id=None, comment=None):
"""豁免放行:qc_review → approved(任务声明 skip_generic_qc 时,通用门禁的兜底放行)。
用于存量已卡在 qc_review 的豁免任务:认领后不再做交付件检查,直接放行。
"""
return await _transition(task_id, tenant_id, S_QC_REVIEW, S_APPROVED, 'qc_exempt',
who=who, agent_id=agent_id, detail=comment)
async def approve_task(task_id, tenant_id, who=None, agent_id=None, comment=None):
"""审核通过:review → approved。"""
return await _transition(task_id, tenant_id, S_REVIEW, S_APPROVED, 'approve',
who=who, agent_id=agent_id, detail=comment)
async def reject_task(task_id, tenant_id, who=None, agent_id=None, comment=None):
"""审核退回:review → submitted(重复计数+1,超上限转 failed 打破死循环)。"""
return await _reject_with_retry_limit(task_id, tenant_id, S_REVIEW, 'reject',
who=who, agent_id=agent_id, comment=comment)
async def complete_task(task_id, tenant_id, who=None, agent_id=None):
"""标记完成:approved → completed(也兼容 review → completed)。"""
ok, msg = await _transition(task_id, tenant_id, S_APPROVED, S_COMPLETED, 'complete',
who=who, agent_id=agent_id)
if ok:
return ok, msg
return await _transition(task_id, tenant_id, S_REVIEW, S_COMPLETED, 'complete',
who=who, agent_id=agent_id)
async def mark_failed(task_id, tenant_id, who=None, agent_id=None, error=None):
"""标记失败(任意状态 → failed,清 claimed_by)。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.R('pipeline_tasks', {'id': task_id})
if not recs:
return False, "任务不存在"
from_state = getattr(recs[0], 'state', '')
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='failed', last_error=${e}$, "
"claimed_by=NULL, updated_at=NOW() WHERE id=${tid}$ AND tenant_id=${tn}$",
{"e": (error or '')[:4000], "tid": task_id, "tn": tenant_id})
await sor.sqlExe("COMMIT", {})
await record_audit(tenant_id, 'pipeline_tasks', task_id, 'fail',
from_state=from_state, to_state=S_FAILED,
who=who, agent_id=agent_id, detail=error, sor=sor)
return True, S_FAILED
async def cancel_task(task_id, tenant_id, who=None, agent_id=None, comment=None):
"""取消任务(任意非终态 → cancelled,清 claimed_by)。
重做/作废场景必须先取消旧任务,避免两个相同任务并存;正在执行的 agent 会在
下一轮心跳检测到 cancelled 后自行中止。终态(completed/cancelled/failed/approved)
不重复取消。CAS:仅当任务仍处原非终态时取消,防并发覆盖。
"""
if not task_id or not tenant_id:
return False, "缺少 task_id 或 tenant_id"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.R('pipeline_tasks', {'id': task_id})
if not recs:
return False, "任务不存在"
from_state = getattr(recs[0], 'state', '')
if from_state in (S_COMPLETED, 'cancelled', S_FAILED, S_APPROVED):
return False, f"任务已是终态 {from_state},无需取消"
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='cancelled', claimed_by=NULL, "
"updated_at=NOW() WHERE id=${tid}$ AND tenant_id=${tn}$ AND state=${from}$",
{"tid": task_id, "tn": tenant_id, "from": from_state})
chk = await sor.R('pipeline_tasks', {'id': task_id})
await sor.sqlExe("COMMIT", {})
if not chk or getattr(chk[0], 'state', '') != 'cancelled':
return False, "取消失败(CAS): 任务状态已变化"
await record_audit(tenant_id, 'pipeline_tasks', task_id, 'cancel',
from_state=from_state, to_state='cancelled',
who=who, agent_id=agent_id, detail=comment, sor=sor)
return True, 'cancelled'
async def retry_task(task_id, tenant_id, who=None, agent_id=None):
"""重试:failed → submitted(retry_count+1,清 claimed_by)。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
await sor.sqlExe(
"UPDATE pipeline_tasks SET retry_count=retry_count+1, state='submitted', "
"claimed_by=NULL, last_error=NULL, updated_at=NOW() "
"WHERE id=${tid}$ AND tenant_id=${tn}$ AND state='failed'",
{"tid": task_id, "tn": tenant_id})
chk = await sor.R('pipeline_tasks', {'id': task_id})
await sor.sqlExe("COMMIT", {})
if not chk or getattr(chk[0], 'state', '') != S_SUBMITTED:
return False, "重试失败(任务不在 failed 状态)"
await record_audit(tenant_id, 'pipeline_tasks', task_id, 'retry',
from_state=S_FAILED, to_state=S_SUBMITTED,
who=who, agent_id=agent_id, sor=sor)
return True, S_SUBMITTED
async def reset_task_retry(task_id, tenant_id, who=None, agent_id=None):
"""人工处理后恢复:retry_count 归零,waiting/failed/submitted → submitted 重新执行。
任务重复超限后任务链暂停、故障抛给人工;人工处理完毕调用本函数把该任务重复数清零并重新执行。
state IN 含 submitted(2026-09-16 实测 bug):标准解链三步 resolve_problem
(resume_task=True) → reset_task_retry → resume_project 中,resolve 已把
waiting→submitted,本函数旧 CAS 只认 waiting/failed → UPDATE 0 行;而成功
判定只看「终态==submitted」→ 空转也报 True,retry_count 从未清零、陈旧
last_error 残留,任务带着满重试计数重新认领,一次退回即再触顶 fault。
running 态仍排除(防中途拽回 submitted 打断在跑协程)。
"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.R('pipeline_tasks', {'id': task_id})
if not recs:
return False, "任务不存在"
from_state = getattr(recs[0], 'state', '')
if from_state not in ('waiting', 'failed', 'submitted'):
return False, f"恢复失败(任务状态 {from_state} 不在 waiting/failed/submitted)"
await sor.sqlExe(
"UPDATE pipeline_tasks SET retry_count=0, state='submitted', "
"claimed_by=NULL, last_error=NULL, updated_at=NOW() "
"WHERE id=${tid}$ AND tenant_id=${tn}$ AND state IN ('waiting','failed','submitted')",
{"tid": task_id, "tn": tenant_id})
chk = await sor.R('pipeline_tasks', {'id': task_id})
await sor.sqlExe("COMMIT", {})
# 成功判定=UPDATE 真实生效(state 与 retry_count 双核对),不只看终态——
# 终态可能本来就是 submitted(空转假成功,见 docstring 实测事故)。
if (not chk or getattr(chk[0], 'state', '') != S_SUBMITTED
or int(getattr(chk[0], 'retry_count', -1) or -1) != 0):
return False, "恢复失败(计数未清零或状态竞态,请重试)"
await record_audit(tenant_id, 'pipeline_tasks', task_id, 'reset_retry',
from_state=from_state, to_state=S_SUBMITTED,
who=who, agent_id=agent_id, sor=sor)
return True, S_SUBMITTED
async def suspend_task(task_id, tenant_id, who=None, agent_id=None):
"""挂起等回答:running → waiting(清 claimed_by)。"""
return await _transition(task_id, tenant_id, S_RUNNING, S_WAITING, 'suspend',
who=who, agent_id=agent_id)
async def revive_task(task_id, tenant_id, who=None, agent_id=None):
"""恢复认领:waiting → submitted(清 claimed_by,重新认领)。"""
return await _transition(task_id, tenant_id, S_WAITING, S_SUBMITTED, 'revive',
who=who, agent_id=agent_id)
async def set_task_state(task_id, tenant_id, from_state, to_state,
who=None, agent_id=None, detail=None):
"""通用 CAS 状态迁移兜底(跨产线自定义状态机用)。"""
return await _transition(task_id, tenant_id, from_state, to_state, 'set_state',
who=who, agent_id=agent_id, detail=detail)