mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 19:12:00 +00:00
fc57ed0e32
- append_events 加 approval_id(只用于 interrupted):问的是哪张审批卡,那张卡仍待审批才写。核对放在和其余条件同一次条件更新里(EXISTS 待审批的那条),批过了、不是这次运行的卡都不写、返回 None。条件更新写明不同步会话里的运行:条件里带了 EXISTS 时 SQLAlchemy 会悄悄换一种同步办法、给语句加上 RETURNING - manifest_seq 为空的老封存运行,封到哪一条是 verify_manifest 按事件类型推出来的:有终态事件的封到最后一条终态事件,追加在后面不碍事,照旧追加;没有终态事件的封到最后一行,追加一条范围就挪到它身上,我们自己写的批注被当成篡改,现在不追加 - _stage 落库时用事件自己的 id:广播出去的、交回给调用方的和库里的是同一个 id - 运行用量不计数据带 post_seal 的质询:运行用量是这次运行自己花的,结束时就定下了、写进了终态事件;封存后的批注另有自己的账,同按需裁判的判定。继续运行之后它落进新的封存范围,照样不计 测试:两道审批的运行批过第一道、停在第二道,钉住第一道的质询不写、钉住第二道的写,读完运行之后那张卡被批了也不写,返回的 id 和序号与落库的一致;广播时查库,每条都已落库,落空的一条都不广播(把广播挪到条件更新之前这条就变红,以前的测试照样通过);老封存三种(无终态、run.finished、run.failed)追加前后 verify 都一致,无终态的不写;失败封存的运行追加 post_seal 质询后继续运行,运行用量里没有它。去掉审批卡的核对、改回老封存一律追加、改回计 post_seal、去掉落库 id,对应的测试各自变红
161 lines
8.5 KiB
Python
161 lines
8.5 KiB
Python
"""往一次停下的运行里追加事件:运行停在审批时的审批质询、封存之后的批注。
|
||
|
||
事件流是封存的依据:清单按 seq 排序算哈希,而 run_events 的 (run_id, seq) 没有唯一约束——两条事件撞了号,
|
||
排序就说不准,封存核对时对时不对。所以追加和 runner 发事件守同一套序号:接在库里最后一条后面,从 bus 取号
|
||
(同一进程里和执行任务共用一个计数器),写事件和条件更新 last_seq 在同一个事务里,谁先提交算谁的。做法同
|
||
api/evidence.py 的 _append_judged(按需裁判的判定)。
|
||
|
||
require 说明往哪种运行里追加:
|
||
- "interrupted":运行停在审批、还没封存。事件落在以后的封存范围里,随运行结束一起封存。
|
||
- "sealed":已经封存的终态运行。事件带 post_seal: true,落在 manifest_seq 之后,不进封存范围,verify 照旧一致。
|
||
|
||
没抢到(读完运行之后它被恢复、被放弃、继续运行后重新封存了,问的那张审批卡已经批过了,或者别处刚往里写过
|
||
事件)就一条都不写、返回 None,调用方据此说明「未写入运行记录」。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import contextlib
|
||
from collections.abc import AsyncIterator, Sequence
|
||
from typing import Any, Literal
|
||
|
||
from sqlalchemy import exists, func, select, update
|
||
|
||
from app.core.bus import bus
|
||
from app.core.events import EventType, RunEventModel
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Approval, Run, RunEvent
|
||
from app.engine.runner import _seq_floor, _stage, run_manager
|
||
|
||
#: 运行的终态。封存过的运行只会停在这几种上
|
||
_TERMINAL = ("succeeded", "failed", "cancelled")
|
||
#: 宣布运行结束的事件。封存盖住了最后一条这种事件,才是这次结束之后封的(见 _current_seal)
|
||
_ENDINGS = (EventType.RUN_FINISHED, EventType.RUN_FAILED, EventType.RUN_CANCELLED)
|
||
|
||
#: 一条要追加的事件:(事件类型, node_id, 数据)
|
||
Item = tuple[EventType | str, str | None, dict[str, Any] | None]
|
||
|
||
|
||
class _RunLocks:
|
||
"""每次运行一把锁,没人用了就扔:同一次运行的追加在本进程里排队,读 last_seq 到条件更新之间不会被
|
||
另一次追加插进来——插进来的话后提交的那次条件更新落空,明明没有冲突也成了「未写入」。"""
|
||
|
||
def __init__(self) -> None:
|
||
self._locks: dict[str, asyncio.Lock] = {}
|
||
self._users: dict[str, int] = {}
|
||
|
||
@contextlib.asynccontextmanager
|
||
async def hold(self, key: str) -> AsyncIterator[None]:
|
||
lock = self._locks.setdefault(key, asyncio.Lock())
|
||
self._users[key] = self._users.get(key, 0) + 1
|
||
try:
|
||
async with lock:
|
||
yield
|
||
finally:
|
||
self._users[key] -= 1
|
||
if not self._users[key]:
|
||
del self._users[key], self._locks[key]
|
||
|
||
|
||
_APPENDING = _RunLocks()
|
||
|
||
|
||
async def append_events(
|
||
run_id: str,
|
||
items: Sequence[Item],
|
||
*,
|
||
require: Literal["interrupted", "sealed"],
|
||
seal: tuple[int | None, str | None] | None = None,
|
||
approval_id: str | None = None,
|
||
) -> list[RunEventModel] | None:
|
||
"""把 items 依次接在运行记录的最后一条后面,返回写入的事件(seq 严格递增,id 和落库的相同);没写入返回 None。
|
||
|
||
调用方开始做事时看到的状态可以钉住,做的事要花几秒(问模型),这期间变了就不追加:
|
||
- seal 只用于 "sealed":当时的 (manifest_seq, manifest_hash)。继续运行、重新封存了的话,结果依据的已经
|
||
不是现在的封存。不传就按这次读到的封存。
|
||
- approval_id 只用于 "interrupted":问的是哪张审批卡。那张卡仍待审批才写——批过了,运行可能已经停到了
|
||
下一张卡上,仍是「停在审批」,关于上一张的问答却会写进这一次停下里。
|
||
items 为空时什么都不做,返回空列表。
|
||
"""
|
||
if require not in ("interrupted", "sealed"):
|
||
raise ValueError(f"require 只能是 interrupted 或 sealed,收到的是 {require!r}")
|
||
if approval_id is not None and require != "interrupted":
|
||
raise ValueError("approval_id 只用于 interrupted:封存之后没有待审批的卡")
|
||
# 事件类型先验过:写错了在取号之前就报出来
|
||
entries = [(EventType(str(kind)), node_id, dict(data or {})) for kind, node_id, data in items]
|
||
if not entries:
|
||
return []
|
||
sealed = require == "sealed"
|
||
for _, _, data in entries:
|
||
if sealed:
|
||
data["post_seal"] = True
|
||
else:
|
||
# 停在审批时追加的事件要进封存范围,不能自称在封存之后
|
||
data.pop("post_seal", None)
|
||
|
||
async with _APPENDING.hold(run_id):
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
if run is None:
|
||
return None
|
||
if sealed:
|
||
if run.status not in _TERMINAL or not run.manifest_hash \
|
||
or (seal is not None and (run.manifest_seq, run.manifest_hash) != tuple(seal)) \
|
||
or not await _current_seal(session, run):
|
||
return None
|
||
guard = (Run.status.in_(_TERMINAL), _same(Run.manifest_seq, run.manifest_seq),
|
||
_same(Run.manifest_hash, run.manifest_hash))
|
||
else:
|
||
if run.status != "interrupted":
|
||
return None
|
||
guard = (Run.status == "interrupted",)
|
||
if approval_id is not None:
|
||
guard += (exists().where(Approval.id == approval_id, Approval.run_id == run_id,
|
||
Approval.status == "pending"),)
|
||
last = run.last_seq
|
||
# 进程重启过的话内存里的序号从 0 起:接在库里最后一条后面。last_seq 按说就是最后一条,_seq_floor
|
||
# 另取一次实际的最大序号兜底——别处的追加把 last_seq 写小了,也不至于在这里撞号
|
||
bus.set_seq(run_id, await _seq_floor(session, run_id, last))
|
||
events = [run_manager._event(run_id, kind, node_id=node_id, data=data)
|
||
for kind, node_id, data in entries]
|
||
# 核对和写入是同一行上的条件更新,和恢复、放弃、继续运行(runner._claim / _abandon)抢的是同一行。
|
||
# 不同步会话里的 run(下面 _stage 会把它的 last_seq 改成同一个值):不写明的话,条件里带了 EXISTS 时
|
||
# SQLAlchemy 会悄悄换一种同步办法、给这条语句加上 RETURNING
|
||
claimed = await session.execute(
|
||
update(Run).where(Run.id == run_id, _same(Run.last_seq, last), *guard)
|
||
.values(last_seq=events[-1].seq).execution_options(synchronize_session=False))
|
||
if claimed.rowcount != 1:
|
||
# 读完运行之后它变了。烧掉几个序号不要紧:不落库的事件(流式增量)本来就占序号,
|
||
# 序号只要求递增、不撞号
|
||
await session.rollback()
|
||
return None
|
||
for event in events:
|
||
await _stage(session, event, run)
|
||
await session.commit()
|
||
# 先落库再广播,理由同 RunManager._emit
|
||
for event in events:
|
||
await bus.publish(event)
|
||
return events
|
||
|
||
|
||
def _same(column: Any, value: Any) -> Any:
|
||
return column.is_(None) if value is None else column == value
|
||
|
||
|
||
async def _current_seal(session: Any, run: Run) -> bool:
|
||
"""运行记录上的封存是不是这次结束之后封的:manifest_seq 盖住了最后一条宣布结束的事件。
|
||
|
||
继续运行之后再次结束时,终态和终态事件先提交、封存后算。这中间运行记录上还是上一次结束时的封存,
|
||
当成现在的封存去追加,事件会被接下来的封存收进去,post_seal 就成了假话。
|
||
|
||
manifest_seq 为空的是加这一列之前封存的老运行,封到哪一条是 verify_manifest 按老口径推出来的:有终态事件的
|
||
封到最后一条终态事件,追加在后面不碍事;没有的封到最后一行,追加一条,范围就挪到它身上,我们自己写的
|
||
批注被当成篡改。所以老运行要有终态事件才追加。
|
||
"""
|
||
ended = await session.scalar(
|
||
select(func.max(RunEvent.seq)).where(RunEvent.run_id == run.id,
|
||
RunEvent.type.in_([str(t) for t in _ENDINGS])))
|
||
if run.manifest_seq is None:
|
||
return ended is not None
|
||
return ended is None or run.manifest_seq >= ended
|