codebee 0.1.70 → 0.1.72

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,11 +32,11 @@ backend | frontend | both | not_ours | unknown。
32
32
  纯对方端问题 → 不建任务,直接转派该端负责人+排查结论评论
33
33
  非我方 → 转派报告人(或 owners.not_ours)+评论;只转派不解决
34
34
  unknown → 不碰 bug,need_manual + 群通知(下轮扫描 bug 仍激活则重排查)
35
- 修复任务失败 → 评论尝试记录 + 转派该端负责人(模块路由 account > 端负责人);
36
- CodeBee 自身崩溃(内部异常)不转派——那是工具故障不是修不动,
37
- 只评论+群通知人工;负责人未配置同样只评论。
38
- 只评论也绝不盲写 PUT:禅道 PUT /bugs/{id} 缺 assignedTo 会
39
- 清空指派人,必须带回当前指派人(#27783 案)。
35
+ 修复任务失败 → 不评论不转派(#27828 案定口径:失败不是修复结论,写回
36
+ 禅道是拿工具故障/中间态打扰业务流,也绝不当甩锅转派)——
37
+ 只记台账+群通知,回炉 retry 状态等下轮扫描自动重试(上限
38
+ FIX_MAX_ATTEMPTS 次),到限置 failed 终态等人工;禅道页
39
+ 「立即处理」(retry_claim)随时可手工重触发。
40
40
 
41
41
  落库纪律:修复任务落单即带基线(档案配了用档案,没配取工作目录当前 HEAD)
42
42
  走任务分支隔离,修完的代码自动提交在任务分支上,对账合并后才算落库;
@@ -94,6 +94,7 @@ HTTP_TIMEOUT = 15
94
94
  PAGE_LIMIT = 100
95
95
  MAX_BUGS = 500
96
96
  RESOLVE_MAX_ATTEMPTS = 3
97
+ FIX_MAX_ATTEMPTS = 3 # 修复失败自动重试上限(retry 状态下轮扫描重试计数)
97
98
  RETRY_DELAY_MIN = 30
98
99
  INTERVAL_MIN, INTERVAL_MAX = 5, 10080 # 扫描间隔(分钟):5 分钟 ~ 7 天
99
100
  INTERVAL_DEFAULT = 5
@@ -1684,22 +1685,14 @@ def _transfer_text(claim, profile, fixed_runs, target_side):
1684
1685
  return "\n".join(lines)
1685
1686
 
1686
1687
 
1687
- def _fail_text(claim, failed_tasks, runs, internal=False):
1688
- tri = claim.get("triage") or {}
1689
- lines = ["【CodeBee 自动修复未成功】",
1690
- "Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or "")]
1691
- if tri.get("side"):
1692
- lines.append("排查结论:%s(%s)" % (tri.get("side"), tri.get("reason") or ""))
1688
+ def _fail_brief(failed_tasks, runs):
1689
+ """失败端一句话摘要:『后端:原因;前端:原因』(群通知/台账共用)。"""
1690
+ parts = []
1693
1691
  for t in failed_tasks:
1694
1692
  r = runs.get(t.get("run_id")) or {}
1695
1693
  why = str(r.get("error") or "").strip() or ("运行状态 " + str(r.get("status") or ""))
1696
- lines.append("【%s】失败:%s" % (SIDE_CN.get(t.get("side"), t.get("side")), why[:300]))
1697
- if internal:
1698
- lines.append("(失败原因是 CodeBee 工具自身异常,不代表修复结论;"
1699
- "请人工排查 CodeBee 或续跑修复任务)")
1700
- lines.append("CodeBee 修复任务:%s(可人工续跑或接管);本 bug 保持待处理。"
1701
- % "、".join(t.get("task_id") or "?" for t in claim.get("tasks") or []))
1702
- return "\n".join(lines)
1694
+ parts.append("%s:%s" % (SIDE_CN.get(t.get("side"), t.get("side")), why[:200]))
1695
+ return ";".join(parts)[:300]
1703
1696
 
1704
1697
 
1705
1698
  # ---------------------------------------------------------------- 禅道写回动作
@@ -2022,25 +2015,32 @@ def _finish_ok(claim, cfg):
2022
2015
  # 0.1.63 _TRANSIENT 缩进事故实测形态)或带 Python traceback。这不是「修不动
2023
2016
  # bug」——转派给端负责人等于拿自家工具故障甩锅。冒号形态(TimeoutError: x)
2024
2017
  # 多为 CLI/被测仓的真实输出,不算内部崩溃。
2018
+ # 自家工具链故障形态:评审器没跑起来(评审未执行)——pipeline 对评审 CLI
2019
+ # 启动失败的包装错误,同样不是「修不动」的结论。
2025
2020
  _CRASH_REPR_RE = re.compile(r"^[A-Za-z_]\w*(?:Error|Exception|Interrupt)\(")
2026
2021
 
2022
+ _TOOL_FAIL_MARKERS = ("评审器故障(评审未执行)", "评审器执行失败(评审未执行)")
2023
+
2027
2024
 
2028
2025
  def _looks_internal_crash(err):
2029
2026
  e = str(err or "").strip()
2030
2027
  if not e:
2031
2028
  return False
2029
+ if any(m in e for m in _TOOL_FAIL_MARKERS):
2030
+ return True
2032
2031
  return ("Traceback (most recent call last" in e
2033
2032
  or _CRASH_REPR_RE.match(e) is not None)
2034
2033
 
2035
2034
 
2036
2035
  def _finish_failed(claim, cfg):
2037
- """我方任一任务失败:评论尝试记录 + 转派该端负责人(有配则转)。
2036
+ """我方任一任务失败:不评论不转派(#27828 案定口径),只记台账+群通知。
2038
2037
 
2039
- CodeBee 自身崩溃(内部异常)不转派——工具故障不是「修不动」,转出去是
2040
- 甩锅,只评论+群通知人工。负责人未配置同样只评论。
2038
+ 失败不是修复结论——无论是 CodeBee 工具链故障(评审器起不来/内部崩溃)
2039
+ 还是修复未通过,写回禅道都是拿中间态打扰业务流,转派更是甩锅。失败回炉
2040
+ retry 状态,下轮扫描自动重试(只重建失败端,上限 FIX_MAX_ATTEMPTS 次);
2041
+ 到限置 failed 终态等人工,禅道页「立即处理」随时可手工重触发。
2041
2042
  """
2042
2043
  bid = str(claim.get("bug_id"))
2043
- profile = _profile_for(cfg, claim.get("product")) or {}
2044
2044
  tasks = claim.get("tasks") or []
2045
2045
  runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
2046
2046
  failed = [t for t in tasks
@@ -2048,36 +2048,27 @@ def _finish_failed(claim, cfg):
2048
2048
  ("queued", "running", "done")]
2049
2049
  internal = any(_looks_internal_crash((runs.get(t.get("run_id")) or {}).get("error"))
2050
2050
  for t in failed)
2051
- text = _fail_text(claim, failed, runs, internal=internal)
2052
- # 升级目标:取第一个失败端的路由账号/负责人(内部崩溃不转派)
2053
- target = ""
2054
- if not internal:
2055
- for t in failed:
2056
- target = _route_account(profile, claim.get("triage") or {}, t.get("side"))
2057
- if target:
2058
- break
2059
- try:
2060
- _transfer(cfg, bid, target, text)
2061
- if internal:
2062
- note = "CodeBee 内部异常,已评论说明(不转派,请人工排查工具链)"
2063
- state = "commented"
2064
- elif target:
2065
- note = "已评论说明并转派 %s" % target
2066
- state = "escalated"
2067
- else:
2068
- side = str((failed[0].get("side") if failed else "") or "")
2069
- note = "已评论说明(%s负责人未配置,未转派)" % SIDE_CN.get(side, side or "该端")
2070
- state = "commented"
2071
- except ZenError as e:
2072
- log.warning("zentao: bug %s 失败评论未送达(群通知兜底):%s", bid, e)
2073
- note = "修复失败,评论未送达:%s" % e
2074
- state = "commented"
2075
- _set_claim(bid, state=state, note=note)
2076
- why = ("CodeBee 内部异常,未转派" if internal
2077
- else ("已转派 " + target if target else "负责人未配置,未转派"))
2078
- _notify("🐛❌ 禅道 Bug #%s 自动修复未成功(%s)\n%s\n修复任务:%s"
2079
- % (bid, why,
2080
- claim.get("title") or "", "、".join(t.get("task_id") or "" for t in tasks)))
2051
+ why = _fail_brief(failed, runs)
2052
+ kind = "CodeBee 工具链异常" if internal else "修复未通过"
2053
+ task_ids = "、".join(t.get("task_id") or "?" for t in tasks)
2054
+ attempts = int(claim.get("attempts") or 0) + 1
2055
+ if attempts <= FIX_MAX_ATTEMPTS:
2056
+ _set_claim(bid, attempts=attempts, state="retry",
2057
+ note="修复失败(%s:%s),第 %d/%d 次重试将于下轮扫描自动进行"
2058
+ % (kind, why, attempts, FIX_MAX_ATTEMPTS))
2059
+ if attempts == 1: # 中间次只记台账不刷屏,成败都有下文(重试/终态)再报
2060
+ _notify("🐛❌ 禅道 Bug #%s 自动修复未成功(%s)\n%s\n失败:%s\n"
2061
+ "第 %d/%d 次重试将于下轮扫描自动进行,也可到禅道页点「立即处理」。\n"
2062
+ "修复任务:%s"
2063
+ % (bid, kind, claim.get("title") or "", why,
2064
+ attempts, FIX_MAX_ATTEMPTS, task_ids))
2065
+ return
2066
+ _set_claim(bid, attempts=attempts, state="failed",
2067
+ note="连续 %d 次修复失败(%s:%s),已停止自动重试,请人工接管"
2068
+ % (attempts, kind, why))
2069
+ _notify("🐛❌ 禅道 Bug #%s 连续 %d 次自动修复未成功(%s),已停止重试,"
2070
+ "请人工接管(禅道页可「立即处理」重新触发)\n%s\n失败:%s\n修复任务:%s"
2071
+ % (bid, attempts, kind, claim.get("title") or "", why, task_ids))
2081
2072
 
2082
2073
 
2083
2074
  def _refresh_claim_runs(claim):
@@ -2258,8 +2249,88 @@ def _route_one(bug, profile, cfg, notify=True):
2258
2249
  return "fixing"
2259
2250
 
2260
2251
 
2252
+ def _retry_one(bug, claim, profile, cfg):
2253
+ """重试一个 retry 挂单:只重建失败的修复任务(成功端保留),attempts 累加
2254
+ 不清零(上限计数跨重试累计)。建任务失败 → need_manual 留人工。"""
2255
+ bid = str(bug.get("id") or "")
2256
+ tasks = []
2257
+ try:
2258
+ for t in claim.get("tasks") or []:
2259
+ r = store.get_run(t.get("run_id") or "") or {}
2260
+ if str(r.get("status") or "") in ("queued", "running", "done"):
2261
+ tasks.append(dict(t))
2262
+ continue
2263
+ task, run = _launch_fix(bug, profile, t.get("side"), cfg)
2264
+ tasks.append({"side": t.get("side"), "task_id": task["id"],
2265
+ "run_id": run["id"], "state": "fixing"})
2266
+ except Exception as e:
2267
+ log.warning("zentao: bug %s 重试建任务失败:%s", bid, e)
2268
+ _set_claim(bid, state="need_manual", note="重试建任务失败:%s" % e)
2269
+ _notify("🐛⚠️ 禅道 Bug #%s 重试建任务失败,留人工:%s" % (bid, e))
2270
+ return
2271
+ _set_claim(bid, tasks=tasks, state="fixing",
2272
+ note="第 %d 次重试进行中" % max(1, int(claim.get("attempts") or 1)))
2273
+
2274
+
2275
+ # 「立即处理」可重触发的状态:fixing(在跑)/resolved/transferred(已闭环)/
2276
+ # done_manual(修完等人工确认,重跑会重复修复)不行,其余都行。
2277
+ _RETRYABLE_STATES = ("retry", "failed", "escalated", "commented",
2278
+ "need_manual", "merge_failed", "resolve_failed", "lost")
2279
+
2280
+
2281
+ def retry_claim(bid):
2282
+ """手工「立即处理」:不等下轮扫描,立即重新触发一个失败/留人工的修复。
2283
+
2284
+ 按状态分流:need_manual 重新排查认领;merge_failed/resolve_failed 置回
2285
+ fixing 交给对账重试合并/回写;其余(retry/failed/历史 escalated 等)重建
2286
+ 失败端任务。归档的不拒——手工触发即用户明确意图,顺手取消归档。
2287
+ 返回 (claim, 动作文案);不存在/状态不允许抛 ZenError。
2288
+ """
2289
+ bid = str(bid)
2290
+ _ensure_loaded()
2291
+ with _LOCK:
2292
+ cfg = dict(_cfg())
2293
+ c = _STATE["claims"].get(bid)
2294
+ if c is None:
2295
+ raise ZenError("修复记录 #%s 不存在" % bid)
2296
+ state = str(c.get("state") or "")
2297
+ if state == "fixing":
2298
+ raise ZenError("Bug #%s 修复进行中,无需重触发" % bid)
2299
+ if state == "resolved":
2300
+ raise ZenError("Bug #%s 已解决,无需重触发" % bid)
2301
+ if state == "transferred":
2302
+ raise ZenError("Bug #%s 已转派他人,不能重触发" % bid)
2303
+ if state == "done_manual":
2304
+ raise ZenError("Bug #%s 修复已完成,等人工确认合并/解决即可" % bid)
2305
+ product = c.get("product")
2306
+ profile = _profile_for(cfg, product)
2307
+ if profile is None:
2308
+ raise ZenError("Bug #%s 的产品档案已不存在,无法重触发" % bid)
2309
+ bug = _call("GET", "/bugs/%s" % bid, cfg=cfg) # ZenError 往上抛(连不上别硬触)
2310
+ if str(bug.get("status") or "") not in ("active", ""):
2311
+ raise ZenError("Bug #%s 禅道侧状态已是 %s,无需修复" % (bid, bug.get("status")))
2312
+ if state == "need_manual":
2313
+ _route_one(bug, profile, cfg, notify=False)
2314
+ msg = "已重新排查认领"
2315
+ elif state in ("merge_failed", "resolve_failed"):
2316
+ _set_claim(bid, state="fixing", note="手工重触发:等待对账重试合并/回写")
2317
+ msg = "已置回修复中,等待对账重试合并/回写"
2318
+ else:
2319
+ # failed/retry/历史遗留 escalated/commented/lost:清零计数重新给满重试额度
2320
+ with _LOCK:
2321
+ cur = dict(_STATE["claims"].get(bid) or {})
2322
+ cur["attempts"] = 0
2323
+ _retry_one(bug, cur, profile, cfg)
2324
+ msg = "已重新拉起修复任务(重试额度已重置)"
2325
+ _set_claim(bid, attempts=0, archived=False, archived_by="",
2326
+ archived_at="", archive_reason="")
2327
+ with _LOCK:
2328
+ out = dict(_STATE["claims"].get(bid) or {})
2329
+ return out, msg
2330
+
2331
+
2261
2332
  def _scan(cfg):
2262
- """按产品档案逐个拉 bug:认领新 bug + 重试 need_manual 的存量 + 归档对账。
2333
+ """按产品档案逐个拉 bug:认领新 bug + 重试 need_manual/retry 的存量 + 归档对账。
2263
2334
  返回认领数。"""
2264
2335
  claimed = 0
2265
2336
  profiles = _profiles(cfg)
@@ -2271,7 +2342,8 @@ def _scan(cfg):
2271
2342
  with _LOCK:
2272
2343
  seen = set(_STATE["claims"].keys())
2273
2344
  retry_ids = [k for k, c in _STATE["claims"].items()
2274
- if c.get("state") == "need_manual" and not c.get("archived")
2345
+ if c.get("state") in ("need_manual", "retry")
2346
+ and not c.get("archived")
2275
2347
  and k in by_id
2276
2348
  and str(by_id[k].get("status") or "") == "active"]
2277
2349
  for bug in bugs:
@@ -2280,12 +2352,18 @@ def _scan(cfg):
2280
2352
  continue
2281
2353
  _route_one(bug, profile, cfg)
2282
2354
  claimed += 1
2283
- # need_manual 重排查(bug 仍激活才出现在列表里)
2355
+ # need_manual 重排查 / retry 失败端重试(bug 仍激活才出现在列表里)
2284
2356
  for bid in retry_ids:
2285
2357
  with _LOCK:
2286
- if _STATE["claims"].get(bid, {}).get("state") != "need_manual":
2358
+ c = _STATE["claims"].get(bid) or {}
2359
+ st = str(c.get("state") or "")
2360
+ if st not in ("need_manual", "retry"):
2287
2361
  continue
2288
- _route_one(by_id[bid], profile, cfg, notify=False)
2362
+ claim = dict(c)
2363
+ if st == "need_manual":
2364
+ _route_one(by_id[bid], profile, cfg, notify=False)
2365
+ else:
2366
+ _retry_one(by_id[bid], claim, profile, cfg)
2289
2367
  # 归档对账:走到这里说明本产品列表拉取成功,消失判定才可信
2290
2368
  try:
2291
2369
  _archive_sweep(cfg, profile, by_id)
package/app/main.py CHANGED
@@ -220,6 +220,19 @@ class Handler(BaseHTTPRequestHandler):
220
220
 
221
221
  # ------------------------------------------------------------ 路由
222
222
  def do_GET(self):
223
+ # 顶层兜底:任何路由裸抛以前=socketserver 直接掐断连接、零日志,
224
+ # 浏览器只见 "Failed to fetch"(2026-09-29 store 局部名遮蔽实案)。
225
+ # 现在统一落日志并回 500 JSON;对端已断开时无从回话,原样上抛。
226
+ try:
227
+ return self._route_get()
228
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError,
229
+ socket.timeout):
230
+ raise
231
+ except Exception:
232
+ log.exception("GET %s 处理异常", getattr(self, "path", "?"))
233
+ self._safe_500()
234
+
235
+ def _route_get(self):
223
236
  path = urlparse(self.path).path
224
237
  m = None
225
238
  # 静态页不设防(无敏感信息):远程裸地址打开时由前端令牌门引导输入
@@ -274,7 +287,22 @@ class Handler(BaseHTTPRequestHandler):
274
287
  return self._json(200, {"versions": flows.flow_versions(fid)})
275
288
  if path == "/api/skills":
276
289
  from core import skills
277
- return self._json(200, skills.view())
290
+ v = skills.view()
291
+ try:
292
+ # 技能行为卡(ClawHub Skill Card 模式):市场安装的包按文件
293
+ # 名关联拼卡——声明/来源/指纹/安检一次可见。skills(L1) 不能
294
+ # 反向 import market(成环),拼接放路由层。
295
+ from core import market as _market
296
+ reg = _market._load_registry().get("installed") or {}
297
+ by_file = {str(r.get("file") or ""): pid
298
+ for pid, r in reg.items() if isinstance(r, dict)}
299
+ for p in v.get("packs") or []:
300
+ pid = by_file.get("market-%s.md" % p.get("id"))
301
+ if pid:
302
+ p["card"] = _market.skill_card(pid)
303
+ except Exception:
304
+ pass
305
+ return self._json(200, v)
278
306
  if path == "/api/knowledge":
279
307
  from core import knowledge
280
308
  return self._json(200, knowledge.view())
@@ -296,6 +324,14 @@ class Handler(BaseHTTPRequestHandler):
296
324
  if path == "/api/evalbench":
297
325
  from core import evalbench
298
326
  return self._json(200, evalbench.state())
327
+ if path == "/api/evalbench/report":
328
+ # 评测报告导出(Markdown,浏览器下载)
329
+ from core import evalbench
330
+ return self._send(200, evalbench.report_markdown().encode("utf-8"),
331
+ ctype="text/markdown; charset=utf-8",
332
+ headers={"Content-Disposition":
333
+ 'attachment; filename="codebee-bench-%s.md"'
334
+ % time.strftime("%Y%m%d-%H%M%S")})
299
335
  if path == "/api/catalog":
300
336
  return self._json(200, {"catalog": manager.catalog_view(),
301
337
  "checking": manager.updates_checking()})
@@ -520,11 +556,15 @@ class Handler(BaseHTTPRequestHandler):
520
556
  return self._json(200, {"path": fp, "text": text})
521
557
  m = re.match(r"^/api/tasks/([^/]+)/book-meta$", path)
522
558
  if m:
523
- # 作品信息(番茄/七猫建书表单资料):读取任务上的生成状态与结果
559
+ # 作品信息(番茄/七猫建书表单资料):读取生成状态与结果。
560
+ # 连载链沿链继承(都是用第一个):续写批次读到的就是首批的结果。
524
561
  task = store.get_task(m.group(1))
525
562
  if not task:
526
563
  return self._json(404, {"error": "not found"})
527
- return self._json(200, {"book_meta": task.get("book_meta") or {}})
564
+ return self._json(200, {"book_meta": {
565
+ p: store.inherited_book_meta(task, p)
566
+ for p in ("fanqie", "qimao")
567
+ if store.inherited_book_meta(task, p)}})
528
568
  if path == "/api/publish":
529
569
  # 一键发布:平台连接状态 + 最近台账(详情页发布面板)
530
570
  from core.publish import manager as pub
@@ -536,7 +576,8 @@ class Handler(BaseHTTPRequestHandler):
536
576
  from core.publish import manager as pub
537
577
  from core.publish import ledger as pub_ledger
538
578
  tid = m.group(1)
539
- books = pub_ledger.load_books().get(tid) or {}
579
+ # 绑定沿连载链继承(都是用第一个):续写批次也要看到这本书的登记
580
+ books = pub_ledger.books_for(tid)
540
581
  return self._json(200, {"history": pub.history(task_id=tid, limit=50),
541
582
  "books": books,
542
583
  "published": {p: pub.published_local(tid, p)
@@ -667,6 +708,24 @@ class Handler(BaseHTTPRequestHandler):
667
708
  return self._json(404, {"error": "not found"})
668
709
 
669
710
  def do_POST(self):
711
+ # 同 do_GET:路由裸抛统一落日志并回 500,不再静默掐连接
712
+ try:
713
+ return self._route_post()
714
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError,
715
+ socket.timeout):
716
+ raise
717
+ except Exception:
718
+ log.exception("POST %s 处理异常", getattr(self, "path", "?"))
719
+ self._safe_500()
720
+
721
+ def _safe_500(self):
722
+ # 响应头可能已发出(流式路由半途抛),二次失败只能放弃
723
+ try:
724
+ self._json(500, {"error": "服务内部错误,详情见服务端日志"})
725
+ except Exception:
726
+ pass
727
+
728
+ def _route_post(self):
670
729
  path = urlparse(self.path).path
671
730
  m = None
672
731
  if not self._authed():
@@ -906,6 +965,15 @@ class Handler(BaseHTTPRequestHandler):
906
965
  m = re.match(r"^/api/publish/task/([^/]+)/(create-book|chapter|auto-publish)$", path)
907
966
  if m:
908
967
  return self._api_publish_task_op(m.group(1), m.group(2))
968
+ m = re.match(r"^/api/publish/task/([^/]+)/confirm-chapter$", path)
969
+ if m:
970
+ from core.publish import manager as pub
971
+ body = self._body() or {}
972
+ platform = (body.get("platform") or "").strip()
973
+ ok, err = pub.confirm_manual_chapter(
974
+ m.group(1), platform, body.get("chapter_no"))
975
+ return self._json(400, {"error": err or "确认失败"}) \
976
+ if not ok else self._json(200, {"ok": True})
909
977
  m = re.match(r"^/api/publish/task/([^/]+)/register-book$", path)
910
978
  if m:
911
979
  return self._api_publish_register_book(m.group(1))
@@ -918,14 +986,20 @@ class Handler(BaseHTTPRequestHandler):
918
986
  # manual=true(用户点「校准」)允许 attach-or-launch;自动触发
919
987
  # 只 attach 在跑实例,绝不因打开页面弹出新浏览器窗口。
920
988
  from core.publish import manager as pub
921
- from core import store
922
989
  from core.publish import ledger as pub_ledger
990
+ # 注意:这里绝不能再 `from core import store`——函数内 import 会把
991
+ # store 变成 do_POST 整个函数的局部名,同函数里其余路由(新建任务/
992
+ # 取消/升级全部等)一读 store 就是 UnboundLocalError,连接被静默
993
+ # 掐断,前端只见 "Failed to fetch"(0.1.70 实案,bisect 定位)。
994
+ # store 已在模块顶层导入,直接用。
923
995
  tid = m.group(1)
924
996
  if not store.get_task(tid):
925
997
  return self._json(404, {"error": "任务不存在"})
926
998
  body = self._body() or {}
927
999
  plat = str(body.get("platform") or "").strip()
928
- books = pub_ledger.load_books().get(tid) or {}
1000
+ # 绑定沿连载链继承(与 history 端点同口径):续写批次发起校准也能
1001
+ # 落到这本书的账上,否则前端看到继承绑定、这里却报「未登记」互相打架
1002
+ books = pub_ledger.books_for(tid)
929
1003
  if plat and plat not in books:
930
1004
  return self._json(400, {"error": "该平台未登记作品"})
931
1005
  plats = [plat] if plat else list(books)
@@ -1533,6 +1607,14 @@ class Handler(BaseHTTPRequestHandler):
1533
1607
  except zentao.ZenError as e:
1534
1608
  return self._json(404, {"error": str(e)})
1535
1609
  return self._json(200, {"ok": True, "claim": c})
1610
+ if path == "/api/zentao/claims/retry":
1611
+ from core import zentao
1612
+ body = self._body() or {}
1613
+ try:
1614
+ c, msg = zentao.retry_claim(body.get("bug_id"))
1615
+ except zentao.ZenError as e:
1616
+ return self._json(409, {"error": str(e)})
1617
+ return self._json(200, {"ok": True, "claim": c, "message": msg})
1536
1618
  if path == "/api/zentao/modules":
1537
1619
  from core import zentao
1538
1620
  pid = (self._body() or {}).get("product")
@@ -1679,7 +1761,13 @@ class Handler(BaseHTTPRequestHandler):
1679
1761
  if platform not in bookmeta.PLATFORMS:
1680
1762
  return self._json(400, {"error": "platform 必须是 fanqie 或 qimao"})
1681
1763
  if not bookmeta.needs_book_meta(task):
1682
- return self._json(400, {"error": "只有连载首批任务需要作品信息;续写批次沿用第一批的开书资料"})
1764
+ # 生成权留在首批(一本书一份资料):指路根任务,别让用户在续写批次上撞墙
1765
+ root = next((store.get_task(t) for t
1766
+ in store.serial_chain_ids(task_id) if t != task_id), None)
1767
+ return self._json(400, {"error": (
1768
+ "开书资料在连载首批任务《%s》上生成与维护,请到首批任务的「作品信息」页操作"
1769
+ % ((root or {}).get("title") or "首批")
1770
+ if root else "只有连载首批任务需要作品信息;续写批次沿用第一批的开书资料")})
1683
1771
  if task.get("status") in ("queued", "running"):
1684
1772
  return self._json(400, {"error": "任务正在运行,请等本轮结束后再生成作品信息"})
1685
1773
  cur = ((task.get("book_meta") or {}).get(platform) or {})
@@ -1724,8 +1812,14 @@ class Handler(BaseHTTPRequestHandler):
1724
1812
  if platform not in ("fanqie", "qimao"):
1725
1813
  return self._json(400, {"error": "platform 必须是 fanqie 或 qimao"})
1726
1814
  auto_submit = bool(body.get("auto_submit"))
1815
+ force = bool(body.get("force"))
1816
+ force_confirmed = bool(body.get("force_confirmed"))
1817
+ force_reason = str(body.get("force_reason") or "").strip()
1727
1818
  if op == "create-book":
1728
- ok, err = pub.create_book_async(task_id, platform, auto_submit)
1819
+ kwargs = ({"force": force, "force_confirmed": force_confirmed,
1820
+ "force_reason": force_reason}
1821
+ if force or force_confirmed or force_reason else {})
1822
+ ok, err = pub.create_book_async(task_id, platform, auto_submit, **kwargs)
1729
1823
  elif op == "auto-publish":
1730
1824
  # 定时发布配置(P2.5):enabled=false 也落(保留 time 供再开);
1731
1825
  # 校验/归一在 auto.norm_auto_publish,语义见 auto.py 头注
@@ -1754,9 +1848,16 @@ class Handler(BaseHTTPRequestHandler):
1754
1848
  fp.relative_to(_P(wd).resolve())
1755
1849
  except (OSError, ValueError):
1756
1850
  return self._json(400, {"error": "章节文件必须在任务工作目录内"})
1757
- ok, err = pub.upload_chapter_async(task_id, platform, str(fp), auto_submit)
1851
+ kwargs = ({"force": force, "force_confirmed": force_confirmed,
1852
+ "force_reason": force_reason}
1853
+ if force or force_confirmed or force_reason else {})
1854
+ ok, err = pub.upload_chapter_async(task_id, platform, str(fp), auto_submit,
1855
+ **kwargs)
1758
1856
  if not ok:
1759
- return self._json(400, {"error": err or "操作失败"})
1857
+ payload = {"error": err or "操作失败"}
1858
+ if str(err or "").startswith("质量门禁拦截:"):
1859
+ payload["quality_gate"] = {"status": "blocked", "message": err}
1860
+ return self._json(400, payload)
1760
1861
  return self._json(200, {"ok": True, "started": True})
1761
1862
 
1762
1863
  def _api_publish_register_book(self, task_id):
@@ -1786,10 +1887,19 @@ class Handler(BaseHTTPRequestHandler):
1786
1887
  platform = (body.get("platform") or "").strip()
1787
1888
  if platform not in ("fanqie", "qimao"):
1788
1889
  return self._json(400, {"error": "platform 必须是 fanqie 或 qimao"})
1890
+ force = bool(body.get("force"))
1891
+ force_confirmed = bool(body.get("force_confirmed"))
1892
+ force_reason = str(body.get("force_reason") or "").strip()
1789
1893
  ok, err = pub_auto.publish_pending_async(
1790
- task_id, platform, auto_submit=bool(body.get("auto_submit")))
1894
+ task_id, platform, auto_submit=bool(body.get("auto_submit")),
1895
+ **({"force": force, "force_confirmed": force_confirmed,
1896
+ "force_reason": force_reason}
1897
+ if force or force_confirmed or force_reason else {}))
1791
1898
  if not ok:
1792
- return self._json(400, {"error": err or "操作失败"})
1899
+ payload = {"error": err or "操作失败"}
1900
+ if str(err or "").startswith("质量门禁拦截:"):
1901
+ payload["quality_gate"] = {"status": "blocked", "message": err}
1902
+ return self._json(400, payload)
1793
1903
  return self._json(200, {"ok": True, "started": True})
1794
1904
 
1795
1905
  def _api_task_side(self, task_id):
@@ -1916,7 +2026,7 @@ class Handler(BaseHTTPRequestHandler):
1916
2026
  # 服务换了个进程(升级重启)——旧蜜蜂自行退场让新服务 spawn 新蜜蜂
1917
2027
  # (旧形象常驻的根因:同端口轮询永远正常,旧进程从不自离)。
1918
2028
  "boot": _BOOT_TS,
1919
- "settings": {"pet_enabled": bool(st.get("pet_enabled", True)),
2029
+ "settings": {"pet_enabled": bool(st.get("pet_enabled", False)),
1920
2030
  "pet_mode": str(st.get("pet_mode") or "always"),
1921
2031
  "pet_skin": str(st.get("pet_skin") or "plush")},
1922
2032
  "workers": {"running": n_run, "queued": n_q},
@@ -2878,7 +2988,7 @@ def _start_pet_keeper(port):
2878
2988
  def _loop():
2879
2989
  while True:
2880
2990
  try:
2881
- if settings.load().get("pet_enabled", True):
2991
+ if settings.load().get("pet_enabled", False):
2882
2992
  if _PET_PROC is None or _PET_PROC.poll() is not None:
2883
2993
  # 全机唯一蜜蜂:别的服务实例(不同数据目录)已养蜂时
2884
2994
  # 不再拉起,否则桌面会出现多只(用户实测踩坑)
package/app/pet.py CHANGED
@@ -89,7 +89,7 @@ def parse_snapshot(raw):
89
89
  # boot:服务进程启动标识(值变化=服务换人,本宠让位给新服务的蜜蜂)
90
90
  "boot": raw.get("boot") or None,
91
91
  "settings": {
92
- "pet_enabled": bool(st.get("pet_enabled", True)),
92
+ "pet_enabled": bool(st.get("pet_enabled", False)),
93
93
  "pet_mode": str(st.get("pet_mode") or "always"),
94
94
  "pet_skin": str(st.get("pet_skin") or DEFAULT_SKIN),
95
95
  },