codebee 0.1.50 → 0.1.52
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 +13 -0
- package/README.md +3 -4
- package/app/core/automation.py +7 -1
- package/app/core/builtin_agent.py +172 -97
- package/app/core/catalog.py +21 -2
- package/app/core/jobs.py +170 -42
- package/app/core/knowledge.py +4 -1
- package/app/core/manager.py +24 -7
- package/app/core/market.py +27 -1
- package/app/core/pipeline.py +58 -12
- package/app/core/planner.py +14 -5
- package/app/core/prefs.py +25 -20
- package/app/core/router.py +189 -104
- package/app/core/runner.py +37 -3
- package/app/core/settings.py +4 -3
- package/app/core/skills.py +41 -13
- package/app/core/store.py +11 -0
- package/app/core/zentao.py +126 -17
- package/app/main.py +29 -6
- package/app/ui/app.js +13324 -13171
- package/app/ui/i18n.js +63 -4
- package/app/ui/index.html +11 -11
- package/app/ui/style.css +22 -1
- package/package.json +1 -1
package/app/core/jobs.py
CHANGED
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
"""任务执行器:直接启动、无等待队列地执行编排任务与管理操作。
|
|
3
3
|
|
|
4
4
|
每个 job 一条独立线程,run/step 数据按 run_id 隔离,store 层有全局锁。
|
|
5
|
-
|
|
6
|
-
|
|
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 =
|
|
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-
|
|
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
|
|
116
|
-
"""
|
|
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
|
|
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
|
-
|
|
150
|
-
elif
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
|
184
|
+
if busy:
|
|
158
185
|
CANCELS.pop(run_id, None)
|
|
159
|
-
|
|
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
|
-
|
|
236
|
+
def _release_slot(job=None):
|
|
237
|
+
"""释放执行位:按 job 的 light 标记归还对应池,两池全空才唤醒等待方。"""
|
|
238
|
+
global _alive, _chat_alive
|
|
182
239
|
with _idle_cond:
|
|
183
|
-
|
|
184
|
-
|
|
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,
|
|
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
|
|
package/app/core/knowledge.py
CHANGED
|
@@ -234,7 +234,10 @@ def block_for(task):
|
|
|
234
234
|
grams = (skills._text_bigrams(x.get("title"))
|
|
235
235
|
| skills._text_bigrams(x.get("body"))
|
|
236
236
|
| set(x.get("tags") or []))
|
|
237
|
-
|
|
237
|
+
# 过期降权(inkos 检索保留来源/位置的启发):同等相关性下,
|
|
238
|
+
# 可能过期的事实排在新鲜事实之后——旧知识不该压过新知识
|
|
239
|
+
return (-len(probe & grams), 1 if _is_stale(x.get("as_of")) else 0,
|
|
240
|
+
x.get("id") or "")
|
|
238
241
|
entries = sorted(entries, key=rank)
|
|
239
242
|
else:
|
|
240
243
|
entries.sort(key=lambda x: x.get("id") or "")
|
package/app/core/manager.py
CHANGED
|
@@ -192,21 +192,38 @@ def sweep_orphan_cli_processes():
|
|
|
192
192
|
|
|
193
193
|
|
|
194
194
|
def detect_all(force=False):
|
|
195
|
-
"""
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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):
|
package/app/core/market.py
CHANGED
|
@@ -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):
|
package/app/core/pipeline.py
CHANGED
|
@@ -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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
1144
|
+
implement_reason=route_note,
|
|
1134
1145
|
review_reason=route.get("reviewer", ""),
|
|
1135
1146
|
direct=not workflow["review_required"])
|
|
1136
1147
|
return True
|
|
1137
|
-
|
|
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 = "
|
|
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",
|
|
@@ -1457,11 +1481,31 @@ def _run_direct(run, task, agents, ev, stats, mode):
|
|
|
1457
1481
|
route = {}
|
|
1458
1482
|
resume_ctx = _valid_resume(task, agents)
|
|
1459
1483
|
bi = None
|
|
1484
|
+
impl = None
|
|
1460
1485
|
direct_provider = (task.get("direct_provider_id") or "").strip()
|
|
1461
1486
|
direct_model = (task.get("direct_model") or "").strip()
|
|
1487
|
+
direct_agent = (task.get("direct_agent") or "").strip()
|
|
1462
1488
|
direct_difficulty = ("hard" if task.get("thinking") == "high" else
|
|
1463
1489
|
"easy" if task.get("thinking") == "low" else "default")
|
|
1464
|
-
if resume_ctx is None and
|
|
1490
|
+
if resume_ctx is None and direct_agent:
|
|
1491
|
+
# 手动指定执行 CLI(2026-09-23):用户点名压过一切自动(模型绑定/
|
|
1492
|
+
# 自动推荐/路由)。目标未启用编排时按续会话语义注入(installed_agent
|
|
1493
|
+
# 无视启停开关)——显式指定不该被「参与编排」开关否决。
|
|
1494
|
+
impl = _pick(agents, direct_agent)
|
|
1495
|
+
if impl is None:
|
|
1496
|
+
extra = registry.installed_agent(direct_agent, catalog.load(),
|
|
1497
|
+
manager.detect_all())
|
|
1498
|
+
if extra:
|
|
1499
|
+
agents.append(extra)
|
|
1500
|
+
impl = extra
|
|
1501
|
+
if impl is None:
|
|
1502
|
+
store.update_run(run_id, expected_status="running", status="failed",
|
|
1503
|
+
error="指定的执行 CLI「%s」不可用(未安装或未检测到),"
|
|
1504
|
+
"请在对话条改回自动推荐,或到目录页先安装" % direct_agent,
|
|
1505
|
+
ended_at=_now())
|
|
1506
|
+
return
|
|
1507
|
+
route["implementer"] = "%s(手动指定 CLI)" % (impl.get("label") or impl.get("id"))
|
|
1508
|
+
elif resume_ctx is None and direct_provider:
|
|
1465
1509
|
bi = builtin_agent.resolve(direct_provider, direct_model, direct_difficulty)
|
|
1466
1510
|
if bi is None:
|
|
1467
1511
|
store.update_run(run_id, expected_status="running", status="failed",
|
|
@@ -1483,6 +1527,8 @@ def _run_direct(run, task, agents, ev, stats, mode):
|
|
|
1483
1527
|
elif resume_ctx is not None:
|
|
1484
1528
|
impl = resume_ctx["agent"]
|
|
1485
1529
|
route["implementer"] = resume_ctx["note"]
|
|
1530
|
+
elif impl is not None:
|
|
1531
|
+
pass # 手动指定 CLI 已就位(route 亦已写)
|
|
1486
1532
|
elif mode == "manual":
|
|
1487
1533
|
impl, _ = _pick_implementer(agents, task.get("implementer"))
|
|
1488
1534
|
else:
|
package/app/core/planner.py
CHANGED
|
@@ -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 = """
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
package/app/core/prefs.py
CHANGED
|
@@ -14,15 +14,20 @@ from . import paths
|
|
|
14
14
|
|
|
15
15
|
_LOCK = threading.Lock()
|
|
16
16
|
|
|
17
|
-
SAVE_KEYS = ("type", "rounds", "threshold", "best_of", "chapters",
|
|
18
|
-
"words_per_chapter", "variants", "mode", "thinking"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
SAVE_KEYS = ("type", "rounds", "threshold", "best_of", "chapters",
|
|
18
|
+
"words_per_chapter", "variants", "mode", "thinking",
|
|
19
|
+
# direct 类型的对话模型偏好(2026-09-23 补全偏好记忆覆盖面):
|
|
20
|
+
# 厂商 id/模型名/推理档——下次新建 direct 任务沿用上次手选
|
|
21
|
+
"direct_provider", "direct_model", "direct_thinking")
|
|
22
|
+
_LIMITS = {"rounds": (1, 5), "threshold": (1.0, 10.0), "best_of": (1, 3),
|
|
23
|
+
"chapters": (1, 2000), "words_per_chapter": (200, 20000),
|
|
24
|
+
"variants": (1, 3)}
|
|
25
|
+
_CHOICES = {
|
|
26
|
+
"mode": ("auto", "fast", "expert", "manual"),
|
|
27
|
+
"thinking": ("auto", "low", "standard", "high"),
|
|
28
|
+
"direct_thinking": ("auto", "low", "standard", "high"),
|
|
29
|
+
}
|
|
30
|
+
_STR_KEYS = ("type", "direct_provider", "direct_model") # 自由字符串截断白名单
|
|
26
31
|
|
|
27
32
|
|
|
28
33
|
def _file():
|
|
@@ -51,17 +56,17 @@ def record(payload):
|
|
|
51
56
|
v = payload.get(key)
|
|
52
57
|
if v is None:
|
|
53
58
|
continue
|
|
54
|
-
if key
|
|
55
|
-
v = str(v)[:
|
|
56
|
-
if v:
|
|
57
|
-
cur[
|
|
58
|
-
continue
|
|
59
|
-
if key in _CHOICES:
|
|
60
|
-
v = str(v).strip().lower()
|
|
61
|
-
if v in _CHOICES[key]:
|
|
62
|
-
cur[key] = v
|
|
63
|
-
continue
|
|
64
|
-
c = _clamp(key, v)
|
|
59
|
+
if key in _STR_KEYS:
|
|
60
|
+
v = str(v).strip()[:64]
|
|
61
|
+
if v:
|
|
62
|
+
cur[key] = v
|
|
63
|
+
continue
|
|
64
|
+
if key in _CHOICES:
|
|
65
|
+
v = str(v).strip().lower()
|
|
66
|
+
if v in _CHOICES[key]:
|
|
67
|
+
cur[key] = v
|
|
68
|
+
continue
|
|
69
|
+
c = _clamp(key, v)
|
|
65
70
|
if c is not None:
|
|
66
71
|
cur[key] = c
|
|
67
72
|
tmp = _file().with_suffix(".tmp")
|