codebee 0.1.54 → 0.1.56

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/app/core/store.py CHANGED
@@ -28,7 +28,11 @@ _RUNS = {}
28
28
  # 长跑也不会无限增长(finish_step 里调 clear_stream_state)。
29
29
  _STREAM_LOCK = threading.Lock()
30
30
  _STREAM_TS = {}
31
- LIVE_TEXT_MAX = 20000 # 单步骤实时思考/正文的保留上限(与 builtin_agent 同口径)
31
+ LIVE_TEXT_MAX = 20000 # 单步骤实时思考/正文的保留上限(与 builtin_agent 同口径)
32
+
33
+ DEFAULT_TASK_TIMEOUT_S = 3600
34
+ MIN_TASK_TIMEOUT_S = 1
35
+ MAX_TASK_TIMEOUT_S = 7 * 24 * 3600
32
36
 
33
37
  # ---------------------------------------------------------------- 状态版本(SSE 事件驱动)
34
38
  # 任何落盘写都算状态变化:SSE 连接等版本号变化才构建/推送全量状态,
@@ -57,12 +61,38 @@ def wait_state_change(last_ver, timeout):
57
61
  return _STATE_VER
58
62
 
59
63
 
60
- def _new_id(prefix):
61
- return "%s-%s-%04d" % (prefix, time.strftime("%Y%m%d-%H%M%S"), secrets.randbelow(10000))
62
-
63
-
64
- def _safe_name(s):
65
- return re.sub(r"[^0-9A-Za-z_-]+", "-", str(s))[:40].strip("-") or "x"
64
+ def _new_id(prefix):
65
+ stamp = time.strftime("%Y%m%d-%H%M%S")
66
+ # 10k random suffixes are not unique under a burst of task/run creation.
67
+ # Reserve against both indexes and disk so a collision cannot silently
68
+ # overwrite a queued run and make one of several submissions disappear.
69
+ with LOCK:
70
+ for _ in range(100):
71
+ candidate = "%s-%s-%04d" % (prefix, stamp, secrets.randbelow(10000))
72
+ if candidate in _TASKS or candidate in _RUNS:
73
+ continue
74
+ if (paths.TASKS_DIR / (candidate + ".json")).exists():
75
+ continue
76
+ if (paths.RUNS_DIR / candidate).exists():
77
+ continue
78
+ return candidate
79
+ raise RuntimeError("无法生成唯一记录 ID")
80
+
81
+
82
+ def _safe_name(s):
83
+ return re.sub(r"[^0-9A-Za-z_-]+", "-", str(s))[:40].strip("-") or "x"
84
+
85
+
86
+ def _task_timeout_s(value=None):
87
+ """Resolve one bounded wall-clock budget for a task."""
88
+ raw = value
89
+ if raw in (None, ""):
90
+ raw = os.environ.get("TUTTI_TASK_TIMEOUT_S", DEFAULT_TASK_TIMEOUT_S)
91
+ try:
92
+ seconds = int(float(raw))
93
+ except (TypeError, ValueError):
94
+ seconds = DEFAULT_TASK_TIMEOUT_S
95
+ return max(MIN_TASK_TIMEOUT_S, min(MAX_TASK_TIMEOUT_S, seconds))
66
96
 
67
97
 
68
98
  # ---------------------------------------------------------------- 任务
@@ -128,7 +158,8 @@ def create_task(payload):
128
158
  "status": "created",
129
159
  }
130
160
  thinking = str(payload.get("thinking") or "standard").strip().lower()
131
- task["thinking"] = thinking if thinking in ("auto", "low", "standard", "high") else "auto"
161
+ task["thinking"] = thinking if thinking in ("auto", "low", "standard", "high") else "auto"
162
+ task["timeout_s"] = _task_timeout_s(payload.get("timeout_s"))
132
163
  if flow["engine"] == "direct":
133
164
  task["direct_provider_id"] = _text(payload.get("direct_provider_id"),
134
165
  "direct_provider_id")[:80]
@@ -515,7 +546,7 @@ def load_all():
515
546
  _save_json(paths.TASKS_DIR / (t["id"] + ".json"), t)
516
547
  continue
517
548
  latest = max(runs, key=lambda r: r["id"])
518
- if latest.get("status") in ("done", "failed", "cancelled"):
549
+ if latest.get("status") in ("done", "failed", "cancelled", "timeout"):
519
550
  t["status"] = latest["status"]
520
551
  _save_json(paths.TASKS_DIR / (t["id"] + ".json"), t)
521
552
 
@@ -531,7 +562,7 @@ def set_run_estimator(estimator):
531
562
  _RUN_ESTIMATOR = estimator if callable(estimator) else None
532
563
 
533
564
 
534
- def create_run(kind, title, task_id=None, entry_id=None, op=None):
565
+ def create_run(kind, title, task_id=None, entry_id=None, op=None):
535
566
  run = {
536
567
  "id": _new_id("r" if kind == "orchestration" else "m"),
537
568
  "kind": kind, # orchestration | mgmt
@@ -539,12 +570,14 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
539
570
  "task_id": task_id, "entry_id": entry_id, "op": op,
540
571
  "status": "queued", "steps": [], "messages": [],
541
572
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
542
- "started_at": None, "ended_at": None,
573
+ "started_at": None, "ended_at": None,
574
+ "deadline_at": None,
543
575
  "cost_usd": 0.0, "tokens": 0, "error": "",
544
576
  "verdict": None, "summary": "",
545
577
  }
546
- if kind == "orchestration" and task_id:
547
- task = get_task(task_id) or {}
578
+ if kind == "orchestration" and task_id:
579
+ task = get_task(task_id) or {}
580
+ run["deadline_at"] = time.time() + _task_timeout_s(task.get("timeout_s"))
548
581
  try:
549
582
  eta = _RUN_ESTIMATOR(
550
583
  task_type=task.get("type") or "", mode=task.get("mode") or "auto",
@@ -729,25 +762,27 @@ def update_run(run_id, expected_status=None, **fields):
729
762
  # 这套在落终态瞬间即时收口——打磨组长被取消/异常打断时 UI 不闪
730
763
  # 假「工作中」)。run 终态后不可能还有步骤在真跑。
731
764
  st = fields.get("status")
732
- if st in ("done", "failed", "cancelled"):
733
- end_s = time.strftime("%H:%M:%S")
734
- for s in run["steps"]:
735
- if s.get("status") in ("queued", "running"):
736
- s["status"] = "cancelled"
737
- s["ended_at"] = s.get("ended_at") or end_s
738
- if not s.get("summary"):
739
- s["summary"] = "步骤未正常收尾(终态自动恢复)"
765
+ if st in ("done", "failed", "cancelled", "timeout"):
766
+ end_s = time.strftime("%H:%M:%S")
767
+ for s in run["steps"]:
768
+ if s.get("status") in ("queued", "running"):
769
+ s["status"] = "timeout" if st == "timeout" else "cancelled"
770
+ s["ended_at"] = s.get("ended_at") or end_s
771
+ if not s.get("summary"):
772
+ s["summary"] = ("步骤未正常收尾(任务总时限已到)"
773
+ if st == "timeout" else
774
+ "步骤未正常收尾(终态自动恢复)")
740
775
  _save_json(paths.RUNS_DIR / run_id / "run.json", run)
741
776
  # 状态回填:起跑与结束都同步任务状态。只回填终态的话,run 在跑、
742
777
  # 任务永远显示「排队中」(2026-09-18 实案:「# 重写·续」run 已 running
743
778
  # 写了一小时,任务卡 queued,用户以为卡死连点重试)。
744
- if st in ("running", "done", "failed", "cancelled"):
779
+ if st in ("running", "done", "failed", "cancelled", "timeout"):
745
780
  tid = run.get("task_id")
746
781
  task = _TASKS.get(tid) if tid else None
747
782
  if task:
748
783
  task["status"] = st
749
784
  _save_json(paths.TASKS_DIR / (tid + ".json"), task)
750
- if st in ("done", "failed", "cancelled"):
785
+ if st in ("done", "failed", "cancelled", "timeout"):
751
786
  # run_end 钩子(2026-09-22):终态即触发,后台跑副作用型脚本
752
787
  # (回写知识库等);失败/超时静默,绝不拖慢收尾路径
753
788
  try:
@@ -838,15 +873,17 @@ def recover_orphaned_runs():
838
873
  step_now = time.strftime("%H:%M:%S")
839
874
  with LOCK:
840
875
  for r in _RUNS.values():
841
- if r.get("status") not in ("done", "failed", "cancelled"):
842
- continue
876
+ if r.get("status") not in ("done", "failed", "cancelled", "timeout"):
877
+ continue
843
878
  changed = False
844
879
  for s in r.get("steps") or []:
845
880
  if s.get("status") in ("queued", "running"):
846
- s["status"] = "cancelled"
881
+ s["status"] = "timeout" if r.get("status") == "timeout" else "cancelled"
847
882
  s["ended_at"] = s.get("ended_at") or step_now
848
883
  if not s.get("summary"):
849
- s["summary"] = "步骤未正常收尾(取消/中断自动恢复)"
884
+ s["summary"] = ("步骤未正常收尾(任务总时限已到)"
885
+ if r.get("status") == "timeout" else
886
+ "步骤未正常收尾(取消/中断自动恢复)")
850
887
  changed = True
851
888
  if changed:
852
889
  _save_json(paths.RUNS_DIR / r["id"] / "run.json", r)
@@ -66,6 +66,7 @@ from . import jobs, paths, settings, store, tlsctx
66
66
  log = logging.getLogger(__name__)
67
67
 
68
68
  _LOCK = threading.RLock()
69
+ _SCAN_LOCK = threading.Lock() # 手动/定时扫描单飞,避免重复拉取和重复认领
69
70
  _FILE = paths.DATA_DIR / "zentao.json"
70
71
  _STATE = {
71
72
  "config": {}, # 持久配置(_CFG_DEFAULTS)
@@ -868,12 +869,20 @@ def _claimable(bug, profile):
868
869
  return True
869
870
 
870
871
 
871
- def list_bugs(cfg, product_id):
872
- """拉一个产品下的 bug(分页,总量封顶 MAX_BUGS)。返回原始 bug dict 列表。"""
872
+ def list_bugs(cfg, product_id, profile=None):
873
+ """拉一个产品下的激活 bug(分页,总量封顶 MAX_BUGS)。
874
+
875
+ REST v1 支持时把状态/指派人过滤下推给禅道,减少无关 bug 的分页传输;
876
+ 本地的 _claimable 仍保留,兼容老接口或禅道忽略未知查询参数的情况。
877
+ """
873
878
  out = []
874
879
  for page in range(1, 6):
875
- d = _call("GET", "/products/%s/bugs?page=%d&limit=%d" % (product_id, page, PAGE_LIMIT),
876
- cfg=cfg)
880
+ query = {"page": page, "limit": PAGE_LIMIT, "status": "active"}
881
+ assigned = str((profile or {}).get("assigned_to") or "").strip()
882
+ if assigned:
883
+ query["assignedTo"] = assigned
884
+ d = _call("GET", "/products/%s/bugs?%s" % (
885
+ product_id, urllib.parse.urlencode(query)), cfg=cfg)
877
886
  bugs = d.get("bugs") if isinstance(d, dict) else None
878
887
  if not isinstance(bugs, list):
879
888
  raise ZenError("禅道 bug 列表响应形状不对(预期 bugs 数组)")
@@ -1038,7 +1047,7 @@ def test_connection(base_url=None, account=None, password=None):
1038
1047
  try:
1039
1048
  profiles = _profiles(c)
1040
1049
  if profiles:
1041
- bugs = list_bugs(probe, profiles[0]["product"])
1050
+ bugs = list_bugs(probe, profiles[0]["product"], profiles[0])
1042
1051
  return True, "连接成功(%s),产品 %s 可访问(当前 %d 条 bug 在列表里)" % (
1043
1052
  tag, profiles[0]["product"], len(bugs))
1044
1053
  except ZenError as e:
@@ -1712,7 +1721,7 @@ def _scan(cfg):
1712
1721
  if not profiles:
1713
1722
  raise ZenError("未配置产品档案——请先在设置页添加产品并配置仓库")
1714
1723
  for profile in profiles:
1715
- bugs = list_bugs(cfg, profile["product"])
1724
+ bugs = list_bugs(cfg, profile["product"], profile)
1716
1725
  by_id = {str(b.get("id") or ""): b for b in bugs}
1717
1726
  with _LOCK:
1718
1727
  seen = set(_STATE["claims"].keys())
@@ -1735,6 +1744,17 @@ def _scan(cfg):
1735
1744
 
1736
1745
 
1737
1746
  def _poll(force=False):
1747
+ """执行一次扫描;同一进程内只允许一个扫描实例。"""
1748
+ if not _SCAN_LOCK.acquire(blocking=False):
1749
+ return {"ok": False, "claimed": 0, "reconciled": False,
1750
+ "error": "扫描正在进行,请稍候", "skipped": "in_progress"}
1751
+ try:
1752
+ return _poll_unlocked(force=force)
1753
+ finally:
1754
+ _SCAN_LOCK.release()
1755
+
1756
+
1757
+ def _poll_unlocked(force=False):
1738
1758
  """一次完整轮询:对账回写 + (到点/强制时)扫描认领。异常不外抛。"""
1739
1759
  _ensure_loaded()
1740
1760
  with _LOCK:
@@ -1945,6 +1965,17 @@ def save_config(patch):
1945
1965
  return view()["config"]
1946
1966
 
1947
1967
 
1968
+ def bug_web_base(cfg=None):
1969
+ """给前端拼 bug 详情页用的 web 根:探测出的有效地基(含自动补的 /zentao 子路径)
1970
+ 优先,没探测过回落用户填的原始地址。返回 stripped 根或空串。"""
1971
+ c = dict(cfg) if cfg else _cfg()
1972
+ hit = resolved_base_url(c.get("base_url"))
1973
+ if hit:
1974
+ return hit
1975
+ b = re.sub(r"/api\.php/v1$", "", str(c.get("base_url") or "").strip().rstrip("/"))
1976
+ return b if b.startswith(("http://", "https://")) else ""
1977
+
1978
+
1948
1979
  def view():
1949
1980
  """前端视图:配置脱敏(password 只回是否已设)+ claims 列表(新在前)。"""
1950
1981
  _ensure_loaded()
@@ -1953,9 +1984,11 @@ def view():
1953
1984
  has_pw = bool(cfg.get("password"))
1954
1985
  cfg["password"] = ""
1955
1986
  cfg["has_password"] = has_pw
1987
+ bug_base = bug_web_base(cfg)
1956
1988
  claims = sorted(_STATE["claims"].values(),
1957
1989
  key=lambda c: str(c.get("claimed_at") or ""), reverse=True)
1958
1990
  return {"config": cfg,
1991
+ "bug_base": bug_base,
1959
1992
  "claims": [dict(c) for c in claims],
1960
1993
  "last_scan": _STATE.get("last_scan") or "",
1961
1994
  "next_scan": _STATE.get("next_scan") or "",
package/app/main.py CHANGED
@@ -367,6 +367,12 @@ class Handler(BaseHTTPRequestHandler):
367
367
  if m:
368
368
  run = store.get_run(m.group(1))
369
369
  return self._json(200, {"run": run}) if run else self._json(404, {"error": "not found"})
370
+ m = re.match(r"^/api/runs/([^/]+)/messages$", path)
371
+ if m:
372
+ run = store.get_run(m.group(1))
373
+ if not run:
374
+ return self._json(404, {"error": "not found"})
375
+ return self._json(200, {"messages": run.get("messages") or []})
370
376
  m = re.match(r"^/api/runs/([^/]+)/report$", path)
371
377
  if m:
372
378
  run = store.get_run(m.group(1))
@@ -419,6 +425,10 @@ class Handler(BaseHTTPRequestHandler):
419
425
  if m:
420
426
  # 任务检查器(右缘停靠列)专用:轻量聚合、可轮询,不带 diff 文本
421
427
  return self._api_task_side(m.group(1))
428
+ m = re.match(r"^/api/tasks/([^/]+)/detail$", path)
429
+ if m:
430
+ task = store.get_task(m.group(1))
431
+ return self._json(200, {"task": task}) if task else self._json(404, {"error": "not found"})
422
432
  m = re.match(r"^/api/tasks/([^/]+)/runs$", path)
423
433
  if m:
424
434
  # 任务级详情用:该任务全部 run(含 steps),不受前端 run 窗口限制
@@ -2365,16 +2375,20 @@ def _state_payload(client_id="", ver=None):
2365
2375
  agents = registry.effective_agents(catalog.load(), manager.detect_all())
2366
2376
  if ver is None:
2367
2377
  ver = store.state_version()
2378
+ runs = store.list_runs(40)
2379
+ latest = store.latest_run_by_task()
2368
2380
  return {
2369
2381
  "v": ver,
2370
2382
  "agents": agents,
2371
2383
  "tasks": store.list_tasks(30, archived=False),
2372
- "archived_tasks": store.list_tasks(30, archived=True),
2384
+ # 归档任务默认隐藏;目标/上下文可能很大,只有查看或恢复时再按需读取。
2385
+ "archived_tasks": [_state_task_summary(t) for t in store.list_tasks(30, archived=True)],
2373
2386
  # 每个任务的最近一次运行(不受 runs 窗口限制):侧栏靠它展示各任务真实近况
2374
- "task_latest": store.latest_run_by_task(),
2387
+ "task_latest": {tid: _state_run_summary(run) for tid, run in latest.items()},
2375
2388
  # 每个任务的运行次数/步骤总数(全量):侧栏「查看全部」的计数来源
2376
2389
  "task_stats": store.task_run_stats(),
2377
- "runs": store.list_runs(40),
2390
+ # 列表与 SSE 只带摘要;日志、模型输出、计划、消息正文等走详情 API。
2391
+ "runs": [_state_run_summary(run) for run in runs],
2378
2392
  "control": remote.control_view(client_id),
2379
2393
  # 供应商健康/告警(顶栏横幅数据源;有告警时 bump_state 会推给所有端)
2380
2394
  "health": health.snapshot(),
@@ -2383,6 +2397,32 @@ def _state_payload(client_id="", ver=None):
2383
2397
  }
2384
2398
 
2385
2399
 
2400
+ def _state_task_summary(task):
2401
+ """归档任务的最小索引;大字段由 /api/tasks/<id>/detail 按需读取。"""
2402
+ fields = ("id", "title", "type", "engine", "workdir", "status",
2403
+ "created_at", "archived", "git_state", "serial")
2404
+ return {key: task[key] for key in fields if key in task}
2405
+
2406
+
2407
+ def _state_run_summary(run):
2408
+ """列表/SSE 投影,不重复携带运行详情中的大字段。"""
2409
+ fields = ("id", "task_id", "kind", "op", "title", "status", "created_at",
2410
+ "started_at", "ended_at", "summary", "error", "paused", "direct_session",
2411
+ "auto_resumes", "resume_enqueue_at", "cost_usd", "tokens")
2412
+ summary = {key: run[key] for key in fields if key in run}
2413
+ summary["step_count"] = len(run.get("steps") or [])
2414
+ messages = run.get("messages") or []
2415
+ summary["message_count"] = len(messages)
2416
+ summary["pending_message_count"] = sum(1 for message in messages if not message.get("consumed"))
2417
+ verdict = run.get("verdict") or {}
2418
+ if isinstance(verdict, dict):
2419
+ checks = ("pass", "publishable", "verify_pass", "review_pass")
2420
+ compact = {key: verdict[key] for key in checks if key in verdict}
2421
+ if compact:
2422
+ summary["verdict"] = compact
2423
+ return summary
2424
+
2425
+
2386
2426
  class ThreadedServer(ThreadingHTTPServer):
2387
2427
  """Windows 下 SO_REUSEADDR 允许两个进程同时 LISTEN 同一端口(请求随机
2388
2428
  分发到其中一个——服务"时好时坏"的根源)。独占锁让第二个实例在这里