codebee 0.1.19 → 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.
@@ -21,7 +21,7 @@ _FILE = paths.DATA_DIR / "settings.json"
21
21
  # 运行时出现(空闲 90s 隐身)。
22
22
  # cleanup_enabled / cleanup_retention_days:每日垃圾清理(core/cleanup.py)——
23
23
  # 运行过程日志/发布截图/bak 残留等超期自动清理;retention 为保留天数。
24
- DEFAULTS = {"max_concurrent_jobs": 6, "default_workdir": "", "hooks_token": "",
24
+ DEFAULTS = {"max_concurrent_jobs": 12, "default_workdir": "", "hooks_token": "",
25
25
  "telemetry_errors": True, "publish_daily_cap": 10,
26
26
  "publish_fail_streak": 3, "notify_webhook": "", "notify_base_url": "",
27
27
  "pet_enabled": True, "pet_mode": "always", "pet_skin": "plush",
@@ -29,8 +29,8 @@ DEFAULTS = {"max_concurrent_jobs": 6, "default_workdir": "", "hooks_token": "",
29
29
  # 桌宠形象白名单(与 app/pet.py 的 SKINS 对齐;这里不 import pet 模块,避免
30
30
  # core 反向依赖 app 根目录脚本)
31
31
  PET_SKINS = ("plush", "robot")
32
- # 并发上限 12:worker 只是拉起 CLI 子进程的调度位,跨任务无共享资源;
33
- # 同任务单飞守卫在 jobs 层。默认 6 对齐「多任务并行不排队」的使用预期。
32
+ # 并发保护上限 12:任务有空位即直接启动,满载明确返回忙,不进入等待队列;
33
+ # 同任务单飞守卫在 jobs 层。默认取上限,对齐「默认不排队」的使用预期。
34
34
  MIN_WORKERS, MAX_WORKERS = 1, 12
35
35
 
36
36
 
@@ -394,9 +394,50 @@ def relevance_top(lessons, task, limit):
394
394
  grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
395
395
  return (-len(probe & grams), x.get("id") or "")
396
396
 
397
+ # 使用反馈闭环(pmb「量化记忆真实帮助」借鉴):被选中次数多的教训排前
398
+ hits = _load_hits()
399
+ def rank(x):
400
+ grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
401
+ overlap = -len(probe & grams)
402
+ lid = x.get("id") or ""
403
+ return (overlap, -hits.get(lid, 0), lid)
404
+
397
405
  return sorted(lessons, key=rank)[:limit]
398
406
 
399
407
 
408
+ def _hits_path():
409
+ return paths.DATA_DIR / "skill_hits.json"
410
+
411
+
412
+ def _load_hits():
413
+ """读取教训使用计数({lesson_id: 次数})。文件缺失/损坏返回空 dict。"""
414
+ try:
415
+ p = _hits_path()
416
+ if p.is_file():
417
+ import json
418
+ return json.loads(p.read_text(encoding="utf-8"))
419
+ except Exception:
420
+ pass
421
+ return {}
422
+
423
+
424
+ def _bump_hits(ids):
425
+ """注入后递增使用计数并持久化(fire-and-forget,失败静默)。"""
426
+ try:
427
+ import json
428
+ p = _hits_path()
429
+ hits = _load_hits()
430
+ for lid in ids:
431
+ if lid:
432
+ hits[lid] = hits.get(lid, 0) + 1
433
+ p.parent.mkdir(parents=True, exist_ok=True)
434
+ tmp = p.with_suffix(".tmp")
435
+ tmp.write_text(json.dumps(hits, ensure_ascii=False), encoding="utf-8")
436
+ tmp.replace(p)
437
+ except Exception:
438
+ pass
439
+
440
+
400
441
  def block_for(task, scope_override=None, *, stable_order=False):
401
442
  """生成注入提示词的经验块。命中即计数。返回 (文本, 命中的 id 列表)。
402
443
 
@@ -440,6 +481,8 @@ def block_for(task, scope_override=None, *, stable_order=False):
440
481
  text = "## 经验库(写作/工程规范 + 历史教训,必须遵守)\n\n" + "\n\n".join(parts)
441
482
  if len(text) > MAX_INJECT_CHARS:
442
483
  text = text[:MAX_INJECT_CHARS] + "\n…(已截断)"
484
+ if used:
485
+ _bump_hits(used) # 使用反馈闭环:被选中的教训递增计数,下次排序升权
443
486
  if used:
444
487
  bump_hits(used)
445
488
  return text, used
package/app/core/store.py CHANGED
@@ -448,11 +448,16 @@ def load_all():
448
448
  # 错误串(`[91m[1mError:`、U+FFFD 乱码墙),读盘时统一洗一遍——
449
449
  # 老运行不必等重跑才干净(2026-09-20「咋还有乱码」实测)
450
450
  _sanitize_run_text(r)
451
- # 队列不跨进程持久化:磁盘上仍是 queued/running 的运行必是上次进程中断的残骸
452
- if r.get("status") in ("queued", "running"):
453
- r["status"] = "failed"
454
- r["error"] = r.get("error") or "服务重启中断,可重试"
455
- _save_json(p, r)
451
+ # 直接执行线程不跨进程:running 与无截止时间的 queued 是上次
452
+ # 进程中断残骸。带 resume_enqueue_at queued 是有意的自动续跑
453
+ # 退避,保留给 jobs.restore_deferred_resumes 重建 Timer。
454
+ interrupted = (r.get("status") == "running" or
455
+ (r.get("status") == "queued" and
456
+ not r.get("resume_enqueue_at")))
457
+ if interrupted:
458
+ r["status"] = "failed"
459
+ r["error"] = r.get("error") or "服务重启中断,可重试"
460
+ _save_json(p, r)
456
461
  _RUNS[r["id"]] = r
457
462
  except Exception:
458
463
  pass
@@ -507,10 +512,11 @@ def get_run(run_id):
507
512
  return _RUNS.get(run_id)
508
513
 
509
514
 
510
- def list_runs(limit=60):
511
- with LOCK:
512
- ids = sorted(_RUNS.keys(), reverse=True)
513
- return [_RUNS[i] for i in ids[:limit]]
515
+ def list_runs(limit=60):
516
+ with LOCK:
517
+ ids = sorted(_RUNS.keys(), reverse=True)
518
+ selected = ids if limit is None else ids[:limit]
519
+ return [_RUNS[i] for i in selected]
514
520
 
515
521
 
516
522
  def latest_run_by_task():
@@ -635,17 +641,17 @@ def task_side(task_id):
635
641
  }
636
642
 
637
643
 
638
- def update_run(run_id, expected_status=None, **fields):
644
+ def update_run(run_id, expected_status=None, **fields):
639
645
  """更新运行字段。expected_status 非 None 时做 CAS(§2C):
640
646
  当前状态不等于 expected_status 则拒绝写入并返回 None,
641
647
  防止陈旧执行方(被取消的 worker、崩溃恢复前的旧线程)覆盖新状态
642
648
  ——防御模式「异步状态不是同步状态」。不传则保持原行为。"""
643
649
  with LOCK:
644
650
  run = _RUNS.get(run_id)
645
- if not run:
646
- return None
647
- if expected_status is not None and run.get("status") != expected_status:
648
- return None
651
+ if not run:
652
+ return None
653
+ if expected_status is not None and run.get("status") != expected_status:
654
+ return None
649
655
  for k in ("error", "summary"):
650
656
  if isinstance(fields.get(k), str):
651
657
  # 错误串多是我们自己拼的 CLI 尾巴(含 ANSI/覆写/乱码墙):
@@ -1077,7 +1077,7 @@ def _goal_text(bug, side=None):
1077
1077
 
1078
1078
 
1079
1079
  def _launch_fix(bug, profile, side, cfg):
1080
- """为一个 bug 的某一端建修复任务并入队。与 automation._launch_run 同一条链。"""
1080
+ """为一个 bug 的某一端建修复任务并立即启动。"""
1081
1081
  bid = bug.get("id")
1082
1082
  repo = _repo_of(profile, side)
1083
1083
  wd = str(repo.get("workdir") or "").strip() or settings.default_workdir()
@@ -1088,11 +1088,31 @@ def _launch_fix(bug, profile, side, cfg):
1088
1088
  payload["git_rev"] = str(repo["git_rev"]).strip()
1089
1089
  if str(repo.get("verify_command") or "").strip():
1090
1090
  payload["verify_command"] = str(repo["verify_command"]).strip()
1091
- task = store.create_task(payload)
1092
- run = store.create_run("orchestration", task["title"], task_id=task["id"])
1093
- store.update_task_status(task["id"], "queued")
1094
- jobs.enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
1095
- return task, run
1091
+ task = None
1092
+ run = None
1093
+ try:
1094
+ task = store.create_task(payload)
1095
+ run = store.create_run("orchestration", task["title"], task_id=task["id"])
1096
+ store.update_task_status(task["id"], "queued")
1097
+ jobs.enqueue({"kind": "orchestration", "run_id": run["id"],
1098
+ "task_id": task["id"]})
1099
+ return task, run
1100
+ except Exception:
1101
+ log.exception("zentao: 修复任务启动失败 bug=%s side=%s task=%s run=%s",
1102
+ bid, side, (task or {}).get("id"), (run or {}).get("id"))
1103
+ if run:
1104
+ try:
1105
+ store.update_run(run["id"], status="failed",
1106
+ error="禅道修复任务启动失败,本次未排队,请稍后重试",
1107
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
1108
+ except Exception:
1109
+ log.exception("zentao: 修复运行失败收口失败 run=%s", run.get("id"))
1110
+ if task:
1111
+ try:
1112
+ store.update_task_status(task["id"], "failed")
1113
+ except Exception:
1114
+ log.exception("zentao: 修复任务失败收口失败 task=%s", task.get("id"))
1115
+ raise
1096
1116
 
1097
1117
 
1098
1118
  # ---------------------------------------------------------------- 回写文本
package/app/main.py CHANGED
@@ -972,7 +972,7 @@ class Handler(BaseHTTPRequestHandler):
972
972
  m = re.match(r"^/api/catalog/([^/]+)/launch$", path)
973
973
  if m:
974
974
  # 一键打开(web 类起服务+开浏览器 / console 类新终端窗口)。
975
- # 即时返回不走任务队列;body 可传 {"open": false} 供测试免开浏览器
975
+ # 即时返回不走任务执行器;body 可传 {"open": false} 供测试免开浏览器
976
976
  entry = catalog.by_id(m.group(1))
977
977
  if not entry:
978
978
  return self._json(404, {"error": "catalog 中无此条目"})
@@ -1713,9 +1713,13 @@ class Handler(BaseHTTPRequestHandler):
1713
1713
  ok, err, new_run = store.retry_task(run["task_id"])
1714
1714
  if ok:
1715
1715
  store.update_run(new_run["id"], op="qa", qa_text=text)
1716
- self._enqueue_run(new_run["id"], run["task_id"],
1717
- {"kind": "orchestration", "run_id": new_run["id"],
1718
- "task_id": run["task_id"]})
1716
+ started, start_err = self._enqueue_run(
1717
+ new_run["id"], run["task_id"],
1718
+ {"kind": "orchestration", "run_id": new_run["id"],
1719
+ "task_id": run["task_id"]})
1720
+ if not started:
1721
+ return self._json(503, {"error": start_err,
1722
+ "run_id": new_run["id"]})
1719
1723
  return self._json(200, {"ok": True, "message": msg,
1720
1724
  "qa_run": new_run["id"]})
1721
1725
  return self._json(200, {"ok": True, "message": msg})
@@ -2100,15 +2104,17 @@ class Handler(BaseHTTPRequestHandler):
2100
2104
  return self._json(status, resp)
2101
2105
 
2102
2106
  def _enqueue_run(self, run_id, task_id, job):
2103
- """入队失败时把已持久化记录收口到 failed,避免 UI 永远显示排队中。"""
2107
+ """立即启动失败时收口到 failed;系统默认没有等待队列。"""
2104
2108
  try:
2105
2109
  jobs.enqueue(job)
2106
2110
  return True, ""
2107
- except Exception:
2111
+ except Exception as exc:
2108
2112
  # 不把异常文本(本机路径、命令行参数、供应商响应)返回给客户端;
2109
2113
  # 详细堆栈只进服务端日志,run 记录也保留稳定的用户可读文案。
2110
2114
  log.exception("任务入队失败 run=%s task=%s", run_id, task_id)
2111
- err = "任务入队失败,请稍后重试"
2115
+ busy = isinstance(exc, jobs.JobsBusyError)
2116
+ err = ("当前运行任务已达并发保护上限,本次未排队,请稍后重试"
2117
+ if busy else "任务启动失败,请稍后重试")
2112
2118
  try:
2113
2119
  closed = store.update_run(run_id, status="failed", error=err,
2114
2120
  ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
@@ -2161,8 +2167,7 @@ def _state_payload(client_id="", ver=None):
2161
2167
  "control": remote.control_view(client_id),
2162
2168
  # 供应商健康/告警(顶栏横幅数据源;有告警时 bump_state 会推给所有端)
2163
2169
  "health": health.snapshot(),
2164
- # 任务队列观测(worker 池目标/存活 + 队列深度):排队问题排障一眼定位
2165
- # 是「并发满载在等」还是「job 蒸发没人管」(后者由看门狗 2 分钟自愈)
2170
+ # 直接执行观测:并发保护上限、运行数和可用位;queued 恒为 0。
2166
2171
  "jobs": jobs.workers_info(),
2167
2172
  }
2168
2173
 
@@ -2355,14 +2360,17 @@ def main():
2355
2360
  telemetry.start_background() # 匿名错误回传+版本 ping(默认开可关;未配端点自动休眠,延迟 45s 不挡启动)
2356
2361
  except Exception:
2357
2362
  pass
2358
- _step("正在启动任务队列…")
2363
+ _step("正在启动任务执行器…")
2359
2364
  jobs.start_worker()
2365
+ n_wait = jobs.restore_deferred_resumes()
2366
+ if n_wait:
2367
+ print("[CodeBee] 已恢复 %d 个定时退避中的自动续跑任务" % n_wait)
2360
2368
  n_resume = jobs.resume_interrupted() # 启动恢复:服务被杀中断的连载任务自动续跑
2361
2369
  if n_resume:
2362
2370
  print("[CodeBee] 已自动恢复 %d 个中断的连载任务(断点续跑)" % n_resume)
2363
- n_rq = jobs.requeue_pending() # 启动补队:队列在内存里,重启会让排队项变僵尸
2371
+ n_rq = jobs.requeue_pending() # 兼容旧版本遗留的无退避 queued 记录
2364
2372
  if n_rq:
2365
- print("[CodeBee] 已重新入队 %d 个遗留排队运行" % n_rq)
2373
+ print("[CodeBee] 已直接启动 %d 个遗留运行" % n_rq)
2366
2374
  _step("正在启动自动化调度…")
2367
2375
  n_auto = automation.start() # 自动化:加载定时任务并拉起调度线程(错过的一次性任务不补跑)
2368
2376
  if n_auto:
package/app/pet.py CHANGED
@@ -117,7 +117,7 @@ def derive_state(prev_active_ids, tasks):
117
117
 
118
118
 
119
119
  def tooltip_lines(snap, lang="zh"):
120
- """悬停清单:未读摘要 + 进行中 + 排队,最多 6 行。
120
+ """悬停清单:未读摘要 + 进行中 + 待启动/自动续跑,最多 6 行。
121
121
 
122
122
  只列「还在跑 / 待跑」的任务——历史失败项会刷满清单(截图实测十几条
123
123
  ✘ 把面板撑成一堵墙),失败已由警示气泡点名,这里不再重复。
@@ -167,7 +167,7 @@ LANG = {
167
167
  "all_clear": "蜂群闲着,都在打盹…",
168
168
  "title": "蜂群动态",
169
169
  "n_running": "%d 个进行中",
170
- "queued": "排队中",
170
+ "queued": "正在启动",
171
171
  "cheer": "🎉 %d 个任务完工!",
172
172
  "alert": "⚠️ 「%s」出岔子了,点我看看",
173
173
  "bye": "蜜蜂回巢啦",