ymq bc5ce2f06d fix: 任务链卡死根治 — 心跳+stale回收+claimed_by僵尸锁清除
- questions.py: agent_ask/answer_question 置 waiting/submitted 时清 claimed_by,否则 poll 器永不重新认领
- agent_loop_v2.py: _t_answer_question 回答后恢复任务 waiting→submitted(此前只标记问题已答,任务永久卡 waiting)
- agent_loop.py: _claim_task 加 claimed_by IS NULL 原子认领 + updated_at 心跳;role/pm 循环心跳 COMMIT;pm 认领保持 review 态
- init.py: role poller 回收僵尸 running 任务、pm poller 回收僵尸 review 任务(心跳超时 10 分钟)
2026-08-15 00:11:36 +08:00

197 lines
7.6 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.

"""问题回路 — 角色agent缺信息时提问,主agent路由回答。
流程:
1. 角色agent执行任务时发现缺关键信息 → agent_ask() 写入 pipeline_agent_questions
(status=pending),任务置 waiting,退出本轮执行。
2. 主agent轮询 pending 问题:
- 能直接回答 → answer_question(answer_source='main_agent'),任务回 submitted,
角色agent下轮重新认领并带着问答历史继续。
- 答不了 → forward_question() 转客户 (status=forwarded);
客户在会话中回答后,主agent调 answer_question(answer_source='customer')。
"""
import json
import logging
from sqlor.dbpools import DBPools
from appPublic.uniqueID import getID
DBNAME = "pipeline"
logger = logging.getLogger("pipeline.questions")
# 问题状态
Q_PENDING = "pending" # 待主agent回答
Q_FORWARDED = "forwarded" # 主agent已转客户,等客户回复
Q_ANSWERED = "answered" # 已回答
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
async def agent_ask(tenant_id: str, task_id: str, from_role: str, question: str, context: dict = None) -> str:
"""角色agent提问。任务置 waiting,等待主agent/客户回答。返回 question_id。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
qid = getID()
ctx_json = json.dumps(context, ensure_ascii=False, default=str) if context else None
await sor.C('pipeline_agent_questions', {
"id": qid,
"tenant_id": tenant_id or "",
"task_id": task_id or "",
"from_role": from_role or "",
"question": question or "",
"context": ctx_json,
"status": Q_PENDING,
})
if task_id:
# 置 waiting 同时清 claimed_by,否则任务僵尸(poll器要求 claimed_by IS NULL 才会重新认领)
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='waiting', claimed_by=NULL WHERE id=${tid}$",
{"tid": task_id})
logger.info(f"agent_ask: task={task_id} role={from_role} qid={qid}")
return qid
async def answer_question(question_id: str, answer: str, answered_by: str = "",
answer_source: str = "main_agent", resume: bool = True):
"""回答问题。任务从 waiting 回 submitted,让角色agent重新认领继续执行。
Args:
answer_source: 'main_agent' 或 'customer'
resume: 是否把任务恢复为 submitted
Returns:
更新后的问题记录 dict;问题不存在返回 None。
"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.R('pipeline_agent_questions', {'id': question_id})
if not recs:
return None
rec = recs[0]
task_id = rec.task_id if hasattr(rec, 'task_id') else rec['task_id']
await sor.U('pipeline_agent_questions', {
"id": question_id,
"answer": answer or "",
"answered_by": answered_by or "",
"answer_source": answer_source or "main_agent",
"status": Q_ANSWERED,
})
resumed = False
if resume and task_id:
trecs = await sor.R('pipeline_tasks', {'id': task_id})
if trecs:
t = trecs[0]
tstate = t.state if hasattr(t, 'state') else t['state']
if tstate == "waiting":
# 恢复 submitted 同时清 claimed_by,否则 poll 器永不重新认领该任务
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='submitted', claimed_by=NULL WHERE id=${tid}$",
{"tid": task_id})
resumed = True
logger.info(f"answer_question: qid={question_id} task={task_id} source={answer_source} resumed={resumed}")
return {
"id": question_id,
"task_id": task_id,
"status": Q_ANSWERED,
"answer_source": answer_source,
"resumed": resumed,
}
async def forward_question(question_id: str):
"""主agent答不了 → 转给客户。status: pending → forwarded。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
await sor.U('pipeline_agent_questions', {"id": question_id, "status": Q_FORWARDED})
logger.info(f"forward_question: qid={question_id}")
async def get_question(question_id: str):
"""取单条问题记录(含 context 解析)。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.R('pipeline_agent_questions', {'id': question_id})
if not recs:
return None
rec = recs[0]
if hasattr(rec, '__dict__'):
d = {k: getattr(rec, k) for k in dir(rec) if not k.startswith('_')}
else:
d = dict(rec)
ctx = d.get('context')
if ctx and isinstance(ctx, str):
try:
d['context'] = json.loads(ctx)
except (json.JSONDecodeError, TypeError):
pass
return d
async def list_questions(tenant_id: str = None, task_id: str = None,
status: str = None, limit: int = 50) -> list:
"""问题列表,支持租户/任务/状态过滤。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
conditions = []
params = {}
if tenant_id:
conditions.append("tenant_id=${tenant_id}$")
params["tenant_id"] = tenant_id
if task_id:
conditions.append("task_id=${task_id}$")
params["task_id"] = task_id
if status:
conditions.append("status=${status}$")
params["status"] = status
where = " AND ".join(conditions) if conditions else "1=1"
try:
limit = int(limit)
except (TypeError, ValueError):
limit = 50
sql = f"SELECT * FROM pipeline_agent_questions WHERE {where} ORDER BY created_at DESC LIMIT {limit}"
recs = await sor.sqlExe(sql, params)
result = []
for rec in (recs or []):
if hasattr(rec, '__dict__'):
d = {k: getattr(rec, k) for k in dir(rec)
if not k.startswith('_') and not callable(getattr(rec, k))}
else:
d = dict(rec)
ctx = d.get('context')
if ctx and isinstance(ctx, str):
try:
d['context'] = json.loads(ctx)
except (json.JSONDecodeError, TypeError):
pass
result.append(d)
return result
async def get_task_qna(task_id: str) -> list:
"""取任务的全部已回答问答(按时间序),用于角色agent继续执行时注入prompt。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT question, answer, answer_source, from_role FROM pipeline_agent_questions "
"WHERE task_id=${tid}$ AND status='answered' ORDER BY created_at ASC",
{"tid": task_id})
result = []
for rec in (recs or []):
result.append({
"question": getattr(rec, 'question', '') or '',
"answer": getattr(rec, 'answer', '') or '',
"answer_source": getattr(rec, 'answer_source', '') or '',
"from_role": getattr(rec, 'from_role', '') or '',
})
return result