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 分钟)
This commit is contained in:
ymq 2026-08-15 00:11:36 +08:00
parent d2f3caf8a7
commit bc5ce2f06d
4 changed files with 61 additions and 17 deletions

View File

@ -328,7 +328,7 @@ __REPO_STATE__
# ── Agent 核心逻辑 ──
async def _claim_task(sor, tenant_id, role, state='submitted', match_role=True):
async def _claim_task(sor, tenant_id, role, state='submitted', match_role=True, set_state='running'):
role = _normalize_role(role)
where_role = "AND (role=${role}$ OR role='')" if match_role else ""
recs = await sor.sqlExe(
@ -343,13 +343,15 @@ async def _claim_task(sor, tenant_id, role, state='submitted', match_role=True):
task_id = task.id
from appPublic.uniqueID import getID
claim_token = getID()
# claimed_by IS NULL 保证原子认领(并发 poller / start_agents 不会双重认领);
# updated_at=NOW() 作为心跳,供 stale 回收判断。
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='running', claimed_by=${cb}$ "
"WHERE id=${tid}$ AND state=${state}$",
{"cb": claim_token, "tid": task_id, "state": state})
"UPDATE pipeline_tasks SET state=${setstate}$, claimed_by=${cb}$, updated_at=NOW() "
"WHERE id=${tid}$ AND state=${state}$ AND claimed_by IS NULL",
{"setstate": set_state, "cb": claim_token, "tid": task_id, "state": state})
check = await sor.sqlExe(
"SELECT id FROM pipeline_tasks WHERE id=${tid}$ AND state='running' AND claimed_by=${cb}$",
{"tid": task_id, "cb": claim_token})
"SELECT id FROM pipeline_tasks WHERE id=${tid}$ AND state=${setstate}$ AND claimed_by=${cb}$",
{"tid": task_id, "setstate": set_state, "cb": claim_token})
if not check:
logger.info(f"claim lost race: task={task_id}")
return None
@ -650,6 +652,12 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
# ── Tool Loop原生 function calling──
for turn in range(30):
# 心跳:标记任务仍在执行,供 stale 回收判断(进程崩溃后任务不再被 touch 即被回收)。
# 必须 COMMIT 让心跳对其他连接可见,否则 poller 看不到心跳会误判为僵尸。
await sor.sqlExe(
"UPDATE pipeline_tasks SET updated_at=NOW() WHERE id=${tid}$ AND state='running'",
{"tid": task_id})
await sor.sqlExe("COMMIT", {})
try:
resp = await llm_call_msgs_native(msgs, tools=tools_schema, model=model_name, temperature=0.4)
except Exception as e:
@ -786,7 +794,7 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
async def pm_review_run(project_id, agent_id=None, model_name=None):
db = _get_db()
async with db.sqlorContext("pipeline") as sor:
task = await _claim_task(sor, project_id, '', state=TASK_REVIEW, match_role=False)
task = await _claim_task(sor, project_id, '', state=TASK_REVIEW, match_role=False, set_state='review')
if not task:
return {"status": "idle", "message": "没有待审核任务"}
@ -826,6 +834,11 @@ async def pm_review_run(project_id, agent_id=None, model_name=None):
decision = None
for turn in range(5):
# 心跳PM 审核期间持续标记,进程崩溃后由 stale 回收重置COMMIT 使其对其他连接可见
await sor.sqlExe(
"UPDATE pipeline_tasks SET updated_at=NOW() WHERE id=${tid}$ AND state='review'",
{"tid": task_id})
await sor.sqlExe("COMMIT", {})
try:
raw = await llm_call_msgs(msgs, model=model_name, temperature=0.3)
except Exception as e:
@ -870,7 +883,7 @@ async def pm_review_run(project_id, agent_id=None, model_name=None):
"quality_score": 100, "review_status": "approved", "created_by": agent_id or "pm",
})
await sor.sqlExe("UPDATE pipeline_deliverables SET review_status='approved', review_comment=${cm}$ WHERE task_id=${tid}$", {"cm": comment, "tid": task_id})
await sor.sqlExe("UPDATE pipeline_tasks SET state=${st}$ WHERE id=${tid}$", {"st": TASK_APPROVED, "tid": task_id})
await sor.sqlExe("UPDATE pipeline_tasks SET state=${st}$, claimed_by=NULL WHERE id=${tid}$", {"st": TASK_APPROVED, "tid": task_id})
next_role = await _get_next_role(task_role)
if next_role:
next_tid, next_title = await _create_next_task(sor, project_id, task, next_role, comment)
@ -886,7 +899,7 @@ async def pm_review_run(project_id, agent_id=None, model_name=None):
return {"status": "rejected", "task_id": task_id, "comment": comment, "question": rejection_q}
else:
await sor.sqlExe("UPDATE pipeline_tasks SET state='completed' WHERE id=${tid}$", {"tid": task_id})
await sor.sqlExe("UPDATE pipeline_tasks SET state='completed', claimed_by=NULL WHERE id=${tid}$", {"tid": task_id})
return {"status": "completed", "task_id": task_id, "comment": comment or "项目完成"}

View File

@ -930,19 +930,33 @@ class AgentExecutor:
# 先精确匹配,再前缀匹配兜底(兼容截断 ID
recs = await sor.sqlExe(
"SELECT id FROM pipeline_agent_questions WHERE id=${qid}$", {"qid": qid})
"SELECT id, task_id FROM pipeline_agent_questions WHERE id=${qid}$", {"qid": qid})
if not recs and len(qid) >= 6:
recs = await sor.sqlExe(
"SELECT id FROM pipeline_agent_questions WHERE id LIKE ${prefix}$ LIMIT 1",
"SELECT id, task_id FROM pipeline_agent_questions WHERE id LIKE ${prefix}$ LIMIT 1",
{"prefix": qid + "%"})
if not recs:
return f"问题不存在: {qid}"
qid = getattr(recs[0], "id", qid)
full_qid = getattr(recs[0], "id", qid)
task_id = getattr(recs[0], "task_id", "") or ""
await sor.sqlExe(
"UPDATE pipeline_agent_questions SET answer=${a}$, status='answered' WHERE id=${qid}$",
{"a": answer, "qid": qid})
return "OK: 已回答"
"UPDATE pipeline_agent_questions SET answer=${a}$, answer_source='main_agent', "
"answered_by='main_agent', status='answered' WHERE id=${qid}$",
{"a": answer, "qid": full_qid})
# 恢复任务waiting → submitted并清 claimed_by。
# 否则任务永久卡在 waitingpoll 器要求 state='submitted' AND claimed_by IS NULL 才会重新认领)。
resumed = False
if task_id:
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='submitted', claimed_by=NULL "
"WHERE id=${tid}$ AND state='waiting'",
{"tid": task_id})
resumed = True
suffix = ",任务已恢复执行" if resumed else ""
return "OK: 已回答" + suffix
async def _t_add_repo(self, sor, p, pid):
if not pid:

View File

@ -549,6 +549,12 @@ def load_pipeline_service():
while True:
try:
async with poll_db.sqlorContext("pipeline") as sor:
# 回收僵尸 running 任务:进程崩溃/协程挂起遗留(心跳超时 10 分钟未更新)。
# 否则任务永久卡 running后续任务链断裂。
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='submitted', claimed_by=NULL, updated_at=NOW() "
"WHERE state='running' AND pipeline_id='role_task' "
"AND updated_at < (NOW() - INTERVAL 10 MINUTE)", {})
recs = await sor.sqlExe(
"SELECT id, tenant_id, role FROM pipeline_tasks "
"WHERE state='submitted' AND pipeline_id='role_task' "
@ -591,6 +597,11 @@ def load_pipeline_service():
while True:
try:
async with pm_db.sqlorContext("pipeline") as sor:
# 回收僵尸 review 任务PM 审核进程崩溃后 claimed_by 残留,重新放回审核队列。
await sor.sqlExe(
"UPDATE pipeline_tasks SET claimed_by=NULL, updated_at=NOW() "
"WHERE state='review' AND claimed_by IS NOT NULL "
"AND updated_at < (NOW() - INTERVAL 10 MINUTE)", {})
recs = await sor.sqlExe(
"SELECT id, tenant_id, role FROM pipeline_tasks "
"WHERE state='review' AND claimed_by IS NULL "

View File

@ -50,7 +50,10 @@ async def agent_ask(tenant_id: str, task_id: str, from_role: str, question: str,
"status": Q_PENDING,
})
if task_id:
await sor.U('pipeline_tasks', {"id": task_id, "state": "waiting"})
# 置 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
@ -89,7 +92,10 @@ async def answer_question(question_id: str, answer: str, answered_by: str = "",
t = trecs[0]
tstate = t.state if hasattr(t, 'state') else t['state']
if tstate == "waiting":
await sor.U('pipeline_tasks', {"id": task_id, "state": "submitted"})
# 恢复 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}")