codebee 0.1.3 → 0.1.4

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/core/jobs.py CHANGED
@@ -1,424 +1,441 @@
1
- # -*- coding: utf-8 -*-
2
- """任务队列:可并发 worker 池(默认 3,1-6 可配)执行编排任务与管理操作。
3
-
4
- 多个任务同时跑、互不打扰:每个 job 一条独立线程,run/step 数据按 run_id
5
- 隔离,store 层有全局锁。目标并发数可在设置页调整;调小后多余线程在取到
6
- 新任务前自行退出,调大即时补齐。安装/升级失败时自动触发 AI 诊断修复:
7
- 由真实智能体读取失败日志与本机环境给出修正命令;仅当命令命中白名单前缀
8
- (npm/winget/pip 安装类)才自动执行,否则把建议命令记录在运行记录里等人工确认。
9
- """
10
- from __future__ import annotations
11
-
12
- import queue
13
- import threading
14
- import traceback
15
-
16
- _QUEUE = queue.Queue()
17
- CANCELS = {}
18
- _started = False
19
- _alive = 0 # 活跃 worker 线程数
20
- _target = 3 # 目标并发数(settings.max_concurrent_jobs)
21
- _pool_lock = threading.Lock()
22
- _seq = 0
23
-
24
- AI_REPAIR_PROMPT = """你是环境工程师。在 Windows 上执行下面的安装命令失败了,请诊断原因并给出修正命令。
25
- 只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
26
- {"diagnosis": "失败原因(一句话)", "command": "修正后的完整安装命令", "safe": true/false}
27
- 硬性约束:command 只能是本机包管理器的安装命令,前缀必须是 npm install / winget install /
28
- py -3.13 -m pip install 之一。给不出符合约束的安全命令时,safe 设为 false 且 command 留空。
29
-
30
- ## 失败的命令
31
- __CMD__
32
-
33
- ## 失败输出(尾部)
34
- __LOG__
35
-
36
- ## 本机环境
37
- __ENV__"""
38
-
39
- # AI 修复命令白名单:只放行包管理器的安装类命令
40
- AI_REPAIR_ALLOW = ("npm install ", "winget install", "py -3.13 -m pip install")
41
-
42
-
43
- def _repair_command_allowed(cmd):
44
- cmd = (cmd or "").strip()
45
- return cmd.startswith(AI_REPAIR_ALLOW) and "|" not in cmd and "&" not in cmd and ">" not in cmd
46
-
47
-
48
- def configure(max_workers):
49
- """设置目标并发数(1-6):扩容立即补线程,缩容由空闲线程自行退出。"""
50
- global _target
51
- _target = max(1, min(6, int(max_workers)))
52
- if _started:
53
- _resize()
54
- return _target
55
-
56
-
57
- def _resize():
58
- global _seq
59
- with _pool_lock:
60
- while _alive < _target:
61
- _seq += 1
62
- try:
63
- threading.Thread(target=_worker, name="job-worker-%d" % _seq,
64
- daemon=True).start()
65
- except RuntimeError:
66
- break # 资源受限起不了新线程:保持现有 worker,不影响任务执行
67
-
68
-
69
- def start_worker():
70
- global _started
71
- if _started:
72
- return
73
- _started = True
74
- try:
75
- from . import settings
76
- configure(settings.load()["max_concurrent_jobs"])
77
- return
78
- except Exception:
79
- pass
80
- configure(3)
81
-
82
-
83
- def enqueue(job):
84
- if not _started:
85
- start_worker()
86
- _QUEUE.put(job)
87
-
88
-
89
- def cancel(run_id):
90
- ev = CANCELS.get(run_id)
91
- if ev:
92
- ev.set()
93
- return True
94
- # 事件不存在=任务还在队列里没被 worker 拿起:直接落终态(取消事件在
95
- # worker 起跑时才创建,排队任务点取消会在这里漏掉——起跑后再杀一遍)。
96
- try:
97
- from . import store
98
- run = store.get_run(run_id)
99
- if not run:
100
- return False
101
- if run.get("status") == "queued":
102
- store.update_run(run_id, expected_status="queued", status="cancelled",
103
- ended_at=_now())
104
- return True
105
- if run.get("status") == "running" and run.get("cancelled_by_user"):
106
- return True # 上一轮取消已标记,等起跑时的兜底检查收口
107
- except Exception:
108
- pass
109
- return False
110
-
111
-
112
- def cancel_event_for(run_id):
113
- ev = CANCELS.get(run_id) # get-or-create:排队期置位的取消不因重建事件而丢失
114
- if ev is None:
115
- ev = threading.Event()
116
- CANCELS[run_id] = ev
117
- return ev
118
-
119
-
120
- AUTO_RESUME_MAX = 2 # 连载任务自动续跑上限(超时/中断后自动接着写,无需人工)
121
-
122
-
123
- def _maybe_auto_resume(run_id):
124
- """连载任务失败自动续跑:继承已完成章继续,最多 AUTO_RESUME_MAX 次。
125
-
126
- 真实长篇单次运行常因供应商拥堵超时中断;这里在 worker 收尾时自动重排一次
127
- 续跑(store.retry_task 会带上 inherit),让整个流程真正无人值守。
128
- """
129
- try:
130
- from . import store
131
- run = store.get_run(run_id)
132
- if not run or run.get("status") not in ("failed", "cancelled"):
133
- return False
134
- task = store.get_task(run.get("task_id")) if run.get("task_id") else None
135
- if not task or not task.get("serial"):
136
- return False
137
- if run.get("cancelled_by_user") or run.get("status") == "cancelled":
138
- return False # 用户主动取消的运行绝不自动续跑
139
- if int(run.get("auto_resumes") or 0) >= AUTO_RESUME_MAX:
140
- return False
141
- ok, err, new_run = store.retry_task(task["id"])
142
- if not ok or not new_run:
143
- return False
144
- store.update_run(new_run["id"], auto_resumes=int(run.get("auto_resumes") or 0) + 1,
145
- auto_resumed_from=run_id)
146
- _QUEUE.put({"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
147
- return True
148
- except Exception:
149
- return False
150
-
151
-
152
- RESUME_WINDOW_HOURS = 24 # 启动恢复只看最近 24h 内中断的运行(更早的视为已放弃)
153
-
154
-
155
- def _recent(run):
156
- """运行创建时间是否在恢复窗口内(时间格式 %Y-%m-%d %H:%M:%S)。"""
157
- import time as _t
158
- try:
159
- ts = _t.mktime(_t.strptime(run.get("created_at") or "", "%Y-%m-%d %H:%M:%S"))
160
- except Exception:
161
- return False
162
- return (_t.time() - ts) <= RESUME_WINDOW_HOURS * 3600
163
-
164
-
165
- def resume_interrupted(limit=3):
166
- """启动恢复:把**近期**中断的连载任务重新入队(继承已完成章)。返回恢复条数。
167
-
168
- 服务被外部杀掉/崩溃时 worker 的 finally 不会执行;这里在启动时补一次。
169
- 只在 RESUME_WINDOW_HOURS 窗口内、且该任务没有更新的终态运行时恢复——
170
- 避免复活用户早已放弃或已完成任务的旧运行。用户手动取消的一律跳过。
171
- """
172
- try:
173
- from . import store
174
- except Exception:
175
- return 0
176
- runs = store.list_runs(200)
177
- latest_by_task = {}
178
- for r in runs: # list_runs 已按 id 倒序:首次出现即该 task 最新运行
179
- tid = r.get("task_id")
180
- if tid and tid not in latest_by_task:
181
- latest_by_task[tid] = r["id"]
182
- n = 0
183
- try:
184
- for run in sorted(runs, key=lambda r: r["id"]):
185
- if n >= limit:
186
- break
187
- if run.get("status") != "failed" or not run.get("task_id"):
188
- continue
189
- if run.get("cancelled_by_user"):
190
- continue
191
- if not _recent(run):
192
- continue
193
- if latest_by_task.get(run["task_id"]) != run["id"]:
194
- continue # 该任务已有更新的运行(如用户重试/已完结),不复活旧中断
195
- task = store.get_task(run["task_id"])
196
- if not task or not task.get("serial"):
197
- continue
198
- if int(run.get("auto_resumes") or 0) >= AUTO_RESUME_MAX:
199
- continue
200
- ok, err, new_run = store.retry_task(task["id"])
201
- if not ok or not new_run:
202
- continue
203
- store.update_run(new_run["id"],
204
- auto_resumes=int(run.get("auto_resumes") or 0) + 1,
205
- auto_resumed_from=run["id"])
206
- _QUEUE.put({"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
207
- n += 1
208
- except Exception:
209
- return n
210
- return n
211
-
212
-
213
- def _worker():
214
- global _alive
215
- with _pool_lock:
216
- _alive += 1
217
- try:
218
- while True:
219
- with _pool_lock:
220
- if _alive > _target: # 缩容:多余的线程在空闲检查点自行退出
221
- return
222
- try:
223
- job = _QUEUE.get(timeout=5) # 定期醒来检查并发数是否被调小
224
- except queue.Empty:
225
- continue
226
- run_id = job.get("run_id")
227
- ev = cancel_event_for(run_id) if run_id else threading.Event()
228
- try:
229
- # 出队后再兜一次底:排队期取消(cancel 已直接落终态)的任务
230
- # 不再进流水线,避免 execute_run 又把 cancelled 改回 running。
231
- # 查不到的 run(如测试 mock)不拦,保持原行为。
232
- if run_id:
233
- from . import store
234
- r0 = store.get_run(run_id)
235
- if r0 and r0.get("status") == "cancelled":
236
- continue # task_done 由 finally 统一收口,不能在此重复
237
- if job.get("kind") == "orchestration":
238
- from . import pipeline
239
- pipeline.execute_run(run_id)
240
- elif job.get("kind") == "mgmt":
241
- _do_mgmt(job, ev)
242
- elif job.get("kind") == "selfupgrade":
243
- _do_selfupgrade(job)
244
- except Exception:
245
- try:
246
- from . import store
247
- err = traceback.format_exc()
248
- store.update_run(run_id, status="failed", error=err[-1500:], ended_at=_now())
249
- except Exception:
250
- pass
251
- finally:
252
- if run_id:
253
- CANCELS.pop(run_id, None)
254
- try:
255
- _maybe_auto_resume(run_id) # 连载失败自动续跑(继承已完成章)
256
- except Exception:
257
- pass
258
- _QUEUE.task_done()
259
- finally:
260
- with _pool_lock:
261
- _alive -= 1
262
-
263
-
264
- def workers_info():
265
- with _pool_lock:
266
- return {"target": _target, "alive": _alive}
267
-
268
-
269
- def _now():
270
- import time
271
- return time.strftime("%Y-%m-%d %H:%M:%S")
272
-
273
-
274
- def _do_mgmt(job, ev):
275
- from . import catalog, manager, store
276
- run_id = job["run_id"]
277
- entry = catalog.by_id(job.get("entry_id"))
278
- op = job.get("op") or ""
279
- store.update_run(run_id, status="running", started_at=_now())
280
- if entry is None:
281
- store.update_run(run_id, status="failed", error="catalog 中找不到 %s" % job.get("entry_id"),
282
- ended_at=_now())
283
- return
284
- step, log_abs = store.add_step(run_id, op or "mgmt", entry["id"], entry.get("name", entry["id"]))
285
- ok = False
286
- if op in ("install", "upgrade", "uninstall"):
287
- res = manager.run_mgmt_command(entry, op, cancel_event=ev, log_path=str(log_abs))
288
- ok = res["ok"]
289
- store.finish_step(run_id, step["n"],
290
- "done" if ok else "failed",
291
- summary=("完成" if ok else "失败") + (": " + res["error"][:300] if res.get("error") else ""),
292
- exit_code=res.get("exit_code"))
293
- # AI 修复只针对安装类失败;卸载失败多为权限/程序占用,留给用户看日志处理
294
- if not ok and op in ("install", "upgrade"):
295
- ok = _ai_repair(run_id, entry, ev, entry.get(op), log_abs)
296
- elif op == "smoke":
297
- from . import runner as _r
298
- from . import registry
299
- from . import usage as _usage
300
- agents = registry.effective_agents(catalog.load(), manager.detect_all())
301
- agent = next((a for a in agents if a["id"] == entry["id"]), None)
302
- if agent is None:
303
- store.finish_step(run_id, step["n"], "failed", summary="该智能体未安装或未启用编排")
304
- store.update_run(run_id, status="failed", error="未启用", ended_at=_now())
305
- return
306
- res = _r.run_agent(agent, "连通性测试:请只回复两个字:OK", readonly=True,
307
- timeout=180, cancel_event=ev, log_path=str(log_abs))
308
- ok = res["ok"] and "OK" in (res.get("text") or "").upper()
309
- try:
310
- _usage.record(source="smoke", run_id=run_id, step=step["n"], role="smoke",
311
- agent=agent.get("id", ""), agent_label=agent.get("label", ""),
312
- tool=agent.get("kind", ""), model=res.get("model") or "",
313
- ok=bool(res.get("ok")),
314
- duration_s=float((res.get("raw") or {}).get("duration") or 0.0),
315
- cost_usd=float(res.get("cost_usd") or 0.0),
316
- usage=res.get("usage"))
317
- except Exception:
318
- pass
319
- store.finish_step(run_id, step["n"], "done" if ok else "failed",
320
- summary=("连通正常:%s" % (res.get("text") or "")[:120]) if ok
321
- else ("异常:%s" % (res.get("error") or (res.get("text") or "")[:120])),
322
- exit_code=res["raw"].get("exit_code"),
323
- cost_usd=res.get("cost_usd", 0.0), tokens=res.get("tokens", 0))
324
- else:
325
- store.finish_step(run_id, step["n"], "failed", summary="未知操作 %s" % op)
326
- # 以步骤状态汇总 run 状态
327
- run = store.get_run(run_id)
328
- statuses = [s["status"] for s in (run.get("steps") if run else [])] or ["failed"]
329
- final = "done" if all(s == "done" for s in statuses) else "failed"
330
- suffix = "(AI 修复成功)" if ok and len((run.get("steps") if run else [])) > 1 else ""
331
- store.update_run(run_id, status=final, ended_at=_now(),
332
- summary=("%s %s %s%s" % (entry.get("name"), op,
333
- "完成" if final == "done" else "失败", suffix)))
334
-
335
-
336
- def _do_selfupgrade(job):
337
- """CodeBee 自升级:在 mgmt run 里跑 npm install -g @latest,日志实时落盘。"""
338
- from . import selfupdate, store
339
- run_id = job["run_id"]
340
- store.update_run(run_id, status="running", started_at=_now())
341
- step, log_abs = store.add_step(run_id, "selfupgrade", "__self__", "CodeBee")
342
- try:
343
- res = selfupdate.run_upgrade(run_id, str(log_abs))
344
- except Exception as e:
345
- res = {"ok": False, "exit_code": None, "error": repr(e)}
346
- store.finish_step(run_id, step["n"], "done" if res["ok"] else "failed",
347
- summary="升级完成,点「重启」生效" if res["ok"]
348
- else ("升级失败: " + res["error"][:300]),
349
- exit_code=res.get("exit_code"))
350
- store.update_run(run_id, status="done" if res["ok"] else "failed", ended_at=_now(),
351
- summary="CodeBee selfupgrade %s" % ("完成" if res["ok"] else "失败"))
352
-
353
-
354
- def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
355
- """安装失败后的 AI 诊断修复:诊断 白名单校验 → 执行 → 复检。"""
356
- from . import catalog, manager, registry, router, runner, store
357
- from . import usage as _usage
358
- agents = registry.effective_agents(catalog.load(), manager.detect_all())
359
- agent, _reason = router.pick(agents, "repair", "mgmt")
360
- if agent is None or agent.get("mode") != "real":
361
- return False # 无真实智能体可用,维持原失败
362
- try:
363
- log_tail = runner.tail_decoded(orig_log.read_bytes(), 2000) if orig_log.exists() else "(无输出)"
364
- except Exception:
365
- log_tail = "(日志不可读)"
366
- import shutil
367
- env_lines = [
368
- "OS: Windows",
369
- "node: %s" % (shutil.which("node") or "缺失"),
370
- "npm: %s" % (shutil.which("npm") or "缺失"),
371
- "pnpm: %s" % (shutil.which("pnpm") or "缺失"),
372
- "python: %s" % (shutil.which("python") or "缺失"),
373
- ]
374
- prompt = (AI_REPAIR_PROMPT.replace("__CMD__", failed_cmd or "(未知)")
375
- .replace("__LOG__", log_tail)
376
- .replace("__ENV__", "\n".join(env_lines)))
377
- step, log_abs = store.add_step(run_id, "ai-repair", agent["id"], agent.get("label"),
378
- note="自动诊断修复")
379
- res = runner.run_agent(agent, prompt, readonly=True, timeout=300,
380
- cancel_event=ev, log_path=str(log_abs))
381
- try:
382
- _usage.record(source="repair", run_id=run_id, step=step["n"], role="ai-repair",
383
- agent=agent.get("id", ""), agent_label=agent.get("label", ""),
384
- tool=agent.get("kind", ""), model=res.get("model") or "",
385
- ok=bool(res.get("ok")),
386
- duration_s=float((res.get("raw") or {}).get("duration") or 0.0),
387
- cost_usd=float(res.get("cost_usd") or 0.0),
388
- usage=res.get("usage"))
389
- except Exception:
390
- pass
391
- if not res["ok"]:
392
- store.finish_step(run_id, step["n"], "failed",
393
- summary="诊断调用失败:%s" % (res.get("error") or "")[:200])
394
- return False
395
- import re
396
- data = runner.extract_json(res.get("text") or "")
397
- diagnosis = str((data or {}).get("diagnosis") or "(无诊断)")[:200]
398
- cmd = str((data or {}).get("command") or "").strip()
399
- safe = bool((data or {}).get("safe")) and _repair_command_allowed(cmd)
400
- try:
401
- log_abs.write_text(
402
- ("\n[AI 诊断] %s\n[AI 建议] %s\n[白名单] %s\n" %
403
- (diagnosis, cmd or "(无)", "通过" if safe else "不通过,拒绝自动执行")).encode("utf-8"))
404
- except Exception:
405
- pass
406
- if not safe:
407
- store.finish_step(run_id, step["n"], "failed",
408
- summary="AI 建议命令未过白名单,需人工执行:%s(诊断:%s)" % (cmd, diagnosis))
409
- return False
410
- from . import paths
411
- fix = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT), timeout=1800,
412
- cancel_event=ev, log_path=str(log_abs))
413
- manager.detect_all(force=True)
414
- with manager._LOCK:
415
- manager._STATE["versions"].pop(entry["id"], None)
416
- installed = manager.detect_entry(entry)["installed"]
417
- store.finish_step(run_id, step["n"],
418
- "done" if (fix["ok"] and installed) else "failed",
419
- summary="诊断:%s 执行 %r:%s,检测安装状态:%s" % (
420
- diagnosis, cmd,
421
- "命令成功" if fix["ok"] else "命令失败",
422
- "已安装" if installed else "未检出"),
423
- exit_code=fix.get("exit_code"))
424
- return bool(fix["ok"] and installed)
1
+ # -*- coding: utf-8 -*-
2
+ """任务队列:可并发 worker 池(默认 3,1-6 可配)执行编排任务与管理操作。
3
+
4
+ 多个任务同时跑、互不打扰:每个 job 一条独立线程,run/step 数据按 run_id
5
+ 隔离,store 层有全局锁。目标并发数可在设置页调整;调小后多余线程在取到
6
+ 新任务前自行退出,调大即时补齐。安装/升级失败时自动触发 AI 诊断修复:
7
+ 由真实智能体读取失败日志与本机环境给出修正命令;仅当命令命中白名单前缀
8
+ (npm/winget/pip 安装类)才自动执行,否则把建议命令记录在运行记录里等人工确认。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import queue
13
+ import threading
14
+ import traceback
15
+
16
+ _QUEUE = queue.Queue()
17
+ CANCELS = {}
18
+ _started = False
19
+ _alive = 0 # 活跃 worker 线程数
20
+ _target = 3 # 目标并发数(settings.max_concurrent_jobs)
21
+ _pool_lock = threading.Lock()
22
+ _seq = 0
23
+
24
+ AI_REPAIR_PROMPT = """你是环境工程师。在 Windows 上执行下面的安装命令失败了,请诊断原因并给出修正命令。
25
+ 只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
26
+ {"diagnosis": "失败原因(一句话)", "command": "修正后的完整安装命令", "safe": true/false}
27
+ 硬性约束:command 只能是本机包管理器的安装命令,前缀必须是 npm install / winget install /
28
+ py -3.13 -m pip install 之一。给不出符合约束的安全命令时,safe 设为 false 且 command 留空。
29
+
30
+ ## 失败的命令
31
+ __CMD__
32
+
33
+ ## 失败输出(尾部)
34
+ __LOG__
35
+
36
+ ## 本机环境
37
+ __ENV__"""
38
+
39
+ # AI 修复命令白名单:只放行包管理器的安装类命令
40
+ AI_REPAIR_ALLOW = ("npm install ", "winget install", "py -3.13 -m pip install")
41
+
42
+
43
+ def _repair_command_allowed(cmd):
44
+ cmd = (cmd or "").strip()
45
+ return cmd.startswith(AI_REPAIR_ALLOW) and "|" not in cmd and "&" not in cmd and ">" not in cmd
46
+
47
+
48
+ def configure(max_workers):
49
+ """设置目标并发数(1-6):扩容立即补线程,缩容由空闲线程自行退出。"""
50
+ global _target
51
+ _target = max(1, min(6, int(max_workers)))
52
+ if _started:
53
+ _resize()
54
+ return _target
55
+
56
+
57
+ def _resize():
58
+ global _seq
59
+ with _pool_lock:
60
+ # 上限 50 次尝试:Thread.start() 返回到 _worker 真正执行之间有调度间隙,
61
+ # 极端环境下(杀软挂起新线程)_alive 迟迟不涨,无界循环会转着圈造线程。
62
+ attempts = 0
63
+ while _alive < _target and attempts < 50:
64
+ attempts += 1
65
+ _seq += 1
66
+ try:
67
+ threading.Thread(target=_worker, name="job-worker-%d" % _seq,
68
+ daemon=True).start()
69
+ except RuntimeError:
70
+ break # 资源受限起不了新线程:保持现有 worker,不影响任务执行
71
+
72
+
73
+ def start_worker():
74
+ """标记队列可用并加载并发配置。worker 线程**不在启动期创建**(真实装机
75
+ 案例:某些杀软环境下启动期 Thread.start() 挂死,进程停在任务队列一步),
76
+ 推迟到首次 enqueue 时由 _ensure_workers 创建——服务就绪不再依赖线程。"""
77
+ global _started
78
+ if _started:
79
+ return
80
+ _started = True
81
+ try:
82
+ from . import settings
83
+ configure(settings.load()["max_concurrent_jobs"])
84
+ return
85
+ except Exception:
86
+ pass
87
+ configure(3)
88
+
89
+
90
+ def enqueue(job):
91
+ if not _started:
92
+ start_worker()
93
+ _ensure_workers()
94
+ _QUEUE.put(job)
95
+
96
+
97
+ def _ensure_workers():
98
+ """队列里积压超过空闲 worker 数时补线程(惰性扩容,替代启动期预建)。"""
99
+ with _pool_lock:
100
+ pending = _QUEUE.qsize()
101
+ need = max(_target, 1) - _alive + pending
102
+ if need > 0:
103
+ _resize()
104
+
105
+
106
+ def cancel(run_id):
107
+ ev = CANCELS.get(run_id)
108
+ if ev:
109
+ ev.set()
110
+ return True
111
+ # 事件不存在=任务还在队列里没被 worker 拿起:直接落终态(取消事件在
112
+ # worker 起跑时才创建,排队任务点取消会在这里漏掉——起跑后再杀一遍)。
113
+ try:
114
+ from . import store
115
+ run = store.get_run(run_id)
116
+ if not run:
117
+ return False
118
+ if run.get("status") == "queued":
119
+ store.update_run(run_id, expected_status="queued", status="cancelled",
120
+ ended_at=_now())
121
+ return True
122
+ if run.get("status") == "running" and run.get("cancelled_by_user"):
123
+ return True # 上一轮取消已标记,等起跑时的兜底检查收口
124
+ except Exception:
125
+ pass
126
+ return False
127
+
128
+
129
+ def cancel_event_for(run_id):
130
+ ev = CANCELS.get(run_id) # get-or-create:排队期置位的取消不因重建事件而丢失
131
+ if ev is None:
132
+ ev = threading.Event()
133
+ CANCELS[run_id] = ev
134
+ return ev
135
+
136
+
137
+ AUTO_RESUME_MAX = 2 # 连载任务自动续跑上限(超时/中断后自动接着写,无需人工)
138
+
139
+
140
+ def _maybe_auto_resume(run_id):
141
+ """连载任务失败自动续跑:继承已完成章继续,最多 AUTO_RESUME_MAX 次。
142
+
143
+ 真实长篇单次运行常因供应商拥堵超时中断;这里在 worker 收尾时自动重排一次
144
+ 续跑(store.retry_task 会带上 inherit),让整个流程真正无人值守。
145
+ """
146
+ try:
147
+ from . import store
148
+ run = store.get_run(run_id)
149
+ if not run or run.get("status") not in ("failed", "cancelled"):
150
+ return False
151
+ task = store.get_task(run.get("task_id")) if run.get("task_id") else None
152
+ if not task or not task.get("serial"):
153
+ return False
154
+ if run.get("cancelled_by_user") or run.get("status") == "cancelled":
155
+ return False # 用户主动取消的运行绝不自动续跑
156
+ if int(run.get("auto_resumes") or 0) >= AUTO_RESUME_MAX:
157
+ return False
158
+ ok, err, new_run = store.retry_task(task["id"])
159
+ if not ok or not new_run:
160
+ return False
161
+ store.update_run(new_run["id"], auto_resumes=int(run.get("auto_resumes") or 0) + 1,
162
+ auto_resumed_from=run_id)
163
+ _QUEUE.put({"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
164
+ return True
165
+ except Exception:
166
+ return False
167
+
168
+
169
+ RESUME_WINDOW_HOURS = 24 # 启动恢复只看最近 24h 内中断的运行(更早的视为已放弃)
170
+
171
+
172
+ def _recent(run):
173
+ """运行创建时间是否在恢复窗口内(时间格式 %Y-%m-%d %H:%M:%S)。"""
174
+ import time as _t
175
+ try:
176
+ ts = _t.mktime(_t.strptime(run.get("created_at") or "", "%Y-%m-%d %H:%M:%S"))
177
+ except Exception:
178
+ return False
179
+ return (_t.time() - ts) <= RESUME_WINDOW_HOURS * 3600
180
+
181
+
182
+ def resume_interrupted(limit=3):
183
+ """启动恢复:把**近期**中断的连载任务重新入队(继承已完成章)。返回恢复条数。
184
+
185
+ 服务被外部杀掉/崩溃时 worker finally 不会执行;这里在启动时补一次。
186
+ 只在 RESUME_WINDOW_HOURS 窗口内、且该任务没有更新的终态运行时恢复——
187
+ 避免复活用户早已放弃或已完成任务的旧运行。用户手动取消的一律跳过。
188
+ """
189
+ try:
190
+ from . import store
191
+ except Exception:
192
+ return 0
193
+ runs = store.list_runs(200)
194
+ latest_by_task = {}
195
+ for r in runs: # list_runs 已按 id 倒序:首次出现即该 task 最新运行
196
+ tid = r.get("task_id")
197
+ if tid and tid not in latest_by_task:
198
+ latest_by_task[tid] = r["id"]
199
+ n = 0
200
+ try:
201
+ for run in sorted(runs, key=lambda r: r["id"]):
202
+ if n >= limit:
203
+ break
204
+ if run.get("status") != "failed" or not run.get("task_id"):
205
+ continue
206
+ if run.get("cancelled_by_user"):
207
+ continue
208
+ if not _recent(run):
209
+ continue
210
+ if latest_by_task.get(run["task_id"]) != run["id"]:
211
+ continue # 该任务已有更新的运行(如用户重试/已完结),不复活旧中断
212
+ task = store.get_task(run["task_id"])
213
+ if not task or not task.get("serial"):
214
+ continue
215
+ if int(run.get("auto_resumes") or 0) >= AUTO_RESUME_MAX:
216
+ continue
217
+ ok, err, new_run = store.retry_task(task["id"])
218
+ if not ok or not new_run:
219
+ continue
220
+ store.update_run(new_run["id"],
221
+ auto_resumes=int(run.get("auto_resumes") or 0) + 1,
222
+ auto_resumed_from=run["id"])
223
+ _QUEUE.put({"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
224
+ n += 1
225
+ except Exception:
226
+ return n
227
+ return n
228
+
229
+
230
+ def _worker():
231
+ global _alive
232
+ with _pool_lock:
233
+ _alive += 1
234
+ try:
235
+ while True:
236
+ with _pool_lock:
237
+ if _alive > _target: # 缩容:多余的线程在空闲检查点自行退出
238
+ return
239
+ try:
240
+ job = _QUEUE.get(timeout=5) # 定期醒来检查并发数是否被调小
241
+ except queue.Empty:
242
+ continue
243
+ run_id = job.get("run_id")
244
+ ev = cancel_event_for(run_id) if run_id else threading.Event()
245
+ try:
246
+ # 出队后再兜一次底:排队期取消(cancel 已直接落终态)的任务
247
+ # 不再进流水线,避免 execute_run 又把 cancelled 改回 running。
248
+ # 查不到的 run(如测试 mock)不拦,保持原行为。
249
+ if run_id:
250
+ from . import store
251
+ r0 = store.get_run(run_id)
252
+ if r0 and r0.get("status") == "cancelled":
253
+ continue # task_done 由 finally 统一收口,不能在此重复
254
+ if job.get("kind") == "orchestration":
255
+ from . import pipeline
256
+ pipeline.execute_run(run_id)
257
+ elif job.get("kind") == "mgmt":
258
+ _do_mgmt(job, ev)
259
+ elif job.get("kind") == "selfupgrade":
260
+ _do_selfupgrade(job)
261
+ except Exception:
262
+ try:
263
+ from . import store
264
+ err = traceback.format_exc()
265
+ store.update_run(run_id, status="failed", error=err[-1500:], ended_at=_now())
266
+ except Exception:
267
+ pass
268
+ finally:
269
+ if run_id:
270
+ CANCELS.pop(run_id, None)
271
+ try:
272
+ _maybe_auto_resume(run_id) # 连载失败自动续跑(继承已完成章)
273
+ except Exception:
274
+ pass
275
+ _QUEUE.task_done()
276
+ finally:
277
+ with _pool_lock:
278
+ _alive -= 1
279
+
280
+
281
+ def workers_info():
282
+ with _pool_lock:
283
+ return {"target": _target, "alive": _alive}
284
+
285
+
286
+ def _now():
287
+ import time
288
+ return time.strftime("%Y-%m-%d %H:%M:%S")
289
+
290
+
291
+ def _do_mgmt(job, ev):
292
+ from . import catalog, manager, store
293
+ run_id = job["run_id"]
294
+ entry = catalog.by_id(job.get("entry_id"))
295
+ op = job.get("op") or ""
296
+ store.update_run(run_id, status="running", started_at=_now())
297
+ if entry is None:
298
+ store.update_run(run_id, status="failed", error="catalog 中找不到 %s" % job.get("entry_id"),
299
+ ended_at=_now())
300
+ return
301
+ step, log_abs = store.add_step(run_id, op or "mgmt", entry["id"], entry.get("name", entry["id"]))
302
+ ok = False
303
+ if op in ("install", "upgrade", "uninstall"):
304
+ res = manager.run_mgmt_command(entry, op, cancel_event=ev, log_path=str(log_abs))
305
+ ok = res["ok"]
306
+ store.finish_step(run_id, step["n"],
307
+ "done" if ok else "failed",
308
+ summary=("完成" if ok else "失败") + (": " + res["error"][:300] if res.get("error") else ""),
309
+ exit_code=res.get("exit_code"))
310
+ # AI 修复只针对安装类失败;卸载失败多为权限/程序占用,留给用户看日志处理
311
+ if not ok and op in ("install", "upgrade"):
312
+ ok = _ai_repair(run_id, entry, ev, entry.get(op), log_abs)
313
+ elif op == "smoke":
314
+ from . import runner as _r
315
+ from . import registry
316
+ from . import usage as _usage
317
+ agents = registry.effective_agents(catalog.load(), manager.detect_all())
318
+ agent = next((a for a in agents if a["id"] == entry["id"]), None)
319
+ if agent is None:
320
+ store.finish_step(run_id, step["n"], "failed", summary="该智能体未安装或未启用编排")
321
+ store.update_run(run_id, status="failed", error="未启用", ended_at=_now())
322
+ return
323
+ res = _r.run_agent(agent, "连通性测试:请只回复两个字:OK", readonly=True,
324
+ timeout=180, cancel_event=ev, log_path=str(log_abs))
325
+ ok = res["ok"] and "OK" in (res.get("text") or "").upper()
326
+ try:
327
+ _usage.record(source="smoke", run_id=run_id, step=step["n"], role="smoke",
328
+ agent=agent.get("id", ""), agent_label=agent.get("label", ""),
329
+ tool=agent.get("kind", ""), model=res.get("model") or "",
330
+ ok=bool(res.get("ok")),
331
+ duration_s=float((res.get("raw") or {}).get("duration") or 0.0),
332
+ cost_usd=float(res.get("cost_usd") or 0.0),
333
+ usage=res.get("usage"))
334
+ except Exception:
335
+ pass
336
+ store.finish_step(run_id, step["n"], "done" if ok else "failed",
337
+ summary=("连通正常:%s" % (res.get("text") or "")[:120]) if ok
338
+ else ("异常:%s" % (res.get("error") or (res.get("text") or "")[:120])),
339
+ exit_code=res["raw"].get("exit_code"),
340
+ cost_usd=res.get("cost_usd", 0.0), tokens=res.get("tokens", 0))
341
+ else:
342
+ store.finish_step(run_id, step["n"], "failed", summary="未知操作 %s" % op)
343
+ # 以步骤状态汇总 run 状态
344
+ run = store.get_run(run_id)
345
+ statuses = [s["status"] for s in (run.get("steps") if run else [])] or ["failed"]
346
+ final = "done" if all(s == "done" for s in statuses) else "failed"
347
+ suffix = "(AI 修复成功)" if ok and len((run.get("steps") if run else [])) > 1 else ""
348
+ store.update_run(run_id, status=final, ended_at=_now(),
349
+ summary=("%s %s %s%s" % (entry.get("name"), op,
350
+ "完成" if final == "done" else "失败", suffix)))
351
+
352
+
353
+ def _do_selfupgrade(job):
354
+ """CodeBee 自升级:在 mgmt run 里跑 npm install -g @latest,日志实时落盘。"""
355
+ from . import selfupdate, store
356
+ run_id = job["run_id"]
357
+ store.update_run(run_id, status="running", started_at=_now())
358
+ step, log_abs = store.add_step(run_id, "selfupgrade", "__self__", "CodeBee")
359
+ try:
360
+ res = selfupdate.run_upgrade(run_id, str(log_abs))
361
+ except Exception as e:
362
+ res = {"ok": False, "exit_code": None, "error": repr(e)}
363
+ store.finish_step(run_id, step["n"], "done" if res["ok"] else "failed",
364
+ summary="升级完成,点「重启」生效" if res["ok"]
365
+ else ("升级失败: " + res["error"][:300]),
366
+ exit_code=res.get("exit_code"))
367
+ store.update_run(run_id, status="done" if res["ok"] else "failed", ended_at=_now(),
368
+ summary="CodeBee selfupgrade %s" % ("完成" if res["ok"] else "失败"))
369
+
370
+
371
+ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
372
+ """安装失败后的 AI 诊断修复:诊断 白名单校验 → 执行 → 复检。"""
373
+ from . import catalog, manager, registry, router, runner, store
374
+ from . import usage as _usage
375
+ agents = registry.effective_agents(catalog.load(), manager.detect_all())
376
+ agent, _reason = router.pick(agents, "repair", "mgmt")
377
+ if agent is None or agent.get("mode") != "real":
378
+ return False # 无真实智能体可用,维持原失败
379
+ try:
380
+ log_tail = runner.tail_decoded(orig_log.read_bytes(), 2000) if orig_log.exists() else "(无输出)"
381
+ except Exception:
382
+ log_tail = "(日志不可读)"
383
+ import shutil
384
+ env_lines = [
385
+ "OS: Windows",
386
+ "node: %s" % (shutil.which("node") or "缺失"),
387
+ "npm: %s" % (shutil.which("npm") or "缺失"),
388
+ "pnpm: %s" % (shutil.which("pnpm") or "缺失"),
389
+ "python: %s" % (shutil.which("python") or "缺失"),
390
+ ]
391
+ prompt = (AI_REPAIR_PROMPT.replace("__CMD__", failed_cmd or "(未知)")
392
+ .replace("__LOG__", log_tail)
393
+ .replace("__ENV__", "\n".join(env_lines)))
394
+ step, log_abs = store.add_step(run_id, "ai-repair", agent["id"], agent.get("label"),
395
+ note="自动诊断修复")
396
+ res = runner.run_agent(agent, prompt, readonly=True, timeout=300,
397
+ cancel_event=ev, log_path=str(log_abs))
398
+ try:
399
+ _usage.record(source="repair", run_id=run_id, step=step["n"], role="ai-repair",
400
+ agent=agent.get("id", ""), agent_label=agent.get("label", ""),
401
+ tool=agent.get("kind", ""), model=res.get("model") or "",
402
+ ok=bool(res.get("ok")),
403
+ duration_s=float((res.get("raw") or {}).get("duration") or 0.0),
404
+ cost_usd=float(res.get("cost_usd") or 0.0),
405
+ usage=res.get("usage"))
406
+ except Exception:
407
+ pass
408
+ if not res["ok"]:
409
+ store.finish_step(run_id, step["n"], "failed",
410
+ summary="诊断调用失败:%s" % (res.get("error") or "")[:200])
411
+ return False
412
+ import re
413
+ data = runner.extract_json(res.get("text") or "")
414
+ diagnosis = str((data or {}).get("diagnosis") or "(无诊断)")[:200]
415
+ cmd = str((data or {}).get("command") or "").strip()
416
+ safe = bool((data or {}).get("safe")) and _repair_command_allowed(cmd)
417
+ try:
418
+ log_abs.write_text(
419
+ ("\n[AI 诊断] %s\n[AI 建议] %s\n[白名单] %s\n" %
420
+ (diagnosis, cmd or "(无)", "通过" if safe else "不通过,拒绝自动执行")).encode("utf-8"))
421
+ except Exception:
422
+ pass
423
+ if not safe:
424
+ store.finish_step(run_id, step["n"], "failed",
425
+ summary="AI 建议命令未过白名单,需人工执行:%s(诊断:%s)" % (cmd, diagnosis))
426
+ return False
427
+ from . import paths
428
+ fix = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT), timeout=1800,
429
+ cancel_event=ev, log_path=str(log_abs))
430
+ manager.detect_all(force=True)
431
+ with manager._LOCK:
432
+ manager._STATE["versions"].pop(entry["id"], None)
433
+ installed = manager.detect_entry(entry)["installed"]
434
+ store.finish_step(run_id, step["n"],
435
+ "done" if (fix["ok"] and installed) else "failed",
436
+ summary="诊断:%s → 执行 %r:%s,检测安装状态:%s" % (
437
+ diagnosis, cmd,
438
+ "命令成功" if fix["ok"] else "命令失败",
439
+ "已安装" if installed else "未检出"),
440
+ exit_code=fix.get("exit_code"))
441
+ return bool(fix["ok"] and installed)