账本接口:刚跑完未封存答 pending 只限结束后两分钟内,早就结束、一直没封存的照实答「运行还没有封存」

复核 W4R-08:上一次给 GET /api/runs/{id}/ledger 加的 pending 分支没有时间界限——成功结束、要比对、没封存的运行一律答
pending。它本来只为 runner 落库 run.finished 之后、封存之前的那一下(页面收到 run.finished 立刻来取)。封存出了错、或者是
封存这件事之前的老数据,会永远答 pending:每个轮次底部、证据面板都转两分钟「正在比对口径」,再取一次还是 pending;改之前
答的 ineligible「运行还没有封存,不比对」才是实情。

- ledger/view.py:新增 SEAL_GRACE_S = 120(和页面等比对结论的上限 store/ledger.ts 的两分钟同一个量级),_just_finished 看
  finished_at 在不在这段时间里(没记结束时间的不算;库里读回 naive 的按 UTC 算,同 db/base.UTCDateTime)。只有刚结束的才答
  pending,其余照旧走 ineligible(NOT_SEALED);_stamp 里的局部 datetime 导入挪到模块顶上
- 测试:刚跑完的那条补上 finished_at=现在;新增一条结束已久、一直没封存的和一条没记结束时间的,都答 ineligible、原因是
  没封存、不排按需比对。去掉 _just_finished 回跑,新测试失败(答的是 pending)
This commit is contained in:
ThearchyHelios
2026-10-16 22:13:52 +08:00
parent 9ad6c035d0
commit 9886e10be2
2 changed files with 47 additions and 6 deletions
+16 -4
View File
@@ -10,6 +10,7 @@ issued 是这次运行按口径出具过的正式运行,reconciled 是对过
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
@@ -23,6 +24,10 @@ from app.ledger import store
#: state:matched 比对过;pending 已排队按需比对;ineligible 这次运行不比对(reason 写为什么);unreadable 比对记录读不出
STATES = ("matched", "pending", "ineligible", "unreadable")
#: 成功结束、还没封存的运行,结束后这么多秒内答 pending(runner 落库之后马上封存,正常是几秒)。过了还没封存的是封存出了错、
#: 或者是封存之前的老数据:再答 pending,页面就一直转圈、等满两分钟(store/ledger.ts 的等待上限)再取还是 pending——照实说没封存
SEAL_GRACE_S = 120
class _Hide:
"""按这次运行查到过的遮罩值遮:hide(值) 这个值给不给,shown(文字) 文字里的遮罩值换成「已遮罩」。"""
@@ -92,8 +97,6 @@ def _labels_view(content: dict[str, Any], hider: _Hide, names: dict[str, str]) -
def _stamp(recorded: store.Recorded) -> dict[str, Any]:
"""比对的来历:哪份工件、哪一版算法、谁触发的、什么时候追加的;事后补比的写明按现在的口径回看。"""
from datetime import datetime, timezone
at = datetime.fromtimestamp(recorded.ts, timezone.utc).isoformat(timespec="seconds") \
if isinstance(recorded.ts, (int, float)) else None
return {"artifact": recorded.artifact, "algo": recorded.algo, "trigger": recorded.trigger, "retro": recorded.retro,
@@ -102,6 +105,14 @@ def _stamp(recorded: store.Recorded) -> dict[str, Any]:
else ledger_labels.RETRO_NOTE} if recorded.retro else {})}
def _just_finished(finished_at: datetime | None) -> bool:
"""运行是不是刚结束(SEAL_GRACE_S 以内)。没记结束时间的不算;库里读回 naive 的按 UTC 算(同 db/base.UTCDateTime)。"""
if finished_at is None:
return False
at = finished_at if finished_at.tzinfo is not None else finished_at.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - at).total_seconds() <= SEAL_GRACE_S
async def run_view(run_id: str) -> dict[str, Any]:
"""GET /api/runs/{run_id}/ledger 的答复:标签、整题判断、每条查询的比对、能做的动作。没比对过、能比的运行这时排上按需比对
(trigger=on_demand),答复 state=pending;不比的照实说为什么。"""
@@ -117,9 +128,10 @@ async def run_view(run_id: str) -> dict[str, Any]:
if recorded is None:
why = ledger_match.ineligible(run)
if why == ledger_match.NOT_SEALED and run.status == "succeeded" \
and ledger_match.wanted(run.run_class, run.origin_kind):
and ledger_match.wanted(run.run_class, run.origin_kind) and _just_finished(run.finished_at):
# runner 先发 run.finished、落库,再封存、再排上比对(engine/runner._finalize):页面收到 run.finished 立刻来取,
# 这一下还没封存。这不是「不比对」,是封存完就比——照实答 pending,页面按退避接着取;这里不另排按需比对
# 这一下还没封存。这不是「不比对」,是封存完就比——照实答 pending,页面按退避接着取;这里不另排按需比对。
# 只限刚结束的那一会儿(SEAL_GRACE_S),早就结束、一直没封存的照实答 ineligible
return {**base, "state": "pending"}
sealed = await _sealed(run_id) if why is None else None
if sealed is not None and not sealed.queries:
+31 -2
View File
@@ -102,11 +102,14 @@ async def test_a_run_that_just_finished_and_is_about_to_be_sealed_is_pending(cli
await ledger_match.drain()
from app.db.models import Run
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)
async with SessionLocal() as session:
fresh = Run(workflow_name="刚跑完", status="succeeded", run_class="exploratory", origin_kind="chat", graph={},
input={}, output={})
input={}, output={}, finished_at=now)
failed = Run(workflow_name="失败了", status="failed", run_class="exploratory", origin_kind="chat", graph={},
input={}, output={})
input={}, output={}, finished_at=now)
session.add_all([fresh, failed])
await session.commit()
fresh_id, failed_id = fresh.id, failed.id
@@ -117,6 +120,32 @@ async def test_a_run_that_just_finished_and_is_about_to_be_sealed_is_pending(cli
assert (await ledger_of(client, failed_id))["state"] == "ineligible"
async def test_a_run_that_finished_long_ago_and_was_never_sealed_says_so(client, monkeypatch):
"""「刚跑完、马上封存」只是结束后的那一会儿:成功结束了好一阵、一直没封存的(封存时出了错、封存这件事之前的老数据),
再答 pending 就是让页面一直转圈、等两分钟、点「再取一次」还是 pending。照实说没封存(ineligible,NOT_SEALED);
结束时间没记的同样不当成刚结束。"""
from datetime import datetime, timedelta, timezone
from app.db.models import Run
from app.ledger import view as ledger_view
old = datetime.now(timezone.utc) - timedelta(seconds=ledger_view.SEAL_GRACE_S + 60)
async with SessionLocal() as session:
stale = Run(workflow_name="很早以前跑完", status="succeeded", run_class="exploratory", origin_kind="chat",
graph={}, input={}, output={}, finished_at=old)
undated = Run(workflow_name="没记结束时间", status="succeeded", run_class="exploratory", origin_kind="chat",
graph={}, input={}, output={})
session.add_all([stale, undated])
await session.commit()
stale_id, undated_id = stale.id, undated.id
scheduled: list[str] = []
monkeypatch.setattr(ledger_match, "schedule", lambda run_id, **kw: scheduled.append(run_id) or True)
for run_id in (stale_id, undated_id):
body = await ledger_of(client, run_id)
assert body["state"] == "ineligible" and body["reason"] == ledger_match.NOT_SEALED, body
assert scheduled == []
async def test_runs_that_are_not_compared_say_why(client, monkeypatch):
row = await S.explore(monkeypatch, S.EQUIVALENT, origin="api", text="本周销售额 [[v:Q1.r0.销售额]]。")
body = await ledger_of(client, row.id)