codebee 0.1.0
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/LICENSE +21 -0
- package/README.md +392 -0
- package/app/__init__.py +0 -0
- package/app/core/__init__.py +0 -0
- package/app/core/attachments.py +322 -0
- package/app/core/automation.py +585 -0
- package/app/core/bookmeta.py +296 -0
- package/app/core/capability.py +130 -0
- package/app/core/catalog.py +319 -0
- package/app/core/compaction.py +186 -0
- package/app/core/diagnostics.py +115 -0
- package/app/core/env_scrub.py +84 -0
- package/app/core/error_codes.py +65 -0
- package/app/core/flows.py +328 -0
- package/app/core/gitmod.py +949 -0
- package/app/core/goal_service.py +159 -0
- package/app/core/health.py +294 -0
- package/app/core/history.py +32 -0
- package/app/core/jobs.py +424 -0
- package/app/core/manager.py +1415 -0
- package/app/core/market.py +299 -0
- package/app/core/market_remote.py +896 -0
- package/app/core/mocks.py +64 -0
- package/app/core/modelhub.py +2750 -0
- package/app/core/paths.py +60 -0
- package/app/core/pipeline.py +2161 -0
- package/app/core/planner.py +493 -0
- package/app/core/registry.py +105 -0
- package/app/core/remote.py +303 -0
- package/app/core/repeat_guard.py +124 -0
- package/app/core/router.py +120 -0
- package/app/core/runner.py +856 -0
- package/app/core/selfupdate.py +170 -0
- package/app/core/session_log.py +162 -0
- package/app/core/sessions.py +312 -0
- package/app/core/settings.py +85 -0
- package/app/core/settings_schema.py +250 -0
- package/app/core/skillpacks/fanqie-novel.md +80 -0
- package/app/core/skillpacks/market/character-bible.md +66 -0
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
- package/app/core/skillpacks/market/git-workflow.md +57 -0
- package/app/core/skillpacks/market/release-notes.md +72 -0
- package/app/core/skillpacks/market/weekly-report.md +71 -0
- package/app/core/skillpacks/market/worldview-consistency.md +70 -0
- package/app/core/skillpacks/qimao-signing.md +105 -0
- package/app/core/skills.py +649 -0
- package/app/core/step_runner.py +61 -0
- package/app/core/store.py +1321 -0
- package/app/core/token_meter.py +130 -0
- package/app/core/usage.py +450 -0
- package/app/main.py +1448 -0
- package/app/ui/app.js +8021 -0
- package/app/ui/i18n.js +1709 -0
- package/app/ui/icons/brand-horizontal.png +0 -0
- package/app/ui/icons/brand-square.png +0 -0
- package/app/ui/icons/icon-192.png +0 -0
- package/app/ui/icons/icon-512.png +0 -0
- package/app/ui/icons/logo-horizontal.png +0 -0
- package/app/ui/icons/logo-mark.png +0 -0
- package/app/ui/index.html +864 -0
- package/app/ui/manifest.json +16 -0
- package/app/ui/qrcode.js +2297 -0
- package/app/ui/style.css +2733 -0
- package/bin/tutti.js +121 -0
- package/package.json +39 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Goal 状态外置:跨轮目标(title/描述/phase/轮次)独立于 LLM 调用持久化。
|
|
3
|
+
|
|
4
|
+
设计稿:docs/migration/03-state-externalization.md §2A。
|
|
5
|
+
参考 dsh packages/goal/goal/:状态 100% 来自事件溯源(goal/change 事件),
|
|
6
|
+
唯一当前 goal,revision 走 CAS 拒绝陈旧引用。
|
|
7
|
+
|
|
8
|
+
与 planner.py 的关系:planner 继续负责「LLM 拆解」;GoalService 只管
|
|
9
|
+
「目标状态」——谁都可以读(UI/续行驱动器/流水线),变更必须过 CAS。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_LOCK = threading.RLock()
|
|
20
|
+
# goal_id -> dict(内存缓存;磁盘为唯一事实来源)
|
|
21
|
+
_GOALS = {}
|
|
22
|
+
_FILE = None # 由 init() 注入
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def init(data_dir=None):
|
|
26
|
+
"""注入存储路径并从磁盘加载。测试可传临时目录。"""
|
|
27
|
+
global _FILE, _GOALS
|
|
28
|
+
with _LOCK:
|
|
29
|
+
base = Path(data_dir) if data_dir else Path(_default_dir())
|
|
30
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
_FILE = base / "goals.json"
|
|
32
|
+
_GOALS = {}
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(_FILE.read_text(encoding="utf-8"))
|
|
35
|
+
for g in data.get("goals") or []:
|
|
36
|
+
if isinstance(g, dict) and g.get("goal_id"):
|
|
37
|
+
_GOALS[g["goal_id"]] = g
|
|
38
|
+
except (OSError, json.JSONDecodeError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _default_dir():
|
|
43
|
+
from . import paths
|
|
44
|
+
return str(paths.DATA_DIR)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _persist():
|
|
48
|
+
"""原子写盘(tmp + replace)。调用方必须已持有 _LOCK。"""
|
|
49
|
+
if _FILE is None:
|
|
50
|
+
return
|
|
51
|
+
tmp = _FILE.with_suffix(".tmp")
|
|
52
|
+
tmp.write_text(json.dumps({"goals": list(_GOALS.values())},
|
|
53
|
+
ensure_ascii=False, indent=2), encoding="utf-8")
|
|
54
|
+
tmp.replace(_FILE)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class StaleRevisionError(Exception):
|
|
58
|
+
"""CAS 失败:调用方持有的是陈旧 revision。"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GoalExistsError(Exception):
|
|
62
|
+
"""已有未完结的当前 goal,需先 complete/abandon。"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class GoalNotFoundError(Exception):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _valid_phase(p):
|
|
70
|
+
return p in ("active", "paused", "complete", "abandoned")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def current():
|
|
74
|
+
"""返回唯一「当前」goal(未 complete/abandoned 的最新一个);无则 None。"""
|
|
75
|
+
with _LOCK:
|
|
76
|
+
open_goals = [g for g in _GOALS.values()
|
|
77
|
+
if g.get("phase") in ("active", "paused")]
|
|
78
|
+
if not open_goals:
|
|
79
|
+
return None
|
|
80
|
+
return max(open_goals, key=lambda g: g.get("created_at", 0))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get(goal_id: str):
|
|
84
|
+
with _LOCK:
|
|
85
|
+
g = _GOALS.get(goal_id)
|
|
86
|
+
return dict(g) if g else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def create(title: str, description: str = "", *, rounds_max: int = 5,
|
|
90
|
+
metadata: dict | None = None) -> dict:
|
|
91
|
+
"""创建新 goal。已有未完结 goal 时拒绝(单一当前目标,仿 dsh)。"""
|
|
92
|
+
with _LOCK:
|
|
93
|
+
cur = current()
|
|
94
|
+
if cur is not None:
|
|
95
|
+
raise GoalExistsError(
|
|
96
|
+
"已有进行中的 goal %s(%s),请先 complete/abandon"
|
|
97
|
+
% (cur["goal_id"], cur["title"]))
|
|
98
|
+
goal = {
|
|
99
|
+
"goal_id": "g" + uuid.uuid4().hex[:12],
|
|
100
|
+
"title": (title or "").strip(),
|
|
101
|
+
"description": (description or "").strip(),
|
|
102
|
+
"phase": "active",
|
|
103
|
+
"revision": 1,
|
|
104
|
+
"rounds_started": 0,
|
|
105
|
+
"rounds_max": max(1, int(rounds_max)),
|
|
106
|
+
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
107
|
+
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
108
|
+
"metadata": dict(metadata or {}),
|
|
109
|
+
}
|
|
110
|
+
_GOALS[goal["goal_id"]] = goal
|
|
111
|
+
_persist()
|
|
112
|
+
return dict(goal)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def update(goal_id: str, expected_revision: int, **changes) -> dict:
|
|
116
|
+
"""CAS 更新:expected_revision 不匹配即抛 StaleRevisionError。
|
|
117
|
+
|
|
118
|
+
允许变更的字段:title/description/phase/rounds_max/metadata。
|
|
119
|
+
phase 只允许合法值;complete/abandoned 为终态不可再改回。
|
|
120
|
+
"""
|
|
121
|
+
with _LOCK:
|
|
122
|
+
g = _GOALS.get(goal_id)
|
|
123
|
+
if not g:
|
|
124
|
+
raise GoalNotFoundError(goal_id)
|
|
125
|
+
if g["revision"] != expected_revision:
|
|
126
|
+
raise StaleRevisionError(
|
|
127
|
+
"%s 期望 rev %d,实际 %d" % (goal_id, expected_revision, g["revision"]))
|
|
128
|
+
if g["phase"] in ("complete", "abandoned"):
|
|
129
|
+
raise ValueError("goal 已终态(%s),不可再变更" % g["phase"])
|
|
130
|
+
allowed = {"title", "description", "phase", "rounds_max",
|
|
131
|
+
"rounds_started", "metadata"}
|
|
132
|
+
for k, v in changes.items():
|
|
133
|
+
if k not in allowed:
|
|
134
|
+
raise ValueError("不可变更字段:%s" % k)
|
|
135
|
+
if k == "phase" and not _valid_phase(v):
|
|
136
|
+
raise ValueError("非法 phase:%s" % v)
|
|
137
|
+
if k == "rounds_max":
|
|
138
|
+
v = max(1, int(v))
|
|
139
|
+
g[k] = v
|
|
140
|
+
g["revision"] += 1
|
|
141
|
+
g["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
142
|
+
_persist()
|
|
143
|
+
return dict(g)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def increment_rounds(goal_id: str, expected_revision: int) -> dict:
|
|
147
|
+
"""轮次 +1(Goal Round Driver 专用 CAS 入口)。"""
|
|
148
|
+
with _LOCK:
|
|
149
|
+
g = _GOALS.get(goal_id)
|
|
150
|
+
if not g:
|
|
151
|
+
raise GoalNotFoundError(goal_id)
|
|
152
|
+
return update(goal_id, expected_revision, rounds_started=g["rounds_started"] + 1)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def list_goals(limit: int = 50) -> list:
|
|
156
|
+
with _LOCK:
|
|
157
|
+
goals = sorted(_GOALS.values(),
|
|
158
|
+
key=lambda g: g.get("created_at", ""), reverse=True)
|
|
159
|
+
return [dict(g) for g in goals[:limit]]
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""供应商健康监测与告警:连不上模型/厂商时自动降级并告警,恢复自动解除。
|
|
3
|
+
|
|
4
|
+
设计稿:docs/migration/07-token-cost.md 的延伸(2026-09-15 连载验收实战需求:
|
|
5
|
+
cavoti 网关宕机 5 小时,系统只会反复失败重试,没有主动告知「通道已不可用」)。
|
|
6
|
+
|
|
7
|
+
状态机(按 provider):
|
|
8
|
+
ok 正常(有近期成功或不活跃)
|
|
9
|
+
failing 连续失败 ≥FAILING_AFTER(进入探测模式)
|
|
10
|
+
down failing 持续 ≥DOWN_AFTER_SECONDS 或连续失败 ≥DOWN_AFTER_FAILURES
|
|
11
|
+
→ 触发告警(未静默时)
|
|
12
|
+
recovered down/failing 后再次成功 → 自动解除告警,记一笔恢复
|
|
13
|
+
|
|
14
|
+
探针策略(省 token):正常时不主动探测(靠真实调用信号);failing/down 后按
|
|
15
|
+
退避(60s→120s→300s 封顶)发轻量探活(modelhub.chat,max_tokens=8)。
|
|
16
|
+
恢复 → 回正常节奏。
|
|
17
|
+
|
|
18
|
+
手动操作:
|
|
19
|
+
silence(provider) 静默本次告警(不再提醒,直到恢复或手动 reset)
|
|
20
|
+
reset(provider) 手动清除状态视为恢复(下次失败重新计数)
|
|
21
|
+
|
|
22
|
+
线程模型:上报(runner/pipeline/modelhub 多线程调用)走锁;探针单线程 daemon。
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
FAILING_AFTER = 2 # 连续失败 ≥2 → failing(进入探测)
|
|
35
|
+
DOWN_AFTER_FAILURES = 4 # 连续失败 ≥4 → down(告警)
|
|
36
|
+
DOWN_AFTER_SECONDS = 600 # 或 failing 持续 10 分钟 → down
|
|
37
|
+
PROBE_BACKOFF = (60, 120, 300) # 探测退避秒数(封顶 300)
|
|
38
|
+
SILENCE_FOREVER = -1 # 手动静默不自动过期(恢复时才清除)
|
|
39
|
+
|
|
40
|
+
_LOCK = threading.RLock()
|
|
41
|
+
_PROVIDERS = {} # name -> 状态 dict
|
|
42
|
+
_FILE = None # init() 注入(data/provider_health.json)
|
|
43
|
+
_PROBE_THREAD = None
|
|
44
|
+
_STARTED = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def init(data_dir=None):
|
|
48
|
+
"""注入存储路径并从磁盘恢复状态(服务重启不丢告警上下文)。"""
|
|
49
|
+
global _FILE, _PROVIDERS, _STARTED
|
|
50
|
+
with _LOCK:
|
|
51
|
+
base = Path(data_dir) if data_dir else Path(_default_dir())
|
|
52
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
_FILE = base / "provider_health.json"
|
|
54
|
+
_PROVIDERS = {}
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(_FILE.read_text(encoding="utf-8"))
|
|
57
|
+
for name, st in (data.get("providers") or {}).items():
|
|
58
|
+
if isinstance(st, dict) and st.get("name"):
|
|
59
|
+
_PROVIDERS[name] = st
|
|
60
|
+
except (OSError, json.JSONDecodeError):
|
|
61
|
+
pass
|
|
62
|
+
if not _STARTED:
|
|
63
|
+
_STARTED = True
|
|
64
|
+
t = threading.Thread(target=_probe_loop, name="health-probe", daemon=True)
|
|
65
|
+
t.start()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _default_dir():
|
|
69
|
+
from . import paths
|
|
70
|
+
return str(paths.DATA_DIR)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _persist():
|
|
74
|
+
if _FILE is None:
|
|
75
|
+
return
|
|
76
|
+
tmp = _FILE.with_suffix(".tmp")
|
|
77
|
+
tmp.write_text(json.dumps({"providers": _PROVIDERS}, ensure_ascii=False, indent=2),
|
|
78
|
+
encoding="utf-8")
|
|
79
|
+
tmp.replace(_FILE)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _now():
|
|
83
|
+
return time.time()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fmt_ts(epoch):
|
|
87
|
+
if not epoch:
|
|
88
|
+
return ""
|
|
89
|
+
return time.strftime("%m-%d %H:%M", time.localtime(epoch))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------- 上报入口
|
|
93
|
+
|
|
94
|
+
def report_success(provider: str):
|
|
95
|
+
"""一次真实成功调用(CLI 或 API 直连)。"""
|
|
96
|
+
if not provider:
|
|
97
|
+
return
|
|
98
|
+
with _LOCK:
|
|
99
|
+
st = _PROVIDERS.get(provider)
|
|
100
|
+
if st is None:
|
|
101
|
+
return # 没失败过就不建档(零开销)
|
|
102
|
+
if st.get("status") in ("failing", "down"):
|
|
103
|
+
st["status"] = "recovered"
|
|
104
|
+
st["recovered_at"] = _now()
|
|
105
|
+
st["consecutive_failures"] = 0
|
|
106
|
+
st["last_ok_at"] = _now()
|
|
107
|
+
st["probe_next_at"] = 0
|
|
108
|
+
st["alerted"] = False
|
|
109
|
+
st["silenced"] = False # 静默只管一次故障期;恢复后重新武装
|
|
110
|
+
st["silence_until"] = 0
|
|
111
|
+
log.info("[health] %s 已恢复(告警自动解除)", provider)
|
|
112
|
+
else:
|
|
113
|
+
st["last_ok_at"] = _now()
|
|
114
|
+
st["consecutive_failures"] = 0
|
|
115
|
+
_persist()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def report_failure(provider: str, error: str = "", *, model: str = "",
|
|
119
|
+
provider_id: str = ""):
|
|
120
|
+
"""一次真实失败调用。provider 用展示名(与 usage 台账一致)。"""
|
|
121
|
+
if not provider:
|
|
122
|
+
return
|
|
123
|
+
with _LOCK:
|
|
124
|
+
st = _PROVIDERS.setdefault(provider, {
|
|
125
|
+
"name": provider, "provider_id": provider_id or "",
|
|
126
|
+
"model": model or "", "status": "ok",
|
|
127
|
+
"consecutive_failures": 0, "first_fail_at": 0,
|
|
128
|
+
"last_fail_at": 0, "last_ok_at": 0, "last_error": "",
|
|
129
|
+
"alerted": False, "silenced": False, "silence_until": 0,
|
|
130
|
+
"probe_next_at": 0, "probe_backoff_idx": 0,
|
|
131
|
+
"recovered_at": 0,
|
|
132
|
+
})
|
|
133
|
+
st["name"] = provider
|
|
134
|
+
if provider_id and not st.get("provider_id"):
|
|
135
|
+
st["provider_id"] = provider_id
|
|
136
|
+
if model and not st.get("model"):
|
|
137
|
+
st["model"] = model
|
|
138
|
+
n = int(st.get("consecutive_failures") or 0) + 1
|
|
139
|
+
st["consecutive_failures"] = n
|
|
140
|
+
st["last_fail_at"] = _now()
|
|
141
|
+
st["last_error"] = str(error or "")[:300]
|
|
142
|
+
if st.get("status") in ("ok", "recovered", ""):
|
|
143
|
+
st["status"] = "failing"
|
|
144
|
+
st["first_fail_at"] = _now()
|
|
145
|
+
st["probe_next_at"] = _now() + PROBE_BACKOFF[0]
|
|
146
|
+
st["probe_backoff_idx"] = 0
|
|
147
|
+
# failing → down:连续次数或持续时间任一达到
|
|
148
|
+
if st["status"] == "failing":
|
|
149
|
+
dur = _now() - (st.get("first_fail_at") or _now())
|
|
150
|
+
if n >= DOWN_AFTER_FAILURES or dur >= DOWN_AFTER_SECONDS:
|
|
151
|
+
st["status"] = "down"
|
|
152
|
+
if st["status"] == "down" and not st.get("silenced"):
|
|
153
|
+
if not st.get("alerted"):
|
|
154
|
+
st["alerted"] = True
|
|
155
|
+
log.warning("[health] ⚠️ 供应商告警:%s 不可用(连续 %d 次失败,%s)",
|
|
156
|
+
provider, n, st["last_error"][:120])
|
|
157
|
+
_persist()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------- 手动操作
|
|
161
|
+
|
|
162
|
+
def silence(provider: str, minutes: int = 0):
|
|
163
|
+
"""手动静默告警(minutes=0 表示静默到恢复为止)。"""
|
|
164
|
+
with _LOCK:
|
|
165
|
+
st = _PROVIDERS.get(provider)
|
|
166
|
+
if not st:
|
|
167
|
+
return False, "未知的供应商"
|
|
168
|
+
st["silenced"] = True
|
|
169
|
+
st["silence_until"] = (_now() + minutes * 60) if minutes else SILENCE_FOREVER
|
|
170
|
+
st["alerted"] = False
|
|
171
|
+
_persist()
|
|
172
|
+
return True, ""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def reset(provider: str):
|
|
176
|
+
"""手动清除状态视为恢复(下次失败重新计数)。"""
|
|
177
|
+
with _LOCK:
|
|
178
|
+
st = _PROVIDERS.get(provider)
|
|
179
|
+
if not st:
|
|
180
|
+
return False, "未知的供应商"
|
|
181
|
+
st.update({"status": "recovered", "consecutive_failures": 0,
|
|
182
|
+
"alerted": False, "silenced": False, "silence_until": 0,
|
|
183
|
+
"probe_next_at": 0, "recovered_at": _now()})
|
|
184
|
+
_persist()
|
|
185
|
+
return True, ""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------- 查询
|
|
189
|
+
|
|
190
|
+
def _effective_silenced(st):
|
|
191
|
+
"""静默是否仍有效(永久静默或未到过期时间)。"""
|
|
192
|
+
if not st.get("silenced"):
|
|
193
|
+
return False
|
|
194
|
+
until = st.get("silence_until") or 0
|
|
195
|
+
return until == SILENCE_FOREVER or until > _now()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def snapshot():
|
|
199
|
+
"""给 API/SSE 的完整视图。"""
|
|
200
|
+
with _LOCK:
|
|
201
|
+
out = []
|
|
202
|
+
for name, st in _PROVIDERS.items():
|
|
203
|
+
silenced = _effective_silenced(st)
|
|
204
|
+
alerting = st.get("status") == "down" and not silenced
|
|
205
|
+
out.append({
|
|
206
|
+
"provider": name,
|
|
207
|
+
"provider_id": st.get("provider_id") or "",
|
|
208
|
+
"model": st.get("model") or "",
|
|
209
|
+
"status": st.get("status") or "ok",
|
|
210
|
+
"consecutive_failures": st.get("consecutive_failures") or 0,
|
|
211
|
+
"first_fail_at": _fmt_ts(st.get("first_fail_at")),
|
|
212
|
+
"last_fail_at": _fmt_ts(st.get("last_fail_at")),
|
|
213
|
+
"last_ok_at": _fmt_ts(st.get("last_ok_at")),
|
|
214
|
+
"recovered_at": _fmt_ts(st.get("recovered_at")),
|
|
215
|
+
"last_error": st.get("last_error") or "",
|
|
216
|
+
"alerting": alerting,
|
|
217
|
+
"silenced": silenced,
|
|
218
|
+
})
|
|
219
|
+
# 稳定排序:告警中 > 故障中 > 恢复 > 正常
|
|
220
|
+
rank = {"down": 0, "failing": 1, "recovered": 2, "ok": 3}
|
|
221
|
+
out.sort(key=lambda x: (rank.get(x["status"], 9), x["provider"]))
|
|
222
|
+
return {
|
|
223
|
+
"providers": out,
|
|
224
|
+
"alerts": [p for p in out if p["alerting"]],
|
|
225
|
+
"any_alerting": any(p["alerting"] for p in out),
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def is_down(provider: str) -> bool:
|
|
230
|
+
"""链降级查询:该供应商当前是否 down(供 resolve_binding 跳过)。"""
|
|
231
|
+
with _LOCK:
|
|
232
|
+
st = _PROVIDERS.get(provider)
|
|
233
|
+
return bool(st and st.get("status") == "down")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def down_names() -> set:
|
|
237
|
+
with _LOCK:
|
|
238
|
+
return {n for n, st in _PROVIDERS.items() if st.get("status") == "down"}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------- 探针
|
|
242
|
+
|
|
243
|
+
def _probe_one(st):
|
|
244
|
+
"""对单个故障 provider 发轻量探活。返回是否恢复。"""
|
|
245
|
+
try:
|
|
246
|
+
from . import modelhub
|
|
247
|
+
pid = st.get("provider_id") or ""
|
|
248
|
+
model = st.get("model") or ""
|
|
249
|
+
provs = {p.get("name"): p for p in modelhub.providers()}
|
|
250
|
+
prov = provs.get(st.get("name")) or (modelhub.providers() and next(
|
|
251
|
+
(p for p in modelhub.providers() if p.get("id") == pid), None))
|
|
252
|
+
if not prov or not prov.get("api_key"):
|
|
253
|
+
return False # 供应商被停用/删除:无法探测,保持状态
|
|
254
|
+
if not model:
|
|
255
|
+
try:
|
|
256
|
+
names = modelhub._enabled_models(prov)
|
|
257
|
+
model = names[0]["name"] if names else ""
|
|
258
|
+
except Exception:
|
|
259
|
+
model = ""
|
|
260
|
+
if not model:
|
|
261
|
+
return False
|
|
262
|
+
res = modelhub.chat(prov["id"], model, "1", max_tokens=8, timeout=20)
|
|
263
|
+
return bool(res.get("ok"))
|
|
264
|
+
except Exception as e:
|
|
265
|
+
log.debug("probe %s error: %s", st.get("name"), e)
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _probe_loop():
|
|
270
|
+
while True:
|
|
271
|
+
try:
|
|
272
|
+
with _LOCK:
|
|
273
|
+
targets = [(name, st) for name, st in _PROVIDERS.items()
|
|
274
|
+
if st.get("status") in ("failing", "down")
|
|
275
|
+
and (st.get("probe_next_at") or 0) <= _now()]
|
|
276
|
+
for name, st in targets:
|
|
277
|
+
idx = int(st.get("probe_backoff_idx") or 0)
|
|
278
|
+
backoff = PROBE_BACKOFF[min(idx, len(PROBE_BACKOFF) - 1)]
|
|
279
|
+
st["probe_next_at"] = _now() + backoff
|
|
280
|
+
st["probe_backoff_idx"] = idx + 1
|
|
281
|
+
for name, st in targets:
|
|
282
|
+
ok = _probe_one(st)
|
|
283
|
+
if ok:
|
|
284
|
+
report_success(name)
|
|
285
|
+
log.info("[health] 探针确认 %s 已恢复", name)
|
|
286
|
+
else:
|
|
287
|
+
with _LOCK:
|
|
288
|
+
cur = _PROVIDERS.get(name)
|
|
289
|
+
if cur:
|
|
290
|
+
cur["last_fail_at"] = _now()
|
|
291
|
+
_persist()
|
|
292
|
+
except Exception:
|
|
293
|
+
log.exception("health probe loop error")
|
|
294
|
+
time.sleep(15) # 探针调度心跳
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""历史统计:从过往运行记录提取每个智能体在每种任务类型上的胜率,反哺路由。"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from . import store
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def agent_stats(limit=200):
|
|
9
|
+
"""返回 {agent_id: {task_type: {"runs": n, "wins": w}}}。"""
|
|
10
|
+
stats = {}
|
|
11
|
+
|
|
12
|
+
def bump(aid, ttype, win):
|
|
13
|
+
if not aid:
|
|
14
|
+
return
|
|
15
|
+
s = stats.setdefault(aid, {}).setdefault(ttype, {"runs": 0, "wins": 0})
|
|
16
|
+
s["runs"] += 1
|
|
17
|
+
if win:
|
|
18
|
+
s["wins"] += 1
|
|
19
|
+
|
|
20
|
+
for run in store.list_runs(limit):
|
|
21
|
+
if run.get("kind") != "orchestration" or run.get("status") != "done":
|
|
22
|
+
continue
|
|
23
|
+
verdict = run.get("verdict") or {}
|
|
24
|
+
task = store.get_task(run.get("task_id")) if run.get("task_id") else None
|
|
25
|
+
ttype = (task or {}).get("type") or verdict.get("type")
|
|
26
|
+
if not ttype:
|
|
27
|
+
continue
|
|
28
|
+
win = bool(verdict.get("pass") or verdict.get("publishable"))
|
|
29
|
+
for s in run.get("steps") or []:
|
|
30
|
+
if s.get("role") in ("implement", "draft"):
|
|
31
|
+
bump(s.get("agent"), ttype, win)
|
|
32
|
+
return stats
|