codebee 0.1.2 → 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/bookmeta.py +161 -27
- package/app/core/bookmeta_catalog.py +187 -0
- package/app/core/builtin_agent.py +382 -0
- package/app/core/flows.py +1 -1
- package/app/core/jobs.py +441 -424
- package/app/core/manager.py +1523 -1511
- package/app/core/market_remote.py +959 -896
- package/app/core/modelhub.py +7 -0
- package/app/core/pipeline.py +138 -32
- package/app/core/store.py +3 -1
- package/app/main.py +1478 -1448
- package/app/ui/app.js +212 -20
- package/app/ui/i18n.js +1732 -1712
- package/app/ui/index.html +6 -2
- package/app/ui/style.css +87 -59
- package/bin/tutti.js +147 -147
- package/package.json +39 -39
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
return
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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)
|