codebee 0.1.73 → 0.1.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +5 -4
- package/app/core/analytics.py +38 -0
- package/app/core/bookmeta.py +115 -14
- package/app/core/contracts.py +255 -0
- package/app/core/error_codes.py +9 -0
- package/app/core/health.py +16 -1
- package/app/core/jobs.py +72 -0
- package/app/core/manager.py +38 -14
- package/app/core/modelhub.py +104 -9
- package/app/core/openapi.py +154 -0
- package/app/core/pipeline.py +210 -18
- package/app/core/presence.py +77 -0
- package/app/core/publish/auto.py +11 -0
- package/app/core/retrieval.py +137 -0
- package/app/core/runner.py +101 -5
- package/app/core/store.py +72 -0
- package/app/core/story_tracking.py +275 -0
- package/app/main.py +240 -20
- package/app/mcp_server.py +75 -6
- package/app/ui/app.js +237 -43
- package/app/ui/i18n.js +34 -5
- package/app/ui/index.html +9 -0
- package/app/ui/style.css +32 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
|
|
|
6
6
|
|
|
7
7
|
## 未发布
|
|
8
8
|
|
|
9
|
+
## v0.1.76(2026-10-03)
|
|
10
|
+
|
|
11
|
+
- **连载「打磨永远不过」的三道结构性死闸拆了**(2026-10-03 不周山守村人 21–28 章脱纲空转实案:8 章章分全 8+ 全过、全局情节/节奏 5.0 不到线,两轮打磨重改的全是达标好章,病灶章没人碰,单轮 $12/6.9M tokens 打水漂):
|
|
12
|
+
- **aider 全局评审恒拒答**:超长指令落文件后提示词让模型「用读文件工具读」,被 glm-5.3 当提示注入直接拒答(十秒白卷)——全局有效评审永远只有 1 名,低于门禁「≥2 名」要求,数学上不可能通过。现在 aider 的超长指令改走 `--read` 参数由 CLI 自己读入会话(语义同交互式 `aider file.md`),不再指挥模型读文件。
|
|
13
|
+
- **打磨选章永远选不中病灶章**:选章按章分挑,而脱纲章的章分往往全部达标(章级评审只看局部、看不到章纲),全局评审点名的病灶章(成稿与锁定章纲脱轨的那几章)永远进不了打磨面。现在全局评审 major 点名的章优先进入打磨面,且点名章放开「不得改主线」禁令——按本章大纲要点推翻重写自创情节才是脱纲章的唯一修法,保事实微调恰恰修不动;点名原话与本章节拍一并注入重改提示词。
|
|
14
|
+
- **重评达标旧账不撤**:三处评审账本(全局 issues / 打磨章 issues / 章内修订回路 issues)都是只增不减——重写修好了、重评也过了线,第一轮的旧 majors 仍压着 `publishable=false`。现在三处统一「重评出分即换新账、评审链全挂保留旧账」(评审链挂了不代表书变差)。
|
|
15
|
+
- **续写批不再被「故事追踪漂移」误杀**:Windows 上章稿以 CRLF 入账(`_read_chapter` 保真读回),追踪文件再用文本模式落盘会把换行二次翻译成 `\r\r\n`,磁盘稿与状态摘要从此永久错位——下一批续写任务一开场就被漂移闸拦下「章节正文不一致」(0.1.71 引入起 Windows 全量中招,续写批无法续跑;test_continue_serial 自 09-30 起预存挂)。现在写侧归一 LF+字节落盘钉死换行,查侧按字节愈合比对、兼容新旧三种历史形态——已损坏的旧状态不用手工修复,同源即认;真被手工改过的内容照常拦截。
|
|
16
|
+
- **续写批全书评审不再「失忆」**:前作章节发布后归档成单文件(chapter-NN.md 不在场)时,全书一致性评审只看得到本批——现在从「已成稿/」找回前文,且 60k 字预算优先保本批完整(前文从尾部截取,越靠近本批越要紧);合并成书同样带回归档前文,续写批的「成书」不再静默丢掉此前所有章节;跨轮重复的问题清单去重,不再把同一处问题计成三四条 major。
|
|
17
|
+
- 回归:tests/test_serial_outline_polish.py(9 项)+ tests/test_serial_polish_named.py(13 项);tests/test_continue_serial.py 预存挂随 CRLF 修复转绿。
|
|
18
|
+
|
|
19
|
+
## v0.1.75(2026-10-03)
|
|
20
|
+
|
|
21
|
+
- **一键急停来了**:侧栏底部新增红色急停钮(有活跃任务才现身、确认框点名、busy 防双击)——`cancel_all` 全量扫描取消 queued/running 任务,终态不动,`cancelled_by_user` 先落防自动续跑续上(借鉴 hcom/amux 同类标配)。回归:tests/test_kill_all.py。
|
|
22
|
+
- **成品文件长出「第 N 版」徽章**:多版本成品一眼分先后——头部徽章(run 总数-列表序位)、对比 chip「与第 N 版对比」、diff 弹窗副标「第 N 版 → 当前」(run_id 摘要降为审计尾注;total 缺失不编不显示,纯增强不加闸门)。i18n 四键;回归:tests/ui_ver_badge.mjs。
|
|
23
|
+
- **作品信息「换名」按钮**:书名撞名只换名不重摇——排除名单=books.json 登记+全站 book_meta 书名,轻模型同步接口、成功才写盘;前端书名行换名钮+防双击+失败就地恢复。回归:tests/test_bookmeta_rename.py + tests/ui_bookmeta_rename.mjs。
|
|
24
|
+
- **用户作品数据不再漏进 npm 包**:工作目录里生成的 `作品信息-*.md`(用户书目资料,非代码)此前会被 `npm pack` 打进发布包——发版冒烟校验当场拦下(tar 内路径含非 ASCII 段即拒)。现在 .npmignore 增补排除(根目录与 app/ 两处;app/.npmignore 就近生效),文件本体不动。
|
|
25
|
+
- README 首屏 CLI 名册补齐 OpenClaw(catalog 14 条目中唯一漏列者)。
|
|
9
26
|
- **扫榜选材报告逐条带「证据分级」了**:热门题材/高频人设/差异化切入建议每一条末尾标注【证据 A-E】——A=三平台及以上重合、B=两平台重合、C=单平台多次出现、D=单平台仅一次、E=素材无直接证据系推断(借鉴 career-ops 的分级报告交付形态)。此前 AI 选题洞察里的「大热题材」不区分三平台重合的真风口和单平台单次的孤证,读者无从判断该信几分;现在分级只认四源榜单素材的真实出现情况,组合条目按组成元素较低档取值、拿不准就降不升级,报告第一行自带图例。菜单/流程说明与 README 同步。回归:tests/test_rank_scan_grading.py(新增)+ tests/test_rank_scan_copy.py。
|
|
10
27
|
- **作品信息「复制」按钮没用了的根因治了**:作品简介/书名/标签等字段旁的复制按钮点了只弹「已复制」、剪贴板却是空的——数据备份区在文件尾部又定义了一次 `copyText`(读 `data-copy` 属性的元素版),后定义覆盖了前定义,所有传字符串的复制入口(作品信息各字段、对话消息复制、详情页工作目录路径、预览页工作目录)一进函数就 TypeError 静默断掉,而 toast 照弹(async 函数把异常转成无人接的 rejected promise,不阻断后续代码),UI 测试只断言 toast 所以一直全绿。现在:重复定义移除,统一为单一字符串版 `copyText`(剪贴板 API 失败自动回退 execCommand 兜底——局域网 IP/远程地址打开页面时 `navigator.clipboard` 本就不存在,此前这条路也是死的);「已复制」只在真实写进剪贴板后弹,失败明说。回归:tests/ui_bookmeta.mjs 复制断言升级为对账剪贴板真拿到文本(旧断言抓不住这类假成功)。
|
|
11
28
|
- **禅道自动修复失败不再打扰业务流**:修复任务失败时此前会往禅道评论「自动修复未成功」并转派端负责人——失败只是中间态,不是修复结论,写回是拿工具故障打扰业务流,转派更是甩锅(2026-09-29 #27828 实案:评审器没跑起来也照评照转)。现在失败只记台账+群通知,回炉「待重试」状态等下轮扫描自动重试(只重建失败端、成功端不重跑,上限 3 次),到限置「修复失败」终态等人工;禅道页新增「立即处理」按钮,失败/需人工的记录不等下轮扫描随时手工重触发。全程零禅道写入,PUT 清空指派人的老坑彻底不再踩。回归:tests/test_zentao.py 53 项 + tests/ui_zentao.mjs。
|
|
@@ -38,6 +55,12 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
|
|
|
38
55
|
- 调度层加固:绑定链上的「纯模型条目」(只配模型名、没挂供应商)起跑前即被点名——所有 CLI 起跑日志新增凭据模式审计行(绑定链注入/无注入/本机默认,密钥值绝不入日志),缺凭据+登录闸门类错误按 CLI 附修复指引;不再只有 claude 有这层观测(2026-09-25 Mac claude 实案的三层加固通用化)。
|
|
39
56
|
- **Mac 桌宠不再是「白板蜜蜂」**:macOS 的 Tk 没有逐像素窗口透明(`-transparentcolor` 是 Windows 专属),蜜蜂的矩形底板整个露出来还带系统边框(实拍=一块白板)。现在非 Windows 平台把底板画成深色卡片(对齐悬停清单面板色),桌宠呈「桌面小组件」形态;睡觉/失联的状态滤镜改为只作用于蜂体(原先整帧压暗靠键色噪点兜底,卡片底兜不回来会浮出一块更暗的小框)。Windows 逐像素透明不受影响。
|
|
40
57
|
|
|
58
|
+
## v0.1.74(2026-09-30)
|
|
59
|
+
|
|
60
|
+
- 新增 `TOOL_LAUNCH` 错误码:本地 CLI 启动失败(找不到可执行文件)与供应商故障彻底区分——此前这类失败被记成供应商健康故障,把健康的 Bigmodel 评测作废、整链判死(claude 垫片被清实案)
|
|
61
|
+
- runner:Popen 异常统一包裹前缀「启动失败」零误判识别,先于一切上游分类
|
|
62
|
+
- 评测/重探间隔与失败探针刷新调优
|
|
63
|
+
|
|
41
64
|
## v0.1.73(2026-09-30)
|
|
42
65
|
|
|
43
66
|
- **建书假「已建书」三修**(我的马甲藏不住实案):建书未取得 book_id 先按书名对账补回、仍空则**不落台账**(空条目曾让界面亮「已建书」);校准遇空 book_id 先对账、找不回明报「可能尚未建成」,不再盲跑作品列表页误报「可能改版」;七猫补齐对账通道(resolve_book_id)
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
**AI 是引擎,你的经验是方向盘。** CodeBee 为你指挥一支跨厂商的执笔/编码小队:
|
|
12
12
|
统一调度本机已装的 AI 编码 CLI(Codex CLI、Claude Code、Qwen Code、OpenCode、Aider、
|
|
13
13
|
Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness、Gemini CLI、CodeBuddy CLI、
|
|
14
|
-
Trae Agent……),提供
|
|
14
|
+
Trae Agent、OpenClaw……),提供
|
|
15
15
|
**目标 → 自动拆解 → 智能路由 → 执行 → 客观验证 → 跨厂商评审 → 自动修复/换将 → 汇总报告**
|
|
16
16
|
的完整闭环。你不在时它们自动推进、自我打磨;需要你拍板的地方——质量闸门、任务分支的
|
|
17
17
|
采纳与丢弃——它们会亮起「待裁决」等你,绝不静默替你决定。
|
|
@@ -33,10 +33,11 @@ Trae Agent……),提供
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
35
|
<!-- relnotes:start -->
|
|
36
|
-
### 最新版更新内容(v0.1.
|
|
36
|
+
### 最新版更新内容(v0.1.76)
|
|
37
37
|
|
|
38
|
-
-
|
|
39
|
-
-
|
|
38
|
+
- 连载「打磨永远不过」死锁拆除:aider 全局评审拒答(恒 1 名评审 < 门禁 2 名)、打磨选章选不中脱纲病灶章、重评达标旧问题清单不撤——三道结构性死闸全修,全局点名章优先重改并放开禁改主线
|
|
39
|
+
- 续写批不再被「故事追踪漂移」误杀:Windows 换行二次翻译(\r\r\n)致磁盘稿与摘要永久错位的 0.1.71 起预存病根拔除,旧损坏状态免手工修复自动兼容
|
|
40
|
+
- 续写批全书评审找回前文(已成稿/归档)、合并成书不再静默丢掉此前章节
|
|
40
41
|
<!-- relnotes:end -->
|
|
41
42
|
|
|
42
43
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Task cost/quality rollups for operator views."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def summary(store):
|
|
8
|
+
by_task = defaultdict(lambda: {"runs": 0, "done": 0, "failed": 0,
|
|
9
|
+
"tokens": 0, "cost_usd": 0.0, "quality_sum": 0.0,
|
|
10
|
+
"quality_count": 0})
|
|
11
|
+
for run in store.list_runs(limit=None):
|
|
12
|
+
tid = run.get("task_id") or "_unowned"
|
|
13
|
+
row = by_task[tid]
|
|
14
|
+
row["runs"] += 1
|
|
15
|
+
row["done"] += int(run.get("status") == "done")
|
|
16
|
+
row["failed"] += int(run.get("status") == "failed")
|
|
17
|
+
row["tokens"] += int(run.get("tokens") or 0)
|
|
18
|
+
row["cost_usd"] += float(run.get("cost_usd") or 0)
|
|
19
|
+
verdict = run.get("verdict") or {}
|
|
20
|
+
quality = verdict.get("overall")
|
|
21
|
+
if isinstance(quality, (int, float)):
|
|
22
|
+
row["quality_sum"] += float(quality)
|
|
23
|
+
row["quality_count"] += 1
|
|
24
|
+
rows = []
|
|
25
|
+
for task_id, row in by_task.items():
|
|
26
|
+
item = dict(row)
|
|
27
|
+
item["task_id"] = task_id
|
|
28
|
+
item["cost_usd"] = round(item["cost_usd"], 6)
|
|
29
|
+
item["quality_avg"] = round(item.pop("quality_sum") / item.pop("quality_count"), 2) \
|
|
30
|
+
if item["quality_count"] else None
|
|
31
|
+
item.pop("quality_count", None)
|
|
32
|
+
rows.append(item)
|
|
33
|
+
rows.sort(key=lambda x: (-x["cost_usd"], x["task_id"]))
|
|
34
|
+
return {"tasks": rows, "totals": {
|
|
35
|
+
"runs": sum(x["runs"] for x in rows),
|
|
36
|
+
"tokens": sum(x["tokens"] for x in rows),
|
|
37
|
+
"cost_usd": round(sum(x["cost_usd"] for x in rows), 6),
|
|
38
|
+
}}
|
package/app/core/bookmeta.py
CHANGED
|
@@ -31,20 +31,6 @@ def template_only():
|
|
|
31
31
|
return str(os.environ.get("TUTTI_BOOKMETA_TEMPLATE_ONLY") or "").strip() in ("1", "true", "yes")
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def needs_book_meta(task):
|
|
35
|
-
"""这本书是否需要开书资料:连载首批(start_chapter <= 1)才需要。
|
|
36
|
-
|
|
37
|
-
开书资料属于「这本书」而不是某一批章节——续写批次沿用第一批的书名/简介/
|
|
38
|
-
标签即可,重复生成只会覆盖成同内容的近义版本。非连载任务一律不需要。"""
|
|
39
|
-
serial = (task or {}).get("serial")
|
|
40
|
-
if not isinstance(serial, dict):
|
|
41
|
-
return False
|
|
42
|
-
try:
|
|
43
|
-
return int(serial.get("start_chapter") or 1) <= 1
|
|
44
|
-
except (TypeError, ValueError):
|
|
45
|
-
return True
|
|
46
|
-
|
|
47
|
-
|
|
48
34
|
# 各平台建书表单的字段展示顺序(前端卡片与 Markdown 归档共用)。
|
|
49
35
|
# 番茄官方表单(2026-09 实抓):签约模式=连载模式/完本模式;阅读标签=主分类(必选1)
|
|
50
36
|
# +主题/角色/情节(各≤2);内容标签=情节(≤4)/情感(≤2)/人设(≤4)/世界观(≤1)。
|
|
@@ -795,6 +781,121 @@ def make_book_meta(task, platform, author_agent=None, log_path=None):
|
|
|
795
781
|
return meta, _apply_fix_note(meta, meta["source"])
|
|
796
782
|
|
|
797
783
|
|
|
784
|
+
# ---- 换名(2026-10-02 用户实案:生成的书名与本站已有作品重名,用户要的是
|
|
785
|
+
# 「换一个名字」而不是整套重摇——重摇会覆盖已核对过的分类/标签/简介)
|
|
786
|
+
|
|
787
|
+
RENAME_TIMEOUT = 120
|
|
788
|
+
# 排除名单注入上限:全站书名可能上百个,超出的按序丢弃(提示词预算保护;
|
|
789
|
+
# 排序后字母序前 80 个 + 调用方传入的近期撞名足够防撞,撞了还有重试兜底)
|
|
790
|
+
RENAME_EXCLUDE_LIMIT = 80
|
|
791
|
+
|
|
792
|
+
RENAME_PROMPT = """你是网文编辑。这本书《__OLD__》的书名与已有作品重名了,请另起一个新书名。
|
|
793
|
+
|
|
794
|
+
书的基本情况:
|
|
795
|
+
__MATERIAL__
|
|
796
|
+
|
|
797
|
+
硬性要求:
|
|
798
|
+
- 只输出一个 ```json 代码块:{"book_name": "新书名"},不要输出其他内容;
|
|
799
|
+
- 新书名不超过 15 个字,贴合题材、有网文点击欲,与旧书名同一题材但用词明显不同;
|
|
800
|
+
- 严禁与下面任何一本书重名,也不得只加「2」「新」「之」等后缀敷衍:
|
|
801
|
+
__EXCLUDE__"""
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def used_book_names():
|
|
805
|
+
"""本站全部已用书名(换名排除名单):已建书登记(books.json 的 title)
|
|
806
|
+
+ 所有任务 book_meta 各平台 done 态的 book_name。读取异常只降级不阻断
|
|
807
|
+
(名单不全最坏结果是换出来的名字仍撞,用户再点一次)。"""
|
|
808
|
+
from .publish import ledger
|
|
809
|
+
names = set()
|
|
810
|
+
try:
|
|
811
|
+
# books.json 结构:{task_id: {platform: {book_id, title, url, ...}}}
|
|
812
|
+
for by_plat in (ledger.load_books() or {}).values():
|
|
813
|
+
if not isinstance(by_plat, dict):
|
|
814
|
+
continue
|
|
815
|
+
for rec in by_plat.values():
|
|
816
|
+
t = str((rec or {}).get("title") or "").strip()
|
|
817
|
+
if t:
|
|
818
|
+
names.add(t)
|
|
819
|
+
except Exception:
|
|
820
|
+
pass
|
|
821
|
+
try:
|
|
822
|
+
from . import store
|
|
823
|
+
for task in store.list_tasks(limit=10 ** 9):
|
|
824
|
+
for entry in (task.get("book_meta") or {}).values():
|
|
825
|
+
if isinstance(entry, dict) and entry.get("status") == "done":
|
|
826
|
+
n = str((entry.get("data") or {}).get("book_name") or "").strip()
|
|
827
|
+
if n:
|
|
828
|
+
names.add(n)
|
|
829
|
+
except Exception:
|
|
830
|
+
pass
|
|
831
|
+
return names
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def rename_book(task, platform, old_name, exclude_names=()):
|
|
835
|
+
"""给已生成的作品信息换一个不重名的书名。返回 (new_name, "") 或 ("", 错误)。
|
|
836
|
+
|
|
837
|
+
只产出新名,不写盘——调用方负责更新 book_meta 与归档同步;这样模型失败
|
|
838
|
+
时现有数据原样保留(区别于整套生成的线程+状态机,轻调用同步返回即可)。
|
|
839
|
+
新名若仍撞排除名单,把撞名项并入名单重试一次。"""
|
|
840
|
+
from . import modelhub, planner, runner
|
|
841
|
+
old_name = str(old_name or "").strip()
|
|
842
|
+
exclude = {str(x).strip() for x in (exclude_names or ()) if str(x).strip()}
|
|
843
|
+
exclude.add(old_name)
|
|
844
|
+
goal_lines = [x.strip() for x in (task.get("goal") or "").splitlines() if x.strip()]
|
|
845
|
+
meta_lines = []
|
|
846
|
+
if goal_lines:
|
|
847
|
+
meta_lines.append("目标:" + goal_lines[0][:200])
|
|
848
|
+
try:
|
|
849
|
+
_, bible, _ = store_read_bible(task)
|
|
850
|
+
if bible:
|
|
851
|
+
meta_lines.append("故事圣经摘录:" + bible[:400])
|
|
852
|
+
except Exception:
|
|
853
|
+
pass
|
|
854
|
+
material = "\n".join(meta_lines) if meta_lines else "(素材缺失,按旧书名题材另起)"
|
|
855
|
+
orch = None
|
|
856
|
+
try:
|
|
857
|
+
orch = modelhub.resolve_orchestrator()
|
|
858
|
+
except Exception:
|
|
859
|
+
orch = None
|
|
860
|
+
if not orch:
|
|
861
|
+
return "", "编排者模型不可用,无法自动换名(可稍后重试)"
|
|
862
|
+
prov, model = orch
|
|
863
|
+
last = ""
|
|
864
|
+
for _ in range(2): # 首轮撞名单 → 带撞名项重试一次
|
|
865
|
+
ex_block = "\n".join("- " + n for n in sorted(exclude)[:RENAME_EXCLUDE_LIMIT])
|
|
866
|
+
prompt = (RENAME_PROMPT.replace("__OLD__", old_name)
|
|
867
|
+
.replace("__MATERIAL__", material)
|
|
868
|
+
.replace("__EXCLUDE__", ex_block or "(暂无)"))
|
|
869
|
+
# max_tokens 4000 对齐主生成(2026-10-03 实案:glm-5.3 思考计入
|
|
870
|
+
# max_tokens,给 400 三次全被思考烧穿 output 打满,JSON 没成形)
|
|
871
|
+
res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000,
|
|
872
|
+
timeout=RENAME_TIMEOUT)
|
|
873
|
+
planner._log_usage("bookmeta", "bookmeta-rename", task, res, model=model,
|
|
874
|
+
provider=prov.get("name", prov.get("id", "")),
|
|
875
|
+
provider_id=prov.get("id", ""))
|
|
876
|
+
if not res["ok"]:
|
|
877
|
+
last = str(res.get("error") or "模型调用失败")[:200]
|
|
878
|
+
continue
|
|
879
|
+
text = str(res.get("text") or "")
|
|
880
|
+
data = runner.extract_json(text)
|
|
881
|
+
new_name = _name(data.get("book_name") if isinstance(data, dict) else "", 15)
|
|
882
|
+
if not new_name:
|
|
883
|
+
# 兜底:思考烧预算时 JSON 可能没成形,正文里有《书名》/「书名」
|
|
884
|
+
# 形态就抠出来用——抠出的名字仍要过下面的排除名单检查
|
|
885
|
+
m = re.search(r"[《「]([^《》「」]{1,15})[》」]", text)
|
|
886
|
+
if m:
|
|
887
|
+
new_name = _name(m.group(1), 15)
|
|
888
|
+
if not new_name:
|
|
889
|
+
last = "返回内容无法解析出新书名"
|
|
890
|
+
continue
|
|
891
|
+
if new_name in exclude:
|
|
892
|
+
last = "新书名《%s》仍与本站已有作品重名" % new_name
|
|
893
|
+
exclude.add(new_name)
|
|
894
|
+
continue
|
|
895
|
+
return new_name, ""
|
|
896
|
+
return "", last or "换名失败,请稍后重试"
|
|
897
|
+
|
|
898
|
+
|
|
798
899
|
def render_markdown(task, platform, meta):
|
|
799
900
|
"""落工作目录的归档 Markdown(与前端弹框同一份字段顺序)。"""
|
|
800
901
|
plat = PLATFORMS[platform]["label"]
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Durable task contracts, evidence, approvals and completion receipts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import paths
|
|
12
|
+
|
|
13
|
+
_DIR = paths.DATA_DIR / "contracts"
|
|
14
|
+
_LOCK = threading.RLock()
|
|
15
|
+
_ID_RE = re.compile(r"^[A-Za-z][0-9A-Za-z_-]{0,99}$")
|
|
16
|
+
_CRITERIA_MAX = 40
|
|
17
|
+
_EVIDENCE_MAX = 300
|
|
18
|
+
_MISSING = object()
|
|
19
|
+
_CASE_KEYWORDS = {
|
|
20
|
+
"correctness": ("正确", "验证", "测试", "质量", "correct", "test", "verify"),
|
|
21
|
+
"performance": ("性能", "耗时", "成本", "token", "performance", "latency", "cost"),
|
|
22
|
+
"boundary": ("边界", "极端", "boundary", "edge"),
|
|
23
|
+
"exception": ("异常", "失败处理", "容错", "exception", "error", "failure"),
|
|
24
|
+
"readability": ("可读", "文档", "表达", "readability", "documentation"),
|
|
25
|
+
"security": ("安全", "权限", "注入", "security", "auth", "injection"),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _valid_id(task_id):
|
|
30
|
+
return bool(_ID_RE.fullmatch(str(task_id or "")))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _path(task_id):
|
|
34
|
+
if not _valid_id(task_id):
|
|
35
|
+
raise ValueError("任务 ID 无效")
|
|
36
|
+
return _DIR / (str(task_id) + ".json")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _now():
|
|
40
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _save(item):
|
|
44
|
+
p = _path(item["task_id"])
|
|
45
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
tmp = p.with_suffix(".tmp")
|
|
47
|
+
tmp.write_text(json.dumps(item, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
48
|
+
tmp.replace(p)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load(task_id):
|
|
52
|
+
try:
|
|
53
|
+
value = json.loads(_path(task_id).read_text(encoding="utf-8"))
|
|
54
|
+
return value if isinstance(value, dict) else None
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _actor(value):
|
|
60
|
+
return str(value or "local").strip()[:120] or "local"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create(task_id, payload=None):
|
|
64
|
+
"""Create or update a task's contract, preserving evidence and audit history."""
|
|
65
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
66
|
+
raw_criteria = payload.get("acceptance_criteria", _MISSING)
|
|
67
|
+
if raw_criteria is not _MISSING and not isinstance(raw_criteria, list):
|
|
68
|
+
raise ValueError("acceptance_criteria 必须是列表")
|
|
69
|
+
clean = []
|
|
70
|
+
for value in (raw_criteria if raw_criteria is not _MISSING else [])[:_CRITERIA_MAX]:
|
|
71
|
+
text = str(value or "").strip()[:500]
|
|
72
|
+
if text and text not in clean:
|
|
73
|
+
clean.append(text)
|
|
74
|
+
with _LOCK:
|
|
75
|
+
item = _load(task_id) or {
|
|
76
|
+
"version": 1, "task_id": str(task_id), "created_at": _now(),
|
|
77
|
+
"acceptance_criteria": [], "evidence": [], "approval_required": False,
|
|
78
|
+
"approval": None, "blocked_reason": "", "completion_receipt": None,
|
|
79
|
+
"audit": [], "status": "open",
|
|
80
|
+
}
|
|
81
|
+
# Partial updates must not erase an existing contract field merely
|
|
82
|
+
# because the caller omitted it (MCP/UI frequently send sparse patches).
|
|
83
|
+
if raw_criteria is not _MISSING:
|
|
84
|
+
item["acceptance_criteria"] = clean
|
|
85
|
+
if "approval_required" in payload:
|
|
86
|
+
approval_required = payload.get("approval_required")
|
|
87
|
+
if not isinstance(approval_required, bool):
|
|
88
|
+
raise ValueError("approval_required 必须是布尔值")
|
|
89
|
+
item["approval_required"] = approval_required
|
|
90
|
+
item["updated_at"] = _now()
|
|
91
|
+
item["audit"].append({"at": item["updated_at"], "actor": _actor(payload.get("actor")),
|
|
92
|
+
"action": "contract_updated",
|
|
93
|
+
"criteria_count": len(item.get("acceptance_criteria") or [])})
|
|
94
|
+
_save(item)
|
|
95
|
+
return item
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get(task_id):
|
|
99
|
+
with _LOCK:
|
|
100
|
+
return _load(task_id)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def add_evidence(task_id, evidence, actor="local"):
|
|
104
|
+
if not isinstance(evidence, dict):
|
|
105
|
+
raise ValueError("证据必须是对象")
|
|
106
|
+
status = str(evidence.get("status") or "unknown").lower()
|
|
107
|
+
if status not in ("passed", "failed", "unknown", "not_evaluated"):
|
|
108
|
+
raise ValueError("证据状态无效")
|
|
109
|
+
with _LOCK:
|
|
110
|
+
item = _load(task_id)
|
|
111
|
+
if not item:
|
|
112
|
+
item = create(task_id, {})
|
|
113
|
+
row = {"id": "ev-%s" % uuid.uuid4().hex[:20],
|
|
114
|
+
"criterion": str(evidence.get("criterion") or "")[:500], "status": status,
|
|
115
|
+
"summary": str(evidence.get("summary") or "")[:2000],
|
|
116
|
+
"source": str(evidence.get("source") or "manual")[:80], "at": _now(),
|
|
117
|
+
"actor": _actor(actor)}
|
|
118
|
+
item["evidence"].append(row)
|
|
119
|
+
item["evidence"] = item["evidence"][-_EVIDENCE_MAX:]
|
|
120
|
+
item["audit"].append({"at": row["at"], "actor": row["actor"],
|
|
121
|
+
"action": "evidence_added", "evidence_id": row["id"]})
|
|
122
|
+
item["updated_at"] = row["at"]
|
|
123
|
+
_save(item)
|
|
124
|
+
return item
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def record_acceptance_cases(task_id, cases, actor="system"):
|
|
128
|
+
"""Map acceptance-matrix cases onto matching user criteria.
|
|
129
|
+
|
|
130
|
+
Matching is deliberately conservative: only criteria containing a known
|
|
131
|
+
case keyword (or the explicit ``acceptance:<case>`` form) are linked. Any
|
|
132
|
+
unmatched criterion remains unverified rather than being guessed green.
|
|
133
|
+
"""
|
|
134
|
+
if not isinstance(cases, list):
|
|
135
|
+
return get(task_id)
|
|
136
|
+
item = get(task_id)
|
|
137
|
+
if not item:
|
|
138
|
+
return None
|
|
139
|
+
criteria = item.get("acceptance_criteria") or []
|
|
140
|
+
for case in cases:
|
|
141
|
+
if not isinstance(case, dict):
|
|
142
|
+
continue
|
|
143
|
+
name = str(case.get("case") or "").strip().lower()
|
|
144
|
+
status = str(case.get("status") or "unknown").lower()
|
|
145
|
+
if name not in _CASE_KEYWORDS or status not in ("passed", "failed", "unknown", "not_evaluated"):
|
|
146
|
+
continue
|
|
147
|
+
for criterion in criteria:
|
|
148
|
+
text = str(criterion or "").strip()
|
|
149
|
+
low = text.lower()
|
|
150
|
+
explicit = low == "acceptance:" + name
|
|
151
|
+
matched = explicit or any(keyword.lower() in low for keyword in _CASE_KEYWORDS[name])
|
|
152
|
+
if not matched:
|
|
153
|
+
continue
|
|
154
|
+
add_evidence(task_id, {"criterion": text, "status": status,
|
|
155
|
+
"summary": case.get("reason") or
|
|
156
|
+
"acceptance matrix: " + name,
|
|
157
|
+
"source": "acceptance"}, actor=actor)
|
|
158
|
+
return get(task_id)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _decision(task_id, status, actor, note):
|
|
162
|
+
with _LOCK:
|
|
163
|
+
item = _load(task_id)
|
|
164
|
+
if not item:
|
|
165
|
+
return None, "任务契约不存在"
|
|
166
|
+
decision = {"status": status, "actor": _actor(actor),
|
|
167
|
+
"note": str(note or "").strip()[:1000], "at": _now()}
|
|
168
|
+
item["approval"] = decision
|
|
169
|
+
item["status"] = "approved" if status == "approved" else "open"
|
|
170
|
+
item["audit"].append(dict(decision, action=status))
|
|
171
|
+
item["updated_at"] = decision["at"]
|
|
172
|
+
_save(item)
|
|
173
|
+
return item, None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def approve(task_id, actor="local", note=""):
|
|
177
|
+
return _decision(task_id, "approved", actor, note)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def reject(task_id, actor="local", note=""):
|
|
181
|
+
if not str(note or "").strip():
|
|
182
|
+
return None, "拒绝审批必须说明原因"
|
|
183
|
+
return _decision(task_id, "rejected", actor, note)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def set_blocked(task_id, reason, actor="local"):
|
|
187
|
+
reason = str(reason or "").strip()[:1000]
|
|
188
|
+
if not reason:
|
|
189
|
+
return None, "Blocked 状态必须填写原因"
|
|
190
|
+
with _LOCK:
|
|
191
|
+
item = _load(task_id)
|
|
192
|
+
if not item:
|
|
193
|
+
return None, "任务契约不存在"
|
|
194
|
+
item["status"] = "blocked"
|
|
195
|
+
item["blocked_reason"] = reason
|
|
196
|
+
item["updated_at"] = _now()
|
|
197
|
+
item["audit"].append({"at": item["updated_at"], "actor": _actor(actor),
|
|
198
|
+
"action": "blocked", "reason": reason})
|
|
199
|
+
_save(item)
|
|
200
|
+
return item, None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def release_allowed(task_id):
|
|
204
|
+
item = get(task_id)
|
|
205
|
+
# Task creation persists the approval bit redundantly for backwards
|
|
206
|
+
# compatibility. Combine both sources with OR so a damaged/tampered
|
|
207
|
+
# sidecar cannot downgrade an approval-protected task to releasable.
|
|
208
|
+
try:
|
|
209
|
+
from . import store
|
|
210
|
+
task = store.get_task(task_id) or {}
|
|
211
|
+
except Exception:
|
|
212
|
+
task = {}
|
|
213
|
+
requires = bool((item or {}).get("approval_required")) or bool(task.get("approval_required"))
|
|
214
|
+
if not requires:
|
|
215
|
+
return True
|
|
216
|
+
if item is None:
|
|
217
|
+
return False
|
|
218
|
+
approval = item.get("approval") or {}
|
|
219
|
+
return approval.get("status") == "approved"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def complete(task_id, run):
|
|
223
|
+
if not isinstance(run, dict) or run.get("status") not in ("done", "failed", "cancelled", "timeout"):
|
|
224
|
+
return None, "完成回执只能关联终态运行"
|
|
225
|
+
with _LOCK:
|
|
226
|
+
item = _load(task_id)
|
|
227
|
+
if not item:
|
|
228
|
+
item = create(task_id, {})
|
|
229
|
+
criteria = item.get("acceptance_criteria") or []
|
|
230
|
+
evidence = item.get("evidence") or []
|
|
231
|
+
latest = {}
|
|
232
|
+
for row in evidence:
|
|
233
|
+
if row.get("criterion"):
|
|
234
|
+
latest[row["criterion"]] = row
|
|
235
|
+
checked = [latest.get(c) for c in criteria]
|
|
236
|
+
receipt = {"version": 1, "task_id": str(task_id), "run_id": run.get("id"),
|
|
237
|
+
"run_status": run.get("status"), "criteria_total": len(criteria),
|
|
238
|
+
"criteria_passed": sum(1 for row in checked if row and row.get("status") == "passed"),
|
|
239
|
+
"criteria_failed": sum(1 for row in checked if row and row.get("status") == "failed"),
|
|
240
|
+
"criteria_unverified": sum(1 for row in checked if not row or row.get("status") not in ("passed", "failed")),
|
|
241
|
+
"evidence_ids": [row.get("id") for row in checked if row],
|
|
242
|
+
"cost_usd": float(run.get("cost_usd") or 0), "tokens": int(run.get("tokens") or 0),
|
|
243
|
+
"completed_at": _now()}
|
|
244
|
+
# Completion is idempotent for the same run. A late duplicate status
|
|
245
|
+
# write must not manufacture a second audit event or mutate the
|
|
246
|
+
# original cost/token receipt.
|
|
247
|
+
previous = item.get("completion_receipt") or {}
|
|
248
|
+
if previous.get("run_id") == receipt["run_id"]:
|
|
249
|
+
return previous, None
|
|
250
|
+
item["completion_receipt"] = receipt
|
|
251
|
+
item["audit"].append({"at": receipt["completed_at"], "actor": "system",
|
|
252
|
+
"action": "completion_receipt_created", "run_id": run.get("id")})
|
|
253
|
+
item["updated_at"] = receipt["completed_at"]
|
|
254
|
+
_save(item)
|
|
255
|
+
return receipt, None
|
package/app/core/error_codes.py
CHANGED
|
@@ -37,6 +37,7 @@ class ErrorCode(str, Enum):
|
|
|
37
37
|
RATE_LIMIT = "RATE_LIMIT" # 短时速率/并发限制(429)
|
|
38
38
|
LOCAL_STATE = "LOCAL_STATE" # 本地 CLI 状态/锁冲突
|
|
39
39
|
CONTEXT_OVERFLOW = "CONTEXT_OVERFLOW" # 触发压缩(与 MAX_TOKENS 不同)
|
|
40
|
+
TOOL_LAUNCH = "TOOL_LAUNCH" # 本地 CLI 启动失败(找不到可执行文件)
|
|
40
41
|
|
|
41
42
|
# === ok ===
|
|
42
43
|
# "" 表示成功,不设枚举值
|
|
@@ -91,6 +92,11 @@ _NETWORK = ("stream disconnected", "stream closed", "connection reset", "connect
|
|
|
91
92
|
"输出停滞")
|
|
92
93
|
_LOCAL_STATE = ("database is locked", "database locked", "sqlite busy",
|
|
93
94
|
"unable to open database file")
|
|
95
|
+
# run_process 对 Popen 异常的统一包裹前缀("启动失败: %r")。供应商侧任何
|
|
96
|
+
# 报错都不会长这个样子,所以单靠前缀即可零误判地识别「本地工具链起不来」。
|
|
97
|
+
# 2026-09-30 claude 垫片被清案:这类失败被记成供应商健康故障,把健康的
|
|
98
|
+
# Bigmodel 评测作废、整链判死——必须先于一切上游分类识别。
|
|
99
|
+
_TOOL_LAUNCH = ("启动失败",)
|
|
94
100
|
_TRANSIENT_HINTS = ("stream closed before response.completed", "output stall", "timed out",
|
|
95
101
|
"timeout", "output stalled", "ENOTFOUND", "Reconnecting...",
|
|
96
102
|
"initialize", "[1211]")
|
|
@@ -116,6 +122,8 @@ def classify_error_text(error):
|
|
|
116
122
|
text = str(error or "").lower()
|
|
117
123
|
if not text:
|
|
118
124
|
return None
|
|
125
|
+
if any(marker in text for marker in _TOOL_LAUNCH):
|
|
126
|
+
return ErrorCode.TOOL_LAUNCH
|
|
119
127
|
if re.search(r"(?:http(?:/\d(?:\.\d)?)?\s*|status(?:\s+code)?\s*[:=]?\s*)401\b", text):
|
|
120
128
|
return ErrorCode.AUTH
|
|
121
129
|
# An explicit HTTP 403 is authoritative even when the provider describes the
|
|
@@ -196,6 +204,7 @@ _SAFE_ERROR_SUMMARIES = {
|
|
|
196
204
|
ErrorCode.RATE_LIMIT: "上游触发速率或并发限制",
|
|
197
205
|
ErrorCode.LOCAL_STATE: "CLI 本地状态或数据库锁冲突",
|
|
198
206
|
ErrorCode.CONTEXT_OVERFLOW: "输入上下文超出模型容量",
|
|
207
|
+
ErrorCode.TOOL_LAUNCH: "本地 CLI 启动失败(可执行文件不存在,与供应商无关)",
|
|
199
208
|
}
|
|
200
209
|
|
|
201
210
|
|
package/app/core/health.py
CHANGED
|
@@ -141,11 +141,26 @@ def report_success(provider: str):
|
|
|
141
141
|
_persist()
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
# run_process 包裹 Popen 启动异常的固定前缀。与 error_codes._TOOL_LAUNCH 的
|
|
145
|
+
# 判定标记同源双写(error_codes 在 L0,health 在 L1,策略禁止 L1→L0 import;
|
|
146
|
+
# 供应商侧任何报错都不会带这个前缀,单靠前缀即可零误判)。
|
|
147
|
+
_LAUNCH_FAILURE_PREFIX = "启动失败"
|
|
148
|
+
|
|
149
|
+
|
|
144
150
|
def report_failure(provider: str, error: str = "", *, model: str = "",
|
|
145
151
|
provider_id: str = ""):
|
|
146
|
-
"""一次真实失败调用。provider 用展示名(与 usage 台账一致)。
|
|
152
|
+
"""一次真实失败调用。provider 用展示名(与 usage 台账一致)。
|
|
153
|
+
|
|
154
|
+
本地 CLI 启动失败(找不到可执行文件等)与供应商无关,记进来会把健康
|
|
155
|
+
供应商的评测结论作废、链被解析层过滤成死链——2026-09-30 claude 垫片被
|
|
156
|
+
清案:Bigmodel 全程健康却被连续启动失败拖进 down。只打日志提醒修本地
|
|
157
|
+
环境,不进健康台账。"""
|
|
147
158
|
if not provider:
|
|
148
159
|
return
|
|
160
|
+
if _LAUNCH_FAILURE_PREFIX in str(error or "")[:80]:
|
|
161
|
+
log.warning("[health] 本地 CLI 启动失败(与供应商 %s 无关,不记健康故障):%s",
|
|
162
|
+
provider, str(error or "")[:160])
|
|
163
|
+
return
|
|
149
164
|
|
|
150
165
|
# A failed live call invalidates capability evidence for this upstream.
|
|
151
166
|
# Keep this hook lazy and best-effort: health telemetry must never make a
|
package/app/core/jobs.py
CHANGED
|
@@ -422,6 +422,56 @@ def _schedule_enqueue(job, delay_s):
|
|
|
422
422
|
return True
|
|
423
423
|
|
|
424
424
|
|
|
425
|
+
def enqueue_now(run_id):
|
|
426
|
+
"""立即重试:退避窗口内的续跑副本跳过等待马上入队(详情页按钮触发)。
|
|
427
|
+
|
|
428
|
+
返回 (ok, err)。先摘到点 Timer 再直接 enqueue——CAS 认领保证与 Timer
|
|
429
|
+
竞态时只有一方生效,Timer 抢先起跑按目的已达成处理。resume_enqueue_at
|
|
430
|
+
同步清空:副本一旦入队/起跑,UI 不该再显示「将于 HH:MM 自动续跑」;
|
|
431
|
+
满载转排队后由补跑心跳接管,巡检也把它当普通排队记录。跳过退避是
|
|
432
|
+
用户的主动选择(等效于手动重试),不受「别烧退避窗口」的巡检约束。"""
|
|
433
|
+
from . import store
|
|
434
|
+
run = store.get_run(run_id)
|
|
435
|
+
if not run:
|
|
436
|
+
return False, "运行不存在"
|
|
437
|
+
if run.get("status") != "queued":
|
|
438
|
+
return False, "该运行已不在排队等待中"
|
|
439
|
+
if not str(run.get("resume_enqueue_at") or ""):
|
|
440
|
+
return False, "该运行不在自动续跑等待中"
|
|
441
|
+
if str(run.get("kind") or "orchestration") != "orchestration" or not run.get("task_id"):
|
|
442
|
+
return False, "该运行不支持立即重试"
|
|
443
|
+
resume_at = str(run.get("resume_enqueue_at") or "")
|
|
444
|
+
with _timer_lock:
|
|
445
|
+
old = _deferred_timers.pop(run_id, None)
|
|
446
|
+
if old is not None:
|
|
447
|
+
old.cancel()
|
|
448
|
+
if store.update_run(run_id, resume_enqueue_at="") is None:
|
|
449
|
+
if old is not None:
|
|
450
|
+
_schedule_enqueue({"kind": "orchestration", "run_id": run_id,
|
|
451
|
+
"task_id": run.get("task_id")}, 1.0)
|
|
452
|
+
return False, "运行状态已变化,请刷新后重试"
|
|
453
|
+
try:
|
|
454
|
+
enqueue({"kind": "orchestration", "run_id": run_id,
|
|
455
|
+
"task_id": run.get("task_id")})
|
|
456
|
+
except DuplicateJobError:
|
|
457
|
+
pass # 到点 Timer 抢先起跑/运行已被取消接管:无需再排
|
|
458
|
+
except Exception as exc:
|
|
459
|
+
# A non-duplicate enqueue failure must not strand the run with the
|
|
460
|
+
# backoff marker cleared and its original timer removed.
|
|
461
|
+
current = store.get_run(run_id)
|
|
462
|
+
if current and current.get("status") == "queued":
|
|
463
|
+
store.update_run(run_id, resume_enqueue_at=resume_at)
|
|
464
|
+
try:
|
|
465
|
+
import time as _time
|
|
466
|
+
due = _time.mktime(_time.strptime(resume_at, "%Y-%m-%d %H:%M:%S"))
|
|
467
|
+
_schedule_enqueue({"kind": "orchestration", "run_id": run_id,
|
|
468
|
+
"task_id": run.get("task_id")}, max(0.0, due - _time.time()))
|
|
469
|
+
except Exception:
|
|
470
|
+
pass
|
|
471
|
+
return False, "立即重试失败,已恢复自动续跑:%s" % str(exc)[:160]
|
|
472
|
+
return True, ""
|
|
473
|
+
|
|
474
|
+
|
|
425
475
|
def restore_deferred_resumes(limit=None, now=None):
|
|
426
476
|
"""重建重启前的自动续跑退避 Timer;返回成功恢复的数量。"""
|
|
427
477
|
import time as _t
|
|
@@ -551,6 +601,28 @@ def cancel(run_id):
|
|
|
551
601
|
return False
|
|
552
602
|
|
|
553
603
|
|
|
604
|
+
def cancel_all():
|
|
605
|
+
"""急停(kill switch):取消全部 queued/running 运行,返回实际取消数。
|
|
606
|
+
|
|
607
|
+
逐跑走 cancel() 同一条链路(事件置位 + 终态落盘 + 退避定时器清理),
|
|
608
|
+
单跑失败不影响其余。cancelled_by_user 先落——与单跑取消端点同一语义,
|
|
609
|
+
自动续跑不得把急停的任务又续上。
|
|
610
|
+
"""
|
|
611
|
+
from . import store
|
|
612
|
+
n = 0
|
|
613
|
+
for r in store.list_runs(limit=None):
|
|
614
|
+
rid = r.get("id")
|
|
615
|
+
if not rid or (r.get("status") or "") not in ("queued", "running"):
|
|
616
|
+
continue
|
|
617
|
+
try:
|
|
618
|
+
store.update_run(rid, cancelled_by_user=True)
|
|
619
|
+
except Exception:
|
|
620
|
+
pass
|
|
621
|
+
if cancel(rid):
|
|
622
|
+
n += 1
|
|
623
|
+
return n
|
|
624
|
+
|
|
625
|
+
|
|
554
626
|
def cancel_event_for(run_id):
|
|
555
627
|
ev = CANCELS.get(run_id)
|
|
556
628
|
if ev is None:
|