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.
- package/CHANGELOG.md +31 -0
- package/README.md +4 -150
- package/app/core/aiflavor.py +14 -0
- package/app/core/builtin_agent.py +567 -74
- package/app/core/jobs.py +170 -42
- package/app/core/knowledge.py +432 -405
- package/app/core/manager.py +100 -10
- package/app/core/market.py +27 -1
- package/app/core/modelhub.py +53 -5
- package/app/core/pipeline.py +193 -77
- package/app/core/planner.py +14 -5
- package/app/core/preview.py +293 -0
- package/app/core/router.py +189 -104
- package/app/core/runner.py +370 -144
- package/app/core/settings.py +11 -4
- package/app/core/skills.py +36 -12
- package/app/core/store.py +74 -2
- package/app/core/zentao.py +189 -30
- package/app/main.py +95 -7
- package/app/ui/app.js +13256 -12876
- package/app/ui/i18n.js +97 -34
- package/app/ui/index.html +55 -12
- package/app/ui/style.css +217 -26
- package/package.json +1 -1
package/app/core/settings.py
CHANGED
|
@@ -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
|
-
# 并发保护上限
|
|
33
|
-
#
|
|
34
|
-
|
|
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"))
|
package/app/core/skills.py
CHANGED
|
@@ -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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
# ---------------------------------------------------------------- 运行中指挥(消息信箱)
|
package/app/core/zentao.py
CHANGED
|
@@ -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 模式),内部按
|
|
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 =
|
|
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
|
-
"
|
|
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", "
|
|
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
|
-
|
|
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
|
-
|
|
728
|
-
|
|
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
|
-
|
|
851
|
-
|
|
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
|
-
|
|
1118
|
-
|
|
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
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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
|
|
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(
|
|
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 == "
|
|
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("
|
|
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)
|