codebee 0.1.6 → 0.1.8

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/app/main.py CHANGED
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import argparse
9
9
  import json
10
+ import logging
10
11
  import os
11
12
  import re
12
13
  import socket
@@ -22,6 +23,9 @@ from urllib.parse import parse_qs, unquote, urlparse
22
23
  from core import automation, catalog, flows, jobs, manager, market, market_remote, registry, remote, settings, store
23
24
  from core import paths
24
25
  from core import health
26
+ import pick_dialog
27
+
28
+ log = logging.getLogger(__name__)
25
29
 
26
30
  MIME = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
27
31
  ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png",
@@ -48,10 +52,34 @@ def _utf8_bytes(data):
48
52
 
49
53
  PORT = 8765 # main() 启动时更新;/api/connect 组装扫码地址用
50
54
 
55
+ # 写接口统一限制 JSON 请求体,避免误传文件或异常客户端把 worker 线程和
56
+ # 内存拖垮。16 MiB 足够覆盖任务上下文、故事圣经和附件清单(附件本体走
57
+ # 独立上传接口)。
58
+ MAX_BODY_BYTES = 16 * 1024 * 1024
59
+ # 附件本体用 base64 包装:24 MiB Office 文件编码后约 32 MiB,再留少量 JSON
60
+ # 开销。该上限仍会在 attachments.save_pending 中按扩展名再次精确校验。
61
+ MAX_ATTACHMENT_BODY_BYTES = 34 * 1024 * 1024
62
+ _BODY_UNSET = object()
63
+
64
+
65
+ class RequestBodyError(ValueError):
66
+ """客户端请求体不可解析;status 用于把错误稳定映射为 400/413。"""
67
+
68
+ def __init__(self, message, status=400):
69
+ super().__init__(message)
70
+ self.status = status
71
+
51
72
  # 侧栏「查看文件」/「目录浏览」跳过的噪音目录(与 store 的习惯一致)
52
73
  _SKIP_DIRS_SHARE = {".git", "node_modules", "__pycache__", ".venv", "venv",
53
74
  ".idea", ".vscode", "_attachments"}
54
75
 
76
+ # 原生「选择文件夹」对话框:Tk 必须活在自家进程的主线程里(HTTP 请求线程里
77
+ # 建 root 在 macOS 上会崩,Windows 上反复建/销毁也不稳),pick_dialog.py
78
+ # 同文件提供父端 ask_directory()(拉起自身为子进程,参数走 stdin、结果走
79
+ # stdout),这里只持锁防重入。
80
+ # 同时只允许一个原生对话框:对话框开着时第二个请求立即拿到 busy,不排队
81
+ _PICK_LOCK = threading.Lock()
82
+
55
83
 
56
84
  class Handler(BaseHTTPRequestHandler):
57
85
  server_version = "CodeBee/1.0"
@@ -79,13 +107,38 @@ class Handler(BaseHTTPRequestHandler):
79
107
  self._send(code, json.dumps(obj, ensure_ascii=False))
80
108
 
81
109
  def _body(self):
82
- try:
83
- n = int(self.headers.get("Content-Length") or 0)
84
- if n <= 0:
85
- return {}
86
- return json.loads(self.rfile.read(n).decode("utf-8"))
87
- except Exception:
88
- return {}
110
+ cached = getattr(self, "_parsed_body", _BODY_UNSET)
111
+ if cached is not _BODY_UNSET:
112
+ return cached
113
+ raw_len = self.headers.get("Content-Length")
114
+ if not raw_len:
115
+ body = {}
116
+ else:
117
+ try:
118
+ n = int(raw_len)
119
+ except (TypeError, ValueError):
120
+ raise RequestBodyError("Content-Length 无效")
121
+ if n < 0:
122
+ raise RequestBodyError("Content-Length 无效")
123
+ limit = getattr(self, "_body_limit", MAX_BODY_BYTES)
124
+ if n > limit:
125
+ raise RequestBodyError("请求体过大(最大 %d MiB)" % (limit // (1024 * 1024)), 413)
126
+ if n == 0:
127
+ body = {}
128
+ else:
129
+ data = self.rfile.read(n)
130
+ try:
131
+ text = data.decode("utf-8")
132
+ except UnicodeDecodeError:
133
+ raise RequestBodyError("请求体必须使用 UTF-8 编码")
134
+ try:
135
+ body = json.loads(text)
136
+ except (TypeError, ValueError):
137
+ raise RequestBodyError("请求体不是有效的 JSON")
138
+ if not isinstance(body, dict):
139
+ raise RequestBodyError("JSON 请求体顶层必须是对象")
140
+ self._parsed_body = body
141
+ return body
89
142
 
90
143
  # ------------------------------------------------------------ 远程访问
91
144
  def _forwarded_ip(self):
@@ -210,6 +263,27 @@ class Handler(BaseHTTPRequestHandler):
210
263
  except ValueError:
211
264
  days = 30
212
265
  return self._json(200, usage.summary(days=days))
266
+ if path == "/api/usage/estimate":
267
+ from core import usage
268
+ q = parse_qs(urlparse(self.path).query)
269
+ ttype = (q.get("type") or [""])[0][:32]
270
+ try:
271
+ edays = max(1, min(3650, int((q.get("days") or ["90"])[0])))
272
+ except ValueError:
273
+ edays = 90
274
+ return self._json(200, usage.estimate(task_type=ttype, days=edays))
275
+ if path == "/api/diagnostics/bundle":
276
+ # 诊断包(zip):脱敏错误台账 + 用量台账 + 环境元信息,供用户贴 Issue
277
+ from core import telemetry
278
+ data = telemetry.build_bundle_bytes(days=30)
279
+ return self._send(200, data, ctype="application/zip", headers={
280
+ "Content-Disposition":
281
+ 'attachment; filename="codebee-diag-%s.zip"'
282
+ % time.strftime("%Y%m%d-%H%M%S")})
283
+ if path == "/api/diagnostics/issue-summary":
284
+ # 一键反馈 Issue 的预填摘要(标题+正文,全程脱敏,用户亲手提交)
285
+ from core import telemetry
286
+ return self._json(200, telemetry.issue_report(days=30))
213
287
  m = re.match(r"^/api/runs/([^/]+)$", path)
214
288
  if m:
215
289
  run = store.get_run(m.group(1))
@@ -366,6 +440,14 @@ class Handler(BaseHTTPRequestHandler):
366
440
  m = None
367
441
  if not self._authed():
368
442
  return self._json(401, {"error": "需要访问令牌(启动 CodeBee 时控制台会显示)"})
443
+ # 所有写接口共用一次严格解析;后续路由再次调用 _body() 时直接取缓存。
444
+ # 这样 malformed JSON 不会被当成空对象继续执行,也避免同一请求重复读流。
445
+ self._body_limit = (MAX_ATTACHMENT_BODY_BYTES if path == "/api/attachments"
446
+ else MAX_BODY_BYTES)
447
+ try:
448
+ self._body()
449
+ except RequestBodyError as e:
450
+ return self._json(e.status, {"error": str(e)})
369
451
  if path == "/api/control":
370
452
  return self._api_control()
371
453
  if path == "/api/control/heartbeat":
@@ -374,9 +456,10 @@ class Handler(BaseHTTPRequestHandler):
374
456
  # 写操作需要控制权:空闲自动接管;他人持有时 423,由前端引导抢夺。
375
457
  # 例外(配置管理类操作全局生效,不被「哪台设备在操作」挡住,
376
458
  # 否则告警弹框里的按钮在多端场景会静默 423 失败):
459
+ # /api/hooks/run 用自己的令牌鉴权(外部脚本没有设备控制权握手)。
377
460
  if path in ("/api/health/op", "/api/models/provider-op", "/api/models/model-op",
378
461
  "/api/models/test-provider", "/api/models/test-model",
379
- "/api/models/probe-wire", "/api/models/key-op"):
462
+ "/api/models/probe-wire", "/api/models/key-op", "/api/hooks/run"):
380
463
  pass # 落到下方各自路由
381
464
  else:
382
465
  deny = self._deny_control()
@@ -384,6 +467,8 @@ class Handler(BaseHTTPRequestHandler):
384
467
  return deny
385
468
  if path == "/api/tasks":
386
469
  return self._api_create_task()
470
+ if path == "/api/hooks/run":
471
+ return self._api_hook_run()
387
472
  if path == "/api/health/op":
388
473
  # 供应商健康告警的手动操作(silence 静默 / reset 手动恢复)
389
474
  from core import health
@@ -407,6 +492,8 @@ class Handler(BaseHTTPRequestHandler):
407
492
  if path == "/api/dir/save":
408
493
  # 「查看文件」弹窗编辑保存(本机 + 控制权 + 防穿越 + mtime 冲突检测)
409
494
  return self._api_dir_save()
495
+ if path == "/api/pick_folder":
496
+ return self._api_pick_folder()
410
497
  m = re.match(r"^/api/tasks/([^/]+)/(archive|delete|retry|rename|continue)$", path)
411
498
  if m:
412
499
  if m.group(2) == "archive":
@@ -416,7 +503,11 @@ class Handler(BaseHTTPRequestHandler):
416
503
  ok, err, run = store.retry_task(m.group(1))
417
504
  if not ok:
418
505
  return self._json(400, {"error": err})
419
- jobs.enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": m.group(1)})
506
+ queued, qerr = self._enqueue_run(
507
+ run["id"], m.group(1),
508
+ {"kind": "orchestration", "run_id": run["id"], "task_id": m.group(1)})
509
+ if not queued:
510
+ return self._json(503, {"error": qerr, "run_id": run["id"]})
420
511
  return self._json(200, {"ok": True, "run_id": run["id"]})
421
512
  elif m.group(2) == "continue":
422
513
  # 继续连载:在旧任务基础上新建任务(沿用目标/目录/评审设置,章节号衔接)
@@ -424,11 +515,36 @@ class Handler(BaseHTTPRequestHandler):
424
515
  m.group(1), (self._body() or {}).get("chapters"))
425
516
  if not ok:
426
517
  return self._json(400, {"error": err})
427
- run = store.create_run("orchestration", new_task["title"],
428
- task_id=new_task["id"])
429
- store.update_task_status(new_task["id"], "queued")
430
- jobs.enqueue({"kind": "orchestration",
431
- "run_id": run["id"], "task_id": new_task["id"]})
518
+ run = None
519
+ try:
520
+ run = store.create_run("orchestration", new_task["title"],
521
+ task_id=new_task["id"])
522
+ store.update_task_status(new_task["id"], "queued")
523
+ except Exception:
524
+ # continue_task has already persisted the new task. If run
525
+ # initialization fails, close whichever records exist so a
526
+ # retry is possible and no task remains queued forever.
527
+ log.exception("续写运行初始化失败 task=%s run=%s",
528
+ new_task.get("id"), (run or {}).get("id"))
529
+ if run:
530
+ try:
531
+ store.update_run(
532
+ run["id"], status="failed",
533
+ error="运行记录创建失败,请稍后重试",
534
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
535
+ except Exception:
536
+ log.exception("续写运行失败收口失败 run=%s", run.get("id"))
537
+ try:
538
+ store.update_task_status(new_task["id"], "failed")
539
+ except Exception:
540
+ log.exception("续写任务失败收口失败 task=%s", new_task.get("id"))
541
+ return self._json(503, {"error": "运行记录创建失败,请稍后重试"})
542
+ queued, qerr = self._enqueue_run(
543
+ run["id"], new_task["id"],
544
+ {"kind": "orchestration",
545
+ "run_id": run["id"], "task_id": new_task["id"]})
546
+ if not queued:
547
+ return self._json(503, {"error": qerr, "run_id": run["id"]})
432
548
  return self._json(200, {"ok": True, "task_id": new_task["id"],
433
549
  "run_id": run["id"]})
434
550
  elif m.group(2) == "rename":
@@ -508,7 +624,11 @@ class Handler(BaseHTTPRequestHandler):
508
624
  "checking": manager.updates_checking()})
509
625
  if path == "/api/selfupdate/apply":
510
626
  from core import selfupdate
511
- res = selfupdate.apply_upgrade()
627
+ try:
628
+ res = selfupdate.apply_upgrade()
629
+ except Exception:
630
+ log.exception("自更新任务创建失败")
631
+ return self._json(503, {"error": "升级任务创建失败,请稍后重试"})
512
632
  return self._json(400, res) if res.get("error") else self._json(200, dict(res, ok=True))
513
633
  if path == "/api/selfupdate/restart":
514
634
  from core import selfupdate
@@ -566,6 +686,13 @@ class Handler(BaseHTTPRequestHandler):
566
686
  from core import modelhub
567
687
  n, err = modelhub.refresh_models(self._body().get("id") or "")
568
688
  return self._json(200, {"ok": bool(n), "count": n, "message": err})
689
+ if path == "/api/models/add":
690
+ # 手工添加模型:厂商列表接口调不通时直接填模型名进列表
691
+ from core import modelhub
692
+ body = self._body()
693
+ n, err = modelhub.add_model_manual(body.get("id") or "",
694
+ body.get("name") or "")
695
+ return self._json(200, {"ok": not err, "count": n, "message": err})
569
696
  if path == "/api/models/refresh-all":
570
697
  from core import modelhub
571
698
  n = modelhub.refresh_all_async()
@@ -707,9 +834,22 @@ class Handler(BaseHTTPRequestHandler):
707
834
  return self._json(400, {
708
835
  "error": "无法推导卸载命令:请在 data/catalog.json 的 \"%s\" 里配置 uninstall 字段"
709
836
  % entry["id"]})
837
+ if op in ("install", "upgrade", "uninstall"):
838
+ # 同条目去重闸:已有进行中的管理操作就把本次请求挂到那个 run 上。
839
+ # 两个同包全局 npm 并发装会互锁成双僵尸(2026-09-18 codex 双开案)
840
+ active = store.active_mgmt_run(entry["id"])
841
+ if active:
842
+ return self._json(200, {"run_id": active["id"], "deduped": True})
710
843
  run = store.create_run("mgmt", "%s %s" % (titles[op], entry.get("name", entry["id"])),
711
844
  entry_id=entry["id"], op=op)
712
- jobs.enqueue({"kind": "mgmt", "run_id": run["id"], "entry_id": entry["id"], "op": op})
845
+ queued, qerr = self._enqueue_run(
846
+ run["id"], None,
847
+ {"kind": "mgmt", "run_id": run["id"], "entry_id": entry["id"], "op": op})
848
+ if not queued:
849
+ # 入队失败必须落终态:queued 僵尸会永久堵住去重闸
850
+ store.update_run(run["id"], status="failed", error=qerr or "enqueue 失败",
851
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
852
+ return self._json(503, {"error": qerr, "run_id": run["id"]})
713
853
  return self._json(200, {"run_id": run["id"]})
714
854
  m = re.match(r"^/api/catalog/([^/]+)/launch$", path)
715
855
  if m:
@@ -942,6 +1082,26 @@ class Handler(BaseHTTPRequestHandler):
942
1082
  parent = "" if p.parent == p else str(p.parent)
943
1083
  return self._json(200, {"path": str(p), "parent": parent, "dirs": dirs})
944
1084
 
1085
+ def _api_pick_folder(self):
1086
+ """系统原生「选择文件夹」对话框(工作目录「选择…」/点输入框用)。仅限本机:
1087
+ 对话框弹在服务所在机器上,远端触发等于替别人开窗。pick_dialog.ask_directory
1088
+ 拉起子进程弹真窗口,选中绝对路径直接回给前端回填;用户取消回空 path。
1089
+ 机器没有 tkinter 时 fallback=true,前端回落网页目录弹框,不失去选目录能力。"""
1090
+ ip, fw = self._forwarded_ip()
1091
+ if ip not in ("127.0.0.1", "::1") or fw:
1092
+ return self._json(403, {"error": "目录选择仅限本机使用,请手动输入路径"})
1093
+ body = self._body()
1094
+ if not _PICK_LOCK.acquire(blocking=False):
1095
+ return self._json(200, {"path": "", "busy": True})
1096
+ try:
1097
+ path, err, fb = pick_dialog.ask_directory(str(body.get("initial") or ""),
1098
+ str(body.get("title") or "选择文件夹"))
1099
+ finally:
1100
+ _PICK_LOCK.release()
1101
+ if err:
1102
+ return self._json(200, {"path": "", "fallback": fb, "error": err})
1103
+ return self._json(200, {"path": path or ""})
1104
+
945
1105
  def _api_dir_scan(self):
946
1106
  """侧栏文件夹「查看文件」:递归列出该工作目录下的全部文件(左侧文件页树形展示)。
947
1107
 
@@ -1144,6 +1304,19 @@ class Handler(BaseHTTPRequestHandler):
1144
1304
  attachments=saved_paths)
1145
1305
  if not msg:
1146
1306
  return self._json(400, {"error": "运行不存在或消息非法"})
1307
+ # 终态连载 run 收到递话:自动起答疑轮(op=qa)。此前消息只会躺在信箱里
1308
+ # 无人消费——向已完结的连载任务「下达指令」= 石沉大海(2026-09-18 实案)
1309
+ task = store.get_task(run.get("task_id") or "") if run.get("task_id") else None
1310
+ if (task and task.get("serial")
1311
+ and (run.get("status") or "") not in ("queued", "running")):
1312
+ ok, err, new_run = store.retry_task(run["task_id"])
1313
+ if ok:
1314
+ store.update_run(new_run["id"], op="qa", qa_text=text)
1315
+ self._enqueue_run(new_run["id"], run["task_id"],
1316
+ {"kind": "orchestration", "run_id": new_run["id"],
1317
+ "task_id": run["task_id"]})
1318
+ return self._json(200, {"ok": True, "message": msg,
1319
+ "qa_run": new_run["id"]})
1147
1320
  return self._json(200, {"ok": True, "message": msg})
1148
1321
 
1149
1322
  def _api_run_timeline(self, run_id):
@@ -1202,11 +1375,13 @@ class Handler(BaseHTTPRequestHandler):
1202
1375
  "id": m.get("id"),
1203
1376
  })
1204
1377
 
1205
- def _emit_step(s):
1378
+ def _emit_step(s, run_id_of_step):
1206
1379
  # 正文只认 output(runner 抽好的最终回答)→ summary。绝不回退读原始
1207
1380
  # 日志:日志是全量事件流(下发提示词回显 + CLI 报错),塞进气泡就成了
1208
1381
  # 「看日志」(2026-09-17 实测症状);运行中的步骤两者都还没有,留空给
1209
- # 前端显示「正在执行」占位。
1382
+ # 前端显示「正在执行」占位。log/run 随项下发:气泡上「执行过程」入口
1383
+ # 要按归属 run 打开该步日志(时间线是任务级跨 run 回放,不能拿当前
1384
+ # run id 想当然)。
1210
1385
  body = s.get("output") or ""
1211
1386
  if not body:
1212
1387
  body = s.get("summary") or ""
@@ -1219,6 +1394,9 @@ class Handler(BaseHTTPRequestHandler):
1219
1394
  "status": s.get("status") or "",
1220
1395
  "text": body,
1221
1396
  "note": s.get("note") or "",
1397
+ "run": run_id_of_step,
1398
+ "log": s.get("log") or "",
1399
+ "followups": s.get("followups") or [],
1222
1400
  })
1223
1401
 
1224
1402
  for r in runs:
@@ -1233,31 +1411,73 @@ class Handler(BaseHTTPRequestHandler):
1233
1411
  _emit_msg(m)
1234
1412
  for s in steps:
1235
1413
  if (s.get("status") or "") == "done":
1236
- _emit_step(s)
1414
+ _emit_step(s, r.get("id") or run_id)
1237
1415
  for m in msgs:
1238
1416
  if not m.get("consumed"):
1239
1417
  _emit_msg(m)
1240
1418
  for s in steps:
1241
1419
  if (s.get("status") or "") != "done":
1242
- _emit_step(s)
1420
+ _emit_step(s, r.get("id") or run_id)
1243
1421
  return self._json(200, {
1244
1422
  "run_id": run_id, "status": run.get("status") or "",
1245
1423
  "engine": engine,
1246
1424
  "items": items,
1425
+ "result": self._direct_result(runs[-1] if runs else run, engine),
1247
1426
  })
1248
1427
 
1428
+ def _direct_result(self, latest, engine):
1429
+ """对话页「执行结果」卡的数据:最新 run 到终态后给出确定性摘要——
1430
+ 成没成、跑多久、谁执行的、产出了哪些文件。模型最后一轮回答可能只是
1431
+ 寒暄/追问(用户反馈:输入"1"跑完 55 秒只见一句"消息可能发错了"),
1432
+ 执行结果不能依赖模型自觉交代,由产品明示。"""
1433
+ if engine != "direct" or not latest:
1434
+ return None
1435
+ st = latest.get("status") or ""
1436
+ if st not in ("done", "failed", "cancelled", "timeout"):
1437
+ return None
1438
+ verdict = latest.get("verdict") or {}
1439
+ route = latest.get("route") or {}
1440
+
1441
+ def _sec(a, b):
1442
+ try:
1443
+ return max(0, int(time.mktime(time.strptime(b, "%Y-%m-%d %H:%M:%S")) -
1444
+ time.mktime(time.strptime(a, "%Y-%m-%d %H:%M:%S"))))
1445
+ except Exception:
1446
+ return None
1447
+ t0 = latest.get("started_at") or latest.get("created_at")
1448
+ wd, files = "", []
1449
+ try:
1450
+ wd, files = store.run_artifacts(latest.get("id") or "", limit=12)
1451
+ if files and not store.task_step_count(latest.get("task_id") or ""):
1452
+ files = [] # 与 /files 端点同口径:无步骤的任务不给成品(fixture 防误报)
1453
+ except Exception:
1454
+ wd, files = "", []
1455
+ return {
1456
+ "status": st,
1457
+ "error": (latest.get("error") or "") if st in ("failed", "timeout") else "",
1458
+ "executor": route.get("implementer") or verdict.get("impl") or "",
1459
+ "turns": verdict.get("turns") or 0,
1460
+ "duration_s": _sec(t0, latest.get("ended_at") or "")
1461
+ if t0 and latest.get("ended_at") else None,
1462
+ "workdir": wd,
1463
+ "files": files,
1464
+ }
1465
+
1249
1466
  def _api_direct_chat(self, run_id):
1250
- """直连对话追话:往已结束的 direct run 追加一条消息并自动续跑。
1467
+ """追话:往已结束的 run 追加一条消息并自动续跑。
1251
1468
 
1252
- 机制:消息入旧 run 信箱 → retry_task 起新 run(未消费消息自动继承)
1469
+ 直连任务:消息入旧 run 信箱 → retry_task 起新 run(未消费消息自动继承)
1253
1470
  → 入队编排 → _run_direct 看到信箱积压走续轮档(DIRECT_FOLLOWUP)。
1254
- 仅 direct 引擎任务可用;运行中的 run 走既有 /messages(轮间注入)。
1471
+ 连载任务:走答疑档(op=qa)——单步只读回答,不再整本重跑(一句
1472
+ 「为啥没有第九章」触发全量重评+连环自动续跑,2026-09-18 实案)。
1473
+ 运行中的 run 走既有 /messages(轮间注入)。
1255
1474
  写接口已在 do_POST 统一做过设备控制。"""
1256
1475
  run = store.get_run(run_id)
1257
1476
  if not run:
1258
1477
  return self._json(404, {"error": "not found"})
1259
1478
  task = store.get_task(run.get("task_id") or "") if run.get("task_id") else None
1260
- if not task or task.get("engine") != "direct":
1479
+ is_serial = bool(task and task.get("serial"))
1480
+ if not task or (task.get("engine") != "direct" and not is_serial):
1261
1481
  return self._json(400, {"error": "该任务不是直连任务,请用「下达指令」"})
1262
1482
  if (run.get("status") or "") in ("queued", "running"):
1263
1483
  return self._json(400, {"error": "运行中:消息会随下一步自动送达,无需追话"})
@@ -1282,7 +1502,15 @@ class Handler(BaseHTTPRequestHandler):
1282
1502
  ok, err, new_run = store.retry_task(task["id"])
1283
1503
  if not ok:
1284
1504
  return self._json(400, {"error": err or "无法续跑"})
1285
- jobs.enqueue({"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
1505
+ if is_serial:
1506
+ # 答疑档:问题文本显式带上(真实调用失败会把信箱消息 drain 掉,
1507
+ # 换将重试时不能丢问题);retry_task 只认终态任务,active 已挡
1508
+ store.update_run(new_run["id"], op="qa", qa_text=text)
1509
+ queued, qerr = self._enqueue_run(
1510
+ new_run["id"], task["id"],
1511
+ {"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
1512
+ if not queued:
1513
+ return self._json(503, {"error": qerr, "run_id": new_run["id"]})
1286
1514
  return self._json(200, {"ok": True, "run_id": new_run["id"]})
1287
1515
 
1288
1516
  def _api_retract_message(self, run_id):
@@ -1341,16 +1569,108 @@ class Handler(BaseHTTPRequestHandler):
1341
1569
  except Exception:
1342
1570
  pass # 客户端断开是常态,线程随进程退出
1343
1571
 
1344
- def _api_create_task(self):
1345
- body = self._body()
1572
+ def _create_and_start(self, body):
1573
+ """建任务并起跑(UI /api/tasks 与外部 webhook /api/hooks/run 共用一条链)。
1574
+
1575
+ 返回 (http_status, response_dict);失败路径都已收口(run/任务状态不会
1576
+ 永久卡在排队中)。"""
1346
1577
  try:
1347
1578
  task = store.create_task(body)
1348
1579
  except ValueError as e:
1349
- return self._json(400, {"error": str(e)})
1350
- run = store.create_run("orchestration", task["title"], task_id=task["id"])
1351
- store.update_task_status(task["id"], "queued")
1352
- jobs.enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
1353
- return self._json(200, {"task_id": task["id"], "run_id": run["id"]})
1580
+ return 400, {"error": str(e)}
1581
+ run = None
1582
+ try:
1583
+ run = store.create_run("orchestration", task["title"], task_id=task["id"])
1584
+ store.update_task_status(task["id"], "queued")
1585
+ except Exception:
1586
+ # create_run 已落盘后,update_task_status 仍可能因磁盘/JSON 错误失败。
1587
+ # 这时必须把已经存在的 run 收口,否则 UI 会永久显示「排队中」。
1588
+ log.exception("创建任务运行记录失败 task=%s run=%s", task.get("id"),
1589
+ (run or {}).get("id"))
1590
+ if run:
1591
+ try:
1592
+ store.update_run(run["id"], status="failed",
1593
+ error="运行记录初始化失败",
1594
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
1595
+ except Exception:
1596
+ log.exception("收口失败的运行记录失败 run=%s", run.get("id"))
1597
+ try:
1598
+ store.update_task_status(task["id"], "failed")
1599
+ except Exception:
1600
+ log.exception("收口失败的任务状态失败 task=%s", task.get("id"))
1601
+ return 503, {"error": "运行记录创建失败,请稍后重试"}
1602
+ queued, qerr = self._enqueue_run(
1603
+ run["id"], task["id"],
1604
+ {"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
1605
+ if not queued:
1606
+ return 503, {"error": qerr, "run_id": run["id"]}
1607
+ return 200, {"task_id": task["id"], "run_id": run["id"]}
1608
+
1609
+ def _api_create_task(self):
1610
+ status, resp = self._create_and_start(self._body())
1611
+ return self._json(status, resp)
1612
+
1613
+ def _api_hook_run(self):
1614
+ """外部触发开任务(webhook,借鉴 emdash/mission-control 的外部集成面)。
1615
+
1616
+ POST /api/hooks/run,头 X-CodeBee-Token;体 {goal 必填, type, workdir,
1617
+ context, title}。令牌取设置 hooks_token(data/settings.json,UI/文件均可
1618
+ 配置):已配置则必须精确匹配;未配置仅放行本机回环。任务链与 UI 完全相同。"""
1619
+ try:
1620
+ tok = str(settings.load().get("hooks_token") or "")
1621
+ except Exception:
1622
+ tok = ""
1623
+ given = (self.headers.get("X-CodeBee-Token") or "").strip()
1624
+ if tok:
1625
+ if given != tok:
1626
+ return self._json(401, {"error": "令牌不匹配"})
1627
+ else:
1628
+ host = str(self.client_address[0]) if self.client_address else ""
1629
+ if host not in ("127.0.0.1", "::1", "::ffff:127.0.0.1"):
1630
+ return self._json(403, {
1631
+ "error": "未配置 hooks.token,仅允许本机触发;请先在设置中配置令牌"})
1632
+ raw = self._body()
1633
+ payload = {}
1634
+ for k, cap in (("goal", 4000), ("context", 4000), ("type", 40),
1635
+ ("workdir", 300), ("title", 80)):
1636
+ v = str(raw.get(k) or "").strip()
1637
+ if v:
1638
+ payload[k] = v[:cap]
1639
+ if not payload.get("goal"):
1640
+ return self._json(400, {"error": "goal 必填"})
1641
+ if not payload.get("type"):
1642
+ payload["type"] = "direct"
1643
+ if not payload.get("title"):
1644
+ payload["title"] = payload["goal"][:30]
1645
+ status, resp = self._create_and_start(payload)
1646
+ return self._json(status, resp)
1647
+
1648
+ def _enqueue_run(self, run_id, task_id, job):
1649
+ """入队失败时把已持久化记录收口到 failed,避免 UI 永远显示排队中。"""
1650
+ try:
1651
+ jobs.enqueue(job)
1652
+ return True, ""
1653
+ except Exception:
1654
+ # 不把异常文本(本机路径、命令行参数、供应商响应)返回给客户端;
1655
+ # 详细堆栈只进服务端日志,run 记录也保留稳定的用户可读文案。
1656
+ log.exception("任务入队失败 run=%s task=%s", run_id, task_id)
1657
+ err = "任务入队失败,请稍后重试"
1658
+ try:
1659
+ closed = store.update_run(run_id, status="failed", error=err,
1660
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
1661
+ if closed is None:
1662
+ # The run may have been removed between creation and enqueue
1663
+ # (for example, an operator cleared history concurrently).
1664
+ # Still force the task out of queued so the UI cannot wait
1665
+ # forever on a record that no longer exists.
1666
+ raise RuntimeError("运行记录不存在")
1667
+ except Exception:
1668
+ if task_id:
1669
+ try:
1670
+ store.update_task_status(task_id, "failed")
1671
+ except Exception:
1672
+ pass
1673
+ return False, err
1354
1674
 
1355
1675
  def _api_set_preference(self):
1356
1676
  body = self._body()
@@ -1470,13 +1790,27 @@ def main():
1470
1790
  n_rc = store.recover_orphaned_runs()
1471
1791
  if n_rc:
1472
1792
  print("[CodeBee] 崩溃恢复:%d 个遗留运行标记为 failed(interrupted at startup)" % n_rc)
1793
+ n_mg = store.recover_interrupted_mgmt()
1794
+ if n_mg:
1795
+ print("[CodeBee] 崩溃恢复:%d 个遗留管理操作标记为 failed(interrupted at startup)" % n_mg)
1473
1796
  try:
1797
+ # 孤儿 CLI 清扫走后台线程:PowerShell Get-CimInstance 在部分机器上会慢满
1798
+ # timeout(真实装机 60s,启动被白拖一分钟且无任何提示——看门狗堆栈抓到)。
1799
+ # 清扫是尽力而为的旁路;60s timeout 保证它终会结束,但不能挡服务就绪。
1474
1800
  from core import manager as _mgr
1475
- n_z = _mgr.sweep_orphan_cli_processes()
1476
- if n_z:
1477
- # 服务重启孤儿化的 CLI 孙进程:僵尸 opencode 会劫持后续会话的项目根,
1478
- # 必须在恢复运行之前清掉(2026-09-17 mo-so「工作目录是 Temp」真凶)
1479
- print("[CodeBee] 崩溃恢复:清扫 %d 个孤儿 CLI 进程(opencode/codex)" % n_z)
1801
+
1802
+ def _sweep_async():
1803
+ try:
1804
+ n_z = _mgr.sweep_orphan_cli_processes()
1805
+ if n_z:
1806
+ # 服务重启孤儿化的 CLI 孙进程:僵尸 opencode 会劫持后续会话
1807
+ # 的项目根(2026-09-17 mo-so「工作目录是 Temp」真凶)
1808
+ print("[CodeBee] 后台清扫:%d 个孤儿 CLI 进程(opencode/codex/kimi)"
1809
+ % n_z, flush=True)
1810
+ except Exception:
1811
+ pass
1812
+ threading.Thread(target=_sweep_async, name="orphan-sweep",
1813
+ daemon=True).start()
1480
1814
  except Exception:
1481
1815
  pass
1482
1816
  from core import bookmeta
@@ -1488,11 +1822,19 @@ def main():
1488
1822
  settings_schema.register_default_namespaces() # budget/cascade/compaction 配置就绪(幂等)
1489
1823
  except Exception:
1490
1824
  pass
1825
+ try:
1826
+ from core import telemetry
1827
+ telemetry.start_background() # 匿名错误回传+版本 ping(默认开可关;未配端点自动休眠,延迟 45s 不挡启动)
1828
+ except Exception:
1829
+ pass
1491
1830
  _step("正在启动任务队列…")
1492
1831
  jobs.start_worker()
1493
1832
  n_resume = jobs.resume_interrupted() # 启动恢复:服务被杀中断的连载任务自动续跑
1494
1833
  if n_resume:
1495
1834
  print("[CodeBee] 已自动恢复 %d 个中断的连载任务(断点续跑)" % n_resume)
1835
+ n_rq = jobs.requeue_pending() # 启动补队:队列在内存里,重启会让排队项变僵尸
1836
+ if n_rq:
1837
+ print("[CodeBee] 已重新入队 %d 个遗留排队运行" % n_rq)
1496
1838
  _step("正在启动自动化调度…")
1497
1839
  n_auto = automation.start() # 自动化:加载定时任务并拉起调度线程(错过的一次性任务不补跑)
1498
1840
  if n_auto: