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/CHANGELOG.md +13 -0
- package/README.md +3 -4
- package/app/core/builtin_agent.py +67 -26
- package/app/core/jobs.py +23 -4
- package/app/core/knowledge.py +26 -4
- package/app/core/modelhub.py +50 -5
- package/app/core/pipeline.py +156 -66
- package/app/core/planner.py +27 -13
- package/app/core/runner.py +398 -121
- package/app/core/store.py +64 -27
- package/app/core/zentao.py +39 -6
- package/app/main.py +43 -3
- package/app/ui/app.js +289 -56
- package/app/ui/board.css +616 -258
- package/app/ui/board.html +119 -41
- package/app/ui/board.js +60 -14
- package/app/ui/i18n.js +2699 -2659
- package/app/ui/index.html +2 -0
- package/app/ui/style.css +8 -6
- package/package.json +1 -1
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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)
|
package/app/core/zentao.py
CHANGED
|
@@ -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
|
-
"""
|
|
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
|
-
|
|
876
|
-
|
|
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
|
-
|
|
2384
|
+
# 归档任务默认隐藏;目标/上下文可能很大,只有查看或恢复时再按需读取。
|
|
2385
|
+
"archived_tasks": [_state_task_summary(t) for t in store.list_tasks(30, archived=True)],
|
|
2373
2386
|
# 每个任务的最近一次运行(不受 runs 窗口限制):侧栏靠它展示各任务真实近况
|
|
2374
|
-
"task_latest":
|
|
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
|
-
|
|
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
|
分发到其中一个——服务"时好时坏"的根源)。独占锁让第二个实例在这里
|