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.
@@ -25,13 +25,18 @@ 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",
28
- "cleanup_enabled": True, "cleanup_retention_days": 14}
28
+ "cleanup_enabled": True, "cleanup_retention_days": 14,
29
+ # claude_config_sync:打开/运行前防线是否直写 ~/.claude/settings.json。
30
+ # 关闭后交互 claude 配置归用户手动管理(cc-switch 等),编排步骤经
31
+ # runner 一次性 --settings 注入绑定链,路由不受影响(manager 读取)
32
+ "claude_config_sync": True}
29
33
  # 桌宠形象白名单(与 app/pet.py 的 SKINS 对齐;这里不 import pet 模块,避免
30
34
  # core 反向依赖 app 根目录脚本)
31
35
  PET_SKINS = ("plush", "robot")
32
- # 并发保护上限 12:任务有空位即直接启动,满载明确返回忙,不进入等待队列;
33
- # 同任务单飞守卫在 jobs 层。默认取上限,对齐「默认不排队」的使用预期。
34
- MIN_WORKERS, MAX_WORKERS = 1, 12
36
+ # 并发保护上限 24:编排任务有空位即直接启动,满载回滚 queued 排队自动补跑,
37
+ # 不秒判失败;同任务单飞守卫在 jobs 层。该上限保护的是本机资源(CLI 子进程
38
+ # 数),与各 API 厂商的限流无关;direct 对话走 jobs 轻量池不占此额度。
39
+ MIN_WORKERS, MAX_WORKERS = 1, 24
35
40
 
36
41
 
37
42
  def builtin_workdir():
@@ -127,6 +132,8 @@ def save(patch):
127
132
  cur["pet_skin"] = ps
128
133
  if "cleanup_enabled" in patch:
129
134
  cur["cleanup_enabled"] = bool(patch.get("cleanup_enabled"))
135
+ if "claude_config_sync" in patch:
136
+ cur["claude_config_sync"] = bool(patch.get("claude_config_sync"))
130
137
  if "cleanup_retention_days" in patch:
131
138
  try:
132
139
  cur["cleanup_retention_days"] = int(patch.get("cleanup_retention_days"))
@@ -300,11 +300,26 @@ def list_lessons(scope=None, only_enabled=False, category=None):
300
300
  return items
301
301
 
302
302
 
303
+ def _title_containment(a, b):
304
+ """标题近似度:字符 bigram 包含度(交集/较短者)。与 knowledge._title_sim
305
+ 同款度量(追加后缀形态 Jaccard 漏、包含度=1.0),教训/知识去重口径一致。"""
306
+ ga, gb = _text_bigrams(a), _text_bigrams(b)
307
+ if not ga or not gb:
308
+ return 0.0
309
+ return len(ga & gb) / float(min(len(ga), len(gb)))
310
+
311
+
312
+ _LESSON_NEAR_DUP = 0.8 # 同 scope 教训标题包含度 ≥0.8 视为同一条(近似题合并)
313
+
314
+
303
315
  def upsert_lesson(scope, title, content, source="", category=None, dim=None):
304
316
  """写入/合并一条教训:同 scope 同标题视为同一条(seen+1,内容取新的)。
305
317
 
306
318
  category 为闭集枚举之一(见 LESSON_CATEGORIES);输入非法时退到 dim 关键词映射,
307
319
  仍归不出则落「未分类」。id 仍只按 scope+标题哈希,故老教训再沉淀会合并而非分裂。
320
+ 近似题合并(与知识库同款防膨胀):自动复盘每次 done 运行都跑,「节奏拖沓」与
321
+ 「节奏拖沓问题」会各占一条;同 scope 标题包含度 ≥0.8 也视为同一条,合并语义
322
+ 与精确同题一致。<4 字符短题只认精确(bigram 噪声大)。
308
323
  """
309
324
  title = str(title or "").strip()[:60]
310
325
  content = str(content or "").strip()[:1200]
@@ -315,18 +330,27 @@ def upsert_lesson(scope, title, content, source="", category=None, dim=None):
315
330
  with _LOCK:
316
331
  data = _load()
317
332
  items = data.setdefault("lessons", [])
318
- for it in items:
319
- if it.get("id") == lid:
320
- it["content"] = content
321
- it["seen"] = int(it.get("seen") or 1) + 1
322
- it["updated_at"] = _now()
323
- # 合并时不降级已有分类:除非本次归到了明确类别,或该条原本没有分类
324
- if cat != LESSON_UNCATEGORIZED or not it.get("category"):
325
- it["category"] = cat
326
- if source:
327
- it["source"] = source
328
- _save(data)
329
- return it
333
+ hit = next((x for x in items if x.get("id") == lid), None)
334
+ if hit is None and len(title) >= 4:
335
+ for x in items:
336
+ if x.get("scope") != scope:
337
+ continue
338
+ xt = str(x.get("title") or "")
339
+ if len(xt) >= 4 and _title_containment(title, xt) >= _LESSON_NEAR_DUP:
340
+ hit = x
341
+ break
342
+ if hit is not None:
343
+ it = hit
344
+ it["content"] = content
345
+ it["seen"] = int(it.get("seen") or 1) + 1
346
+ it["updated_at"] = _now()
347
+ # 合并时不降级已有分类:除非本次归到了明确类别,或该条原本没有分类
348
+ if cat != LESSON_UNCATEGORIZED or not it.get("category"):
349
+ it["category"] = cat
350
+ if source:
351
+ it["source"] = source
352
+ _save(data)
353
+ return it
330
354
  it = {"id": lid, "scope": scope, "title": title, "content": content,
331
355
  "source": source, "hits": 0, "seen": 1, "enabled": True,
332
356
  "category": cat,
package/app/core/store.py CHANGED
@@ -24,6 +24,12 @@ LOCK = threading.RLock()
24
24
  _TASKS = {}
25
25
  _RUNS = {}
26
26
 
27
+ # 流式实时落盘的节流表:(run_id, 步骤号) -> 上次落盘时刻。步骤收尾即清,
28
+ # 长跑也不会无限增长(finish_step 里调 clear_stream_state)。
29
+ _STREAM_LOCK = threading.Lock()
30
+ _STREAM_TS = {}
31
+ LIVE_TEXT_MAX = 20000 # 单步骤实时思考/正文的保留上限(与 builtin_agent 同口径)
32
+
27
33
  # ---------------------------------------------------------------- 状态版本(SSE 事件驱动)
28
34
  # 任何落盘写都算状态变化:SSE 连接等版本号变化才构建/推送全量状态,
29
35
  # 空闲时连接零开销(此前每连接每 0.8s 盲构建全量 payload,多端并发会放大成假死)
@@ -547,6 +553,9 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
547
553
  _RUNS.pop(run["id"], None)
548
554
  raise
549
555
  _RUNS[run["id"]] = run
556
+ # 新 run(排队/起跑)必须唤醒 SSE:否则侧栏 task_latest 感知不到
557
+ # 「最新一次运行」已易主,字形/时间停在上一轮(2026-09-22 侧栏假在跑案)
558
+ bump_state()
550
559
  return run
551
560
 
552
561
 
@@ -737,7 +746,13 @@ def update_run(run_id, expected_status=None, **fields):
737
746
  "error": str(run.get("error") or "")[:300]}}).start()
738
747
  except Exception:
739
748
  pass
740
- return run
749
+ # 每次成功写入都唤醒 SSE:run 的状态/步骤变化从前只能靠无关操作
750
+ # (自动化落盘、删记录)顺带广播,空闲时侧栏 task_latest 会冻在
751
+ # 「在跑」直到下一次无关 bump(2026-09-22 侧栏假在跑案)。
752
+ # 调用都是步骤级边界,不会形成推送风暴;CAS 拒绝/无此 run 的早退
753
+ # 路径不动版本号。
754
+ bump_state()
755
+ return run
741
756
 
742
757
 
743
758
  def run_dir(run_id):
@@ -1524,9 +1539,58 @@ def add_step(run_id, role, agent_id, agent_label, note=""):
1524
1539
  return step, log_abs
1525
1540
 
1526
1541
 
1542
+ def stream_step(run_id, n, thinking=None, text=None, activity=None, min_secs=0.6):
1543
+ """步骤运行中实时落一段「思考过程/正文/活动」——对话时间线边跑边打印。
1544
+
1545
+ 直连对话此前整轮只有三点打字动画(32 秒黑箱,用户 2026-09-22 反馈);
1546
+ 模型的思维链与正文在流式回调里到达,这里节流落盘(默认 0.6s 一次),
1547
+ 前端 2s 轮询一次读到的就是「它正在想什么」。
1548
+
1549
+ 节流按步骤记忆上次落盘时刻;收尾时 finish_step 会写入最终值并清掉
1550
+ live 标记。返回是否真的落盘(调用方可据此决定要不要做下一步)。
1551
+ """
1552
+ key = (run_id, int(n))
1553
+ now = time.time()
1554
+ with _STREAM_LOCK:
1555
+ last = _STREAM_TS.get(key, 0)
1556
+ if min_secs and now - last < min_secs:
1557
+ return False
1558
+ _STREAM_TS[key] = now
1559
+ with LOCK:
1560
+ run = _RUNS.get(run_id)
1561
+ if not run:
1562
+ return False
1563
+ for s in run["steps"]:
1564
+ if s["n"] != n:
1565
+ continue
1566
+ if thinking is not None:
1567
+ s["thinking"] = str(thinking)[-LIVE_TEXT_MAX:]
1568
+ if text is not None:
1569
+ s["stream"] = str(text)[-LIVE_TEXT_MAX:]
1570
+ if activity:
1571
+ acts = list(s.get("activity") or [])
1572
+ if not acts or acts[-1] != activity:
1573
+ acts.append(str(activity)[:200])
1574
+ s["activity"] = acts[-8:]
1575
+ s["live"] = int(s.get("live") or 0) + 1 # 前端重绘签名(内容变即刷新)
1576
+ _save_json(paths.RUNS_DIR / run_id / "run.json", run)
1577
+ return True
1578
+ return False
1579
+
1580
+
1581
+ def clear_stream_state(run_id=None, n=None):
1582
+ """清掉流式节流状态(步骤收尾/测试收场用),避免字典随长跑无限增长。"""
1583
+ with _STREAM_LOCK:
1584
+ if run_id is None:
1585
+ _STREAM_TS.clear()
1586
+ return
1587
+ for key in [k for k in _STREAM_TS if k[0] == run_id and (n is None or k[1] == n)]:
1588
+ _STREAM_TS.pop(key, None)
1589
+
1590
+
1527
1591
  def finish_step(run_id, n, status, summary="", exit_code=None,
1528
1592
  cost_usd=0.0, tokens=0.0, duration_s=None, model=None, output=None,
1529
- followups=None):
1593
+ followups=None, thinking=None):
1530
1594
  with LOCK:
1531
1595
  run = _RUNS.get(run_id)
1532
1596
  if not run:
@@ -1549,12 +1613,20 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
1549
1613
  # output 是智能体正文(对话气泡直读):只剥 ANSI,不做噪声折叠
1550
1614
  # ——正文里的装饰性长串是作者写的,不能替它省略
1551
1615
  s["output"] = runner.strip_ansi(str(output))[:6000]
1616
+ if thinking is not None:
1617
+ s["thinking"] = runner.strip_ansi(str(thinking))[:LIVE_TEXT_MAX]
1552
1618
  if followups:
1553
1619
  s["followups"] = list(followups)[:3]
1620
+ # 收尾即清运行中态:stream/activity/live 只是过程快照,留着会让
1621
+ # 前端把「已结束」的步骤仍当实时流渲染(也白占 run.json 体积)
1622
+ s.pop("stream", None)
1623
+ s.pop("activity", None)
1624
+ s.pop("live", None)
1554
1625
  break
1555
1626
  run["cost_usd"] = round(run.get("cost_usd", 0.0) + cost_usd, 4)
1556
1627
  run["tokens"] = run.get("tokens", 0) + tokens
1557
1628
  _save_json(paths.RUNS_DIR / run_id / "run.json", run)
1629
+ clear_stream_state(run_id, n)
1558
1630
 
1559
1631
 
1560
1632
  # ---------------------------------------------------------------- 运行中指挥(消息信箱)
@@ -4,7 +4,7 @@
4
4
  数据落盘 <data>/zentao.json(tmp + os.replace 原子写;TUTTI_DATA 环境变量感知)。
5
5
  修复走与 /api/tasks 完全相同的链路(store.create_task → store.create_run →
6
6
  jobs.enqueue,code 引擎=实现→验证→评审→修复),不自造运行器。调度挂在
7
- automation._tick(同 publish/auto.fire_due 模式),内部按 interval_hours 节流。
7
+ automation._tick(同 publish/auto.fire_due 模式),内部按 interval_minutes 节流。
8
8
 
9
9
  禅道 REST API v1(开源版 15.x+;请求头 Token: <token>):
10
10
  POST {base}/api.php/v1/tokens {account, password} → {token}
@@ -34,6 +34,12 @@ backend | frontend | both | not_ours | unknown。
34
34
  unknown → 不碰 bug,need_manual + 群通知(下轮扫描 bug 仍激活则重排查)
35
35
  修复任务失败 → 评论尝试记录 + 转派该端负责人(模块路由 account > 端负责人)
36
36
 
37
+ 落库纪律:修复任务落单即带基线(档案配了用档案,没配取工作目录当前 HEAD)
38
+ 走任务分支隔离,修完的代码自动提交在任务分支上,对账合并后才算落库;
39
+ resolve 前再验一遍——没基线的任务改动只在工作区,有未提交的已跟踪改动
40
+ 不 resolve(转 done_manual 人工收口),auto_merge 关闭同理。
41
+ 对账(_reconcile)认任务最新一次运行:人工重试换 run 不影响回写判断。
42
+
37
43
  出网边界(SSRF 防护,_guard_url):请求目标来自用户自配禅道地址——内网按设计
38
44
  放行;强制 http(s)、解析主机并阻断云元数据/链路本地地址、禁跟随重定向。
39
45
  """
@@ -69,6 +75,7 @@ _STATE = {
69
75
  "last_error": "",
70
76
  }
71
77
  _LOADED = False
78
+ _BOOT_TIMER = None # 启动补对账的兜底 Timer(测试里要能拿到并取消)
72
79
 
73
80
  TOKEN_TTL = 23 * 3600
74
81
  HTTP_TIMEOUT = 15
@@ -76,7 +83,9 @@ PAGE_LIMIT = 100
76
83
  MAX_BUGS = 500
77
84
  RESOLVE_MAX_ATTEMPTS = 3
78
85
  RETRY_DELAY_MIN = 30
79
- INTERVAL_MIN, INTERVAL_MAX = 1, 168
86
+ INTERVAL_MIN, INTERVAL_MAX = 5, 10080 # 扫描间隔(分钟):5 分钟 ~ 7 天
87
+ INTERVAL_DEFAULT = 5
88
+ _LEGACY_INTERVAL_HOURS_DEFAULT = 2 # 老配置 interval_hours 缺省值(迁移用)
80
89
 
81
90
  SIDES = ("backend", "frontend")
82
91
  TRIAGE_SIDES = ("backend", "frontend", "both", "not_ours") # unknown 单列
@@ -91,7 +100,7 @@ _CFG_DEFAULTS = {
91
100
  "auto_merge": True,
92
101
  "triage_ai": True, # 模块路由未命中时用 AI 兜底排查
93
102
  "poll_enabled": False,
94
- "interval_hours": 2,
103
+ "interval_minutes": INTERVAL_DEFAULT,
95
104
  }
96
105
 
97
106
  _REPO_DEFAULTS = {"workdir": "", "git_rev": "", "verify_command": ""}
@@ -109,7 +118,7 @@ _PROFILE_DEFAULTS = {
109
118
 
110
119
  _UPDATABLE = ("base_url", "account", "password", "product_profiles",
111
120
  "auto_resolve", "auto_merge", "triage_ai",
112
- "poll_enabled", "interval_hours")
121
+ "poll_enabled", "interval_minutes")
113
122
 
114
123
 
115
124
  # ---------------------------------------------------------------- 出网边界(SSRF)
@@ -232,6 +241,17 @@ def load(force=False):
232
241
  # 必须在按 _CFG_DEFAULTS 过滤之前完成迁移)
233
242
  if isinstance(cfg, dict) and not cfg.get("product_profiles"):
234
243
  cfg = _migrate_legacy(dict(cfg))
244
+ # 老配置用 interval_hours(小时),新配置改用 interval_minutes(分钟):
245
+ # 用户没动过间隔(还是老默认 2 小时)就跟随新默认 5 分钟;真改过的按小时换算保留。
246
+ if isinstance(cfg, dict) and "interval_minutes" not in cfg \
247
+ and "interval_hours" in cfg:
248
+ try:
249
+ hours = int(cfg.get("interval_hours") or _LEGACY_INTERVAL_HOURS_DEFAULT)
250
+ except (TypeError, ValueError):
251
+ hours = _LEGACY_INTERVAL_HOURS_DEFAULT
252
+ cfg = dict(cfg)
253
+ mins = INTERVAL_DEFAULT if hours == _LEGACY_INTERVAL_HOURS_DEFAULT else hours * 60
254
+ cfg["interval_minutes"] = max(INTERVAL_MIN, min(INTERVAL_MAX, mins))
235
255
  merged = dict(_CFG_DEFAULTS)
236
256
  if isinstance(cfg, dict):
237
257
  merged.update({k: v for k, v in cfg.items() if k in _CFG_DEFAULTS})
@@ -623,7 +643,8 @@ def _pairs_to_list(pairs, id_key, name_key):
623
643
  def _module_items(d):
624
644
  """老版模块树形状兼容(2026-09-21 真机反馈「响应形状不认识」):
625
645
  sons/modules 数组、{id: {name,...}} 字典、children/sons 嵌套树,统一
626
- 递归展开成 [{id,name}] 平铺清单(id=0 是树根容器,跳过)。"""
646
+ 递归展开成 [{id,name}] 平铺清单(id=0 是树根容器,跳过);全数字键的
647
+ {id: 名字} 扁平映射也认(部分版本的模块摘要形状)。"""
627
648
  out = []
628
649
 
629
650
  def walk(node):
@@ -637,11 +658,19 @@ def _module_items(d):
637
658
  if isinstance(node.get(k), (list, dict)):
638
659
  walk(node[k])
639
660
  else:
661
+ if node and all(str(k).isdigit() for k in node.keys()):
662
+ flat = [{"id": k, "name": str(v)}
663
+ for k, v in node.items() if str(v or "").strip()]
664
+ if flat:
665
+ out.extend(flat)
666
+ return
640
667
  for v in node.values():
641
668
  walk(v)
642
669
 
643
670
  if isinstance(d, dict):
644
- walk(d.get("sons") or d.get("modules") or d.get("tree") or d)
671
+ # tree 优先:真机 tree=带 children 的完整嵌套树,sons 只有顶层扁平表
672
+ # (走 sons 会漏子模块)
673
+ walk(d.get("tree") or d.get("sons") or d.get("modules") or d)
645
674
  elif isinstance(d, list):
646
675
  walk(d)
647
676
  return out
@@ -724,8 +753,27 @@ def _old_route(api, method, p, q, body):
724
753
 
725
754
  m = re.match(r"^/products/(\d+)/modules$", p)
726
755
  if m and method == "GET":
727
- d = go(get("/tree-browse-%s-module.json" % m.group(1)), "(拉模块清单)")
728
- return {"_list": [x for x in _module_items(d) if isinstance(x, dict) and x.get("id")]}
756
+ # 2026-09-22 真机实探:该版模块行 type=story,viewType=module 查出空树
757
+ # (status 仍 success)——「响应形状不认识」的真根因。改按 bug 视图拉
758
+ # (bug 表单模块下拉用的就是这棵树),story/case 兜底。
759
+ pid = m.group(1)
760
+ last, saw_js = None, False
761
+ for vt in ("bug", "story", "case"):
762
+ d = go(get("/tree-browse-%s-%s.json" % (pid, vt)), "(拉模块清单)")
763
+ last = d
764
+ if isinstance(d, dict) and d.get("_js"):
765
+ saw_js = True # 接口回了页面脚本:换视图再试
766
+ continue
767
+ items = [x for x in _module_items(d) if isinstance(x, dict) and x.get("id")]
768
+ if items:
769
+ return {"_list": items}
770
+ if saw_js:
771
+ raise ZenError("禅道老接口(拉模块清单):回了页面脚本而不是数据"
772
+ "(模块树接口不在或被重定向)——请在禅道产品视图 URL 里查模块 ID 手工填写")
773
+ keys = ",".join(sorted(str(k) for k in last.keys())) \
774
+ if isinstance(last, dict) else type(last).__name__
775
+ raise ZenError("该产品模块树为空(bug/story/case 三个视图都没拉到模块,"
776
+ "响应顶层键:%s)——请到禅道确认产品下建过模块,或手工填模块 ID" % keys)
729
777
 
730
778
  if p == "/products" and method == "GET":
731
779
  d = go(get("/api-getmodel-product-getpairs.json"), "(拉产品清单)")
@@ -847,8 +895,12 @@ def fetch_modules(product_id):
847
895
  try:
848
896
  d = _call("GET", "/products/%s/modules" % product_id, cfg=cfg)
849
897
  except ZenError as e:
850
- return {"ok": False,
851
- "error": "%s——也可能你的禅道没有该接口:请在禅道产品视图 URL 里查模块 ID 手工填写" % e}
898
+ msg = str(e)
899
+ # 只有「接口不存在」类错误才补「没有该接口」提示;老通道自己报的
900
+ # 空树/脚本回包已带人话结论,别再叠一层误导
901
+ if "404" in msg or "不认识" in msg:
902
+ msg += "——也可能你的禅道没有该接口:请在禅道产品视图 URL 里查模块 ID 手工填写"
903
+ return {"ok": False, "error": msg}
852
904
  # 形状兼容器({_list} / {modules:[...]} / {id:{...}} 字典 / 裸数组)
853
905
  items = _list_items(d, "modules")
854
906
  if items is None:
@@ -858,6 +910,11 @@ def fetch_modules(product_id):
858
910
  if isinstance(m, dict) and m.get("id"):
859
911
  out.append({"id": m.get("id"), "name": str(m.get("name") or "")})
860
912
  if not out:
913
+ if isinstance(d, dict) and set(d.keys()) == {"_list"}:
914
+ # 自家归一包装(裸数组进 _api/_old_route 时打的包):空清单=
915
+ # 产品真没模块,不是形状问题,别拿顶层键吓人
916
+ return {"ok": False,
917
+ "error": "禅道回了空模块清单(该产品可能没建模块)——请手工填模块 ID"}
861
918
  # 带响应形状摘要帮排查(顶层键名,不吐正文——用户实测反馈
862
919
  # 「响应形状不认识」却看不到真实形状,没法报修)
863
920
  keys = ",".join(sorted(str(k) for k in d.keys())) if isinstance(d, dict) else type(d).__name__
@@ -1114,8 +1171,14 @@ def _launch_fix(bug, profile, side, cfg):
1114
1171
  title = ("[禅道#%s][%s] %s" % (bid, SIDE_CN.get(side, side),
1115
1172
  str(bug.get("title") or "").strip())).strip()[:60]
1116
1173
  payload = {"type": "code", "title": title, "goal": _goal_text(bug, side), "workdir": wd}
1117
- if str(repo.get("git_rev") or "").strip():
1118
- payload["git_rev"] = str(repo["git_rev"]).strip()
1174
+ base = str(repo.get("git_rev") or "").strip()
1175
+ if not base:
1176
+ # 档案没配基线就取落单时点的 HEAD:让修复走任务分支隔离——修完自动
1177
+ # 提交在任务分支上,对账合并才真正落库,resolve 才站得住(#27697 案:
1178
+ # 无基线任务修完只躺在工作区,闭环永远不会替你提交)。
1179
+ base = _head_rev(wd)
1180
+ if base:
1181
+ payload["git_rev"] = base
1119
1182
  if str(repo.get("verify_command") or "").strip():
1120
1183
  payload["verify_command"] = str(repo["verify_command"]).strip()
1121
1184
  task = None
@@ -1280,6 +1343,42 @@ def _merge_branch(workdir, task):
1280
1343
  return False, "合并异常:%s" % e, None
1281
1344
 
1282
1345
 
1346
+ def _head_rev(workdir):
1347
+ """工作目录当前 HEAD 短哈希(作任务隔离基线)。非仓库/拿不到返回空串。"""
1348
+ try:
1349
+ from . import gitmod, runner
1350
+ r = runner.run_process(
1351
+ argv=["git", "-C", str(workdir), "rev-parse", "--short", "HEAD"], timeout=20)
1352
+ if r.get("ok"):
1353
+ rev = (r.get("stdout") or "").strip()
1354
+ if rev and gitmod.valid_rev(rev):
1355
+ return rev
1356
+ except Exception:
1357
+ log.debug("zentao: 读取 HEAD 失败", exc_info=True)
1358
+ return ""
1359
+
1360
+
1361
+ def _uncommitted(workdir):
1362
+ """工作目录里有没有未提交的已跟踪改动(untracked 不算——CLI 噪音文件太多)。
1363
+
1364
+ 返回 (是否脏, 人话明细)。目录不是 git 仓库视为不脏(无提交语义,放行)。
1365
+ """
1366
+ try:
1367
+ from . import runner
1368
+ if not workdir:
1369
+ return False, ""
1370
+ r = runner.run_process(
1371
+ argv=["git", "-C", str(workdir), "status", "--porcelain", "-uno", "--", "."],
1372
+ timeout=20)
1373
+ if r.get("ok"):
1374
+ dirty = [ln.strip() for ln in (r.get("stdout") or "").splitlines() if ln.strip()]
1375
+ if dirty:
1376
+ return True, "未提交改动 %d 处(如 %s)" % (len(dirty), dirty[0][:80])
1377
+ except Exception:
1378
+ log.debug("zentao: 工作区状态检查失败", exc_info=True)
1379
+ return False, ""
1380
+
1381
+
1283
1382
  def _set_claim(bid, **patch):
1284
1383
  with _LOCK:
1285
1384
  c = _STATE["claims"].get(str(bid))
@@ -1307,20 +1406,38 @@ def _finish_ok(claim, cfg):
1307
1406
  tasks = claim.get("tasks") or []
1308
1407
  runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
1309
1408
  # 1) 逐任务合并(任一失败即停:不转派不 resolve,绝不带着没落库的修复转派)
1310
- if cfg.get("auto_merge"):
1311
- for t in tasks:
1312
- task = store.get_task(t.get("task_id") or "")
1313
- if task is None or not task.get("git_rev"):
1314
- continue
1315
- ok, err, _info = _merge_branch(str(_repo_of(profile, t.get("side")).get("workdir")
1316
- or settings.default_workdir()), task)
1317
- if not ok:
1318
- _set_claim(bid, state="merge_failed",
1319
- note="【%s】任务分支合并失败:%s(不转派不 resolve,留人工)"
1320
- % (SIDE_CN.get(t.get("side"), t.get("side")), err))
1321
- _notify("🐛❌ 禅道 Bug #%s 的%s修复代码合并失败:%s\n修复任务:%s"
1322
- % (bid, SIDE_CN.get(t.get("side"), ""), err, t.get("task_id") or "?"))
1323
- return
1409
+ if not cfg.get("auto_merge"):
1410
+ _set_claim(bid, state="done_manual",
1411
+ note="修复完成;auto_merge 已关,请人工合并落库后到禅道解决 bug")
1412
+ return
1413
+ for t in tasks:
1414
+ task = store.get_task(t.get("task_id") or "")
1415
+ if task is None or not task.get("git_rev"):
1416
+ continue
1417
+ ok, err, _info = _merge_branch(str(_repo_of(profile, t.get("side")).get("workdir")
1418
+ or settings.default_workdir()), task)
1419
+ if not ok:
1420
+ _set_claim(bid, state="merge_failed",
1421
+ note="【%s】任务分支合并失败:%s(不转派不 resolve,留人工)"
1422
+ % (SIDE_CN.get(t.get("side"), t.get("side")), err))
1423
+ _notify("🐛❌ 禅道 Bug #%s 的%s修复代码合并失败:%s\n修复任务:%s"
1424
+ % (bid, SIDE_CN.get(t.get("side"), ""), err, t.get("task_id") or "?"))
1425
+ return
1426
+ # 1.5) 落库闸:没基线的任务不走任务分支隔离,改动只躺在工作区——带着
1427
+ # 未提交的已跟踪改动点 resolve 是对禅道撒谎(#27697 案),拦下转人工。
1428
+ for t in tasks:
1429
+ task = store.get_task(t.get("task_id") or "")
1430
+ if task is None or task.get("git_rev"):
1431
+ continue
1432
+ dirty, detail = _uncommitted(str(_repo_of(profile, t.get("side")).get("workdir")
1433
+ or settings.default_workdir()))
1434
+ if dirty:
1435
+ _set_claim(bid, state="done_manual",
1436
+ note="修复完成但工作区有未提交改动(%s),已阻止自动 resolve"
1437
+ "——请人工提交后到禅道解决" % detail)
1438
+ _notify("🐛⚠️ 禅道 Bug #%s 修复完成但未提交落库,已阻止自动 resolve\n%s"
1439
+ % (bid, claim.get("title") or ""))
1440
+ return
1324
1441
  # 2) 需要转派的端 = 判定端 - 我方端(both 且我方只管一端时非空)
1325
1442
  verdict_sides = ([tri["side"]] if tri.get("side") in SIDES
1326
1443
  else list(SIDES) if tri.get("side") == "both" else [])
@@ -1413,6 +1530,25 @@ def _finish_failed(claim, cfg):
1413
1530
  claim.get("title") or "", "、".join(t.get("task_id") or "" for t in tasks)))
1414
1531
 
1415
1532
 
1533
+ def _refresh_claim_runs(claim):
1534
+ """把 claim.tasks 的 run_id 对齐到各任务最新一次运行,返回 (tasks, 是否换绑)。
1535
+
1536
+ 领单时钉住的 run 失败后,人工重试会换新 run——对账只认旧 run 会把已经
1537
+ 成功的修复判成失败(2026-09-22 #27697 误报失败评论案)。
1538
+ """
1539
+ fresh, changed = [], False
1540
+ for t in claim.get("tasks") or []:
1541
+ tid = str(t.get("task_id") or "")
1542
+ if tid:
1543
+ runs = store.task_runs(tid)
1544
+ rid = str((runs[0].get("id") if runs else "") or "")
1545
+ if rid and rid != t.get("run_id"):
1546
+ t = dict(t, run_id=rid)
1547
+ changed = True
1548
+ fresh.append(t)
1549
+ return fresh, changed
1550
+
1551
+
1416
1552
  def _reconcile(cfg):
1417
1553
  """对账:fixing 中的 claim 查各 run 终态并回写。单条异常只跳过该条。"""
1418
1554
  with _LOCK:
@@ -1420,7 +1556,10 @@ def _reconcile(cfg):
1420
1556
  for claim in fixing:
1421
1557
  bid = str(claim.get("bug_id"))
1422
1558
  try:
1423
- tasks = claim.get("tasks") or []
1559
+ tasks, moved = _refresh_claim_runs(claim)
1560
+ if moved:
1561
+ _set_claim(bid, tasks=tasks)
1562
+ claim = dict(claim, tasks=tasks)
1424
1563
  runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
1425
1564
  if any(r is None for r in runs.values()):
1426
1565
  _set_claim(bid, state="lost", note="运行记录不存在(可能被清理)")
@@ -1627,7 +1766,7 @@ def _poll(force=False):
1627
1766
  err = "扫描失败:%s" % e
1628
1767
  log.warning("zentao: %s", err)
1629
1768
  now = datetime.now()
1630
- delay = timedelta(hours=max(INTERVAL_MIN, int(cfg.get("interval_hours") or 2)))
1769
+ delay = timedelta(minutes=max(INTERVAL_MIN, int(cfg.get("interval_minutes") or INTERVAL_DEFAULT)))
1631
1770
  if err:
1632
1771
  delay = timedelta(minutes=RETRY_DELAY_MIN)
1633
1772
  with _LOCK:
@@ -1654,14 +1793,34 @@ def scan_now():
1654
1793
  return _poll(force=True)
1655
1794
 
1656
1795
 
1796
+ def _boot_reconcile():
1797
+ """服务重启后补一次对账:上个进程死亡窗口里的终态变化别等下个 tick
1798
+ (#27697 案:服务死窗 7 小时,失败转派被拖到重启后才发现)。"""
1799
+ try:
1800
+ _ensure_loaded()
1801
+ with _LOCK:
1802
+ cfg = _cfg()
1803
+ _reconcile(cfg)
1804
+ except Exception:
1805
+ log.exception("zentao: 启动补对账失败")
1806
+
1807
+
1657
1808
  def start():
1658
1809
  """服务启动接线:加载状态(含老配置迁移)。"""
1810
+ global _BOOT_TIMER
1659
1811
  n = load()
1660
1812
  with _LOCK:
1661
1813
  cfg = _STATE.get("config") or {}
1662
1814
  if cfg.get("poll_enabled") and not _STATE.get("next_scan"):
1663
1815
  _STATE["next_scan"] = time.strftime("%Y-%m-%d %H:%M:%S")
1664
1816
  _save_locked()
1817
+ pending = any(c.get("state") == "fixing" for c in _STATE["claims"].values())
1818
+ if pending:
1819
+ if _BOOT_TIMER is not None:
1820
+ _BOOT_TIMER.cancel()
1821
+ _BOOT_TIMER = threading.Timer(3.0, _boot_reconcile)
1822
+ _BOOT_TIMER.daemon = True
1823
+ _BOOT_TIMER.start()
1665
1824
  return n
1666
1825
 
1667
1826
 
@@ -1770,11 +1929,11 @@ def save_config(patch):
1770
1929
  v = _norm_profiles(v)
1771
1930
  if v is None:
1772
1931
  continue
1773
- elif k == "interval_hours":
1932
+ elif k == "interval_minutes":
1774
1933
  try:
1775
1934
  v = max(INTERVAL_MIN, min(INTERVAL_MAX, int(v)))
1776
1935
  except (TypeError, ValueError):
1777
- raise ValueError("interval_hours 必须是 %d-%d 的整数"
1936
+ raise ValueError("interval_minutes 必须是 %d-%d 的整数"
1778
1937
  % (INTERVAL_MIN, INTERVAL_MAX))
1779
1938
  elif k in ("auto_resolve", "auto_merge", "triage_ai", "poll_enabled"):
1780
1939
  v = bool(v)