codebee 0.1.63 → 0.1.64

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.
@@ -32,7 +32,11 @@ backend | frontend | both | not_ours | unknown。
32
32
  纯对方端问题 → 不建任务,直接转派该端负责人+排查结论评论
33
33
  非我方 → 转派报告人(或 owners.not_ours)+评论;只转派不解决
34
34
  unknown → 不碰 bug,need_manual + 群通知(下轮扫描 bug 仍激活则重排查)
35
- 修复任务失败 → 评论尝试记录 + 转派该端负责人(模块路由 account > 端负责人)
35
+ 修复任务失败 → 评论尝试记录 + 转派该端负责人(模块路由 account > 端负责人);
36
+ CodeBee 自身崩溃(内部异常)不转派——那是工具故障不是修不动,
37
+ 只评论+群通知人工;负责人未配置同样只评论。
38
+ 只评论也绝不盲写 PUT:禅道 PUT /bugs/{id} 缺 assignedTo 会
39
+ 清空指派人,必须带回当前指派人(#27783 案)。
36
40
 
37
41
  落库纪律:修复任务落单即带基线(档案配了用档案,没配取工作目录当前 HEAD)
38
42
  走任务分支隔离,修完的代码自动提交在任务分支上,对账合并后才算落库;
@@ -45,8 +49,10 @@ resolve 前再验一遍——没基线的任务改动只在工作区,有未提
45
49
  """
46
50
  from __future__ import annotations
47
51
 
52
+ import base64
48
53
  import html as _html
49
54
  import hashlib
55
+ import io
50
56
  import ipaddress
51
57
  import json
52
58
  import logging
@@ -92,6 +98,13 @@ SIDES = ("backend", "frontend")
92
98
  TRIAGE_SIDES = ("backend", "frontend", "both", "not_ours") # unknown 单列
93
99
  SIDE_CN = {"backend": "后端", "frontend": "前端"}
94
100
 
101
+ # 图片证据:#27754 案——「功能未对齐,具体如图」被纯文本排查脑补成前端样式问题。
102
+ # 排查/修复前先尽量把图抓到手;抓不到就按守卫留人工,绝不盲判。
103
+ _IMG_REF_RE = re.compile(r"如图|见图|截图|附图|下图|上图|图示|图片|图像|录屏|视频")
104
+ TRIAGE_MAX_IMAGES = 4 # 排查提示词最多随附几张图(请求体与 token 预算)
105
+ IMG_CACHE_TTL = 900 # bug 图片按 bug_id 短缓存(排查+建任务两处共用)
106
+ _IMG_CACHE = {} # str(bug_id) → (ts, [(mime, b64)]);空结果也缓存防重复下载
107
+
95
108
  _CFG_DEFAULTS = {
96
109
  "base_url": "",
97
110
  "account": "",
@@ -828,7 +841,13 @@ def _old_route(api, method, p, q, body):
828
841
  m = re.match(r"^/bugs/(\d+)$", p)
829
842
  if m and method == "GET":
830
843
  d = go(get("/bug-view-%s.json" % m.group(1)), "(查 bug)")
831
- return d.get("bug") if isinstance(d, dict) and isinstance(d.get("bug"), dict) else d
844
+ bug = d.get("bug") if isinstance(d, dict) and isinstance(d.get("bug"), dict) else d
845
+ # 老接口的 files 附件在 data 顶层,不在 bug 里:合并进 bug dict(REST 形状兼容)
846
+ if isinstance(bug, dict) and isinstance(d, dict) \
847
+ and isinstance(d.get("files"), (dict, list)):
848
+ bug = dict(bug)
849
+ bug["files"] = d["files"]
850
+ return bug
832
851
 
833
852
  m = re.match(r"^/bugs/(\d+)/resolve$", p)
834
853
  if m and method == "POST":
@@ -893,6 +912,231 @@ def _severity(bug):
893
912
  return 0
894
913
 
895
914
 
915
+ # ---------------------------------------------------------------- bug 图片证据
916
+
917
+ _IMG_MAGIC = ((b"\x89PNG", "image/png"), (b"\xff\xd8\xff", "image/jpeg"),
918
+ (b"GIF8", "image/gif"))
919
+
920
+
921
+ def _img_mime(raw):
922
+ """按魔数判图片类型;非图返回空串(下载到错误页 HTML 时靠它拦住)。"""
923
+ raw = bytes(raw or b"")
924
+ for pre, mime in _IMG_MAGIC:
925
+ if raw.startswith(pre):
926
+ return mime
927
+ if raw[:4] == b"RIFF" and raw[8:12] == b"WEBP":
928
+ return "image/webp"
929
+ return ""
930
+
931
+
932
+ def _img_ext_mime(ext):
933
+ return {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
934
+ "gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp"}.get(
935
+ str(ext or "").strip().lstrip(".").lower(), "")
936
+
937
+
938
+ def _img_srcs(steps_html):
939
+ """steps 富文本里的 <img src> 候选(含 data: URI)。"""
940
+ return re.findall(r'(?is)<img\b[^>]*?\bsrc\s*=\s*["\']([^"\']+)["\']',
941
+ str(steps_html or ""))
942
+
943
+
944
+ def _bug_mentions_images(bug):
945
+ """描述是否指向图片证据:steps 带 <img>、文字提「如图/截图…」、或附件含图片。"""
946
+ html = str(bug.get("steps") or "")
947
+ if "<img" in html.lower():
948
+ return True
949
+ text = str(bug.get("title") or "") + "\n" + _strip_html(html)
950
+ if _IMG_REF_RE.search(text):
951
+ return True
952
+ files = bug.get("files")
953
+ vals = files.values() if isinstance(files, dict) else (files if isinstance(files, list) else [])
954
+ for e in vals:
955
+ if isinstance(e, dict) and _img_ext_mime(
956
+ e.get("extension") or str(e.get("title") or "").rsplit(".", 1)[-1]):
957
+ return True
958
+ return False
959
+
960
+
961
+ def _decode_data_uri(src):
962
+ m = re.match(r"(?is)^data:(image/[\w.+-]+);base64,(.+)$", src.strip())
963
+ if not m:
964
+ return "", b""
965
+ try:
966
+ raw = base64.b64decode(m.group(2))
967
+ except Exception:
968
+ return "", b""
969
+ mime = _img_mime(raw) or m.group(1).lower()
970
+ return mime, raw
971
+
972
+
973
+ def _download_same_origin(src, cfg):
974
+ """下载禅道站内图片字节:仅同源(host:port 与配置的禅道一致),带会话多形态
975
+ 尝试(zentaosid 查询参——新老两通道都认;再裸 GET 兜底),魔数校验。
976
+ 非 200 / 非图 / 网络失败返回 b""。"""
977
+ try:
978
+ base = _raw_base(cfg.get("base_url"))
979
+ except ZenError:
980
+ return b""
981
+ url = src if "://" in str(src) else base + "/" + str(src).lstrip("/")
982
+ try:
983
+ u, bu = urllib.parse.urlsplit(url), urllib.parse.urlsplit(base)
984
+ except ValueError:
985
+ return b""
986
+ if (u.scheme or bu.scheme, u.netloc) != (bu.scheme, bu.netloc):
987
+ return b"" # 第三方图床一律不取(SSRF 边界外只放行同源)
988
+ url = _guard_url("%s://%s%s" % (bu.scheme, bu.netloc, u.path or "/")
989
+ + (("?" + u.query) if u.query else ""))
990
+ tries = []
991
+ for sid in (str(_OLD.get("sid") or ""), str(_TOKEN.get("v") or "")):
992
+ if sid:
993
+ tries.append(url + ("&" if "?" in url else "?") + "zentaosid=" + sid)
994
+ tries.append(url)
995
+ for t in tries:
996
+ try:
997
+ req = urllib.request.Request(t, headers={"Accept": "*/*"})
998
+ with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
999
+ if r.status != 200:
1000
+ continue
1001
+ raw = r.read(20 * 1024 * 1024)
1002
+ except Exception:
1003
+ continue
1004
+ if _img_mime(raw):
1005
+ return raw
1006
+ return b""
1007
+
1008
+
1009
+ def _file_entry_urls(fid, entry):
1010
+ """详情 files 条目 → 下载候选 URL(entry 自带地址优先,再猜经典路由)。"""
1011
+ out = []
1012
+ if isinstance(entry, dict):
1013
+ for k in ("download", "url", "href"):
1014
+ v = str(entry.get(k) or "").strip()
1015
+ if v.startswith(("http://", "https://", "/")):
1016
+ out.append(v)
1017
+ ext = _img_ext_mime(entry.get("extension")
1018
+ or str(entry.get("title") or "").rsplit(".", 1)[-1])
1019
+ else:
1020
+ ext = ""
1021
+ if ext:
1022
+ ext = ext.split("/", 1)[1]
1023
+ for route in ("file-read-%s.%s", "file-download-%s.%s"):
1024
+ out.append("/" + route % (fid, ext))
1025
+ return out
1026
+
1027
+
1028
+ def _prep_image_pairs(pairs):
1029
+ """[(mime, 原始字节)] → [(mime, b64)]。缩放规则与内置智能体同款(长边 1568、
1030
+ 超大转 JPEG q85、单张 5MB 剔除);就地实现不跨层 import(架构 L2→L1 反向禁)。"""
1031
+ try:
1032
+ from PIL import Image
1033
+ except Exception:
1034
+ Image = None
1035
+ out = []
1036
+ for mime, raw in pairs or []:
1037
+ data = raw
1038
+ if Image is not None:
1039
+ try:
1040
+ im = Image.open(io.BytesIO(raw))
1041
+ im.load()
1042
+ w, h = im.size
1043
+ edge = max(w, h, 1)
1044
+ if edge > 1568:
1045
+ r = 1568 / float(edge)
1046
+ im = im.resize((max(1, round(w * r)), max(1, round(h * r))),
1047
+ Image.LANCZOS)
1048
+ fmt = {"image/png": "PNG", "image/jpeg": "JPEG",
1049
+ "image/gif": "GIF", "image/webp": "WEBP"}.get(mime, "PNG")
1050
+ buf = io.BytesIO()
1051
+ im.save(buf, format=fmt)
1052
+ data = buf.getvalue()
1053
+ except Exception:
1054
+ data = raw # 解码失败退回原始字节
1055
+ if mime != "image/jpeg" and len(data) > 3500 * 1024:
1056
+ try: # 仍过大:转 JPEG q85(RGB 拍平 alpha)
1057
+ im = Image.open(io.BytesIO(data)).convert("RGB")
1058
+ buf = io.BytesIO()
1059
+ im.save(buf, format="JPEG", quality=85)
1060
+ data, mime = buf.getvalue(), "image/jpeg"
1061
+ except Exception:
1062
+ pass
1063
+ if len(data) > 5 * 1024 * 1024:
1064
+ continue
1065
+ out.append((mime, base64.b64encode(data).decode("ascii")))
1066
+ return out
1067
+
1068
+
1069
+ def _bug_images(bug_id, steps_html, cfg=None):
1070
+ """bug 关联图片 → [(mime, b64)]:steps 内嵌 <img>(含 data: URI)优先,
1071
+ 读不到再看详情 files 附件。下载带禅道会话、魔数校验、缩放管线同内置智能体。
1072
+ 拿不到返回 [](也缓存,15 分钟内不重复下载)。"""
1073
+ key = str(bug_id or "")
1074
+ now = time.time()
1075
+ hit = _IMG_CACHE.get(key)
1076
+ if hit and now - hit[0] < IMG_CACHE_TTL:
1077
+ return hit[1]
1078
+ cfg = cfg or _cfg()
1079
+ pairs = []
1080
+ for src in _img_srcs(steps_html):
1081
+ s = src.strip()
1082
+ if s.lower().startswith("data:"):
1083
+ mime, raw = _decode_data_uri(s)
1084
+ if raw:
1085
+ pairs.append((mime or "image/png", raw))
1086
+ elif not s.lower().startswith("javascript:"):
1087
+ raw = _download_same_origin(s, cfg)
1088
+ if raw:
1089
+ pairs.append((_img_mime(raw), raw))
1090
+ if not pairs and key:
1091
+ try:
1092
+ detail = _call("GET", "/bugs/%s" % key, cfg=cfg)
1093
+ files = detail.get("files") if isinstance(detail, dict) else None
1094
+ entries = (list(files.items()) if isinstance(files, dict)
1095
+ else [(str(i), e) for i, e in enumerate(files or [])])
1096
+ for fid, entry in entries:
1097
+ mime = _img_ext_mime(
1098
+ (entry.get("extension") if isinstance(entry, dict) else "")
1099
+ or str(entry.get("title") if isinstance(entry, dict) else "").rsplit(".", 1)[-1])
1100
+ if not mime:
1101
+ continue # 日志/压缩包等非图附件不取
1102
+ for u in _file_entry_urls(fid, entry):
1103
+ raw = _download_same_origin(u, cfg)
1104
+ if raw:
1105
+ pairs.append((_img_mime(raw), raw))
1106
+ break
1107
+ except Exception:
1108
+ log.debug("zentao: bug 附件图片获取失败", exc_info=True)
1109
+ imgs = _prep_image_pairs(pairs[:TRIAGE_MAX_IMAGES])
1110
+ if len(_IMG_CACHE) > 128:
1111
+ for k in sorted(_IMG_CACHE, key=lambda k: _IMG_CACHE[k][0])[:64]:
1112
+ _IMG_CACHE.pop(k, None)
1113
+ _IMG_CACHE[key] = (now, imgs)
1114
+ return imgs
1115
+
1116
+
1117
+ def _save_bug_attachments(workdir, bug_id, imgs):
1118
+ """bug 截图落工作目录 _attachments/(create_task 的 dict 清单形态直用)。
1119
+ 返回 [{name,size,mime,path}];失败返回 []。"""
1120
+ if not imgs:
1121
+ return []
1122
+ try:
1123
+ adir = Path(workdir) / "_attachments"
1124
+ adir.mkdir(parents=True, exist_ok=True)
1125
+ ext = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif",
1126
+ "image/webp": ".webp"}
1127
+ items = []
1128
+ for i, (mime, b64) in enumerate(imgs, 1):
1129
+ name = "zenbug%s-%d%s" % (bug_id, i, ext.get(mime, ".png"))
1130
+ data = base64.b64decode(b64)
1131
+ (adir / name).write_bytes(data)
1132
+ items.append({"name": name, "size": len(data), "mime": mime,
1133
+ "path": "_attachments/" + name})
1134
+ return items
1135
+ except Exception:
1136
+ log.debug("zentao: bug 截图落附件失败", exc_info=True)
1137
+ return []
1138
+
1139
+
896
1140
  def _claimable(bug, profile):
897
1141
  """认领过滤:active + 指派 + 严重度(产品由档案本身界定)。"""
898
1142
  if str(bug.get("status") or "") != "active":
@@ -1129,13 +1373,27 @@ def test_connection(base_url=None, account=None, password=None):
1129
1373
  # ---------------------------------------------------------------- 排查(triage)
1130
1374
 
1131
1375
  def _ai_triage(bug, profile):
1132
- """AI 兜底排查:单次 LLM 调用判端。不可用/解析失败返回 None(bookmeta 同款配方)。"""
1376
+ """AI 兜底排查:单次 LLM 调用判端。不可用/解析失败返回 None(bookmeta 同款配方)。
1377
+
1378
+ 图片守卫(#27754 案):描述指向截图而图片读不到 → unknown 留人工,绝不拿
1379
+ 纯文本盲判。排查模型不会看图时也不甩人工——自动全库找看图候选(声明
1380
+ image_in 的优先,名字推断兜底),候选逐个试、实测吃图成功回写能力标记。
1381
+ """
1133
1382
  try:
1134
1383
  from . import modelhub, runner
1384
+ images = []
1385
+ if _bug_mentions_images(bug):
1386
+ try:
1387
+ images = _bug_images(bug.get("id"), bug.get("steps"))
1388
+ except Exception:
1389
+ log.debug("zentao: bug 图片获取失败", exc_info=True)
1390
+ if not images:
1391
+ return {"side": "unknown",
1392
+ "reason": "描述指向截图/图片但图片未能读取,证据不足,"
1393
+ "留人工确认后定责",
1394
+ "imgs": 0}
1135
1395
  orch = modelhub.resolve_orchestrator()
1136
- if not orch:
1137
- return None
1138
- prov, model = orch
1396
+ prov, model = orch if orch else (None, None)
1139
1397
  repos = profile.get("repos") or {}
1140
1398
  hints = profile.get("repo_hints") or {}
1141
1399
 
@@ -1153,6 +1411,9 @@ def _ai_triage(bug, profile):
1153
1411
  steps = _strip_html(bug.get("steps"))
1154
1412
  if steps:
1155
1413
  lines.append(steps[:3000])
1414
+ if images:
1415
+ lines.append("【截图】随本消息附 %d 张 bug 截图,判定前先看图,"
1416
+ "截图与文字冲突时以截图为准。" % len(images))
1156
1417
  lines.append("")
1157
1418
  lines.append("【仓库背景】后端仓库:%s;前端仓库:%s" % (_repo_desc("backend"),
1158
1419
  _repo_desc("frontend")))
@@ -1161,16 +1422,49 @@ def _ai_triage(bug, profile):
1161
1422
  '"reason": "一句话依据"}')
1162
1423
  lines.append("判定口径:backend=纯后端问题;frontend=纯前端问题;both=两端都要改;"
1163
1424
  "not_ours=与这两个仓库无关(第三方服务/环境/需求变更/数据问题等)。")
1164
- res = modelhub.chat(prov["id"], model, "\n".join(lines), max_tokens=500,
1165
- timeout=90)
1166
- if not res.get("ok"):
1167
- log.warning("zentao: AI 排查失败:%s", res.get("error"))
1168
- return None
1425
+ prompt = "\n".join(lines)
1426
+ if images:
1427
+ cand = modelhub.vision_candidates(
1428
+ prefer_provider_id=(prov or {}).get("id") or "",
1429
+ prefer_model=model or "")
1430
+ if not cand:
1431
+ return {"side": "unknown",
1432
+ "reason": "所有已启用模型都不支持图片输入,无法看图定责"
1433
+ "(请在模型管理里启用一个视觉模型)",
1434
+ "imgs": 0}
1435
+ res, last_err = None, ""
1436
+ for p, m, inferred in cand:
1437
+ r = modelhub.chat(p["id"], m, prompt, max_tokens=500, timeout=90,
1438
+ images=images)
1439
+ if r.get("ok"):
1440
+ res = r
1441
+ if inferred:
1442
+ # 名字推断的候选实测吃图成功:回写能力标记(幂等),
1443
+ # 模型管理页图徽章同步可见,后续调用直接命中
1444
+ err = modelhub.set_model_caps(p["id"], m, True)
1445
+ if err:
1446
+ log.debug("zentao: 自动标记 image_in 失败:%s", err)
1447
+ break
1448
+ last_err = str(r.get("error") or "")
1449
+ log.warning("zentao: 视觉排查候选 %s/%s 不可用:%s",
1450
+ p.get("id"), m, last_err[:160])
1451
+ if not res:
1452
+ return {"side": "unknown",
1453
+ "reason": "看图排查全部候选失败(%s)" % last_err[:160],
1454
+ "imgs": 0}
1455
+ else:
1456
+ if not orch:
1457
+ return None
1458
+ res = modelhub.chat(prov["id"], model, prompt, max_tokens=500, timeout=90)
1459
+ if not res.get("ok"):
1460
+ log.warning("zentao: AI 排查失败:%s", res.get("error"))
1461
+ return None
1169
1462
  data = runner.extract_json(res.get("text") or "")
1170
1463
  side = str((data or {}).get("side") or "").strip().lower()
1171
1464
  if side not in TRIAGE_SIDES:
1172
1465
  return None
1173
- return {"side": side, "reason": str((data or {}).get("reason") or "")[:300]}
1466
+ return {"side": side, "reason": str((data or {}).get("reason") or "")[:300],
1467
+ "imgs": len(images)}
1174
1468
  except Exception:
1175
1469
  log.debug("zentao: AI 排查异常", exc_info=True)
1176
1470
  return None
@@ -1206,8 +1500,8 @@ def _repo_of(profile, side):
1206
1500
  return (profile.get("repos") or {}).get(side) or dict(_REPO_DEFAULTS)
1207
1501
 
1208
1502
 
1209
- def _goal_text(bug, side=None):
1210
- """bug → 修复目标提示词。side 给出时附端约束。"""
1503
+ def _goal_text(bug, side=None, images=0):
1504
+ """bug → 修复目标提示词。side 给出时附端约束;images>0 时提示先读附件截图。"""
1211
1505
  bid = bug.get("id")
1212
1506
  lines = ["修复禅道 Bug #%s:%s" % (bid, str(bug.get("title") or "").strip())]
1213
1507
  steps = _strip_html(bug.get("steps"))
@@ -1233,6 +1527,11 @@ def _goal_text(bug, side=None):
1233
1527
  if meta:
1234
1528
  lines.append("")
1235
1529
  lines.append("【元信息】" + ";".join(meta))
1530
+ if images:
1531
+ lines.append("")
1532
+ lines.append("【截图】bug 截图共 %d 张,已放在工作目录 _attachments/(文件名 "
1533
+ "zenbug*);先读图再动手——需求以截图为准,不要凭文字想象。"
1534
+ % images)
1236
1535
  lines.append("")
1237
1536
  lines.append("【要求】只修这个 bug,不做无关重构;改动最小化;修完自查不引入回归。")
1238
1537
  if side in SIDES:
@@ -1250,7 +1549,18 @@ def _launch_fix(bug, profile, side, cfg):
1250
1549
  wd = str(repo.get("workdir") or "").strip() or settings.default_workdir()
1251
1550
  title = ("[禅道#%s][%s] %s" % (bid, SIDE_CN.get(side, side),
1252
1551
  str(bug.get("title") or "").strip())).strip()[:60]
1253
- payload = {"type": "code", "title": title, "goal": _goal_text(bug, side), "workdir": wd}
1552
+ # bug 截图随任务走:修复智能体跟排查同样需要看图,否则修出来的就是想象中的 bug
1553
+ imgs = []
1554
+ try:
1555
+ if _bug_mentions_images(bug):
1556
+ imgs = _bug_images(bid, bug.get("steps"))
1557
+ except Exception:
1558
+ imgs = []
1559
+ atts = _save_bug_attachments(str(wd), bid, imgs)
1560
+ payload = {"type": "code", "title": title, "goal": _goal_text(bug, side, images=len(atts)),
1561
+ "workdir": wd}
1562
+ if atts:
1563
+ payload["attachments"] = atts
1254
1564
  base = str(repo.get("git_rev") or "").strip()
1255
1565
  if not base:
1256
1566
  # 档案没配基线就取落单时点的 HEAD:让修复走任务分支隔离——修完自动
@@ -1354,6 +1664,8 @@ def _transfer_text(claim, profile, fixed_runs, target_side):
1354
1664
  "Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or ""),
1355
1665
  "排查结论:%s问题——%s" % (SIDE_CN.get(target_side, target_side),
1356
1666
  tri.get("reason") or tri.get("by") or "按规则")]
1667
+ if tri.get("imgs"):
1668
+ lines.append("(判定依据含 %d 张 bug 截图)" % tri["imgs"])
1357
1669
  for side, run in (fixed_runs or []):
1358
1670
  lines.extend(_fix_summary(claim, run, profile, side))
1359
1671
  lines.append("请%s负责人接手处理;本 bug 保持激活,处理完请按正常流程解决。"
@@ -1362,7 +1674,7 @@ def _transfer_text(claim, profile, fixed_runs, target_side):
1362
1674
  return "\n".join(lines)
1363
1675
 
1364
1676
 
1365
- def _fail_text(claim, failed_tasks, runs):
1677
+ def _fail_text(claim, failed_tasks, runs, internal=False):
1366
1678
  tri = claim.get("triage") or {}
1367
1679
  lines = ["【CodeBee 自动修复未成功】",
1368
1680
  "Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or "")]
@@ -1372,6 +1684,9 @@ def _fail_text(claim, failed_tasks, runs):
1372
1684
  r = runs.get(t.get("run_id")) or {}
1373
1685
  why = str(r.get("error") or "").strip() or ("运行状态 " + str(r.get("status") or ""))
1374
1686
  lines.append("【%s】失败:%s" % (SIDE_CN.get(t.get("side"), t.get("side")), why[:300]))
1687
+ if internal:
1688
+ lines.append("(失败原因是 CodeBee 工具自身异常,不代表修复结论;"
1689
+ "请人工排查 CodeBee 或续跑修复任务)")
1375
1690
  lines.append("CodeBee 修复任务:%s(可人工续跑或接管);本 bug 保持待处理。"
1376
1691
  % "、".join(t.get("task_id") or "?" for t in claim.get("tasks") or []))
1377
1692
  return "\n".join(lines)
@@ -1400,10 +1715,18 @@ def _ensure_resolved(cfg, bug_id, comment, assign_to):
1400
1715
 
1401
1716
 
1402
1717
  def _transfer(cfg, bug_id, target, comment):
1403
- """转派:PUT assignedTo+comment(bug 保持激活)。target 空=只评论。抛 ZenError。"""
1718
+ """转派:PUT assignedTo+comment(bug 保持激活)。target 空=只评论。抛 ZenError。
1719
+
1720
+ 禅道 PUT /bugs/{id} 不带 assignedTo 会把指派人清空(#27783 案:只评论
1721
+ 也出了「指派给(空)」记录),空 target 必须先 GET 带回当前指派人;GET
1722
+ 不到就整个不发——宁可不评论,不可盲写清指派。
1723
+ """
1404
1724
  body = {"comment": comment}
1405
1725
  if target:
1406
1726
  body["assignedTo"] = target
1727
+ else:
1728
+ cur = _call("GET", "/bugs/%s" % bug_id, cfg=cfg)
1729
+ body["assignedTo"] = _acct(cur.get("assignedTo")) or ""
1407
1730
  _call("PUT", "/bugs/%s" % bug_id, cfg=cfg, body=body)
1408
1731
 
1409
1732
 
@@ -1580,8 +1903,27 @@ def _finish_ok(claim, cfg):
1580
1903
  % (bid, claim.get("title") or "", "、".join(t.get("task_id") or "" for t in tasks)))
1581
1904
 
1582
1905
 
1906
+ # CodeBee 自身崩溃形态:run.error 是裸 Python 异常 repr(如 NameError(...),
1907
+ # 0.1.63 _TRANSIENT 缩进事故实测形态)或带 Python traceback。这不是「修不动
1908
+ # bug」——转派给端负责人等于拿自家工具故障甩锅。冒号形态(TimeoutError: x)
1909
+ # 多为 CLI/被测仓的真实输出,不算内部崩溃。
1910
+ _CRASH_REPR_RE = re.compile(r"^[A-Za-z_]\w*(?:Error|Exception|Interrupt)\(")
1911
+
1912
+
1913
+ def _looks_internal_crash(err):
1914
+ e = str(err or "").strip()
1915
+ if not e:
1916
+ return False
1917
+ return ("Traceback (most recent call last" in e
1918
+ or _CRASH_REPR_RE.match(e) is not None)
1919
+
1920
+
1583
1921
  def _finish_failed(claim, cfg):
1584
- """我方任一任务失败:评论尝试记录 + 转派该端负责人(有配则转)。"""
1922
+ """我方任一任务失败:评论尝试记录 + 转派该端负责人(有配则转)。
1923
+
1924
+ CodeBee 自身崩溃(内部异常)不转派——工具故障不是「修不动」,转出去是
1925
+ 甩锅,只评论+群通知人工。负责人未配置同样只评论。
1926
+ """
1585
1927
  bid = str(claim.get("bug_id"))
1586
1928
  profile = _profile_for(cfg, claim.get("product")) or {}
1587
1929
  tasks = claim.get("tasks") or []
@@ -1589,24 +1931,37 @@ def _finish_failed(claim, cfg):
1589
1931
  failed = [t for t in tasks
1590
1932
  if str((runs.get(t.get("run_id")) or {}).get("status") or "") not in
1591
1933
  ("queued", "running", "done")]
1592
- text = _fail_text(claim, failed, runs)
1593
- # 升级目标:取第一个失败端的路由账号/负责人
1934
+ internal = any(_looks_internal_crash((runs.get(t.get("run_id")) or {}).get("error"))
1935
+ for t in failed)
1936
+ text = _fail_text(claim, failed, runs, internal=internal)
1937
+ # 升级目标:取第一个失败端的路由账号/负责人(内部崩溃不转派)
1594
1938
  target = ""
1595
- for t in failed:
1596
- target = _route_account(profile, claim.get("triage") or {}, t.get("side"))
1597
- if target:
1598
- break
1939
+ if not internal:
1940
+ for t in failed:
1941
+ target = _route_account(profile, claim.get("triage") or {}, t.get("side"))
1942
+ if target:
1943
+ break
1599
1944
  try:
1600
1945
  _transfer(cfg, bid, target, text)
1601
- note = "已评论说明%s" % ("并转派 %s" % target if target else "")
1602
- state = "escalated" if target else "commented"
1946
+ if internal:
1947
+ note = "CodeBee 内部异常,已评论说明(不转派,请人工排查工具链)"
1948
+ state = "commented"
1949
+ elif target:
1950
+ note = "已评论说明并转派 %s" % target
1951
+ state = "escalated"
1952
+ else:
1953
+ side = str((failed[0].get("side") if failed else "") or "")
1954
+ note = "已评论说明(%s负责人未配置,未转派)" % SIDE_CN.get(side, side or "该端")
1955
+ state = "commented"
1603
1956
  except ZenError as e:
1604
1957
  log.warning("zentao: bug %s 失败评论未送达(群通知兜底):%s", bid, e)
1605
1958
  note = "修复失败,评论未送达:%s" % e
1606
1959
  state = "commented"
1607
1960
  _set_claim(bid, state=state, note=note)
1608
- _notify("🐛❌ 禅道 Bug #%s 自动修复未成功%s\n%s\n修复任务:%s"
1609
- % (bid, (",已转派 " + target) if target else "",
1961
+ why = ("CodeBee 内部异常,未转派" if internal
1962
+ else ("已转派 " + target if target else "负责人未配置,未转派"))
1963
+ _notify("🐛❌ 禅道 Bug #%s 自动修复未成功(%s)\n%s\n修复任务:%s"
1964
+ % (bid, why,
1610
1965
  claim.get("title") or "", "、".join(t.get("task_id") or "" for t in tasks)))
1611
1966
 
1612
1967
 
@@ -1692,8 +2047,9 @@ def _route_one(bug, profile, cfg, notify=True):
1692
2047
  target = tri.get("account") or str(profile.get("owners").get("not_ours") or "") \
1693
2048
  or opened
1694
2049
  text = ("【CodeBee 排查转派】\nBug:#%s %s\n排查结论:非我方两个仓库的问题——%s\n"
1695
- "转回 %s 核实处理。\n(本条由 CodeBee 禅道集成自动回写)"
2050
+ "%s转回 %s 核实处理。\n(本条由 CodeBee 禅道集成自动回写)"
1696
2051
  % (bid, bug.get("title") or "", tri.get("reason") or "按规则",
2052
+ ("(判定依据含 %d 张 bug 截图)\n" % tri["imgs"]) if tri.get("imgs") else "",
1697
2053
  target or "报告人"))
1698
2054
  try:
1699
2055
  _transfer(cfg, bid, target, text)
@@ -2087,4 +2443,5 @@ def _test_reset():
2087
2443
  _OLD.update(api="", sid="", at=0.0)
2088
2444
  _OLD_FORM["form"] = ""
2089
2445
  _REST_LAST_ERR["msg"] = ""
2446
+ _IMG_CACHE.clear()
2090
2447
  globals()["_LOADED"] = True
package/app/main.py CHANGED
@@ -411,7 +411,7 @@ class Handler(BaseHTTPRequestHandler):
411
411
  run = store.get_run(m.group(1))
412
412
  if not run:
413
413
  return self._json(404, {"error": "not found"})
414
- wd, files = store.run_artifacts(m.group(1))
414
+ wd, files = store.run_artifacts(m.group(1), cache=True)
415
415
  # 任务一步都没跑出来过(如历次都在检出前失败)→ 工作目录里的
416
416
  # 文件变动是并行活动的噪音,不算这个任务的成品
417
417
  if files and not store.task_step_count(run.get("task_id") or ""):
@@ -2035,7 +2035,7 @@ class Handler(BaseHTTPRequestHandler):
2035
2035
  duration_s = step_duration_s
2036
2036
  wd, files = "", []
2037
2037
  try:
2038
- wd, files = store.run_artifacts(latest.get("id") or "", limit=12)
2038
+ wd, files = store.run_artifacts(latest.get("id") or "", limit=12, cache=True)
2039
2039
  if files and not store.task_step_count(latest.get("task_id") or ""):
2040
2040
  files = [] # 与 /files 端点同口径:无步骤的任务不给成品(fixture 防误报)
2041
2041
  except Exception:
@@ -2645,6 +2645,12 @@ def main():
2645
2645
  n_bo = bookmeta.recover_orphans() # 作品信息生成线程同样会被重启杀掉,遗留 running 收尸
2646
2646
  if n_bo:
2647
2647
  print("[CodeBee] 崩溃恢复:%d 条作品信息生成中断标记为 failed(可点重试)" % n_bo)
2648
+ try:
2649
+ n_bm = bookmeta.repair_existing()
2650
+ if n_bm:
2651
+ print("[CodeBee] 作品信息迁移:%d 条历史记录已补齐建书必填字段" % n_bm)
2652
+ except Exception:
2653
+ pass
2648
2654
  try:
2649
2655
  from core.publish import manager as publish_mgr
2650
2656
  n_pb = publish_mgr.recover_orphans() # 发布线程同款收尸:waiting_login/busy 改判 error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebee",
3
- "version": "0.1.63",
3
+ "version": "0.1.64",
4
4
  "description": "CodeBee · 多智能体编排台 — 统一编排本机 AI 编码 CLI(Codex / Claude Code / QwenCode / OpenCode / Aider…):目标→拆解→路由→执行→验证→跨厂商评审→修复→报告。纯 Python 标准库实现,本地 Web UI。",
5
5
  "license": "MIT",
6
6
  "bin": {