499 lines
22 KiB
Python
499 lines
22 KiB
Python
"""圆桌后台作业控制 API(Phase 3a)。
|
||
|
||
把 Step 2「后台挂起」的任务交给后端编排引擎跑,并暴露进度订阅 / 续跑 / 取消。
|
||
|
||
Routes(prefix ``/api/roundtable-jobs``)::
|
||
POST "" 启动一个后台作业(创建 job 行 + 拉起执行器任务)
|
||
GET ""?draft_id= 查某草稿最近一个作业
|
||
GET "/{job_id}" 作业快照
|
||
GET "/{job_id}/stream" SSE 进度流(轮询 job 表,多 worker 安全)
|
||
POST "/{job_id}/resume" 回答澄清后续跑
|
||
POST "/{job_id}/cancel" 取消
|
||
|
||
进度数据源是 ``roundtable_jobs`` 表(Phase 1),SSE 走 DB 轮询而非内存 pub/sub —— 这样
|
||
刷新 / 换 worker / 重连都能继续收到进度,规避 ai_writing 踩过的「per-worker 内存 dict
|
||
丢会话」坑。响应统一 camelCase(对齐前端 §2.5)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import time
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import Any
|
||
from uuid import uuid4
|
||
|
||
from fastapi import APIRouter, HTTPException, Request, Response
|
||
from fastapi.responses import StreamingResponse
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.gateway.roundtable_job_executor import build_start_snapshot
|
||
from deerflow.agents.roundtable_orchestrator import SeatRef
|
||
from deerflow.runtime.user_context import get_effective_user_id
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/api/roundtable-jobs", tags=["roundtable-jobs"])
|
||
|
||
_TERMINAL = {"done", "error", "cancelled"}
|
||
# SSE 轮询间隔与最长存活(对齐 ai_writing:长连接最多挂 30 分钟)。
|
||
_POLL_INTERVAL_S = 0.7
|
||
_MAX_STREAM_TICKS = int(30 * 60 / _POLL_INTERVAL_S)
|
||
|
||
# 续跑澄清时拼给 leader 的 prompt 前缀。
|
||
_RESUME_PREFIX = "用户已回复你刚才的澄清问题,回答如下,请据此继续派活或给出结论:\n"
|
||
|
||
|
||
# ── schemas ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class JobAgent(BaseModel):
|
||
agentId: str
|
||
name: str = ""
|
||
|
||
|
||
class StartJobRequest(BaseModel):
|
||
# Phase 3 客户端幂等键:前端为每次「后台挂起」生成一个 requestId,网络重试**复用同一
|
||
# requestId**。服务端据此审计 / 排查;「同草稿不双跑」本身由 active_dedupe_key 唯一索引
|
||
# 保证(requestId 只是锦上添花的请求级指纹)。缺省(旧前端)→ 服务端生成一个。
|
||
requestId: str | None = Field(default=None, max_length=64)
|
||
draftId: str | None = Field(default=None, max_length=64)
|
||
# 外部任务 id(taskId 深链会商)。非空 = task 作业:按 task_id 共享、不按 user 分权,
|
||
# 其 draft_id 指向独立的 roundtable_task_drafts 表(最终报告写回那里)。
|
||
taskId: str | None = Field(default=None, max_length=128)
|
||
intent: str = ""
|
||
agents: list[JobAgent] = Field(default_factory=list)
|
||
model: str | None = None
|
||
orchestrationMode: str = "recommend"
|
||
chain: dict[str, Any] | None = None
|
||
# dag 模式分层编排计划(前端 OrchestrationPlan:{mode,stages,finalSynthesis,coordinatorPrompt?})。
|
||
# 仅 orchestrationMode=="dag" 时携带;驱动后端 _run_dag。缺失 → 引擎回退 recommend。
|
||
orchestrationPlan: dict[str, Any] | None = None
|
||
# 第一轮 leader prompt;缺省用 intent。
|
||
seedMessage: str | None = None
|
||
# 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。
|
||
threadIds: dict[str, str] | None = None
|
||
coordinatorName: str = "roundtable-coordinator"
|
||
# 席位执行模式(快速/思考/专业问答/多智能体)派生参数,由前端 toStartBody 下发。
|
||
# 仅 special 席位用:覆盖 roundtable_run_policy("seat") 的 thinking/reasoning;
|
||
# None/缺省 → 后端按 policy 默认(≈pro),向后兼容旧前端。subagent 仅 ultra 档为真。
|
||
thinking_enabled: bool | None = None
|
||
reasoning_effort: str | None = None
|
||
subagent_enabled: bool = False
|
||
# 每席位「技能识别强化」开关映射 {agent_id: bool}(前端读所选业务链条里**每个席位**的
|
||
# 同名配置后下发)。后台席位 run 据此(与各席位 agent 自身 config.yaml 的开关取 OR)决定
|
||
# 是否注入。缺省空 dict(全部按 False/关,仅各 agent 自身开关生效)。
|
||
seat_skill_directives: dict[str, bool] = Field(default_factory=dict)
|
||
# 每席位「推理深度覆盖」映射 {agent_id: SeatMode}(前端读所选业务链条里**每个席位**单独配的
|
||
# 推理深度后下发;值为 flash/thinking/pro/ultra)。后台席位 run 按 agent_id 查表:命中则用该档
|
||
# 派生的 thinking/reasoning/subagent 覆盖作业级 seatMode;未命中 → 跟随作业级。缺省空 dict。
|
||
seat_modes: dict[str, str] = Field(default_factory=dict)
|
||
# 是否研讨前先并行『取数』(业务链条逐条 opt-in;前端读所选链条的开关后下发)。默认 False。
|
||
gather_first: bool = False
|
||
# 深链接业务码(rwfx→6BF 等)。透传到后台总结报告的「业务链完整性核对」。普通会商为空。
|
||
business_code: str | None = None
|
||
|
||
|
||
class ResumeJobRequest(BaseModel):
|
||
answer: str = ""
|
||
|
||
|
||
class CleanupRequest(BaseModel):
|
||
retention_days: int = Field(default=7, ge=0, description="保留多少天内的作业;0 = 不保留")
|
||
only_finished: bool = Field(default=True, description="仅清终态(done/error/cancelled),保护在跑作业")
|
||
batch_size: int = Field(default=500, ge=1, le=5000)
|
||
|
||
|
||
class CleanupResponse(BaseModel):
|
||
deleted: int
|
||
elapsed_seconds: float
|
||
params: dict[str, Any]
|
||
|
||
|
||
class RoundtableJobResponse(BaseModel):
|
||
id: str
|
||
draftId: str | None = None
|
||
status: str = "queued"
|
||
phase: str = "initializing"
|
||
cycle: int = 0
|
||
dispatchChain: list[dict[str, Any]] = Field(default_factory=list)
|
||
activeAgentId: str | None = None
|
||
consensusPercentage: int = 0
|
||
pendingClarification: str | None = None
|
||
orchestrationMode: str = "recommend"
|
||
chain: dict[str, Any] | None = None
|
||
orchestrationPlan: dict[str, Any] | None = None
|
||
error: str | None = None
|
||
dialogues: list[dict[str, Any]] = Field(default_factory=list)
|
||
step3: dict[str, Any] | None = None
|
||
createdAt: datetime | str | None = None
|
||
updatedAt: datetime | str | None = None
|
||
|
||
|
||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _current_user_id(request: Request) -> str:
|
||
user = getattr(request.state, "user", None)
|
||
if user is not None:
|
||
return str(user.id)
|
||
return get_effective_user_id()
|
||
|
||
|
||
def _get_store(request: Request):
|
||
store = getattr(request.app.state, "roundtable_job_store", None)
|
||
if store is None:
|
||
raise HTTPException(status_code=503, detail="Roundtable job store not available")
|
||
return store
|
||
|
||
|
||
def _get_executor(request: Request):
|
||
executor = getattr(request.app.state, "roundtable_job_executor", None)
|
||
if executor is None:
|
||
raise HTTPException(status_code=503, detail="Roundtable job executor not available")
|
||
return executor
|
||
|
||
|
||
def _get_dispatcher(request: Request):
|
||
"""Phase 3 dispatcher(可能为 None:store 未配置时)。nudge 前先判空。"""
|
||
return getattr(request.app.state, "roundtable_job_dispatcher", None)
|
||
|
||
|
||
def _request_hash(body: StartJobRequest) -> str:
|
||
"""启动入参指纹(sha256)——审计/排查「同 requestId 但入参变了」的异常重试。"""
|
||
canonical = json.dumps(
|
||
body.model_dump(mode="json", exclude={"requestId"}),
|
||
ensure_ascii=False, sort_keys=True, default=str,
|
||
)
|
||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _pending_nodes(agents: list[JobAgent]) -> list[dict[str, Any]]:
|
||
"""启动即落一份「全 pending」的派活链路,使 GET / 首帧 SSE 立刻能渲染流程图。"""
|
||
return [
|
||
{"agentId": a.agentId, "name": a.name or a.agentId, "order": i + 1, "state": "pending"}
|
||
for i, a in enumerate(agents)
|
||
]
|
||
|
||
|
||
def _to_response(row: dict[str, Any]) -> RoundtableJobResponse:
|
||
return RoundtableJobResponse(
|
||
id=row["id"],
|
||
draftId=row.get("draft_id"),
|
||
status=row.get("status") or "queued",
|
||
phase=row.get("phase") or "initializing",
|
||
cycle=int(row.get("cycle") or 0),
|
||
dispatchChain=row.get("dispatch_chain") or [],
|
||
activeAgentId=row.get("active_agent_id"),
|
||
consensusPercentage=int(row.get("consensus_percentage") or 0),
|
||
pendingClarification=row.get("pending_clarification"),
|
||
orchestrationMode=row.get("orchestration_mode") or "recommend",
|
||
chain=row.get("chain"),
|
||
orchestrationPlan=row.get("orchestration_plan"),
|
||
error=row.get("error"),
|
||
dialogues=row.get("dialogues") or [],
|
||
step3=row.get("step3"),
|
||
createdAt=row.get("created_at"),
|
||
updatedAt=row.get("updated_at"),
|
||
)
|
||
|
||
|
||
def _seat_refs_from_nodes(nodes: list[dict[str, Any]] | None) -> list[SeatRef]:
|
||
out: list[SeatRef] = []
|
||
for n in nodes or []:
|
||
aid = str(n.get("agentId") or "")
|
||
if aid:
|
||
out.append(SeatRef(aid, str(n.get("name") or aid)))
|
||
return out
|
||
|
||
|
||
# ── routes ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@router.post("", response_model=RoundtableJobResponse, status_code=201)
|
||
async def start_job(
|
||
request: Request, body: StartJobRequest, response: Response
|
||
) -> RoundtableJobResponse:
|
||
"""启动一个后台作业(Phase 3:唯一活跃键去重 + dispatcher 领取执行)。
|
||
|
||
流程:
|
||
1. 把整份启动入参序列化成 ``input_snapshot`` 落库(跨 worker 重建 StartParams 的前提);
|
||
2. ``try_create_or_get_active`` 直接 INSERT 并占用 ``active_dedupe_key`` 唯一索引 ——
|
||
冲突(同草稿已有活跃作业)即幂等返回它(``created=False`` → HTTP 200),不再新建;
|
||
3. 新建成功(HTTP 201,status=queued)后 ``nudge`` dispatcher,由它 ``claim_job``
|
||
原子领取并在本(或其它)worker 执行 —— **不再由路由直接拉起进程内任务**。
|
||
|
||
「同一草稿至多一个活跃作业」由 DB 唯一索引强制,跨 worker / 并发 / 重试天然互斥,
|
||
不依赖 GET_LOCK 命名锁或进程内 asyncio。
|
||
"""
|
||
store = _get_store(request)
|
||
user_id = _current_user_id(request)
|
||
|
||
if not body.agents:
|
||
raise HTTPException(status_code=400, detail="agents is required")
|
||
|
||
task_id = (body.taskId or "").strip() or None
|
||
draft_id = (body.draftId or "").strip() or None
|
||
agents = [SeatRef(a.agentId, a.name or a.agentId) for a in body.agents]
|
||
seed_message = (body.seedMessage or body.intent or "").strip()
|
||
|
||
# 整份启动入参快照 —— dispatcher 在任意 worker 领取后据此无损重建 StartParams。
|
||
snapshot = build_start_snapshot(
|
||
user_id=user_id,
|
||
agents=agents,
|
||
seed_message=seed_message,
|
||
model=body.model,
|
||
mode=body.orchestrationMode,
|
||
intent_text=body.intent,
|
||
thread_ids=body.threadIds,
|
||
coordinator_name=body.coordinatorName,
|
||
draft_id=draft_id,
|
||
task_id=task_id,
|
||
is_resume=False,
|
||
chain=body.chain,
|
||
orchestration_plan=body.orchestrationPlan,
|
||
seat_thinking_enabled=body.thinking_enabled,
|
||
seat_reasoning_effort=body.reasoning_effort,
|
||
seat_subagent_enabled=body.subagent_enabled,
|
||
seat_skill_directives=dict(body.seat_skill_directives or {}),
|
||
seat_modes=dict(body.seat_modes or {}),
|
||
gather_first=bool(body.gather_first),
|
||
business_code=(body.business_code or None),
|
||
)
|
||
request_id = (body.requestId or "").strip() or uuid4().hex
|
||
|
||
job_id = uuid4().hex
|
||
row, created = await store.try_create_or_get_active(
|
||
draft_id=draft_id,
|
||
user_id=user_id,
|
||
task_id=task_id,
|
||
id=job_id,
|
||
orchestration_mode=body.orchestrationMode,
|
||
coordinator_name=body.coordinatorName,
|
||
thread_ids=body.threadIds,
|
||
chain=body.chain,
|
||
orchestration_plan=body.orchestrationPlan,
|
||
dispatch_chain=_pending_nodes(body.agents),
|
||
status="queued",
|
||
request_id=request_id,
|
||
request_hash=_request_hash(body),
|
||
input_snapshot=snapshot,
|
||
)
|
||
if created:
|
||
# 新建(queued):交给 dispatcher 领取执行。
|
||
response.status_code = 201
|
||
else:
|
||
# 命中已有活跃作业:幂等返回(不重复建 / 不重复跑)。
|
||
response.status_code = 200
|
||
# 任一分支都唤醒 dispatcher:新建需被领取;幂等命中时若既有作业还在 queued、未被任何
|
||
# worker 领走(如 dispatcher 正在轮询间隙),nudge 确保它被立刻 claim,无感延迟。
|
||
dispatcher = _get_dispatcher(request)
|
||
if dispatcher is not None:
|
||
dispatcher.nudge()
|
||
return _to_response(row)
|
||
|
||
|
||
@router.get("", response_model=RoundtableJobResponse | None)
|
||
async def get_job_by_draft(request: Request, draft_id: str, task_id: str | None = None) -> RoundtableJobResponse | None:
|
||
# task_id 非空 = task 作业查询(按 task 共享、不分权);否则按 user 分权。
|
||
store = _get_store(request)
|
||
tid = (task_id or "").strip() or None
|
||
user_id = _current_user_id(request)
|
||
row = await store.get_by_draft(draft_id, user_id=user_id, task_id=tid)
|
||
return _to_response(row) if row is not None else None
|
||
|
||
|
||
@router.get("/{job_id}", response_model=RoundtableJobResponse)
|
||
async def get_job(request: Request, job_id: str, task_id: str | None = None) -> RoundtableJobResponse:
|
||
store = _get_store(request)
|
||
tid = (task_id or "").strip() or None
|
||
user_id = _current_user_id(request)
|
||
row = await store.get(job_id, user_id=user_id, task_id=tid)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
return _to_response(row)
|
||
|
||
|
||
@router.get("/{job_id}/stream")
|
||
async def stream_job(request: Request, job_id: str, task_id: str | None = None) -> StreamingResponse:
|
||
store = _get_store(request)
|
||
tid = (task_id or "").strip() or None
|
||
user_id = _current_user_id(request)
|
||
|
||
def _frame(payload: dict[str, Any]) -> str:
|
||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||
|
||
async def gen():
|
||
for _ in range(_MAX_STREAM_TICKS):
|
||
if await request.is_disconnected():
|
||
return
|
||
row = await store.get(job_id, user_id=user_id, task_id=tid)
|
||
if row is None:
|
||
yield _frame({"event": "error", "detail": "Job not found"})
|
||
return
|
||
yield _frame({"event": "progress", **_to_response(row).model_dump(mode="json")})
|
||
if (row.get("status") or "") in _TERMINAL:
|
||
return
|
||
await asyncio.sleep(_POLL_INTERVAL_S)
|
||
|
||
return StreamingResponse(
|
||
gen(),
|
||
media_type="text/event-stream",
|
||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
|
||
)
|
||
|
||
|
||
@router.post("/{job_id}/resume", response_model=RoundtableJobResponse)
|
||
async def resume_job(
|
||
request: Request, job_id: str, body: ResumeJobRequest, task_id: str | None = None
|
||
) -> RoundtableJobResponse:
|
||
"""回答澄清后续跑(Phase 3:条件 UPDATE 串行 + dispatcher 领取)。
|
||
|
||
``queue_for_resume`` 用**原子条件 UPDATE**(WHERE status='awaiting_input')把作业翻成
|
||
queued —— 并发 / 重复的 resume 只有一个能成功(其余返回 None → 409),杜绝「同一澄清
|
||
被续跑两次」的双跑。成功后清租约 + 写含续跑语义的新 snapshot,``nudge`` dispatcher 由
|
||
它在任意 worker 领取执行;**路由不再直接拉起进程内任务**。
|
||
"""
|
||
store = _get_store(request)
|
||
tid = (task_id or "").strip() or None
|
||
user_id = _current_user_id(request)
|
||
|
||
row = await store.get(job_id, user_id=user_id, task_id=tid)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
|
||
answer = body.answer.strip()
|
||
if not answer:
|
||
raise HTTPException(status_code=400, detail="answer is required")
|
||
|
||
# task 作业不分权:放开写入归属校验(user_id=None)。
|
||
write_user_id = None if tid else user_id
|
||
run_user_id = row.get("user_id") or user_id
|
||
|
||
# 续跑 snapshot:优先复用原 input_snapshot(含席位/模式/链条等完整入参),覆盖续跑三件套
|
||
# (is_resume / seed_message / intent_text),并用作业行里**最新**的 thread_ids /
|
||
# coordinator_name(首跑建线程后由执行器写回,比原 snapshot 里的更新)。旧作业无 snapshot
|
||
# 时从作业行字段兜底重建。
|
||
base_snapshot = row.get("input_snapshot") or {}
|
||
if not isinstance(base_snapshot, dict):
|
||
base_snapshot = {}
|
||
resume_snapshot: dict[str, Any] = {
|
||
"v": base_snapshot.get("v", 1),
|
||
"user_id": run_user_id,
|
||
"agents": base_snapshot.get("agents")
|
||
or [{"agent_id": n.get("agentId"), "name": n.get("name")} for n in (row.get("dispatch_chain") or [])],
|
||
"seed_message": _RESUME_PREFIX + answer,
|
||
"model": base_snapshot.get("model"),
|
||
"mode": row.get("orchestration_mode") or base_snapshot.get("mode") or "recommend",
|
||
"intent_text": answer,
|
||
"thread_ids": row.get("thread_ids"),
|
||
"coordinator_name": row.get("coordinator_name") or "roundtable-coordinator",
|
||
"draft_id": row.get("draft_id"),
|
||
"task_id": tid or row.get("task_id"),
|
||
"is_resume": True,
|
||
"chain": row.get("chain"),
|
||
"orchestration_plan": row.get("orchestration_plan"),
|
||
"enable_report": base_snapshot.get("enable_report", True),
|
||
"seat_thinking_enabled": base_snapshot.get("seat_thinking_enabled"),
|
||
"seat_reasoning_effort": base_snapshot.get("seat_reasoning_effort"),
|
||
"seat_subagent_enabled": base_snapshot.get("seat_subagent_enabled", False),
|
||
"seat_skill_directives": base_snapshot.get("seat_skill_directives") or {},
|
||
"seat_modes": base_snapshot.get("seat_modes") or {},
|
||
"gather_first": base_snapshot.get("gather_first", False),
|
||
"business_code": base_snapshot.get("business_code"),
|
||
}
|
||
|
||
updated = await store.queue_for_resume(
|
||
job_id, input_snapshot=resume_snapshot, user_id=write_user_id, task_id=tid
|
||
)
|
||
if updated is None:
|
||
# 条件 UPDATE 未命中:作业已不在 awaiting_input(被并发 resume 抢先 / 已续跑 / 已终态)。
|
||
raise HTTPException(status_code=409, detail="Job is not awaiting input")
|
||
|
||
dispatcher = _get_dispatcher(request)
|
||
if dispatcher is not None:
|
||
dispatcher.nudge()
|
||
return _to_response(updated)
|
||
|
||
|
||
async def _require_admin(request: Request) -> None:
|
||
"""放行 admin / auth-disabled,其余 403(与 ai_writing / scheduled_tasks 同套语义)。"""
|
||
from app.gateway.deps import get_optional_user_from_request
|
||
|
||
user = await get_optional_user_from_request(request)
|
||
if user is None:
|
||
return
|
||
if getattr(user, "system_role", None) != "admin":
|
||
raise HTTPException(status_code=403, detail="圆桌作业清理管理接口仅限管理员")
|
||
|
||
|
||
@router.post("/admin/cleanup", response_model=CleanupResponse)
|
||
async def admin_cleanup(request: Request, body: CleanupRequest) -> CleanupResponse:
|
||
"""清理过期作业行(活跃作业受 only_finished 保护,永不误删)。
|
||
|
||
分批 ``list_older_than`` → ``delete_by_ids``,避免大事务。``retention_days=0``
|
||
一刷清空所有终态作业(调试用)。
|
||
"""
|
||
await _require_admin(request)
|
||
store = _get_store(request)
|
||
cutoff = datetime.now(UTC) - timedelta(days=body.retention_days)
|
||
|
||
t0 = time.perf_counter()
|
||
deleted = 0
|
||
while True:
|
||
ids = await store.list_older_than(cutoff, only_finished=body.only_finished, limit=body.batch_size)
|
||
if not ids:
|
||
break
|
||
deleted += await store.delete_by_ids(ids)
|
||
if len(ids) < body.batch_size:
|
||
break
|
||
|
||
return CleanupResponse(
|
||
deleted=deleted,
|
||
elapsed_seconds=round(time.perf_counter() - t0, 3),
|
||
params={"retention_days": body.retention_days, "only_finished": body.only_finished, "batch_size": body.batch_size},
|
||
)
|
||
|
||
|
||
@router.post("/{job_id}/cancel", response_model=RoundtableJobResponse)
|
||
async def cancel_job(request: Request, job_id: str, task_id: str | None = None) -> RoundtableJobResponse:
|
||
"""取消作业(Phase 3:两段式 cancel)。
|
||
|
||
1. ``request_cancel``:原子条件 UPDATE(可取消态 → ``cancel_requested``)—— 权威意图落库,
|
||
重复 cancel 幂等(已 cancel_requested/cancelled/终态时返回 None,直接回现状)。
|
||
2. **无持租者**(queued / awaiting_input,执行器未在跑)→ 路由立刻 ``finalize_cancel``
|
||
收口为终态 ``cancelled``(清唯一活跃键 + 租约)。
|
||
3. **有持租者**(running)→ 保留 ``cancel_requested``,由持租者的 DB 看门狗收口
|
||
``cancelled``;本接口先回 ``cancel_requested``(SSE 随后见翻转)。租约过期前未能
|
||
收口(例如 worker 崩溃)时,dispatcher 会回收该取消请求。
|
||
|
||
「已取消」终态永远由持租者或 finalize 收口,避免与执行器的进度写竞态。
|
||
"""
|
||
store = _get_store(request)
|
||
tid = (task_id or "").strip() or None
|
||
user_id = _current_user_id(request)
|
||
|
||
row = await store.get(job_id, user_id=user_id, task_id=tid)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
|
||
# task 作业不分权:放开写入归属校验。
|
||
write_user_id = None if tid else user_id
|
||
requested = await store.request_cancel(job_id, user_id=write_user_id, task_id=tid)
|
||
if requested is None:
|
||
# 已不可取消(终态 / 已 cancel_requested):幂等返回现状,不当作错误。
|
||
fresh = await store.get(job_id, user_id=user_id, task_id=tid)
|
||
return _to_response(fresh or row)
|
||
|
||
if not requested.get("lease_owner"):
|
||
# 无持租者:路由直接收口(queued / awaiting_input)。
|
||
finalized = await store.finalize_cancel(job_id)
|
||
return _to_response(finalized or requested)
|
||
|
||
# 有持租者(running):不要直接 task.cancel()。直接取消会中断当前 worker 的看门狗,
|
||
# 使 DB 永久遗留在 cancel_requested;让 DB 看门狗以持租者身份收口,崩溃时由 dispatcher
|
||
# 在租约到期后回收,才能保证跨进程取消最终完成。
|
||
return _to_response(requested)
|