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:
caoqianming 2026-07-13 14:06:29 +08:00
parent b2d665a100
commit 859c02cf65
9 changed files with 198 additions and 7 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.58.21 — 2026-07-13
- 任务运行出错时不再"悄无声息":此前错误提示一闪即被刷掉,发「继续」也像没反应;现在对话里会一直显示「上次运行出错」的红色错误卡(写明原因和重试方式),刷新页面、切换任务再回来都在,直到下次运行开始才消失。
## 0.58.19 — 2026-07-13
- 长文写入触发「工具调用参数损坏,改非流式重试」后,页面不再全程静止像卡死:恢复「思考中 · 秒数」的活跃提示;重试期间点「停止」也立即生效,不再要等这一次重试整个跑完(此前可能要干等几分钟)。

View File

@ -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
View File

@ -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,已知边缘 |

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.58.20"
__version__ = "0.58.21"

View File

@ -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,

View File

@ -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 活跃的在前):", ""]

View File

@ -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()

View File

@ -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)

View File

@ -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";