Files
AgentLab/backend/tests/test_emit_order.py
T
ThearchyHelios aa18904af0 运行和 Copilot 分成两层;顺带修了一个会丢事件的时序缺陷
用户:「点击右上角运行后,会跳到流程;然后下方就又有 Copilot 对话框,把
结果和 Copilot 又放在一块儿了」。说得对,而且我上一版的理由是错的。

我当时把建图和跑图排进同一条时间轴,说它们是"一件事的两半"。它们在时间上
先后发生,但不是同一种东西——最直接的证据在我自己的代码里:运行那一轮的
question 只能填 undefined。一个没有提问的"对话轮次",本来就不是对话。

更实际的问题是那个输入框:它贴在运行结果下面,暗示你可以回复这次运行。
但你不能——在那儿打字改的是画布上的图。这是个假的可供性。两者的生命周期
也对不上:Copilot 的轮次会累积,运行永远只有最新一次。

现在右栏是一个常驻的家(Copilot 对话)加两层临时盖上来的东西(节点属性、
运行过程)。运行那一层没有输入框,有自己的头部和停止按钮;对话那一层底部
留一条运行进度,点一下过去。

---

顺着这条线查下去,发现一个更严重的问题:**后端在丢事件**。

浏览器侧连跑 5 次,2 次丢了中间一条。抓 WebSocket 原始帧才看清:所有帧的
replay 都是 false——历史读回来是空的,全部走实时;丢的是 run.started,
它在客户端订阅之前就广播了,而那时还没落库,所以历史里也没有。

根因是 _emit 的顺序:先广播、后落库。订阅者的完整性靠"先订阅 → 读历史 →
接实时"拼起来,它需要这条保证——提交早于我这次 SELECT 的,在历史里;提交
晚于我这次 SELECT 的,广播也晚于我订阅,在实时流里。后半句只有在"提交先于
广播"时才成立。交换顺序后,6 次 WebSocket 探测 + 5 次浏览器运行全部无缺口。

(上一轮我修过这个文件的另一个窗口——先读历史后订阅。那个修复是对的,但
只堵住了两步之间的缝,堵不住订阅建立之前的那一段。)

tests/test_emit_order.py 守这条不变式:广播的那一刻,这条事件必须已经能从
另一个 session 查到。去掉修复后它确实失败。

---

还修了三处:

- 停在人工介入时,"在不在等人"原先取自审批列表,而那是 4 秒轮询一次的;
  中断后会有几秒钟界面说"这次运行已结束",而它正等着你点通过。改成从事件
  推导(isAwaitingHuman),即时且和显示的内容一致。
- 新增 tests/conftest.py 把整个测试会话关进临时目录。test_emit_order 第一版
  直接用 SessionLocal 往开发库里写,几行假运行排到了运行列表最前面。已清掉
  那 12 行。这该是全局保障,不是只修一个文件。
- 开发期把 store 挂到 window.__studio,好让 playwright 直接读状态——"事件
  收全了没有"在界面上看不出来,只能问 store。已确认不进生产包。
2026-09-18 09:39:24 +08:00

112 lines
4.1 KiB
Python

"""事件的落库与广播顺序。
订阅者的完整性靠"先订阅 → 读历史 → 接实时"三步拼起来,它需要这条保证:
提交早于我这次 SELECT 的,在历史里;
提交晚于我这次 SELECT 的,广播也晚于我订阅,在实时流里。
后半句只有在"提交先于广播"时才成立。反过来(先广播后落库)会漏掉这样一条
事件:在订阅建立之前广播、却在读历史之后才提交——两边都拿不到。
这不是理论。实测跑一次 25 毫秒的图,六条事件里的 run.started 有一半几率
就这么没了,界面上表现为某个节点永远在转圈、它的子步骤跑到了顶层。
"""
from __future__ import annotations
import uuid
import pytest
from sqlalchemy import select
from app.core.events import EventType
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent
from app.engine import runner as runner_mod
from app.engine.runner import run_manager
@pytest.mark.asyncio
async def test_event_is_in_db_before_it_is_broadcast(monkeypatch) -> None:
"""广播的那一刻,这条事件必须已经能从库里查到。"""
run_id = f"emit-order-1-{uuid.uuid4().hex[:8]}"
async with SessionLocal() as session:
session.add(Run(id=run_id, status="running", graph={}, input={}))
await session.commit()
visible_at_publish: list[bool] = []
original = runner_mod.bus.publish
async def spy(event):
# 用一个独立 session 查,模拟另一个连接(WebSocket 的历史读就是这样)
async with SessionLocal() as s:
row = (
await s.execute(
select(RunEvent).where(
RunEvent.run_id == event.run_id, RunEvent.seq == event.seq
)
)
).scalar_one_or_none()
visible_at_publish.append(row is not None)
await original(event)
monkeypatch.setattr(runner_mod.bus, "publish", spy)
await run_manager._emit(run_id, EventType.RUN_STARTED, data={"nodes": 3})
await run_manager._emit(run_id, EventType.NODE_STARTED, node_id="a", data={})
assert visible_at_publish == [True, True], (
"广播时还查不到这条事件——说明是先广播后落库,"
"订阅者会漏掉在它订阅之前广播、在它读历史之后提交的那些"
)
@pytest.mark.asyncio
async def test_ephemeral_events_still_broadcast(monkeypatch) -> None:
"""不落库的流式增量照样要广播出去,不能因为跳过落库就一起跳过广播。"""
run_id = f"emit-order-2-{uuid.uuid4().hex[:8]}"
async with SessionLocal() as session:
session.add(Run(id=run_id, status="running", graph={}, input={}))
await session.commit()
published: list[str] = []
bus_mod = runner_mod.bus
original = bus_mod.publish
async def spy(event):
published.append(str(event.type))
await original(event)
monkeypatch.setattr(bus_mod, "publish", spy)
await run_manager._emit(run_id, EventType.LLM_TOKEN, node_id="a", data={"delta": "x"})
assert published == ["llm.token"]
async with SessionLocal() as s:
rows = (
await s.execute(select(RunEvent).where(RunEvent.run_id == run_id))
).scalars().all()
assert rows == [], "llm.token 是流式增量,不该落库"
@pytest.mark.asyncio
async def test_seq_is_monotonic_across_emits() -> None:
"""序号按发出顺序递增——前端排序和去重都建立在这上面。"""
run_id = f"emit-order-3-{uuid.uuid4().hex[:8]}"
async with SessionLocal() as session:
session.add(Run(id=run_id, status="running", graph={}, input={}))
await session.commit()
for i in range(5):
await run_manager._emit(run_id, EventType.LOG, data={"message": str(i)})
async with SessionLocal() as s:
seqs = [
e.seq
for e in (
await s.execute(
select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq)
)
).scalars()
]
assert seqs == sorted(seqs) and len(set(seqs)) == len(seqs)