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.
@@ -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
@@ -1164,8 +1171,14 @@ def _launch_fix(bug, profile, side, cfg):
1164
1171
  title = ("[禅道#%s][%s] %s" % (bid, SIDE_CN.get(side, side),
1165
1172
  str(bug.get("title") or "").strip())).strip()[:60]
1166
1173
  payload = {"type": "code", "title": title, "goal": _goal_text(bug, side), "workdir": wd}
1167
- if str(repo.get("git_rev") or "").strip():
1168
- 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
1169
1182
  if str(repo.get("verify_command") or "").strip():
1170
1183
  payload["verify_command"] = str(repo["verify_command"]).strip()
1171
1184
  task = None
@@ -1330,6 +1343,42 @@ def _merge_branch(workdir, task):
1330
1343
  return False, "合并异常:%s" % e, None
1331
1344
 
1332
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
+
1333
1382
  def _set_claim(bid, **patch):
1334
1383
  with _LOCK:
1335
1384
  c = _STATE["claims"].get(str(bid))
@@ -1357,20 +1406,38 @@ def _finish_ok(claim, cfg):
1357
1406
  tasks = claim.get("tasks") or []
1358
1407
  runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
1359
1408
  # 1) 逐任务合并(任一失败即停:不转派不 resolve,绝不带着没落库的修复转派)
1360
- if cfg.get("auto_merge"):
1361
- for t in tasks:
1362
- task = store.get_task(t.get("task_id") or "")
1363
- if task is None or not task.get("git_rev"):
1364
- continue
1365
- ok, err, _info = _merge_branch(str(_repo_of(profile, t.get("side")).get("workdir")
1366
- or settings.default_workdir()), task)
1367
- if not ok:
1368
- _set_claim(bid, state="merge_failed",
1369
- note="【%s】任务分支合并失败:%s(不转派不 resolve,留人工)"
1370
- % (SIDE_CN.get(t.get("side"), t.get("side")), err))
1371
- _notify("🐛❌ 禅道 Bug #%s 的%s修复代码合并失败:%s\n修复任务:%s"
1372
- % (bid, SIDE_CN.get(t.get("side"), ""), err, t.get("task_id") or "?"))
1373
- 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
1374
1441
  # 2) 需要转派的端 = 判定端 - 我方端(both 且我方只管一端时非空)
1375
1442
  verdict_sides = ([tri["side"]] if tri.get("side") in SIDES
1376
1443
  else list(SIDES) if tri.get("side") == "both" else [])
@@ -1463,6 +1530,25 @@ def _finish_failed(claim, cfg):
1463
1530
  claim.get("title") or "", "、".join(t.get("task_id") or "" for t in tasks)))
1464
1531
 
1465
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
+
1466
1552
  def _reconcile(cfg):
1467
1553
  """对账:fixing 中的 claim 查各 run 终态并回写。单条异常只跳过该条。"""
1468
1554
  with _LOCK:
@@ -1470,7 +1556,10 @@ def _reconcile(cfg):
1470
1556
  for claim in fixing:
1471
1557
  bid = str(claim.get("bug_id"))
1472
1558
  try:
1473
- 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)
1474
1563
  runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
1475
1564
  if any(r is None for r in runs.values()):
1476
1565
  _set_claim(bid, state="lost", note="运行记录不存在(可能被清理)")
@@ -1704,14 +1793,34 @@ def scan_now():
1704
1793
  return _poll(force=True)
1705
1794
 
1706
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
+
1707
1808
  def start():
1708
1809
  """服务启动接线:加载状态(含老配置迁移)。"""
1810
+ global _BOOT_TIMER
1709
1811
  n = load()
1710
1812
  with _LOCK:
1711
1813
  cfg = _STATE.get("config") or {}
1712
1814
  if cfg.get("poll_enabled") and not _STATE.get("next_scan"):
1713
1815
  _STATE["next_scan"] = time.strftime("%Y-%m-%d %H:%M:%S")
1714
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()
1715
1824
  return n
1716
1825
 
1717
1826
 
package/app/main.py CHANGED
@@ -64,6 +64,10 @@ def _utf8_bytes(data):
64
64
  PORT = 8765 # main() 启动时更新;/api/connect 组装扫码地址用
65
65
  _BOOT_TS = time.time() # 服务进程启动时间戳:/api/pet_state 下发,蜜蜂据此识别「服务换人了」并让位
66
66
 
67
+ # SSE 空闲心跳间隔(秒):长 CLI 步骤期间状态零变化,注释行心跳保持中间层
68
+ # 不掐空闲流、半死连接尽早显形(EventSource 不派发注释事件,纯链路层探活)
69
+ SSE_PING_S = 15.0
70
+
67
71
  # 写接口统一限制 JSON 请求体,避免误传文件或异常客户端把 worker 线程和
68
72
  # 内存拖垮。16 MiB 足够覆盖任务上下文、故事圣经和附件清单(附件本体走
69
73
  # 独立上传接口)。
@@ -2084,30 +2088,49 @@ class Handler(BaseHTTPRequestHandler):
2084
2088
  def _api_events(self):
2085
2089
  """SSE 事件驱动:等 store 状态版本变化才构建/推送,空闲连接几乎零开销
2086
2090
  (此前每连接每 0.8s 盲构建全量 payload,多端并发会把 detect 的慢 IO
2087
- 放大成服务假死)。每 ~2s 醒一次顺带检查控制权变化。"""
2091
+ 放大成服务假死)。每 ~2s 醒一次;空闲超过 SSE_PING_S 发注释行心跳。"""
2088
2092
  self.send_response(200)
2089
2093
  self.send_header("Content-Type", "text/event-stream; charset=utf-8")
2090
2094
  self.send_header("Cache-Control", "no-store")
2091
2095
  self.send_header("X-Accel-Buffering", "no") # 防 nginx/隧道缓冲 SSE
2092
2096
  self.end_headers()
2097
+ try:
2098
+ # 半死连接(对端消失/网络黑洞)的写会无限挂起钉死线程;给足量
2099
+ # 超时让它必然显形成异常 → 线程退出 → 客户端断流重连自愈
2100
+ self.connection.settimeout(300)
2101
+ except Exception:
2102
+ pass
2093
2103
  cid = self._client_id()
2094
2104
  ver = store.state_version()
2095
2105
  sent_ctrl = None
2096
- n = 0
2106
+ last_send = 0.0
2097
2107
  try:
2108
+ # 连接即推一次当前全量状态:客户端重连(服务重启/网络闪断)后
2109
+ # 不必等下一次状态变化才知道真实进度。长 CLI 步骤一跑十来分钟
2110
+ # 期间一次 bump 都没有,等推送就是等个寂寞(2026-09-22 详情
2111
+ # 「完成」侧栏「在跑」实案)。此前靠 sent_ctrl=None 首轮必不等
2112
+ # 的巧合工作,这里改成显式行为。
2113
+ payload = json.dumps(_state_payload(cid, ver), ensure_ascii=False)
2114
+ self.wfile.write(("data: " + payload + "\n\n").encode("utf-8"))
2115
+ self.wfile.flush()
2116
+ sent_ctrl = remote.control_view(cid)
2117
+ last_send = time.time()
2098
2118
  while True:
2099
2119
  ver = store.wait_state_change(ver, 2.0)
2100
2120
  ctrl = remote.control_view(cid)
2101
2121
  if ver == store.state_version() and ctrl == sent_ctrl:
2122
+ # 空闲心跳:真发出去的字节让中间层不掐空闲流,也让半死
2123
+ # 连接在下一次写时尽早报错(注释行,EventSource 不派发事件)
2124
+ if time.time() - last_send >= SSE_PING_S:
2125
+ self.wfile.write(b": ping\n\n")
2126
+ self.wfile.flush()
2127
+ last_send = time.time()
2102
2128
  continue # 超时醒来且无变化
2103
2129
  payload = json.dumps(_state_payload(cid, ver), ensure_ascii=False)
2104
2130
  self.wfile.write(("data: " + payload + "\n\n").encode("utf-8"))
2105
2131
  self.wfile.flush()
2106
2132
  sent_ctrl = ctrl
2107
- n += 1
2108
- if n % 20 == 0: # ~40s 一次注释行:探活兼防中间层断开空闲连接
2109
- self.wfile.write(b": ping\n\n")
2110
- self.wfile.flush()
2133
+ last_send = time.time()
2111
2134
  except Exception:
2112
2135
  pass # 客户端断开是常态,线程随进程退出
2113
2136