feat(ops+web): run 级错误持久提示+失败聚集+provider 致命错误即时告警(bump 0.58.21)
反 2a1bc25d 教训(Zai 余额不足连挂 3 次续跑,前端零提示、admin 零感知): - chat.js: renderPersistedRunError 挂 renderMessages 末尾——run_status=error 且无 live run 时消息流末尾补「上次运行出错」持久卡(live 错误卡被收尾 loadMessages 整屏重建冲掉是"发了继续啥也没发生"的根因);下次 run 起跑 后端清 run_error 自然消失 - usage.py: record_run_error(kind=run_error, cost 0)——tasks.run_error 只留 最后一次,此行才是聚合的完整数据源;app.py _run_agent_bg except 落库 - toolfail.py: 第三段扫描聚成 kind=run / tool=(run),admin 面板+巡检邮件 零改动带上;alert_provider_critical 余额/配额/认证关键词命中即时邮件 ZCBOT_DEVELOPER_EMAIL(同签名 6h 进程内冷却,全路径静默失败) - tests/test_toolfail_malformed.py +3 用例;PROGRESS/CHANGELOG/RUN 同步 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b2d665a100
commit
859c02cf65
|
|
@ -5,6 +5,10 @@
|
|||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||
|
||||
## 0.58.21 — 2026-07-13
|
||||
|
||||
- 任务运行出错时不再"悄无声息":此前错误提示一闪即被刷掉,发「继续」也像没反应;现在对话里会一直显示「上次运行出错」的红色错误卡(写明原因和重试方式),刷新页面、切换任务再回来都在,直到下次运行开始才消失。
|
||||
|
||||
## 0.58.19 — 2026-07-13
|
||||
|
||||
- 长文写入触发「工具调用参数损坏,改非流式重试」后,页面不再全程静止像卡死:恢复「思考中 · 秒数」的活跃提示;重试期间点「停止」也立即生效,不再要等这一次重试整个跑完(此前可能要干等几分钟)。
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
|
||||
### 2026-07
|
||||
|
||||
- **07-13 / 0.58.21**:**run 级错误前端持久提示 + 进失败聚集面板 + provider 级致命错误即时告警**(用户报 task 2a1bc25d "ppt3":Zai 余额不足连挂 3 次续跑,前端零提示、admin 零感知):错误其实有 emit(SSE error 事件→前端错误卡),但 fetchSse 收尾 `loadMessages()` → `renderMessages` 先 `innerHTML=""` 整屏重建,错误不是持久化消息 → 卡瞬间被冲掉;历史打开更没有(run_error 唯一出口是任务列表红点 hover title)。admin 侧 toolfail 巡检只扫 tool 消息 + tool_malformed,LLM 请求层直接抛的 run 级错误(整轮无 tool 消息)全盲。三改:① chat.js 新增 `renderPersistedRunError()`(挂在 renderMessages 两分支末尾,所有重渲路径统一走):meta.run_status=error 且无 live run → 消息流末尾补「上次运行出错」持久卡(含重试引导),刷新/切任务都在,下次 run 起跑后端清 run_error 自然消失;selectTask 本就先赋 taskMeta 再 renderMessages,时序天然成立。② run 错误留痕:`_run_agent_bg` except 里加 `record_run_error`(usage_events kind=run_error,units={err},cost 0——run_error 列只留最后一次,此行才是完整数据源);`core/toolfail.py` 加第三段扫描聚成 kind=run / tool=(run) 的 cluster,admin 面板/巡检邮件零改动自动带上。③ provider 级致命错误(余额不足/配额/认证,正则关键词)即时邮件 `alert_provider_critical`:这类错误该 provider 上所有 run 全挂,等日巡检 5 次/2 task 阈值太慢;同归一化签名 6h 进程内冷却,SMTP 未配只打日志,全路径静默失败不反噬错误路径。`tests/test_toolfail_malformed.py` +3 用例(run_error 聚集 / 关键词命中+冷却 / 普通异常不告警),FakeSession 分流补第三段。
|
||||
- **07-13 / 0.58.20**:**非流式重试期间「思考中」指示恢复 + 停止即时生效**(用户报 716ed3be 重试期间页面无 loading、与卡死无法区分;代码已随 da289b2/0.58.19 一并入库,本条补文档):非流式一次生成可达分钟级且完成前零事件,而 `warn` 事件会让前端把当前文字段定稿关闭(chat.js `closeTextSeg`),「思考中 · Ns」占位段没人重建 → 页面静止到重试完成。① `llm_start` emit 挪进 attempt 循环体每次重发(stats 同一份):前端既有逻辑 `ensureTextSeg + setRunPhase("llm")` 自动重建占位段恢复跳秒,前端零改动。② `_nonstream_once` 阻塞调用挪后台 daemon 线程,主线程 0.5s 一拍 poll cancel,命中返 `None` → `_stream_llm` 返 `(None, True)` 走既有 cancelled 路径——此前点「停止」要同步等非流式调用整个返回(分钟级);弃养线程自行跑完、响应丢弃不入库不记账,与流式 cancel 丢弃已收 chunk 语义一致(provider 侧照跑照计费,litellm 同步调用不暴露连接中断,省不掉)。`tests/test_loop_malformed_retry.py` +4 用例:每 attempt 重发 llm_start 计数 / 非流式期 cancel 短路 / 真 `_nonstream_once` 在 llm.chat 阻塞 30s 时 poll 拍内返回 / 不取消行为与原同步版等价。
|
||||
- **07-13 / 0.58.19**:**畸形 tool_call 进 admin「工具失败聚集」+ 巡检邮件只发活跃聚集**(0.58.18 的观测盲区收尾 + 用户报巡检邮件太频):① 畸形留痕落库:`_log_malformed_args` 在 stdout 外加写 `usage_events` 一行 `kind=tool_malformed`(新 `record_malformed_tool_call`,units={tool,len,err,head,tail,tokens_in/out}),**cost_cny 恒 0**——cost 统计全 kind 合计且随任务展示,provider 抖动的浪费不算用户花销;真实 token 快照进 units 供反推浪费(token 汇总只算 kind=chat,不污染)。DB 写失败静默,绝不阻塞重试主路径。② `core/toolfail.py` 加第二段扫描(usage_events kind=tool_malformed),聚成 kind=malformed 的同构 cluster(signature=归一化 JSON 报错——行列号数字被 `_normalize` 抹平自动聚拢,sample=head…tail),admin 面板/巡检邮件零改动自动带上;聚合逻辑抽 `_add` 闭包双数据源复用。③ 巡检邮件降频:签名去重集是内存态、每次部署清零,高频部署期每次重启把 7 天窗口内早已安静的存量聚集重发一遍——发信前加 `count_24h>0` 过滤,已安静的不再重发,还在烧的重启后再提醒一次(刻意保留)。新增 `tests/test_toolfail_malformed.py` 4 用例(mock session_scope 不碰 DB:聚集/签名归一/双源并存/历史分桶);RUN.md env 巡检段 + 故障兜底 909 行同步。
|
||||
- **07-13 / 0.58.18**:**畸形 tool_call 首败即降级非流式 + 损坏原文留痕**(用户报 task 716ed3be 芜湖零碳园区方案逐章 write 反复「工具调用参数损坏」1/3→3/3):DB 实测 deepseek-v4-pro 也踩 v4-flash 老病(流式 delta 错位粘坏大参数 arguments),且**同轮流式重 roll 失败强相关**——3~4k 字符中文 write 连挂 3 次全畸形、每章拖 3 分钟+,最后总靠非流式兜底救场,3 次流式重试纯烧 token(每次一整段 2k+ token 生成,不记账但 provider 照计费)。改法(`core/loop.py`):① `_MAX_MALFORMED_RETRIES=3`(3 流式+1 非流式)→ `_MAX_MALFORMED_ATTEMPTS=3`(1 流式+2 非流式),首败即降级;顺手修掉旧版 warn 计数可显示 "4/3" 的 off-by-one。② 新增 `_log_malformed_args`:丢弃前把损坏 arguments 首尾各 300 字符(ascii() 转义防 GBK 控制台崩)+ JSON 报错位置打 stdout `[malformed]` 标签——畸形轮不入库,这是唯一留痕,供定性 provider delta 错位 vs 本地拼接 bug 及报 case 取证(toolfail 扫描器看不见此类失败:无 tool 消息)。新增 `tests/test_loop_malformed_retry.py` 4 用例(object.__new__ 绕构造器打桩双通道):干净流式不重试/首败即非流式/耗尽返最后 response 交 executor 返错/中途 cancel 短路。RUN.md 故障兜底 909 行同步(v4-pro 也中招 + `[malformed]` 日志取证口径)。
|
||||
|
|
|
|||
1
RUN.md
1
RUN.md
|
|
@ -910,6 +910,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
|
|||
| dev.html 显示 "load failed" 立刻回登录页 | token 过期或 JWT_SECRET 服务端变了。已自动跳登录页,按上次 tab 重登 |
|
||||
| dev.html 顶栏出现"连接断开,重连中…(N/3)" | SSE 流被切(`--reload` 重启 / nginx 切换 / 网络抖)。客户端自动重连,1s/2s/4s 退避;新进程已 reaper 标 error 则立即收 done + 卡片末尾"请重发"提示;若服务端还活着会继续看后续 delta(断开期间的丢失,broker 不持久化) |
|
||||
| 对话里偶发 `[Error] invalid JSON arguments` / warn「工具调用参数损坏…重试」 | deepseek v4 系(flash/**pro 也踩过**,2026-07 task 716ed3be)**大参数工具调用(大 write/run_python,≈3K+ 字符中文长文)偶发把内容碎片错位粘进 arguments 或退化成空 `{}`**(上游流式抖动,同轮流式重 roll 连挂强相关)。`core/loop.py` 已自动兜底:畸形参数丢弃整轮,**首败即降级非流式重试**(≤3 次总尝试),非流式历史上从未再畸形。每次丢弃会留痕两路:服务端日志 `[malformed] task=... tool=... head=... tail=...`(journalctl 里 grep `\[malformed\]` 取证/报 provider case)+ `usage_events` 一行 `kind=tool_malformed`(cost=0,admin 页「工具失败」表以 kind=malformed 聚集展示,可按模型分组看哪个 provider 在坏)。仍频繁撞 → 引导模型把大文件拆小分块写 |
|
||||
| run 直接报错终止(对话里红卡「上次运行出错」),错误含 `余额不足` / `insufficient quota` / `AuthenticationError` | provider 级故障(2026-07 task 2a1bc25d:Zai 余额不足,该 provider 上所有 run 全挂,用户发「继续」也无用)。给对应 provider 充值 / 换 key 后重发消息即可续跑。观测三路(0.58.21):对话内持久错误卡(下次 run 起跑才消失)+ admin「工具失败」表 kind=run 聚集(数据源 usage_events kind=run_error,`tasks.run_error` 只留最后一次)+ 余额/认证类命中即时邮件 `ZCBOT_DEVELOPER_EMAIL`(同签名 6h 冷却,SMTP 未配只打日志 `[runerror]`) |
|
||||
| 蓝绿脚本报 `旧单实例服务 zcbot.service 还在跑` | 还没做蓝绿 bootstrap(旧服务占 8765 与 `zcbot@blue` 冲突)。走 RUN.md「蓝绿部署」bootstrap 段:`disable --now zcbot` 后起 `zcbot@blue` |
|
||||
| 蓝绿 wait_health 报 `instance 不是 blue/green` | 实例 env 没生效:`/etc/systemd/system/zcbot@.service` 未装 / 没 `daemon-reload` / `deploy/instances/<色>.env` 缺失。`systemctl cat zcbot@blue` 核对 `EnvironmentFile` 两行都在 |
|
||||
| 蓝绿切换后用户直播不动、但任务其实在跑 | 用户恰在切换窗口刷新页面,run 还在旧实例上而 SSE 连到了新实例(broker 进程内不跨实例)。run 照常跑完落库,等旧实例日志安静后让用户刷新看历史;非 bug,已知边缘 |
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.58.20"
|
||||
__version__ = "0.58.21"
|
||||
|
|
|
|||
|
|
@ -190,6 +190,33 @@ def record_malformed_tool_call(
|
|||
))
|
||||
|
||||
|
||||
def record_run_error(
|
||||
*,
|
||||
task_id: UUID,
|
||||
user_id: UUID,
|
||||
model_profile: str,
|
||||
error: str,
|
||||
) -> None:
|
||||
"""记一次 run 级终态错误(kind=run_error)。
|
||||
|
||||
留痕而非记账(cost 恒 0):run 在 LLM 请求层/构建期直接抛异常(RateLimitError
|
||||
余额不足、认证失败等)时,整轮没有 tool 消息,错误只写 tasks.run_error 一列 ——
|
||||
而该列只留最后一次,下次成功即被清掉。此行是「工具失败聚集」面板 + 巡检邮件
|
||||
看到 run 级错误的唯一持久数据源(core/toolfail.py 第三段扫描按 units->>'err'
|
||||
聚合)。task 2a1bc25d 案:Zai 余额不足连挂 3 次续跑,前端无提示、admin 无感知。
|
||||
"""
|
||||
with session_scope() as s:
|
||||
s.add(UsageEvent(
|
||||
user_id=user_id,
|
||||
task_id=task_id,
|
||||
message_id=None, # 错误轮通常无 assistant message 落库,无可关联
|
||||
kind="run_error",
|
||||
model_profile=model_profile,
|
||||
units={"err": str(error)[:500]},
|
||||
cost_cny=Decimal("0"),
|
||||
))
|
||||
|
||||
|
||||
def record_image_usage(
|
||||
*,
|
||||
task_id: UUID,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@
|
|||
整轮不入 messages(防投毒级联),loop 落 usage_events(kind=tool_malformed),
|
||||
在此并入同一聚合口径(signature=归一化 JSON 报错,sample=损坏参数首尾片段)。
|
||||
|
||||
第三数据源(0.58.21):run 级终态错误(kind=run)—— LLM 请求层/构建期直接抛异常
|
||||
(RateLimitError 余额不足、认证失败等),整轮无 tool 消息,_run_agent_bg 落
|
||||
usage_events(kind=run_error),在此按归一化错误签名聚合(tool 名固定 "(run)")。
|
||||
其中 provider 级致命错误(余额/认证)另走 `alert_provider_critical` 即时邮件,
|
||||
不等日巡检 —— 这类错误会让该 provider 上所有用户的所有 run 全挂。
|
||||
|
||||
失败判定(tool content 的三类标记,形态见 executor_docker/_host):
|
||||
- `[Error` 开头 —— 执行器/工具层报错([Error]、[Error executing ...])
|
||||
- `command timed out` —— shell/run_python 超时
|
||||
|
|
@ -119,6 +125,17 @@ def scan_tool_failures(
|
|||
),
|
||||
{"cutoff": cutoff},
|
||||
).fetchall()
|
||||
# 第三段:run 级终态错误(kind=run)。LLM 层抛异常时整轮无 tool 消息,
|
||||
# tasks.run_error 只留最后一次,usage_events(kind=run_error)才是完整留痕。
|
||||
rrows = s.execute(
|
||||
text(
|
||||
"select task_id, user_id, created_at, "
|
||||
" units->>'err' as err "
|
||||
"from usage_events "
|
||||
"where kind = 'run_error' and created_at >= :cutoff"
|
||||
),
|
||||
{"cutoff": cutoff},
|
||||
).fetchall()
|
||||
|
||||
agg: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
||||
|
||||
|
|
@ -171,6 +188,9 @@ def scan_tool_failures(
|
|||
task_id, user_id, created_at,
|
||||
)
|
||||
|
||||
for task_id, user_id, created_at, err in rrows:
|
||||
_add("(run)", "run", err or "?", err or "", task_id, user_id, created_at)
|
||||
|
||||
out = []
|
||||
for c in agg.values():
|
||||
if c["count"] < min_count or len(c["tasks"]) < min_tasks:
|
||||
|
|
@ -193,6 +213,62 @@ def scan_tool_failures(
|
|||
return out
|
||||
|
||||
|
||||
# ── provider 级致命错误即时告警 ──
|
||||
# 命中判据:错误文案含余额/配额/认证类关键词 —— 这类错误不是单任务偶发,而是该
|
||||
# provider 上所有 run 全挂(如 Zai 余额不足),等日巡检的 5 次/2 task 阈值太慢。
|
||||
# 冷却:同归一化签名 6h 内只发一封(进程内存态,重启清零 —— 重启后再发一封可接受,
|
||||
# 比引入持久化状态表划算)。
|
||||
_CRITICAL_RE = re.compile(
|
||||
r"余额不足|无可用资源包|请充值|欠费"
|
||||
r"|insufficient[_ ](?:quota|balance|funds)"
|
||||
r"|exceeded your current quota"
|
||||
r"|AuthenticationError|invalid[_ ]api[_ ]?key|api key.{0,20}(?:invalid|expired)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ALERT_COOLDOWN_S = 6 * 3600
|
||||
_alerted_at: Dict[str, datetime] = {}
|
||||
|
||||
|
||||
def alert_provider_critical(err: str, *, task_id: Any = None, model_profile: str = "") -> bool:
|
||||
"""run 错误若属 provider 级致命(余额/认证)→ 立即邮件开发者;返回是否已发。
|
||||
|
||||
同步阻塞(SMTP),只应在 worker 线程调用(_run_agent_bg 的 except 路径本就在
|
||||
to_thread 里)。所有失败静默 —— 告警绝不能反过来在错误路径上再抛异常。
|
||||
"""
|
||||
try:
|
||||
if not err or not _CRITICAL_RE.search(err):
|
||||
return False
|
||||
sig = _normalize(err)
|
||||
now = datetime.now(timezone.utc)
|
||||
last = _alerted_at.get(sig)
|
||||
if last and (now - last).total_seconds() < _ALERT_COOLDOWN_S:
|
||||
return False
|
||||
_alerted_at[sig] = now
|
||||
import os
|
||||
|
||||
from tools.send_email import send_email_smtp, smtp_configured
|
||||
print(f"[runerror] provider-critical task={task_id} mp={model_profile} "
|
||||
f"err={err[:200]}", flush=True)
|
||||
dev_email = os.getenv("ZCBOT_DEVELOPER_EMAIL", "").strip()
|
||||
if not (dev_email and smtp_configured()):
|
||||
print("[runerror] ZCBOT_DEVELOPER_EMAIL/SMTP 未配,仅日志", flush=True)
|
||||
return False
|
||||
body = "\n".join([
|
||||
"检出 provider 级致命错误(余额/配额/认证),该 provider 上的 run 可能全部失败:",
|
||||
"",
|
||||
f"错误: {err[:500]}",
|
||||
f"模型档: {model_profile or '?'}",
|
||||
f"任务: {task_id or '?'}",
|
||||
"",
|
||||
f"同类签名 {_ALERT_COOLDOWN_S // 3600}h 内不再重复告警;"
|
||||
"完整聚合见 admin 工具失败面板(kind=run)。",
|
||||
])
|
||||
send_email_smtp(dev_email, f"[zcbot] provider 级错误:{sig[:60]}", body)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def format_alert(clusters: List[Dict[str, Any]], days: float) -> str:
|
||||
"""聚集列表 → 告警邮件正文(纯文本)。"""
|
||||
lines = [f"近 {days:g} 天内检出 {len(clusters)} 类工具失败聚集(近 24h 活跃的在前):", ""]
|
||||
|
|
|
|||
|
|
@ -16,22 +16,28 @@ NOW = datetime.now(timezone.utc)
|
|||
|
||||
|
||||
class _FakeSession:
|
||||
"""按 SQL 关键字分流两段查询:messages 段与 usage_events(tool_malformed)段。"""
|
||||
"""按 SQL 关键字分流三段查询:messages / tool_malformed / run_error。"""
|
||||
|
||||
def __init__(self, msg_rows, malformed_rows):
|
||||
def __init__(self, msg_rows, malformed_rows, run_rows):
|
||||
self._msg_rows = msg_rows
|
||||
self._malformed_rows = malformed_rows
|
||||
self._run_rows = run_rows
|
||||
|
||||
def execute(self, clause, params=None):
|
||||
sql = str(clause)
|
||||
rows = self._malformed_rows if "tool_malformed" in sql else self._msg_rows
|
||||
if "tool_malformed" in sql:
|
||||
rows = self._malformed_rows
|
||||
elif "run_error" in sql:
|
||||
rows = self._run_rows
|
||||
else:
|
||||
rows = self._msg_rows
|
||||
return SimpleNamespace(fetchall=lambda: rows)
|
||||
|
||||
|
||||
def _scan(msg_rows, malformed_rows, **kw):
|
||||
def _scan(msg_rows, malformed_rows, run_rows=None, **kw):
|
||||
@contextmanager
|
||||
def fake_scope():
|
||||
yield _FakeSession(msg_rows, malformed_rows)
|
||||
yield _FakeSession(msg_rows, malformed_rows, run_rows or [])
|
||||
|
||||
with patch.object(tf, "session_scope", fake_scope):
|
||||
return tf.scan_tool_failures(**kw)
|
||||
|
|
@ -91,5 +97,45 @@ class TestToolfailMalformed(unittest.TestCase):
|
|||
self.assertEqual(c["daily"][-4], 2) # 尾桶=近24h,往前退 3 桶
|
||||
|
||||
|
||||
class TestToolfailRunError(unittest.TestCase):
|
||||
def test_run_error_rows_cluster(self):
|
||||
"""usage_events 的 run_error 行聚成 kind=run / tool=(run) 的 cluster。"""
|
||||
err = "RateLimitError: litellm.RateLimitError: ZaiException - 余额不足或无可用资源包,请充值。"
|
||||
rows = [
|
||||
("t1", "u1", NOW - timedelta(hours=1), err),
|
||||
("t1", "u1", NOW - timedelta(minutes=30), err),
|
||||
("t2", "u2", NOW - timedelta(minutes=10), err),
|
||||
]
|
||||
out = _scan([], [], rows, days=7, min_count=3, min_tasks=2)
|
||||
self.assertEqual(len(out), 1)
|
||||
c = out[0]
|
||||
self.assertEqual(c["tool"], "(run)")
|
||||
self.assertEqual(c["kind"], "run")
|
||||
self.assertEqual(c["count"], 3)
|
||||
self.assertEqual(c["task_count"], 2)
|
||||
self.assertIn("余额不足", c["sample"])
|
||||
|
||||
|
||||
class TestProviderCriticalAlert(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tf._alerted_at.clear()
|
||||
|
||||
def test_pattern_hit_and_cooldown(self):
|
||||
"""余额类错误命中;同签名冷却期内第二次不再发。"""
|
||||
err = "RateLimitError: ZaiException - 余额不足或无可用资源包,请充值。"
|
||||
sent = []
|
||||
with patch("tools.send_email.smtp_configured", return_value=True), \
|
||||
patch("tools.send_email.send_email_smtp",
|
||||
side_effect=lambda *a, **k: sent.append(a)), \
|
||||
patch.dict("os.environ", {"ZCBOT_DEVELOPER_EMAIL": "dev@x.com"}):
|
||||
self.assertTrue(tf.alert_provider_critical(err, task_id="t1"))
|
||||
self.assertFalse(tf.alert_provider_critical(err, task_id="t2")) # 冷却
|
||||
self.assertEqual(len(sent), 1)
|
||||
|
||||
def test_non_critical_ignored(self):
|
||||
"""普通异常不告警。"""
|
||||
self.assertFalse(tf.alert_provider_critical("ValueError: bad input"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
17
web/app.py
17
web/app.py
|
|
@ -55,7 +55,9 @@ from core.storage import (
|
|||
session_scope,
|
||||
)
|
||||
from core.storage.models import Message, ScheduledJob, Task, User, UsageEvent
|
||||
from core.storage.usage import record_run_error
|
||||
from core.storage.utils import ensure_local_task_row
|
||||
from core.toolfail import alert_provider_critical
|
||||
|
||||
from .auth import (
|
||||
AuthConfig,
|
||||
|
|
@ -462,8 +464,12 @@ def _run_agent_bg(
|
|||
except Exception as e:
|
||||
err = f"{type(e).__name__}: {e}"
|
||||
broker.emit(task_id, {"type": "error", "msg": err})
|
||||
mp = ""
|
||||
try:
|
||||
with session_scope() as s:
|
||||
mp = s.execute(
|
||||
select(Task.model_profile).where(Task.task_id == task_id)
|
||||
).scalar_one_or_none() or ""
|
||||
s.execute(
|
||||
update(Task).where(Task.task_id == task_id).values(
|
||||
run_status="error", run_error=err,
|
||||
|
|
@ -471,6 +477,17 @@ def _run_agent_bg(
|
|||
)
|
||||
except Exception:
|
||||
pass # 已 emit error 给前端,DB 写失败不放大噪声
|
||||
# 留痕 + 告警(0.58.21,反 2a1bc25d 教训:Zai 余额不足连挂 3 次续跑无人知):
|
||||
# run_error 列只留最后一次,usage_events(kind=run_error)才是聚合面板/巡检
|
||||
# 邮件的完整数据源;余额/认证类 provider 级错误另走即时邮件(6h 签名冷却)。
|
||||
# 两路都静默失败 —— 留痕/告警绝不能在错误路径上再抛。
|
||||
try:
|
||||
record_run_error(
|
||||
task_id=task_id, user_id=user_id, model_profile=mp, error=err,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
alert_provider_critical(err, task_id=task_id, model_profile=mp)
|
||||
finally:
|
||||
broker.clear_cancel(task_id)
|
||||
broker.close(task_id)
|
||||
|
|
|
|||
|
|
@ -423,7 +423,7 @@ export async function selectTask(tid) {
|
|||
refreshProcs(),
|
||||
]);
|
||||
state.taskMeta = meta;
|
||||
renderMessages(state.loadedMessages);
|
||||
renderMessages(state.loadedMessages); // 内部含 renderPersistedRunError(须在 taskMeta 赋值后)
|
||||
renderChatMeta();
|
||||
applyChannelComposerLock(meta);
|
||||
if (meta.run_status === "running" || meta.run_status === "cancelling") {
|
||||
|
|
@ -1114,6 +1114,7 @@ function renderMessages(msgs) {
|
|||
state.msgHasMore = false;
|
||||
setTaskProgress(state.taskId, []);
|
||||
wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
|
||||
renderPersistedRunError();
|
||||
renderLiveRunIfVisible();
|
||||
return;
|
||||
}
|
||||
|
|
@ -1256,6 +1257,7 @@ function renderMessages(msgs) {
|
|||
wrap.scrollTop = wrap.scrollHeight;
|
||||
setTaskProgress(state.taskId, currentProgressSteps);
|
||||
upgradeMediaArtifacts(wrap);
|
||||
renderPersistedRunError(); // 上次 run error 终态 → 末尾补持久错误卡(所有重渲路径统一走这)
|
||||
renderLiveRunIfVisible();
|
||||
}
|
||||
|
||||
|
|
@ -2378,6 +2380,23 @@ function handleSseEvent(ev, asstCard, ctx) {
|
|||
if (nearBottom) stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
|
||||
// 持久错误卡:上次 run 以 error 终态收场(tasks.run_error)→ 消息流末尾补一张卡。
|
||||
// live 的错误卡(handleSseEvent → appendErrorCard)会被收尾 loadMessages 的整屏重建
|
||||
// 冲掉(renderMessages 先 innerHTML="",错误不是持久化消息,重建即消失 —— 2a1bc25d
|
||||
// 案"发了继续啥也没发生"的根因);这张以 meta.run_status 为准的卡在每次重渲后补,
|
||||
// 刷新/切换任务也在。下次 run 起跑时后端清 run_error,meta 刷新后自然消失。
|
||||
function renderPersistedRunError() {
|
||||
document.querySelectorAll("#chat-stream .run-error-persist").forEach((el) => el.remove());
|
||||
const t = state.taskMeta;
|
||||
if (!t || state.taskId !== t.task_id) return;
|
||||
if (t.run_status !== "error" || getLiveRun(state.taskId)) return;
|
||||
const card = document.createElement("div");
|
||||
card.className = "msg error run-error-persist";
|
||||
card.innerHTML = `<div class="role">上次运行出错</div><div class="body">${escapeHtml(t.run_error || "(未知错误)")}`
|
||||
+ `<div class="muted" style="margin-top:4px;font-size:12px;">可直接重发消息或回复「继续」重试。</div></div>`;
|
||||
$("chat-stream").appendChild(card);
|
||||
}
|
||||
|
||||
function appendErrorCard(msg) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "msg error";
|
||||
|
|
|
|||
Loading…
Reference in New Issue