deerflow-code/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py
2026-09-07 18:24:55 +08:00

890 lines
38 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.

"""SQLAlchemy-backed roundtable background-job storage.
JSON columns (``dispatch_chain`` / ``thread_ids`` / ``chain``) are stored as
serialized strings in ``PortableLongText`` and round-tripped here with
``json.dumps`` / ``json.loads`` — same approach as ``ai_writing_sessions`` /
``roundtable_drafts``.
User isolation mirrors ``ai_writing_sessions``: methods take ``user_id`` defaulting
to ``AUTO`` (resolved from the run context) so backend graph nodes can write without
threading a user id through, while gateway routes pass it explicitly.
"""
from __future__ import annotations
import hashlib
import json
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import delete, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
_UNSET = object()
logger = logging.getLogger(__name__)
# 终态:列表/清理判定用。
TERMINAL_STATUSES = ("done", "error", "cancelled")
# 非终态(活跃):唯一活跃键去重 / 领取判定用。
ACTIVE_STATUSES = ("queued", "running", "awaiting_input")
# cancel 可命中的状态(queued/running/awaiting_input → cancel_requested)。
CANCELLABLE_STATUSES = ("queued", "running", "awaiting_input")
def compute_active_dedupe_key(
draft_id: str | None, user_id: str | None, task_id: str | None
) -> str | None:
"""活跃去重键:``sha256(scope)``。scope = draft_id + (task_id | user_id)。
- task 作业(task_id 非空):按 (draft_id, task_id) 去重 —— 跨用户共享;
- 普通作业:按 (draft_id, user_id) 去重;
- draft_id 为空 → 无法去重,返回 None(直接创建,保持原行为)。
该键写入 ``active_dedupe_key`` 唯一索引:作业活跃时占用,进入终态时清 NULL
(MySQL/SQLite 唯一索引都允许多个 NULL)——「同一 scope 至多一个活跃作业」
由 DB 唯一键强制,INSERT 冲突即幂等命中。
"""
if not draft_id:
return None
if task_id:
scope = f"task:{draft_id}:{task_id}"
else:
scope = f"user:{draft_id}:{user_id or ''}"
return hashlib.sha256(scope.encode("utf-8")).hexdigest()
def _loads(value: Any) -> Any:
if not isinstance(value, str):
return value
if not value:
return None
try:
return json.loads(value)
except Exception:
return None
def _dumps(value: Any) -> str | None:
if value is None:
return None
return json.dumps(value, ensure_ascii=False)
class RoundtableJobRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: RoundtableJobRow) -> dict[str, Any]:
d = row.to_dict()
for k in ("created_at", "updated_at", "lease_until"):
if isinstance(d.get(k), datetime):
d[k] = d[k].isoformat()
for k in (
"dispatch_chain",
"thread_ids",
"chain",
"orchestration_plan",
"dialogues",
"step3",
"input_snapshot",
):
d[k] = _loads(d.get(k))
return d
# ── create ────────────────────────────────────────────────────────────
@staticmethod
def _make_row(
*,
id: str,
resolved_user_id: str | None,
draft_id: str | None,
task_id: str | None,
orchestration_mode: str,
coordinator_name: str | None,
thread_ids: dict[str, str] | None,
chain: dict[str, Any] | None,
orchestration_plan: dict[str, Any] | None,
dispatch_chain: list[dict[str, Any]] | None,
status: str,
phase: str,
request_id: str | None = None,
request_hash: str | None = None,
active_dedupe_key: str | None = None,
input_snapshot: dict[str, Any] | None = None,
) -> RoundtableJobRow:
now = datetime.now(UTC)
return RoundtableJobRow(
id=id,
draft_id=draft_id,
user_id=resolved_user_id,
task_id=(task_id or None),
request_id=(request_id or None),
request_hash=(request_hash or None),
active_dedupe_key=(active_dedupe_key or None),
input_snapshot=_dumps(input_snapshot),
status=status,
phase=phase,
cycle=0,
dispatch_chain=_dumps(dispatch_chain),
active_agent_id=None,
consensus_percentage=0,
pending_clarification=None,
thread_ids=_dumps(thread_ids),
coordinator_name=coordinator_name,
orchestration_mode=orchestration_mode,
chain=_dumps(chain),
orchestration_plan=_dumps(orchestration_plan),
error=None,
attempt=0,
version=0,
created_at=now,
updated_at=now,
)
async def create(
self,
*,
id: str,
draft_id: str | None = None,
user_id: str | None | _AutoSentinel = AUTO,
task_id: str | None = None,
orchestration_mode: str = "recommend",
coordinator_name: str | None = None,
thread_ids: dict[str, str] | None = None,
chain: dict[str, Any] | None = None,
orchestration_plan: dict[str, Any] | None = None,
dispatch_chain: list[dict[str, Any]] | None = None,
status: str = "queued",
phase: str = "initializing",
) -> dict[str, Any]:
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.create")
row = self._make_row(
id=id,
resolved_user_id=resolved_user_id,
draft_id=draft_id,
task_id=task_id,
orchestration_mode=orchestration_mode,
coordinator_name=coordinator_name,
thread_ids=thread_ids,
chain=chain,
orchestration_plan=orchestration_plan,
dispatch_chain=dispatch_chain,
status=status,
phase=phase,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def try_create_or_get_active(
self,
*,
draft_id: str | None,
user_id: str | None | _AutoSentinel,
task_id: str | None,
id: str,
orchestration_mode: str = "recommend",
coordinator_name: str | None = None,
thread_ids: dict[str, str] | None = None,
chain: dict[str, Any] | None = None,
orchestration_plan: dict[str, Any] | None = None,
dispatch_chain: list[dict[str, Any]] | None = None,
status: str = "queued",
phase: str = "initializing",
request_id: str | None = None,
request_hash: str | None = None,
input_snapshot: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], bool]:
"""原子地「创建活跃作业 → 冲突则幂等返回已有活跃作业」(Phase 3 唯一活跃键版)。
一致性来自 DB **唯一索引** ``uq_roundtable_jobs_active_dedupe_key``:活跃作业占用
``active_dedupe_key = sha256(draft_id + task_id|user_id)``,进入终态时清 NULL。于是
「同一 scope 至多一个活跃作业」由唯一键强制——直接 INSERT,冲突(IntegrityError)即
说明已有活跃作业,读回它幂等返回(``created=False``)。**不再依赖 GET_LOCK 命名锁**:
唯一键在 MySQL / PostgreSQL / SQLite 上语义一致,跨进程/跨 worker 天然互斥。
极小竞态兜底:INSERT 冲突后读回时若胜者恰好已进终态(键已释放),则重试一次 INSERT。
返回 ``(job_dict, created)``。``draft_id`` 为空时无法去重,直接创建(保持原行为)。
"""
resolved_user_id = resolve_user_id(
user_id, method_name="RoundtableJobRepository.try_create_or_get_active"
)
dedupe_key = compute_active_dedupe_key(draft_id, resolved_user_id, task_id)
def _build_row() -> RoundtableJobRow:
return self._make_row(
id=id,
resolved_user_id=resolved_user_id,
draft_id=draft_id,
task_id=task_id,
orchestration_mode=orchestration_mode,
coordinator_name=coordinator_name,
thread_ids=thread_ids,
chain=chain,
orchestration_plan=orchestration_plan,
dispatch_chain=dispatch_chain,
status=status,
phase=phase,
request_id=request_id,
request_hash=request_hash,
active_dedupe_key=dedupe_key,
input_snapshot=input_snapshot,
)
# 无 draft_id → 无法去重,直接创建(保持原有行为)。
if dedupe_key is None:
row = _build_row()
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row), True
for _attempt in range(2):
try:
async with self._sf() as session:
row = _build_row()
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row), True
except IntegrityError:
pass # 唯一键冲突 → 已有活跃作业(或极小竞态,见下)。
existing = await self._get_by_dedupe_key(dedupe_key)
if existing is not None:
return self._row_to_dict(existing), False
# 胜者在冲突与我们读回之间进了终态(键已释放)→ 重试 INSERT 可成功。
# 两次都冲突且读不回 —— 理论上不可达;保守读回/报错,绝不双建。
existing = await self._get_by_dedupe_key(dedupe_key)
if existing is not None:
return self._row_to_dict(existing), False
raise RuntimeError(
f"roundtable job dedupe conflict but no active row found for key {dedupe_key}"
)
async def _get_by_dedupe_key(self, dedupe_key: str) -> RoundtableJobRow | None:
"""按唯一活跃键读回占用该键的(活跃)作业行;无则 None。"""
stmt = select(RoundtableJobRow).where(
RoundtableJobRow.active_dedupe_key == dedupe_key
)
async with self._sf() as session:
return (await session.execute(stmt)).scalars().first()
# ── update progress ─────────────────────────────────────────────────────
async def update_progress(
self,
job_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
status=_UNSET,
phase=_UNSET,
cycle=_UNSET,
dispatch_chain=_UNSET,
active_agent_id=_UNSET,
consensus_percentage=_UNSET,
pending_clarification=_UNSET,
thread_ids=_UNSET,
coordinator_name=_UNSET,
dialogues=_UNSET,
step3=_UNSET,
error=_UNSET,
lease_owner: str | None = None,
) -> dict[str, Any] | None:
"""更新进度字段。
**租约门(Phase 3)**:传入 ``lease_owner`` 时走**原子条件 UPDATE**——要求 DB 里
``lease_owner`` 匹配且状态不在 ``cancel_requested/cancelled``,否则返回 None(写被
拒)。这保证:① 只有持租者能推进度;② 一旦 cancel 落库(cancel_requested),迟到的
进度写不会覆盖取消意图;③ 多 worker 下非持租者的过期写直接失效。一致性来自 DB 条件
UPDATE,而非读后判断。
**终态处理**:写入 status ∈ 终态(done/error/cancelled)时,同时清空
``active_dedupe_key``(释放唯一活跃键,允许该 scope 再建作业)与租约字段。
不传 ``lease_owner`` 时保持原「按 user 分权 + 读改写」行为(向后兼容旧调用/测试)。
"""
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.update_progress")
# ── 租约门:原子条件 UPDATE ──
if lease_owner is not None:
values: dict[str, Any] = {"updated_at": datetime.now(UTC)}
if status is not _UNSET:
values["status"] = status
if phase is not _UNSET:
values["phase"] = phase
if cycle is not _UNSET:
values["cycle"] = int(cycle)
if dispatch_chain is not _UNSET:
values["dispatch_chain"] = _dumps(dispatch_chain)
if active_agent_id is not _UNSET:
values["active_agent_id"] = active_agent_id
if consensus_percentage is not _UNSET:
values["consensus_percentage"] = int(consensus_percentage)
if pending_clarification is not _UNSET:
values["pending_clarification"] = pending_clarification
if thread_ids is not _UNSET:
values["thread_ids"] = _dumps(thread_ids)
if coordinator_name is not _UNSET:
values["coordinator_name"] = coordinator_name
if dialogues is not _UNSET:
values["dialogues"] = _dumps(dialogues)
if step3 is not _UNSET:
values["step3"] = _dumps(step3)
if error is not _UNSET:
values["error"] = error
# 终态:释放唯一活跃键 + 清租约。
if status is not _UNSET and status in TERMINAL_STATUSES:
values["active_dedupe_key"] = None
values["lease_owner"] = None
values["lease_until"] = None
# awaiting_input:任务已暂停、执行协程退出 —— 必须释放租约,否则 resume 前
# 租约未过期没人能重新领取。活跃键保留(作业仍未终态,继续占着 scope)。
elif status is not _UNSET and status == "awaiting_input":
values["lease_owner"] = None
values["lease_until"] = None
values["version"] = RoundtableJobRow.version + 1
where = [
RoundtableJobRow.id == job_id,
RoundtableJobRow.lease_owner == lease_owner,
RoundtableJobRow.status.not_in(["cancel_requested", "cancelled"]),
]
if resolved_user_id is not None:
where.append(RoundtableJobRow.user_id == resolved_user_id)
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow).where(*where).values(**values)
)
if result.rowcount == 0:
return None
await session.commit()
row = await session.get(RoundtableJobRow, job_id)
return self._row_to_dict(row) if row is not None else None
# ── 兼容路径:按 user 分权 + 读改写(不持租约的旧调用)──
async with self._sf() as session:
row = await session.get(RoundtableJobRow, job_id)
if row is None:
return None
if resolved_user_id is not None and row.user_id != resolved_user_id:
return None
if status is not _UNSET:
row.status = status
if phase is not _UNSET:
row.phase = phase
if cycle is not _UNSET:
row.cycle = int(cycle)
if dispatch_chain is not _UNSET:
row.dispatch_chain = _dumps(dispatch_chain)
if active_agent_id is not _UNSET:
row.active_agent_id = active_agent_id
if consensus_percentage is not _UNSET:
row.consensus_percentage = int(consensus_percentage)
if pending_clarification is not _UNSET:
row.pending_clarification = pending_clarification
if thread_ids is not _UNSET:
row.thread_ids = _dumps(thread_ids)
if coordinator_name is not _UNSET:
row.coordinator_name = coordinator_name
if dialogues is not _UNSET:
row.dialogues = _dumps(dialogues)
if step3 is not _UNSET:
row.step3 = _dumps(step3)
if error is not _UNSET:
row.error = error
# 终态同样要释放唯一活跃键(兼容路径没有租约也要清键)。
if status is not _UNSET and status in TERMINAL_STATUSES:
row.active_dedupe_key = None
row.lease_owner = None
row.lease_until = None
# awaiting_input 同样释放租约(与租约门路径一致)。
elif status is not _UNSET and status == "awaiting_input":
row.lease_owner = None
row.lease_until = None
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def mark_draft_persist_pending(self, job_id: str) -> bool:
"""把草稿回写 pending 标记置 1(``_write_run_to_draft`` 重试耗尽时调用)。
一致性来自 DB:绝不退化为 last-write-wins 盲写,而是留下可被 outbox 补偿器
(Phase 3)发现的标记。返回是否命中了作业行。
"""
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow)
.where(RoundtableJobRow.id == job_id)
.values(draft_persist_pending=True, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
return False
await session.commit()
return True
# ── Phase 3:租约领取 / 续租 / 释放(dispatcher) ────────────────────────
async def list_claimable(self, *, now: datetime, limit: int = 20) -> list[str]:
"""列出可领取的作业 id:``queued``(新作业/续跑入队)或 ``running`` 但租约已过期
(持租 worker 崩溃/卡死)。按 updated_at 升序(先来先领),最多 limit 个。
这只是「候选列表」;真正的领取靠 {@link claim_job} 的条件 UPDATE —— 多 worker 并发
扫到同一候选时,只有一个 UPDATE 能命中(一致性来自 DB)。
"""
stmt = (
select(RoundtableJobRow.id)
.where(
or_(
RoundtableJobRow.status == "queued",
(RoundtableJobRow.status == "running")
& (RoundtableJobRow.lease_until.is_not(None))
& (RoundtableJobRow.lease_until < now),
)
)
.order_by(RoundtableJobRow.updated_at.asc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [r[0] for r in result.all()]
async def list_cancel_reapable(self, *, now: datetime, limit: int = 20) -> list[str]:
"""列出可以由 dispatcher 收口的取消请求。
``cancel_requested`` 是取消的权威意图,但持租 worker 可能在看门狗收口前崩溃。
仅当作业没有租约或租约已过期时才允许 dispatcher 无租约收口;仍持有有效租约的
作业继续由原 worker 的看门狗处理,避免抢占仍在正常退出的执行器。
"""
stmt = (
select(RoundtableJobRow.id)
.where(
RoundtableJobRow.status == "cancel_requested",
or_(
RoundtableJobRow.lease_owner.is_(None),
RoundtableJobRow.lease_until.is_(None),
RoundtableJobRow.lease_until < now,
),
)
.order_by(RoundtableJobRow.updated_at.asc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [r[0] for r in result.all()]
async def claim_job(
self, job_id: str, *, lease_owner: str, lease_until: datetime
) -> dict[str, Any] | None:
"""原子条件 UPDATE 领取一个作业:仅当它仍是可领取状态时才成功。
成功条件(DB 保证,跨 worker 互斥):``status='queued'``,或 ``status='running'``
且租约已过期。成功则置 ``status='running'``、写 ``lease_owner/lease_until``、
``attempt+1``、``version+1``,返回行;否则返回 None(别的 worker 先领了 / 状态已变)。
"""
values = {
"status": "running",
"lease_owner": lease_owner,
"lease_until": lease_until,
"attempt": RoundtableJobRow.attempt + 1,
"version": RoundtableJobRow.version + 1,
"updated_at": datetime.now(UTC),
}
where = [
RoundtableJobRow.id == job_id,
or_(
RoundtableJobRow.status == "queued",
(RoundtableJobRow.status == "running")
& (RoundtableJobRow.lease_until.is_not(None))
& (RoundtableJobRow.lease_until < datetime.now(UTC)),
),
]
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow).where(*where).values(**values)
)
if result.rowcount == 0:
return None
await session.commit()
row = await session.get(RoundtableJobRow, job_id)
if row is None:
return None
# Phase 7 observability: attempt >= 2 means this job was previously
# claimed by another worker whose lease expired (crash / OOM / GC
# stall) and is now being reclaimed. This is the key signal for
# diagnosing worker instability under load.
if row.attempt >= 2:
logger.warning(
"roundtable_jobs lease takeover: job=%s reclaimed by %s "
"(attempt=%d — previous worker's lease expired)",
job_id, lease_owner, row.attempt,
)
return self._row_to_dict(row)
async def renew_lease(
self, job_id: str, *, lease_owner: str, lease_until: datetime
) -> bool:
"""心跳续租:仅持租者可延长 ``lease_until``。返回是否续租成功。"""
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow)
.where(
RoundtableJobRow.id == job_id,
RoundtableJobRow.lease_owner == lease_owner,
RoundtableJobRow.status == "running",
)
.values(lease_until=lease_until, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
return False
await session.commit()
return True
async def release_lease(self, job_id: str, *, lease_owner: str | None = None) -> bool:
"""释放租约(进入 awaiting_input 等「暂停但非终态」时调用)。
置 ``lease_owner/lease_until`` 为 NULL,使该作业可被任意 worker 重新领取(续跑)。
传入 ``lease_owner`` 时仅当匹配才释放(防止误放他人的租约)。
"""
where = [RoundtableJobRow.id == job_id]
if lease_owner is not None:
where.append(RoundtableJobRow.lease_owner == lease_owner)
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow)
.where(*where)
.values(lease_owner=None, lease_until=None, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
return False
await session.commit()
return True
# ── Phase 3:resume / cancel 条件状态机 ──────────────────────────────────
async def queue_for_resume(
self,
job_id: str,
*,
input_snapshot: dict[str, Any] | None = None,
user_id: str | None | _AutoSentinel = AUTO,
task_id: str | None = None,
) -> dict[str, Any] | None:
"""原子条件 UPDATE:``awaiting_input → queued``(续跑入队),仅第一个成功方有效。
多 worker / 重复点击下可能有多个 resume 请求并发;条件 UPDATE(WHERE status=
'awaiting_input')保证只有一个能把它翻成 queued,其余返回 None(路由据此拒绝)。
成功后清租约(交给 dispatcher 重新领取),并把含续跑语义的新 snapshot 写回。
"""
resolved_user_id = resolve_user_id(
user_id, method_name="RoundtableJobRepository.queue_for_resume"
)
values: dict[str, Any] = {
"status": "queued",
"phase": "initializing",
"pending_clarification": None,
"lease_owner": None,
"lease_until": None,
"version": RoundtableJobRow.version + 1,
"updated_at": datetime.now(UTC),
}
if input_snapshot is not None:
values["input_snapshot"] = _dumps(input_snapshot)
where = [RoundtableJobRow.id == job_id, RoundtableJobRow.status == "awaiting_input"]
if task_id is not None:
where.append(RoundtableJobRow.task_id == task_id)
elif resolved_user_id is not None:
where.append(RoundtableJobRow.user_id == resolved_user_id)
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow).where(*where).values(**values)
)
if result.rowcount == 0:
return None
await session.commit()
row = await session.get(RoundtableJobRow, job_id)
return self._row_to_dict(row) if row is not None else None
async def request_cancel(
self,
job_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
task_id: str | None = None,
) -> dict[str, Any] | None:
"""原子条件 UPDATE:可取消态(queued/running/awaiting_input)→ ``cancel_requested``。
cancel 的**第一步**(权威意图落库)。成功后路由再 best-effort 取消本进程任务;
实际终态 ``cancelled`` 由持租者看门狗(检测到 cancel_requested)或 ``finalize_cancel``
写入 —— 保证「已取消」终态由持租者收口,避免竞态。重复 cancel 幂等(已 cancel_requested/
cancelled 时返回 None,路由据此回 409 或返回现状)。
"""
resolved_user_id = resolve_user_id(
user_id, method_name="RoundtableJobRepository.request_cancel"
)
values: dict[str, Any] = {
"status": "cancel_requested",
"version": RoundtableJobRow.version + 1,
"updated_at": datetime.now(UTC),
}
where = [
RoundtableJobRow.id == job_id,
RoundtableJobRow.status.in_(list(CANCELLABLE_STATUSES)),
]
if task_id is not None:
where.append(RoundtableJobRow.task_id == task_id)
elif resolved_user_id is not None:
where.append(RoundtableJobRow.user_id == resolved_user_id)
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow).where(*where).values(**values)
)
if result.rowcount == 0:
return None
await session.commit()
row = await session.get(RoundtableJobRow, job_id)
return self._row_to_dict(row) if row is not None else None
async def finalize_cancel(
self,
job_id: str,
*,
lease_owner: str | None = None,
) -> dict[str, Any] | None:
"""把 ``cancel_requested``(或孤儿 queued/running)收口为终态 ``cancelled``。
由持租者看门狗(检测 cancel_requested 后)或路由(作业尚在 queued、无持租者时)调用。
条件 UPDATE:仅当 ``status='cancel_requested'`` 时翻转;同时清唯一活跃键 + 租约。
传入 ``lease_owner`` 时要求租约匹配(持租者收口),否则不加租约条件(queued 无租约)。
"""
values: dict[str, Any] = {
"status": "cancelled",
"active_dedupe_key": None,
"lease_owner": None,
"lease_until": None,
"version": RoundtableJobRow.version + 1,
"updated_at": datetime.now(UTC),
}
where = [RoundtableJobRow.id == job_id, RoundtableJobRow.status == "cancel_requested"]
if lease_owner is not None:
where.append(RoundtableJobRow.lease_owner == lease_owner)
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow).where(*where).values(**values)
)
if result.rowcount == 0:
return None
await session.commit()
row = await session.get(RoundtableJobRow, job_id)
return self._row_to_dict(row) if row is not None else None
# ── Phase 3:outbox 补偿器(draft_persist_pending 投影) ─────────────────
async def list_draft_persist_pending(self, *, limit: int = 50) -> list[dict[str, Any]]:
"""列出 ``draft_persist_pending=1`` 的作业行(补偿器待投影清单)。"""
stmt = (
select(RoundtableJobRow)
.where(RoundtableJobRow.draft_persist_pending.is_(True))
.order_by(RoundtableJobRow.updated_at.asc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(r) for r in result.scalars()]
async def clear_draft_persist_pending(self, job_id: str) -> bool:
"""投影成功后清 ``draft_persist_pending`` 标记。返回是否命中。"""
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow)
.where(RoundtableJobRow.id == job_id)
.values(draft_persist_pending=False, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
return False
await session.commit()
return True
# ── read ────────────────────────────────────────────────────────────────
async def get(
self,
job_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
task_id: str | None = None,
) -> dict[str, Any] | None:
# task 作业按 task_id 共享、**不按 user 分权**:给定 task_id 时只校验作业属于该
# task(忽略 user_id)。否则走原「按 user 分权」逻辑。
if task_id is not None:
async with self._sf() as session:
row = await session.get(RoundtableJobRow, job_id)
if row is None or row.task_id != task_id:
return None
return self._row_to_dict(row)
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.get")
async with self._sf() as session:
row = await session.get(RoundtableJobRow, job_id)
if row is None:
return None
if resolved_user_id is not None and row.user_id != resolved_user_id:
return None
return self._row_to_dict(row)
async def get_by_draft(
self,
draft_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
task_id: str | None = None,
) -> dict[str, Any] | None:
"""Return the most-recently-updated job for a draft (or None).
``task_id`` set → task 作业查询:按 ``draft_id`` + ``task_id`` 过滤,**不按 user
分权**(同一 task 的作业跨用户共享)。否则按 user 分权。
"""
stmt = select(RoundtableJobRow).where(RoundtableJobRow.draft_id == draft_id)
if task_id is not None:
stmt = stmt.where(RoundtableJobRow.task_id == task_id)
else:
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.get_by_draft")
if resolved_user_id is not None:
stmt = stmt.where(RoundtableJobRow.user_id == resolved_user_id)
stmt = stmt.order_by(RoundtableJobRow.updated_at.desc()).limit(1)
async with self._sf() as session:
result = await session.execute(stmt)
row = result.scalars().first()
return self._row_to_dict(row) if row is not None else None
async def list_by_user(
self,
*,
user_id: str | None | _AutoSentinel = AUTO,
limit: int = 50,
) -> list[dict[str, Any]]:
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.list_by_user")
stmt = select(RoundtableJobRow)
if resolved_user_id is not None:
stmt = stmt.where(RoundtableJobRow.user_id == resolved_user_id)
stmt = stmt.order_by(RoundtableJobRow.updated_at.desc()).limit(limit)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(r) for r in result.scalars()]
async def status_map_by_user(
self,
user_id: str,
*,
limit: int = 200,
) -> dict[str, dict[str, str]]:
"""``{draft_id: {"jobId": ..., "status": ...}}`` for the latest job per draft.
Drives the history dropdown's per-draft status badge (Phase 6). Walks jobs
newest-first and keeps the first (latest) one seen for each draft_id.
"""
stmt = (
select(RoundtableJobRow.id, RoundtableJobRow.draft_id, RoundtableJobRow.status)
.where(RoundtableJobRow.user_id == user_id)
.where(RoundtableJobRow.draft_id.is_not(None))
.order_by(RoundtableJobRow.updated_at.desc())
.limit(limit)
)
out: dict[str, dict[str, str]] = {}
async with self._sf() as session:
result = await session.execute(stmt)
for job_id, draft_id, status in result.all():
if draft_id is None or draft_id in out:
continue
out[draft_id] = {"jobId": job_id, "status": status}
return out
async def status_map_by_drafts(
self,
draft_ids: list[str],
*,
limit: int = 400,
) -> dict[str, dict[str, str]]:
"""``{draft_id: {"jobId": ..., "status": ...}}`` for the latest job per draft.
Like :meth:`status_map_by_user` but filtered by an explicit draft-id list
instead of by owner — used by the taskId-mode history dropdown, where a
task's drafts may span multiple users (task-bound drafts are shared).
"""
ids = [d for d in draft_ids if d]
if not ids:
return {}
stmt = (
select(RoundtableJobRow.id, RoundtableJobRow.draft_id, RoundtableJobRow.status)
.where(RoundtableJobRow.draft_id.in_(ids))
.order_by(RoundtableJobRow.updated_at.desc())
.limit(limit)
)
out: dict[str, dict[str, str]] = {}
async with self._sf() as session:
result = await session.execute(stmt)
for job_id, draft_id, status in result.all():
if draft_id is None or draft_id in out:
continue
out[draft_id] = {"jobId": job_id, "status": status}
return out
# ── delete / cleanup ─────────────────────────────────────────────────────
async def delete(
self,
job_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
) -> bool:
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.delete")
async with self._sf() as session:
row = await session.get(RoundtableJobRow, job_id)
if row is None:
return False
if resolved_user_id is not None and row.user_id != resolved_user_id:
return False
await session.delete(row)
await session.commit()
return True
async def list_older_than(
self,
cutoff: datetime,
*,
only_finished: bool = False,
limit: int = 500,
) -> list[str]:
"""列出 updated_at 早于 cutoff 的作业 id(最多 limit 个),供清理任务用。
only_finished=True 时只列终态(done/error/cancelled),避免清掉还在跑的作业。
"""
stmt = select(RoundtableJobRow.id).where(RoundtableJobRow.updated_at < cutoff)
if only_finished:
stmt = stmt.where(RoundtableJobRow.status.in_(TERMINAL_STATUSES))
stmt = stmt.order_by(RoundtableJobRow.updated_at.asc()).limit(limit)
async with self._sf() as session:
result = await session.execute(stmt)
return [row[0] for row in result.all()]
async def delete_by_ids(self, job_ids: list[str]) -> int:
if not job_ids:
return 0
stmt = delete(RoundtableJobRow).where(RoundtableJobRow.id.in_(job_ids))
async with self._sf() as session:
result = await session.execute(stmt)
await session.commit()
return int(result.rowcount or 0)