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/app/core/jobs.py CHANGED
@@ -1,9 +1,9 @@
1
1
  # -*- coding: utf-8 -*-
2
- """任务队列:可并发 worker 池(默认 3,1-6 可配)执行编排任务与管理操作。
2
+ """任务执行器:直接启动、无等待队列地执行编排任务与管理操作。
3
3
 
4
- 多个任务同时跑、互不打扰:每个 job 一条独立线程,run/step 数据按 run_id
5
- 隔离,store 层有全局锁。目标并发数可在设置页调整;调小后多余线程在取到
6
- 新任务前自行退出,调大即时补齐。安装/升级失败时自动触发 AI 诊断修复:
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 # 活跃 worker 线程数
22
- _target = 3 # 目标并发数(settings.max_concurrent_jobs)
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 # 并发上限:每个 job 只是拉起 CLI 子进程,跨任务并行无共享
26
- # 资源(同任务单飞另有守卫),照竞品(emdash/munder-difflin
27
- # 高并行环境)放开到 12
28
- WATCHDOG_INTERVAL_S = 60 # 队列看门狗巡检周期
29
- WATCHDOG_STALE_S = 120 # queued 超过该秒数视为掉队(正常入队到被拿起 ≤5s)
30
- _watchdog_started = False
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
- """设置目标并发数(1-12):扩容立即补线程,缩容由空闲线程自行退出。"""
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
- """标记队列可用并加载并发配置。worker 线程**不在启动期创建**(真实装机
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(3)
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
- _ensure_workers()
120
- _QUEUE.put(job)
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
- def _ensure_workers():
124
- """队列里积压超过空闲 worker 数时补线程(惰性扩容,替代启动期预建)。
125
- 看门狗线程同样惰性起:与 worker 一样不在启动期 Thread.start()(杀软
126
- 挂起新线程的真实装机案例,见 start_worker 注释),首队到达时一起补。"""
127
- global _watchdog_started
128
- with _pool_lock:
129
- pending = _QUEUE.qsize()
130
- need = max(_target, 1) - _alive + pending
131
- if not _watchdog_started:
132
- _watchdog_started = True
133
- threading.Thread(target=_watchdog, name="job-watchdog",
134
- daemon=True).start()
135
- if need > 0:
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
- while True:
148
- _t.sleep(WATCHDOG_INTERVAL_S)
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
- requeue_pending(limit=6, max_age_s=WATCHDOG_STALE_S)
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
- # 事件不存在=任务还在队列里没被 worker 拿起:直接落终态(取消事件在
173
- # worker 起跑时才创建,排队任务点取消会在这里漏掉——起跑后再杀一遍)。
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
- if run.get("status") == "queued":
180
- store.update_run(run_id, expected_status="queued", status="cancelled",
181
- ended_at=_now())
182
- return True
183
- if run.get("status") == "running" and run.get("cancelled_by_user"):
184
- return True # 上一轮取消已标记,等起跑时的兜底检查收口
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) # get-or-create:排队期置位的取消不因重建事件而丢失
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
- 真实长篇单次运行常因供应商拥堵超时中断;这里在 worker 收尾时自动重排一次
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
- # 退避窗口要让用户看得见:把「预定入队时刻」写到 run 上,前端据此显示
266
- # 「将在 HH:MM 自动续跑」而不是笼统的排队中(run 在建好到入队之间会
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
- def _enqueue():
275
- _QUEUE.put({"kind": "orchestration",
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
- _QUEUE.put({"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
343
- n += 1
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
- """同任务单飞(worker 出队时把关):系统续跑副本出队时若同任务已有
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
- """把遗留的 queued 运行重新入队(启动补队与运行期巡检共用)。
398
-
399
- 队列在内存里,进程一死排队项就没人管了(2026-09-18 七猫 r-162724
400
- 排队僵尸案:续跑副本 created 后服务重启,Timer 随进程蒸发,运行永远
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
- job = _QUEUE.get(timeout=5) # 定期醒来检查并发数是否被调小
457
- except queue.Empty:
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
- try:
483
- from . import store
484
- err = traceback.format_exc()
485
- store.update_run(run_id, status="failed", error=err[-1500:], ended_at=_now())
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": _QUEUE.qsize()}
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
- """测试辅助:清空内存队列并结清未决 join(生产代码勿调)。"""
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.update_run(run_id, status="running", started_at=_now())
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, status="failed", error="catalog 中找不到 %s" % job.get("entry_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, status="failed", error="未启用", ended_at=_now())
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.update_run(run_id, status="running", started_at=_now())
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]),