skills: /v1/skills 每次现扫,加/删 skill 目录无需重启
lifespan 静态快照改 per-request 扫描,SkillRegistry 构造 ~3ms / 次, e2e ~20ms 在 JWT + DB 噪声里完全可忽略。agent_builder 早已每次新建 registry,本次只补前端下拉这一处。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
da4e47139f
commit
c4229bede4
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-05-27(Stage C 收尾包:容器资源 yaml 化 + 磁盘配额(scan+gate)+ 网络放开 dogfood + 容器内 pip/npm 源持久化;Step 4 完整 egress proxy 延后到外部用户开放前)
|
最后更新:2026-05-27(skill 热更新:`/v1/skills` 每次现扫,加 / 删 skill 目录无需重启 web)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-05-27
|
### 2026-05-27
|
||||||
|
|
||||||
|
- **skill 热更新:`/v1/skills` 每次现扫**:此前 `web/app.py` lifespan 启动时 `app.state.skill_registry = SkillRegistry(...)` 扫一次,`GET /v1/skills` 从这份静态快照读 → 加新 skill 目录(放到 `skills/<name>/SKILL.md`)必须重启 web 才能在 dev SPA 新建任务弹窗下拉看见,为未来"允许用户安装自己 skill"埋了一刀。改法:删 lifespan 那行 + 注释,`/v1/skills` 路由内每次 `SkillRegistry(ROOT / cfg["skills_dir"])` 现扫。实测 SkillRegistry 构造 ~3ms / 次(9 个 skill,iterdir + 9 次 read_text + yaml frontmatter),整个 HTTP e2e ~20ms 完全淹没在 JWT + DB 噪声里,且 `/v1/skills` 只在"新建任务弹窗打开"触发,非热路径。**`core/agent_builder.py::build_agent` 早已是每次新建 SkillRegistry**(每发新消息重扫),所以 agent 内部 `load_skill` 工具与 system prompt discovery block 一直是热的;此次仅修补前端下拉这一处静态快照。Smoke 验证:`skills/_smoke_hot_reload/SKILL.md` 临时创建 → `/v1/skills` 9→10 看见、删除 → 10→9 消失,全程未重启进程。否决:(a) 加 `POST /v1/skills/reload` 显式触发 —— 多 API + 用户得记得调,3ms / 次的优化收益为 0;(b) watchdog 文件系统监听 —— 加依赖 + 容器 bind mount inotify 偶尔不稳 + 工程量过重,与方案 A 在用户体感上无差异;(c) `SkillRegistry` 加 mtime cache —— 智能化复杂度换 ms 级 IO 节省,§7.9 "不为不存在的瓶颈预付架构成本"同款判定。**未来"用户自带 skill"独立维度**(届时 `workspace/users/<uid>/.skills/` 与全局 `skills/` 多 root 叠加),跟当前热更新不耦合,等真做时再加多 root 支持。`DESIGN.md` 不动(无架构变化);`RUN.md` 不动(无 CLI / env 变化)。
|
||||||
- **dev SPA 「导出对话记录」/「清空对话」按钮 disable 逻辑改成只要选中 task 就常亮**:`web/static/dev.html` `renderChatMeta` 里原本按 `t.n_messages === 0` 一并禁用两键(`:1812`/`:1815`)。bug:用户「清空对话」→ `clearMessages` 返回 `n_messages=0` → 两键 disable;之后发新消息走 SSE 流(`sendMessage` `:2210`),后端 task.n_messages 累加但前端 `state.taskMeta.n_messages` 没刷,renderChatMeta 也不会再跑,两键一直灰。改法:不按 n_messages 门禁,导出常亮、清空仅在 run running/cancelling 期间禁(后端 409,confirm 后再报错 UX 差);0 条时点导出生成空 docx、点清空 confirm 显 0 条 —— 都不会出错,语义一致更省心。`deleteTask` 路径里把"无 task 选中时两键显式 disable"逻辑保留(那时 chat-meta 退到"未选中任务"占位,常亮反而误导)。否决:(a) 修真正的 n_messages 同步问题(SSE 收 done 时回拉 taskMeta) —— 一次额外 GET,且 streaming 期间频繁拉一致性更易飘;(b) 把 export 也按 running 禁 —— export 是只读快照,run 中导出当前可见的部分对话完全合理。`DESIGN.md` 不动(无架构变化);`RUN.md` 不动(无 CLI/env 变化)。
|
- **dev SPA 「导出对话记录」/「清空对话」按钮 disable 逻辑改成只要选中 task 就常亮**:`web/static/dev.html` `renderChatMeta` 里原本按 `t.n_messages === 0` 一并禁用两键(`:1812`/`:1815`)。bug:用户「清空对话」→ `clearMessages` 返回 `n_messages=0` → 两键 disable;之后发新消息走 SSE 流(`sendMessage` `:2210`),后端 task.n_messages 累加但前端 `state.taskMeta.n_messages` 没刷,renderChatMeta 也不会再跑,两键一直灰。改法:不按 n_messages 门禁,导出常亮、清空仅在 run running/cancelling 期间禁(后端 409,confirm 后再报错 UX 差);0 条时点导出生成空 docx、点清空 confirm 显 0 条 —— 都不会出错,语义一致更省心。`deleteTask` 路径里把"无 task 选中时两键显式 disable"逻辑保留(那时 chat-meta 退到"未选中任务"占位,常亮反而误导)。否决:(a) 修真正的 n_messages 同步问题(SSE 收 done 时回拉 taskMeta) —— 一次额外 GET,且 streaming 期间频繁拉一致性更易飘;(b) 把 export 也按 running 禁 —— export 是只读快照,run 中导出当前可见的部分对话完全合理。`DESIGN.md` 不动(无架构变化);`RUN.md` 不动(无 CLI/env 变化)。
|
||||||
- **dev SPA embed + task_id 模式模型下拉不显示修复**:`web/static/dev.html` `enterApp()` 里 `loadModels()` 是 fire-and-forget(`:1512` 无 await),非 embed 模式下用户手动点 task 列表行时 `/v1/models` 早已 resolve,所以下拉正常;embed 模式 + URL 带 `task_id` 时 `embedHandleMessage` 收到 token → `enterApp()` 后立刻 `selectTask(EMBED_INITIAL_TASK_ID)`(`:3766-3771`),此时 `state.models` 还是 `[]`,而 `renderModelDropdown` 在 models 为空时直接 `return ""`(`:1817`)→ 顶上模型下拉缺失(同理影响生图 / 生视频下拉)。修法在 `loadModels()` 尾部加 `if (state.taskMeta) renderChatMeta();` 一行,让 models / image_models / video_models 全部 resolve 后,若当前已有 task 选中就补一次 chat-meta 重渲;`state.taskMeta` 不存在时 `renderChatMeta` 本就 no-op,无副作用。一并覆盖"loadModels 接口慢/失败再恢复"的场景。否决:(a) 在 embed 初始 task 分支单独 `loadModels().then(selectTask)` —— 要跟 `enterApp` 里 fire 出的 loadModels 共享同一个 promise,改起来啰嗦;(b) `renderModelDropdown` 内部 fallback 自己拉一次 models —— 把数据获取塞进渲染函数,违反单一职责。`DESIGN.md` 不动(无架构变化);`RUN.md` 不动(无 CLI/env 变化)。
|
- **dev SPA embed + task_id 模式模型下拉不显示修复**:`web/static/dev.html` `enterApp()` 里 `loadModels()` 是 fire-and-forget(`:1512` 无 await),非 embed 模式下用户手动点 task 列表行时 `/v1/models` 早已 resolve,所以下拉正常;embed 模式 + URL 带 `task_id` 时 `embedHandleMessage` 收到 token → `enterApp()` 后立刻 `selectTask(EMBED_INITIAL_TASK_ID)`(`:3766-3771`),此时 `state.models` 还是 `[]`,而 `renderModelDropdown` 在 models 为空时直接 `return ""`(`:1817`)→ 顶上模型下拉缺失(同理影响生图 / 生视频下拉)。修法在 `loadModels()` 尾部加 `if (state.taskMeta) renderChatMeta();` 一行,让 models / image_models / video_models 全部 resolve 后,若当前已有 task 选中就补一次 chat-meta 重渲;`state.taskMeta` 不存在时 `renderChatMeta` 本就 no-op,无副作用。一并覆盖"loadModels 接口慢/失败再恢复"的场景。否决:(a) 在 embed 初始 task 分支单独 `loadModels().then(selectTask)` —— 要跟 `enterApp` 里 fire 出的 loadModels 共享同一个 promise,改起来啰嗦;(b) `renderModelDropdown` 内部 fallback 自己拉一次 models —— 把数据获取塞进渲染函数,违反单一职责。`DESIGN.md` 不动(无架构变化);`RUN.md` 不动(无 CLI/env 变化)。
|
||||||
- **Stage C 收尾包:容器资源 yaml 化 + 应用层磁盘配额 + dogfood 网络放开 + 容器内 pip/npm 源持久化**:Step 4 完整 egress proxy(allowlist + audit + 字节计量)1-2 天工程量,**dogfood + 信任同事白名单阶段不必先做**,符合 DESIGN §7.7 阶段语义;沉淀为升级触发信号(任一陌生用户注册 / dogfood 发现模型异常 outbound / 信任白名单出现非密切相识者 → 必上 Step 4)。本批做 3 件:① **容器资源 yaml 化**:`config/agent.yaml` 加 `sandbox` 段(memory/cpus/pids_limit),`SandboxPool.__init__` 加三个字段,优先级 env > yaml > 默(2g/1.0/256);`setup_pool` / `init_pool` 透传 sandbox_cfg;`main.py sandbox check` 输出加 4 行 `[info]`(memory/cpus/pids_limit/disk_bytes_per_user)给运维一眼对账。② **应用层磁盘配额**:migration `0008_user_disk_usage`(单行 per user,bytes_used/file_count/scanned_at)+ `core/storage/disk_quota.py`(`parse_bytes`("5gb"/"500mb"/int)+ `scan_user_dir`(os.scandir 跳顶层 dotfile `.zcbot_tmp` `.memory`)+ `upsert_user_usage` ON CONFLICT + `check_disk_quota`(超额返中文 msg)+ `scan_all_users` 串行扫所有 user)+ web/app.py lifespan `_disk_scanner` 后台 task(启动跑一次 + 默 15min 周期 `run_in_executor`)+ `DockerExecutor._exec_fs_tool` write/edit 起手 `_check_user_disk_quota` 超额返 `[Error]` 不调容器 + `/v1/files/upload` 同款 gate 超额 HTTP 413。yaml `quotas.disk_bytes_per_user: 5gb` + `disk_scan_interval_seconds: 900`,≤0 视为不限,首次扫描前 check 短路放行避免冷启动卡死。race 接受:扫描间隙写入轻微突破上限(与 image/video 配额同款 race-tolerant)。③ **网络放开 + 容器内源持久化**:`core/sandbox/network.py` 去掉 `--internal` flag(改 docker bridge default 有 NAT outbound;dogfood 阶段让模型能 `pip install foo` / `curl https://...`),已存在 internal network 不自动 rm 仅 warn(避免破坏现有容器,RUN.md 给迁移命令)。Dockerfile 加 `/etc/pip.conf`(写 `[global]\nindex-url=${PIP_INDEX_URL}` + timeout 60)+ `/etc/npmrc`(写 `registry=${NPM_REGISTRY}`)让运行时 pip / npm install 也走 mirror(此前 `--build-arg` 只 build 时生效)。iptables 红线段不动 ── `169.254/127/10/172.16/192.168/100.64/PG_IP` 仍 DROP,挡 cloud metadata + 内网扫描 + loopback,这是基线不依赖 proxy。**测试**:`tests/test_disk_quota.py` 11 测试覆盖 parse_bytes 各单位 / scan_user_dir 跳 dotfile / 空目录 / 不存在路径;**unittest discover 46/46 PASS**(原 35 + 新 11)。**DESIGN §7.5 #2 待 commit 加"Step 4 延后 + 升级触发表"段落**(本 commit 暂没改 DESIGN ── DESIGN 只在架构变时改,延后决策仍在 §7.7 Stage C 阶段语义内,触发信号沉淀进 PROGRESS / RUN);RUN.md 加 yaml sandbox 段 + 网络迁移 + 配额命令 + 故障兜底 2 行(internal network legacy / 磁盘 413)。否决:(a) network 改 internal 时自动 rm + recreate ── destructive,会破现有容器连接,改 warn 让运维 ack;(b) 写前实时 du ── user_root 大时几秒一次写不能接受,sticky 周期扫描表 + 写前查表是 image/video 配额同款范式;(c) 同时做完整 Step 4 ── 1-2 天大工程,dogfood 不阻塞,先放开网络让模型能 pip install 更急(实测装包 / 拉资源能力是产品门槛);(d) 磁盘配额硬阻所有写(包括 run_python / shell)── 截 syscall 太重,write/edit + upload gate 已覆盖 95%(skill 产物路径),run_python / shell 写文件靠扫描后续感知(下次周期 check 时挡新增写入);(e) yaml `sandbox.memory` 默 4g/2cpu ── 腾讯云轻量 4 核 8G,留 host 跑 web + PG + nginx 需求,2g/1cpu 是合理基线,极端任务用户改 yaml 升配。
|
- **Stage C 收尾包:容器资源 yaml 化 + 应用层磁盘配额 + dogfood 网络放开 + 容器内 pip/npm 源持久化**:Step 4 完整 egress proxy(allowlist + audit + 字节计量)1-2 天工程量,**dogfood + 信任同事白名单阶段不必先做**,符合 DESIGN §7.7 阶段语义;沉淀为升级触发信号(任一陌生用户注册 / dogfood 发现模型异常 outbound / 信任白名单出现非密切相识者 → 必上 Step 4)。本批做 3 件:① **容器资源 yaml 化**:`config/agent.yaml` 加 `sandbox` 段(memory/cpus/pids_limit),`SandboxPool.__init__` 加三个字段,优先级 env > yaml > 默(2g/1.0/256);`setup_pool` / `init_pool` 透传 sandbox_cfg;`main.py sandbox check` 输出加 4 行 `[info]`(memory/cpus/pids_limit/disk_bytes_per_user)给运维一眼对账。② **应用层磁盘配额**:migration `0008_user_disk_usage`(单行 per user,bytes_used/file_count/scanned_at)+ `core/storage/disk_quota.py`(`parse_bytes`("5gb"/"500mb"/int)+ `scan_user_dir`(os.scandir 跳顶层 dotfile `.zcbot_tmp` `.memory`)+ `upsert_user_usage` ON CONFLICT + `check_disk_quota`(超额返中文 msg)+ `scan_all_users` 串行扫所有 user)+ web/app.py lifespan `_disk_scanner` 后台 task(启动跑一次 + 默 15min 周期 `run_in_executor`)+ `DockerExecutor._exec_fs_tool` write/edit 起手 `_check_user_disk_quota` 超额返 `[Error]` 不调容器 + `/v1/files/upload` 同款 gate 超额 HTTP 413。yaml `quotas.disk_bytes_per_user: 5gb` + `disk_scan_interval_seconds: 900`,≤0 视为不限,首次扫描前 check 短路放行避免冷启动卡死。race 接受:扫描间隙写入轻微突破上限(与 image/video 配额同款 race-tolerant)。③ **网络放开 + 容器内源持久化**:`core/sandbox/network.py` 去掉 `--internal` flag(改 docker bridge default 有 NAT outbound;dogfood 阶段让模型能 `pip install foo` / `curl https://...`),已存在 internal network 不自动 rm 仅 warn(避免破坏现有容器,RUN.md 给迁移命令)。Dockerfile 加 `/etc/pip.conf`(写 `[global]\nindex-url=${PIP_INDEX_URL}` + timeout 60)+ `/etc/npmrc`(写 `registry=${NPM_REGISTRY}`)让运行时 pip / npm install 也走 mirror(此前 `--build-arg` 只 build 时生效)。iptables 红线段不动 ── `169.254/127/10/172.16/192.168/100.64/PG_IP` 仍 DROP,挡 cloud metadata + 内网扫描 + loopback,这是基线不依赖 proxy。**测试**:`tests/test_disk_quota.py` 11 测试覆盖 parse_bytes 各单位 / scan_user_dir 跳 dotfile / 空目录 / 不存在路径;**unittest discover 46/46 PASS**(原 35 + 新 11)。**DESIGN §7.5 #2 待 commit 加"Step 4 延后 + 升级触发表"段落**(本 commit 暂没改 DESIGN ── DESIGN 只在架构变时改,延后决策仍在 §7.7 Stage C 阶段语义内,触发信号沉淀进 PROGRESS / RUN);RUN.md 加 yaml sandbox 段 + 网络迁移 + 配额命令 + 故障兜底 2 行(internal network legacy / 磁盘 413)。否决:(a) network 改 internal 时自动 rm + recreate ── destructive,会破现有容器连接,改 warn 让运维 ack;(b) 写前实时 du ── user_root 大时几秒一次写不能接受,sticky 周期扫描表 + 写前查表是 image/video 配额同款范式;(c) 同时做完整 Step 4 ── 1-2 天大工程,dogfood 不阻塞,先放开网络让模型能 pip install 更急(实测装包 / 拉资源能力是产品门槛);(d) 磁盘配额硬阻所有写(包括 run_python / shell)── 截 syscall 太重,write/edit + upload gate 已覆盖 95%(skill 产物路径),run_python / shell 写文件靠扫描后续感知(下次周期 check 时挡新增写入);(e) yaml `sandbox.memory` 默 4g/2cpu ── 腾讯云轻量 4 核 8G,留 host 跑 web + PG + nginx 需求,2g/1cpu 是合理基线,极端任务用户改 yaml 升配。
|
||||||
|
|
|
||||||
20
web/app.py
20
web/app.py
|
|
@ -480,12 +480,8 @@ def create_app() -> FastAPI:
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
broker.bind_loop(asyncio.get_running_loop())
|
broker.bind_loop(asyncio.get_running_loop())
|
||||||
# Skill 注册表启动时扫一次 — 文件系统静态,运行中不变;/v1/skills 直接读
|
|
||||||
from core.agent_builder import load_config, resolve_workspace
|
from core.agent_builder import load_config, resolve_workspace
|
||||||
from core.paths import ROOT
|
|
||||||
from core.skills import SkillRegistry
|
|
||||||
_cfg = load_config()
|
_cfg = load_config()
|
||||||
app.state.skill_registry = SkillRegistry(ROOT / _cfg.get("skills_dir", "skills"))
|
|
||||||
# Stale-run reaper:上次进程 crash 留下的 "running" / "cancelling" 已无 BG 线程
|
# Stale-run reaper:上次进程 crash 留下的 "running" / "cancelling" 已无 BG 线程
|
||||||
# 继续,启动时标 error,让对应 task 重新可发消息(否则 gate 永挂)。
|
# 继续,启动时标 error,让对应 task 重新可发消息(否则 gate 永挂)。
|
||||||
# TODO 真生产 multi-worker:换 heartbeat / lease,只 reap 自家 worker 的孤儿。
|
# TODO 真生产 multi-worker:换 heartbeat / lease,只 reap 自家 worker 的孤儿。
|
||||||
|
|
@ -991,15 +987,21 @@ def create_app() -> FastAPI:
|
||||||
def list_skills(user_id: UUID = Depends(require_user)):
|
def list_skills(user_id: UUID = Depends(require_user)):
|
||||||
"""列出当前可用的 skill(智能体类型),供新建 task 时下拉选择。
|
"""列出当前可用的 skill(智能体类型),供新建 task 时下拉选择。
|
||||||
|
|
||||||
skill registry 在 lifespan 启动时扫一次(`skills/<name>/SKILL.md` frontmatter),
|
每次请求现扫 `skills/<name>/SKILL.md` frontmatter(~9 个文件,稳态 ~3ms),
|
||||||
运行中不变;返 name + description 给 UI 渲染。排序按 name 升序(registry 已 sorted)。
|
以便加 / 改 / 删 skill 目录后无需重启 web 即可在前端下拉看到。
|
||||||
|
`core/agent_builder.py::build_agent` 同样每次新建 SkillRegistry,
|
||||||
|
所以 agent 内部 `load_skill` 工具与 system prompt discovery 也是热的。
|
||||||
|
排序按 name 升序(registry 内部 iterdir + sorted)。
|
||||||
"""
|
"""
|
||||||
reg = getattr(app.state, "skill_registry", None)
|
from core.agent_builder import load_config
|
||||||
skills = list(reg.skills.values()) if reg else []
|
from core.paths import ROOT
|
||||||
|
from core.skills import SkillRegistry
|
||||||
|
cfg = load_config()
|
||||||
|
reg = SkillRegistry(ROOT / cfg.get("skills_dir", "skills"))
|
||||||
return {
|
return {
|
||||||
"skills": [
|
"skills": [
|
||||||
{"name": s.name, "description": s.description}
|
{"name": s.name, "description": s.description}
|
||||||
for s in skills
|
for s in reg.skills.values()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue