pipeline-service/pipeline_service/task_capability.py

359 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', '')
# CASclaimed_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-03params.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:
# 重复计数 +1CAS仅当任务仍在 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 → submittedretry_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 含 submitted2026-09-16 实测 bug标准解链三步 resolve_problem
(resume_task=True) → reset_task_retry → resume_project 中resolve 已把
waiting→submitted本函数旧 CAS 只认 waiting/failed → UPDATE 0 行;而成功
判定只看「终态==submitted」→ 空转也报 Trueretry_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)"
# 2026-09-18 M4a 事故配套人工恢复时基础设施回置预算params.infra_requeue
# 一并清零——否则任务带着满 infra 计数重新认领,一次 LLM 瞬时故障即触顶。
_new_params = getattr(recs[0], 'params', '') or ''
try:
_p = json.loads(_new_params or '{}')
if isinstance(_p, dict) and _p.get('infra_requeue'):
_p['infra_requeue'] = 0
_new_params = json.dumps(_p, ensure_ascii=False)
except Exception:
pass
await sor.sqlExe(
"UPDATE pipeline_tasks SET retry_count=0, state='submitted', "
"claimed_by=NULL, last_error=NULL, params=${p}$, updated_at=NOW() "
"WHERE id=${tid}$ AND tenant_id=${tn}$ AND state IN ('waiting','failed','submitted')",
{"p": _new_params, "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)