codebee 0.1.7 → 0.1.9

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.
@@ -0,0 +1,291 @@
1
+ # -*- coding: utf-8 -*-
2
+ """遥测回传(L2)+ 版本 ping(L3)+ 诊断包构建(L1)。
3
+
4
+ 设计原则:
5
+ - 默认只传「匿名错误元数据」:errorlog 台账里的结构化失败记录(厂商/模型/原因码/
6
+ 脱敏摘录/版本/OS),不含任何任务正文、章节内容、密钥、绝对路径;
7
+ - 上传前对每条记录再兜一道 scrub_text(不信任入台账时的第一道);
8
+ - 开关 settings.telemetry_errors(默认开);关掉后什么都不出机器(含版本 ping);
9
+ - 网络失败静默吞掉:遥测永远不影响业务,也不制造新错误;
10
+ - 端点:CloudBase HTTP 函数(国内可达、零运维),可用环境变量
11
+ TUTTI_TELEMETRY_URL 覆盖;端点为空/未配置时整个模块自动休眠。
12
+
13
+ SSRF 防线(端点可被 env 覆盖,所以请求前必须自证安全):
14
+ https 限定 + 域名后缀白名单 + 解析 IP 拒绝私网/环回/链路本地。
15
+
16
+ 线程模型:start_background() 起一条 daemon 线程,首轮延迟 45s(不挡启动),
17
+ 之后每 6 小时一轮(ping 每轮一次,错误按游标增量上传)。
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import io
22
+ import ipaddress
23
+ import json
24
+ import os
25
+ import platform
26
+ import socket
27
+ import threading
28
+ import time
29
+ import urllib.request
30
+ import zipfile
31
+
32
+ from . import errorlog, paths, settings
33
+
34
+ # 端点:部署后把 CloudBase HTTP 函数 URL 填到这里(见 cloudfunctions/telemetry-collect/)
35
+ ENDPOINT = os.environ.get("TUTTI_TELEMETRY_URL", "").strip()
36
+
37
+ # 请求只允许发往我们自己的收集域(后缀匹配,大小写不敏感);
38
+ # 测试需要假端点时 monkeypatch 本常量或直接 patch _post。
39
+ ALLOWED_SUFFIXES = (".tcloudbase.com", ".tencentyun.com", ".tencentcs.com")
40
+
41
+ FIRST_DELAY_S = 45
42
+ INTERVAL_S = 6 * 3600
43
+ BATCH_LIMIT = 200
44
+ TIMEOUT_S = 10
45
+
46
+ _thread_started = False
47
+
48
+
49
+ def _cursor_path():
50
+ """游标文件路径惰性计算:测试会把 paths.ERRORS_DIR 重定向到临时目录,
51
+ import 期绑定会钉死在真实数据目录(与 settings._FILE 同一个坑)。"""
52
+ return paths.ERRORS_DIR / "upload.cursor"
53
+
54
+
55
+ def enabled():
56
+ try:
57
+ return bool(settings.load().get("telemetry_errors", True))
58
+ except Exception:
59
+ return False
60
+
61
+
62
+ def _os_tag():
63
+ return {"win32": "windows", "darwin": "macos"}.get(platform.system().lower(),
64
+ platform.system().lower())
65
+
66
+
67
+ def _version():
68
+ try:
69
+ from . import selfupdate
70
+ return str(selfupdate.package_version() or "dev")
71
+ except Exception:
72
+ return "dev"
73
+
74
+
75
+ def _endpoint_safe(url):
76
+ """端点自证安全:https + 域名后缀白名单 + 解析 IP 全部公网。
77
+ 不合格返回 ""(调用方静默跳过——遥测绝不为安全边界让步)。"""
78
+ if not url:
79
+ return ""
80
+ try:
81
+ from urllib.parse import urlparse
82
+ p = urlparse(url)
83
+ if p.scheme != "https" or not p.hostname:
84
+ return ""
85
+ host = p.hostname.lower()
86
+ if not any(host == s.lstrip(".") or host.endswith(s)
87
+ for s in ALLOWED_SUFFIXES):
88
+ return ""
89
+ for info in socket.getaddrinfo(host, 443, proto=socket.IPPROTO_TCP):
90
+ ip = ipaddress.ip_address(str(info[4][0]))
91
+ if (ip.is_private or ip.is_loopback or ip.is_link_local
92
+ or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
93
+ return ""
94
+ return url
95
+ except Exception:
96
+ return ""
97
+
98
+
99
+ def _post(url, payload):
100
+ """POST JSON 到已通过 _endpoint_safe 校验的 URL;返回状态码,异常返回 0。"""
101
+ safe = _endpoint_safe(url)
102
+ if not safe:
103
+ return 0
104
+ try:
105
+ req = urllib.request.Request(
106
+ safe, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
107
+ headers={"Content-Type": "application/json",
108
+ "User-Agent": "CodeBee-Telemetry/1"},
109
+ method="POST")
110
+ with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
111
+ return int(resp.status or 0)
112
+ except Exception:
113
+ return 0
114
+
115
+
116
+ # ---------------------------------------------------------------- 上报循环
117
+
118
+ def _read_cursor():
119
+ try:
120
+ return _cursor_path().read_text(encoding="utf-8").strip()
121
+ except Exception:
122
+ return ""
123
+
124
+
125
+ def _write_cursor(v):
126
+ try:
127
+ p = _cursor_path()
128
+ p.parent.mkdir(parents=True, exist_ok=True)
129
+ p.write_text(str(v), encoding="utf-8")
130
+ except Exception:
131
+ pass
132
+
133
+
134
+ def _sanitize_record(r):
135
+ """上传前的最后一道闸:只挑白名单字段,detail 再过一次 scrub。"""
136
+ keep = ("id", "ts", "day", "category", "reason", "provider", "model",
137
+ "tool", "role", "run_id", "task_id", "step", "exit_code",
138
+ "app_version", "os")
139
+ out = {k: r.get(k) for k in keep if k in r}
140
+ out["detail"] = errorlog.scrub_text(r.get("detail") or "", limit=600)
141
+ return out
142
+
143
+
144
+ def run_once():
145
+ """一轮上报:版本 ping + 增量错误批量。返回 (ping_ok, uploaded_n)。"""
146
+ if not enabled() or not ENDPOINT:
147
+ return (False, 0)
148
+ ping_ok = _post(ENDPOINT, {
149
+ "kind": "ping", "app": "codebee", "version": _version(),
150
+ "os": _os_tag(), "python": "%d.%d" % platform.python_version_tuple()[:2],
151
+ "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
152
+ }) == 200
153
+ uploaded = 0
154
+ cursor = _read_cursor()
155
+ batch, new_cursor = errorlog.pending_since(cursor, limit=BATCH_LIMIT)
156
+ if batch:
157
+ payload = {"kind": "errors", "records": [_sanitize_record(r) for r in batch]}
158
+ if _post(ENDPOINT, payload) == 200:
159
+ _write_cursor(new_cursor)
160
+ uploaded = len(batch)
161
+ return (ping_ok, uploaded)
162
+
163
+
164
+ def _loop():
165
+ while True:
166
+ try:
167
+ run_once()
168
+ except Exception:
169
+ pass
170
+ time.sleep(INTERVAL_S)
171
+
172
+
173
+ def start_background():
174
+ """启动上报线程(daemon,首轮延迟 45s)。重复调用安全;未配端点直接休眠。"""
175
+ global _thread_started
176
+ if _thread_started or not ENDPOINT:
177
+ return
178
+ _thread_started = True
179
+
180
+ def _delayed():
181
+ time.sleep(FIRST_DELAY_S)
182
+ try:
183
+ run_once()
184
+ except Exception:
185
+ pass
186
+ _loop()
187
+
188
+ threading.Thread(target=_delayed, name="telemetry-up", daemon=True).start()
189
+
190
+
191
+ # ---------------------------------------------------------------- 诊断包(L1)
192
+
193
+ def build_bundle_bytes(days=30):
194
+ """构建「诊断包」zip 字节流:脱敏错误台账 + 用量台账 + 环境元信息。
195
+
196
+ 只装可安全外发的聚合数据——绝不打包 tasks/runs/models.json(后者含密钥)。
197
+ 错误记录出包前再 scrub 一遍(与上传同一道闸,出机器的东西不过两道不放行)。
198
+ """
199
+ meta = {
200
+ "app": "CodeBee",
201
+ "version": _version(),
202
+ "os": _os_tag(),
203
+ "os_version": platform.platform(),
204
+ "python": platform.python_version(),
205
+ "telemetry_enabled": enabled(),
206
+ "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
207
+ "note": "此包只含匿名错误元数据与用量统计;已剥离密钥、路径与任务内容。",
208
+ }
209
+ errors = [_sanitize_record(r) for r in errorlog.iter_records(days)]
210
+ buf = io.BytesIO()
211
+ stamp = time.strftime("%Y%m%d-%H%M%S")
212
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
213
+ z.writestr("codebee-diag-%s/meta.json" % stamp,
214
+ json.dumps(meta, ensure_ascii=False, indent=2))
215
+ z.writestr("codebee-diag-%s/errors-%ddays.jsonl" % (stamp, days),
216
+ "\n".join(json.dumps(r, ensure_ascii=False) for r in errors))
217
+ usage_lines = []
218
+ try:
219
+ from . import usage
220
+ for r in usage._iter_records(days):
221
+ usage_lines.append(json.dumps(r, ensure_ascii=False))
222
+ except Exception:
223
+ pass
224
+ z.writestr("codebee-diag-%s/usage-%ddays.jsonl" % (stamp, days),
225
+ "\n".join(usage_lines))
226
+ return buf.getvalue()
227
+
228
+
229
+ # ---------------------------------------------------------------- 一键反馈 Issue(免费通道)
230
+
231
+ # 反馈目标仓库(Issue 预填正文,由用户在浏览器亲手提交——不自动回传)
232
+ ISSUE_URL = "https://github.com/Vercel-By-WXP/CodeBee/issues/new"
233
+
234
+
235
+ def issue_report(days=30, max_groups=8, max_recent=10, detail_chars=120):
236
+ """本地错误台账 → GitHub Issue 预填摘要。返回 {title, body}。
237
+
238
+ 聚合 + 明细全部来自已脱敏的 errorlog 台账,出正文前再 scrub 一道
239
+ (与上传/诊断包同一纪律:出机器的东西过两道不放行)。明细压成单行,
240
+ 方便 URL 预填。days=0 表示全部历史。
241
+ """
242
+ errors = errorlog.iter_records(days)
243
+ groups = {}
244
+ for r in errors:
245
+ key = (str(r.get("reason") or "UNKNOWN"),
246
+ str(r.get("provider") or "-"),
247
+ str(r.get("model") or "-"))
248
+ g = groups.setdefault(key, {"n": 0, "last": ""})
249
+ g["n"] += 1
250
+ if str(r.get("ts") or "") > g["last"]:
251
+ g["last"] = str(r.get("ts") or "")
252
+ top = sorted(groups.items(), key=lambda kv: -kv[1]["n"])[:max_groups]
253
+ version = _version()
254
+
255
+ if top:
256
+ t_reason, t_prov, t_model = top[0][0]
257
+ title = "错误反馈:%s ×%d(v%s / %s)" % (
258
+ t_reason, top[0][1]["n"], version, _os_tag())
259
+ else:
260
+ title = "CodeBee 错误反馈(无自动记录的错误)"
261
+
262
+ lines = [
263
+ "## CodeBee 错误反馈", "",
264
+ "- 版本:v%s(%s)" % (version, _os_tag()),
265
+ "- Python:%s" % platform.python_version(),
266
+ "- 生成时间:%s" % time.strftime("%Y-%m-%d %H:%M:%S"),
267
+ "",
268
+ ("近 %d 天失败聚合(原因 × 次数)" % days) if days
269
+ else "全部历史失败聚合(原因 × 次数)",
270
+ ]
271
+ if top:
272
+ lines.append("| 原因 | 供应商/模型 | 次数 | 最近一次 |")
273
+ lines.append("|---|---|---|---|")
274
+ for (reason, prov, model), g in top:
275
+ lines.append("| %s | %s/%s | %d | %s |" % (reason, prov, model,
276
+ g["n"], g["last"]))
277
+ else:
278
+ lines.append("(本机错误台账为空)")
279
+ lines += ["", "## 最近失败明细(最多 %d 条,已脱敏)" % max_recent]
280
+ if errors:
281
+ for r in errors[-max_recent:]:
282
+ detail = errorlog.scrub_text(r.get("detail") or "", limit=detail_chars)
283
+ detail = detail.replace("\r", " ").replace("\n", " ")
284
+ lines.append("- `%s` %s [%s/%s] %s" % (
285
+ str(r.get("ts") or ""), str(r.get("reason") or "UNKNOWN"),
286
+ str(r.get("provider") or "-"), str(r.get("model") or "-"), detail))
287
+ else:
288
+ lines.append("(无)")
289
+ lines += ["", "> 提示:可在 设置 → 关于与更新 → 导出诊断包 生成 zip 附在本 Issue"
290
+ "(含更完整的脱敏错误记录与用量统计)。", ""]
291
+ return {"title": title, "body": "\n".join(lines)}
@@ -121,6 +121,24 @@ class TokenMeter:
121
121
  cap = cap_table.get(m, cap_table.get("", 128_000))
122
122
  return self.used(run_id) / max(cap, 1)
123
123
 
124
+ def capacity(self, model: str = "") -> int:
125
+ """模型上下文容量(未命中用默认行)。"""
126
+ cap_table = self._capacity()
127
+ return cap_table.get(model or "", cap_table.get("", 128_000))
128
+
129
+ def last_context(self, run_id: str) -> int:
130
+ """最近一次调用的 input+output+reasoning——下个请求要重发的上下文锚点。
131
+
132
+ used() 是窗口累计(多次调用叠加,系统性偏大,适合做响应式预算闸);
133
+ 事前预检判断「这个请求会不会被拒」要用最近一次的真实上下文体量,
134
+ 两者口径不同不可混用(借鉴 freebuff base-chat 每步容量重估)。"""
135
+ with self._lock:
136
+ window = self._windows.get(run_id)
137
+ if not window:
138
+ return 0
139
+ _, i, o, r, _ = window[-1]
140
+ return i + o + r
141
+
124
142
  def reset(self, run_id: str):
125
143
  with self._lock:
126
144
  self._windows.pop(run_id, None)
package/app/main.py CHANGED
@@ -23,6 +23,7 @@ from urllib.parse import parse_qs, unquote, urlparse
23
23
  from core import automation, catalog, flows, jobs, manager, market, market_remote, registry, remote, settings, store
24
24
  from core import paths
25
25
  from core import health
26
+ import pick_dialog
26
27
 
27
28
  log = logging.getLogger(__name__)
28
29
 
@@ -72,6 +73,13 @@ class RequestBodyError(ValueError):
72
73
  _SKIP_DIRS_SHARE = {".git", "node_modules", "__pycache__", ".venv", "venv",
73
74
  ".idea", ".vscode", "_attachments"}
74
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
+
75
83
 
76
84
  class Handler(BaseHTTPRequestHandler):
77
85
  server_version = "CodeBee/1.0"
@@ -264,6 +272,18 @@ class Handler(BaseHTTPRequestHandler):
264
272
  except ValueError:
265
273
  edays = 90
266
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))
267
287
  m = re.match(r"^/api/runs/([^/]+)$", path)
268
288
  if m:
269
289
  run = store.get_run(m.group(1))
@@ -436,9 +456,10 @@ class Handler(BaseHTTPRequestHandler):
436
456
  # 写操作需要控制权:空闲自动接管;他人持有时 423,由前端引导抢夺。
437
457
  # 例外(配置管理类操作全局生效,不被「哪台设备在操作」挡住,
438
458
  # 否则告警弹框里的按钮在多端场景会静默 423 失败):
459
+ # /api/hooks/run 用自己的令牌鉴权(外部脚本没有设备控制权握手)。
439
460
  if path in ("/api/health/op", "/api/models/provider-op", "/api/models/model-op",
440
461
  "/api/models/test-provider", "/api/models/test-model",
441
- "/api/models/probe-wire", "/api/models/key-op"):
462
+ "/api/models/probe-wire", "/api/models/key-op", "/api/hooks/run"):
442
463
  pass # 落到下方各自路由
443
464
  else:
444
465
  deny = self._deny_control()
@@ -446,6 +467,8 @@ class Handler(BaseHTTPRequestHandler):
446
467
  return deny
447
468
  if path == "/api/tasks":
448
469
  return self._api_create_task()
470
+ if path == "/api/hooks/run":
471
+ return self._api_hook_run()
449
472
  if path == "/api/health/op":
450
473
  # 供应商健康告警的手动操作(silence 静默 / reset 手动恢复)
451
474
  from core import health
@@ -469,6 +492,8 @@ class Handler(BaseHTTPRequestHandler):
469
492
  if path == "/api/dir/save":
470
493
  # 「查看文件」弹窗编辑保存(本机 + 控制权 + 防穿越 + mtime 冲突检测)
471
494
  return self._api_dir_save()
495
+ if path == "/api/pick_folder":
496
+ return self._api_pick_folder()
472
497
  m = re.match(r"^/api/tasks/([^/]+)/(archive|delete|retry|rename|continue)$", path)
473
498
  if m:
474
499
  if m.group(2) == "archive":
@@ -661,6 +686,13 @@ class Handler(BaseHTTPRequestHandler):
661
686
  from core import modelhub
662
687
  n, err = modelhub.refresh_models(self._body().get("id") or "")
663
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})
664
696
  if path == "/api/models/refresh-all":
665
697
  from core import modelhub
666
698
  n = modelhub.refresh_all_async()
@@ -802,12 +834,21 @@ class Handler(BaseHTTPRequestHandler):
802
834
  return self._json(400, {
803
835
  "error": "无法推导卸载命令:请在 data/catalog.json 的 \"%s\" 里配置 uninstall 字段"
804
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})
805
843
  run = store.create_run("mgmt", "%s %s" % (titles[op], entry.get("name", entry["id"])),
806
844
  entry_id=entry["id"], op=op)
807
845
  queued, qerr = self._enqueue_run(
808
846
  run["id"], None,
809
847
  {"kind": "mgmt", "run_id": run["id"], "entry_id": entry["id"], "op": op})
810
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"))
811
852
  return self._json(503, {"error": qerr, "run_id": run["id"]})
812
853
  return self._json(200, {"run_id": run["id"]})
813
854
  m = re.match(r"^/api/catalog/([^/]+)/launch$", path)
@@ -1041,6 +1082,26 @@ class Handler(BaseHTTPRequestHandler):
1041
1082
  parent = "" if p.parent == p else str(p.parent)
1042
1083
  return self._json(200, {"path": str(p), "parent": parent, "dirs": dirs})
1043
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
+
1044
1105
  def _api_dir_scan(self):
1045
1106
  """侧栏文件夹「查看文件」:递归列出该工作目录下的全部文件(左侧文件页树形展示)。
1046
1107
 
@@ -1243,6 +1304,19 @@ class Handler(BaseHTTPRequestHandler):
1243
1304
  attachments=saved_paths)
1244
1305
  if not msg:
1245
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"]})
1246
1320
  return self._json(200, {"ok": True, "message": msg})
1247
1321
 
1248
1322
  def _api_run_timeline(self, run_id):
@@ -1322,6 +1396,7 @@ class Handler(BaseHTTPRequestHandler):
1322
1396
  "note": s.get("note") or "",
1323
1397
  "run": run_id_of_step,
1324
1398
  "log": s.get("log") or "",
1399
+ "followups": s.get("followups") or [],
1325
1400
  })
1326
1401
 
1327
1402
  for r in runs:
@@ -1389,17 +1464,20 @@ class Handler(BaseHTTPRequestHandler):
1389
1464
  }
1390
1465
 
1391
1466
  def _api_direct_chat(self, run_id):
1392
- """直连对话追话:往已结束的 direct run 追加一条消息并自动续跑。
1467
+ """追话:往已结束的 run 追加一条消息并自动续跑。
1393
1468
 
1394
- 机制:消息入旧 run 信箱 → retry_task 起新 run(未消费消息自动继承)
1469
+ 直连任务:消息入旧 run 信箱 → retry_task 起新 run(未消费消息自动继承)
1395
1470
  → 入队编排 → _run_direct 看到信箱积压走续轮档(DIRECT_FOLLOWUP)。
1396
- 仅 direct 引擎任务可用;运行中的 run 走既有 /messages(轮间注入)。
1471
+ 连载任务:走答疑档(op=qa)——单步只读回答,不再整本重跑(一句
1472
+ 「为啥没有第九章」触发全量重评+连环自动续跑,2026-09-18 实案)。
1473
+ 运行中的 run 走既有 /messages(轮间注入)。
1397
1474
  写接口已在 do_POST 统一做过设备控制。"""
1398
1475
  run = store.get_run(run_id)
1399
1476
  if not run:
1400
1477
  return self._json(404, {"error": "not found"})
1401
1478
  task = store.get_task(run.get("task_id") or "") if run.get("task_id") else None
1402
- 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):
1403
1481
  return self._json(400, {"error": "该任务不是直连任务,请用「下达指令」"})
1404
1482
  if (run.get("status") or "") in ("queued", "running"):
1405
1483
  return self._json(400, {"error": "运行中:消息会随下一步自动送达,无需追话"})
@@ -1424,6 +1502,10 @@ class Handler(BaseHTTPRequestHandler):
1424
1502
  ok, err, new_run = store.retry_task(task["id"])
1425
1503
  if not ok:
1426
1504
  return self._json(400, {"error": err or "无法续跑"})
1505
+ if is_serial:
1506
+ # 答疑档:问题文本显式带上(真实调用失败会把信箱消息 drain 掉,
1507
+ # 换将重试时不能丢问题);retry_task 只认终态任务,active 已挡
1508
+ store.update_run(new_run["id"], op="qa", qa_text=text)
1427
1509
  queued, qerr = self._enqueue_run(
1428
1510
  new_run["id"], task["id"],
1429
1511
  {"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
@@ -1487,12 +1569,15 @@ class Handler(BaseHTTPRequestHandler):
1487
1569
  except Exception:
1488
1570
  pass # 客户端断开是常态,线程随进程退出
1489
1571
 
1490
- def _api_create_task(self):
1491
- 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
+ 永久卡在排队中)。"""
1492
1577
  try:
1493
1578
  task = store.create_task(body)
1494
1579
  except ValueError as e:
1495
- return self._json(400, {"error": str(e)})
1580
+ return 400, {"error": str(e)}
1496
1581
  run = None
1497
1582
  try:
1498
1583
  run = store.create_run("orchestration", task["title"], task_id=task["id"])
@@ -1513,13 +1598,52 @@ class Handler(BaseHTTPRequestHandler):
1513
1598
  store.update_task_status(task["id"], "failed")
1514
1599
  except Exception:
1515
1600
  log.exception("收口失败的任务状态失败 task=%s", task.get("id"))
1516
- return self._json(503, {"error": "运行记录创建失败,请稍后重试"})
1601
+ return 503, {"error": "运行记录创建失败,请稍后重试"}
1517
1602
  queued, qerr = self._enqueue_run(
1518
1603
  run["id"], task["id"],
1519
1604
  {"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
1520
1605
  if not queued:
1521
- return self._json(503, {"error": qerr, "run_id": run["id"]})
1522
- return self._json(200, {"task_id": task["id"], "run_id": run["id"]})
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)
1523
1647
 
1524
1648
  def _enqueue_run(self, run_id, task_id, job):
1525
1649
  """入队失败时把已持久化记录收口到 failed,避免 UI 永远显示排队中。"""
@@ -1666,13 +1790,27 @@ def main():
1666
1790
  n_rc = store.recover_orphaned_runs()
1667
1791
  if n_rc:
1668
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)
1669
1796
  try:
1797
+ # 孤儿 CLI 清扫走后台线程:PowerShell Get-CimInstance 在部分机器上会慢满
1798
+ # timeout(真实装机 60s,启动被白拖一分钟且无任何提示——看门狗堆栈抓到)。
1799
+ # 清扫是尽力而为的旁路;60s timeout 保证它终会结束,但不能挡服务就绪。
1670
1800
  from core import manager as _mgr
1671
- n_z = _mgr.sweep_orphan_cli_processes()
1672
- if n_z:
1673
- # 服务重启孤儿化的 CLI 孙进程:僵尸 opencode 会劫持后续会话的项目根,
1674
- # 必须在恢复运行之前清掉(2026-09-17 mo-so「工作目录是 Temp」真凶)
1675
- 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()
1676
1814
  except Exception:
1677
1815
  pass
1678
1816
  from core import bookmeta
@@ -1684,11 +1822,19 @@ def main():
1684
1822
  settings_schema.register_default_namespaces() # budget/cascade/compaction 配置就绪(幂等)
1685
1823
  except Exception:
1686
1824
  pass
1825
+ try:
1826
+ from core import telemetry
1827
+ telemetry.start_background() # 匿名错误回传+版本 ping(默认开可关;未配端点自动休眠,延迟 45s 不挡启动)
1828
+ except Exception:
1829
+ pass
1687
1830
  _step("正在启动任务队列…")
1688
1831
  jobs.start_worker()
1689
1832
  n_resume = jobs.resume_interrupted() # 启动恢复:服务被杀中断的连载任务自动续跑
1690
1833
  if n_resume:
1691
1834
  print("[CodeBee] 已自动恢复 %d 个中断的连载任务(断点续跑)" % n_resume)
1835
+ n_rq = jobs.requeue_pending() # 启动补队:队列在内存里,重启会让排队项变僵尸
1836
+ if n_rq:
1837
+ print("[CodeBee] 已重新入队 %d 个遗留排队运行" % n_rq)
1692
1838
  _step("正在启动自动化调度…")
1693
1839
  n_auto = automation.start() # 自动化:加载定时任务并拉起调度线程(错过的一次性任务不补跑)
1694
1840
  if n_auto: