codebee 0.1.72 → 0.1.74
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 +12 -0
- package/README.md +4 -4
- package/app/core/analytics.py +38 -0
- package/app/core/bookmeta.py +0 -14
- package/app/core/contracts.py +255 -0
- package/app/core/error_codes.py +9 -0
- package/app/core/flows.py +1 -1
- package/app/core/health.py +14 -1
- package/app/core/jobs.py +54 -1
- package/app/core/manager.py +106 -17
- package/app/core/market.py +2 -2
- package/app/core/modelhub.py +104 -9
- package/app/core/openapi.py +154 -0
- package/app/core/paihang.py +8 -1
- package/app/core/pipeline.py +41 -0
- package/app/core/planner.py +2 -0
- package/app/core/plugins.py +34 -0
- package/app/core/presence.py +77 -0
- package/app/core/publish/auto.py +11 -0
- package/app/core/publish/flow.py +54 -3
- package/app/core/publish/manager.py +215 -28
- package/app/core/publish/qimao.py +30 -0
- package/app/core/retrieval.py +137 -0
- package/app/core/runner.py +85 -2
- package/app/core/skillpacks/fanqie-novel.md +66 -48
- package/app/core/skillpacks/qimao-signing.md +70 -59
- package/app/core/store.py +72 -0
- package/app/core/story_tracking.py +241 -0
- package/app/main.py +205 -48
- package/app/mcp_server.py +75 -6
- package/app/ui/app.js +390 -101
- package/app/ui/i18n.js +36 -7
- package/app/ui/index.html +8 -3
- package/app/ui/style.css +7059 -7040
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,8 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
|
|
|
6
6
|
|
|
7
7
|
## 未发布
|
|
8
8
|
|
|
9
|
+
- **扫榜选材报告逐条带「证据分级」了**:热门题材/高频人设/差异化切入建议每一条末尾标注【证据 A-E】——A=三平台及以上重合、B=两平台重合、C=单平台多次出现、D=单平台仅一次、E=素材无直接证据系推断(借鉴 career-ops 的分级报告交付形态)。此前 AI 选题洞察里的「大热题材」不区分三平台重合的真风口和单平台单次的孤证,读者无从判断该信几分;现在分级只认四源榜单素材的真实出现情况,组合条目按组成元素较低档取值、拿不准就降不升级,报告第一行自带图例。菜单/流程说明与 README 同步。回归:tests/test_rank_scan_grading.py(新增)+ tests/test_rank_scan_copy.py。
|
|
10
|
+
- **作品信息「复制」按钮没用了的根因治了**:作品简介/书名/标签等字段旁的复制按钮点了只弹「已复制」、剪贴板却是空的——数据备份区在文件尾部又定义了一次 `copyText`(读 `data-copy` 属性的元素版),后定义覆盖了前定义,所有传字符串的复制入口(作品信息各字段、对话消息复制、详情页工作目录路径、预览页工作目录)一进函数就 TypeError 静默断掉,而 toast 照弹(async 函数把异常转成无人接的 rejected promise,不阻断后续代码),UI 测试只断言 toast 所以一直全绿。现在:重复定义移除,统一为单一字符串版 `copyText`(剪贴板 API 失败自动回退 execCommand 兜底——局域网 IP/远程地址打开页面时 `navigator.clipboard` 本就不存在,此前这条路也是死的);「已复制」只在真实写进剪贴板后弹,失败明说。回归:tests/ui_bookmeta.mjs 复制断言升级为对账剪贴板真拿到文本(旧断言抓不住这类假成功)。
|
|
9
11
|
- **禅道自动修复失败不再打扰业务流**:修复任务失败时此前会往禅道评论「自动修复未成功」并转派端负责人——失败只是中间态,不是修复结论,写回是拿工具故障打扰业务流,转派更是甩锅(2026-09-29 #27828 实案:评审器没跑起来也照评照转)。现在失败只记台账+群通知,回炉「待重试」状态等下轮扫描自动重试(只重建失败端、成功端不重跑,上限 3 次),到限置「修复失败」终态等人工;禅道页新增「立即处理」按钮,失败/需人工的记录不等下轮扫描随时手工重触发。全程零禅道写入,PUT 清空指派人的老坑彻底不再踩。回归:tests/test_zentao.py 53 项 + tests/ui_zentao.mjs。
|
|
10
12
|
- **提示消息改到屏幕正中**:操作成功/失败的轻提示(toast)此前贴视口底部弹出,在详情页会被底部输入条挡住、经常根本看不见(2026-09-29 实案:「该任务还没跑过,还没有任务详情」整条藏在输入条后面)。现在 toast 在视口水平+垂直正中弹出,仍不拦截鼠标点击,手机窄窗同样居中;无头 Edge 实测居中偏差 ≤4px、窄窗不被拉伸。
|
|
11
13
|
- **「一键升级全部」报 Failed to fetch 的真凶治了(0.1.70 全线写接口劫案)**:0.1.70 起目录页「一键升级全部/单条升级/安装/卸载」一点就报「失败:Failed to fetch」且服务端零痕迹——「已发章数校准」功能在 do_POST 中段路由里写了 `from core import store`,Python 的函数内 import 把 `store` 变成整个 do_POST 的局部名,同函数里所有不经过那行的写路由(新建任务/重试/归档/改名/删除/暂停/取消/清空运行/管理操作全线)一读 `store` 就是 `UnboundLocalError`,socketserver 直接掐断连接、不回任何响应。现在:毒 import 移除(模块顶层本就导入了 store);并给 do_GET/do_POST 加顶层兜底——路由再裸抛统一落服务端日志并回 500 JSON,这类事故从「前端莫名断连+服务端零痕迹」变成一眼可查。回归:tests/e2e_mgmt_upgrade_all.py(本轮 bisect 主力)+ tests/test_http_500_guard.py(500 兜底语义)。
|
|
@@ -36,6 +38,16 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
|
|
|
36
38
|
- 调度层加固:绑定链上的「纯模型条目」(只配模型名、没挂供应商)起跑前即被点名——所有 CLI 起跑日志新增凭据模式审计行(绑定链注入/无注入/本机默认,密钥值绝不入日志),缺凭据+登录闸门类错误按 CLI 附修复指引;不再只有 claude 有这层观测(2026-09-25 Mac claude 实案的三层加固通用化)。
|
|
37
39
|
- **Mac 桌宠不再是「白板蜜蜂」**:macOS 的 Tk 没有逐像素窗口透明(`-transparentcolor` 是 Windows 专属),蜜蜂的矩形底板整个露出来还带系统边框(实拍=一块白板)。现在非 Windows 平台把底板画成深色卡片(对齐悬停清单面板色),桌宠呈「桌面小组件」形态;睡觉/失联的状态滤镜改为只作用于蜂体(原先整帧压暗靠键色噪点兜底,卡片底兜不回来会浮出一块更暗的小框)。Windows 逐像素透明不受影响。
|
|
38
40
|
|
|
41
|
+
## v0.1.74(2026-09-30)
|
|
42
|
+
|
|
43
|
+
- 新增 `TOOL_LAUNCH` 错误码:本地 CLI 启动失败(找不到可执行文件)与供应商故障彻底区分——此前这类失败被记成供应商健康故障,把健康的 Bigmodel 评测作废、整链判死(claude 垫片被清实案)
|
|
44
|
+
- runner:Popen 异常统一包裹前缀「启动失败」零误判识别,先于一切上游分类
|
|
45
|
+
- 评测/重探间隔与失败探针刷新调优
|
|
46
|
+
|
|
47
|
+
## v0.1.73(2026-09-30)
|
|
48
|
+
|
|
49
|
+
- **建书假「已建书」三修**(我的马甲藏不住实案):建书未取得 book_id 先按书名对账补回、仍空则**不落台账**(空条目曾让界面亮「已建书」);校准遇空 book_id 先对账、找不回明报「可能尚未建成」,不再盲跑作品列表页误报「可能改版」;七猫补齐对账通道(resolve_book_id)
|
|
50
|
+
|
|
39
51
|
## v0.1.72(2026-09-24)
|
|
40
52
|
|
|
41
53
|
- 作品信息沿连载链继承:续写批次与首展示同账,发布绑定/已发章数整链贯通
|
package/README.md
CHANGED
|
@@ -33,10 +33,10 @@ Trae Agent……),提供
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
35
|
<!-- relnotes:start -->
|
|
36
|
-
### 最新版更新内容(v0.1.
|
|
36
|
+
### 最新版更新内容(v0.1.74)
|
|
37
37
|
|
|
38
|
-
-
|
|
39
|
-
-
|
|
38
|
+
- 新增 TOOL_LAUNCH 错误码:本地 CLI 启动失败与供应商故障彻底区分,不再误作废健康评测
|
|
39
|
+
- 评测/重探间隔与失败探针刷新调优
|
|
40
40
|
<!-- relnotes:end -->
|
|
41
41
|
|
|
42
42
|
|
|
@@ -252,7 +252,7 @@ python tools\bee.py discard <task> :: 否决——删除任务分支(需输
|
|
|
252
252
|
| 🎬 短视频脚本 | review | 画面 + 口播 + 字幕/音效 → 多维评审 → 修订 → 发布门禁 |
|
|
253
253
|
| 📝 文档 | review | 起草 → 多维评审(准确性/结构/表达/实用)→ 修订 → 门禁 |
|
|
254
254
|
| 🌐 翻译 | review | 起草 → 多维评审(忠实度/流畅度/术语/风格)→ 修订 → 门禁 |
|
|
255
|
-
| 📈 扫榜选材 | direct | 抓七猫/番茄/起点/纵横四平台榜单 →
|
|
255
|
+
| 📈 扫榜选材 | direct | 抓七猫/番茄/起点/纵横四平台榜单 → 跨平台题材/人设/切入点洞察,逐条附 A-E 证据分级 |
|
|
256
256
|
| 🕓 缺陷复盘 | direct | 读禅道/Jira 导出 → 产品/开发/测试三视角复盘报告 |
|
|
257
257
|
| 🔍 调研报告 | review | 起草 → 多维评审(全面性/深度/论据/结论)→ 修订 → 门禁 |
|
|
258
258
|
| 🎤 演讲稿 | review | 起草 → 多维评审(主题/结构/感染力/语言)→ 修订 → 门禁 |
|
|
@@ -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)。
|
|
@@ -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/flows.py
CHANGED
|
@@ -79,7 +79,7 @@ BUILTIN_FLOWS = [
|
|
|
79
79
|
"note": "起草 → 多维评审 → 修订循环 → 发布门禁"},
|
|
80
80
|
{"id": "rank_scan", "name": "扫榜选材", "icon": "i-chart", "engine": "direct", "builtin": True,
|
|
81
81
|
"goal_hint": "想写哪个方向(一句话,可留空默认分析总榜热门题材)",
|
|
82
|
-
"note": "抓取七猫/番茄/起点/纵横四平台排行榜公开数据 → AI
|
|
82
|
+
"note": "抓取七猫/番茄/起点/纵横四平台排行榜公开数据 → AI 提炼跨平台热门题材/人设/差异化切入点,每条附 A-E 跨平台证据分级(快档直出报告)"},
|
|
83
83
|
{"id": "defect_retro", "name": "缺陷复盘", "icon": "i-history", "engine": "direct", "builtin": True,
|
|
84
84
|
"goal_hint": "复盘哪个版本/时间段的缺陷(建议附带禅道或 Jira 导出 CSV)",
|
|
85
85
|
"note": "读取附带的缺陷导出数据 → AI 输出产品/开发/测试三视角复盘报告:整体画像、根因聚类、漏测分析、改进动作(快档直出报告)"},
|
package/app/core/health.py
CHANGED
|
@@ -143,9 +143,22 @@ def report_success(provider: str):
|
|
|
143
143
|
|
|
144
144
|
def report_failure(provider: str, error: str = "", *, model: str = "",
|
|
145
145
|
provider_id: str = ""):
|
|
146
|
-
"""一次真实失败调用。provider 用展示名(与 usage 台账一致)。
|
|
146
|
+
"""一次真实失败调用。provider 用展示名(与 usage 台账一致)。
|
|
147
|
+
|
|
148
|
+
本地 CLI 启动失败(TOOL_LAUNCH:找不到可执行文件等)与供应商无关,
|
|
149
|
+
记进来会把健康供应商的评测结论作废、链被解析层过滤成死链——
|
|
150
|
+
2026-09-30 claude 垫片被清案:Bigmodel 全程健康却被连续启动失败
|
|
151
|
+
拖进 down。只打日志提醒修本地环境,不进健康台账。"""
|
|
147
152
|
if not provider:
|
|
148
153
|
return
|
|
154
|
+
try:
|
|
155
|
+
from .error_codes import ErrorCode, classify_error_text
|
|
156
|
+
if classify_error_text(error) == ErrorCode.TOOL_LAUNCH:
|
|
157
|
+
log.warning("[health] 本地 CLI 启动失败(与供应商 %s 无关,不记健康故障):%s",
|
|
158
|
+
provider, str(error or "")[:160])
|
|
159
|
+
return
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
149
162
|
|
|
150
163
|
# A failed live call invalidates capability evidence for this upstream.
|
|
151
164
|
# 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
|
|
@@ -924,7 +974,10 @@ def _do_mgmt(job, ev):
|
|
|
924
974
|
summary = ("失败:安装文件被占用(可能有同名程序在运行,或杀毒软件正在扫描),"
|
|
925
975
|
"请关闭占用该文件的程序后重试。完整输出见日志。")
|
|
926
976
|
else:
|
|
927
|
-
|
|
977
|
+
# note 优先:良性收口(如 winget「已装无可升级」复检在装)给用户
|
|
978
|
+
# 看原因,别让「完成」两个字孤零零不带解释
|
|
979
|
+
extra = str(res.get("note") or res.get("error") or "")[:300]
|
|
980
|
+
summary = ("完成" if ok else "失败") + (": " + extra if extra else "")
|
|
928
981
|
store.finish_step(run_id, step["n"],
|
|
929
982
|
"done" if ok else "failed",
|
|
930
983
|
summary=summary,
|