codebee 0.1.20 → 0.1.21
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/CHANGELOG.md +6 -0
- package/README.md +8 -6
- package/app/core/automation.py +6 -6
- package/app/core/capability.py +16 -2
- package/app/core/catalog.py +1 -1
- package/app/core/covergen.py +40 -4
- package/app/core/dispatch.py +141 -0
- package/app/core/flows.py +5 -0
- package/app/core/jobs.py +281 -166
- package/app/core/manager.py +30 -23
- package/app/core/market_remote.py +66 -22
- package/app/core/modelhub.py +145 -11
- package/app/core/pipeline.py +111 -30
- package/app/core/router.py +18 -7
- package/app/core/runner.py +2 -1
- package/app/core/selfupdate.py +24 -14
- package/app/core/settings.py +3 -3
- package/app/core/skills.py +43 -0
- package/app/core/store.py +20 -14
- package/app/core/zentao.py +26 -6
- package/app/main.py +20 -12
- package/app/pet.py +2 -2
- package/app/ui/app.js +62 -58
- package/app/ui/i18n.js +21 -3
- package/app/ui/index.html +11 -11
- package/app/ui/style.css +2 -0
- package/package.json +1 -1
package/app/core/jobs.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""
|
|
2
|
+
"""任务执行器:直接启动、无等待队列地执行编排任务与管理操作。
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
每个 job 一条独立线程,run/step 数据按 run_id 隔离,store 层有全局锁。
|
|
5
|
+
设置中的并发数是保护上限:有空位就立即启动,满载则明确失败并提示稍后重试,
|
|
6
|
+
绝不把任务留在内存队列里无限等待。安装/升级失败时自动触发 AI 诊断修复:
|
|
7
7
|
由真实智能体读取失败日志与本机环境给出修正命令;仅当命令命中白名单前缀
|
|
8
8
|
(npm/winget/brew/pip 安装类,按平台取对应渠道)才自动执行,否则把建议命令
|
|
9
9
|
记录在运行记录里等人工确认。
|
|
@@ -15,19 +15,26 @@ import sys
|
|
|
15
15
|
import threading
|
|
16
16
|
import traceback
|
|
17
17
|
|
|
18
|
+
# 仅保留给旧测试/诊断代码观察;生产 enqueue 永远不向这里写入。
|
|
18
19
|
_QUEUE = queue.Queue()
|
|
19
20
|
CANCELS = {}
|
|
20
21
|
_started = False
|
|
21
|
-
_alive = 0 #
|
|
22
|
-
_target =
|
|
22
|
+
_alive = 0 # 已获执行位、尚未结束的 job 数
|
|
23
|
+
_target = 12 # 并发保护上限(settings.max_concurrent_jobs)
|
|
23
24
|
_pool_lock = threading.Lock()
|
|
25
|
+
_idle_cond = threading.Condition(_pool_lock)
|
|
26
|
+
_timer_lock = threading.Lock()
|
|
27
|
+
_deferred_timers = {}
|
|
24
28
|
_seq = 0
|
|
25
|
-
MAX_POOL = 12
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
MAX_POOL = 12
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class JobsBusyError(RuntimeError):
|
|
33
|
+
"""并发保护位已满;调用方应稍后重试,不得排队。"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DuplicateJobError(RuntimeError):
|
|
37
|
+
"""同一 run 已启动或结束,拒绝重复执行。"""
|
|
31
38
|
|
|
32
39
|
AI_REPAIR_PROMPT = """你是环境工程师。在 __OS__ 上执行下面的安装命令失败了,请诊断原因并给出修正命令。
|
|
33
40
|
只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
@@ -72,34 +79,14 @@ def _repair_command_allowed(cmd, platform=None):
|
|
|
72
79
|
|
|
73
80
|
|
|
74
81
|
def configure(max_workers):
|
|
75
|
-
"""
|
|
82
|
+
"""设置并发保护上限(1-12);只影响后续启动,不中断已运行任务。"""
|
|
76
83
|
global _target
|
|
77
84
|
_target = max(1, min(MAX_POOL, int(max_workers)))
|
|
78
|
-
if _started:
|
|
79
|
-
_resize()
|
|
80
85
|
return _target
|
|
81
86
|
|
|
82
87
|
|
|
83
|
-
def _resize():
|
|
84
|
-
global _seq
|
|
85
|
-
with _pool_lock:
|
|
86
|
-
# 上限 50 次尝试:Thread.start() 返回到 _worker 真正执行之间有调度间隙,
|
|
87
|
-
# 极端环境下(杀软挂起新线程)_alive 迟迟不涨,无界循环会转着圈造线程。
|
|
88
|
-
attempts = 0
|
|
89
|
-
while _alive < _target and attempts < 50:
|
|
90
|
-
attempts += 1
|
|
91
|
-
_seq += 1
|
|
92
|
-
try:
|
|
93
|
-
threading.Thread(target=_worker, name="job-worker-%d" % _seq,
|
|
94
|
-
daemon=True).start()
|
|
95
|
-
except RuntimeError:
|
|
96
|
-
break # 资源受限起不了新线程:保持现有 worker,不影响任务执行
|
|
97
|
-
|
|
98
|
-
|
|
99
88
|
def start_worker():
|
|
100
|
-
"""
|
|
101
|
-
案例:某些杀软环境下启动期 Thread.start() 挂死,进程停在任务队列一步),
|
|
102
|
-
推迟到首次 enqueue 时由 _ensure_workers 创建——服务就绪不再依赖线程。"""
|
|
89
|
+
"""加载并发保护配置;线程只在真实任务到达时创建。"""
|
|
103
90
|
global _started
|
|
104
91
|
if _started:
|
|
105
92
|
return
|
|
@@ -110,46 +97,205 @@ def start_worker():
|
|
|
110
97
|
return
|
|
111
98
|
except Exception:
|
|
112
99
|
pass
|
|
113
|
-
configure(
|
|
100
|
+
configure(12)
|
|
114
101
|
|
|
115
102
|
|
|
116
103
|
def enqueue(job):
|
|
104
|
+
"""立即为 job 预留执行位并启动独立线程;从不进入等待队列。
|
|
105
|
+
|
|
106
|
+
返回前先把持久化 run 从 queued 原子切到 running,因此 HTTP/自动化调用方
|
|
107
|
+
不会看到“已接受但仍排队”。满载、重复 run、线程创建失败都会明确抛错;
|
|
108
|
+
满载与启动失败还会就地把 run 收口为 failed,任何入口都不会留下僵尸。
|
|
109
|
+
"""
|
|
110
|
+
global _alive, _seq
|
|
117
111
|
if not _started:
|
|
118
112
|
start_worker()
|
|
119
|
-
|
|
120
|
-
|
|
113
|
+
if not isinstance(job, dict):
|
|
114
|
+
raise ValueError("job 必须是对象")
|
|
115
|
+
run_id = job.get("run_id")
|
|
116
|
+
if not run_id:
|
|
117
|
+
raise ValueError("job.run_id 必填")
|
|
118
|
+
|
|
119
|
+
from . import store
|
|
120
|
+
current = store.get_run(run_id)
|
|
121
|
+
if current:
|
|
122
|
+
# 先 CAS 认领持久化 run,再碰并发位。若先占位,两个调用可能分别看到
|
|
123
|
+
# “容量已满”和“状态已变化”,把唯一 run 误收口为 failed 且无人执行。
|
|
124
|
+
changed = store.update_run(run_id, expected_status="queued",
|
|
125
|
+
status="running", started_at=_now(),
|
|
126
|
+
dispatch_mode="direct")
|
|
127
|
+
if changed is None:
|
|
128
|
+
latest = store.get_run(run_id) or current
|
|
129
|
+
raise DuplicateJobError("运行 %s 当前状态为 %s,拒绝重复启动" %
|
|
130
|
+
(run_id, latest.get("status")))
|
|
131
|
+
cancel_event_for(run_id)
|
|
132
|
+
|
|
133
|
+
# CAS 认领后检查并发保护位。_alive 在 Thread.start 前递增,消除旧实现中线程尚未
|
|
134
|
+
# 回写 alive、扩容循环一次造出几十条 worker 的竞态。
|
|
135
|
+
with _pool_lock:
|
|
136
|
+
if _alive >= _target:
|
|
137
|
+
busy_limit = _target
|
|
138
|
+
else:
|
|
139
|
+
busy_limit = 0
|
|
140
|
+
_alive += 1
|
|
141
|
+
_seq += 1
|
|
142
|
+
seq = _seq
|
|
143
|
+
if busy_limit:
|
|
144
|
+
CANCELS.pop(run_id, None)
|
|
145
|
+
_close_unstarted(job, "当前运行任务已达并发保护上限(%d);本次未排队,请稍后重试" % busy_limit,
|
|
146
|
+
statuses=("running",))
|
|
147
|
+
raise JobsBusyError("当前运行任务已达并发保护上限(%d),本次未排队" % busy_limit)
|
|
121
148
|
|
|
149
|
+
try:
|
|
150
|
+
threading.Thread(target=_run_job, args=(dict(job),),
|
|
151
|
+
name="job-direct-%d" % seq, daemon=True).start()
|
|
152
|
+
except Exception:
|
|
153
|
+
_release_slot()
|
|
154
|
+
CANCELS.pop(run_id, None)
|
|
155
|
+
_close_unstarted(job, "任务执行线程启动失败;本次未排队,请稍后重试",
|
|
156
|
+
statuses=("queued", "running"))
|
|
157
|
+
raise
|
|
122
158
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
global
|
|
128
|
-
with
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
_resize()
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _watchdog():
|
|
140
|
-
"""队列看门狗:周期把卡死的 queued 编排运行补回队列。
|
|
141
|
-
|
|
142
|
-
job 队列在内存里,任何一次入队丢失(2026-09-18 实案:r-20260918-211920
|
|
143
|
-
排队 1 小时无人接手、进程未重启则启动补队永远不跑)都会让 UI 永远
|
|
144
|
-
「排队中」。这里每分钟自愈一次;重复入队由 worker 出队守卫(非 queued
|
|
145
|
-
跳过)与同任务单飞守卫兜底,幂等。巡检自身异常绝不退出。"""
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _release_slot():
|
|
163
|
+
global _alive
|
|
164
|
+
with _idle_cond:
|
|
165
|
+
_alive = max(0, _alive - 1)
|
|
166
|
+
if _alive == 0:
|
|
167
|
+
_idle_cond.notify_all()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def wait_for_idle(timeout=10):
|
|
171
|
+
"""测试/停机辅助:等待所有直接执行任务结束。"""
|
|
146
172
|
import time as _t
|
|
147
|
-
|
|
148
|
-
|
|
173
|
+
end = _t.time() + max(0, float(timeout))
|
|
174
|
+
with _idle_cond:
|
|
175
|
+
while _alive:
|
|
176
|
+
left = end - _t.time()
|
|
177
|
+
if left <= 0:
|
|
178
|
+
return False
|
|
179
|
+
_idle_cond.wait(min(left, 0.2))
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _close_unstarted(job, message, statuses=("queued",)):
|
|
184
|
+
"""无法启动时统一收口 run/task,供所有入口复用。"""
|
|
185
|
+
try:
|
|
186
|
+
from . import store
|
|
187
|
+
run_id = job.get("run_id")
|
|
188
|
+
run = store.get_run(run_id) if run_id else None
|
|
189
|
+
if run and run.get("status") in statuses:
|
|
190
|
+
store.update_run(run_id, expected_status=run.get("status"), status="failed",
|
|
191
|
+
error=message, ended_at=_now())
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _schedule_enqueue(job, delay_s):
|
|
197
|
+
"""按明确截止时间延迟启动;同一 run 只保留一个 Timer。"""
|
|
198
|
+
run_id = job.get("run_id")
|
|
199
|
+
if not run_id:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
def _fire():
|
|
203
|
+
with _timer_lock:
|
|
204
|
+
_deferred_timers.pop(run_id, None)
|
|
205
|
+
try:
|
|
206
|
+
enqueue(job)
|
|
207
|
+
except Exception:
|
|
208
|
+
# enqueue 会把未启动 run 收口为 failed;Timer 线程不外抛。
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
with _timer_lock:
|
|
212
|
+
old = _deferred_timers.get(run_id)
|
|
213
|
+
if old is not None:
|
|
214
|
+
return False
|
|
215
|
+
timer = threading.Timer(max(0.0, float(delay_s)), _fire)
|
|
216
|
+
timer.daemon = True
|
|
217
|
+
_deferred_timers[run_id] = timer
|
|
218
|
+
try:
|
|
219
|
+
timer.start()
|
|
220
|
+
except Exception:
|
|
221
|
+
_deferred_timers.pop(run_id, None)
|
|
222
|
+
_close_unstarted(job, "自动续跑定时器启动失败,请手动重试")
|
|
223
|
+
raise
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def restore_deferred_resumes(limit=None, now=None):
|
|
228
|
+
"""重建重启前的自动续跑退避 Timer;返回成功恢复的数量。"""
|
|
229
|
+
import time as _t
|
|
230
|
+
try:
|
|
231
|
+
from . import store
|
|
232
|
+
except Exception:
|
|
233
|
+
return 0
|
|
234
|
+
now = _t.time() if now is None else float(now)
|
|
235
|
+
restored = 0
|
|
236
|
+
for run in store.list_runs(None):
|
|
237
|
+
if limit is not None and restored >= limit:
|
|
238
|
+
break
|
|
239
|
+
if run.get("status") != "queued" or run.get("kind") != "orchestration":
|
|
240
|
+
continue
|
|
241
|
+
at = str(run.get("resume_enqueue_at") or "")
|
|
242
|
+
if not at:
|
|
243
|
+
continue
|
|
244
|
+
try:
|
|
245
|
+
due = _t.mktime(_t.strptime(at, "%Y-%m-%d %H:%M:%S"))
|
|
246
|
+
except Exception:
|
|
247
|
+
due = now
|
|
248
|
+
job = {"kind": "orchestration", "run_id": run["id"],
|
|
249
|
+
"task_id": run.get("task_id")}
|
|
250
|
+
if _schedule_enqueue(job, max(0.0, due - now)):
|
|
251
|
+
restored += 1
|
|
252
|
+
return restored
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _run_job(job):
|
|
256
|
+
"""直接执行线程入口;无 get/put 等待阶段。"""
|
|
257
|
+
run_id = job.get("run_id")
|
|
258
|
+
ev = cancel_event_for(run_id) if run_id else threading.Event()
|
|
259
|
+
try:
|
|
260
|
+
# enqueue 已把 run 原子切为 running。线程真正得到调度时再检查一次,
|
|
261
|
+
# 用户若在两者之间取消,直接跳过,避免取消后仍进入流水线。
|
|
262
|
+
if run_id:
|
|
263
|
+
from . import store
|
|
264
|
+
r0 = store.get_run(run_id)
|
|
265
|
+
if r0 and r0.get("status") != "running":
|
|
266
|
+
return
|
|
267
|
+
if job.get("kind") == "orchestration" and _yield_duplicate(run_id):
|
|
268
|
+
return
|
|
269
|
+
if job.get("kind") == "orchestration":
|
|
270
|
+
from . import pipeline
|
|
271
|
+
pipeline.execute_run(run_id)
|
|
272
|
+
elif job.get("kind") == "mgmt":
|
|
273
|
+
_do_mgmt(job, ev)
|
|
274
|
+
elif job.get("kind") == "selfupgrade":
|
|
275
|
+
_do_selfupgrade(job, ev)
|
|
276
|
+
else:
|
|
277
|
+
raise ValueError("未知任务类型 %r" % job.get("kind"))
|
|
278
|
+
except Exception:
|
|
149
279
|
try:
|
|
150
|
-
|
|
280
|
+
from . import store
|
|
281
|
+
err = traceback.format_exc()
|
|
282
|
+
store.update_run(run_id, expected_status="running", status="failed",
|
|
283
|
+
error=err[-1500:], ended_at=_now())
|
|
151
284
|
except Exception:
|
|
152
285
|
pass
|
|
286
|
+
finally:
|
|
287
|
+
if run_id:
|
|
288
|
+
CANCELS.pop(run_id, None)
|
|
289
|
+
try:
|
|
290
|
+
_maybe_auto_resume(run_id)
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
try:
|
|
294
|
+
from . import notify
|
|
295
|
+
notify.push_run_async(run_id)
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
_release_slot()
|
|
153
299
|
|
|
154
300
|
|
|
155
301
|
def cancel(run_id):
|
|
@@ -169,26 +315,34 @@ def cancel(run_id):
|
|
|
169
315
|
except Exception:
|
|
170
316
|
pass
|
|
171
317
|
return True
|
|
172
|
-
#
|
|
173
|
-
#
|
|
318
|
+
# 事件不存在也可能撞在 enqueue 已把 run 切成 running、尚未来得及登记
|
|
319
|
+
# CANCELS 的极短窗口。无论 queued/running 都直接落终态,不能让取消丢失。
|
|
174
320
|
try:
|
|
175
321
|
from . import store
|
|
176
322
|
run = store.get_run(run_id)
|
|
177
323
|
if not run:
|
|
178
324
|
return False
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
325
|
+
status = run.get("status")
|
|
326
|
+
if status in ("queued", "running"):
|
|
327
|
+
changed = store.update_run(run_id, expected_status=status,
|
|
328
|
+
status="cancelled", ended_at=_now(),
|
|
329
|
+
error="用户主动取消",
|
|
330
|
+
cancelled_by_user=True)
|
|
331
|
+
if changed is not None:
|
|
332
|
+
with _timer_lock:
|
|
333
|
+
timer = _deferred_timers.pop(run_id, None)
|
|
334
|
+
if timer is not None:
|
|
335
|
+
timer.cancel()
|
|
336
|
+
return True
|
|
337
|
+
latest = store.get_run(run_id) or {}
|
|
338
|
+
return latest.get("status") == "cancelled"
|
|
185
339
|
except Exception:
|
|
186
340
|
pass
|
|
187
341
|
return False
|
|
188
342
|
|
|
189
343
|
|
|
190
344
|
def cancel_event_for(run_id):
|
|
191
|
-
ev = CANCELS.get(run_id)
|
|
345
|
+
ev = CANCELS.get(run_id)
|
|
192
346
|
if ev is None:
|
|
193
347
|
ev = threading.Event()
|
|
194
348
|
CANCELS[run_id] = ev
|
|
@@ -229,7 +383,7 @@ def _err_signature(err):
|
|
|
229
383
|
def _maybe_auto_resume(run_id):
|
|
230
384
|
"""连载任务失败自动续跑:继承已完成章继续,最多 AUTO_RESUME_MAX 次。
|
|
231
385
|
|
|
232
|
-
|
|
386
|
+
真实长篇单次运行常因供应商拥堵超时中断;这里在执行线程收尾时自动续跑一次
|
|
233
387
|
续跑(store.retry_task 会带上 inherit),让整个流程真正无人值守。
|
|
234
388
|
同因连撞止损:续跑副本再失败时与本次失败的错误签名比对(2026-09-18
|
|
235
389
|
重写任务 kimi 403 欠费案),一模一样说明退避没换来不同结果,直接落
|
|
@@ -262,21 +416,16 @@ def _maybe_auto_resume(run_id):
|
|
|
262
416
|
ok, err, new_run = store.retry_task(task["id"])
|
|
263
417
|
if not ok or not new_run:
|
|
264
418
|
return False
|
|
265
|
-
#
|
|
266
|
-
# 「将在 HH:MM
|
|
267
|
-
# 以 queued 状态干等 AUTO_RESUME_DELAY_S 秒)。
|
|
419
|
+
# 退避窗口要让用户看得见:把「预定启动时刻」写到 run 上,前端据此显示
|
|
420
|
+
# 「将在 HH:MM 自动续跑」。这不是容量排队,且等待期间不占执行位。
|
|
268
421
|
import time as _t
|
|
269
422
|
resume_at = _t.strftime("%Y-%m-%d %H:%M:%S",
|
|
270
423
|
_t.localtime(_t.time() + AUTO_RESUME_DELAY_S))
|
|
271
424
|
store.update_run(new_run["id"], auto_resumes=int(run.get("auto_resumes") or 0) + 1,
|
|
272
425
|
auto_resumed_from=run_id, resume_enqueue_at=resume_at)
|
|
273
426
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
"run_id": new_run["id"], "task_id": task["id"]})
|
|
277
|
-
t = threading.Timer(AUTO_RESUME_DELAY_S, _enqueue)
|
|
278
|
-
t.daemon = True
|
|
279
|
-
t.start()
|
|
427
|
+
_schedule_enqueue({"kind": "orchestration", "run_id": new_run["id"],
|
|
428
|
+
"task_id": task["id"]}, AUTO_RESUME_DELAY_S)
|
|
280
429
|
return True
|
|
281
430
|
except Exception:
|
|
282
431
|
return False
|
|
@@ -339,15 +488,19 @@ def resume_interrupted(limit=3):
|
|
|
339
488
|
store.update_run(new_run["id"],
|
|
340
489
|
auto_resumes=int(run.get("auto_resumes") or 0) + 1,
|
|
341
490
|
auto_resumed_from=run["id"])
|
|
342
|
-
|
|
343
|
-
|
|
491
|
+
try:
|
|
492
|
+
enqueue({"kind": "orchestration", "run_id": new_run["id"],
|
|
493
|
+
"task_id": task["id"]})
|
|
494
|
+
n += 1
|
|
495
|
+
except Exception:
|
|
496
|
+
pass
|
|
344
497
|
except Exception:
|
|
345
498
|
return n
|
|
346
499
|
return n
|
|
347
500
|
|
|
348
501
|
|
|
349
502
|
def _yield_duplicate(run_id):
|
|
350
|
-
"""
|
|
503
|
+
"""同任务单飞(执行线程入口把关):系统续跑副本启动时若同任务已有
|
|
351
504
|
运行排队/在跑,取消自己让位——恢复副本与原轮并行跑只会双烧评审。
|
|
352
505
|
用户轮不在此拦(retry_task 建轮时已拒运行中任务)。返回 True 表示
|
|
353
506
|
本运行已落 cancelled,不要执行。"""
|
|
@@ -394,17 +547,14 @@ def _in_resume_backoff(run, now=None):
|
|
|
394
547
|
|
|
395
548
|
|
|
396
549
|
def requeue_pending(limit=10, max_age_s=None):
|
|
397
|
-
"""
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
mgmt 同样纳入:CLI 安装/升级 job 也只存在于内存队列,worker 线程
|
|
403
|
-
起失败(杀软挂起 Thread.start)或入队丢失后永远「排队中」,还堵住
|
|
404
|
-
同条目去重闸(2026-09-19 三连 CLI 升级排队无人接案)。selfupgrade
|
|
550
|
+
"""直接启动遗留的 queued 运行(启动恢复与运行期巡检共用)。
|
|
551
|
+
|
|
552
|
+
兼容旧版本曾持久化的排队状态,以及续跑 Timer 随进程消失的历史情况。
|
|
553
|
+
同任务已有在跑/等待续跑的不重复启动。mgmt 同样纳入,避免旧的排队记录
|
|
554
|
+
堵住同条目去重闸。selfupgrade
|
|
405
555
|
不补——升级本体有进程替换语义,自动重排不可控。
|
|
406
556
|
|
|
407
|
-
max_age_s
|
|
557
|
+
max_age_s:巡检模式只接管「卡了超过该秒数」的,刚创建的退避记录不掺和;
|
|
408
558
|
None(启动模式)全量补。resume_enqueue_at 未到点的续跑副本两种模式都
|
|
409
559
|
跳过——重启不该把退避窗口烧掉。返回补队条数。"""
|
|
410
560
|
try:
|
|
@@ -434,82 +584,37 @@ def requeue_pending(limit=10, max_age_s=None):
|
|
|
434
584
|
other = store.active_mgmt_run(eid)
|
|
435
585
|
if other and other.get("id") != run["id"]:
|
|
436
586
|
continue # 同条目已有更活跃的 run,去重闸语义收敛
|
|
437
|
-
_QUEUE.put({"kind": kind, "run_id": run["id"],
|
|
438
|
-
"task_id": run.get("task_id"),
|
|
439
|
-
"entry_id": run.get("entry_id"), "op": run.get("op")})
|
|
440
|
-
n += 1
|
|
441
|
-
except Exception:
|
|
442
|
-
pass
|
|
443
|
-
return n
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
def _worker():
|
|
447
|
-
global _alive
|
|
448
|
-
with _pool_lock:
|
|
449
|
-
_alive += 1
|
|
450
|
-
try:
|
|
451
|
-
while True:
|
|
452
|
-
with _pool_lock:
|
|
453
|
-
if _alive > _target: # 缩容:多余的线程在空闲检查点自行退出
|
|
454
|
-
return
|
|
455
587
|
try:
|
|
456
|
-
|
|
457
|
-
|
|
588
|
+
enqueue({"kind": kind, "run_id": run["id"],
|
|
589
|
+
"task_id": run.get("task_id"),
|
|
590
|
+
"entry_id": run.get("entry_id"), "op": run.get("op")})
|
|
591
|
+
n += 1
|
|
592
|
+
except (JobsBusyError, DuplicateJobError):
|
|
593
|
+
# 满载时 enqueue 已明确收口;重复 run 已由另一个执行方接管。
|
|
458
594
|
continue
|
|
459
|
-
run_id = job.get("run_id")
|
|
460
|
-
ev = cancel_event_for(run_id) if run_id else threading.Event()
|
|
461
|
-
try:
|
|
462
|
-
# 出队后状态闸:非 queued 一律跳过。排队期取消的(cancel 已
|
|
463
|
-
# 直接落终态)不再进流水线;running/done/failed 的是看门狗
|
|
464
|
-
# 重排/双入队产生的重复副本——另一 worker 已在跑或已跑完,
|
|
465
|
-
# 再 execute_run 会把同一运行执行两次。查不到的 run(测试
|
|
466
|
-
# mock)不拦,保持原行为。
|
|
467
|
-
if run_id:
|
|
468
|
-
from . import store
|
|
469
|
-
r0 = store.get_run(run_id)
|
|
470
|
-
if r0 and r0.get("status") != "queued":
|
|
471
|
-
continue # task_done 由 finally 统一收口,不能在此重复
|
|
472
|
-
if job.get("kind") == "orchestration" and _yield_duplicate(run_id):
|
|
473
|
-
continue # 同任务单飞:续跑副本让位(已落 cancelled)
|
|
474
|
-
if job.get("kind") == "orchestration":
|
|
475
|
-
from . import pipeline
|
|
476
|
-
pipeline.execute_run(run_id)
|
|
477
|
-
elif job.get("kind") == "mgmt":
|
|
478
|
-
_do_mgmt(job, ev)
|
|
479
|
-
elif job.get("kind") == "selfupgrade":
|
|
480
|
-
_do_selfupgrade(job)
|
|
481
595
|
except Exception:
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
except Exception:
|
|
487
|
-
pass
|
|
488
|
-
finally:
|
|
489
|
-
if run_id:
|
|
490
|
-
CANCELS.pop(run_id, None)
|
|
491
|
-
try:
|
|
492
|
-
_maybe_auto_resume(run_id) # 连载失败自动续跑(继承已完成章)
|
|
493
|
-
except Exception:
|
|
494
|
-
pass
|
|
495
|
-
try:
|
|
496
|
-
from . import notify
|
|
497
|
-
notify.push_run_async(run_id) # 结果推群(借鉴 agency-orchestrator --notify)
|
|
498
|
-
except Exception:
|
|
499
|
-
pass
|
|
500
|
-
_QUEUE.task_done()
|
|
501
|
-
finally:
|
|
502
|
-
with _pool_lock:
|
|
503
|
-
_alive -= 1
|
|
596
|
+
continue
|
|
597
|
+
except Exception:
|
|
598
|
+
pass
|
|
599
|
+
return n
|
|
504
600
|
|
|
505
601
|
|
|
506
602
|
def workers_info():
|
|
507
603
|
with _pool_lock:
|
|
508
|
-
return {"target": _target, "alive": _alive, "queued":
|
|
604
|
+
return {"target": _target, "alive": _alive, "queued": 0,
|
|
605
|
+
"available": max(0, _target - _alive), "mode": "direct"}
|
|
509
606
|
|
|
510
607
|
|
|
511
608
|
def _drain_test_queue():
|
|
512
|
-
"""
|
|
609
|
+
"""测试辅助:清空兼容队列并取消未决 Timer(生产代码勿调)。"""
|
|
610
|
+
with _timer_lock:
|
|
611
|
+
timers = list(_deferred_timers.values())
|
|
612
|
+
_deferred_timers.clear()
|
|
613
|
+
for timer in timers:
|
|
614
|
+
try:
|
|
615
|
+
timer.cancel()
|
|
616
|
+
except Exception:
|
|
617
|
+
pass
|
|
513
618
|
while True:
|
|
514
619
|
try:
|
|
515
620
|
_QUEUE.get_nowait()
|
|
@@ -528,15 +633,19 @@ def _do_mgmt(job, ev):
|
|
|
528
633
|
run_id = job["run_id"]
|
|
529
634
|
entry = catalog.by_id(job.get("entry_id"))
|
|
530
635
|
op = job.get("op") or ""
|
|
531
|
-
store.
|
|
636
|
+
if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
|
|
637
|
+
return
|
|
532
638
|
if entry is None:
|
|
533
|
-
store.update_run(run_id,
|
|
639
|
+
store.update_run(run_id, expected_status="running", status="failed",
|
|
640
|
+
error="catalog 中找不到 %s" % job.get("entry_id"),
|
|
534
641
|
ended_at=_now())
|
|
535
642
|
return
|
|
536
643
|
step, log_abs = store.add_step(run_id, op or "mgmt", entry["id"], entry.get("name", entry["id"]))
|
|
537
644
|
ok = False
|
|
538
645
|
if op in ("install", "upgrade", "uninstall"):
|
|
539
646
|
res = manager.run_mgmt_command(entry, op, cancel_event=ev, log_path=str(log_abs))
|
|
647
|
+
if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
|
|
648
|
+
return
|
|
540
649
|
ok = res["ok"]
|
|
541
650
|
# 文件占用类失败(Windows 文件锁 EBUSY/EPERM)给人话结论并跳过 AI 修复:
|
|
542
651
|
# 修复智能体面对文件锁只会给出 taskkill 全杀 node 之类白名单必拒的危险
|
|
@@ -562,7 +671,8 @@ def _do_mgmt(job, ev):
|
|
|
562
671
|
agent = next((a for a in agents if a["id"] == entry["id"]), None)
|
|
563
672
|
if agent is None:
|
|
564
673
|
store.finish_step(run_id, step["n"], "failed", summary="该智能体未安装或未启用编排")
|
|
565
|
-
store.update_run(run_id,
|
|
674
|
+
store.update_run(run_id, expected_status="running", status="failed",
|
|
675
|
+
error="未启用", ended_at=_now())
|
|
566
676
|
return
|
|
567
677
|
res = _r.run_agent(agent, "连通性测试:请只回复两个字:OK",
|
|
568
678
|
readonly=True,
|
|
@@ -570,6 +680,8 @@ def _do_mgmt(job, ev):
|
|
|
570
680
|
# token 技能上下文,首 token 常超 180s(2026-09-17 实测
|
|
571
681
|
# 网关裸探 8s 就回,慢在 CLI 自身启动与上下文)。
|
|
572
682
|
timeout=300, cancel_event=ev, log_path=str(log_abs))
|
|
683
|
+
if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
|
|
684
|
+
return
|
|
573
685
|
ok = res["ok"] and "OK" in (res.get("text") or "").upper()
|
|
574
686
|
try:
|
|
575
687
|
_usage.record(source="smoke", run_id=run_id, step=step["n"], role="smoke",
|
|
@@ -599,16 +711,19 @@ def _do_mgmt(job, ev):
|
|
|
599
711
|
"完成" if final == "done" else "失败", suffix)))
|
|
600
712
|
|
|
601
713
|
|
|
602
|
-
def _do_selfupgrade(job):
|
|
714
|
+
def _do_selfupgrade(job, ev):
|
|
603
715
|
"""CodeBee 自升级:在 mgmt run 里跑 npm install -g @latest,日志实时落盘。"""
|
|
604
716
|
from . import selfupdate, store
|
|
605
717
|
run_id = job["run_id"]
|
|
606
|
-
store.
|
|
718
|
+
if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
|
|
719
|
+
return
|
|
607
720
|
step, log_abs = store.add_step(run_id, "selfupgrade", "__self__", "CodeBee")
|
|
608
721
|
try:
|
|
609
|
-
res = selfupdate.run_upgrade(run_id, str(log_abs))
|
|
722
|
+
res = selfupdate.run_upgrade(run_id, str(log_abs), cancel_event=ev)
|
|
610
723
|
except Exception as e:
|
|
611
724
|
res = {"ok": False, "exit_code": None, "error": repr(e)}
|
|
725
|
+
if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
|
|
726
|
+
return
|
|
612
727
|
store.finish_step(run_id, step["n"], "done" if res["ok"] else "failed",
|
|
613
728
|
summary="升级完成,点「重启」生效" if res["ok"]
|
|
614
729
|
else ("升级失败: " + res["error"][:300]),
|