codebee 0.1.64 → 0.1.66

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +520 -507
  2. package/LICENSE +21 -21
  3. package/README.md +494 -487
  4. package/app/core/aiflavor.py +197 -197
  5. package/app/core/artifacts.py +148 -148
  6. package/app/core/attachments.py +532 -532
  7. package/app/core/automation.py +758 -758
  8. package/app/core/backup.py +470 -470
  9. package/app/core/beekeeper.py +183 -183
  10. package/app/core/board.py +209 -209
  11. package/app/core/bookmeta.py +272 -217
  12. package/app/core/bookmeta_catalog.py +180 -180
  13. package/app/core/branching.py +137 -137
  14. package/app/core/builtin_agent.py +1234 -1191
  15. package/app/core/capability.py +144 -144
  16. package/app/core/catalog.py +23 -24
  17. package/app/core/chaptersafe.py +32 -32
  18. package/app/core/cleanup.py +303 -303
  19. package/app/core/compaction.py +201 -186
  20. package/app/core/covergen.py +356 -356
  21. package/app/core/diagnostics.py +114 -114
  22. package/app/core/dispatch.py +173 -173
  23. package/app/core/dispatch_log.py +113 -113
  24. package/app/core/env_scrub.py +85 -85
  25. package/app/core/error_codes.py +220 -65
  26. package/app/core/errorlog.py +137 -137
  27. package/app/core/evaluation.py +174 -0
  28. package/app/core/failure_notify.py +290 -0
  29. package/app/core/flows.py +355 -355
  30. package/app/core/goal_service.py +167 -167
  31. package/app/core/health.py +374 -342
  32. package/app/core/history.py +38 -38
  33. package/app/core/hooks.py +162 -156
  34. package/app/core/jobs.py +1034 -1022
  35. package/app/core/knowledge.py +6 -2
  36. package/app/core/manager.py +2115 -1996
  37. package/app/core/market.py +362 -362
  38. package/app/core/market_remote.py +66 -66
  39. package/app/core/mocks.py +64 -64
  40. package/app/core/modelhub.py +3517 -3332
  41. package/app/core/notify.py +106 -106
  42. package/app/core/paihang.py +109 -109
  43. package/app/core/paths.py +2 -2
  44. package/app/core/pipeline.py +4614 -4483
  45. package/app/core/planner.py +33 -31
  46. package/app/core/portguard.py +111 -111
  47. package/app/core/preview.py +293 -293
  48. package/app/core/publish/__init__.py +7 -7
  49. package/app/core/publish/auto.py +367 -367
  50. package/app/core/publish/browser.py +533 -533
  51. package/app/core/publish/fanqie.py +128 -128
  52. package/app/core/publish/flow.py +432 -406
  53. package/app/core/publish/flows-fanqie-calibrated.json +17 -9
  54. package/app/core/publish/flows-qimao-calibrated.json +6 -2
  55. package/app/core/publish/ledger.py +198 -198
  56. package/app/core/publish/manager.py +623 -590
  57. package/app/core/publish/qimao.py +141 -141
  58. package/app/core/publish/ws.py +139 -139
  59. package/app/core/redact.py +38 -38
  60. package/app/core/repeat_guard.py +129 -124
  61. package/app/core/router.py +96 -22
  62. package/app/core/runner.py +2050 -2019
  63. package/app/core/selfupdate.py +384 -384
  64. package/app/core/session_log.py +161 -161
  65. package/app/core/sessions.py +312 -312
  66. package/app/core/settings.py +149 -149
  67. package/app/core/settings_schema.py +262 -262
  68. package/app/core/share_page.py +114 -114
  69. package/app/core/skill_scan.py +114 -106
  70. package/app/core/skillpacks/fanqie-novel.md +80 -80
  71. package/app/core/skillpacks/market/character-bible.md +66 -66
  72. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  73. package/app/core/skillpacks/market/git-workflow.md +57 -57
  74. package/app/core/skillpacks/market/release-notes.md +72 -72
  75. package/app/core/skillpacks/market/weekly-report.md +71 -71
  76. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  77. package/app/core/skills.py +221 -72
  78. package/app/core/step_runner.py +132 -132
  79. package/app/core/store.py +23 -0
  80. package/app/core/task_compile.py +199 -199
  81. package/app/core/telemetry.py +292 -292
  82. package/app/core/tlsctx.py +64 -64
  83. package/app/core/token_meter.py +147 -147
  84. package/app/core/upstream.py +41 -0
  85. package/app/core/usage.py +114 -3
  86. package/app/core/volumes.py +355 -355
  87. package/app/core/wxdigest.py +858 -858
  88. package/app/core/zentao.py +2447 -2447
  89. package/app/main.py +2838 -2832
  90. package/app/pick_dialog.py +110 -110
  91. package/app/ui/app.js +13994 -13977
  92. package/app/ui/board.css +20 -41
  93. package/app/ui/board.html +162 -162
  94. package/app/ui/board.js +305 -305
  95. package/app/ui/i18n.js +2716 -2711
  96. package/app/ui/icons/bee.svg +79 -79
  97. package/app/ui/index.html +1 -1
  98. package/app/ui/manifest.json +16 -16
  99. package/app/ui/qrcode.js +2297 -2297
  100. package/app/ui/style.css +6107 -6125
  101. package/bin/tutti.js +147 -147
  102. package/package.json +46 -41
  103. package/tools/wx_reader.py +261 -261
  104. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  105. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  106. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  107. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  108. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  109. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  110. package/app/.playwright-cli/run-detail-after.png +0 -0
package/app/core/jobs.py CHANGED
@@ -1,1022 +1,1034 @@
1
- # -*- coding: utf-8 -*-
2
- """任务执行器:直接启动、无等待队列地执行编排任务与管理操作。
3
-
4
- 每个 job 一条独立线程,run/step 数据按 run_id 隔离,store 层有全局锁。
5
- 设置中的并发数是保护上限:有空位就立即启动。满载时 orchestration 任务
6
- (用户/自动化任务)回滚 queued 排队等下一个空位——补跑 Timer 到点重试,
7
- 封顶 BUSY_WAIT_MAX 拍,超时才判失败(满载秒判失败会逼用户原话重发,
8
- 侧栏堆同题重复任务,2026-09-22 纸枪对话实案);升级排水与其余 kind
9
- 仍明确失败并提示稍后重试。并发保护保护的是本机资源(CLI 子进程数),
10
- 与各 API 厂商的限流无关——厂商侧 429 由 runner 冷却换将兜底。direct
11
- 对话(内置智能体直连 API,本机几乎零开销)走独立轻量池,不与编排任务
12
- 抢全局槽。安装/升级失败时自动触发 AI 诊断修复:
13
- 由真实智能体读取失败日志与本机环境给出修正命令;仅当命令命中白名单前缀
14
- (npm/winget/brew/pip 安装类,按平台取对应渠道)才自动执行,否则把建议命令
15
- 记录在运行记录里等人工确认。
16
- """
17
- from __future__ import annotations
18
-
19
- import queue
20
- import sys
21
- import threading
22
- import time
23
- import traceback
24
-
25
- # 仅保留给旧测试/诊断代码观察;生产 enqueue 永远不向这里写入。
26
- _QUEUE = queue.Queue()
27
- CANCELS = {}
28
- _started = False
29
- _alive = 0 # 已获执行位、尚未结束的 job 数
30
- _restart_drain = False # 升级重启前原子停止接单;不排队、不打断已运行任务
31
- _target = 12 # 并发保护上限(settings.max_concurrent_jobs)
32
- _chat_alive = 0 # 轻量池:direct 对话在跑数(独立于全局池计数)
33
- _pool_lock = threading.Lock()
34
- _idle_cond = threading.Condition(_pool_lock)
35
- _timer_lock = threading.Lock()
36
- _deferred_timers = {}
37
- _seq = 0
38
- MAX_POOL = 24
39
- CHAT_POOL = 4 # 轻量池上限:direct 对话直连 API 本机开销极小,4 个足够
40
- BUSY_WAIT_S = 15.0 # 满载排队后的补跑轮询间隔(秒);测试可调小
41
- BUSY_WAIT_MAX = 40 # 补跑拍数封顶:15s × 40 = 10 分钟,超时才判失败
42
-
43
-
44
- class JobsBusyError(RuntimeError):
45
- """并发保护位已满;调用方应稍后重试,不得排队。"""
46
-
47
-
48
- class DuplicateJobError(RuntimeError):
49
- """同一 run 已启动或结束,拒绝重复执行。"""
50
-
51
- AI_REPAIR_PROMPT = """你是环境工程师。在 __OS__ 上执行下面的安装命令失败了,请诊断原因并给出修正命令。
52
- 只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
53
- {"diagnosis": "失败原因(一句话)", "command": "修正后的完整安装命令", "safe": true/false}
54
- 硬性约束:command 只能是本机包管理器的安装命令,前缀必须是 __ALLOW__ 之一,且不要带 sudo
55
- (命令非交互执行,sudo 会挂死;brew/npm 本身也拒绝 sudo)。
56
- 给不出符合约束的安全命令时,safe 设为 false 且 command 留空。
57
-
58
- ## 失败的命令
59
- __CMD__
60
-
61
- ## 失败输出(尾部)
62
- __LOG__
63
-
64
- ## 本机环境
65
- __ENV__"""
66
-
67
- # AI 修复命令白名单:只放行包管理器的安装类命令(提示词里的前缀清单由它生成,
68
- # 两处永远不会漂移)。按平台分组:winget/py 启动器是 Windows 独有,brew/python3
69
- # 是 macOS/Linux 渠道——Mac 上不放行 brew 的话,AI 给出正确的修正命令也会被拒。
70
- _AI_REPAIR_ALLOW_WIN = ("npm install ", "winget install", "py -3.13 -m pip install",
71
- "uv tool install")
72
- _AI_REPAIR_ALLOW_UNIX = ("npm install ", "brew install", "python3 -m pip install",
73
- "uv tool install")
74
- AI_REPAIR_ALLOW = _AI_REPAIR_ALLOW_WIN # 兼容旧引用:Windows 本机即此表
75
-
76
-
77
- def _repair_os_label(platform=None):
78
- return {"win32": "Windows", "darwin": "macOS"}.get(platform or sys.platform, "Linux")
79
-
80
-
81
- def _repair_allow(platform=None):
82
- if (platform or sys.platform) == "win32":
83
- return _AI_REPAIR_ALLOW_WIN
84
- return _AI_REPAIR_ALLOW_UNIX
85
-
86
-
87
- def _repair_command_allowed(cmd, platform=None):
88
- cmd = (cmd or "").strip()
89
- return (cmd.startswith(_repair_allow(platform)) and "|" not in cmd
90
- and "&" not in cmd and ">" not in cmd)
91
-
92
-
93
- def configure(max_workers):
94
- """设置并发保护上限(1-24);只影响后续启动,不中断已运行任务。"""
95
- global _target
96
- _target = max(1, min(MAX_POOL, int(max_workers)))
97
- return _target
98
-
99
-
100
- def capacity_status():
101
- """返回当前并发槽状态,供会产生管理记录的入口做无副作用预检。"""
102
- with _pool_lock:
103
- return {
104
- "accepting": not _restart_drain and _alive < _target,
105
- "active": _alive,
106
- "limit": _target,
107
- "restarting": _restart_drain,
108
- }
109
-
110
-
111
- def start_worker():
112
- """加载并发保护配置;线程只在真实任务到达时创建。"""
113
- global _started
114
- if _started:
115
- return
116
- _started = True
117
- try:
118
- from . import settings
119
- configure(settings.load()["max_concurrent_jobs"])
120
- return
121
- except Exception:
122
- pass
123
- configure(12)
124
-
125
-
126
- def _job_is_light(run):
127
- """direct 对话走轻量池:内置智能体直连模型 API,本机无 CLI 子进程,
128
- 开销与编排任务(每步起一个 CLI)差几个量级,不该抢全局槽。判定按
129
- run 所属任务的 type,入口侧零改动(自动化/webhook 等新入口自动跟随)。
130
- run 缺失或类型未知时保守走全局池。direct 极少数回落 CLI 的场景,轻量
131
- 池上限也只有 CHAT_POOL 个,本机压力可控。"""
132
- try:
133
- task_id = (run or {}).get("task_id")
134
- if not task_id:
135
- return False
136
- from . import store
137
- task = store.get_task(task_id)
138
- return str((task or {}).get("type") or "") == "direct"
139
- except Exception:
140
- return False
141
-
142
-
143
- def _try_start_once(job):
144
- """CAS 认领持久化 run → 占并发位 → 起执行线程,一次直接启动尝试。
145
-
146
- 返回 True=已起跑;False=并发位满载未启动(run 停在 running,由调用方
147
- 决定回滚排队还是收口失败)。run 已非 queued(被取消/接管/收口)时抛
148
- DuplicateJobError;线程创建失败就地收口后原样上抛。升级排水在这里视作
149
- 满载的一种(返回 False),快速失败还是排队的语义由调用方区分。"""
150
- global _alive, _chat_alive, _seq
151
- from . import store
152
- run_id = job.get("run_id")
153
- current = store.get_run(run_id)
154
- if current:
155
- # 先 CAS 认领持久化 run,再碰并发位。若先占位,两个调用可能分别看到
156
- # “容量已满”和“状态已变化”,把唯一 run 误收口为 failed 且无人执行。
157
- changed = store.update_run(run_id, expected_status="queued",
158
- status="running", started_at=_now(),
159
- dispatch_mode="direct")
160
- if changed is None:
161
- latest = store.get_run(run_id) or current
162
- raise DuplicateJobError("运行 %s 当前状态为 %s,拒绝重复启动" %
163
- (run_id, latest.get("status")))
164
- cancel_event_for(run_id)
165
-
166
- # CAS 认领后检查并发保护位。占位在 Thread.start 前递增,消除旧实现中线程尚未
167
- # 回写 alive、扩容循环一次造出几十条 worker 的竞态。direct 对话占轻量池,
168
- # 其余任务占全局池;job["light"] 随副本传给执行线程,结束时按原池释放。
169
- light = _job_is_light(current)
170
- with _pool_lock:
171
- if _restart_drain:
172
- busy = True
173
- elif light:
174
- busy = _chat_alive >= CHAT_POOL
175
- if not busy:
176
- _chat_alive += 1
177
- job["light"] = True
178
- else:
179
- busy = _alive >= _target
180
- if not busy:
181
- _alive += 1
182
- if not busy:
183
- _seq += 1
184
- seq = _seq
185
- if busy:
186
- CANCELS.pop(run_id, None)
187
- return False
188
-
189
- try:
190
- threading.Thread(target=_run_job, args=(dict(job),),
191
- name="job-direct-%d" % seq, daemon=True).start()
192
- except Exception:
193
- _release_slot(job)
194
- CANCELS.pop(run_id, None)
195
- _close_unstarted(job, "任务执行线程启动失败;本次未排队,请稍后重试",
196
- statuses=("queued", "running"))
197
- raise
198
- return True
199
-
200
-
201
- def enqueue(job):
202
- """立即为 job 预留执行位并启动独立线程;从不进入内存等待队列。
203
-
204
- 返回前先把持久化 run 从 queued 原子切到 running,因此 HTTP/自动化调用方
205
- 不会看到“已接受但仍排队”。满载时 orchestration 任务转排队等待空位(对
206
- 调用方视作受理成功,见 _requeue_await_slot);重复 run、线程创建失败、
207
- 升级排水满载都会明确抛错,并就地把 run 收口为 failed,任何入口都不会
208
- 留下无人认领的僵尸。
209
- """
210
- if not _started:
211
- start_worker()
212
- if not isinstance(job, dict):
213
- raise ValueError("job 必须是对象")
214
- run_id = job.get("run_id")
215
- if not run_id:
216
- raise ValueError("job.run_id 必填")
217
-
218
- if not _try_start_once(job):
219
- if _restart_drain:
220
- # 升级排水:接单通道已在关闭,排队只会排进一次注定重启的进程——
221
- # 照旧快速失败。
222
- message = "服务正在完成升级重启;本次未排队,请稍后重试"
223
- _close_unstarted(job, message,
224
- statuses=("running",))
225
- raise JobsBusyError(message)
226
- if str(job.get("kind") or "") != "orchestration" or not _requeue_await_slot(job):
227
- # mgmt 与回滚失败(run 已被取消/收口)维持快速失败原语义;
228
- # orchestration 已转排队,对调用方视作受理成功。
229
- message = ("当前运行任务已达并发保护上限(%d);本次未排队,请稍后重试"
230
- % _target)
231
- _close_unstarted(job, message,
232
- statuses=("running",))
233
- raise JobsBusyError(message)
234
- return True
235
-
236
-
237
- def _release_slot(job=None):
238
- """释放执行位:按 job 的 light 标记归还对应池,两池全空才唤醒等待方。"""
239
- global _alive, _chat_alive
240
- with _idle_cond:
241
- if job is not None and job.get("light"):
242
- _chat_alive = max(0, _chat_alive - 1)
243
- else:
244
- _alive = max(0, _alive - 1)
245
- if _alive == 0 and _chat_alive == 0:
246
- _idle_cond.notify_all()
247
-
248
-
249
- def wait_for_idle(timeout=10):
250
- """测试/停机辅助:等待所有直接执行任务(含轻量池)结束。"""
251
- import time as _t
252
- end = _t.time() + max(0, float(timeout))
253
- with _idle_cond:
254
- while _alive or _chat_alive:
255
- left = end - _t.time()
256
- if left <= 0:
257
- return False
258
- _idle_cond.wait(min(left, 0.2))
259
- return True
260
-
261
-
262
- def begin_restart_drain():
263
- """升级任务已退出执行位且没有用户任务或 direct 对话时,原子停止接单。"""
264
- global _restart_drain
265
- with _pool_lock:
266
- if _restart_drain or _alive != 0 or _chat_alive != 0:
267
- return False
268
- _restart_drain = True
269
- return True
270
-
271
-
272
- def cancel_restart_drain():
273
- """重启未执行或失败时恢复接单。"""
274
- global _restart_drain
275
- with _pool_lock:
276
- _restart_drain = False
277
-
278
-
279
- def _close_unstarted(job, message, statuses=("queued",)):
280
- """无法启动时统一收口 run/task,供所有入口复用。"""
281
- try:
282
- from . import store
283
- run_id = job.get("run_id")
284
- run = store.get_run(run_id) if run_id else None
285
- if run and run.get("status") in statuses:
286
- store.update_run(run_id, expected_status=run.get("status"), status="failed",
287
- error=message, ended_at=_now())
288
- except Exception:
289
- pass
290
-
291
-
292
- def _requeue_await_slot(job, waits_left=None):
293
- """满载转排队:run 回滚 queued 并挂补跑 Timer,等下一个并发空位。
294
-
295
- 只服务 orchestration 任务(用户/自动化任务)。enqueue 起手已把 run CAS
296
- 成 running,这里再 CAS 回 queued(清 started_at,别让「排队中」顶着起跑
297
- 时间),并同步任务状态回排队。返回 True=已转排队(调用方视作受理成功,
298
- 不再向调用方抛忙);False=回滚失败(run 已被取消/收口),走原快速失败
299
- 路径。取消/重启/超时的收口:
300
- - 用户取消 → run 已非 running,回滚 CAS 落空,或补跑 enqueue 撞
301
- DuplicateJobError,链条自然断掉;
302
- - 服务重启 → Timer 随进程消失,启动时 requeue_pending() 接管 queued 记录;
303
- - 等满 BUSY_WAIT_MAX 拍仍无空位 → 判失败并给出可读原因。
304
- """
305
- run_id = job.get("run_id")
306
- if waits_left is None:
307
- waits_left = BUSY_WAIT_MAX
308
- try:
309
- from . import store
310
- changed = store.update_run(run_id, expected_status="running",
311
- status="queued", started_at="")
312
- if changed is None:
313
- return False
314
- if job.get("task_id"):
315
- try:
316
- store.update_task_status(job["task_id"], "queued")
317
- except Exception:
318
- pass
319
- except Exception:
320
- return False
321
-
322
- def _fire():
323
- # 补跑心跳:直接尝试一次启动(不走 enqueue 的排队分支,否则每拍都会
324
- # 带着全新重试额度重新入队,封顶永远打不中)。仍满载则续下一拍。
325
- with _timer_lock:
326
- _deferred_timers.pop(run_id, None)
327
- try:
328
- from . import store
329
- run = store.get_run(run_id) or {}
330
- deadline_at = run.get("deadline_at")
331
- if deadline_at is not None and float(deadline_at) <= time.time():
332
- store.update_run(run_id, expected_status="queued", status="timeout",
333
- ended_at=_now(), error="任务总时限已到(排队等待期间)")
334
- return
335
- except (TypeError, ValueError, AttributeError):
336
- pass
337
- try:
338
- started = _try_start_once(job)
339
- except DuplicateJobError:
340
- return # 已被取消/接管/收口:补跑链到此为止
341
- except Exception:
342
- return # 线程启动失败等异常路径已就地收口,不续棒
343
- if started:
344
- return
345
- if _restart_drain:
346
- _close_unstarted(job, "服务正在完成升级重启;本次未排队,请稍后重试",
347
- statuses=("queued", "running"))
348
- elif waits_left > 0:
349
- _requeue_await_slot(job, waits_left - 1)
350
- else:
351
- _close_unstarted(job, "并发位已满,排队等待超时仍未起跑,请稍后重试",
352
- statuses=("queued", "running"))
353
-
354
- with _timer_lock:
355
- if run_id in _deferred_timers:
356
- return True # 已有补跑在等:别叠 Timer(防与续跑退避串台)
357
- timer = threading.Timer(max(0.0, float(BUSY_WAIT_S)), _fire)
358
- timer.daemon = True
359
- _deferred_timers[run_id] = timer
360
- try:
361
- timer.start()
362
- except Exception:
363
- _deferred_timers.pop(run_id, None)
364
- return False
365
- return True
366
-
367
-
368
- def _schedule_enqueue(job, delay_s):
369
- """按明确截止时间延迟启动;同一 run 只保留一个 Timer。"""
370
- run_id = job.get("run_id")
371
- if not run_id:
372
- return False
373
-
374
- def _fire():
375
- with _timer_lock:
376
- _deferred_timers.pop(run_id, None)
377
- try:
378
- enqueue(job)
379
- except Exception:
380
- # enqueue 会把未启动 run 收口为 failed;Timer 线程不外抛。
381
- pass
382
-
383
- with _timer_lock:
384
- old = _deferred_timers.get(run_id)
385
- if old is not None:
386
- return False
387
- timer = threading.Timer(max(0.0, float(delay_s)), _fire)
388
- timer.daemon = True
389
- _deferred_timers[run_id] = timer
390
- try:
391
- timer.start()
392
- except Exception:
393
- _deferred_timers.pop(run_id, None)
394
- _close_unstarted(job, "自动续跑定时器启动失败,请手动重试")
395
- raise
396
- return True
397
-
398
-
399
- def restore_deferred_resumes(limit=None, now=None):
400
- """重建重启前的自动续跑退避 Timer;返回成功恢复的数量。"""
401
- import time as _t
402
- try:
403
- from . import store
404
- except Exception:
405
- return 0
406
- now = _t.time() if now is None else float(now)
407
- restored = 0
408
- for run in store.list_runs(None):
409
- if limit is not None and restored >= limit:
410
- break
411
- if run.get("status") != "queued" or run.get("kind") != "orchestration":
412
- continue
413
- at = str(run.get("resume_enqueue_at") or "")
414
- if not at:
415
- continue
416
- try:
417
- due = _t.mktime(_t.strptime(at, "%Y-%m-%d %H:%M:%S"))
418
- except Exception:
419
- due = now
420
- job = {"kind": "orchestration", "run_id": run["id"],
421
- "task_id": run.get("task_id")}
422
- if _schedule_enqueue(job, max(0.0, due - now)):
423
- restored += 1
424
- return restored
425
-
426
-
427
- def _run_job(job):
428
- """直接执行线程入口;无 get/put 等待阶段。"""
429
- run_id = job.get("run_id")
430
- ev = cancel_event_for(run_id) if run_id else threading.Event()
431
- try:
432
- # enqueue 已把 run 原子切为 running。线程真正得到调度时再检查一次,
433
- # 用户若在两者之间取消,直接跳过,避免取消后仍进入流水线。
434
- if run_id:
435
- from . import store
436
- r0 = store.get_run(run_id)
437
- if r0 and r0.get("status") != "running":
438
- return
439
- if job.get("kind") == "orchestration" and _yield_duplicate(run_id):
440
- return
441
- if job.get("kind") == "orchestration":
442
- from . import pipeline
443
- pipeline.execute_run(run_id)
444
- elif job.get("kind") == "mgmt":
445
- _do_mgmt(job, ev)
446
- elif job.get("kind") == "selfupgrade":
447
- _do_selfupgrade(job, ev)
448
- else:
449
- raise ValueError("未知任务类型 %r" % job.get("kind"))
450
- except Exception:
451
- try:
452
- from . import store
453
- err = traceback.format_exc()
454
- store.update_run(run_id, expected_status="running", status="failed",
455
- error=err[-1500:], ended_at=_now())
456
- except Exception:
457
- pass
458
- finally:
459
- if run_id:
460
- # 执行线程退出就是生命周期的最后边界。流水线分支若意外提前 return、
461
- # 或内部吞掉异常却漏写终态,旧逻辑会让 run/task 永久停在 running。
462
- try:
463
- from . import store
464
- latest = store.get_run(run_id)
465
- if latest and latest.get("status") == "running":
466
- store.update_run(
467
- run_id, expected_status="running", status="failed",
468
- ended_at=_now(),
469
- error="执行流程已退出但未写入完成状态,已自动标记失败;可重试")
470
- except Exception:
471
- pass
472
- CANCELS.pop(run_id, None)
473
- try:
474
- _maybe_auto_resume(run_id)
475
- except Exception:
476
- pass
477
- try:
478
- from . import notify
479
- notify.push_run_async(run_id)
480
- except Exception:
481
- pass
482
- _release_slot(job)
483
-
484
-
485
- def cancel(run_id):
486
- ev = CANCELS.get(run_id)
487
- if ev:
488
- ev.set()
489
- # 强制终止语义:置位事件后立刻把 running 落成 cancelled 终态,
490
- # UI 即刻翻牌,不等流水线走到下一个检查点(CLI 子进程树由取消
491
- # 事件在 run_process 的 0.4s 轮询里秒杀;内置智能体生成中的 HTTP
492
- # 由取消等待方放弃)。expected_status 守卫保证已正常结束的运行
493
- # 不被误改;流水线随后照常收尾,终态幂等。
494
- try:
495
- from . import store
496
- store.update_run(run_id, expected_status="running",
497
- status="cancelled", ended_at=_now(),
498
- error="用户主动取消")
499
- except Exception:
500
- pass
501
- return True
502
- # 事件不存在也可能撞在 enqueue 已把 run 切成 running、尚未来得及登记
503
- # CANCELS 的极短窗口。无论 queued/running 都直接落终态,不能让取消丢失。
504
- try:
505
- from . import store
506
- run = store.get_run(run_id)
507
- if not run:
508
- return False
509
- status = run.get("status")
510
- if status in ("queued", "running"):
511
- changed = store.update_run(run_id, expected_status=status,
512
- status="cancelled", ended_at=_now(),
513
- error="用户主动取消",
514
- cancelled_by_user=True)
515
- if changed is not None:
516
- with _timer_lock:
517
- timer = _deferred_timers.pop(run_id, None)
518
- if timer is not None:
519
- timer.cancel()
520
- return True
521
- latest = store.get_run(run_id) or {}
522
- return latest.get("status") == "cancelled"
523
- except Exception:
524
- pass
525
- return False
526
-
527
-
528
- def cancel_event_for(run_id):
529
- ev = CANCELS.get(run_id)
530
- if ev is None:
531
- ev = threading.Event()
532
- CANCELS[run_id] = ev
533
- return ev
534
-
535
-
536
- AUTO_RESUME_MAX = 3 # 连载任务自动续跑上限(超时/中断后自动接着写,无需人工)
537
- AUTO_RESUME_DELAY_S = 300 # 自动续跑延迟入队秒数:网关限流/欠费窗口通常分钟级,
538
- # 立即重排会撞在同一堵墙上把续跑次数烧光(2026-09-17 七猫实测)
539
-
540
-
541
- def _task_active_run(task_id, exclude_run_id=None):
542
- """该任务当前 queued/running 的运行(同任务单飞守卫用);无则 None。"""
543
- try:
544
- from . import store
545
- for r in store.task_runs(task_id):
546
- if r.get("id") == exclude_run_id:
547
- continue
548
- if r.get("status") in ("queued", "running"):
549
- return r
550
- except Exception:
551
- pass
552
- return None
553
-
554
-
555
- def _err_signature(err):
556
- """错误串的稳定特征:剥掉章号/引用号/数字等易变片段,只留病根文本。
557
- 同因连撞判定用——退避重跑后错误若还长得一样,说明墙没动(欠费/配置死),
558
- 再多次退避也只是重复付费。"""
559
- import re
560
- s = str(err or "")
561
- s = re.sub(r"第\s*\d+\s*章", "第N章", s)
562
- s = re.sub(r"err_[0-9a-fA-F]+", "err_X", s)
563
- s = re.sub(r"\d+", "N", s)
564
- return re.sub(r"\s+", " ", s).strip()[:400]
565
-
566
-
567
- def _maybe_auto_resume(run_id):
568
- """连载任务失败自动续跑:继承已完成章继续,最多 AUTO_RESUME_MAX 次。
569
-
570
- 真实长篇单次运行常因供应商拥堵超时中断;这里在执行线程收尾时自动续跑一次
571
- 续跑(store.retry_task 会带上 inherit),让整个流程真正无人值守。
572
- 同因连撞止损:续跑副本再失败时与本次失败的错误签名比对(2026-09-18
573
- 重写任务 kimi 403 欠费案),一模一样说明退避没换来不同结果,直接落
574
- 终态写明死因,不再烧剩余的退避次数。
575
- """
576
- try:
577
- from . import store
578
- run = store.get_run(run_id)
579
- if not run or run.get("status") not in ("failed", "cancelled"):
580
- return False
581
- task = store.get_task(run.get("task_id")) if run.get("task_id") else None
582
- if not task or not task.get("serial"):
583
- return False
584
- if run.get("cancelled_by_user") or run.get("status") == "cancelled":
585
- return False # 用户主动取消的运行绝不自动续跑
586
- if int(run.get("auto_resumes") or 0) >= AUTO_RESUME_MAX:
587
- return False
588
- prev_id = run.get("auto_resumed_from")
589
- if prev_id:
590
- prev = store.get_run(prev_id)
591
- sig_now = _err_signature(run.get("error"))
592
- if (prev and sig_now and prev.get("error")
593
- and sig_now == _err_signature(prev.get("error"))):
594
- store.update_run(run_id, auto_resume_stopped="same_cause",
595
- error=(run.get("error") or "")
596
- + "|自动续跑止损:连续两次失败原因相同,不再重试")
597
- return False
598
- if _task_active_run(task["id"], exclude_run_id=run_id):
599
- return False # 同任务已有运行排队/在跑:再排副本只会与之撞车
600
- ok, err, new_run = store.retry_task(task["id"])
601
- if not ok or not new_run:
602
- return False
603
- # 退避窗口要让用户看得见:把「预定启动时刻」写到 run 上,前端据此显示
604
- # 「将在 HH:MM 自动续跑」。这不是容量排队,且等待期间不占执行位。
605
- import time as _t
606
- resume_at = _t.strftime("%Y-%m-%d %H:%M:%S",
607
- _t.localtime(_t.time() + AUTO_RESUME_DELAY_S))
608
- store.update_run(new_run["id"], auto_resumes=int(run.get("auto_resumes") or 0) + 1,
609
- auto_resumed_from=run_id, resume_enqueue_at=resume_at)
610
-
611
- _schedule_enqueue({"kind": "orchestration", "run_id": new_run["id"],
612
- "task_id": task["id"]}, AUTO_RESUME_DELAY_S)
613
- return True
614
- except Exception:
615
- return False
616
-
617
-
618
- RESUME_WINDOW_HOURS = 24 # 启动恢复只看最近 24h 内中断的运行(更早的视为已放弃)
619
-
620
-
621
- def _recent(run):
622
- """运行创建时间是否在恢复窗口内(时间格式 %Y-%m-%d %H:%M:%S)。"""
623
- import time as _t
624
- try:
625
- ts = _t.mktime(_t.strptime(run.get("created_at") or "", "%Y-%m-%d %H:%M:%S"))
626
- except Exception:
627
- return False
628
- return (_t.time() - ts) <= RESUME_WINDOW_HOURS * 3600
629
-
630
-
631
- def resume_interrupted(limit=3):
632
- """启动恢复:把**近期**中断的连载任务重新入队(继承已完成章)。返回恢复条数。
633
-
634
- 服务被外部杀掉/崩溃时 worker 的 finally 不会执行;这里在启动时补一次。
635
- 只在 RESUME_WINDOW_HOURS 窗口内、且该任务没有更新的终态运行时恢复——
636
- 避免复活用户早已放弃或已完成任务的旧运行。用户手动取消的一律跳过。
637
- """
638
- try:
639
- from . import store
640
- except Exception:
641
- return 0
642
- runs = store.list_runs(200)
643
- latest_by_task = {}
644
- for r in runs: # list_runs 已按 id 倒序:首次出现即该 task 最新运行
645
- tid = r.get("task_id")
646
- if tid and tid not in latest_by_task:
647
- latest_by_task[tid] = r["id"]
648
- n = 0
649
- try:
650
- for run in sorted(runs, key=lambda r: r["id"]):
651
- if n >= limit:
652
- break
653
- if run.get("status") != "failed" or not run.get("task_id"):
654
- continue
655
- if run.get("cancelled_by_user"):
656
- continue
657
- if run.get("paused"):
658
- continue # 用户主动暂停的运行(重启收尸后 paused 标志保留)不自动续跑——
659
- # 「继续」由用户点「继续任务」决定,不替用户做主
660
- if not _recent(run):
661
- continue
662
- if latest_by_task.get(run["task_id"]) != run["id"]:
663
- continue # 该任务已有更新的运行(如用户重试/已完结),不复活旧中断
664
- task = store.get_task(run["task_id"])
665
- if not task or not task.get("serial"):
666
- continue
667
- if int(run.get("auto_resumes") or 0) >= AUTO_RESUME_MAX:
668
- continue
669
- ok, err, new_run = store.retry_task(task["id"])
670
- if not ok or not new_run:
671
- continue
672
- store.update_run(new_run["id"],
673
- auto_resumes=int(run.get("auto_resumes") or 0) + 1,
674
- auto_resumed_from=run["id"])
675
- try:
676
- enqueue({"kind": "orchestration", "run_id": new_run["id"],
677
- "task_id": task["id"]})
678
- n += 1
679
- except Exception:
680
- pass
681
- except Exception:
682
- return n
683
- return n
684
-
685
-
686
- def _yield_duplicate(run_id):
687
- """同任务单飞(执行线程入口把关):系统续跑副本启动时若同任务已有
688
- 运行排队/在跑,取消自己让位——恢复副本与原轮并行跑只会双烧评审。
689
- 用户轮不在此拦(retry_task 建轮时已拒运行中任务)。返回 True 表示
690
- 本运行已落 cancelled,不要执行。"""
691
- try:
692
- from . import store
693
- r0 = store.get_run(run_id)
694
- if not r0 or not r0.get("auto_resumed_from"):
695
- return False
696
- other = _task_active_run(r0.get("task_id"), exclude_run_id=run_id)
697
- if not other or other.get("kind") != "orchestration":
698
- return False
699
- store.update_run(run_id, status="cancelled", ended_at=_now(),
700
- error="同任务已有运行在跑(%s),续跑副本自动让位" % other.get("id"))
701
- return True
702
- except Exception:
703
- return False
704
-
705
-
706
- def _age_s(run, now=None):
707
- """run 已创建多少秒(created_at 解析失败返回 0:宁可早补,别误判卡死)。"""
708
- import time as _t
709
- now = now if now is not None else _t.time()
710
- try:
711
- ts = _t.mktime(_t.strptime(run.get("created_at") or "", "%Y-%m-%d %H:%M:%S"))
712
- except Exception:
713
- return 0.0
714
- return max(0.0, now - ts)
715
-
716
-
717
- def _in_resume_backoff(run, now=None):
718
- """续跑副本是否还在退避等待窗口内(resume_enqueue_at 未到点)。
719
-
720
- 到点时间由 _maybe_auto_resume 落在 run 上,Timer 到点才入队;巡检/补队
721
- 若无视它直接重排,等于把 300s 网关退避窗口烧掉(立即重排撞同一堵墙)。"""
722
- import time as _t
723
- now = now if now is not None else _t.time()
724
- at = str(run.get("resume_enqueue_at") or "")
725
- if not at:
726
- return False
727
- try:
728
- return _t.mktime(_t.strptime(at, "%Y-%m-%d %H:%M:%S")) > now
729
- except Exception:
730
- return False # 解析失败按「不在退避」处理:宁可入队,别让副本永远卡死
731
-
732
-
733
- def requeue_pending(limit=10, max_age_s=None):
734
- """直接启动遗留的 queued 运行(启动恢复与运行期巡检共用)。
735
-
736
- 兼容旧版本曾持久化的排队状态,以及续跑 Timer 随进程消失的历史情况。
737
- 同任务已有在跑/等待续跑的不重复启动。mgmt 同样纳入,避免旧的排队记录
738
- 堵住同条目去重闸。selfupgrade
739
- 不补——升级本体有进程替换语义,自动重排不可控。
740
-
741
- max_age_s:巡检模式只接管「卡了超过该秒数」的,刚创建的退避记录不掺和;
742
- None(启动模式)全量补。resume_enqueue_at 未到点的续跑副本两种模式都
743
- 跳过——重启不该把退避窗口烧掉。返回补队条数。"""
744
- try:
745
- from . import store
746
- except Exception:
747
- return 0
748
- n = 0
749
- try:
750
- for run in store.list_runs(200):
751
- if n >= limit:
752
- break
753
- kind = run.get("kind")
754
- if run.get("status") != "queued" or kind not in ("orchestration", "mgmt"):
755
- continue
756
- if max_age_s is not None and _age_s(run) < max_age_s:
757
- continue
758
- if kind == "orchestration":
759
- if not run.get("task_id") or not _recent(run):
760
- continue
761
- if _in_resume_backoff(run):
762
- continue # 退避窗口内的续跑副本:到点 Timer 自会入队
763
- if _task_active_run(run["task_id"], exclude_run_id=run["id"]):
764
- continue # 同任务已有更活跃的运行,别再排一份
765
- else:
766
- eid = run.get("entry_id")
767
- if eid:
768
- other = store.active_mgmt_run(eid)
769
- if other and other.get("id") != run["id"]:
770
- continue # 同条目已有更活跃的 run,去重闸语义收敛
771
- try:
772
- enqueue({"kind": kind, "run_id": run["id"],
773
- "task_id": run.get("task_id"),
774
- "entry_id": run.get("entry_id"), "op": run.get("op")})
775
- n += 1
776
- except (JobsBusyError, DuplicateJobError):
777
- # 满载时 enqueue 已明确收口;重复 run 已由另一个执行方接管。
778
- continue
779
- except Exception:
780
- continue
781
- except Exception:
782
- pass
783
- return n
784
-
785
-
786
- def workers_info():
787
- with _pool_lock:
788
- info = {"target": _target, "alive": _alive,
789
- "chat_alive": _chat_alive, "chat_pool": CHAT_POOL,
790
- "available": 0 if _restart_drain else max(0, _target - _alive),
791
- "mode": "restart-drain" if _restart_drain else "direct"}
792
- # queued runs are deliberately persisted rather than held in _QUEUE. Report
793
- # the same source the board renders so capacity waits are observable.
794
- try:
795
- from . import store
796
- info["queued"] = sum(1 for run in store.list_runs(None)
797
- if run.get("status") == "queued")
798
- except Exception:
799
- info["queued"] = 0
800
- return info
801
-
802
-
803
- def _drain_test_queue():
804
- """测试辅助:清空兼容队列并取消未决 Timer(生产代码勿调)。"""
805
- cancel_restart_drain()
806
- with _timer_lock:
807
- timers = list(_deferred_timers.values())
808
- _deferred_timers.clear()
809
- for timer in timers:
810
- try:
811
- timer.cancel()
812
- except Exception:
813
- pass
814
- while True:
815
- try:
816
- _QUEUE.get_nowait()
817
- _QUEUE.task_done()
818
- except Exception:
819
- return
820
-
821
-
822
- def _now():
823
- import time
824
- return time.strftime("%Y-%m-%d %H:%M:%S")
825
-
826
-
827
- def _do_mgmt(job, ev):
828
- from . import catalog, manager, store
829
- run_id = job["run_id"]
830
- entry = catalog.by_id(job.get("entry_id"))
831
- op = job.get("op") or ""
832
- if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
833
- return
834
- if entry is None:
835
- store.update_run(run_id, expected_status="running", status="failed",
836
- error="catalog 中找不到 %s" % job.get("entry_id"),
837
- ended_at=_now())
838
- return
839
- step, log_abs = store.add_step(run_id, op or "mgmt", entry["id"], entry.get("name", entry["id"]))
840
- ok = False
841
- if op in ("install", "upgrade", "uninstall"):
842
- res = manager.run_mgmt_command(entry, op, cancel_event=ev, log_path=str(log_abs))
843
- if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
844
- return
845
- ok = res["ok"]
846
- # 文件占用类失败(Windows 文件锁 EBUSY/EPERM)给人话结论并跳过 AI 修复:
847
- # 修复智能体面对文件锁只会给出 taskkill 全杀 node 之类白名单必拒的危险
848
- # 命令,白白烧一轮 300s 诊断(2026-09-18 dsh 同版本重装 EBUSY 案)
849
- lock_hit = (not ok and op in ("install", "upgrade") and _file_lock_error(res))
850
- if lock_hit:
851
- summary = ("失败:安装文件被占用(可能有同名程序在运行,或杀毒软件正在扫描),"
852
- "请关闭占用该文件的程序后重试。完整输出见日志。")
853
- else:
854
- summary = ("完成" if ok else "失败") + (": " + res["error"][:300] if res.get("error") else "")
855
- store.finish_step(run_id, step["n"],
856
- "done" if ok else "failed",
857
- summary=summary,
858
- exit_code=res.get("exit_code"))
859
- # AI 修复只针对安装类失败;卸载失败多为权限/程序占用,留给用户看日志处理
860
- if not ok and op in ("install", "upgrade") and not lock_hit:
861
- ok = _ai_repair(run_id, entry, ev, entry.get(op), log_abs)
862
- elif op == "smoke":
863
- from . import runner as _r
864
- from . import registry
865
- from . import usage as _usage
866
- agents = registry.effective_agents(catalog.load(), manager.detect_all())
867
- agent = next((a for a in agents if a["id"] == entry["id"]), None)
868
- if agent is None:
869
- store.finish_step(run_id, step["n"], "failed", summary="该智能体未安装或未启用编排")
870
- store.update_run(run_id, expected_status="running", status="failed",
871
- error="未启用", ended_at=_now())
872
- return
873
- res = _r.run_agent(agent, "连通性测试:请只回复两个字:OK",
874
- readonly=True,
875
- # 300s:codex CLI 启动要拉 5 个 MCP 服务器 + 注入约 13 万
876
- # token 技能上下文,首 token 常超 180s(2026-09-17 实测
877
- # 网关裸探 8s 就回,慢在 CLI 自身启动与上下文)。
878
- timeout=300, cancel_event=ev, log_path=str(log_abs))
879
- if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
880
- return
881
- ok = res["ok"] and "OK" in (res.get("text") or "").upper()
882
- try:
883
- _usage.record(source="smoke", run_id=run_id, step=step["n"], role="smoke",
884
- agent=agent.get("id", ""), agent_label=agent.get("label", ""),
885
- tool=agent.get("kind", ""), model=res.get("model") or "",
886
- ok=bool(res.get("ok")),
887
- duration_s=float((res.get("raw") or {}).get("duration") or 0.0),
888
- cost_usd=float(res.get("cost_usd") or 0.0),
889
- usage=res.get("usage"))
890
- except Exception:
891
- pass
892
- store.finish_step(run_id, step["n"], "done" if ok else "failed",
893
- summary=("连通正常:%s" % (res.get("text") or "")[:120]) if ok
894
- else ("异常:%s" % (res.get("error") or (res.get("text") or "")[:120])),
895
- exit_code=res["raw"].get("exit_code"),
896
- cost_usd=res.get("cost_usd", 0.0), tokens=res.get("tokens", 0))
897
- else:
898
- store.finish_step(run_id, step["n"], "failed", summary="未知操作 %s" % op)
899
- # 以步骤状态汇总 run 状态;expected_status 守卫:用户已强制终止的运行
900
- # 保持 cancelled,不被步骤汇总改写成 failed(被杀的步骤记的就是非 done)
901
- run = store.get_run(run_id)
902
- statuses = [s["status"] for s in (run.get("steps") if run else [])] or ["failed"]
903
- final = "done" if all(s == "done" for s in statuses) else "failed"
904
- suffix = "(AI 修复成功)" if ok and len((run.get("steps") if run else [])) > 1 else ""
905
- store.update_run(run_id, expected_status="running", status=final, ended_at=_now(),
906
- summary=("%s %s %s%s" % (entry.get("name"), op,
907
- "完成" if final == "done" else "失败", suffix)))
908
-
909
-
910
- def _do_selfupgrade(job, ev):
911
- """CodeBee 自升级:在 mgmt run 里跑 npm install -g @latest,日志实时落盘。"""
912
- from . import selfupdate, store
913
- run_id = job["run_id"]
914
- if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
915
- return
916
- step, log_abs = store.add_step(run_id, "selfupgrade", "__self__", "CodeBee")
917
- try:
918
- res = selfupdate.run_upgrade(run_id, str(log_abs), cancel_event=ev)
919
- except Exception as e:
920
- res = {"ok": False, "exit_code": None, "error": repr(e)}
921
- if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
922
- return
923
- store.finish_step(run_id, step["n"], "done" if res["ok"] else "failed",
924
- summary="升级完成,服务将自动重启生效" if res["ok"]
925
- else ("升级失败: " + res["error"][:300]),
926
- exit_code=res.get("exit_code"))
927
- # expected_status 守卫:用户已强制终止的运行保持 cancelled,不被改写
928
- store.update_run(run_id, expected_status="running",
929
- status="done" if res["ok"] else "failed", ended_at=_now(),
930
- summary="CodeBee selfupgrade %s" % ("完成" if res["ok"] else "失败"))
931
-
932
-
933
- def _file_lock_error(res):
934
- """安装/升级失败输出是否为文件占用类错误(npm/pip 的 EBUSY/EPERM 文件锁)。"""
935
- err = (res or {}).get("error") or ""
936
- return "EBUSY" in err or "EPERM" in err
937
-
938
-
939
- def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
940
- """安装失败后的 AI 诊断修复:诊断 → 白名单校验 → 执行 → 复检。"""
941
- from . import catalog, manager, registry, router, runner, store
942
- from . import usage as _usage
943
- agents = registry.effective_agents(catalog.load(), manager.detect_all())
944
- agent, _reason = router.pick(agents, "repair", "mgmt")
945
- if agent is None or agent.get("mode") != "real":
946
- return False # 无真实智能体可用,维持原失败
947
- try:
948
- # 日志尾巴喂进 AI 修复提示词:先洗终端噪声,否则 ANSI 转义/乱码墙会被
949
- # 模型当成"日志原文"照抄进修复推理里
950
- log_tail = runner.clean_cli_text(
951
- runner.tail_decoded(orig_log.read_bytes(), 2000)) if orig_log.exists() else "(无输出)"
952
- except Exception:
953
- log_tail = "(日志不可读)"
954
- import shutil
955
- is_win = sys.platform == "win32"
956
- env_lines = [
957
- "OS: %s" % _repair_os_label(),
958
- "node: %s" % (shutil.which("node") or "缺失"),
959
- "npm: %s" % (shutil.which("npm") or "缺失"),
960
- "pnpm: %s" % (shutil.which("pnpm") or "缺失"),
961
- "python: %s" % (shutil.which("python3" if not is_win else "python") or "缺失"),
962
- ]
963
- if not is_win:
964
- env_lines.append("brew: %s" % (shutil.which("brew") or "缺失"))
965
- prompt = (AI_REPAIR_PROMPT.replace("__CMD__", failed_cmd or "(未知)")
966
- .replace("__LOG__", log_tail)
967
- .replace("__ENV__", "\n".join(env_lines))
968
- .replace("__OS__", _repair_os_label())
969
- .replace("__ALLOW__", " / ".join(p.strip() for p in _repair_allow())))
970
- step, log_abs = store.add_step(run_id, "ai-repair", agent["id"], agent.get("label"),
971
- note="自动诊断修复", model=agent.get("model") or "")
972
- res = runner.run_agent(agent, prompt, readonly=True, timeout=300,
973
- cancel_event=ev, log_path=str(log_abs))
974
- try:
975
- _usage.record(source="repair", run_id=run_id, step=step["n"], role="ai-repair",
976
- agent=agent.get("id", ""), agent_label=agent.get("label", ""),
977
- tool=agent.get("kind", ""), model=res.get("model") or "",
978
- ok=bool(res.get("ok")),
979
- duration_s=float((res.get("raw") or {}).get("duration") or 0.0),
980
- cost_usd=float(res.get("cost_usd") or 0.0),
981
- usage=res.get("usage"))
982
- except Exception:
983
- pass
984
- if not res["ok"]:
985
- store.finish_step(run_id, step["n"], "failed",
986
- summary="诊断调用失败:%s" % (res.get("error") or "")[:200])
987
- return False
988
- import re
989
- data = runner.extract_json(res.get("text") or "")
990
- diagnosis = str((data or {}).get("diagnosis") or "(无诊断)")[:200]
991
- cmd = str((data or {}).get("command") or "").strip()
992
- safe = bool((data or {}).get("safe")) and _repair_command_allowed(cmd)
993
- try:
994
- # write_bytes 而非 write_text(...encode()):bytes 传入文本模式 write 会
995
- # TypeError 且被下面的裸 except 吞掉,诊断三行从未落过日志(2026-09-18 修)
996
- log_abs.write_bytes(
997
- ("\n[AI 诊断] %s\n[AI 建议] %s\n[白名单] %s\n" %
998
- (diagnosis, cmd or "(无)", "通过" if safe else "不通过,拒绝自动执行")).encode("utf-8"))
999
- except Exception:
1000
- pass
1001
- if not safe:
1002
- store.finish_step(run_id, step["n"], "failed",
1003
- summary="AI 建议命令未过白名单,需人工执行:%s(诊断:%s)" % (cmd, diagnosis))
1004
- return False
1005
- from . import paths
1006
- fix = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT), timeout=1800,
1007
- cancel_event=ev, log_path=str(log_abs))
1008
- manager.detect_all(force=True)
1009
- with manager._LOCK:
1010
- manager._STATE["versions"].pop(entry["id"], None)
1011
- if fix["ok"]:
1012
- # 修复安装同样会改变版本结论,徽章缓存一并作废复检
1013
- manager.refresh_update_async(entry)
1014
- installed = manager.detect_entry(entry)["installed"]
1015
- store.finish_step(run_id, step["n"],
1016
- "done" if (fix["ok"] and installed) else "failed",
1017
- summary="诊断:%s → 执行 %r:%s,检测安装状态:%s" % (
1018
- diagnosis, cmd,
1019
- "命令成功" if fix["ok"] else "命令失败",
1020
- "已安装" if installed else "未检出"),
1021
- exit_code=fix.get("exit_code"))
1022
- return bool(fix["ok"] and installed)
1
+ # -*- coding: utf-8 -*-
2
+ """任务执行器:直接启动、无等待队列地执行编排任务与管理操作。
3
+
4
+ 每个 job 一条独立线程,run/step 数据按 run_id 隔离,store 层有全局锁。
5
+ 设置中的并发数是保护上限:有空位就立即启动。满载时 orchestration 任务
6
+ (用户/自动化任务)回滚 queued 排队等下一个空位——补跑 Timer 到点重试,
7
+ 封顶 BUSY_WAIT_MAX 拍,超时才判失败(满载秒判失败会逼用户原话重发,
8
+ 侧栏堆同题重复任务,2026-09-22 纸枪对话实案);升级排水与其余 kind
9
+ 仍明确失败并提示稍后重试。并发保护保护的是本机资源(CLI 子进程数),
10
+ 与各 API 厂商的限流无关——厂商侧 429 由 runner 冷却换将兜底。direct
11
+ 对话(内置智能体直连 API,本机几乎零开销)走独立轻量池,不与编排任务
12
+ 抢全局槽。安装/升级失败时自动触发 AI 诊断修复:
13
+ 由真实智能体读取失败日志与本机环境给出修正命令;仅当命令命中白名单前缀
14
+ (npm/winget/brew/pip 安装类,按平台取对应渠道)才自动执行,否则把建议命令
15
+ 记录在运行记录里等人工确认。
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import queue
20
+ import sys
21
+ import threading
22
+ import time
23
+ import traceback
24
+
25
+ # 仅保留给旧测试/诊断代码观察;生产 enqueue 永远不向这里写入。
26
+ _QUEUE = queue.Queue()
27
+ CANCELS = {}
28
+ _started = False
29
+ _alive = 0 # 已获执行位、尚未结束的 job 数
30
+ _restart_drain = False # 升级重启前原子停止接单;不排队、不打断已运行任务
31
+ _target = 12 # 并发保护上限(settings.max_concurrent_jobs)
32
+ _chat_alive = 0 # 轻量池:direct 对话在跑数(独立于全局池计数)
33
+ _pool_lock = threading.Lock()
34
+ _idle_cond = threading.Condition(_pool_lock)
35
+ _timer_lock = threading.Lock()
36
+ _deferred_timers = {}
37
+ _seq = 0
38
+ MAX_POOL = 24
39
+ CHAT_POOL = 4 # 轻量池上限:direct 对话直连 API 本机开销极小,4 个足够
40
+ BUSY_WAIT_S = 15.0 # 满载排队后的补跑轮询间隔(秒);测试可调小
41
+ BUSY_WAIT_MAX = 40 # 补跑拍数封顶:15s × 40 = 10 分钟,超时才判失败
42
+
43
+
44
+ class JobsBusyError(RuntimeError):
45
+ """并发保护位已满;调用方应稍后重试,不得排队。"""
46
+
47
+
48
+ class DuplicateJobError(RuntimeError):
49
+ """同一 run 已启动或结束,拒绝重复执行。"""
50
+
51
+ AI_REPAIR_PROMPT = """你是环境工程师。在 __OS__ 上执行下面的安装命令失败了,请诊断原因并给出修正命令。
52
+ 只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
53
+ {"diagnosis": "失败原因(一句话)", "command": "修正后的完整安装命令", "safe": true/false}
54
+ 硬性约束:command 只能是本机包管理器的安装命令,前缀必须是 __ALLOW__ 之一,且不要带 sudo
55
+ (命令非交互执行,sudo 会挂死;brew/npm 本身也拒绝 sudo)。
56
+ 给不出符合约束的安全命令时,safe 设为 false 且 command 留空。
57
+
58
+ ## 失败的命令
59
+ __CMD__
60
+
61
+ ## 失败输出(尾部)
62
+ __LOG__
63
+
64
+ ## 本机环境
65
+ __ENV__"""
66
+
67
+ # AI 修复命令白名单:只放行包管理器的安装类命令(提示词里的前缀清单由它生成,
68
+ # 两处永远不会漂移)。按平台分组:winget/py 启动器是 Windows 独有,brew/python3
69
+ # 是 macOS/Linux 渠道——Mac 上不放行 brew 的话,AI 给出正确的修正命令也会被拒。
70
+ _AI_REPAIR_ALLOW_WIN = ("npm install ", "winget install", "py -3.13 -m pip install",
71
+ "uv tool install")
72
+ _AI_REPAIR_ALLOW_UNIX = ("npm install ", "brew install", "python3 -m pip install",
73
+ "uv tool install")
74
+ AI_REPAIR_ALLOW = _AI_REPAIR_ALLOW_WIN # 兼容旧引用:Windows 本机即此表
75
+
76
+
77
+ def _repair_os_label(platform=None):
78
+ return {"win32": "Windows", "darwin": "macOS"}.get(platform or sys.platform, "Linux")
79
+
80
+
81
+ def _repair_allow(platform=None):
82
+ if (platform or sys.platform) == "win32":
83
+ return _AI_REPAIR_ALLOW_WIN
84
+ return _AI_REPAIR_ALLOW_UNIX
85
+
86
+
87
+ def _repair_command_allowed(cmd, platform=None):
88
+ cmd = (cmd or "").strip()
89
+ return (cmd.startswith(_repair_allow(platform)) and "|" not in cmd
90
+ and "&" not in cmd and ">" not in cmd)
91
+
92
+
93
+ def configure(max_workers):
94
+ """设置并发保护上限(1-24);只影响后续启动,不中断已运行任务。"""
95
+ global _target
96
+ _target = max(1, min(MAX_POOL, int(max_workers)))
97
+ return _target
98
+
99
+
100
+ def capacity_status():
101
+ """返回当前并发槽状态,供会产生管理记录的入口做无副作用预检。"""
102
+ with _pool_lock:
103
+ return {
104
+ "accepting": not _restart_drain and _alive < _target,
105
+ "active": _alive,
106
+ "limit": _target,
107
+ "restarting": _restart_drain,
108
+ }
109
+
110
+
111
+ def start_worker():
112
+ """加载并发保护配置;线程只在真实任务到达时创建。"""
113
+ global _started
114
+ if _started:
115
+ return
116
+ _started = True
117
+ try:
118
+ from . import settings
119
+ configure(settings.load()["max_concurrent_jobs"])
120
+ return
121
+ except Exception:
122
+ pass
123
+ configure(12)
124
+
125
+
126
+ def _job_is_light(run):
127
+ """direct 对话走轻量池:内置智能体直连模型 API,本机无 CLI 子进程,
128
+ 开销与编排任务(每步起一个 CLI)差几个量级,不该抢全局槽。判定按
129
+ run 所属任务的 type,入口侧零改动(自动化/webhook 等新入口自动跟随)。
130
+ run 缺失或类型未知时保守走全局池。direct 极少数回落 CLI 的场景,轻量
131
+ 池上限也只有 CHAT_POOL 个,本机压力可控。"""
132
+ try:
133
+ task_id = (run or {}).get("task_id")
134
+ if not task_id:
135
+ return False
136
+ from . import store
137
+ task = store.get_task(task_id)
138
+ return str((task or {}).get("type") or "") == "direct"
139
+ except Exception:
140
+ return False
141
+
142
+
143
+ def _try_start_once(job):
144
+ """CAS 认领持久化 run → 占并发位 → 起执行线程,一次直接启动尝试。
145
+
146
+ 返回 True=已起跑;False=并发位满载未启动(run 停在 running,由调用方
147
+ 决定回滚排队还是收口失败)。run 已非 queued(被取消/接管/收口)时抛
148
+ DuplicateJobError;线程创建失败就地收口后原样上抛。升级排水在这里视作
149
+ 满载的一种(返回 False),快速失败还是排队的语义由调用方区分。"""
150
+ global _alive, _chat_alive, _seq
151
+ from . import store
152
+ run_id = job.get("run_id")
153
+ current = store.get_run(run_id)
154
+ if current:
155
+ # 先 CAS 认领持久化 run,再碰并发位。若先占位,两个调用可能分别看到
156
+ # “容量已满”和“状态已变化”,把唯一 run 误收口为 failed 且无人执行。
157
+ changed = store.update_run(run_id, expected_status="queued",
158
+ status="running", started_at=_now(),
159
+ dispatch_mode="direct")
160
+ if changed is None:
161
+ latest = store.get_run(run_id) or current
162
+ raise DuplicateJobError("运行 %s 当前状态为 %s,拒绝重复启动" %
163
+ (run_id, latest.get("status")))
164
+ cancel_event_for(run_id)
165
+
166
+ # CAS 认领后检查并发保护位。占位在 Thread.start 前递增,消除旧实现中线程尚未
167
+ # 回写 alive、扩容循环一次造出几十条 worker 的竞态。direct 对话占轻量池,
168
+ # 其余任务占全局池;job["light"] 随副本传给执行线程,结束时按原池释放。
169
+ light = _job_is_light(current)
170
+ with _pool_lock:
171
+ if _restart_drain:
172
+ busy = True
173
+ elif light:
174
+ busy = _chat_alive >= CHAT_POOL
175
+ if not busy:
176
+ _chat_alive += 1
177
+ job["light"] = True
178
+ else:
179
+ busy = _alive >= _target
180
+ if not busy:
181
+ _alive += 1
182
+ if not busy:
183
+ _seq += 1
184
+ seq = _seq
185
+ if busy:
186
+ CANCELS.pop(run_id, None)
187
+ return False
188
+
189
+ try:
190
+ threading.Thread(target=_run_job, args=(dict(job),),
191
+ name="job-direct-%d" % seq, daemon=True).start()
192
+ except Exception:
193
+ _release_slot(job)
194
+ CANCELS.pop(run_id, None)
195
+ _close_unstarted(job, "任务执行线程启动失败;本次未排队,请稍后重试",
196
+ statuses=("queued", "running"))
197
+ raise
198
+ return True
199
+
200
+
201
+ def enqueue(job):
202
+ """立即为 job 预留执行位并启动独立线程;从不进入内存等待队列。
203
+
204
+ 返回前先把持久化 run 从 queued 原子切到 running,因此 HTTP/自动化调用方
205
+ 不会看到“已接受但仍排队”。满载时 orchestration 任务转排队等待空位(对
206
+ 调用方视作受理成功,见 _requeue_await_slot);重复 run、线程创建失败、
207
+ 升级排水满载都会明确抛错,并就地把 run 收口为 failed,任何入口都不会
208
+ 留下无人认领的僵尸。
209
+ """
210
+ if not _started:
211
+ start_worker()
212
+ if not isinstance(job, dict):
213
+ raise ValueError("job 必须是对象")
214
+ run_id = job.get("run_id")
215
+ if not run_id:
216
+ raise ValueError("job.run_id 必填")
217
+
218
+ if not _try_start_once(job):
219
+ if _restart_drain:
220
+ # 升级排水:接单通道已在关闭,排队只会排进一次注定重启的进程——
221
+ # 照旧快速失败。
222
+ message = "服务正在完成升级重启;本次未排队,请稍后重试"
223
+ _close_unstarted(job, message,
224
+ statuses=("running",))
225
+ raise JobsBusyError(message)
226
+ if str(job.get("kind") or "") != "orchestration" or not _requeue_await_slot(job):
227
+ # mgmt 与回滚失败(run 已被取消/收口)维持快速失败原语义;
228
+ # orchestration 已转排队,对调用方视作受理成功。
229
+ message = ("当前运行任务已达并发保护上限(%d);本次未排队,请稍后重试"
230
+ % _target)
231
+ _close_unstarted(job, message,
232
+ statuses=("running",))
233
+ raise JobsBusyError(message)
234
+ return True
235
+
236
+
237
+ def _release_slot(job=None):
238
+ """释放执行位:按 job 的 light 标记归还对应池,两池全空才唤醒等待方。"""
239
+ global _alive, _chat_alive
240
+ with _idle_cond:
241
+ if job is not None and job.get("light"):
242
+ _chat_alive = max(0, _chat_alive - 1)
243
+ else:
244
+ _alive = max(0, _alive - 1)
245
+ if _alive == 0 and _chat_alive == 0:
246
+ _idle_cond.notify_all()
247
+
248
+
249
+ def wait_for_idle(timeout=10):
250
+ """测试/停机辅助:等待所有直接执行任务(含轻量池)结束。"""
251
+ import time as _t
252
+ end = _t.time() + max(0, float(timeout))
253
+ with _idle_cond:
254
+ while _alive or _chat_alive:
255
+ left = end - _t.time()
256
+ if left <= 0:
257
+ return False
258
+ _idle_cond.wait(min(left, 0.2))
259
+ return True
260
+
261
+
262
+ def begin_restart_drain():
263
+ """升级任务已退出执行位且没有用户任务或 direct 对话时,原子停止接单。"""
264
+ global _restart_drain
265
+ with _pool_lock:
266
+ if _restart_drain or _alive != 0 or _chat_alive != 0:
267
+ return False
268
+ _restart_drain = True
269
+ return True
270
+
271
+
272
+ def cancel_restart_drain():
273
+ """重启未执行或失败时恢复接单。"""
274
+ global _restart_drain
275
+ with _pool_lock:
276
+ _restart_drain = False
277
+
278
+
279
+ def _close_unstarted(job, message, statuses=("queued",)):
280
+ """无法启动时统一收口 run/task,供所有入口复用。"""
281
+ try:
282
+ from . import store
283
+ run_id = job.get("run_id")
284
+ run = store.get_run(run_id) if run_id else None
285
+ if run and run.get("status") in statuses:
286
+ store.update_run(run_id, expected_status=run.get("status"), status="failed",
287
+ error=message, ended_at=_now())
288
+ except Exception:
289
+ pass
290
+
291
+
292
+ def _requeue_await_slot(job, waits_left=None):
293
+ """满载转排队:run 回滚 queued 并挂补跑 Timer,等下一个并发空位。
294
+
295
+ 只服务 orchestration 任务(用户/自动化任务)。enqueue 起手已把 run CAS
296
+ 成 running,这里再 CAS 回 queued(清 started_at,别让「排队中」顶着起跑
297
+ 时间),并同步任务状态回排队。返回 True=已转排队(调用方视作受理成功,
298
+ 不再向调用方抛忙);False=回滚失败(run 已被取消/收口),走原快速失败
299
+ 路径。取消/重启/超时的收口:
300
+ - 用户取消 → run 已非 running,回滚 CAS 落空,或补跑 enqueue 撞
301
+ DuplicateJobError,链条自然断掉;
302
+ - 服务重启 → Timer 随进程消失,启动时 requeue_pending() 接管 queued 记录;
303
+ - 等满 BUSY_WAIT_MAX 拍仍无空位 → 判失败并给出可读原因。
304
+ """
305
+ run_id = job.get("run_id")
306
+ if waits_left is None:
307
+ waits_left = BUSY_WAIT_MAX
308
+ try:
309
+ from . import store
310
+ changed = store.update_run(run_id, expected_status="running",
311
+ status="queued", started_at="")
312
+ if changed is None:
313
+ return False
314
+ if job.get("task_id"):
315
+ try:
316
+ store.update_task_status(job["task_id"], "queued")
317
+ except Exception:
318
+ pass
319
+ except Exception:
320
+ return False
321
+
322
+ def _fire():
323
+ # 补跑心跳:直接尝试一次启动(不走 enqueue 的排队分支,否则每拍都会
324
+ # 带着全新重试额度重新入队,封顶永远打不中)。仍满载则续下一拍。
325
+ with _timer_lock:
326
+ _deferred_timers.pop(run_id, None)
327
+ try:
328
+ from . import store
329
+ run = store.get_run(run_id) or {}
330
+ deadline_at = run.get("deadline_at")
331
+ if deadline_at is not None and float(deadline_at) <= time.time():
332
+ store.update_run(run_id, expected_status="queued", status="timeout",
333
+ ended_at=_now(), error="任务总时限已到(排队等待期间)")
334
+ return
335
+ except (TypeError, ValueError, AttributeError):
336
+ pass
337
+ try:
338
+ started = _try_start_once(job)
339
+ except DuplicateJobError:
340
+ return # 已被取消/接管/收口:补跑链到此为止
341
+ except Exception:
342
+ return # 线程启动失败等异常路径已就地收口,不续棒
343
+ if started:
344
+ return
345
+ if _restart_drain:
346
+ _close_unstarted(job, "服务正在完成升级重启;本次未排队,请稍后重试",
347
+ statuses=("queued", "running"))
348
+ elif waits_left > 0:
349
+ _requeue_await_slot(job, waits_left - 1)
350
+ else:
351
+ _close_unstarted(job, "并发位已满,排队等待超时仍未起跑,请稍后重试",
352
+ statuses=("queued", "running"))
353
+
354
+ with _timer_lock:
355
+ if run_id in _deferred_timers:
356
+ return True # 已有补跑在等:别叠 Timer(防与续跑退避串台)
357
+ timer = threading.Timer(max(0.0, float(BUSY_WAIT_S)), _fire)
358
+ timer.daemon = True
359
+ _deferred_timers[run_id] = timer
360
+ try:
361
+ timer.start()
362
+ except Exception:
363
+ _deferred_timers.pop(run_id, None)
364
+ return False
365
+ return True
366
+
367
+
368
+ def _schedule_enqueue(job, delay_s):
369
+ """按明确截止时间延迟启动;同一 run 只保留一个 Timer。"""
370
+ run_id = job.get("run_id")
371
+ if not run_id:
372
+ return False
373
+
374
+ def _fire():
375
+ with _timer_lock:
376
+ _deferred_timers.pop(run_id, None)
377
+ try:
378
+ enqueue(job)
379
+ except Exception:
380
+ # enqueue 会把未启动 run 收口为 failed;Timer 线程不外抛。
381
+ pass
382
+
383
+ with _timer_lock:
384
+ old = _deferred_timers.get(run_id)
385
+ if old is not None:
386
+ return False
387
+ timer = threading.Timer(max(0.0, float(delay_s)), _fire)
388
+ timer.daemon = True
389
+ _deferred_timers[run_id] = timer
390
+ try:
391
+ timer.start()
392
+ except Exception:
393
+ _deferred_timers.pop(run_id, None)
394
+ _close_unstarted(job, "自动续跑定时器启动失败,请手动重试")
395
+ raise
396
+ return True
397
+
398
+
399
+ def restore_deferred_resumes(limit=None, now=None):
400
+ """重建重启前的自动续跑退避 Timer;返回成功恢复的数量。"""
401
+ import time as _t
402
+ try:
403
+ from . import store
404
+ except Exception:
405
+ return 0
406
+ now = _t.time() if now is None else float(now)
407
+ restored = 0
408
+ for run in store.list_runs(None):
409
+ if limit is not None and restored >= limit:
410
+ break
411
+ if run.get("status") != "queued" or run.get("kind") != "orchestration":
412
+ continue
413
+ at = str(run.get("resume_enqueue_at") or "")
414
+ if not at:
415
+ continue
416
+ try:
417
+ due = _t.mktime(_t.strptime(at, "%Y-%m-%d %H:%M:%S"))
418
+ except Exception:
419
+ due = now
420
+ job = {"kind": "orchestration", "run_id": run["id"],
421
+ "task_id": run.get("task_id")}
422
+ if _schedule_enqueue(job, max(0.0, due - now)):
423
+ restored += 1
424
+ return restored
425
+
426
+
427
+ def _run_job(job):
428
+ """直接执行线程入口;无 get/put 等待阶段。"""
429
+ run_id = job.get("run_id")
430
+ ev = cancel_event_for(run_id) if run_id else threading.Event()
431
+ try:
432
+ # enqueue 已把 run 原子切为 running。线程真正得到调度时再检查一次,
433
+ # 用户若在两者之间取消,直接跳过,避免取消后仍进入流水线。
434
+ if run_id:
435
+ from . import store
436
+ r0 = store.get_run(run_id)
437
+ if r0 and r0.get("status") != "running":
438
+ return
439
+ if job.get("kind") == "orchestration" and _yield_duplicate(run_id):
440
+ return
441
+ if job.get("kind") == "orchestration":
442
+ from . import pipeline
443
+ pipeline.execute_run(run_id)
444
+ elif job.get("kind") == "mgmt":
445
+ _do_mgmt(job, ev)
446
+ elif job.get("kind") == "selfupgrade":
447
+ _do_selfupgrade(job, ev)
448
+ else:
449
+ raise ValueError("未知任务类型 %r" % job.get("kind"))
450
+ except Exception:
451
+ try:
452
+ from . import store
453
+ err = traceback.format_exc()
454
+ store.update_run(run_id, expected_status="running", status="failed",
455
+ error=err[-1500:], ended_at=_now())
456
+ except Exception:
457
+ pass
458
+ finally:
459
+ if run_id:
460
+ # 执行线程退出就是生命周期的最后边界。流水线分支若意外提前 return、
461
+ # 或内部吞掉异常却漏写终态,旧逻辑会让 run/task 永久停在 running。
462
+ try:
463
+ from . import store
464
+ latest = store.get_run(run_id)
465
+ if latest and latest.get("status") == "running":
466
+ store.update_run(
467
+ run_id, expected_status="running", status="failed",
468
+ ended_at=_now(),
469
+ error="执行流程已退出但未写入完成状态,已自动标记失败;可重试")
470
+ except Exception:
471
+ pass
472
+ CANCELS.pop(run_id, None)
473
+ try:
474
+ _maybe_auto_resume(run_id)
475
+ except Exception:
476
+ pass
477
+ try:
478
+ from . import notify
479
+ notify.push_run_async(run_id)
480
+ except Exception:
481
+ pass
482
+ _release_slot(job)
483
+
484
+
485
+ def cancel(run_id):
486
+ ev = CANCELS.get(run_id)
487
+ if ev:
488
+ ev.set()
489
+ # 强制终止语义:置位事件后立刻把 running 落成 cancelled 终态,
490
+ # UI 即刻翻牌,不等流水线走到下一个检查点(CLI 子进程树由取消
491
+ # 事件在 run_process 的 0.4s 轮询里秒杀;内置智能体生成中的 HTTP
492
+ # 由取消等待方放弃)。expected_status 守卫保证已正常结束的运行
493
+ # 不被误改;流水线随后照常收尾,终态幂等。
494
+ try:
495
+ from . import store
496
+ store.update_run(run_id, expected_status="running",
497
+ status="cancelled", ended_at=_now(),
498
+ error="用户主动取消")
499
+ except Exception:
500
+ pass
501
+ return True
502
+ # 事件不存在也可能撞在 enqueue 已把 run 切成 running、尚未来得及登记
503
+ # CANCELS 的极短窗口。无论 queued/running 都直接落终态,不能让取消丢失。
504
+ try:
505
+ from . import store
506
+ run = store.get_run(run_id)
507
+ if not run:
508
+ return False
509
+ status = run.get("status")
510
+ if status in ("queued", "running"):
511
+ changed = store.update_run(run_id, expected_status=status,
512
+ status="cancelled", ended_at=_now(),
513
+ error="用户主动取消",
514
+ cancelled_by_user=True)
515
+ if changed is not None:
516
+ with _timer_lock:
517
+ timer = _deferred_timers.pop(run_id, None)
518
+ if timer is not None:
519
+ timer.cancel()
520
+ return True
521
+ latest = store.get_run(run_id) or {}
522
+ return latest.get("status") == "cancelled"
523
+ except Exception:
524
+ pass
525
+ return False
526
+
527
+
528
+ def cancel_event_for(run_id):
529
+ ev = CANCELS.get(run_id)
530
+ if ev is None:
531
+ ev = threading.Event()
532
+ CANCELS[run_id] = ev
533
+ return ev
534
+
535
+
536
+ AUTO_RESUME_MAX = 3 # 连载任务自动续跑上限(超时/中断后自动接着写,无需人工)
537
+ AUTO_RESUME_DELAY_S = 300 # 自动续跑延迟入队秒数:网关限流/欠费窗口通常分钟级,
538
+ # 立即重排会撞在同一堵墙上把续跑次数烧光(2026-09-17 七猫实测)
539
+
540
+
541
+ def _task_active_run(task_id, exclude_run_id=None):
542
+ """该任务当前 queued/running 的运行(同任务单飞守卫用);无则 None。"""
543
+ try:
544
+ from . import store
545
+ for r in store.task_runs(task_id):
546
+ if r.get("id") == exclude_run_id:
547
+ continue
548
+ if r.get("status") in ("queued", "running"):
549
+ return r
550
+ except Exception:
551
+ pass
552
+ return None
553
+
554
+
555
+ def _err_signature(err):
556
+ """错误串的稳定特征:剥掉章号/引用号/数字等易变片段,只留病根文本。
557
+ 同因连撞判定用——退避重跑后错误若还长得一样,说明墙没动(欠费/配置死),
558
+ 再多次退避也只是重复付费。"""
559
+ import re
560
+ s = str(err or "")
561
+ s = re.sub(r"第\s*\d+\s*章", "第N章", s)
562
+ s = re.sub(r"err_[0-9a-fA-F]+", "err_X", s)
563
+ s = re.sub(r"\d+", "N", s)
564
+ return re.sub(r"\s+", " ", s).strip()[:400]
565
+
566
+
567
+ def _maybe_auto_resume(run_id):
568
+ """连载任务失败自动续跑:继承已完成章继续,最多 AUTO_RESUME_MAX 次。
569
+
570
+ 真实长篇单次运行常因供应商拥堵超时中断;这里在执行线程收尾时自动续跑一次
571
+ 续跑(store.retry_task 会带上 inherit),让整个流程真正无人值守。
572
+ 同因连撞止损:续跑副本再失败时与本次失败的错误签名比对(2026-09-18
573
+ 重写任务 kimi 403 欠费案),一模一样说明退避没换来不同结果,直接落
574
+ 终态写明死因,不再烧剩余的退避次数。
575
+ """
576
+ try:
577
+ from . import store
578
+ run = store.get_run(run_id)
579
+ if not run or run.get("status") not in ("failed", "cancelled"):
580
+ return False
581
+ task = store.get_task(run.get("task_id")) if run.get("task_id") else None
582
+ if not task or not task.get("serial"):
583
+ return False
584
+ if run.get("cancelled_by_user") or run.get("status") == "cancelled":
585
+ return False # 用户主动取消的运行绝不自动续跑
586
+ if int(run.get("auto_resumes") or 0) >= AUTO_RESUME_MAX:
587
+ return False
588
+ prev_id = run.get("auto_resumed_from")
589
+ if prev_id:
590
+ prev = store.get_run(prev_id)
591
+ sig_now = _err_signature(run.get("error"))
592
+ if (prev and sig_now and prev.get("error")
593
+ and sig_now == _err_signature(prev.get("error"))):
594
+ store.update_run(run_id, auto_resume_stopped="same_cause",
595
+ error=(run.get("error") or "")
596
+ + "|自动续跑止损:连续两次失败原因相同,不再重试")
597
+ return False
598
+ if _task_active_run(task["id"], exclude_run_id=run_id):
599
+ return False # 同任务已有运行排队/在跑:再排副本只会与之撞车
600
+ ok, err, new_run = store.retry_task(task["id"])
601
+ if not ok or not new_run:
602
+ return False
603
+ # 退避窗口要让用户看得见:把「预定启动时刻」写到 run 上,前端据此显示
604
+ # 「将在 HH:MM 自动续跑」。这不是容量排队,且等待期间不占执行位。
605
+ import time as _t
606
+ resume_at = _t.strftime("%Y-%m-%d %H:%M:%S",
607
+ _t.localtime(_t.time() + AUTO_RESUME_DELAY_S))
608
+ store.update_run(new_run["id"], auto_resumes=int(run.get("auto_resumes") or 0) + 1,
609
+ auto_resumed_from=run_id, resume_enqueue_at=resume_at)
610
+
611
+ _schedule_enqueue({"kind": "orchestration", "run_id": new_run["id"],
612
+ "task_id": task["id"]}, AUTO_RESUME_DELAY_S)
613
+ return True
614
+ except Exception:
615
+ return False
616
+
617
+
618
+ RESUME_WINDOW_HOURS = 24 # 启动恢复只看最近 24h 内中断的运行(更早的视为已放弃)
619
+
620
+
621
+ def _recent(run):
622
+ """运行创建时间是否在恢复窗口内(时间格式 %Y-%m-%d %H:%M:%S)。"""
623
+ import time as _t
624
+ try:
625
+ ts = _t.mktime(_t.strptime(run.get("created_at") or "", "%Y-%m-%d %H:%M:%S"))
626
+ except Exception:
627
+ return False
628
+ return (_t.time() - ts) <= RESUME_WINDOW_HOURS * 3600
629
+
630
+
631
+ def resume_interrupted(limit=3):
632
+ """启动恢复:把**近期**中断的连载任务重新入队(继承已完成章)。返回恢复条数。
633
+
634
+ 服务被外部杀掉/崩溃时 worker 的 finally 不会执行;这里在启动时补一次。
635
+ 只在 RESUME_WINDOW_HOURS 窗口内、且该任务没有更新的终态运行时恢复——
636
+ 避免复活用户早已放弃或已完成任务的旧运行。用户手动取消的一律跳过。
637
+ """
638
+ try:
639
+ from . import store
640
+ except Exception:
641
+ return 0
642
+ runs = store.list_runs(200)
643
+ latest_by_task = {}
644
+ for r in runs: # list_runs 已按 id 倒序:首次出现即该 task 最新运行
645
+ tid = r.get("task_id")
646
+ if tid and tid not in latest_by_task:
647
+ latest_by_task[tid] = r["id"]
648
+ n = 0
649
+ try:
650
+ for run in sorted(runs, key=lambda r: r["id"]):
651
+ if n >= limit:
652
+ break
653
+ if run.get("status") != "failed" or not run.get("task_id"):
654
+ continue
655
+ if run.get("cancelled_by_user"):
656
+ continue
657
+ if run.get("paused"):
658
+ continue # 用户主动暂停的运行(重启收尸后 paused 标志保留)不自动续跑——
659
+ # 「继续」由用户点「继续任务」决定,不替用户做主
660
+ if not _recent(run):
661
+ continue
662
+ if latest_by_task.get(run["task_id"]) != run["id"]:
663
+ continue # 该任务已有更新的运行(如用户重试/已完结),不复活旧中断
664
+ task = store.get_task(run["task_id"])
665
+ if not task or not task.get("serial"):
666
+ continue
667
+ if int(run.get("auto_resumes") or 0) >= AUTO_RESUME_MAX:
668
+ continue
669
+ ok, err, new_run = store.retry_task(task["id"])
670
+ if not ok or not new_run:
671
+ continue
672
+ store.update_run(new_run["id"],
673
+ auto_resumes=int(run.get("auto_resumes") or 0) + 1,
674
+ auto_resumed_from=run["id"])
675
+ try:
676
+ enqueue({"kind": "orchestration", "run_id": new_run["id"],
677
+ "task_id": task["id"]})
678
+ n += 1
679
+ except Exception:
680
+ pass
681
+ except Exception:
682
+ return n
683
+ return n
684
+
685
+
686
+ def _yield_duplicate(run_id):
687
+ """同任务单飞(执行线程入口把关):系统续跑副本启动时若同任务已有
688
+ 运行排队/在跑,取消自己让位——恢复副本与原轮并行跑只会双烧评审。
689
+ 用户轮不在此拦(retry_task 建轮时已拒运行中任务)。返回 True 表示
690
+ 本运行已落 cancelled,不要执行。"""
691
+ try:
692
+ from . import store
693
+ r0 = store.get_run(run_id)
694
+ if not r0 or not r0.get("auto_resumed_from"):
695
+ return False
696
+ other = _task_active_run(r0.get("task_id"), exclude_run_id=run_id)
697
+ if not other or other.get("kind") != "orchestration":
698
+ return False
699
+ store.update_run(run_id, status="cancelled", ended_at=_now(),
700
+ error="同任务已有运行在跑(%s),续跑副本自动让位" % other.get("id"))
701
+ return True
702
+ except Exception:
703
+ return False
704
+
705
+
706
+ def _age_s(run, now=None):
707
+ """run 已创建多少秒(created_at 解析失败返回 0:宁可早补,别误判卡死)。"""
708
+ import time as _t
709
+ now = now if now is not None else _t.time()
710
+ try:
711
+ ts = _t.mktime(_t.strptime(run.get("created_at") or "", "%Y-%m-%d %H:%M:%S"))
712
+ except Exception:
713
+ return 0.0
714
+ return max(0.0, now - ts)
715
+
716
+
717
+ def _in_resume_backoff(run, now=None):
718
+ """续跑副本是否还在退避等待窗口内(resume_enqueue_at 未到点)。
719
+
720
+ 到点时间由 _maybe_auto_resume 落在 run 上,Timer 到点才入队;巡检/补队
721
+ 若无视它直接重排,等于把 300s 网关退避窗口烧掉(立即重排撞同一堵墙)。"""
722
+ import time as _t
723
+ now = now if now is not None else _t.time()
724
+ at = str(run.get("resume_enqueue_at") or "")
725
+ if not at:
726
+ return False
727
+ try:
728
+ return _t.mktime(_t.strptime(at, "%Y-%m-%d %H:%M:%S")) > now
729
+ except Exception:
730
+ return False # 解析失败按「不在退避」处理:宁可入队,别让副本永远卡死
731
+
732
+
733
+ def requeue_pending(limit=10, max_age_s=None):
734
+ """直接启动遗留的 queued 运行(启动恢复与运行期巡检共用)。
735
+
736
+ 兼容旧版本曾持久化的排队状态,以及续跑 Timer 随进程消失的历史情况。
737
+ 同任务已有在跑/等待续跑的不重复启动。mgmt 同样纳入,避免旧的排队记录
738
+ 堵住同条目去重闸。selfupgrade
739
+ 不补——升级本体有进程替换语义,自动重排不可控。
740
+
741
+ max_age_s:巡检模式只接管「卡了超过该秒数」的,刚创建的退避记录不掺和;
742
+ None(启动模式)全量补。resume_enqueue_at 未到点的续跑副本两种模式都
743
+ 跳过——重启不该把退避窗口烧掉。返回补队条数。"""
744
+ try:
745
+ from . import store
746
+ except Exception:
747
+ return 0
748
+ n = 0
749
+ try:
750
+ for run in store.list_runs(200):
751
+ if n >= limit:
752
+ break
753
+ kind = run.get("kind")
754
+ if run.get("status") != "queued" or kind not in ("orchestration", "mgmt"):
755
+ continue
756
+ if max_age_s is not None and _age_s(run) < max_age_s:
757
+ continue
758
+ if kind == "orchestration":
759
+ if not run.get("task_id") or not _recent(run):
760
+ continue
761
+ if _in_resume_backoff(run):
762
+ continue # 退避窗口内的续跑副本:到点 Timer 自会入队
763
+ if _task_active_run(run["task_id"], exclude_run_id=run["id"]):
764
+ continue # 同任务已有更活跃的运行,别再排一份
765
+ else:
766
+ eid = run.get("entry_id")
767
+ if eid:
768
+ other = store.active_mgmt_run(eid)
769
+ if other and other.get("id") != run["id"]:
770
+ continue # 同条目已有更活跃的 run,去重闸语义收敛
771
+ try:
772
+ enqueue({"kind": kind, "run_id": run["id"],
773
+ "task_id": run.get("task_id"),
774
+ "entry_id": run.get("entry_id"), "op": run.get("op")})
775
+ n += 1
776
+ except (JobsBusyError, DuplicateJobError):
777
+ # 满载时 enqueue 已明确收口;重复 run 已由另一个执行方接管。
778
+ continue
779
+ except Exception:
780
+ continue
781
+ except Exception:
782
+ pass
783
+ return n
784
+
785
+
786
+ def workers_info():
787
+ with _pool_lock:
788
+ info = {"target": _target, "alive": _alive,
789
+ "chat_alive": _chat_alive, "chat_pool": CHAT_POOL,
790
+ "available": 0 if _restart_drain else max(0, _target - _alive),
791
+ "mode": "restart-drain" if _restart_drain else "direct"}
792
+ # queued runs are deliberately persisted rather than held in _QUEUE. Report
793
+ # the same source the board renders so capacity waits are observable.
794
+ try:
795
+ from . import store
796
+ info["queued"] = sum(1 for run in store.list_runs(None)
797
+ if run.get("status") == "queued")
798
+ except Exception:
799
+ info["queued"] = 0
800
+ return info
801
+
802
+
803
+ def _drain_test_queue():
804
+ """测试辅助:清空兼容队列并取消未决 Timer(生产代码勿调)。"""
805
+ cancel_restart_drain()
806
+ with _timer_lock:
807
+ timers = list(_deferred_timers.values())
808
+ _deferred_timers.clear()
809
+ for timer in timers:
810
+ try:
811
+ timer.cancel()
812
+ except Exception:
813
+ pass
814
+ while True:
815
+ try:
816
+ _QUEUE.get_nowait()
817
+ _QUEUE.task_done()
818
+ except Exception:
819
+ return
820
+
821
+
822
+ def _now():
823
+ import time
824
+ return time.strftime("%Y-%m-%d %H:%M:%S")
825
+
826
+
827
+ def _smoke_response_ok(text):
828
+ """Accept an explicit OK line; substrings such as "NOT OK" are failures."""
829
+ for line in str(text or "").splitlines():
830
+ value = line.strip().strip("`*_\"'").strip()
831
+ while value and value[-1] in ".!。!":
832
+ value = value[:-1].strip()
833
+ if value.upper() == "OK":
834
+ return True
835
+ return False
836
+
837
+
838
+ def _do_mgmt(job, ev):
839
+ from . import catalog, manager, store
840
+ run_id = job["run_id"]
841
+ entry = catalog.by_id(job.get("entry_id"))
842
+ op = job.get("op") or ""
843
+ if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
844
+ return
845
+ if entry is None:
846
+ store.update_run(run_id, expected_status="running", status="failed",
847
+ error="catalog 中找不到 %s" % job.get("entry_id"),
848
+ ended_at=_now())
849
+ return
850
+ step, log_abs = store.add_step(run_id, op or "mgmt", entry["id"], entry.get("name", entry["id"]))
851
+ ok = False
852
+ if op in ("install", "upgrade", "uninstall"):
853
+ res = manager.run_mgmt_command(entry, op, cancel_event=ev, log_path=str(log_abs))
854
+ if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
855
+ return
856
+ ok = res["ok"]
857
+ # 文件占用类失败(Windows 文件锁 EBUSY/EPERM)给人话结论并跳过 AI 修复:
858
+ # 修复智能体面对文件锁只会给出 taskkill 全杀 node 之类白名单必拒的危险
859
+ # 命令,白白烧一轮 300s 诊断(2026-09-18 dsh 同版本重装 EBUSY 案)
860
+ lock_hit = (not ok and op in ("install", "upgrade") and _file_lock_error(res))
861
+ if lock_hit:
862
+ summary = ("失败:安装文件被占用(可能有同名程序在运行,或杀毒软件正在扫描),"
863
+ "请关闭占用该文件的程序后重试。完整输出见日志。")
864
+ else:
865
+ summary = ("完成" if ok else "失败") + (": " + res["error"][:300] if res.get("error") else "")
866
+ store.finish_step(run_id, step["n"],
867
+ "done" if ok else "failed",
868
+ summary=summary,
869
+ exit_code=res.get("exit_code"))
870
+ # AI 修复只针对安装类失败;卸载失败多为权限/程序占用,留给用户看日志处理
871
+ if not ok and op in ("install", "upgrade") and not lock_hit:
872
+ ok = _ai_repair(run_id, entry, ev, entry.get(op), log_abs)
873
+ elif op == "smoke":
874
+ from . import runner as _r
875
+ from . import registry
876
+ from . import usage as _usage
877
+ agents = registry.effective_agents(catalog.load(), manager.detect_all())
878
+ agent = next((a for a in agents if a["id"] == entry["id"]), None)
879
+ if agent is None:
880
+ store.finish_step(run_id, step["n"], "failed", summary="该智能体未安装或未启用编排")
881
+ store.update_run(run_id, expected_status="running", status="failed",
882
+ error="未启用", ended_at=_now())
883
+ return
884
+ res = _r.run_agent(agent, "连通性测试:请只回复两个字:OK",
885
+ readonly=True,
886
+ # 300s:codex CLI 启动要拉 5 个 MCP 服务器 + 注入约 13 万
887
+ # token 技能上下文,首 token 常超 180s(2026-09-17 实测
888
+ # 网关裸探 8s 就回,慢在 CLI 自身启动与上下文)。
889
+ timeout=300, cancel_event=ev, log_path=str(log_abs))
890
+ if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
891
+ return
892
+ ok = bool(res.get("ok") and _smoke_response_ok(res.get("text")))
893
+ try:
894
+ _usage.record(source="smoke", run_id=run_id, step=step["n"], role="smoke",
895
+ agent=agent.get("id", ""), agent_label=agent.get("label", ""),
896
+ tool=agent.get("kind", ""), model=res.get("model") or "",
897
+ ok=ok,
898
+ duration_s=float((res.get("raw") or {}).get("duration") or 0.0),
899
+ cost_usd=float(res.get("cost_usd") or 0.0),
900
+ usage=res.get("usage"))
901
+ except Exception:
902
+ pass
903
+ store.finish_step(run_id, step["n"], "done" if ok else "failed",
904
+ summary=("CLI 默认配置连通:%s" % (res.get("text") or "")[:120]) if ok
905
+ else ("CLI 默认配置异常:%s" %
906
+ (res.get("error") or (res.get("text") or "")[:120])),
907
+ exit_code=res["raw"].get("exit_code"),
908
+ cost_usd=res.get("cost_usd", 0.0), tokens=res.get("tokens", 0))
909
+ else:
910
+ store.finish_step(run_id, step["n"], "failed", summary="未知操作 %s" % op)
911
+ # 以步骤状态汇总 run 状态;expected_status 守卫:用户已强制终止的运行
912
+ # 保持 cancelled,不被步骤汇总改写成 failed(被杀的步骤记的就是非 done)
913
+ run = store.get_run(run_id)
914
+ statuses = [s["status"] for s in (run.get("steps") if run else [])] or ["failed"]
915
+ final = "done" if all(s == "done" for s in statuses) else "failed"
916
+ suffix = "(AI 修复成功)" if ok and len((run.get("steps") if run else [])) > 1 else ""
917
+ store.update_run(run_id, expected_status="running", status=final, ended_at=_now(),
918
+ summary=("%s %s %s%s" % (entry.get("name"), op,
919
+ "完成" if final == "done" else "失败", suffix)))
920
+
921
+
922
+ def _do_selfupgrade(job, ev):
923
+ """CodeBee 自升级:在 mgmt run 里跑 npm install -g @latest,日志实时落盘。"""
924
+ from . import selfupdate, store
925
+ run_id = job["run_id"]
926
+ if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
927
+ return
928
+ step, log_abs = store.add_step(run_id, "selfupgrade", "__self__", "CodeBee")
929
+ try:
930
+ res = selfupdate.run_upgrade(run_id, str(log_abs), cancel_event=ev)
931
+ except Exception as e:
932
+ res = {"ok": False, "exit_code": None, "error": repr(e)}
933
+ if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
934
+ return
935
+ store.finish_step(run_id, step["n"], "done" if res["ok"] else "failed",
936
+ summary="升级完成,服务将自动重启生效" if res["ok"]
937
+ else ("升级失败: " + res["error"][:300]),
938
+ exit_code=res.get("exit_code"))
939
+ # expected_status 守卫:用户已强制终止的运行保持 cancelled,不被改写
940
+ store.update_run(run_id, expected_status="running",
941
+ status="done" if res["ok"] else "failed", ended_at=_now(),
942
+ summary="CodeBee selfupgrade %s" % ("完成" if res["ok"] else "失败"))
943
+
944
+
945
+ def _file_lock_error(res):
946
+ """安装/升级失败输出是否为文件占用类错误(npm/pip 的 EBUSY/EPERM 文件锁)。"""
947
+ err = (res or {}).get("error") or ""
948
+ return "EBUSY" in err or "EPERM" in err
949
+
950
+
951
+ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
952
+ """安装失败后的 AI 诊断修复:诊断 → 白名单校验 → 执行 → 复检。"""
953
+ from . import catalog, manager, registry, router, runner, store
954
+ from . import usage as _usage
955
+ agents = registry.effective_agents(catalog.load(), manager.detect_all())
956
+ agent, _reason = router.pick(agents, "repair", "mgmt")
957
+ if agent is None or agent.get("mode") != "real":
958
+ return False # 无真实智能体可用,维持原失败
959
+ try:
960
+ # 日志尾巴喂进 AI 修复提示词:先洗终端噪声,否则 ANSI 转义/乱码墙会被
961
+ # 模型当成"日志原文"照抄进修复推理里
962
+ log_tail = runner.clean_cli_text(
963
+ runner.tail_decoded(orig_log.read_bytes(), 2000)) if orig_log.exists() else "(无输出)"
964
+ except Exception:
965
+ log_tail = "(日志不可读)"
966
+ import shutil
967
+ is_win = sys.platform == "win32"
968
+ env_lines = [
969
+ "OS: %s" % _repair_os_label(),
970
+ "node: %s" % (shutil.which("node") or "缺失"),
971
+ "npm: %s" % (shutil.which("npm") or "缺失"),
972
+ "pnpm: %s" % (shutil.which("pnpm") or "缺失"),
973
+ "python: %s" % (shutil.which("python3" if not is_win else "python") or "缺失"),
974
+ ]
975
+ if not is_win:
976
+ env_lines.append("brew: %s" % (shutil.which("brew") or "缺失"))
977
+ prompt = (AI_REPAIR_PROMPT.replace("__CMD__", failed_cmd or "(未知)")
978
+ .replace("__LOG__", log_tail)
979
+ .replace("__ENV__", "\n".join(env_lines))
980
+ .replace("__OS__", _repair_os_label())
981
+ .replace("__ALLOW__", " / ".join(p.strip() for p in _repair_allow())))
982
+ step, log_abs = store.add_step(run_id, "ai-repair", agent["id"], agent.get("label"),
983
+ note="自动诊断修复", model=agent.get("model") or "")
984
+ res = runner.run_agent(agent, prompt, readonly=True, timeout=300,
985
+ cancel_event=ev, log_path=str(log_abs))
986
+ try:
987
+ _usage.record(source="repair", run_id=run_id, step=step["n"], role="ai-repair",
988
+ agent=agent.get("id", ""), agent_label=agent.get("label", ""),
989
+ tool=agent.get("kind", ""), model=res.get("model") or "",
990
+ ok=bool(res.get("ok")),
991
+ duration_s=float((res.get("raw") or {}).get("duration") or 0.0),
992
+ cost_usd=float(res.get("cost_usd") or 0.0),
993
+ usage=res.get("usage"))
994
+ except Exception:
995
+ pass
996
+ if not res["ok"]:
997
+ store.finish_step(run_id, step["n"], "failed",
998
+ summary="诊断调用失败:%s" % (res.get("error") or "")[:200])
999
+ return False
1000
+ import re
1001
+ data = runner.extract_json(res.get("text") or "")
1002
+ diagnosis = str((data or {}).get("diagnosis") or "(无诊断)")[:200]
1003
+ cmd = str((data or {}).get("command") or "").strip()
1004
+ safe = bool((data or {}).get("safe")) and _repair_command_allowed(cmd)
1005
+ try:
1006
+ # write_bytes 而非 write_text(...encode()):bytes 传入文本模式 write 会
1007
+ # TypeError 且被下面的裸 except 吞掉,诊断三行从未落过日志(2026-09-18 修)
1008
+ log_abs.write_bytes(
1009
+ ("\n[AI 诊断] %s\n[AI 建议] %s\n[白名单] %s\n" %
1010
+ (diagnosis, cmd or "(无)", "通过" if safe else "不通过,拒绝自动执行")).encode("utf-8"))
1011
+ except Exception:
1012
+ pass
1013
+ if not safe:
1014
+ store.finish_step(run_id, step["n"], "failed",
1015
+ summary="AI 建议命令未过白名单,需人工执行:%s(诊断:%s)" % (cmd, diagnosis))
1016
+ return False
1017
+ from . import paths
1018
+ fix = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT), timeout=1800,
1019
+ cancel_event=ev, log_path=str(log_abs))
1020
+ manager.detect_all(force=True)
1021
+ with manager._LOCK:
1022
+ manager._STATE["versions"].pop(entry["id"], None)
1023
+ if fix["ok"]:
1024
+ # 修复安装同样会改变版本结论,徽章缓存一并作废复检
1025
+ manager.refresh_update_async(entry)
1026
+ installed = manager.detect_entry(entry)["installed"]
1027
+ store.finish_step(run_id, step["n"],
1028
+ "done" if (fix["ok"] and installed) else "failed",
1029
+ summary="诊断:%s → 执行 %r:%s,检测安装状态:%s" % (
1030
+ diagnosis, cmd,
1031
+ "命令成功" if fix["ok"] else "命令失败",
1032
+ "已安装" if installed else "未检出"),
1033
+ exit_code=fix.get("exit_code"))
1034
+ return bool(fix["ok"] and installed)