codebee 0.1.105 → 0.1.106
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 +9 -0
- package/README.md +10 -7
- package/app/core/activity.py +36 -0
- package/app/core/builtin_agent.py +6 -1
- package/app/core/jobs.py +7 -0
- package/app/core/market_remote.py +19 -7
- package/app/core/mcp_client.py +102 -63
- package/app/core/pipeline.py +14 -6
- package/app/core/publish/auto.py +74 -3
- package/app/core/publish/ledger.py +5 -0
- package/app/core/publish/manager.py +5 -1
- package/app/core/runtime_lock.py +75 -0
- package/app/core/selfupdate.py +8 -0
- package/app/core/store.py +32 -2
- package/app/core/usage.py +31 -26
- package/app/core/webhooks.py +17 -13
- package/app/desktop_host.py +190 -0
- package/app/main.py +88 -4
- package/app/pet.py +1 -1
- package/app/ui/activity.css +34 -0
- package/app/ui/activity.js +199 -0
- package/app/ui/app.js +792 -64
- package/app/ui/hive3d.js +180 -61
- package/app/ui/i18n.js +130 -1
- package/app/ui/index.html +154 -75
- package/app/ui/style.css +583 -26
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
|
|
|
4
4
|
`<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
|
|
5
5
|
README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
|
|
6
6
|
|
|
7
|
+
## v0.1.106(2026-10-12)
|
|
8
|
+
|
|
9
|
+
- **发布台章节批量发布工作流升级**:待发章节自动对账(已发布/已存草稿自动排除)、按卷计划自动分卷、章节状态徽章一目了然(已过审可直发 / 发布受限 / 审稿未通过 / 已修改需重审 / 待审或评审不足)、「只选可直发」一键筛选;任务按工作区分组管理(工作区视图+区內新建任务)
|
|
10
|
+
- **Windows/macOS 桌面版预览**:新增桌面壳、桌面宿主与运行时锁(防双开),CI 产出预览安装包(npm 主分发不变)
|
|
11
|
+
- **输入区 Codex 风格重排**:目标与对话输入布局更聚焦,配套 markdown 渲染回归测试
|
|
12
|
+
- **移动端任务对话控制增强**
|
|
13
|
+
- **自由 3D 入口暂对用户隐藏**:引擎保留给内部 QA(未达发布质量),hidden/aria-hidden + CSS 双保险防旧缓存/恢复状态重新显示
|
|
14
|
+
- 蜂巢 3D 翅膀/手臂视觉打磨多分支合流收口
|
|
15
|
+
|
|
7
16
|
## v0.1.105(2026-10-12)
|
|
8
17
|
|
|
9
18
|
- **claude CLI 输出上限 16000→32000**:全局评审读 40 万字级书稿(约 28 万 token 输入)时按旧值反复续烧 23 分钟后报「response exceeded the 16000 output token maximum」三连挂(实案:单次 $3 零产出);32000=claude CLI 官方默认档,`setdefault` 语义保留——用户显式配置不覆盖
|
package/README.md
CHANGED
|
@@ -25,12 +25,12 @@ Trae Agent、OpenClaw……),提供
|
|
|
25
25
|
---
|
|
26
26
|
|
|
27
27
|
<!-- relnotes:start -->
|
|
28
|
-
### 最新版更新内容(v0.1.
|
|
28
|
+
### 最新版更新内容(v0.1.106)
|
|
29
29
|
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
30
|
+
- 发布台章节批量发布升级:待发自动对账、按卷自动分卷、可直发状态徽章与一键筛选
|
|
31
|
+
- Windows/macOS 桌面版预览打包(CI 预览安装包)
|
|
32
|
+
- 输入区 Codex 风格重排 + 移动端对话控制增强
|
|
33
|
+
- 自由 3D 入口暂对用户隐藏(未达发布质量,保留内部 QA)
|
|
34
34
|
<!-- relnotes:end -->
|
|
35
35
|
|
|
36
36
|
|
|
@@ -331,8 +331,11 @@ cloudflared tunnel login
|
|
|
331
331
|
cloudflared tunnel create codebee
|
|
332
332
|
cloudflared tunnel route dns --overwrite-dns <隧道UUID> codebee.你的域名.com
|
|
333
333
|
|
|
334
|
-
::
|
|
335
|
-
start-
|
|
334
|
+
:: 日常启动服务(本机后台窗口):
|
|
335
|
+
start-service.bat
|
|
336
|
+
|
|
337
|
+
:: 启动隧道(另开一个窗口;也可 cloudflared service install 注册为开机自启):
|
|
338
|
+
cloudflared tunnel --config "%USERPROFILE%\.cloudflared\config.yml" run
|
|
336
339
|
```
|
|
337
340
|
|
|
338
341
|
- 真本机(不带转发头的 loopback)不受影响。
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Small, read-only projections for the sidebar activity view."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from . import contracts
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def run_activity(run):
|
|
8
|
+
steps = [step for step in (run.get("steps") or []) if isinstance(step, dict)]
|
|
9
|
+
current = next((step for step in reversed(steps)
|
|
10
|
+
if step.get("status") == "running"), None)
|
|
11
|
+
if current is None and steps:
|
|
12
|
+
current = steps[-1]
|
|
13
|
+
failed = next((step for step in reversed(steps)
|
|
14
|
+
if step.get("status") in ("failed", "timeout")), None)
|
|
15
|
+
result = {}
|
|
16
|
+
for name, step in (("current_step", current), ("failed_step", failed)):
|
|
17
|
+
if step:
|
|
18
|
+
result[name] = {key: step[key] for key in
|
|
19
|
+
("n", "role", "agent", "agent_label", "status") if key in step}
|
|
20
|
+
result[name]["summary"] = " ".join(str(step.get("summary") or "").split())[:240]
|
|
21
|
+
return result
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def task_attention(tasks, latest):
|
|
25
|
+
"""Approval state is authoritative; never infer approval from a read marker."""
|
|
26
|
+
result = {}
|
|
27
|
+
for task in tasks:
|
|
28
|
+
run = latest.get(task["id"]) or {}
|
|
29
|
+
contract = contracts.get(task["id"]) or {}
|
|
30
|
+
requires_approval = bool(task.get("approval_required") or
|
|
31
|
+
contract.get("approval_required"))
|
|
32
|
+
if not requires_approval or run.get("status") != "done":
|
|
33
|
+
continue
|
|
34
|
+
if (contract.get("approval") or {}).get("status") != "approved":
|
|
35
|
+
result[task["id"]] = {"approval_pending": True}
|
|
36
|
+
return result
|
|
@@ -931,7 +931,8 @@ def _exec_tool(workdir, name, args, cancel_event=None, deadline=None, task_creat
|
|
|
931
931
|
remaining if remaining is not None else CMD_MAX_TIMEOUT)
|
|
932
932
|
r = mcp_client.dispatch_full_name(
|
|
933
933
|
name, args or {}, sandbox=sandbox, workdir=workdir,
|
|
934
|
-
disabled_tools=denied, timeout_s=timeout_s
|
|
934
|
+
disabled_tools=denied, timeout_s=timeout_s,
|
|
935
|
+
cancel_event=cancel_event, deadline=deadline)
|
|
935
936
|
return r.get("text") or ("(MCP 工具失败: %s)" % r.get("error") if not r.get("ok") else "")
|
|
936
937
|
except Exception as e:
|
|
937
938
|
return "MCP 工具执行失败: %s" % e
|
|
@@ -1983,6 +1984,10 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
1983
1984
|
disabled_tools=disabled_tools, sandbox=sandbox,
|
|
1984
1985
|
checkpoint_run_id=checkpoint_run_id,
|
|
1985
1986
|
tool_output_run_id=tool_output_run_id)
|
|
1987
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
1988
|
+
return _deadline_fail()
|
|
1989
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
1990
|
+
return _cancel_fail()
|
|
1986
1991
|
# 运行期注入警示层:外部内容(网页/命令输出/文件)一律
|
|
1987
1992
|
# 包围栏进上下文;检测命中升级强围栏(prompt_guard)。
|
|
1988
1993
|
try:
|
package/app/core/jobs.py
CHANGED
|
@@ -314,6 +314,13 @@ def cancel_restart_drain():
|
|
|
314
314
|
_restart_drain = False
|
|
315
315
|
|
|
316
316
|
|
|
317
|
+
def begin_shutdown_drain():
|
|
318
|
+
"""Stop accepting work before a confirmed desktop shutdown cancels runs."""
|
|
319
|
+
global _restart_drain
|
|
320
|
+
with _pool_lock:
|
|
321
|
+
_restart_drain = True
|
|
322
|
+
|
|
323
|
+
|
|
317
324
|
def _close_unstarted(job, message, statuses=("queued",)):
|
|
318
325
|
"""无法启动时统一收口 run/task,供所有入口复用。"""
|
|
319
326
|
try:
|
|
@@ -110,18 +110,29 @@ def _cache_file(source_id):
|
|
|
110
110
|
|
|
111
111
|
# ---------------------------------------------------------------- 网络边界
|
|
112
112
|
|
|
113
|
+
def _reject_url_userinfo(url):
|
|
114
|
+
"""拒绝 URL authority 中的凭据;错误不回显可能含令牌的原始 URL。"""
|
|
115
|
+
parsed = urlparse(url or "")
|
|
116
|
+
if parsed.username is not None or parsed.password is not None:
|
|
117
|
+
raise ValueError("URL 不允许携带用户信息")
|
|
118
|
+
return parsed
|
|
119
|
+
|
|
120
|
+
|
|
113
121
|
def assert_public_url(url):
|
|
114
122
|
"""SSRF 防护:仅 https;host 解析出的所有 IP 都不得是环回/私有/保留地址。
|
|
115
123
|
返回 (host, port),不合法直接抛 ValueError。"""
|
|
116
|
-
u =
|
|
124
|
+
u = _reject_url_userinfo(url)
|
|
117
125
|
if u.scheme != "https":
|
|
118
|
-
raise ValueError("仅允许 https
|
|
126
|
+
raise ValueError("仅允许 https 地址")
|
|
119
127
|
host = u.hostname
|
|
120
128
|
if not host:
|
|
121
|
-
raise ValueError("URL
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
129
|
+
raise ValueError("URL 缺少主机名")
|
|
130
|
+
try:
|
|
131
|
+
port = u.port or 443
|
|
132
|
+
except ValueError:
|
|
133
|
+
raise ValueError("端口非法") from None
|
|
134
|
+
if not (0 < port < 65536):
|
|
135
|
+
raise ValueError("端口非法")
|
|
125
136
|
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
|
126
137
|
if not infos:
|
|
127
138
|
raise ValueError("主机无法解析: %s" % host)
|
|
@@ -856,7 +867,7 @@ def _download_git(entry, tmp):
|
|
|
856
867
|
text=True, encoding="utf-8", errors="replace",
|
|
857
868
|
env=env)
|
|
858
869
|
except subprocess.TimeoutExpired:
|
|
859
|
-
raise ValueError("git
|
|
870
|
+
raise ValueError("git 克隆超时")
|
|
860
871
|
if proc.returncode == 0:
|
|
861
872
|
break
|
|
862
873
|
err = (proc.stderr or "").strip()[-200:]
|
|
@@ -875,6 +886,7 @@ def _download_git(entry, tmp):
|
|
|
875
886
|
def _download_git_any(entry, tmp):
|
|
876
887
|
"""git 类来源安装:codeload tarball 优先(完整、最新)→ jsdelivr 逐文件
|
|
877
888
|
兜底(codeload 不可达时)→ git 浅克隆收尾(非 github 托管 / 精确 ref)。"""
|
|
889
|
+
_reject_url_userinfo(entry["install"].get("url"))
|
|
878
890
|
try:
|
|
879
891
|
return _gh_tarball(entry, tmp)
|
|
880
892
|
except ValueError as e:
|
package/app/core/mcp_client.py
CHANGED
|
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|
|
19
19
|
import json
|
|
20
20
|
import hashlib
|
|
21
21
|
import os
|
|
22
|
+
import queue
|
|
22
23
|
import re
|
|
23
24
|
import shutil
|
|
24
25
|
import subprocess
|
|
@@ -28,6 +29,7 @@ import time
|
|
|
28
29
|
from pathlib import Path
|
|
29
30
|
|
|
30
31
|
_LOCK = threading.RLock()
|
|
32
|
+
_REFRESH_LOCK = threading.Lock()
|
|
31
33
|
_TOOLS_CACHE = {} # server_key → (ts, tools)
|
|
32
34
|
_TOOLS_TTL = 300.0
|
|
33
35
|
_TOOLS_CONFIG_SHA = ""
|
|
@@ -35,6 +37,7 @@ _TOOLS_CONFIG_SHA = ""
|
|
|
35
37
|
MAX_SERVERS = 4
|
|
36
38
|
MAX_MCP_TOOLS = 24 # 全部服务器合计注入内置智能体的工具数上限
|
|
37
39
|
_NAME_RE = re.compile(r"^[a-z0-9_-]{1,24}$")
|
|
40
|
+
_MODEL_TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
38
41
|
_INIT_TIMEOUT = 15.0
|
|
39
42
|
_CALL_TIMEOUT = 60.0
|
|
40
43
|
_CALL_TIMEOUT_MAX = 300.0
|
|
@@ -64,6 +67,7 @@ def parse_servers(text):
|
|
|
64
67
|
if len(arr) > MAX_SERVERS:
|
|
65
68
|
return None, "最多 %d 个 MCP 服务器" % MAX_SERVERS
|
|
66
69
|
out = []
|
|
70
|
+
names = set()
|
|
67
71
|
for item in arr:
|
|
68
72
|
if not isinstance(item, dict):
|
|
69
73
|
return None, "MCP 服务器配置必须是对象"
|
|
@@ -71,6 +75,11 @@ def parse_servers(text):
|
|
|
71
75
|
command = str(item.get("command") or "").strip()
|
|
72
76
|
if not _NAME_RE.match(name):
|
|
73
77
|
return None, "服务器名 %r 只能是小写字母/数字/-/_(≤24 位)" % name[:24]
|
|
78
|
+
if "__" in name:
|
|
79
|
+
return None, "服务器名 %s 不能包含工具分隔符 __" % name
|
|
80
|
+
if name in names:
|
|
81
|
+
return None, "MCP 服务器名 %s 重名" % name
|
|
82
|
+
names.add(name)
|
|
74
83
|
if not command:
|
|
75
84
|
return None, "服务器 %s 缺 command" % name
|
|
76
85
|
args = item.get("args") if isinstance(item.get("args"), list) else []
|
|
@@ -152,12 +161,16 @@ def _bubblewrap_argv(server, sandbox, roots, cwd):
|
|
|
152
161
|
class _Session:
|
|
153
162
|
"""一次性的 MCP 服务器会话:spawn → initialize → (list/call)→ 杀树。"""
|
|
154
163
|
|
|
155
|
-
def __init__(self, server, sandbox=None, workdir=None, timeout_s=None
|
|
164
|
+
def __init__(self, server, sandbox=None, workdir=None, timeout_s=None,
|
|
165
|
+
cancel_event=None, deadline=None):
|
|
156
166
|
self.server = server
|
|
157
167
|
self.sandbox = sandbox
|
|
158
168
|
self.workdir = workdir
|
|
159
169
|
self.timeout_s = timeout_s
|
|
160
|
-
self.deadline =
|
|
170
|
+
self.deadline = deadline
|
|
171
|
+
self.task_deadline = deadline
|
|
172
|
+
self.cancel_event = cancel_event
|
|
173
|
+
self._nest = None
|
|
161
174
|
self.proc = None
|
|
162
175
|
self.lines = None # reader 线程产出的行队列
|
|
163
176
|
self.err = None
|
|
@@ -190,7 +203,9 @@ class _Session:
|
|
|
190
203
|
|
|
191
204
|
def __enter__(self):
|
|
192
205
|
if self.timeout_s is not None:
|
|
193
|
-
|
|
206
|
+
call_deadline = time.monotonic() + max(0.0, float(self.timeout_s))
|
|
207
|
+
self.deadline = min(self.deadline, call_deadline) if self.deadline is not None else call_deadline
|
|
208
|
+
self._check_interrupted()
|
|
194
209
|
try:
|
|
195
210
|
argv, env, cwd = _sandbox_launch(self.server, self.sandbox, self.workdir)
|
|
196
211
|
if self.deadline is not None and time.monotonic() >= self.deadline:
|
|
@@ -199,18 +214,22 @@ class _Session:
|
|
|
199
214
|
argv,
|
|
200
215
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
201
216
|
stderr=subprocess.PIPE, text=False,
|
|
202
|
-
env=env, cwd=cwd, start_new_session=
|
|
217
|
+
env=env, cwd=cwd, start_new_session=(os.name != "nt"))
|
|
203
218
|
except Exception as e:
|
|
204
219
|
raise RuntimeError("MCP 服务器启动失败: %s" % e)
|
|
205
|
-
import queue as _queue
|
|
206
|
-
self.lines = _queue.Queue()
|
|
207
|
-
self._reader_threads = [
|
|
208
|
-
threading.Thread(target=self._pump_out, daemon=True),
|
|
209
|
-
threading.Thread(target=self._pump_err, daemon=True),
|
|
210
|
-
]
|
|
211
|
-
for thread in self._reader_threads:
|
|
212
|
-
thread.start()
|
|
213
220
|
try:
|
|
221
|
+
import queue as _queue
|
|
222
|
+
self.lines = _queue.Queue()
|
|
223
|
+
self._reader_threads = [
|
|
224
|
+
threading.Thread(target=self._pump_out, daemon=True),
|
|
225
|
+
threading.Thread(target=self._pump_err, daemon=True),
|
|
226
|
+
]
|
|
227
|
+
for thread in self._reader_threads:
|
|
228
|
+
thread.start()
|
|
229
|
+
if os.name == "nt":
|
|
230
|
+
from . import beekeeper
|
|
231
|
+
self._nest = beekeeper.BeeNest()
|
|
232
|
+
self._nest.adopt(self.proc)
|
|
214
233
|
init_timeout = _INIT_TIMEOUT
|
|
215
234
|
if self.deadline is not None:
|
|
216
235
|
init_timeout = min(init_timeout, self.deadline - time.monotonic())
|
|
@@ -220,57 +239,48 @@ class _Session:
|
|
|
220
239
|
"protocolVersion": "2024-11-05", "capabilities": {},
|
|
221
240
|
"clientInfo": _CLIENT_INFO}, timeout=init_timeout)
|
|
222
241
|
self._notify("notifications/initialized")
|
|
223
|
-
except
|
|
242
|
+
except BaseException:
|
|
224
243
|
# Context manager __exit__ is not called when __enter__ raises.
|
|
225
244
|
self.__exit__(None, None, None)
|
|
226
245
|
raise
|
|
227
246
|
return self
|
|
228
247
|
|
|
229
248
|
def __exit__(self, *exc):
|
|
249
|
+
proc = self.proc
|
|
250
|
+
if proc is None:
|
|
251
|
+
return False
|
|
230
252
|
try:
|
|
231
|
-
if self.
|
|
253
|
+
if self._nest is not None:
|
|
254
|
+
self._nest.close()
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
if os.name != "nt" or proc.poll() is None:
|
|
258
|
+
try:
|
|
259
|
+
from . import runner
|
|
260
|
+
runner._kill_tree(proc.pid)
|
|
261
|
+
except Exception:
|
|
232
262
|
try:
|
|
233
|
-
|
|
263
|
+
proc.kill()
|
|
234
264
|
except Exception:
|
|
235
265
|
pass
|
|
266
|
+
try:
|
|
267
|
+
proc.wait(timeout=2)
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
if proc.stdin is not None:
|
|
236
271
|
try:
|
|
237
|
-
|
|
238
|
-
# bwrap runs in its own process group; terminate descendants
|
|
239
|
-
# as well as the launcher so timed-out MCP servers cannot
|
|
240
|
-
# leave background children holding pipes or doing work.
|
|
241
|
-
try:
|
|
242
|
-
from . import runner
|
|
243
|
-
runner._kill_tree(self.proc.pid)
|
|
244
|
-
except Exception:
|
|
245
|
-
pass
|
|
246
|
-
self.proc.kill()
|
|
247
|
-
except Exception:
|
|
248
|
-
pass
|
|
249
|
-
try:
|
|
250
|
-
if self.proc:
|
|
251
|
-
self.proc.wait(timeout=1)
|
|
272
|
+
proc.stdin.close()
|
|
252
273
|
except Exception:
|
|
253
274
|
pass
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
#
|
|
258
|
-
|
|
259
|
-
# on EOF and close only streams no thread currently owns.
|
|
260
|
-
for thread in self._reader_threads:
|
|
275
|
+
for thread, pipe in zip(self._reader_threads, (proc.stdout, proc.stderr)):
|
|
276
|
+
if thread is not None and thread.is_alive():
|
|
277
|
+
thread.join(timeout=0.5)
|
|
278
|
+
# 读线程持有管道锁时主线程 close 可能卡到孙进程自然退出。
|
|
279
|
+
if pipe is not None and (thread is None or not thread.is_alive()):
|
|
261
280
|
try:
|
|
262
|
-
|
|
281
|
+
pipe.close()
|
|
263
282
|
except Exception:
|
|
264
283
|
pass
|
|
265
|
-
for name in ("stdout", "stderr"):
|
|
266
|
-
pipe = getattr(self.proc, name, None) if self.proc else None
|
|
267
|
-
readers_alive = any(
|
|
268
|
-
thread.is_alive() for thread in self._reader_threads)
|
|
269
|
-
if pipe and not readers_alive:
|
|
270
|
-
try:
|
|
271
|
-
pipe.close()
|
|
272
|
-
except Exception:
|
|
273
|
-
pass
|
|
274
284
|
return False
|
|
275
285
|
|
|
276
286
|
def _send(self, obj):
|
|
@@ -281,8 +291,15 @@ class _Session:
|
|
|
281
291
|
def _notify(self, method, params=None):
|
|
282
292
|
self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
|
|
283
293
|
|
|
294
|
+
def _check_interrupted(self):
|
|
295
|
+
if self.cancel_event is not None and self.cancel_event.is_set():
|
|
296
|
+
raise RuntimeError("任务已取消")
|
|
297
|
+
if self.task_deadline is not None and time.monotonic() >= self.task_deadline:
|
|
298
|
+
raise RuntimeError("任务总时限已到")
|
|
299
|
+
|
|
284
300
|
def _request(self, method, params, timeout):
|
|
285
301
|
"""单请求单响应;忽略通知与未知 id 的服务端请求。"""
|
|
302
|
+
self._check_interrupted()
|
|
286
303
|
duration = max(0.0, float(timeout))
|
|
287
304
|
if self.deadline is not None:
|
|
288
305
|
duration = min(duration, self.deadline - time.monotonic())
|
|
@@ -293,13 +310,15 @@ class _Session:
|
|
|
293
310
|
"method": method, "params": params or {}})
|
|
294
311
|
deadline = time.monotonic() + duration
|
|
295
312
|
while True:
|
|
313
|
+
self._check_interrupted()
|
|
296
314
|
remain = deadline - time.monotonic()
|
|
297
315
|
if remain <= 0:
|
|
298
316
|
raise RuntimeError("%s 超时(%.0fs)" % (method, timeout))
|
|
299
317
|
try:
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
318
|
+
wait = min(remain, 0.1) if self.cancel_event is not None else remain
|
|
319
|
+
line = self.lines.get(timeout=wait)
|
|
320
|
+
except queue.Empty:
|
|
321
|
+
continue
|
|
303
322
|
if line is None:
|
|
304
323
|
raise RuntimeError("MCP 服务器提前退出")
|
|
305
324
|
line = line.strip()
|
|
@@ -327,10 +346,11 @@ def list_tools(server, *, sandbox=None, workdir=None):
|
|
|
327
346
|
tools = []
|
|
328
347
|
for t in (res.get("tools") or [])[:16]:
|
|
329
348
|
if isinstance(t, dict) and isinstance(t.get("name"), str) and t["name"]:
|
|
349
|
+
schema = t.get("inputSchema", t.get("input_schema"))
|
|
330
350
|
tools.append({"name": t["name"],
|
|
331
351
|
"description": str(t.get("description") or "")[:400],
|
|
332
|
-
"input_schema":
|
|
333
|
-
if isinstance(
|
|
352
|
+
"input_schema": schema
|
|
353
|
+
if isinstance(schema, dict) else
|
|
334
354
|
{"type": "object", "properties": {}}})
|
|
335
355
|
return {"ok": True, "tools": tools, "error": ""}
|
|
336
356
|
except Exception as e:
|
|
@@ -338,12 +358,13 @@ def list_tools(server, *, sandbox=None, workdir=None):
|
|
|
338
358
|
|
|
339
359
|
|
|
340
360
|
def call_tool(server, tool_name, arguments, timeout_s=_CALL_TIMEOUT,
|
|
341
|
-
*, sandbox=None, workdir=None):
|
|
361
|
+
*, sandbox=None, workdir=None, cancel_event=None, deadline=None):
|
|
342
362
|
"""调用一个工具。返回 {"ok", "text", "error", "is_error"}。"""
|
|
343
363
|
try:
|
|
344
364
|
timeout = min(_CALL_TIMEOUT_MAX, max(0.001, float(timeout_s or _CALL_TIMEOUT)))
|
|
345
365
|
with _Session(server, sandbox=sandbox, workdir=workdir,
|
|
346
|
-
timeout_s=timeout
|
|
366
|
+
timeout_s=timeout, cancel_event=cancel_event,
|
|
367
|
+
deadline=deadline) as sess:
|
|
347
368
|
res = sess._request("tools/call",
|
|
348
369
|
{"name": tool_name,
|
|
349
370
|
"arguments": arguments if isinstance(arguments, dict) else {}},
|
|
@@ -364,6 +385,12 @@ def call_tool(server, tool_name, arguments, timeout_s=_CALL_TIMEOUT,
|
|
|
364
385
|
# ---- 面向 builtin_agent 的合并视图(带缓存) ----
|
|
365
386
|
|
|
366
387
|
def tool_specs_cached(force=False, *, sandbox=None, workdir=None):
|
|
388
|
+
"""Serialize discoveries so callers cannot observe incomplete refreshes."""
|
|
389
|
+
with _REFRESH_LOCK:
|
|
390
|
+
return _tool_specs_cached(force=force, sandbox=sandbox, workdir=workdir)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _tool_specs_cached(force=False, *, sandbox=None, workdir=None):
|
|
367
394
|
"""全部服务器的工具合并清单:[{server, name(原名), full_name, description,
|
|
368
395
|
input_schema}]。TTL 内走缓存;单个服务器失败跳过(error 挂在条目外不打断)。"""
|
|
369
396
|
global _TOOLS_CONFIG_SHA
|
|
@@ -398,8 +425,8 @@ def tool_specs_cached(force=False, *, sandbox=None, workdir=None):
|
|
|
398
425
|
disabled = set(policy.normalize_disabled_tools(
|
|
399
426
|
(sandbox or {}).get("disabled_tools")))
|
|
400
427
|
out = []
|
|
401
|
-
|
|
402
|
-
|
|
428
|
+
discovered = {}
|
|
429
|
+
seen_names = set()
|
|
403
430
|
for srv in servers:
|
|
404
431
|
res = list_tools(srv, sandbox=sandbox, workdir=workdir)
|
|
405
432
|
if not res.get("ok"):
|
|
@@ -410,25 +437,36 @@ def tool_specs_cached(force=False, *, sandbox=None, workdir=None):
|
|
|
410
437
|
if (t["name"] in srv.get("disabled_tools", [])
|
|
411
438
|
or t["name"] in disabled or full_name in disabled):
|
|
412
439
|
continue
|
|
440
|
+
if not _MODEL_TOOL_NAME_RE.fullmatch(full_name) or full_name in seen_names:
|
|
441
|
+
continue
|
|
442
|
+
seen_names.add(full_name)
|
|
413
443
|
specs.append({"server": srv["name"], "name": t["name"],
|
|
414
444
|
"full_name": full_name,
|
|
415
445
|
"description": t["description"],
|
|
416
446
|
"input_schema": t["input_schema"]})
|
|
417
|
-
|
|
418
|
-
_TOOLS_CACHE[(scope_sha, srv["name"])] = (time.time(), specs)
|
|
447
|
+
discovered[(scope_sha, srv["name"])] = (time.time(), specs)
|
|
419
448
|
out.extend(specs)
|
|
420
449
|
if len(out) >= MAX_MCP_TOOLS:
|
|
421
450
|
break
|
|
451
|
+
with _LOCK:
|
|
452
|
+
if config_sha != _TOOLS_CONFIG_SHA:
|
|
453
|
+
return []
|
|
454
|
+
for key in list(_TOOLS_CACHE):
|
|
455
|
+
if isinstance(key, tuple) and key[0] == scope_sha:
|
|
456
|
+
del _TOOLS_CACHE[key]
|
|
457
|
+
_TOOLS_CACHE.update(discovered)
|
|
422
458
|
return out[:MAX_MCP_TOOLS]
|
|
423
459
|
|
|
424
460
|
|
|
425
461
|
def dispatch_full_name(full_name, arguments, timeout_s=_CALL_TIMEOUT, *,
|
|
426
|
-
sandbox=None, workdir=None, disabled_tools=None
|
|
462
|
+
sandbox=None, workdir=None, disabled_tools=None,
|
|
463
|
+
cancel_event=None, deadline=None):
|
|
427
464
|
"""mcp__<server>__<tool> → 找服务器配置并调用。返回 {"ok","text","error"}。"""
|
|
428
|
-
|
|
429
|
-
if
|
|
465
|
+
full_name = str(full_name or "")
|
|
466
|
+
sname, sep, tool = full_name[5:].partition("__") if full_name.startswith("mcp__") \
|
|
467
|
+
else ("", "", "")
|
|
468
|
+
if not sep or not _NAME_RE.fullmatch(sname) or "__" in sname or not tool:
|
|
430
469
|
return {"ok": False, "text": "", "error": "非法 MCP 工具名"}
|
|
431
|
-
sname, tool = m.group(1), m.group(2)
|
|
432
470
|
servers, err = parse_servers(_settings_text())
|
|
433
471
|
if err:
|
|
434
472
|
return {"ok": False, "text": "", "error": err}
|
|
@@ -443,7 +481,8 @@ def dispatch_full_name(full_name, arguments, timeout_s=_CALL_TIMEOUT, *,
|
|
|
443
481
|
if sandbox is None or not workdir:
|
|
444
482
|
return {"ok": False, "text": "", "error": "MCP 调用缺少任务沙箱策略或工作目录,拒绝启动"}
|
|
445
483
|
return call_tool(srv, tool, arguments, timeout_s=timeout_s,
|
|
446
|
-
sandbox=sandbox, workdir=workdir
|
|
484
|
+
sandbox=sandbox, workdir=workdir,
|
|
485
|
+
cancel_event=cancel_event, deadline=deadline)
|
|
447
486
|
|
|
448
487
|
|
|
449
488
|
_SETTINGS_TEXT = None # main.py 启动注入(读 settings.mcp_servers)——本模块
|
package/app/core/pipeline.py
CHANGED
|
@@ -807,6 +807,18 @@ def _cost_gate_block():
|
|
|
807
807
|
return None
|
|
808
808
|
|
|
809
809
|
|
|
810
|
+
def _run_sandbox(run_id):
|
|
811
|
+
"""Return a run's persisted sandbox policy, or the unrestricted default hint.
|
|
812
|
+
|
|
813
|
+
Low-level callers such as budget-gate tests may use a run id before creating
|
|
814
|
+
a task. The runner still enforces any task policy when one is present.
|
|
815
|
+
"""
|
|
816
|
+
run = store.get_run(run_id) or {}
|
|
817
|
+
task_id = run.get("task_id")
|
|
818
|
+
task = store.get_task(task_id) if task_id else None
|
|
819
|
+
return (task or {}).get("sandbox") or {}
|
|
820
|
+
|
|
821
|
+
|
|
810
822
|
def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
|
|
811
823
|
timeout, resume, step, log_abs, images=None, require_tools=False,
|
|
812
824
|
deadline=None):
|
|
@@ -846,9 +858,7 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
|
|
|
846
858
|
timeout=timeout, cancel_event=ev, log_path=str(log_abs),
|
|
847
859
|
images=images, require_tools=require_tools, deadline=deadline,
|
|
848
860
|
command_context={"run_id": session_run_id,
|
|
849
|
-
"sandbox": (
|
|
850
|
-
(store.get_run(session_run_id) or {}).get("task_id")
|
|
851
|
-
) or {}).get("sandbox") or {},
|
|
861
|
+
"sandbox": _run_sandbox(session_run_id),
|
|
852
862
|
"allow_terminal": not readonly},
|
|
853
863
|
checkpoint_run_id=session_run_id)
|
|
854
864
|
|
|
@@ -880,9 +890,7 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
|
|
|
880
890
|
resume=resume, images=images, require_tools=require_tools,
|
|
881
891
|
deadline=deadline,
|
|
882
892
|
command_context={"run_id": session_run_id,
|
|
883
|
-
"sandbox": (
|
|
884
|
-
(store.get_run(session_run_id) or {}).get("task_id")
|
|
885
|
-
) or {}).get("sandbox") or {},
|
|
893
|
+
"sandbox": _run_sandbox(session_run_id),
|
|
886
894
|
"allow_terminal": not readonly},
|
|
887
895
|
checkpoint_run_id=session_run_id)
|
|
888
896
|
# 默认关闭压缩和 resume 都走直通分支,也必须把真实 usage 送进预算表;否则
|
package/app/core/publish/auto.py
CHANGED
|
@@ -215,6 +215,67 @@ def pending(task_id, platform):
|
|
|
215
215
|
return out, ""
|
|
216
216
|
|
|
217
217
|
|
|
218
|
+
def chapter_review_states(task, items, quality, hard_ok, hard_reason="", direct_ok=True):
|
|
219
|
+
"""逐章最新评审证据与实际发布门禁同时展示,不把单章通过当作放行。"""
|
|
220
|
+
from .. import store, quality_gate
|
|
221
|
+
try:
|
|
222
|
+
reviewed_through = int(quality.get("reviewed_through") or 0)
|
|
223
|
+
except (ValueError, TypeError):
|
|
224
|
+
reviewed_through = 0
|
|
225
|
+
runs = []
|
|
226
|
+
for tid in store.serial_chain_ids(task.get("id")):
|
|
227
|
+
runs.extend(store.task_runs(tid) or [])
|
|
228
|
+
evidence = {}
|
|
229
|
+
for run in sorted(runs, key=lambda r: str(r.get("id") or ""), reverse=True):
|
|
230
|
+
verdict = run.get("verdict") if run.get("status") == "done" else None
|
|
231
|
+
if not isinstance(verdict, dict):
|
|
232
|
+
continue
|
|
233
|
+
for score in verdict.get("chapter_scores") or []:
|
|
234
|
+
if not isinstance(score, dict):
|
|
235
|
+
continue
|
|
236
|
+
try:
|
|
237
|
+
n = int(score.get("chapter") or 0)
|
|
238
|
+
except (ValueError, TypeError):
|
|
239
|
+
continue
|
|
240
|
+
if n > 0 and n not in evidence:
|
|
241
|
+
evidence[n] = (score, run)
|
|
242
|
+
for item in items:
|
|
243
|
+
score, run = evidence.get(item["chapter_no"], ({}, {}))
|
|
244
|
+
state, reason = "pending", "尚无有效单章评审"
|
|
245
|
+
try:
|
|
246
|
+
valid = (score.get("passed") is True
|
|
247
|
+
and int(score.get("reviewer_count") or 0) >= quality_gate.MIN_REVIEWERS
|
|
248
|
+
and score.get("reviewer_consensus") is True)
|
|
249
|
+
except (ValueError, TypeError):
|
|
250
|
+
valid = False
|
|
251
|
+
if score.get("passed") is False:
|
|
252
|
+
state, reason = "failed", "单章质量评审未通过"
|
|
253
|
+
elif score and not valid:
|
|
254
|
+
reason = "有效评审人数不足或未形成共识"
|
|
255
|
+
elif valid:
|
|
256
|
+
state, reason = "passed_blocked", "单章已过审,发布门禁未过"
|
|
257
|
+
if not hard_ok:
|
|
258
|
+
reason = hard_reason
|
|
259
|
+
elif not quality.get("allowed"):
|
|
260
|
+
reason = ";".join(quality.get("blockers") or []) or reason
|
|
261
|
+
elif item["chapter_no"] > reviewed_through:
|
|
262
|
+
reason = "目标章节超出当前发布评审版本"
|
|
263
|
+
elif not direct_ok:
|
|
264
|
+
reason = "平台发布流程尚未校准,可人工确认发布,暂不可自动直发"
|
|
265
|
+
else:
|
|
266
|
+
state, reason = "ready", "单章及发布门禁均已通过"
|
|
267
|
+
try:
|
|
268
|
+
reviewed_at = time.mktime(time.strptime(run.get("ended_at") or "", "%Y-%m-%d %H:%M:%S"))
|
|
269
|
+
if os.path.getmtime(os.path.join(task.get("workdir") or "", item["file"])) > reviewed_at + 1:
|
|
270
|
+
state, reason = "stale", "正文在评审后修改,需要重新评审"
|
|
271
|
+
except (OSError, ValueError, TypeError):
|
|
272
|
+
pass
|
|
273
|
+
item.update(review_status=state, review_reason=reason,
|
|
274
|
+
review_passed=valid and state != "stale",
|
|
275
|
+
publish_ready=state == "ready", review_run_id=run.get("id") or "")
|
|
276
|
+
return items
|
|
277
|
+
|
|
278
|
+
|
|
218
279
|
def status(task_id):
|
|
219
280
|
"""前端视图:待发清单 + 护栏状态 + 自动发布进度。
|
|
220
281
|
|
|
@@ -233,6 +294,7 @@ def status(task_id):
|
|
|
233
294
|
if not _book_ready(info):
|
|
234
295
|
hard_ok, hard_why = False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
|
|
235
296
|
blockers = []
|
|
297
|
+
quality = {"allowed": False, "blockers": []}
|
|
236
298
|
if task and _book_ready(info):
|
|
237
299
|
quality = manager._quality_release_guard(task, plat, action="publish")
|
|
238
300
|
if not quality.get("allowed"):
|
|
@@ -240,15 +302,24 @@ def status(task_id):
|
|
|
240
302
|
ok, why = hard_ok, hard_why
|
|
241
303
|
if ok and blockers:
|
|
242
304
|
ok, why = False, "质量门禁拦截:%s" % ";".join(blockers)
|
|
305
|
+
direct_ok = calibrated(plat)
|
|
306
|
+
chapter_review_states(task or {}, pend, quality, hard_ok, hard_why, direct_ok)
|
|
307
|
+
published = ledger.published_chapters(task_id, plat)
|
|
308
|
+
drafted = ledger.drafted_chapters(task_id, plat) - published
|
|
243
309
|
books.append({"platform": plat, "bound": True,
|
|
244
310
|
"title": info.get("title") or "",
|
|
245
311
|
"pending": len(pend),
|
|
246
|
-
"items": pend
|
|
312
|
+
"items": pend,
|
|
313
|
+
"published_latest": max(published, default=0),
|
|
314
|
+
"next_chapter": pend[0]["chapter_no"] if pend else None,
|
|
315
|
+
"ready_count": sum(p["publish_ready"] for p in pend),
|
|
316
|
+
"review_passed_count": sum(p["review_passed"] for p in pend),
|
|
317
|
+
"drafted_nos": sorted(drafted),
|
|
247
318
|
"guard_ok": ok, "guard_reason": why,
|
|
248
319
|
"draft_ok": hard_ok, "draft_reason": hard_why,
|
|
249
320
|
"quality_blockers": blockers,
|
|
250
|
-
"drafted": len(
|
|
251
|
-
"calibrated":
|
|
321
|
+
"drafted": len(drafted),
|
|
322
|
+
"calibrated": direct_ok})
|
|
252
323
|
run = _running.get(task_id) or None
|
|
253
324
|
if run:
|
|
254
325
|
run = dict(run)
|
|
@@ -102,6 +102,11 @@ def published_chapters(task_id, platform):
|
|
|
102
102
|
out.add(int(n))
|
|
103
103
|
try:
|
|
104
104
|
ent = book_for(task_id, platform) or {}
|
|
105
|
+
# 用户确认的连续发布进度独立于平台数行结果;跨卷校准漏行不能
|
|
106
|
+
# 让已发布的旧章重新进入待发。它是章号边界,不是平台章节数量。
|
|
107
|
+
through = int(ent.get("confirmed_published_through") or 0)
|
|
108
|
+
if 0 < through <= 100000:
|
|
109
|
+
out.update(range(1, through + 1))
|
|
105
110
|
for n in (ent.get("remote_published_nos") or []):
|
|
106
111
|
n = int(n)
|
|
107
112
|
if n > 0:
|