From 9886e10be2f05618a6b0ddca49ccd91b17bea36f Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 22:13:52 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=A6=E6=9C=AC=E6=8E=A5=E5=8F=A3=EF=BC=9A?= =?UTF-8?q?=E5=88=9A=E8=B7=91=E5=AE=8C=E6=9C=AA=E5=B0=81=E5=AD=98=E7=AD=94?= =?UTF-8?q?=20pending=20=E5=8F=AA=E9=99=90=E7=BB=93=E6=9D=9F=E5=90=8E?= =?UTF-8?q?=E4=B8=A4=E5=88=86=E9=92=9F=E5=86=85=EF=BC=8C=E6=97=A9=E5=B0=B1?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E3=80=81=E4=B8=80=E7=9B=B4=E6=B2=A1=E5=B0=81?= =?UTF-8?q?=E5=AD=98=E7=9A=84=E7=85=A7=E5=AE=9E=E7=AD=94=E3=80=8C=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E8=BF=98=E6=B2=A1=E6=9C=89=E5=B0=81=E5=AD=98=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复核 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) --- backend/app/ledger/view.py | 20 +++++++++++++++---- backend/tests/test_ledger_api.py | 33 ++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/backend/app/ledger/view.py b/backend/app/ledger/view.py index 81492c2b..5ce136a6 100644 --- a/backend/app/ledger/view.py +++ b/backend/app/ledger/view.py @@ -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: diff --git a/backend/tests/test_ledger_api.py b/backend/tests/test_ledger_api.py index e70a6561..fceeb799 100644 --- a/backend/tests/test_ledger_api.py +++ b/backend/tests/test_ledger_api.py @@ -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)