codebee 0.1.69 → 0.1.70

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +4 -3
  3. package/app/browser-final-1440.png +0 -0
  4. package/app/browser-final-390.png +0 -0
  5. package/app/browser-google-fallback.png +0 -0
  6. package/app/browser-google-test.png +0 -0
  7. package/app/browser-workbench-1440.png +0 -0
  8. package/app/browser-workbench-390.png +0 -0
  9. package/app/browser-workspace-google.png +0 -0
  10. package/app/browser-workspace-smoke.png +0 -0
  11. package/app/browser-workspace.png +0 -0
  12. package/app/core/audit.py +30 -0
  13. package/app/core/automation.py +107 -12
  14. package/app/core/benchstore.py +95 -0
  15. package/app/core/bookmeta.py +9 -4
  16. package/app/core/builtin_agent.py +472 -33
  17. package/app/core/catalog.py +7 -1
  18. package/app/core/dispatch.py +20 -3
  19. package/app/core/error_codes.py +11 -2
  20. package/app/core/evalbench.py +720 -0
  21. package/app/core/flows.py +228 -3
  22. package/app/core/jobs.py +22 -1
  23. package/app/core/manager.py +21 -0
  24. package/app/core/market_remote.py +10 -0
  25. package/app/core/mcp_client.py +287 -0
  26. package/app/core/modelhub.py +185 -2
  27. package/app/core/notify.py +118 -23
  28. package/app/core/novel_quality.py +189 -0
  29. package/app/core/paths.py +4 -0
  30. package/app/core/pipeline.py +426 -28
  31. package/app/core/planner.py +243 -22
  32. package/app/core/plugins.py +525 -0
  33. package/app/core/publish/browser.py +129 -13
  34. package/app/core/publish/fanqie.py +6 -0
  35. package/app/core/publish/flow.py +154 -5
  36. package/app/core/publish/flows-fanqie-calibrated.json +11 -7
  37. package/app/core/publish/ledger.py +29 -3
  38. package/app/core/publish/manager.py +134 -2
  39. package/app/core/publish/qimao.py +5 -0
  40. package/app/core/remotesync.py +121 -0
  41. package/app/core/runner.py +80 -5
  42. package/app/core/settings.py +39 -2
  43. package/app/core/settings_schema.py +8 -0
  44. package/app/core/store.py +30 -8
  45. package/app/core/usage.py +187 -14
  46. package/app/core/zentao.py +153 -4
  47. package/app/current-browser.png +0 -0
  48. package/app/main.py +279 -15
  49. package/app/mcp_server.py +175 -0
  50. package/app/pet.py +82 -21
  51. package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
  52. package/app/plugins/code-review-helper/skills/review.md +16 -0
  53. package/app/ui/app.js +1380 -73
  54. package/app/ui/i18n.js +248 -6
  55. package/app/ui/index.html +277 -13
  56. package/app/ui/style.css +786 -0
  57. package/package.json +2 -1
package/app/core/usage.py CHANGED
@@ -19,6 +19,26 @@ from . import paths
19
19
 
20
20
  LOCK = threading.RLock()
21
21
 
22
+ # ---------------------------------------------------------------- 用量版本(SSE 轻量事件)
23
+ # 台账每落一条记录就 bump 一次版本号。SSE 循环看到变化只推一个几十字节的
24
+ # usage 事件让前端刷新侧栏用量条,绝不为此构建/推送全量状态——运行中记账
25
+ # 是秒级频率,全量 payload 会被放大成推送风暴。版本号自带合并:两次读取
26
+ # 之间落多少条记录都只算一次变化。
27
+ _VER_CV = threading.Condition()
28
+ _USAGE_VER = 1
29
+
30
+
31
+ def bump_usage():
32
+ global _USAGE_VER
33
+ with _VER_CV:
34
+ _USAGE_VER += 1
35
+ _VER_CV.notify_all()
36
+
37
+
38
+ def usage_version():
39
+ return _USAGE_VER
40
+
41
+
22
42
  # 维度 → 显示名(聚合接口与前端共用)
23
43
  DIMENSIONS = {
24
44
  "tool": "工具(CLI)",
@@ -99,6 +119,18 @@ def record(source="", run_id="", task_id="", task_type="", role="", step=0,
99
119
  total = _parse_int(u.get("total")) or (inp + out + cach)
100
120
  rec.update({"input": inp, "output": out, "cached": cach,
101
121
  "reasoning": reas, "total": total})
122
+ # 金额闭环:调用方没给费用(CLI/直连/评测普遍如此)→ 按标定单价折算
123
+ # (¥/百万 tokens;手标优先,回落导入价表;未标价 = 不记钱,宁缺毋滥)。
124
+ # 列名 cost_usd 沿用历史,语义 = 费用(¥)。
125
+ if _parse_float(rec.get("cost_usd")) <= 0 and (inp or out) \
126
+ and rec.get("model") not in ("", "(默认)"):
127
+ try:
128
+ from . import modelhub
129
+ pr = modelhub.model_price(str(rec["model"]), provider=str(rec["provider"]))
130
+ if pr:
131
+ rec["cost_usd"] = round((pr["in"] * inp + pr["out"] * out) / 1e6, 4)
132
+ except Exception:
133
+ pass
102
134
  # 压缩省量注记(source=compaction 专用):上下文折叠净省的估算 token,
103
135
  # 与消耗并列成账——省了多少不再是黑箱。零值不落字段防老记录膨胀。
104
136
  saved = max(0, _parse_int(u.get("saved")))
@@ -120,13 +152,107 @@ def record(source="", run_id="", task_id="", task_type="", role="", step=0,
120
152
  _ROUTING_CACHE["data"].clear()
121
153
  _ROUTING_RECORDS_CACHE.clear()
122
154
  _HOURLY_CACHE["ts"] = 0.0
155
+ bump_usage() # 台账变了:SSE 推 usage 事件,侧栏用量条实时跟进
156
+ if _budget_caps_on():
157
+ # 花费预警(硬顶的事前半环):只在配了上限时起线程,绝不拖慢记账
158
+ threading.Thread(target=_budget_alert_check, daemon=True,
159
+ args=(rec["day"],)).start()
160
+ except Exception:
161
+ pass
162
+
163
+
164
+ def _budget_caps_on():
165
+ try:
166
+ from .settings_schema import get as ss_get, register_default_namespaces
167
+ register_default_namespaces()
168
+ return (float(ss_get("budget", "daily_cost_yuan") or 0) > 0
169
+ or float(ss_get("budget", "monthly_cost_yuan") or 0) > 0)
170
+ except Exception:
171
+ return False
172
+
173
+
174
+ _ALERT_PUSH = None # main.py 启动注入 notify.push_text——usage(L0) 不反向
175
+ # 依赖 notify(L1),静态图不成环(同 set_run_estimator 模式)
176
+
177
+
178
+ def set_alert_push(fn):
179
+ """注入花费预警的推送函数;未注入则预警静默跳过。"""
180
+ global _ALERT_PUSH
181
+ _ALERT_PUSH = fn if callable(fn) else None
182
+
183
+
184
+ def _budget_alert_check(day):
185
+ """花费预警推送(50%/80%,日/月各自去重一次):预警是硬顶的事前提醒,
186
+ 触发阈值状态落 data/budget_alerts.json(按天重置)。失败静默。"""
187
+ try:
188
+ from .settings_schema import get as ss_get, register_default_namespaces
189
+ register_default_namespaces()
190
+ dcap = float(ss_get("budget", "daily_cost_yuan") or 0)
191
+ mcap = float(ss_get("budget", "monthly_cost_yuan") or 0)
192
+ today, month = cost_snapshot()
193
+ marks = []
194
+ for scope, spent, cap in (("d", today, dcap), ("m", month, mcap)):
195
+ if cap <= 0 or spent <= 0:
196
+ continue
197
+ pct = spent / cap
198
+ for th in (0.8, 0.5):
199
+ if pct >= th:
200
+ marks.append((scope, int(th * 100), spent, cap))
201
+ break
202
+ if not marks:
203
+ return
204
+ sp = paths.DATA_DIR / "budget_alerts.json"
205
+ try:
206
+ st = json.loads(sp.read_text(encoding="utf-8"))
207
+ except Exception:
208
+ st = {}
209
+ if st.get("day") != day or not isinstance(st.get("done"), list):
210
+ st = {"day": day, "done": []}
211
+ done = st["done"]
212
+ new = [m for m in marks if ("%s%d" % (m[0], m[1])) not in done]
213
+ if not new:
214
+ return
215
+ st["done"] = done + ["%s%d" % (m[0], m[1]) for m in new]
216
+ sp.parent.mkdir(parents=True, exist_ok=True)
217
+ tmp = sp.with_suffix(".tmp")
218
+ tmp.write_text(json.dumps(st, ensure_ascii=False), encoding="utf-8")
219
+ tmp.replace(sp)
220
+ if not _ALERT_PUSH:
221
+ return
222
+ lines = ["💸 CodeBee 花费预警"]
223
+ for scope, pct, spent, cap in new:
224
+ lines.append("%s花费 ¥%.2f,已达%s上限 ¥%.2f 的 %d%%" % (
225
+ "今日" if scope == "d" else "本月", spent,
226
+ "每日" if scope == "d" else "每月", cap, pct))
227
+ lines.append("(达 100% 后新步骤将停止,可在设置→编排设置→预算调整)")
228
+ _ALERT_PUSH("\n".join(lines))
123
229
  except Exception:
124
230
  pass
125
231
 
126
232
 
233
+ EXPORT_COLUMNS = ("ts", "day", "source", "task_type", "role", "agent", "model",
234
+ "provider", "ok", "input", "output", "cached", "reasoning",
235
+ "total", "cost_usd")
236
+
237
+
238
+ def export_csv(days=30, max_rows=10000):
239
+ """台账明细导出 CSV(UTF-8 BOM,Excel 直开;行数封顶防炸)。返回 bytes。"""
240
+ import csv as _csv
241
+ import io as _io
242
+ buf = _io.StringIO()
243
+ writer = _csv.writer(buf)
244
+ writer.writerow(EXPORT_COLUMNS)
245
+ n = 0
246
+ for r in _iter_records(max(0, int(days or 0))):
247
+ writer.writerow([r.get(c, "") for c in EXPORT_COLUMNS])
248
+ n += 1
249
+ if n >= max_rows:
250
+ break
251
+ return b"\xef\xbb\xbf" + buf.getvalue().encode("utf-8")
252
+
253
+
127
254
  def backfill_from_runs():
128
255
  """把历史 run.json 里已记录的 token 用量回填进台账(启动时调用,幂等)。
129
-
130
256
  埋点是后加的:此前的运行只在 run.json 的步骤里存了 tokens/cost_usd 总量,
131
257
  没有输入/输出/缓存细分,所以回填记录 input/output/cached 记 0、只有 total,
132
258
  并以 source="backfill" 标记便于区分。已回填的 (run_id, 步骤号) 会跳过,
@@ -250,6 +376,15 @@ def agent_tokens_recent(agent, hours=1):
250
376
  return 0
251
377
 
252
378
 
379
+ # 文件级解析缓存:台账 append-only(写入只增不改前缀),按 (mtime_ns, size)
380
+ # 命中就复用已解析记录。用量条实时化后 summary 会被按秒级反复调用,缓存让
381
+ # 每次只重解析当天新落的小文件,历史月份不再逐次全量 json.loads。缓存的是
382
+ # 未过滤的全量记录,days 过滤在读出后做,同一份缓存可服务不同取数窗口。
383
+ # 读+stat 全程持 LOCK(RLock 可重入,summary 外层已持也不死锁),杜绝
384
+ # 「读到旧内容、却按新 mtime 入缓存」的竞态把新记录吞到下次追加才可见。
385
+ _FILE_CACHE = {}
386
+
387
+
253
388
  def _iter_records(days):
254
389
  """按时间范围读取台账(days=0 表示全部)。返回按写入顺序的记录列表。"""
255
390
  out = []
@@ -267,24 +402,62 @@ def _iter_records(days):
267
402
  if since_month and p.stem.rsplit("-", 1)[-1] < since_month:
268
403
  continue
269
404
  try:
270
- for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
271
- line = line.strip()
272
- if not line.startswith("{"):
273
- continue
274
- try:
275
- r = json.loads(line)
276
- except Exception:
277
- continue
278
- if not isinstance(r, dict):
279
- continue
280
- if since_day and str(r.get("day", "")) < since_day:
281
- continue
282
- out.append(r)
405
+ with LOCK:
406
+ st = p.stat()
407
+ key = str(p)
408
+ hit = _FILE_CACHE.get(key)
409
+ if hit and hit[0] == (st.st_mtime_ns, st.st_size):
410
+ recs = hit[1]
411
+ else:
412
+ recs = []
413
+ for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
414
+ line = line.strip()
415
+ if not line.startswith("{"):
416
+ continue
417
+ try:
418
+ r = json.loads(line)
419
+ except Exception:
420
+ continue
421
+ if not isinstance(r, dict):
422
+ continue
423
+ recs.append(r)
424
+ _FILE_CACHE[key] = ((st.st_mtime_ns, st.st_size), recs)
283
425
  except Exception:
284
426
  continue
427
+ for r in recs:
428
+ if since_day and str(r.get("day", "")) < since_day:
429
+ continue
430
+ out.append(r)
285
431
  return out
286
432
 
287
433
 
434
+ def cost_snapshot():
435
+ """(今日实付, 当月实付) 美元快照——花费硬顶闸(pipeline 预算检查)用。
436
+
437
+ 复用 _iter_records 的按文件缓存;窗口取 31 天恒覆盖整个当月,
438
+ 单月文件量级下每次调用开销可忽略。任何异常返回 (0.0, 0.0)(闸放行,
439
+ 统计层故障不能反过来把业务锁死)。
440
+ """
441
+ try:
442
+ import datetime
443
+ today = datetime.date.today().isoformat()
444
+ month = today[:7]
445
+ t = m = 0.0
446
+ for r in _iter_records(31):
447
+ c = _parse_float(r.get("cost_usd"))
448
+ if c <= 0:
449
+ continue
450
+ d = str(r.get("day") or "")
451
+ if d == today:
452
+ t += c
453
+ m += c
454
+ elif d.startswith(month):
455
+ m += c
456
+ return round(t, 4), round(m, 4)
457
+ except Exception:
458
+ return 0.0, 0.0
459
+
460
+
288
461
  def _iter_quality_records(days):
289
462
  """读取独立质量台账;不混入用量统计的调用数、token 和成本。"""
290
463
  out = []
@@ -69,6 +69,11 @@ from pathlib import Path
69
69
 
70
70
  from . import jobs, operations, paths, settings, store, tlsctx
71
71
 
72
+ try:
73
+ import chinese_calendar as _hcal # 法定节假日+调休数据(chinesecalendar)
74
+ except ImportError: # 未装时跳过节假日功能不可用,开启即保存报错
75
+ _hcal = None
76
+
72
77
  log = logging.getLogger(__name__)
73
78
 
74
79
  _LOCK = threading.RLock()
@@ -115,6 +120,7 @@ _CFG_DEFAULTS = {
115
120
  "triage_ai": True, # 模块路由未命中时用 AI 兜底排查
116
121
  "poll_enabled": False,
117
122
  "interval_minutes": INTERVAL_DEFAULT,
123
+ "skip_holidays": False, # 法定休假日顺延定时扫描(对账不受影响)
118
124
  }
119
125
 
120
126
  _REPO_DEFAULTS = {"workdir": "", "git_rev": "", "verify_command": ""}
@@ -132,7 +138,7 @@ _PROFILE_DEFAULTS = {
132
138
 
133
139
  _UPDATABLE = ("base_url", "account", "password", "product_profiles",
134
140
  "auto_resolve", "auto_merge", "triage_ai",
135
- "poll_enabled", "interval_minutes")
141
+ "poll_enabled", "interval_minutes", "skip_holidays")
136
142
 
137
143
 
138
144
  # ---------------------------------------------------------------- 出网边界(SSRF)
@@ -206,6 +212,10 @@ def _normalize_claim(d):
206
212
  c["triage"] = {"side": "", "reason": "", "by": "", "account": ""}
207
213
  c.setdefault("note", "")
208
214
  c.setdefault("attempts", 0)
215
+ c.setdefault("archived", False) # 归档三件套:老数据无字段照常跑
216
+ c.setdefault("archived_by", "") # manual=用户手点 / auto=扫描对账自动
217
+ c.setdefault("archived_at", "")
218
+ c.setdefault("archive_reason", "")
209
219
  return c
210
220
 
211
221
 
@@ -1810,6 +1820,93 @@ def _set_claim(bid, **patch):
1810
1820
  _save_locked()
1811
1821
 
1812
1822
 
1823
+ def archive_claim(bid, archived, reason=""):
1824
+ """手动归档/取消归档一条修复记录。返回更新后的 claim;不存在抛 ZenError。"""
1825
+ bid = str(bid)
1826
+ _ensure_loaded()
1827
+ with _LOCK:
1828
+ c = _STATE["claims"].get(bid)
1829
+ if c is None:
1830
+ raise ZenError("修复记录 #%s 不存在" % bid)
1831
+ if archived:
1832
+ c.update({"archived": True, "archived_by": "manual",
1833
+ "archived_at": time.strftime("%Y-%m-%d %H:%M:%S"),
1834
+ "archive_reason": str(reason or "").strip() or "手动归档"})
1835
+ else:
1836
+ c.update({"archived": False, "archived_by": "",
1837
+ "archived_at": "", "archive_reason": ""})
1838
+ c["at"] = time.strftime("%Y-%m-%d %H:%M:%S")
1839
+ _save_locked()
1840
+ return dict(c)
1841
+
1842
+
1843
+ def _bug_status(cfg, bid):
1844
+ """单查 bug 当前状态。失败抛异常(对账方决定本轮跳过,绝不盲归档)。"""
1845
+ d = _call("GET", "/bugs/%s" % bid, cfg=cfg)
1846
+ return str(d.get("status") or "")
1847
+
1848
+
1849
+ def _archive_sweep(cfg, profile, by_id):
1850
+ """归档对账:本产品激活列表拉取成功后跑一遍(list_bugs 抛错到不了这里,
1851
+ 网络抖动不会 mass-archive)。
1852
+
1853
+ - 记录不在激活列表 → 单查确认:resolved/closed 才自动归档;仍激活
1854
+ (多半已改派给别人)只备注一次,不归档;单查失败本轮跳过。
1855
+ - 已自动归档的记录 bug 重新激活且仍可认领 → 取消归档重走排查
1856
+ (手动归档是用户的明确决定,永不自动取消)。
1857
+ """
1858
+ pid = str(profile.get("product") or "")
1859
+ with _LOCK:
1860
+ mine = [dict(c) for c in _STATE["claims"].values()
1861
+ if str(c.get("product") or "") == pid]
1862
+ for c in mine:
1863
+ bid = str(c.get("bug_id") or "")
1864
+ if not bid:
1865
+ continue
1866
+ if bid in by_id:
1867
+ if c.get("archived") and c.get("archived_by") == "auto" \
1868
+ and _claimable(by_id[bid], profile):
1869
+ _set_claim(bid, archived=False, archived_by="",
1870
+ archived_at="", archive_reason="",
1871
+ note="禅道侧重新激活,重新排查")
1872
+ try:
1873
+ _route_one(by_id[bid], profile, cfg)
1874
+ except Exception as e:
1875
+ log.warning("zentao: bug %s 重新激活重排查失败:%s", bid, e)
1876
+ _set_claim(bid, state="need_manual",
1877
+ note="重新激活重排查失败:%s" % e)
1878
+ continue
1879
+ if c.get("archived"):
1880
+ continue
1881
+ try:
1882
+ status = _bug_status(cfg, bid)
1883
+ except Exception:
1884
+ log.warning("zentao: 归档对账单查 bug %s 失败,本轮跳过", bid)
1885
+ continue
1886
+ if status in ("resolved", "closed"):
1887
+ with _LOCK:
1888
+ cc = _STATE["claims"].get(bid)
1889
+ if cc is not None and not cc.get("archived") \
1890
+ and cc.get("state") != "fixing":
1891
+ cc.update({"archived": True, "archived_by": "auto",
1892
+ "archived_at": time.strftime("%Y-%m-%d %H:%M:%S"),
1893
+ "archive_reason": "禅道侧%s,自动归档"
1894
+ % ("已解决" if status == "resolved" else "已关闭"),
1895
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")})
1896
+ _save_locked()
1897
+ else:
1898
+ # 仍激活但不在我的列表(改派/过滤条件变化):备注一次不归档;
1899
+ # fixing 中的只标注不归档——流程收口交给对账,这里不抢状态
1900
+ note = "bug 仍激活但不在本档案扫描列表(可能已改派),不归档"
1901
+ with _LOCK:
1902
+ cc = _STATE["claims"].get(bid)
1903
+ if cc is not None and note not in str(cc.get("note") or ""):
1904
+ cc["note"] = (str(cc.get("note") or "") + ";" + note) \
1905
+ if cc.get("note") else note
1906
+ cc["at"] = time.strftime("%Y-%m-%d %H:%M:%S")
1907
+ _save_locked()
1908
+
1909
+
1813
1910
  def _route_account(profile, tri, side):
1814
1911
  """转派/升级目标:模块路由 account > 端负责人。"""
1815
1912
  if tri and tri.get("account"):
@@ -2047,6 +2144,8 @@ def _route_one(bug, profile, cfg, notify=True):
2047
2144
  "title": str(bug.get("title") or "")[:120],
2048
2145
  "triage": tri, "tasks": [], "state": "fixing", "note": "",
2049
2146
  "attempts": 0, "claimed_at": time.strftime("%Y-%m-%d %H:%M:%S"),
2147
+ # 归档字段就地归零:reopen 重走排查覆盖老 claim 时不能残留 True
2148
+ "archived": False, "archived_by": "", "archived_at": "", "archive_reason": "",
2050
2149
  }
2051
2150
  opened = _acct(bug.get("openedBy"))
2052
2151
 
@@ -2160,7 +2259,8 @@ def _route_one(bug, profile, cfg, notify=True):
2160
2259
 
2161
2260
 
2162
2261
  def _scan(cfg):
2163
- """按产品档案逐个拉 bug:认领新 bug + 重试 need_manual 的存量。返回认领数。"""
2262
+ """按产品档案逐个拉 bug:认领新 bug + 重试 need_manual 的存量 + 归档对账。
2263
+ 返回认领数。"""
2164
2264
  claimed = 0
2165
2265
  profiles = _profiles(cfg)
2166
2266
  if not profiles:
@@ -2171,7 +2271,8 @@ def _scan(cfg):
2171
2271
  with _LOCK:
2172
2272
  seen = set(_STATE["claims"].keys())
2173
2273
  retry_ids = [k for k, c in _STATE["claims"].items()
2174
- if c.get("state") == "need_manual" and k in by_id
2274
+ if c.get("state") == "need_manual" and not c.get("archived")
2275
+ and k in by_id
2175
2276
  and str(by_id[k].get("status") or "") == "active"]
2176
2277
  for bug in bugs:
2177
2278
  bid = str(bug.get("id") or "")
@@ -2185,9 +2286,42 @@ def _scan(cfg):
2185
2286
  if _STATE["claims"].get(bid, {}).get("state") != "need_manual":
2186
2287
  continue
2187
2288
  _route_one(by_id[bid], profile, cfg, notify=False)
2289
+ # 归档对账:走到这里说明本产品列表拉取成功,消失判定才可信
2290
+ try:
2291
+ _archive_sweep(cfg, profile, by_id)
2292
+ except Exception:
2293
+ log.exception("zentao: 产品 %s 归档对账异常,跳过", profile.get("product"))
2188
2294
  return claimed
2189
2295
 
2190
2296
 
2297
+ def _now():
2298
+ """当前时刻(独立成函数供测试注入时钟)。"""
2299
+ return datetime.now()
2300
+
2301
+
2302
+ def _holiday_gap(now):
2303
+ """今天是法定休假日 → 返回下一个工作日同时刻(作顺延后的 next_scan);
2304
+ 今天是工作日(含调休补班的周末)或判定不了(库缺失/年份超出已知范围)
2305
+ → None 照常扫描。只拦「扫描认领」,对账不受影响。"""
2306
+ if _hcal is None:
2307
+ return None
2308
+ d = now.date()
2309
+ try:
2310
+ if _hcal.is_workday(d):
2311
+ return None
2312
+ except (NotImplementedError, ValueError, KeyError):
2313
+ return None
2314
+ d += timedelta(days=1)
2315
+ for _ in range(366):
2316
+ try:
2317
+ if _hcal.is_workday(d):
2318
+ return datetime.combine(d, now.time())
2319
+ except (NotImplementedError, ValueError, KeyError):
2320
+ break
2321
+ d += timedelta(days=1)
2322
+ return None # 范围不足:按次日顺延兜底,到时再判一次
2323
+
2324
+
2191
2325
  def _poll(force=False):
2192
2326
  """执行一次扫描;同一进程内只允许一个扫描实例。"""
2193
2327
  if not _SCAN_LOCK.acquire(blocking=False):
@@ -2222,6 +2356,17 @@ def _poll_unlocked(force=False):
2222
2356
  "error": "", "skipped": "not_due"}
2223
2357
  except ValueError:
2224
2358
  pass
2359
+ # 到点但今天是法定休假日:不认领新 Bug,next_scan 顺延到下一个工作日
2360
+ # 同时刻(调休补班的周末照常扫;手动「立即扫描」不受此闸约束)
2361
+ if cfg.get("skip_holidays"):
2362
+ gap = _holiday_gap(_now())
2363
+ if gap is not None:
2364
+ with _LOCK:
2365
+ _STATE["next_scan"] = gap.strftime("%Y-%m-%d %H:%M:%S")
2366
+ _save_locked()
2367
+ log.info("zentao: 法定休假日,扫描顺延至 %s", _STATE["next_scan"])
2368
+ return {"ok": True, "claimed": 0, "reconciled": True,
2369
+ "error": "", "skipped": "holiday"}
2225
2370
  err = ""
2226
2371
  try:
2227
2372
  out["claimed"] = _scan(cfg)
@@ -2400,8 +2545,12 @@ def save_config(patch):
2400
2545
  except (TypeError, ValueError):
2401
2546
  raise ValueError("interval_minutes 必须是 %d-%d 的整数"
2402
2547
  % (INTERVAL_MIN, INTERVAL_MAX))
2403
- elif k in ("auto_resolve", "auto_merge", "triage_ai", "poll_enabled"):
2548
+ elif k in ("auto_resolve", "auto_merge", "triage_ai", "poll_enabled",
2549
+ "skip_holidays"):
2404
2550
  v = bool(v)
2551
+ if k == "skip_holidays" and v and _hcal is None:
2552
+ raise ValueError("跳过法定节假日需要先安装 chinesecalendar 库"
2553
+ "(pip install chinesecalendar)")
2405
2554
  cfg[k] = v
2406
2555
  _STATE["config"] = cfg
2407
2556
  if cfg.get("poll_enabled"):
Binary file