Fix job sheet stop races and early result receipts

This commit is contained in:
DemonGatanjieu
2026-11-10 21:02:29 +08:00
parent c9c9e5b21e
commit 20b3d6344e
4 changed files with 118 additions and 14 deletions
+4 -1
View File
@@ -272,7 +272,10 @@ covered by the `styles.css` manifest.
rules (`tests/job_sheet_plan.mjs`). `api/job_sheets.py` keeps the one sheet and where it stands, hears the page's
reports, passes pause / stop / go on to it (from any page, the workbench's stop or the AI's stop_run; with its page
gone another page takes it over) and writes a sheet the AI gave with `run_workflow(sheet=…)` on that call's run step,
so run_status and the workbench follow it like a run (`tests/test_job_sheets.py`).
so run_status and the workbench follow it like a run (`tests/test_job_sheets.py`). Clearing or replacing a stale
sheet stops its registered pending runs; a stop wins over a late pause report, and a queued run reported after
stopping is cancelled. Attaching the AI step carries over the sheet's recent pictures once. Interrupted and
removed runs count as unsuccessful (`tests/test_job_sheet_edges.py`).
- A model's author text from Civitai in its details opens folded as the user last left it and can be translated
into the window's language: `web/modules/detail_intro.js` (`tests/detail_intro.mjs`) keeps the fold and picks the
language, `api/description_translation.py` translates it paragraph by paragraph through
+3
View File
@@ -2,6 +2,9 @@
## v1.64.0 — unreleased
- 任务单停止时,刚提交、迟到回报的运行也会取消,暂停回报不会覆盖停止请求;清除或替换失联任务单会同时停止它已登记的运行。很快完成、早于 AI 步骤绑定的图片会补回工作台,重复绑定不重复添图;中断或被移出队列的运行计入未成功次数。
- Stopping a job sheet also cancels runs reported just after the stop; a pause report cannot erase a stop request. Clearing or replacing a stale sheet stops its registered runs. Pictures completed before the AI step was attached are carried into the workbench without duplicates on reattachment; interrupted or removed runs count as unsuccessful.
- 没有 C 站触发词的 LoRA(自己炼的、从别处下的),扫描时会从模型文件里读出训练图片大多都带的标签(作者写了触发词就用作者的),和 C 站的触发词分开存,搜模型时也能用这些词搜到。
- For a LoRA without trigger words from Civitai (trained yourself, downloaded elsewhere), a scan reads from the file the tags most of its training pictures share (or the trigger phrase its maker wrote) and keeps them apart from Civitai's trigger words; model search finds them too.
- 从模型文件认底模时分得清 Flux.1 Dev 和 Schnell(Schnell 没有引导输入),也认得出 Z-Image 和 Lumina 2 这两种结构相近的模型:以前 Z-Image 的 GGUF 被当成 Lumina,safetensors 两种都认不出。已经扫过的模型重新扫描后才会更新。
+18 -13
View File
@@ -150,7 +150,8 @@ def workflow():
def _end_old(sheet):
"""An unfinished sheet a new one replaces: it ends stopped, on its step too."""
if sheet and sheet["state"] not in ENDED:
sheet.update(state="stopped", want=None, note=sheet.get("note") or "replaced")
_drop(sheet["pending"])
sheet.update(state="stopped", want=None, ended=time.time(), note=sheet.get("note") or "replaced")
_sync_step(sheet)
@@ -213,7 +214,7 @@ def _drop(prompt_ids):
def report(body):
"""Word from the page running the sheet: {id, page, at, queued: [[prompt id, run]], finished: [[prompt id,
outcome]], state?, note?}; outcome "ok", "error" or "gone" (taken out of the queue). Answers the sheet,
outcome]], state?, note?}; outcome "ok", "error", "interrupted" or "gone" (taken out of the queue). Answers the sheet,
whose `want` says what was asked of the page."""
pictures, dropped = [], []
with _lock:
@@ -222,24 +223,28 @@ def report(body):
raise SheetError("This sheet is gone.")
if sheet["page"] != body.get("page"):
raise SheetError("Another page runs this sheet now.")
if sheet["state"] in ENDED:
return public(sheet)
sheet["heard"] = time.time()
ended = sheet["state"] in ENDED
known = {pid for pid, _ in sheet["ids"]}
for item in body.get("queued") or []:
if (isinstance(item, list) and len(item) == 2 and isinstance(item[0], str) and 0 < len(item[0]) <= 128
and isinstance(item[1], int) and item[0] not in known):
sheet["ids"].append(item)
sheet["pending"].append(item[0])
if not ended:
sheet["ids"].append(item)
sheet["pending"].append(item[0])
known.add(item[0])
if sheet["want"] == "stop":
if sheet["want"] == "stop" or sheet["state"] == "stopped":
dropped.append(item[0]) # queued as stop was asked: it goes too
if ended:
_drop(dropped) # queuePrompt can return after the page's own Stop has already ended the sheet
return public(sheet)
sheet["heard"] = time.time()
for item in body.get("finished") or []:
if not (isinstance(item, list) and len(item) == 2 and item[0] in sheet["pending"]):
if not (isinstance(item, list) and len(item) == 2 and item[0] in sheet["pending"]
and item[1] in ("ok", "error", "interrupted", "gone")):
continue
sheet["pending"].remove(item[0])
sheet["done"] += 1
if item[1] == "error":
if item[1] != "ok":
sheet["failed"] += 1
made = _pictures(item[0]) if item[1] == "ok" else []
pictures += made
@@ -249,7 +254,7 @@ def report(body):
at = body.get("at")
if isinstance(at, int) and 0 <= at <= sheet["total"]:
sheet["at"] = at
state = body.get("state")
state = "stopped" if sheet["want"] == "stop" else body.get("state")
if state in ("running", "paused", "stopped", "done"):
if state != sheet["state"]:
sheet["want"] = None
@@ -330,7 +335,7 @@ def attach(sheet_id, step_id):
return
sheet.update(task=task_id, step=step_id)
atomic_write_json(_path(SHEET), sheet)
_sync_step(sheet)
_sync_step(sheet, sheet["last"])
def _words(sheet):
@@ -354,7 +359,7 @@ def _sync_step(sheet, pictures=()):
if pictures:
kept = [item for item in step["results"] if item["type"] != "image"]
shown = [item for item in step["results"] if item["type"] == "image"]
step["results"] = kept + (shown + list(pictures))[-ai_tasks.MAX_RESULTS:]
step["results"] = kept + (shown + [item for item in pictures if item not in shown])[-ai_tasks.MAX_RESULTS:]
if sheet["state"] not in ENDED:
step["state"] = "running"
paused = " · 暂停 / paused" if sheet["state"] == "paused" else ""
+93
View File
@@ -0,0 +1,93 @@
"""Job-sheet cancellation and receipts when the page and MCP return in a different order."""
import sys
import time
import unittest
from pathlib import Path
from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1]), str(PLUGIN / "tests")]
from api import ai_tasks, job_sheets # noqa: E402
from test_job_sheets import FLOW, OTHER, PAGE, Queue, isolate, picture # noqa: E402
class SheetEdgeTests(unittest.TestCase):
def setUp(self):
isolate()
self.queue = Queue()
patch = mock.patch("api.ai_task_runs._queue", return_value=self.queue)
patch.start()
self.addCleanup(patch.stop)
def start(self, **given):
return job_sheets.start({"rows": ["a cat"], "workflow": FLOW, "page": PAGE, **given})
def report(self, sheet, **fields):
return job_sheets.report({"id": sheet["id"], "page": PAGE, **fields})
def step(self, task):
result = {"structuredContent": {}, "content": [{"type": "text", "text": "ok"}]}
return ai_tasks.finish_step("AI", "run_workflow", {"sheet": {}}, result, time.time(), task_id=task)
def test_clearing_a_stale_sheet_stops_its_pending_runs(self):
sheet = self.start()
self.report(sheet, queued=[["old-run", 0]], at=1)
with mock.patch.object(job_sheets.time, "time", return_value=time.time() + job_sheets.STALE + 1):
job_sheets.clear(sheet["id"])
self.assertEqual(self.queue.interrupted, ["old-run"])
self.assertTrue(self.queue.deleted[0]((0, "old-run")))
self.assertFalse(self.queue.deleted[0]((0, "someone-else")))
self.assertIsNone(job_sheets.current())
def test_replacing_a_stale_sheet_stops_only_its_runs(self):
sheet = self.start()
self.report(sheet, queued=[["old-run", 0]], at=1)
with mock.patch.object(job_sheets.time, "time", return_value=time.time() + job_sheets.STALE + 1):
new = self.start(page=OTHER)
self.assertEqual(self.queue.interrupted, ["old-run"])
self.assertEqual(job_sheets.current()["id"], new["id"])
self.assertNotEqual(new["id"], sheet["id"])
def test_a_late_queue_receipt_after_stop_is_cancelled(self):
sheet = self.start()
job_sheets.ask(sheet["id"], "stop", PAGE)
now = self.report(sheet, queued=[["late-run", 0]], at=1, state="running")
self.assertEqual(self.queue.interrupted, ["late-run"])
self.assertEqual(now["state"], "stopped")
self.assertEqual(job_sheets.current()["state"], "stopped")
def test_a_report_of_pause_does_not_erase_a_stop_request(self):
sheet = self.start()
job_sheets.ask(sheet["id"], "stop", OTHER)
now = self.report(sheet, state="paused")
self.assertTrue(now["state"] == "stopped" or now["want"] == "stop")
def test_binding_the_ai_step_keeps_earlier_pictures_once(self):
task = ai_tasks.start_task("fast sheet")["id"]
sheet = self.start(task=task)
self.queue.history["fast-run"] = picture("cached.png")
self.report(sheet, queued=[["fast-run", 0]], finished=[["fast-run", "ok"]], at=1, state="done")
step = self.step(task)
job_sheets.attach(sheet["id"], step["id"])
job_sheets.attach(sheet["id"], step["id"])
shown = next(item for item in ai_tasks.get_task(task)["steps"] if item["id"] == step["id"])
self.assertEqual([item["filename"] for item in shown["results"] if item["type"] == "image"], ["cached.png"])
self.assertEqual(shown["state"], "done")
def test_non_successful_runs_do_not_become_a_successful_sheet(self):
for outcome in ("interrupted", "gone"):
with self.subTest(outcome=outcome):
isolate()
task = ai_tasks.start_task("interrupted sheet")["id"]
sheet = self.start(task=task)
step = self.step(task)
job_sheets.attach(sheet["id"], step["id"])
self.report(sheet, queued=[["p1", 0]], finished=[["p1", outcome]], at=1, state="done")
shown = next(item for item in ai_tasks.get_task(task)["steps"] if item["id"] == step["id"])
self.assertEqual(shown["state"], "error")
self.assertFalse(shown["ok"])
if __name__ == "__main__":
unittest.main()