From ccb2b2baa8d2a406ed5b4f65b187028107fc02a8 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 17 Oct 2026 09:10:52 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=84=E6=B5=8B=E5=81=9C=E6=AD=A2=E3=80=81?= =?UTF-8?q?=E4=B8=AD=E6=96=AD=E3=80=81=E5=88=B0=E4=BA=86=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E8=AE=A1=E6=95=B0=E7=85=A7=E5=AE=9E=E5=88=86?= =?UTF-8?q?=E5=BC=80=EF=BC=9A=E6=B2=A1=E8=B7=91=E7=9A=84=E9=A2=98=E5=8D=95?= =?UTF-8?q?=E7=8B=AC=E8=AE=A1=20not=5Frun=EF=BC=8Cstop=5Freason=20?= =?UTF-8?q?=E5=86=99=E5=81=9C=E4=B8=8B=E7=9A=84=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验收:评测停止或中断后,没跑的题计入 errors、done = total、stop_reason 为 null,读起来像全评完了、出了很多错。 原因:runner._counts 把 cancelled、skipped 都算进 done(TASK_DONE),cancelled 又算进 errors;停止、中断走 _close → _conclude(status, None, error),原因写进了 error、stop_reason 是 None;服务重启后的整理(startup、reconcile_stale) 和排队中就停掉(cancel_queued)只改了逐项状态,计数还是旧的、stop_reason 也没写。 改法: - _counts:done 只算真的跑过的(ok、partial、failed);停止、中断时没评完的(cancelled)和到了上限、止损之后没开评 的(skipped)单独计 not_run;errors 只算 failed;skipped 只算评了、但期望算不出、不评分的(verdict),不再混进没开评 的。done + not_run + running + pending = total。 - 停止、中断是「停下」不是「出错」:_close 把原因记进 stop_reason(STOPPED / INTERRUPTED),error 留空;整体出错照旧 记 error。到了上限、止损本来就写 stop_reason,不变。还没备好几臂就停下的那一支(bare)同样写 stop_reason 并重算计数。 - core/job_runtime 的 close_job、cancel_queued 加一个可选的 finish:状态改好、提交之前在同一个事务里补一步。评测的 startup、reconcile_stale、stop 用它记 stop_reason、按改好的逐项状态重算计数(_closing)。起草、剖析、探查不传, 行为不变(它们的测试全过)。 - stats.summarize 的 not_run 也只算没跑的(cancelled、skipped),出错的另列 errors(以前 not_run 里混着出错的)。 前端:评测页总览「N 项没评(停止、中断,或者到了上限、止损之后没开评)」和「M 项出错」分两行;类型补上 not_run、 errors 的说明。停下的原因本来就照 stop_reason 显示。 测试(test_eval_runner):停止之后 stop_reason 是「评测已停止」、error 为空,errors 为 0、done 是评完的题数、not_run 是其余,总表的 not_run、errors 同样分开;重启整理成 interrupted 时 stop_reason 是中断的说法、计数重算(not_run 1、 running 0);新增一条排队中就停掉的:not_run 等于题数、pending 归零;到了金额上限没开评的那题记 not_run、不记跳过。 去掉修复回跑挂 4 条。check-evals 的停止夹具照服务端改,核对停下的原因、不当成出错、没评的单列。评测、起草、剖析、 探查、定时维护相关的测试 548 条全过;tsc、check-copy 通过,check-evals 103 项全过。 --- backend/app/core/job_runtime.py | 25 +++++++-- backend/app/eval/runner.py | 62 ++++++++++++++++----- backend/app/eval/stats.py | 9 +-- backend/tests/test_eval_runner.py | 23 +++++++- frontend/src/lib/termsEval.ts | 3 +- frontend/src/pages/evals/EvaluationPage.tsx | 3 +- frontend/src/typesEval.ts | 5 ++ scripts/check-evals.mjs | 11 +++- 8 files changed, 111 insertions(+), 30 deletions(-) diff --git a/backend/app/core/job_runtime.py b/backend/app/core/job_runtime.py index 98b641a9..f1f55b10 100644 --- a/backend/app/core/job_runtime.py +++ b/backend/app/core/job_runtime.py @@ -22,8 +22,9 @@ 说明源上正有别的事,接口回 409;占到了,执行期间新建的任务排在它后面。**队里的事是开放的**:运行时不认具体哪几种, 每种在自己的模块里 register_lane_kind 登记一次(kind → 叫法),进队时只核对登记过——第 2 波的后台探查(introspect) 登记一行就排进同一条队,不用改这里;拼错的 kind 当场报错,不会悄悄排成一种谁都不认识的事。 -- **close_job / interrupt_on_startup**:收尾(还没结束的表记成某个状态,任务记成某个状态),和服务启动时的整理:排队、 - 进行中的标 interrupted,没结束的表记 cancelled。**不自动续跑**:复制出来的数据目录(检查脚本、验收副本)启动时不能 +- **close_job / interrupt_on_startup**:收尾(还没结束的表记成某个状态,任务记成某个状态;可选的 finish 在同一个事务里 + 补一步,评测拿它按改好的逐项状态重算计数、记停下的原因),和服务启动时的整理:排队、进行中的标 interrupted,没结束的 + 表记 cancelled。**不自动续跑**:复制出来的数据目录(检查脚本、验收副本)启动时不能 自己去调模型、查业务库,界面让人点「重试」接着做。 进程内登记、进程内的队:服务端是单进程部署,多进程部署时要换成库里的锁。 @@ -243,9 +244,16 @@ lanes = SourceLanes() # ========================================================================== +#: 收尾时在同一个事务里补写的那一步(close_job、cancel_queued 的 finish):表和任务的状态已经改好、还没提交 +Finish = Callable[[AsyncSession], Awaitable[None]] + + async def close_job(session: AsyncSession, job_model: Any, task_model: Any, job_id: str, job_status: str, - job_error: str | None, task_status: str, task_error: str | None) -> None: - """收尾:还没结束的表记 task_status(原因 task_error),任务(还没结束的话)记 job_status。提交。""" + job_error: str | None, task_status: str, task_error: str | None, *, + finish: Finish | None = None) -> None: + """收尾:还没结束的表记 task_status(原因 task_error),任务(还没结束的话)记 job_status。提交。 + + finish:提交前在同一个事务里再做的一步(评测按改好的逐项状态重算计数、记停下的原因)。起草、剖析、探查不传,照旧。""" now = utcnow() await session.execute( update(task_model).where(task_model.job_id == job_id, task_model.status.in_(OPEN)) @@ -253,12 +261,15 @@ async def close_job(session: AsyncSession, job_model: Any, task_model: Any, job_ await session.execute( update(job_model).where(job_model.id == job_id, job_model.status.in_(ACTIVE)) .values(status=job_status, error=job_error, finished_at=now).execution_options(synchronize_session=False)) + if finish is not None: + await finish(session) await session.commit() -async def cancel_queued(session: AsyncSession, job_model: Any, task_model: Any, job_id: str) -> bool: +async def cancel_queued(session: AsyncSession, job_model: Any, task_model: Any, job_id: str, *, + finish: Finish | None = None) -> bool: """停止一个排队中的任务:任务和没结束的表直接记 cancelled。已经被后台认领(开始了)返回 False,交给调用方按进行中 - 的停。""" + 的停。finish 同 close_job(只在真停了时做)。""" now = utcnow() claimed = await session.execute( update(job_model).where(job_model.id == job_id, job_model.status == "queued") @@ -269,6 +280,8 @@ async def cancel_queued(session: AsyncSession, job_model: Any, task_model: Any, await session.execute( update(task_model).where(task_model.job_id == job_id, task_model.status.in_(OPEN)) .values(status="cancelled", finished_at=now).execution_options(synchronize_session=False)) + if finish is not None: + await finish(session) await session.commit() return True diff --git a/backend/app/eval/runner.py b/backend/app/eval/runner.py index 03224dcd..3412c005 100644 --- a/backend/app/eval/runner.py +++ b/backend/app/eval/runner.py @@ -708,20 +708,26 @@ class EvalExecution(runtime.JobRun): .execution_options(synchronize_session=False)) await session.commit() + # 停止、中断是「停下」不是「出错」:原因记 stop_reason(error 留空);整体出错记 error + stopped = status in ("cancelled", "interrupted") try: await self._write(write) if not self.arms: async def bare(session: AsyncSession) -> None: - await session.execute(update(EvalRun).where( + ids = list((await session.execute(select(EvalRun.id).where( (EvalRun.id == self.job_id) | (EvalRun.pair_id == self.job_id), - EvalRun.status.in_(runtime.ACTIVE)) - .values(status=status, error=error, finished_at=utcnow()) - .execution_options(synchronize_session=False)) + EvalRun.status.in_(runtime.ACTIVE)))).scalars()) + await session.execute(update(EvalRun).where(EvalRun.id.in_(ids)) + .values(status=status, error=None if stopped else error, + stop_reason=error if stopped else None, finished_at=utcnow()) + .execution_options(synchronize_session=False)) + for run_id in ids: + await _recount(session, run_id) await session.commit() await self._write(bare) return - await self._conclude(status, None, error) + await self._conclude(status, error if stopped else None, None if stopped else error) except Exception: # noqa: BLE001 logger.exception("评测 %s 收尾时写库出错", self.job_id) @@ -793,16 +799,31 @@ def _sql_shapes(answer: Any) -> list[dict[str, Any]]: return out +#: 逐项结束了、却没有跑出结果的状态:cancelled 停止、中断时还没评完的,skipped 到了金额上限、止损之后没开评的 +NOT_RUN_STATUSES = ("cancelled", "skipped") + + def _counts(rows: Sequence[Mapping[str, Any]]) -> dict[str, int]: - out = {"total": len(rows), "done": 0, "running": 0, "pending": 0, "passed": 0, "failed": 0, "ungradable": 0, - "skipped": 0, "errors": 0} + """一臂的计数(EvalRun.counts)。几样各管各的,加起来不重: + + - done:评完了的(ok、partial)和评的时候出错的(failed)——真的跑过的;running、pending 是还在评、还没轮到的; + - not_run:停止、中断时还没评完,到了上限、止损之后没开评的(NOT_RUN_STATUSES)。以前这些算进 done 和 errors,停下的 + 评测就成了 done = total、一堆「出错」,读起来像全评完了、出了很多错; + - errors:出错的(failed:题目没了、评的时候出了意外),不含没跑的; + - passed / failed / ungradable / skipped:评完了的按结论分(skipped 是评了、但期望算不出、不评分,不含没开评的)。 + + done + not_run + running + pending = total。""" + out = {"total": len(rows), "done": 0, "running": 0, "pending": 0, "not_run": 0, "passed": 0, "failed": 0, + "ungradable": 0, "skipped": 0, "errors": 0} for r in rows: status = r.get("status") - if status in runtime.TASK_DONE: + if status in NOT_RUN_STATUSES: + out["not_run"] += 1 + elif status in runtime.TASK_DONE: out["done"] += 1 elif status in ("running", "pending"): out[status] += 1 - if status in ("failed", "cancelled"): + if status == "failed": out["errors"] += 1 verdict = r.get("verdict") if verdict == "pass": @@ -811,11 +832,21 @@ def _counts(rows: Sequence[Mapping[str, Any]]) -> dict[str, int]: out["failed"] += 1 elif verdict == "ungradable": out["ungradable"] += 1 - elif verdict == "skipped" or status == "skipped": + elif verdict == "skipped": out["skipped"] += 1 return out +def _closing(run_id: str, reason: str) -> runtime.Finish: + """runtime.close_job / cancel_queued 的 finish:同一个事务里记停下的原因(停止、中断),按改好的逐项状态重算计数。""" + async def finish(session: AsyncSession) -> None: + await session.execute(update(EvalRun).where(EvalRun.id == run_id).values(stop_reason=reason) + .execution_options(synchronize_session=False)) + await _recount(session, run_id) + + return finish + + async def _recount(session: AsyncSession, run_id: str) -> None: rows = (await session.execute(select(EvalResult.status, EvalResult.verdict).where( EvalResult.eval_run_id == run_id))).all() @@ -910,16 +941,16 @@ async def reconcile_stale(session: AsyncSession, rows: Sequence[EvalRun]) -> Non """读接口当场整理:记着排队、进行中,这个进程里却没在跑(服务重启过)的,照实标中断。""" stale = [r for r in rows if r.status in runtime.ACTIVE and not running(r)] for row in stale: - await runtime.close_job(session, EvalRun, EvalResult, row.id, "interrupted", INTERRUPTED, "cancelled", - INTERRUPTED) + await runtime.close_job(session, EvalRun, EvalResult, row.id, "interrupted", None, "cancelled", + INTERRUPTED, finish=_closing(row.id, INTERRUPTED)) await session.refresh(row) async def startup() -> int: """服务启动时:没结束的评测标中断(没结束的项记停止),不自动续跑。""" async def close(session: AsyncSession, job_id: str) -> None: - await runtime.close_job(session, EvalRun, EvalResult, job_id, "interrupted", INTERRUPTED, "cancelled", - INTERRUPTED) + await runtime.close_job(session, EvalRun, EvalResult, job_id, "interrupted", None, "cancelled", + INTERRUPTED, finish=_closing(job_id, INTERRUPTED)) return await runtime.interrupt_on_startup(EvalRun, close, running=manager.running) @@ -938,7 +969,8 @@ async def stop(run_id: str) -> bool: (EvalRun.id == leader) | (EvalRun.pair_id == leader)))).scalars()) done = False for arm in arms: - done = await runtime.cancel_queued(session, EvalRun, EvalResult, arm) or done + done = await runtime.cancel_queued(session, EvalRun, EvalResult, arm, + finish=_closing(arm, STOPPED)) or done return done diff --git a/backend/app/eval/stats.py b/backend/app/eval/stats.py index 395eace6..7e4f6a8e 100644 --- a/backend/app/eval/stats.py +++ b/backend/app/eval/stats.py @@ -1,8 +1,8 @@ """评测的汇总与统计(细案 01 做法 11、12;评测-D10)。所有汇总都在后端算好,界面直接显示(界面层不放逻辑:同一个数不能 有两套算法)。纯计算,输入是一次评测的逐题结果(EvalResult 的列,result_row 的形状)。 -- 准确率和可追溯准确率:分母是能判的题(通过 + 不通过);不能评分、跳过的单列,既不算对也不算错,没开评、停下、出错的 - 另列 not_run。都带 Wilson 95% 区间和样本数——题集小、模型有随机性,差两三题就可能被读成「变好了」「变差了」(评测-D10:只展示、不卡门)。 +- 准确率和可追溯准确率:分母是能判的题(通过 + 不通过);不能评分、跳过的单列,既不算对也不算错,没跑的(停止、中断、 + 到了上限、止损没开评)另列 not_run,出错的另列 errors。都带 Wilson 95% 区间和样本数——题集小、模型有随机性,差两三题就可能被读成「变好了」「变差了」(评测-D10:只展示、不卡门)。 - 作答路径、出具档位(声明了出具的按 formal / degraded / withheld,没声明的单列「探索问答」)、复核结论(verdict × severity) 和自动重来的次数、复核用时超过问数据页等待(25 秒)的题数。 - 结论裁判(打开了才有):supported / partial / contradicted / insufficient / 未判的句数;文字要点的判断单列。 @@ -79,7 +79,7 @@ def summarize(results: Sequence[Mapping[str, Any]], *, judge_on: bool = False) - from app.eval.score import SCORER_VERSION done = [r for r in results if r.get("status") in ("ok", "skipped", "failed", "cancelled", "partial")] - #: 评完了的(status=ok);没开评、停下、出错的单列成 not_run + #: 评完了的(status=ok);没跑的(停止、中断、到了上限、止损没开评)单列成 not_run,出错的单列成 errors(同 runner._counts) evaluated = [r for r in done if r.get("status") == "ok"] verdicts = Counter(r.get("verdict") or "none" for r in done) graded = [r for r in done if r.get("verdict") in ("pass", "fail")] @@ -118,7 +118,8 @@ def summarize(results: Sequence[Mapping[str, Any]], *, judge_on: bool = False) - "items": len(results), "done": len(done), "cases": len({r.get("case_id") for r in results}), "verdicts": {k: verdicts.get(k, 0) for k in ("pass", "fail", "ungradable", "skipped")}, - "not_run": len(done) - len(evaluated), + "not_run": sum(1 for r in done if r.get("status") in ("cancelled", "skipped")), + "errors": sum(1 for r in done if r.get("status") == "failed"), "accuracy": rate(len(passed), len(graded)), "traced_accuracy": rate(len(traced), len(graded)), "outcomes": dict(outcomes), diff --git a/backend/tests/test_eval_runner.py b/backend/tests/test_eval_runner.py index 61f3beb2..c6e0ee79 100644 --- a/backend/tests/test_eval_runner.py +++ b/backend/tests/test_eval_runner.py @@ -146,10 +146,14 @@ async def test_stop_then_retry_only_the_unfinished(monkeypatch): await asyncio.sleep(0.1) assert await runner.stop(run.id) row = await get(run.id) - assert row.status == "cancelled" and row.error == runner.STOPPED + # 停止是「停下」不是「出错」:原因记在 stop_reason;没跑的题单独计 not_run,不算进 errors,done 不再等于 total + assert row.status == "cancelled" and row.stop_reason == runner.STOPPED and row.error is None before = {r.id: (r.status, r.finished_at) for r in await results(run.id)} done = [rid for rid, (status, _) in before.items() if status == "ok"] assert done and any(status == "cancelled" for status, _ in before.values()) + assert row.counts["errors"] == 0 and row.counts["done"] == len(done) + assert row.counts["not_run"] == 4 - len(done) and row.counts["done"] + row.counts["not_run"] == row.counts["total"] + assert row.summary["not_run"] == 4 - len(done) and row.summary["errors"] == 0 assert not await runner.stop(run.id), "已经结束的停不了" stub(monkeypatch, delay=0.01) @@ -175,12 +179,25 @@ async def test_a_restart_marks_unfinished_evaluations_interrupted_and_does_not_r run_id = row.id assert await runner.startup() >= 1 row = await get(run_id) - assert row.status == "interrupted" and row.error == runner.INTERRUPTED + assert row.status == "interrupted" and row.stop_reason == runner.INTERRUPTED and row.error is None + assert row.counts["not_run"] == 1 and row.counts["running"] == 0 and row.counts["errors"] == 0, row.counts [item] = await results(run_id) assert item.status == "cancelled" assert not runner.manager.running(run_id), "不自动续跑" +async def test_stopping_a_queued_evaluation_counts_everything_as_not_run(monkeypatch): + """排队中就停掉(还没开始):每一题都没跑,stop_reason 写停止,计数跟着重算(以前还是建的时候那份:pending 照旧)。""" + stub(monkeypatch) + suite_id = await es.make_suite("none", cases=ask_cases(2)) + monkeypatch.setattr(runner.manager, "start", lambda *_a, **_k: None) # 不让它开跑:停在排队 + [run] = await start(suite_id) + assert await runner.stop(run.id) + row = await get(run.id) + assert row.status == "cancelled" and row.stop_reason == runner.STOPPED + assert (row.counts["not_run"], row.counts["pending"], row.counts["done"]) == (2, 0, 0), row.counts + + async def test_the_per_run_cap_stops_new_items(monkeypatch): stub(monkeypatch, cost=1.0) suite_id = await es.make_suite("none", cases=ask_cases(3), budget={"max_parallel": 1, "per_run_max_usd": 1.5}) @@ -190,6 +207,8 @@ async def test_the_per_run_cap_stops_new_items(monkeypatch): items = await results(run.id) assert [r.status for r in items] == ["ok", "ok", "skipped"] assert row.status == "partial" and row.stop_reason == runner.LIMIT_RUN.format(limit=1.5) + assert (row.counts["done"], row.counts["not_run"], row.counts["errors"], row.counts["skipped"]) == (2, 1, 0, 0), \ + "到了上限没开评的是没跑,不是跳过(跳过是评了、但期望算不出、不评分)" assert "金额上限" in items[2].error and row.summary["cost_usd"] == 2.0 diff --git a/frontend/src/lib/termsEval.ts b/frontend/src/lib/termsEval.ts index 0086c2bc..348f9404 100644 --- a/frontend/src/lib/termsEval.ts +++ b/frontend/src/lib/termsEval.ts @@ -411,7 +411,8 @@ export const EVAL_TEXT = { traced: '可追溯准确率', tracedHint: '必需的期望全部经引用对上(答案里的数点得开、来自查询的那一格或口径卡的指标)', verdicts: '结论', - notRun: (n: number) => `${formatNumber(n)} 项没评(停下、出错、到了上限)`, + notRun: (n: number) => `${formatNumber(n)} 项没评(停止、中断,或者到了上限、止损之后没开评)`, + errors: (n: number) => `${formatNumber(n)} 项出错`, cost: '花费', costHint: '助手(建图、挑表、复核)+ 写作(答案运行里的模型调用)+ 结论裁判;算期望不调模型', perCase: (v: string) => `每题约 ${v}`, diff --git a/frontend/src/pages/evals/EvaluationPage.tsx b/frontend/src/pages/evals/EvaluationPage.tsx index 1347302c..bef39bc5 100644 --- a/frontend/src/pages/evals/EvaluationPage.tsx +++ b/frontend/src/pages/evals/EvaluationPage.tsx @@ -221,7 +221,8 @@ function Overview({ run }: { run: EvalRun }) {

{distText(s.verdicts as Record, T.verdict)}

- {!!s.not_run &&

{O.notRun(s.not_run)}

} + {!!s.not_run &&

{O.notRun(s.not_run)}

} + {!!s.errors &&

{O.errors(s.errors)}

}

{O.outcomes}:{distText(s.outcomes as Record, T.outcome)}

{s.text && Object.keys(s.text).length > 0 &&

{O.text}:{distText(s.text as Record, T.textOutcome)}

}
diff --git a/frontend/src/typesEval.ts b/frontend/src/typesEval.ts index 4bb426fb..39d3e185 100644 --- a/frontend/src/typesEval.ts +++ b/frontend/src/typesEval.ts @@ -393,11 +393,14 @@ export interface EvalSpend { export type EvalStatus = 'queued' | 'running' | 'succeeded' | 'partial' | 'failed' | 'cancelled' | 'interrupted' export type EvalTrigger = 'manual' | 'schedule' | 'pair' +/** 一臂的计数(服务端 runner._counts):done 是真的跑过的(评完、出错),not_run 是停止、中断、到了上限、止损没跑的, + * errors 是出错的(不含没跑的);done + not_run + running + pending = total */ export interface EvalCounts { total?: number done?: number running?: number pending?: number + not_run?: number passed?: number failed?: number ungradable?: number @@ -428,7 +431,9 @@ export interface EvalSummary { done?: number cases?: number verdicts?: Partial> + /** 没跑的(停止、中断、到了上限、止损没开评);出错的另在 errors */ not_run?: number + errors?: number accuracy?: EvalRate traced_accuracy?: EvalRate outcomes?: Partial> diff --git a/scripts/check-evals.mjs b/scripts/check-evals.mjs index 9e70dcdf..db3f53b1 100644 --- a/scripts/check-evals.mjs +++ b/scripts/check-evals.mjs @@ -362,7 +362,11 @@ function reply(state, method, path, url, body) { } if (method === 'POST' && rest === '/stop') { write() - Object.assign(ev, { status: 'cancelled', running: false, current: [], stop_reason: null, + // 服务端(runner._counts、_close):停下的原因记 stop_reason、不当成出错;没跑的题单独计 not_run,不进 errors,done 不等于 total + Object.assign(ev, { status: 'cancelled', running: false, current: [], stop_reason: '评测已停止', error: null, + counts: { total: 3, done: 1, not_run: 2, running: 0, pending: 0, passed: 1, failed: 0, ungradable: 0, skipped: 0, errors: 0 }, + summary: { ...clone(SUMMARY), items: 3, done: 3, verdicts: { pass: 1, fail: 0, ungradable: 0, skipped: 0 }, not_run: 2, errors: 0, + accuracy: RATE(1, 1), traced_accuracy: RATE(1, 1) }, actions: { stop: false, retry: true, rescore: true, pin: false, unpin: false, open: 2, cleaned: false } }) return { json: ev } } @@ -743,6 +747,11 @@ await section('评测详情:进度、正在做什么、停止和重试、重 await page.locator('[data-eval-retry]').waitFor() check('停止之后:服务端说能重试,给「重试没做完的」,悬停写几项', (await page.locator('[data-eval-retry]').getAttribute('title')).includes('2 项') && writes(state, /\/stop$/).length === 1) + check('……停下的原因照服务端写「评测已停止」,不当成出错;没评的 2 项单列,不算出错', await until(async () => + (await page.locator('[data-eval-stop-reason]').count()) === 1) + && (await text(page, '[data-eval-stop-reason]')).includes('评测已停止') && await page.locator('[data-eval-error]').count() === 0 + && (await text(page, '[data-eval-not-run]')).startsWith('2 项没评') && await page.locator('[data-eval-errors]').count() === 0, + await text(page, '[data-eval-run-head]')) await page.locator('[data-eval-retry]').click() await until(() => writes(state, /\/retry$/).length === 1) check('重试:发了一次', writes(state, /\/retry$/).length === 1)