codebee 0.1.49 → 0.1.51

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
@@ -2,8 +2,14 @@
2
2
  """任务执行器:直接启动、无等待队列地执行编排任务与管理操作。
3
3
 
4
4
  每个 job 一条独立线程,run/step 数据按 run_id 隔离,store 层有全局锁。
5
- 设置中的并发数是保护上限:有空位就立即启动,满载则明确失败并提示稍后重试,
6
- 绝不把任务留在内存队列里无限等待。安装/升级失败时自动触发 AI 诊断修复:
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 诊断修复:
7
13
  由真实智能体读取失败日志与本机环境给出修正命令;仅当命令命中白名单前缀
8
14
  (npm/winget/brew/pip 安装类,按平台取对应渠道)才自动执行,否则把建议命令
9
15
  记录在运行记录里等人工确认。
@@ -22,12 +28,16 @@ _started = False
22
28
  _alive = 0 # 已获执行位、尚未结束的 job 数
23
29
  _restart_drain = False # 升级重启前原子停止接单;不排队、不打断已运行任务
24
30
  _target = 12 # 并发保护上限(settings.max_concurrent_jobs)
31
+ _chat_alive = 0 # 轻量池:direct 对话在跑数(独立于全局池计数)
25
32
  _pool_lock = threading.Lock()
26
33
  _idle_cond = threading.Condition(_pool_lock)
27
34
  _timer_lock = threading.Lock()
28
35
  _deferred_timers = {}
29
36
  _seq = 0
30
- MAX_POOL = 12
37
+ MAX_POOL = 24
38
+ CHAT_POOL = 4 # 轻量池上限:direct 对话直连 API 本机开销极小,4 个足够
39
+ BUSY_WAIT_S = 15.0 # 满载排队后的补跑轮询间隔(秒);测试可调小
40
+ BUSY_WAIT_MAX = 40 # 补跑拍数封顶:15s × 40 = 10 分钟,超时才判失败
31
41
 
32
42
 
33
43
  class JobsBusyError(RuntimeError):
@@ -80,7 +90,7 @@ def _repair_command_allowed(cmd, platform=None):
80
90
 
81
91
 
82
92
  def configure(max_workers):
83
- """设置并发保护上限(1-12);只影响后续启动,不中断已运行任务。"""
93
+ """设置并发保护上限(1-24);只影响后续启动,不中断已运行任务。"""
84
94
  global _target
85
95
  _target = max(1, min(MAX_POOL, int(max_workers)))
86
96
  return _target
@@ -112,23 +122,33 @@ def start_worker():
112
122
  configure(12)
113
123
 
114
124
 
115
- def enqueue(job):
116
- """立即为 job 预留执行位并启动独立线程;从不进入等待队列。
125
+ def _job_is_light(run):
126
+ """direct 对话走轻量池:内置智能体直连模型 API,本机无 CLI 子进程,
127
+ 开销与编排任务(每步起一个 CLI)差几个量级,不该抢全局槽。判定按
128
+ run 所属任务的 type,入口侧零改动(自动化/webhook 等新入口自动跟随)。
129
+ run 缺失或类型未知时保守走全局池。direct 极少数回落 CLI 的场景,轻量
130
+ 池上限也只有 CHAT_POOL 个,本机压力可控。"""
131
+ try:
132
+ task_id = (run or {}).get("task_id")
133
+ if not task_id:
134
+ return False
135
+ from . import store
136
+ task = store.get_task(task_id)
137
+ return str((task or {}).get("type") or "") == "direct"
138
+ except Exception:
139
+ return False
117
140
 
118
- 返回前先把持久化 run 从 queued 原子切到 running,因此 HTTP/自动化调用方
119
- 不会看到“已接受但仍排队”。满载、重复 run、线程创建失败都会明确抛错;
120
- 满载与启动失败还会就地把 run 收口为 failed,任何入口都不会留下僵尸。
121
- """
122
- global _alive, _seq
123
- if not _started:
124
- start_worker()
125
- if not isinstance(job, dict):
126
- raise ValueError("job 必须是对象")
127
- run_id = job.get("run_id")
128
- if not run_id:
129
- raise ValueError("job.run_id 必填")
130
141
 
142
+ def _try_start_once(job):
143
+ """CAS 认领持久化 run → 占并发位 → 起执行线程,一次直接启动尝试。
144
+
145
+ 返回 True=已起跑;False=并发位满载未启动(run 停在 running,由调用方
146
+ 决定回滚排队还是收口失败)。run 已非 queued(被取消/接管/收口)时抛
147
+ DuplicateJobError;线程创建失败就地收口后原样上抛。升级排水在这里视作
148
+ 满载的一种(返回 False),快速失败还是排队的语义由调用方区分。"""
149
+ global _alive, _chat_alive, _seq
131
150
  from . import store
151
+ run_id = job.get("run_id")
132
152
  current = store.get_run(run_id)
133
153
  if current:
134
154
  # 先 CAS 认领持久化 run,再碰并发位。若先占位,两个调用可能分别看到
@@ -142,55 +162,95 @@ def enqueue(job):
142
162
  (run_id, latest.get("status")))
143
163
  cancel_event_for(run_id)
144
164
 
145
- # CAS 认领后检查并发保护位。_alive 在 Thread.start 前递增,消除旧实现中线程尚未
146
- # 回写 alive、扩容循环一次造出几十条 worker 的竞态。
165
+ # CAS 认领后检查并发保护位。占位在 Thread.start 前递增,消除旧实现中线程尚未
166
+ # 回写 alive、扩容循环一次造出几十条 worker 的竞态。direct 对话占轻量池,
167
+ # 其余任务占全局池;job["light"] 随副本传给执行线程,结束时按原池释放。
168
+ light = _job_is_light(current)
147
169
  with _pool_lock:
148
170
  if _restart_drain:
149
- busy_limit = -1
150
- elif _alive >= _target:
151
- busy_limit = _target
171
+ busy = True
172
+ elif light:
173
+ busy = _chat_alive >= CHAT_POOL
174
+ if not busy:
175
+ _chat_alive += 1
176
+ job["light"] = True
152
177
  else:
153
- busy_limit = 0
154
- _alive += 1
178
+ busy = _alive >= _target
179
+ if not busy:
180
+ _alive += 1
181
+ if not busy:
155
182
  _seq += 1
156
183
  seq = _seq
157
- if busy_limit:
184
+ if busy:
158
185
  CANCELS.pop(run_id, None)
159
- if busy_limit < 0:
160
- message = "服务正在完成升级重启;本次未排队,请稍后重试"
161
- else:
162
- message = "当前运行任务已达并发保护上限(%d);本次未排队,请稍后重试" % busy_limit
163
- _close_unstarted(job, message,
164
- statuses=("running",))
165
- raise JobsBusyError(message)
186
+ return False
166
187
 
167
188
  try:
168
189
  threading.Thread(target=_run_job, args=(dict(job),),
169
190
  name="job-direct-%d" % seq, daemon=True).start()
170
191
  except Exception:
171
- _release_slot()
192
+ _release_slot(job)
172
193
  CANCELS.pop(run_id, None)
173
194
  _close_unstarted(job, "任务执行线程启动失败;本次未排队,请稍后重试",
174
195
  statuses=("queued", "running"))
175
196
  raise
197
+ return True
176
198
 
199
+
200
+ def enqueue(job):
201
+ """立即为 job 预留执行位并启动独立线程;从不进入内存等待队列。
202
+
203
+ 返回前先把持久化 run 从 queued 原子切到 running,因此 HTTP/自动化调用方
204
+ 不会看到“已接受但仍排队”。满载时 orchestration 任务转排队等待空位(对
205
+ 调用方视作受理成功,见 _requeue_await_slot);重复 run、线程创建失败、
206
+ 升级排水满载都会明确抛错,并就地把 run 收口为 failed,任何入口都不会
207
+ 留下无人认领的僵尸。
208
+ """
209
+ if not _started:
210
+ start_worker()
211
+ if not isinstance(job, dict):
212
+ raise ValueError("job 必须是对象")
213
+ run_id = job.get("run_id")
214
+ if not run_id:
215
+ raise ValueError("job.run_id 必填")
216
+
217
+ if not _try_start_once(job):
218
+ if _restart_drain:
219
+ # 升级排水:接单通道已在关闭,排队只会排进一次注定重启的进程——
220
+ # 照旧快速失败。
221
+ message = "服务正在完成升级重启;本次未排队,请稍后重试"
222
+ _close_unstarted(job, message,
223
+ statuses=("running",))
224
+ raise JobsBusyError(message)
225
+ if str(job.get("kind") or "") != "orchestration" or not _requeue_await_slot(job):
226
+ # mgmt 与回滚失败(run 已被取消/收口)维持快速失败原语义;
227
+ # orchestration 已转排队,对调用方视作受理成功。
228
+ message = ("当前运行任务已达并发保护上限(%d);本次未排队,请稍后重试"
229
+ % _target)
230
+ _close_unstarted(job, message,
231
+ statuses=("running",))
232
+ raise JobsBusyError(message)
177
233
  return True
178
234
 
179
235
 
180
- def _release_slot():
181
- global _alive
236
+ def _release_slot(job=None):
237
+ """释放执行位:按 job 的 light 标记归还对应池,两池全空才唤醒等待方。"""
238
+ global _alive, _chat_alive
182
239
  with _idle_cond:
183
- _alive = max(0, _alive - 1)
184
- if _alive == 0:
240
+ if job is not None and job.get("light"):
241
+ _chat_alive = max(0, _chat_alive - 1)
242
+ else:
243
+ _alive = max(0, _alive - 1)
244
+ if _alive == 0 and _chat_alive == 0:
185
245
  _idle_cond.notify_all()
186
246
 
187
247
 
188
248
  def wait_for_idle(timeout=10):
189
- """测试/停机辅助:等待所有直接执行任务结束。"""
249
+ """测试/停机辅助:等待所有直接执行任务(含轻量池)结束。"""
190
250
  import time as _t
191
251
  end = _t.time() + max(0, float(timeout))
192
252
  with _idle_cond:
193
- while _alive:
253
+ while _alive or _chat_alive:
194
254
  left = end - _t.time()
195
255
  if left <= 0:
196
256
  return False
@@ -228,6 +288,72 @@ def _close_unstarted(job, message, statuses=("queued",)):
228
288
  pass
229
289
 
230
290
 
291
+ def _requeue_await_slot(job, waits_left=None):
292
+ """满载转排队:run 回滚 queued 并挂补跑 Timer,等下一个并发空位。
293
+
294
+ 只服务 orchestration 任务(用户/自动化任务)。enqueue 起手已把 run CAS
295
+ 成 running,这里再 CAS 回 queued(清 started_at,别让「排队中」顶着起跑
296
+ 时间),并同步任务状态回排队。返回 True=已转排队(调用方视作受理成功,
297
+ 不再向调用方抛忙);False=回滚失败(run 已被取消/收口),走原快速失败
298
+ 路径。取消/重启/超时的收口:
299
+ - 用户取消 → run 已非 running,回滚 CAS 落空,或补跑 enqueue 撞
300
+ DuplicateJobError,链条自然断掉;
301
+ - 服务重启 → Timer 随进程消失,启动时 requeue_pending() 接管 queued 记录;
302
+ - 等满 BUSY_WAIT_MAX 拍仍无空位 → 判失败并给出可读原因。
303
+ """
304
+ run_id = job.get("run_id")
305
+ if waits_left is None:
306
+ waits_left = BUSY_WAIT_MAX
307
+ try:
308
+ from . import store
309
+ changed = store.update_run(run_id, expected_status="running",
310
+ status="queued", started_at="")
311
+ if changed is None:
312
+ return False
313
+ if job.get("task_id"):
314
+ try:
315
+ store.update_task_status(job["task_id"], "queued")
316
+ except Exception:
317
+ pass
318
+ except Exception:
319
+ return False
320
+
321
+ def _fire():
322
+ # 补跑心跳:直接尝试一次启动(不走 enqueue 的排队分支,否则每拍都会
323
+ # 带着全新重试额度重新入队,封顶永远打不中)。仍满载则续下一拍。
324
+ with _timer_lock:
325
+ _deferred_timers.pop(run_id, None)
326
+ try:
327
+ started = _try_start_once(job)
328
+ except DuplicateJobError:
329
+ return # 已被取消/接管/收口:补跑链到此为止
330
+ except Exception:
331
+ return # 线程启动失败等异常路径已就地收口,不续棒
332
+ if started:
333
+ return
334
+ if _restart_drain:
335
+ _close_unstarted(job, "服务正在完成升级重启;本次未排队,请稍后重试",
336
+ statuses=("running",))
337
+ elif waits_left > 0:
338
+ _requeue_await_slot(job, waits_left - 1)
339
+ else:
340
+ _close_unstarted(job, "并发位已满,排队等待超时仍未起跑,请稍后重试",
341
+ statuses=("running",))
342
+
343
+ with _timer_lock:
344
+ if run_id in _deferred_timers:
345
+ return True # 已有补跑在等:别叠 Timer(防与续跑退避串台)
346
+ timer = threading.Timer(max(0.0, float(BUSY_WAIT_S)), _fire)
347
+ timer.daemon = True
348
+ _deferred_timers[run_id] = timer
349
+ try:
350
+ timer.start()
351
+ except Exception:
352
+ _deferred_timers.pop(run_id, None)
353
+ return False
354
+ return True
355
+
356
+
231
357
  def _schedule_enqueue(job, delay_s):
232
358
  """按明确截止时间延迟启动;同一 run 只保留一个 Timer。"""
233
359
  run_id = job.get("run_id")
@@ -342,7 +468,7 @@ def _run_job(job):
342
468
  notify.push_run_async(run_id)
343
469
  except Exception:
344
470
  pass
345
- _release_slot()
471
+ _release_slot(job)
346
472
 
347
473
 
348
474
  def cancel(run_id):
@@ -648,7 +774,9 @@ def requeue_pending(limit=10, max_age_s=None):
648
774
 
649
775
  def workers_info():
650
776
  with _pool_lock:
651
- return {"target": _target, "alive": _alive, "queued": 0,
777
+ return {"target": _target, "alive": _alive,
778
+ "chat_alive": _chat_alive, "chat_pool": CHAT_POOL,
779
+ "queued": 0,
652
780
  "available": 0 if _restart_drain else max(0, _target - _alive),
653
781
  "mode": "restart-drain" if _restart_drain else "direct"}
654
782