codebee 0.1.54 → 0.1.55

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:
package/app/ui/app.js CHANGED
@@ -6377,18 +6377,18 @@ function logLiveBadge(live) {
6377
6377
  }
6378
6378
  }
6379
6379
 
6380
- /* 复制抽屉日志全文:报错 JSON 动辄几十行还带横向滚动,手动圈选很折磨 */
6381
- window.rdLogCopy = function () {
6382
- const pre = $("rd-log-text");
6383
- const text = ((pre && pre.textContent) || "").trim();
6384
- const idle = [t("(等待输出…)"), t("(无输出)")];
6385
- if (!text || idle.includes(text)) return;
6386
- const done = () => toast(t("已复制"));
6387
- if (navigator.clipboard && navigator.clipboard.writeText) {
6388
- navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, done));
6389
- } else fallbackCopy(text, done);
6390
- };
6391
-
6380
+ /* 复制抽屉日志全文:报错 JSON 动辄几十行还带横向滚动,手动圈选很折磨 */
6381
+ window.rdLogCopy = function () {
6382
+ const pre = $("rd-log-text");
6383
+ const text = ((pre && pre.textContent) || "").trim();
6384
+ const idle = [t("(等待输出…)"), t("(无输出)")];
6385
+ if (!text || idle.includes(text)) return;
6386
+ const done = () => toast(t("已复制"));
6387
+ if (navigator.clipboard && navigator.clipboard.writeText) {
6388
+ navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, done));
6389
+ } else fallbackCopy(text, done);
6390
+ };
6391
+
6392
6392
  /* 抽屉标题:按 (runId, 日志路径) 反查步骤角色与执行者——不知道在看谁的日志,
6393
6393
  * 抽屉就成了无名黑框 */
6394
6394
  function rdLogStepLabel(runId, rel) {
@@ -9240,11 +9240,15 @@ function autoTplPace(tp) {
9240
9240
  });
9241
9241
  }
9242
9242
 
9243
- /* last_status → 徽章:started=正常灰、error=红「拉起失败」、missed=黄「已错过」 */
9243
+ /* last_status → 徽章:started=正常灰、error=红「拉起失败」、missed=黄「已错过」;
9244
+ * done/failed/cancelled=运行终态(2026-09-23 回写接线后会出现,失败必须可见) */
9244
9245
  function autoStatusTag(tsk) {
9245
9246
  if (tsk.last_status === "error") return '<span class="tag auto-tag-err">' + t("拉起失败") + "</span>";
9246
9247
  if (tsk.last_status === "missed") return '<span class="tag auto-tag-miss">' + t("已错过") + "</span>";
9247
9248
  if (tsk.last_status === "started" || tsk.last_status === "queued") return '<span class="tag">' + t("正常") + "</span>";
9249
+ if (tsk.last_status === "failed") return '<span class="tag auto-tag-err">' + t("上次失败") + "</span>";
9250
+ if (tsk.last_status === "cancelled") return '<span class="tag auto-tag-miss">' + t("已取消") + "</span>";
9251
+ if (tsk.last_status === "done") return '<span class="tag">' + t("已完成") + "</span>";
9248
9252
  return "";
9249
9253
  }
9250
9254
 
@@ -11327,6 +11331,12 @@ function pickLang(lang) {
11327
11331
  window.toggleLangMenu = toggleLangMenu;
11328
11332
  window.pickLang = pickLang;
11329
11333
 
11334
+ /* 顶栏大屏入口:新标签页打开任务驾驶舱;同源共享 orch.token,board 页免二次输令牌 */
11335
+ function openBoard() {
11336
+ window.open("/board.html", "_blank", "noopener");
11337
+ }
11338
+ window.openBoard = openBoard;
11339
+
11330
11340
  function setLangBtn(lang) {
11331
11341
  if (getLang() === lang) return;
11332
11342
  setLang(lang);