codebee 0.1.50 → 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
 
@@ -192,21 +192,38 @@ def sweep_orphan_cli_processes():
192
192
 
193
193
 
194
194
  def detect_all(force=False):
195
- """检测全部条目。检测(慢磁盘 IO)在锁外跑:shutil.which/isfile 在
196
- Windows 上遇到断链的 PATH 项可能卡数秒,持锁会把所有并发请求堵死
197
- (曾导致 SSE 多连接时服务假死)。等待方有界等待 30s 后拿旧结果。
198
- """
195
+ """检测全部条目。快照用途(force=False)热路径永不等检测:缓存新鲜直接
196
+ 返回;过期返回旧快照、同时把刷新踢到后台线程(stale-while-revalidate)。
197
+
198
+ 检测是慢磁盘 IO:shutil.which/isfile 在 Windows 上遇到断链的 PATH 项
199
+ 可能卡数秒。此前「领队请求自己跑检测、其余请求 ev.wait(30) 排队」,每
200
+ 60s 缓存周期都有一个 /api/catalog、/api/state(含 SSE 推送)一起卡数秒
201
+ 的窗口;前端无超时轮询把浏览器 6 连接池塞满后整个界面假死(2026-09-22
202
+ 用户实测「左栏点不动、右栏不刷新」)。冷启动首轮与 force=True(显式
203
+ 重载/安装回调)仍同步跑拿新鲜结果。"""
199
204
  with _LOCK:
200
205
  if not force and _STATE["detected"] and time.time() - _STATE["detect_ts"] < 60:
201
206
  return _STATE["detected"]
202
207
  ev = _STATE["detect_ev"]
203
- lead = ev is None # 我是本次检测的执行者
208
+ lead = ev is None # 我是本次刷新的发起人
204
209
  if lead:
205
210
  ev = _STATE["detect_ev"] = threading.Event()
211
+ cold = not _STATE["detected"] # 冷启动首轮:还没有任何快照
206
212
  if not lead:
207
- ev.wait(30) # 检测完成或超时;两种情况都拿当前最新快照
213
+ if not cold:
214
+ return dict(_STATE["detected"]) # 过期快照也比卡请求强
215
+ ev.wait(30) # 冷启动窗口内并发首查:等首轮检测完成(一次性)
208
216
  with _LOCK:
209
217
  return dict(_STATE["detected"])
218
+ if not cold and not force:
219
+ threading.Thread(target=_detect_refresh, args=(ev,),
220
+ name="catalog-detect", daemon=True).start()
221
+ return dict(_STATE["detected"])
222
+ return _detect_refresh(ev)
223
+
224
+
225
+ def _detect_refresh(ev):
226
+ """跑一轮全量检测并落快照;完成后置时间戳、清在飞标记、放行等待者。"""
210
227
  try:
211
228
  detected = {}
212
229
  for entry in catalog.load():
@@ -217,12 +234,12 @@ def detect_all(force=False):
217
234
  if detected:
218
235
  with _LOCK:
219
236
  _STATE["detected"] = detected
237
+ return dict(_STATE["detected"])
220
238
  finally:
221
239
  with _LOCK:
222
240
  _STATE["detect_ts"] = time.time()
223
241
  _STATE["detect_ev"] = None
224
242
  ev.set()
225
- return detected
226
243
 
227
244
 
228
245
  def _uwp_version(package_dir):
@@ -290,8 +290,34 @@ def install_files(pack_id, name, files, extra=None):
290
290
  record.update(extra)
291
291
  installed[pack_id] = record
292
292
  _save_registry(reg)
293
+ # 装后冒烟验证(SkillForge 证据驱动借鉴):装完重新走 skills 的解析链,
294
+ # 证明该包能被加载、名字对得上、正文非空——写盘成功 ≠ 技能可用,
295
+ # frontmatter 缺失/正文为空都能在这里当场暴露,而不是下次任务注入时静默丢失。
296
+ smoke = ""
297
+ try:
298
+ with skills._LOCK:
299
+ skills._user_pack_cache.clear()
300
+ skills._user_dir_mtime["ts"] = 0.0
301
+ skills._user_dir_mtime["ids"] = None
302
+ up = next((p for p in skills.user_packs()
303
+ if str(p.get("file") or "").replace("\\", "/").endswith("/" + target)), None)
304
+ if up is None:
305
+ smoke = "装后冒烟未找到已装包(解析失败?)"
306
+ elif (up.get("name") or "").strip() != name.strip():
307
+ smoke = "装后冒烟名字不符:%s ≠ %s" % (up.get("name"), name)
308
+ elif not str(up.get("body") or "").strip():
309
+ smoke = "装后冒烟正文为空"
310
+ else:
311
+ smoke = "ok:%d字" % len(str(up.get("body") or ""))
312
+ except Exception as e:
313
+ smoke = "装后冒烟异常: %s" % str(e)[:120]
314
+ with _LOCK:
315
+ reg = _load_registry()
316
+ if pack_id in (reg.get("installed") or {}):
317
+ reg["installed"][pack_id]["smoke"] = smoke
318
+ _save_registry(reg)
293
319
  return {"ok": True, "id": pack_id, "name": name, "file": target,
294
- "already": already, "scan": scan_note}, None
320
+ "already": already, "scan": scan_note, "smoke": smoke}, None
295
321
 
296
322
 
297
323
  def remove(pack_id):
@@ -1114,29 +1114,53 @@ def _run_code(run, task, agents, ev, stats, mode):
1114
1114
  and _code_bestof(run, task, impl_agent, difficulty, ev)):
1115
1115
  return True
1116
1116
  # 实现步失败不立刻判死:2026-09-16 实测配额烧干时 5 连跑全在同一条 CLI 上
1117
- # 失败收场,而健康的 opencode 一直在旁观望——跨 CLI 换将重试一次
1118
- # (mode=manual 尊重用户指定,不换)。
1117
+ # 失败收场,而健康的 opencode 一直在旁观望——跨 CLI 换将重试
1118
+ # (mode=manual 尊重用户指定,不换)。2026-09-22 续修:配额/限流类死亡
1119
+ # 是确定性秒死,允许继续走查候选名单(同上游让位异上游);其余死因
1120
+ # (超时等)仍只换一次,防着在坏候选上再烧一整个超时。
1119
1121
  if mode == "auto" and impl_agent.get("mode") == "real":
1120
- ex = {impl_agent["id"], "mock-a", "mock-b"}
1121
- other, other_reason = router.pick(agents, "implement", "code", stats,
1122
- exclude=ex)
1123
- if other is not None and other.get("mode") == "real":
1122
+ tried = {impl_agent["id"], "mock-a", "mock-b"}
1123
+ notes = []
1124
+ dead_ups = [] # 配额死亡候选的上游集合:换将优先异上游
1125
+ prev_id = impl_agent["id"]
1126
+ other = None
1127
+ while True:
1128
+ other, other_reason = router.pick_switch_candidate(
1129
+ agents, "implement", "code", stats,
1130
+ exclude=tried, dead_upstreams=dead_ups)
1131
+ if other is None or other.get("mode") != "real":
1132
+ other = None
1133
+ break
1124
1134
  note = "实现步失败自动换将 %s → %s:%s。失败原因:%s" % (
1125
- impl_agent["id"], other["id"], other_reason,
1135
+ prev_id, other["id"], other_reason,
1126
1136
  (res.get("error") or "")[:200])
1127
1137
  ok2, res = _run_one(other)
1128
1138
  if ok2:
1129
- store.update_run(run_id, error="", route_note=note)
1139
+ route_note = ";".join(notes + [note])
1140
+ store.update_run(run_id, error="", route_note=route_note)
1130
1141
  _record_actual_route(
1131
1142
  run_id, task, agents, stats, other,
1132
1143
  implementers=[other], reviewer=reviewer,
1133
- implement_reason=note,
1144
+ implement_reason=route_note,
1134
1145
  review_reason=route.get("reviewer", ""),
1135
1146
  direct=not workflow["review_required"])
1136
1147
  return True
1137
- res_err = "%s;换将后仍失败:%s" % (note, (res.get("error") or "")[:200])
1148
+ notes.append(note)
1149
+ tried.add(other["id"])
1150
+ prev_id = other["id"]
1151
+ if runner._quota_error(res.get("error") or ""):
1152
+ ups = router.agent_upstreams(other["id"])
1153
+ if ups:
1154
+ dead_ups.append(ups)
1155
+ else:
1156
+ break # 非配额死因:只换一次就收手
1157
+ tail_err = (res.get("error") or "")[:200]
1158
+ if notes:
1159
+ res_err = ";".join(notes) + ";换将后仍失败:%s" % tail_err
1160
+ elif other is None:
1161
+ res_err = "实现步骤失败(无其他真实 CLI 可换将): %s" % tail_err
1138
1162
  else:
1139
- res_err = "实现步骤失败(无其他真实 CLI 可换将): %s" % res.get("error")
1163
+ res_err = "实现步骤失败: %s" % tail_err
1140
1164
  else:
1141
1165
  res_err = "实现步骤失败: %s" % res.get("error")
1142
1166
  store.update_run(run_id, expected_status="running", status="failed",
@@ -114,13 +114,16 @@ def _log_usage(source, role, task, res, agent=None, tool="", model="", provider=
114
114
  except Exception:
115
115
  pass
116
116
 
117
- CODE_PLAN_PROMPT = """你是技术负责人。请把下面的开发目标拆解为 __N__ 个以内、按顺序执行的子任务,
118
- 并判定任务难度和后续质量步骤。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
117
+ CODE_PLAN_PROMPT = """你是技术负责人。请先判定任务难度,再按难度拆解为按顺序执行的子任务,
118
+ 并给出后续质量步骤。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
119
119
  {"difficulty": "easy 或 hard", "workflow": {"review_required": true, "max_repair_rounds": 0, "allow_switch": false}, "subtasks": [{"title": "简短标题", "detail": "具体要做什么,给执行工程师的直接指令", "files": ["涉及的文件路径"]}]}
120
- 难度判定:常规增删改查/小函数/格式调整 = easy;跨模块改动/架构调整/复杂算法/安全相关 = hard。
120
+ 难度判定:宁易勿难,绝大多数任务都是 easy——常规增删改查/小函数/格式调整/单点 bug 修复/
121
+ 配置调整 = easy;只有跨模块改动/架构调整/复杂算法/安全相关才算 hard,拿不准时一律按 easy。
121
122
  质量步骤判定:涉及安全、数据迁移、并发、权限、公共 API 或跨模块改动时必须评审;
122
123
  有明确自动验收的低风险小改可不做模型评审。max_repair_rounds 取 0-2,只有复杂任务才建议换将。
123
- 子任务粒度要可独立验证;最后一个子任务必须包含整体联调/收尾。
124
+ 子任务拆分:easy 一律只拆 1 个子任务——一个子任务把目标完整做完并自检通过即收尾,
125
+ 禁止拆出独立的核查/联调/收尾子任务;只有 hard 才允许多个子任务(至多 __N__ 个),
126
+ 粒度要可独立验证,且最后一个子任务必须包含整体联调/收尾。
124
127
 
125
128
  ## 先探索再计划(重要,借鉴 OpenSpec explore)
126
129
  拆解之前先用你的读文件/搜索工具**实际查看工作目录**,找到目标相关的真实文件与函数,再据此拆解。
@@ -527,7 +530,13 @@ def _norm_subtasks(data):
527
530
  if clean:
528
531
  step["files"] = clean
529
532
  steps.append(step)
530
- return steps or None
533
+ if not steps:
534
+ return None
535
+ # easy 任务执行层钉死单子任务:编排者提示词不服从多拆时,截掉只留首步
536
+ # (联调/收尾要求仅对 hard 生效,easy 单步自检即收尾)。
537
+ if _plan_difficulty(data) == "easy":
538
+ steps = steps[:1]
539
+ return steps
531
540
 
532
541
 
533
542
  def _plan_difficulty(data):