Ver Fonte

feat: 完善 Agent 执行流程并新增 Linux 版本化部署

统一执行准入、会话隔离、请求去重、计费补偿与人工交互恢复。新增版本化部署包、依赖锁定、服务管理和运行调试文档。

验证:190 项 pytest 测试通过;Linux ARM64 安装、启停、升级和回退验证通过。
gaoshutao há 1 mês atrás
pai
commit
f79c3dabe8
52 ficheiros alterados com 4542 adições e 753 exclusões
  1. 93 293
      README.md
  2. 1 0
      VERSION
  3. 7 9
      agents/agent_factory.py
  4. 3 3
      agents/agent_memory.py
  5. 50 0
      agents/execution.py
  6. 2 1
      agents/prompts.py
  7. 35 0
      agents/workspace.py
  8. 101 193
      api/chat_routes.py
  9. 42 0
      api/concurrency.py
  10. 3 2
      api/context_meter.py
  11. 59 0
      api/interrupts.py
  12. 4 3
      api/models.py
  13. 17 17
      api/schedule_routes.py
  14. 29 9
      api/session_routes.py
  15. 110 75
      api/sse_core.py
  16. 2 0
      billing/guard.py
  17. 40 3
      billing/meter.py
  18. 0 8
      billing/pricing.py
  19. 1 0
      config.py
  20. 13 2
      db/billing_store.py
  21. 1 0
      db/chat_store.py
  22. 56 12
      db/db_conn.py
  23. 23 0
      db/mysql_schema.sql
  24. 27 0
      db/run_store.py
  25. 2 2
      db/schedule_store.py
  26. 23 0
      db/schema_sqlite.sql
  27. 324 0
      docs/DEVELOPMENT.md
  28. 62 3
      main.py
  29. 122 0
      release/README.md
  30. 81 0
      release/baseline.constraints
  31. 164 0
      release/build.py
  32. 64 0
      release/common.py
  33. 296 0
      release/deploy.py
  34. 4 0
      release/install.sh
  35. 9 0
      release/lock.meta.json
  36. 26 0
      release/lock.py
  37. 1804 0
      release/requirements.lock
  38. 4 0
      release/service.sh
  39. 22 0
      release/vent-agent.service.example
  40. 19 0
      release/verify.py
  41. 43 106
      scheduler/runner.py
  42. 12 1
      scheduler/scheduler.py
  43. 5 4
      scripts/http_e2e.py
  44. 38 5
      static/app.js
  45. 1 1
      static/index.html
  46. 3 0
      tests/conftest.py
  47. 21 0
      tests/frontend_markdown.html
  48. 1 1
      tests/test_core_units.py
  49. 162 0
      tests/test_release.py
  50. 492 0
      tests/test_runtime_regressions.py
  51. 13 0
      tools/demo_tools.py
  52. 6 0
      tools/registry.py

+ 93 - 293
README.md

@@ -1,344 +1,144 @@
 # deep_agent 2.0
 
-基于 [deepagents](https://pypi.org/project/deepagents/) 的深度智能体服务骨架。
-主入口是 `POST /api/chat`,返回 SSE 流。
+基于 deepagents 的 Agent 服务,提供对话、工具调用、人工审批、会话持久化和定时任务;主接口为 `POST /api/chat`(SSE)。
 
-**对外契约与 1.5 完全兼容**:`POST /api/chat` 的 multipart 入参、SSE 帧格式、
-错误码语义都逐字对齐,1.5 的 `static/index.html` 不改一行即可连上。
+**环境:Python 3.12,独立虚拟环境。** 以下源码命令在项目根目录执行,适用于 macOS / Linux。Linux 部署要求见第 4 节。
 
-**对内实现全部重写**:1.5 的业务工具、业务技能、MCP 工具池不在本仓库范围内。
-骨架提供的是结构与纪律,附 4 个演示工具与 1 个示例技能示范扩展方式。
-
----
-
-## 快速开始
+## 1. 本地运行
 
 ```bash
-# 1) 依赖
-pip install -r requirements.txt
-
-# 2) 配置
-cp .env.example .env
-#    至少填 OPENAI_API_KEY 与 OPENAI_BASE_URL
-#    想用真实 MySQL:把 DB_DIALECT 改成 mysql 并填 DB_* 连接信息
-#    (库不存在时启动会自动建库,不用手工 CREATE DATABASE)
-
-# 3) 跑起来
-python main.py
-#    或 bash scripts/start_server.sh start (生产,带 PID 文件与优雅停止)
-
-# 4) 打开测试页
-#    http://127.0.0.1:8080
+python3.12 -m venv .venv
+.venv/bin/python -m pip install --require-hashes --only-binary=:all: -r release/requirements.lock
+[ -f .env ] || cp .env.example .env
 ```
 
-开箱配置走 **SQLite 开发档**(`DB_DIALECT=sqlite`),无需任何数据库服务即可
-跑起来。生产/联调改 `DB_DIALECT=mysql`。
-
-> **不能与 1.5 共用同一个 Python 环境**:2.0 绑定 langchain 1.3 / langgraph 1.2 /
-> deepagents 0.6.12,与 1.5 声明的 `langchain>=0.3` 系列不同代,装在一起必然冲突。
-> 用独立 venv。
-
----
-
-## 服务端口
-
-**8080**(1.5 是 8070,避开冲突)。改 `PORT` 即可。
-
----
-
-## API
-
-主入口与 1.5 一致,额外新增了若干辅助接口。
-
-### 对话
-
-| 方法 | 路径 | 说明 |
-|---|---|---|
-| POST | `/api/chat` | 主入口,SSE 流式 |
-| POST | `/api/chat/resume` | 恢复被中断挂起的对话 |
-| GET | `/api/chat/interrupt` | 查挂起的中断(刷新页面补渲染卡片);有则 200,无则 204 |
-| GET | `/api/chat/history/{session_id}` | 会话历史 |
-
-> **没有 `/api/chat/only`**:1.5 有这个端点,但它与 `/chat` 除了流水上的
-> `kind` 标签外行为完全相同,而那个标签没有任何消费方(不聚合、不分账、
-> 不影响额度),1.5 前端也从未调用过它。2.0 删掉了它——理由与 1.5 为此付过的
-> 代价见 `AGENTS.md` §六。
-
-`POST /api/chat` 请求体是 `multipart/form-data`:
-
-| 字段 | 必填 | 说明 |
-|---|---|---|
-| `message` | 是 | 用户消息,支持 `#skill:<name>` 与 `#session:<uuid>` 前缀 |
-| `session_id` | 否 | 缺省则新建会话 |
-| `thread_id` | 否 | **必须等于 `session_id`**,否则 404 |
-| `mode` | 否 | `plan`(先出计划待批)或 `full`(直接执行) |
-| `file` | 否 | 附件,落到 `/workspace/uploads/` |
-| `user_name` | 否 | 归属用户,缺省 `default`(**2.0 新增,替代 1.5 的鉴权依赖**) |
-
-响应是 `text/event-stream`,头含 `X-Session-Id` 与 `X-Trace-Id`。
-
-### 会话与用量
-
-| 方法 | 路径 | 说明 |
-|---|---|---|
-| GET | `/api/sessions` | 会话列表(置顶优先) |
-| GET | `/api/sessions/search?q=` | 标题模糊搜索 |
-| DELETE | `/api/sessions/{id}` | 删除会话(**同时清图状态**) |
-| DELETE | `/api/messages/{id}` | 删除单条消息 |
-| GET/PUT | `/api/sessions/{id}/mode` | 权限模式 |
-| PUT | `/api/sessions/{id}/pin` · `/title` | 置顶 · 重命名 |
-| GET | `/api/sessions/{id}/context` | 上下文用量明细与压缩存档索引 |
-| GET | `/api/usage/today` | 当日用量与额度 |
-| GET | `/api/usage/recent` | 最近用量流水 |
-| PUT | `/api/usage/quota` | 调整每日额度(0 = 不限) |
-| GET | `/api/usage/pricing` | 模型单价表 |
-| GET | `/api/preferences` · PUT · DELETE | 用户偏好(每轮自动注入) |
-
-### 模型与工具
-
-| 方法 | 路径 | 说明 |
-|---|---|---|
-| GET | `/api/model` | 模型配置与 agent 组装摘要 |
-| GET | `/api/model/tools` | 工具清单(按分组) |
-| POST | `/api/model/thinking` | 切换思考级别(触发 agent 重建) |
-| POST | `/api/model/reload` | 强制重建 agent |
-
-### 定时任务
-
-| 方法 | 路径 | 说明 |
-|---|---|---|
-| GET/POST | `/api/schedules` | 列表 / 新建 |
-| GET/PUT/DELETE | `/api/schedules/{id}` | 详情 / 更新 / 删除 |
-| POST | `/api/schedules/{id}/pause` · `/resume` · `/run` | 暂停 / 恢复 / 立即执行 |
-| GET | `/api/schedules/{id}/runs` | 执行记录 |
-
-### 运维
-
-| 方法 | 路径 | 说明 |
-|---|---|---|
-| GET | `/health` | 逐子系统健康状态 |
-| GET | `/api/health/billing` | 计费自检(单价是否覆盖当前模型) |
-| GET | `/` | 重定向到测试页 |
-| GET | `/static/index.html` | 极简测试页 |
-
----
-
-## SSE 事件契约
-
-14 种业务事件 + 1 种通用错误。**字段名是从 1.5 前端逐行反查出来的**,
-不是猜的。
-
-| 事件 | 前端读取的字段 |
-|---|---|
-| `agent_start` | `agent` · `cn_agent` |
-| `thinking` | `message` · `cn_agent` |
-| `thinking_token` | `content` |
-| `executing` | `message` · `cn_agent` · `tools[]` · `cn_tools[]` |
-| `tool_call` | `tool` · `cn_tool` · `args` · `agent_id` |
-| `tool_result` | `tool` · `cn_tool` · `content` · `error` · `agent_id` |
-| `token` | `content` |
-| `updated_todo_list` | `todos` |
-| `progress` | `message` |
-| `interrupt` | `tool_approval{tool,args,reason,side_effect}` 或 `ask_user{questions[]}` 或 `message` |
-| `citations` | `items[]` · `model_only` |
-| `agent_done` | `agent` · `cn_agent` · `duration_ms` · `progress` · `context_tokens` |
-| `agent_error` | `agent` · `cn_agent` · `message` · `duration_ms` |
-| `done` | `duration_ms` · `usage{}` |
-| `error` | `message` · `code` · `retry_after` |
-
-帧格式:`data: {json}\n\n`,每帧恒带 `session_id`,绑定 trace 时带 `trace_id`。
-
-HTTP 错误语义:额度超限 429;并发超限 429(带 `retry_after`);上传 400/413;
-会话不存在或越权 404;恢复时无挂起中断 409。
-
-**契约的唯一出口是 `api/sse_events.py`**,其他任何地方都不得手拼 `data: `
-字符串。改字段必须同步改 `tests/test_sse_contract.py`。
-
----
-
-## 四项核心能力(相对 1.5 的做法改进)
-
-这是 2.0 与 1.5 的主要差异所在。
-
-### 子智能体跟踪
-
-| | 做法 |
-|---|---|
-| 1.5 | 从子图命名空间字符串 `tools:<X>` **反查** `subagent_type`;id 被上下文压缩裁掉后反查落空,子智能体身份丢失 |
-| 2.0 | 收到 task 调用的**那一刻**就建 `tool_call_id → {类型, 描述}` 表,之后只按 id 查表;表由跟踪器持有,不受历史窗口影响 |
-
-见 `api/subagent_tracker.py`。
-
-### 上下文计量
-
-| | 做法 |
-|---|---|
-| 1.5 | tiktoken **本地估算**优先,为此专门开了 `fix/context-usage-accounting` 分支修口径 |
-| 2.0 | **真实 usage 优先**(`usage_metadata`),provider 不给才回落估算并标 `usage_source=estimate` |
-
-两个量刻意分开:`input_tokens` 累加值是计费口径,`context_tokens`(最近一次
-调用的 input)是当前上下文占用。
+编辑项目根目录 `.env`,填写模型配置,并添加 SQLite 配置即可本地运行:
+
+```dotenv
+OPENAI_API_KEY=你的密钥
+OPENAI_BASE_URL=https://api.deepseek.com
+DEEPAGENT_MODEL=deepseek-flash
+DB_DIALECT=sqlite
+DB_SQLITE_PATH=data/dev.sqlite
+CHECKPOINTER=sqlite
+CHECKPOINT_DB_PATH=data/checkpoints.sqlite
+HOST=127.0.0.1
+PORT=8080
+```
 
-见 `api/context_meter.py`。
+```bash
+.venv/bin/python main.py
+```
 
-### 自动压缩
+浏览器打开 <http://127.0.0.1:8080>;健康检查为 <http://127.0.0.1:8080/health>。前台按 `Ctrl+C` 停止。
 
-| | 做法 |
-|---|---|
-| 1.5 | 自研 `auto_compact.py`,默认**关闭**,未被信任到敢开 |
-| 2.0 | 直接用 deepagents 的 `SummarizationMiddleware`:85% 窗口触发,**把被淘汰的历史追加落盘**到 `/conversation_history/{thread_id}.md` 并把路径嵌进摘要,agent 能自己读回来 |
+**未配置 `DB_DIALECT` 时默认是 MySQL**。使用 MySQL 时设为 `mysql` 并填写 `.env.example` 中的 `DB_*`;数据库不存在时需账户有建库权限,或提前创建数据库。
 
-调节旋钮是 `CONTEXT_LIMIT`(声明窗口),不是阈值比例——阈值由框架固定为
-85%。压缩存档位置可在 `GET /api/sessions/{id}/context` 的 `archives` 里查。
+## 2. Debug / 调试
 
-### 来源标注
+项目没有单独的 `DEBUG=true` 开关。使用开发数据库,在源码目录调试:
 
-| | 做法 |
-|---|---|
-| 1.5 | 靠提示词让**模型自觉**在正文写引用,再用启发式规则去正文里刮 |
-| 2.0 | **工具侧结构化产出**:工具返回 `sourced(data, sources)`,SSE 层从 ToolMessage 抽取、聚合、去重 |
+```bash
+# 详细日志,前台运行
+DEEP_AGENT_ENV_PRIORITY=process LOG_LEVEL=DEBUG .venv/bin/python main.py
 
-模型漏写和编造这两种失败模式,在读工具返回值的路径上根本不存在。
+# Python 自带断点调试器
+DEEP_AGENT_ENV_PRIORITY=process LOG_LEVEL=DEBUG .venv/bin/python -m pdb main.py
+```
 
-见 `api/source_tracker.py` 与 `tools/registry.py`。
+进入 pdb 后,用 `b 文件路径:行号` 设置断点,`c` 继续、`n` 单步、`p 变量名` 查看变量、`q` 退出。
 
----
+使用 IDE 时,解释器选择 `.venv/bin/python`,入口选择 `main.py`,工作目录选择项目根目录;环境变量设置 `DEEP_AGENT_ENV_PRIORITY=process`、`LOG_LEVEL=DEBUG`,然后在源码中打断点。
 
-## 扩展方式
+- 默认 `.env` 优先;`DEEP_AGENT_ENV_PRIORITY=process` 才让命令行环境变量覆盖它。
+- 保持单进程,不启用 `--reload` 或多 worker;修改代码后手动重启。
+- 已安装版本的前台排查方式见第 5 节;修改源码请回到开发目录,发布包启动时会校验文件完整性。
 
-### 加一个业务工具
+## 3. 打包
 
-```python
-# tools/my_tools.py
-from tools.registry import register, source, sourced
+完成第 1 节的依赖安装后执行:
 
-@register(group="我的分组", label="中文显示名", summary="一句话说明")
-def my_tool(query: str, limit: int = 5) -> dict:
-    """做某事。
+```bash
+.venv/bin/python -B release/build.py --version 2.0.0
+```
 
-    Args:
-        query: 查询词。
-        limit: 最多返回几条。
+输出到 `release/dist/`:
 
-    Returns:
-        含 data 与 sources 的结构;sources 会被渲染成引用标注。
-    """
-    return sourced(
-        data=[...],
-        sources=[source("标题", "https://...", "摘要", score=0.9)],
-    )
+```text
+vent-agent-2.0.0-linux.tar.gz
+vent-agent-2.0.0-linux.tar.gz.sha256
 ```
 
-然后把模块名加进 `tools/registry.py` 的 `_BUILTIN_MODULES`。工具名全局唯一,
-重名会在启动时抛错。
-
-**docstring 是给模型看的**:第一行说清做什么,正文说清何时用、参数含义、
-返回什么。模型选错工具多半是因为 docstring 写得含糊。
+- 不传 `--version` 时读取根目录 `VERSION`;同版本产物不覆盖,再次发布请使用新版本号,例如 `2.0.1`。
+- 打包当前工作区,包含未提交修改;自动跑离线测试和自检,失败不出包。
+- 包含源码、静态资源、文档和固定依赖清单;不包含 `.env`、用户数据、日志和本机虚拟环境。
+- 包不绑定 ARM;安装时下载对应架构依赖。已实测 Linux ARM64,x86_64 尚未实测。
 
-### 加一个技能
+## 4. 部署到 Linux
 
-在 `skills/<name>/SKILL.md` 写带 frontmatter 的操作手册:
+服务器需要 **Linux 5.3+、glibc、Python 3.12(含 venv/pip)、Bash 和下载依赖的网络**。以普通用户安装到 `$HOME/vent-agent` 为例:
 
-```markdown
----
-name: my-skill
-description: 什么时候该用这个技能(这段会被模型看到,要写具体)
----
+将压缩包和 `.sha256` 上传到服务器的同一目录,进入该目录执行:
 
-# 标题
-## 步骤
-1. ...
+```bash
+sha256sum -c vent-agent-2.0.0-linux.tar.gz.sha256
+tar -xzf vent-agent-2.0.0-linux.tar.gz
+cd vent-agent-2.0.0-linux
+python3.12 -B release/verify.py
+bash release/install.sh --prefix "$HOME/vent-agent"
 ```
 
-agent 启动时只读 frontmatter,按需才 `read_file` 读全文(渐进披露)。
-用户用 `#skill:my-skill` 前缀显式指定。
-
-### 加一个子智能体
+编辑自动生成的 **`$HOME/vent-agent/shared/.env`**,填写模型密钥和数据库配置;SQLite 可沿用第 1 节的配置。需要远程访问时将 `HOST` 设为 `0.0.0.0`,开放配置的端口。
 
-改 `agents/agent_factory.py` 的 `_build_subagents()`。注意 `tools` 必须是
-`BaseTool` 列表(用 `registry.get_tools_by_name(...)`),`system_prompt`
-不继承父级。
+配置完成后,在解压目录执行:
 
----
+```bash
+bash release/service.sh activate 2.0.0 --prefix "$HOME/vent-agent"
+bash release/service.sh start --prefix "$HOME/vent-agent"
+bash release/service.sh status --prefix "$HOME/vent-agent"
+```
 
-## 配置
+访问 `http://服务器地址:8080`。更换安装路径时,将所有 `--prefix` 和后续路径一起替换;例如 `/opt/vent-agent` 对应 `/opt/vent-agent/shared/.env`。
 
-全部配置项见 `.env.example`,每项都有默认值。几个容易搞错的:
+## 5. 服务器运行与排查
 
-| 变量 | 说明 |
-|---|---|
-| `DEEP_AGENT_ENV_PRIORITY` | `.env` 与进程环境谁优先。`dotenv`(默认)/ `process` / `none`。**跑变体实例用 `process`**:`DEEP_AGENT_ENV_PRIORITY=process DB_DIALECT=mysql PORT=8081 python main.py` |
-| `DB_DIALECT` | `mysql`(生产)/ `sqlite`(本地开发与测试,无需数据库服务) |
-| `DB_NAME` | MySQL 库名;库不存在时启动会自动建库,不需要手工 `CREATE DATABASE` |
-| `CONTEXT_LIMIT` | 声明的上下文窗口,**压缩的唯一调节旋钮**。0 = 用模型登记值 |
-| `CTX_COMPACT_ENABLED` | 关掉会同时失去历史落盘能力,长会话会超窗报错 |
-| `CHECKPOINTER` | `sqlite`(默认,落盘)/ `memory`(重启丢图状态) |
-| `HITL_TOOLS` | 追加需要人工审批的工具名,逗号分隔。如 `calculate` 便于自测中断闭环 |
-| `BILLING_ALLOW_UNPRICED` | 模型未定价时是否按 0 计费。**默认 0 = 直接报错**(fail-closed) |
-| `TFAGENTS_DAILY_TOKEN_LIMIT` | 每用户每日 token 限额,0 = 不限 |
+安装后,下面的脚本会自动识别部署目录:
 
-**配置冲突会被大声报出来**:若 `.env` 与进程环境里有同名但不同的值,启动
-日志会打 WARNING(含脱敏后的生效值),`/health` 的 `config_conflicts` 也能
-看到。这解决的是一个很费时间的现象:改了配置却不生效,却没有任何提示。
+```bash
+~/vent-agent/current/release/service.sh start    # 后台启动并等待健康检查
+~/vent-agent/current/release/service.sh status   # 查看状态、PID 和版本
+~/vent-agent/current/release/service.sh logs     # 跟踪日志,Ctrl+C 退出查看
+~/vent-agent/current/release/service.sh stop     # 停止服务
+```
 
----
+修改 `shared/.env` 后执行 `stop`、`start` 重启。后台日志保存在 `shared/logs/server.log`。
 
-## 验证
+需要前台查看 DEBUG 日志时,先停止后台服务,再执行:
 
 ```bash
-# 离线单元 + SSE 契约测试(不需要密钥,不需要数据库服务)
-python -m pytest
-
-# 结构自检:导入、建表、agent 编译、工具注册
-python scripts/selfcheck.py
-
-# HTTP 层端到端(需服务已启动 + 可用密钥)
-python scripts/http_e2e.py
+~/vent-agent/current/release/service.sh stop
+DEEP_AGENT_ENV_PRIORITY=process LOG_LEVEL=DEBUG ~/vent-agent/current/release/service.sh run
 ```
 
-`http_e2e.py` 覆盖 49 项断言:SSE 帧格式与响应头、落库与顺序、错误语义
-(404/400/409)、**中断/恢复闭环**、定时任务的手动触发与 cron 校验。
-
----
+`run` 在前台运行,按 `Ctrl+C` 停止。systemd 托管时先用 `systemctl stop vent-agent` 停止服务;托管配置见 [发布说明](release/README.md#systemd-托管)。
 
-## 目录结构
+## 6. 升级与回退
 
-```
-main.py                    FastAPI 入口(lifespan / 健康检查 / 静态挂载)
-config.py                  配置唯一入口(.env 读取与冲突检测)
-api/                       chat · sse_core · session · model · usage · schedule · concurrency
-  sse_events.py            ★ SSE 契约唯一事实源
-  sse_core.py              ★ 流式内核(读流 → 派事件 → 收尾)
-  subagent_tracker.py      子智能体生命周期跟踪
-  context_meter.py         上下文计量(真实 usage 优先)
-  source_tracker.py        来源标注抽取与聚合
-agents/                    agent_factory · agent_registry · model_factory · prompts · middleware · agent_memory
-tools/                     registry(工具唯一事实源)· demo_tools(示例工具)
-skills/demo-skill/         示例技能
-db/                        db_conn(双方言)· mysql_schema.sql · schema_sqlite.sql · 三个 store
-billing/                   pricing · guard(预检)· meter(入账)
-scheduler/                 scheduler(APScheduler)· runner(独立会话执行)
-obs/                       log_setup · tracing(trace_id)
-static/index.html          极简测试页
-tests/                     pytest(141 项,离线可跑)
-scripts/                   selfcheck.py · http_e2e.py · start_server.sh
-```
+升级顺序:**安装新版本 → 停旧服务 → 备份数据 → activate 新版本 → 启动并检查**。
 
-标 ★ 的两个文件是核心,改动前先读 `AGENTS.md`。
+新版本安装到 `releases/<版本>/`,旧版本保留;`current` 指向当前版本,配置和用户数据共用 `shared/`。安装不会覆盖已有版本或共享配置。
 
----
+**目前没有自动的数据迁移、兼容性检查或备份。** 回退只切换代码和依赖,不回滚数据库或 Agent checkpoint;结构不兼容时不能直接切回旧版。完整命令见 [升级与回退](release/README.md#升级与回退)。
 
-## 已知缺口
+## 7. 开发验证与参考
 
-**当前只适合内网或开发环境**,主要因为:
-
-1. **没有鉴权** —— `user_name` 由客户端自报,可伪造。计费与会话归属都建立在
-   这个值上。接真实鉴权只需替换 `api/chat_routes._resolve_user`。
-2. **单进程** —— SQLite checkpointer、并发计数、调度器都是进程内的。多 worker
-   会导致会话状态分裂、定时任务重复触发。
-3. **并发计数不跨进程** —— 多进程部署需挪到 Redis/MySQL。
+```bash
+.venv/bin/python -m pytest            # 离线测试,使用临时数据库
+.venv/bin/python scripts/selfcheck.py # 结构自检,会按当前配置连接数据库并建表
+```
 
-其余有意留空的部分(无业务工具/技能、无 MCP、标题用截断、`remember` 未实现)
-见 `AGENTS.md` §六。
+- [发布说明](release/README.md):目录、升级命令、systemd、依赖更新。
+- [开发参考](docs/DEVELOPMENT.md):API、SSE、工具/技能扩展和框架约束。
+- [.env.example](.env.example):配置项说明。
 
-工程纪律、踩过的 11 个坑、分层依赖规则见 **[AGENTS.md](AGENTS.md)**。
+当前服务为单进程,尚无真实用户鉴权,适合开发和内网使用。

+ 1 - 0
VERSION

@@ -0,0 +1 @@
+2.0.0

+ 7 - 9
agents/agent_factory.py

@@ -39,16 +39,11 @@ def _ensure_dirs() -> None:
 
 
 def _build_backend() -> Any:
-    """文件系统后端:单一真实目录,虚拟路径锚定在项目根。
-
-    ``virtual_mode=True`` 是硬要求——它为 False 时后端直接按真实绝对路径
-    访问磁盘,agent 传 ``/etc/passwd`` 就能读到。开启后 ``/skills/x`` 被解析
-    为 ``<项目根>/skills/x``,且禁止 ``..`` 穿越。
-    """
-    from deepagents.backends import FilesystemBackend
+    """延迟到每次执行,按服务端身份构造隔离文件后端。"""
+    from agents.workspace import backend_for_runtime
 
     _ensure_dirs()
-    return FilesystemBackend(root_dir=str(config.ROOT), virtual_mode=True)
+    return backend_for_runtime
 
 
 def build_permissions() -> list[Any]:
@@ -140,6 +135,8 @@ def _build_interrupt_on() -> dict[str, Any]:
     用来演示与自测中断闭环。
     """
     names = {PLAN_APPROVAL_TOOL}
+    names.update(name for name in tool_registry.get_tool_names()
+                 if tool_registry.get_spec(name).requires_approval)
     extra = config.get_str("HITL_TOOLS", "")
     if extra:
         names.update(n.strip() for n in extra.split(",") if n.strip())
@@ -229,7 +226,8 @@ def describe() -> dict[str, Any]:
         "subagents": [s["name"] for s in _build_subagents()],
         "skills_path": f"/{SKILLS_DIR}/",
         "workspace_path": f"/{WORKSPACE_DIR}/",
-        "sandbox_root": str(Path(config.ROOT)),
+        "sandbox_root": str(Path(config.ROOT) / "data" / "workspaces"),
+        "workspace_scope": "user/session",
         "compaction": {
             "enabled": config.CTX_COMPACT_ENABLED,
             "trigger_fraction": config.FRAMEWORK_COMPACT_TRIGGER,

+ 3 - 3
agents/agent_memory.py

@@ -147,7 +147,7 @@ async def has_checkpoint(thread_id: str) -> bool:
         tup = await saver.aget_tuple(thread_config(thread_id))
     except Exception as exc:  # pragma: no cover - 取决于底层库
         logger.warning("探测 thread 状态失败 thread=%s:%s: %s", thread_id, type(exc).__name__, exc)
-        return False
+        raise
     return tup is not None
 
 
@@ -156,10 +156,10 @@ async def delete_thread(thread_id: str) -> None:
     saver = get_checkpointer()
     delete = getattr(saver, "adelete_thread", None)
     if delete is None:
-        logger.warning("当前 checkpointer 不支持删除 thread,跳过 thread=%s", thread_id)
-        return
+        raise RuntimeError("当前 checkpointer 不支持删除 thread")
     try:
         await delete(thread_id)
         logger.info("已清除图状态 thread=%s", thread_id)
     except Exception as exc:
         logger.warning("清除图状态失败 thread=%s:%s: %s", thread_id, type(exc).__name__, exc)
+        raise

+ 50 - 0
agents/execution.py

@@ -0,0 +1,50 @@
+"""聊天、审批恢复和定时执行共用的准入与运行装配。"""
+import asyncio
+from dataclasses import dataclass
+
+from api import concurrency
+from billing import guard
+from db import chat_store
+
+
+@dataclass
+class Admission:
+    session: concurrency.SessionLease
+    slot: concurrency.TaskSlot
+
+    def release(self):
+        self.slot.release()
+        self.session.release()
+
+
+async def admit(user_name: str, session_id: str) -> Admission:
+    session = concurrency.acquire_session(session_id)
+    slot = None
+    try:
+        slot = concurrency.acquire_task_slot(user_name)
+        if not await asyncio.to_thread(chat_store.get_session_for_user, session_id, user_name):
+            raise concurrency.SessionMissingError("会话不存在")
+        await asyncio.to_thread(guard.precheck, user_name)
+        return Admission(session, slot)
+    except BaseException:
+        if slot:
+            slot.release()
+        session.release()
+        raise
+
+
+async def build_stream(admission: Admission, *, agent=None, **kwargs):
+    from agents import agent_registry
+    from api.sse_core import ChatStream, StreamRequest
+    try:
+        if agent is None:
+            agent = await asyncio.to_thread(agent_registry.get_agent)
+        graph_config = kwargs["config"]
+        graph_config.setdefault("configurable", {}).update(
+            workspace_user=kwargs["user_name"], workspace_session=kwargs["session_id"],
+        )
+        return ChatStream(StreamRequest(agent=agent, slot=admission.slot,
+                         session_lease=admission.session, **kwargs))
+    except BaseException:
+        admission.release()
+        raise

+ 2 - 1
agents/prompts.py

@@ -40,6 +40,7 @@ _BASE = """你是 deep_agent,一个具备工具调用、文件操作与子智
 - 复杂任务先拆解,用 write_todos 维护待办清单;简单任务直接回答,不要为凑流程建清单。
 - 需要外部信息时调工具,不要凭记忆编造。工具返回失败时如实说明失败原因,不要假装成功。
 - 汇报进展时说明「在做什么、为什么」,不要复述工具原始输出。
+- 缺少完成任务必需的信息时,用 ask_user 提问并等待回答;计划审批使用 request_plan_approval。
 
 ## 文件操作
 - 动手改文件前先用 read_file 看清现状;不要基于猜测编辑。
@@ -124,7 +125,7 @@ def build_mode_instruction(mode: str, agent_name: str = "deep_agent") -> str:
         return (
             "\n\n[执行模式:plan]\n"
             "先输出一份可执行的计划(步骤、涉及的工具/文件、预期产出),"
-            "然后用 interrupt 向用户请求确认。用户确认前不要执行任何写操作或"
+            "然后调用 request_plan_approval 提交完整计划。用户确认前不要执行任何写操作或"
             "不可逆操作。用户确认后再按计划执行。"
         )
     return (

+ 35 - 0
agents/workspace.py

@@ -0,0 +1,35 @@
+"""按服务端会话身份映射文件根,虚拟路径保持不变。"""
+import hashlib
+from pathlib import Path
+
+import config
+
+
+def session_root(user_name: str, session_id: str) -> Path:
+    # 哈希避免用户名或外部 ID 成为路径片段;不迁移旧共享文件以免猜错归属。
+    user_key = hashlib.sha256(user_name.encode()).hexdigest()
+    session_key = hashlib.sha256(session_id.encode()).hexdigest()
+    root = config.ROOT / "data" / "workspaces" / user_key / session_key
+    root.mkdir(parents=True, exist_ok=True)
+    return root
+
+
+def backend_for_runtime(runtime):
+    from deepagents.backends import CompositeBackend, FilesystemBackend
+    from langgraph.config import get_config
+    # 文件工具传 ToolRuntime;模型中间件传不含 config 的 Runtime。
+    runtime_config = getattr(runtime, "config", None)
+    if runtime_config is None:
+        runtime_config = get_config()
+    configurable = runtime_config.get("configurable", {})
+    user = configurable.get("workspace_user")
+    session = configurable.get("workspace_session")
+    if not user or not session:
+        raise ValueError("文件后端缺少服务端会话身份")
+    root = session_root(user, session)
+    for directory in ("workspace", "conversation_history"):
+        (root / directory).mkdir(exist_ok=True)
+    return CompositeBackend(
+        default=FilesystemBackend(root_dir=root, virtual_mode=True),
+        routes={"/skills/": FilesystemBackend(root_dir=config.ROOT / "skills", virtual_mode=True)},
+    )

+ 101 - 193
api/chat_routes.py

@@ -33,12 +33,13 @@ from typing import Any
 
 from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
 from fastapi.responses import StreamingResponse
+from starlette.background import BackgroundTask
 
 import config
-from agents import agent_registry, prompts
+from agents import agent_registry, prompts, execution
 from api import concurrency
 from api.models import ResumeRequest
-from api.sse_core import ChatStream, StreamRequest, pending_interrupts, sse_headers
+from api.sse_core import pending_interrupts, sse_headers
 from billing import guard
 from db import chat_store, db_conn
 from obs import get_logger
@@ -235,7 +236,7 @@ def _inject_all(
 # 文件上传
 # ============================================================
 
-async def _save_upload(file: UploadFile) -> str:
+async def _save_upload(file: UploadFile, user_name: str, session_id: str) -> str:
     """把上传文件存进 agent 的沙箱目录,返回虚拟路径。
 
     存到 ``/workspace/uploads/`` 而不是别处,是因为权限规则里只有
@@ -243,27 +244,24 @@ async def _save_upload(file: UploadFile) -> str:
     """
     raw_name = file.filename or "upload.bin"
     safe_name = _SAFE_NAME_RE.sub("_", Path(raw_name).name) or "upload.bin"
-    payload = await file.read()
-
-    if len(payload) > MAX_UPLOAD_SIZE:
-        raise HTTPException(
-            status_code=413,
-            detail={
-                "code": "file_too_large",
-                "message": f"文件超过上限 {MAX_UPLOAD_MB}MB",
-                "max_size_mb": MAX_UPLOAD_MB,
-            },
-        )
-    if not payload:
-        raise HTTPException(
-            status_code=400, detail={"code": "empty_file", "message": "上传的文件为空"}
-        )
-
-    target_dir = config.ROOT / "workspace" / "uploads"
+    from agents.workspace import session_root
+    target_dir = session_root(user_name, session_id) / "workspace" / "uploads"
     target_dir.mkdir(parents=True, exist_ok=True)
-    target = target_dir / f"{uuid.uuid4().hex[:8]}_{safe_name}"
-    target.write_bytes(payload)
-    logger.info("已保存上传文件 path=%s size=%d", target, len(payload))
+    target = target_dir / f"{uuid.uuid4().hex}_{safe_name}"
+    size = 0
+    try:
+        with target.open("xb") as output:
+            while chunk := await file.read(64 * 1024):
+                size += len(chunk)
+                if size > MAX_UPLOAD_SIZE:
+                    raise HTTPException(status_code=413, detail={"code": "file_too_large", "message": f"文件超过上限 {MAX_UPLOAD_MB}MB"})
+                output.write(chunk)
+        if not size:
+            raise HTTPException(status_code=400, detail={"code": "empty_file", "message": "上传的文件为空"})
+    except BaseException:
+        target.unlink(missing_ok=True)
+        raise
+    logger.info("已保存上传文件 path=%s size=%d", target, size)
     return f"/workspace/uploads/{target.name}"
 
 
@@ -280,6 +278,7 @@ async def chat(
     file: UploadFile | None = File(default=None),
     user_name: str | None = Form(default=None),
     request: Request = None,
+    request_id: str | None = Form(default=None),
 ):
     """统一对话接口(SSE 流式)。
 
@@ -299,10 +298,36 @@ async def chat(
         file=file,
         user_name=_resolve_user(user_name),
         trace_id=_trace_id_of(request),
+        request_id=request_id,
     )
 
 
-async def _run_chat_pipeline(
+async def _run_chat_pipeline(*, request_id: str | None = None, **kwargs):
+    """同一用户的请求键只接受一次;重试返回已有运行 ID。"""
+    import asyncio
+    import hashlib
+    if request_id is None:
+        return await _execute_chat_pipeline(**kwargs)
+    if not request_id.strip() or len(request_id) > 128:
+        raise HTTPException(status_code=400, detail={"code": "invalid_request_id", "message": "request_id 必须为 1 至 128 字符"})
+    key = hashlib.sha256((kwargs["user_name"] + "\0" + request_id).encode()).hexdigest()
+    with concurrency.session_guard(f"request:{key}"):
+        existing = await asyncio.to_thread(db_conn.query_one, "SELECT * FROM request_keys WHERE id = ?", (key,))
+        if existing:
+            raise HTTPException(status_code=409, detail={"code": "request_already_processed", "message": "该请求已接受,请查看原会话和执行记录", "session_id": existing["session_id"], "run_id": existing["run_id"]})
+        response = await _execute_chat_pipeline(**kwargs)
+        try:
+            await asyncio.to_thread(db_conn.execute,
+                "INSERT INTO request_keys (id, session_id, run_id, created_at) VALUES (?, ?, ?, ?)",
+                (key, response.headers["X-Session-Id"], response.headers["X-Run-Id"], db_conn.fmt_dt()))
+        except BaseException:
+            # 响应体尚未迭代时 aclose 不会运行生成器 finally。
+            await response.background()
+            raise
+        return response
+
+
+async def _execute_chat_pipeline(
     message: str,
     session_id: str | None,
     thread_id: str | None,
@@ -311,97 +336,37 @@ async def _run_chat_pipeline(
     user_name: str,
     trace_id: str = "",
 ):
-    """对话入口的公共管线。
-
-    步骤顺序与原由:
-    0. 额度预检 —— 在开任何资源之前 fail-closed 拦掉超限用户
-    1. 会话初始化 —— 校验归属、确定 session_id(它同时是 thread_id)
-    2. 模式 —— 落库并算出 recursion_limit
-    3. 标题 —— 首轮才生成
-    4. 上传落盘 —— 存进 agent 可读的沙箱
-    5. 消息拼装 —— 文件说明 + 引用 + 技能 + 偏好 + 模式指令
-    6. 并发闸 + SSE 装配
-
-    不再是「多入口的公共管线」:``/chat/only`` 已删除(理由见模块 docstring),
-    现在只有 ``/chat`` 走这里。保留这层函数是因为它把管线步骤与 FastAPI 的
-    参数声明分开了——将来再加同族入口时不必把它拆回来。
-    """
+    """先验证请求,再取得会话独占权,装配共用的执行流程。"""
+    import asyncio
+    if mode and mode.strip().lower() not in VALID_MODES:
+        raise HTTPException(status_code=400, detail={"code": "invalid_mode", "message": "mode 只能是 plan | full"})
+    if not message.strip():
+        raise HTTPException(status_code=400, detail={"code": "empty_message", "message": "消息不能为空"})
     started_at = db_conn.now()
-    usage_day = db_conn.day_key(started_at)
-
-    # 0) 额度预检
-    try:
-        guard.precheck(user_name, usage_day)
-    except guard.QuotaExceededError as exc:
-        raise HTTPException(
-            status_code=429,
-            detail={
-                "code": "quota_exceeded",
-                "message": str(exc),
-                "limit_tokens": exc.quota.get("limit_tokens", 0),
-                "used_tokens": exc.quota.get("used_tokens", 0),
-            },
-        ) from exc
-    except guard.BillingConfigError as exc:
-        # fail-closed:计费不可用时宁可拒绝服务,也不放行无法计费的请求
-        raise HTTPException(
-            status_code=500,
-            detail={"code": "billing_unavailable", "message": str(exc)},
-        ) from exc
-
-    # 1) 会话
-    session = _init_session(session_id, thread_id, user_name, mode or "full")
-
-    # 2) 模式
-    current_mode, graph_config = _setup_mode(session, mode)
-
-    # 3) 标题
-    _ensure_session_title(session, message)
-
-    # 4) 上传
-    filename = file.filename if file else None
-    saved_path = await _save_upload(file) if file is not None else None
-
-    # 5) 消息拼装
-    ref = _resolve_refs_and_skill(message, user_name)
-    final_message = _inject_all(
-        ref["message"], user_name, current_mode, ref, filename, saved_path
-    )
-
-    # 6) 并发闸 + SSE
-    slot = concurrency.acquire_task_slot(user_name)
+    # 新建会话前也预检,拒绝请求不会产生空会话。
+    await asyncio.to_thread(guard.precheck, user_name)
+    session = await asyncio.to_thread(_init_session, session_id, thread_id, user_name, mode or "full")
+    admission = await execution.admit(user_name, session["id"])
     try:
-        agent = agent_registry.get_agent()
-        stream = ChatStream(
-            StreamRequest(
-                agent=agent,
-                session_id=session["id"],
-                thread_id=session["id"],
-                config=graph_config,
-                message=final_message,
-                user_name=user_name,
-                # 计费渠道标签。取值集合:chat(本入口)| resume(中断恢复)
-                # | scheduled(定时任务)。没有 chat_only——那个入口已删除,
-                # 因为它的标签不承载任何行为差异(见模块 docstring 差异 3)。
-                kind="chat",
-                model=config.MODEL,
-                original_user_message=ref["original"] or message,
-                trace_id=trace_id,
-                slot=slot,
-                started_at=started_at.timestamp(),
-            )
-        )
-        return StreamingResponse(
-            stream.generate(),
-            media_type="text/event-stream",
-            headers=sse_headers(session["id"]),
-        )
-    except HTTPException:
-        slot.release()
-        raise
-    except Exception:
-        # StreamingResponse 构造失败时生成器没机会跑 finally,这里兜底释放
-        slot.release()
+        agent = await asyncio.to_thread(agent_registry.get_agent)
+        graph_config = {"configurable": {"thread_id": session["id"]}}
+        if await pending_interrupts(agent, graph_config):
+            raise HTTPException(status_code=409, detail={"code": "pending_interrupt", "message": "请先处理当前待确认请求"})
+        current_mode, graph_config = await asyncio.to_thread(_setup_mode, session, mode)
+        await asyncio.to_thread(_ensure_session_title, session, message)
+        filename = file.filename if file else None
+        saved_path = await _save_upload(file, user_name, session["id"]) if file else None
+        ref = await asyncio.to_thread(_resolve_refs_and_skill, message, user_name)
+        final_message = await asyncio.to_thread(_inject_all, ref["message"], user_name, current_mode, ref, filename, saved_path)
+        stream = await execution.build_stream(admission, agent=agent,
+            session_id=session["id"], thread_id=session["id"], config=graph_config,
+            message=final_message, user_name=user_name, kind="chat", model=config.MODEL,
+            original_user_message=ref["original"] or message, trace_id=trace_id,
+            started_at=started_at.timestamp())
+        return StreamingResponse(stream.generate(), media_type="text/event-stream", background=BackgroundTask(admission.release),
+                                 headers={**sse_headers(session["id"]), "X-Run-Id": stream.req.run_id})
+    except BaseException:
+        admission.release()
         raise
 
 
@@ -423,6 +388,8 @@ def _to_official_resume(payload: ResumeRequest, pending_count: int) -> dict[str,
     - 有 ``answers`` → ``respond``(把人的回答作为工具结果)
     """
     count = max(pending_count, 1)
+    if count > 1 and payload.approved is True and not payload.interrupt_token:
+        raise HTTPException(status_code=409, detail={"code": "approval_refresh_required", "message": "请刷新并确认完整操作列表"})
 
     if payload.approved is True:
         decision: dict[str, Any] = {"type": "approve"}
@@ -445,67 +412,25 @@ def _to_official_resume(payload: ResumeRequest, pending_count: int) -> dict[str,
 
 @router.post("/chat/resume")
 async def chat_resume(payload: ResumeRequest, request: Request = None):
-    """恢复被 interrupt 挂起的对话。"""
+    """在会话独占期间核对当前中断,再恢复图。"""
+    import asyncio
+    from api.interrupts import resume_value
     user_name = _resolve_user(payload.user_name)
-    session = _init_session(payload.session_id, payload.thread_id, user_name, "full")
-    thread_id = session["id"]
-
-    agent = agent_registry.get_agent()
-    graph_config = {
-        "configurable": {"thread_id": thread_id},
-        "recursion_limit": RECURSION_LIMIT_PLAN
-        if (session.get("mode") == "plan")
-        else RECURSION_LIMIT_DEFAULT,
-    }
-
-    pending = await pending_interrupts(agent, graph_config)
-    if not pending:
-        # 没有挂起的中断却来恢复:多半是重复点击或刷新后的陈旧请求。
-        # 409 而不是 500——这不是服务端故障。
-        raise HTTPException(
-            status_code=409,
-            detail={"code": "no_pending_interrupt", "message": "该会话当前没有待处理的确认请求"},
-        )
-
-    action_count = 0
-    for item in pending:
-        if isinstance(item, dict) and isinstance(item.get("action_requests"), list):
-            action_count = max(action_count, len(item["action_requests"]))
-
-    resume_payload = _to_official_resume(payload, action_count)
-    logger.info(
-        "恢复中断 session=%s 决策=%s pending=%d",
-        thread_id, resume_payload["decisions"], len(pending),
-    )
-
-    slot = concurrency.acquire_task_slot(user_name)
+    session = await asyncio.to_thread(_init_session, payload.session_id, payload.thread_id, user_name, "full")
+    admission = await execution.admit(user_name, session["id"])
     try:
-        stream = ChatStream(
-            StreamRequest(
-                agent=agent,
-                session_id=thread_id,
-                thread_id=thread_id,
-                config=graph_config,
-                message="",
-                user_name=user_name,
-                kind="resume",
-                model=config.MODEL,
-                trace_id=_trace_id_of(request),
-                slot=slot,
-                started_at=db_conn.now().timestamp(),
-                resume_payload=resume_payload,
-            )
-        )
-        return StreamingResponse(
-            stream.generate(),
-            media_type="text/event-stream",
-            headers=sse_headers(thread_id),
-        )
-    except HTTPException:
-        slot.release()
-        raise
-    except Exception:
-        slot.release()
+        agent = await asyncio.to_thread(agent_registry.get_agent)
+        _, graph_config = _setup_mode(session, None)
+        state = await agent.aget_state(graph_config)
+        resume_payload = resume_value(payload, state)
+        stream = await execution.build_stream(admission, agent=agent,
+            session_id=session["id"], thread_id=session["id"], config=graph_config,
+            message="", user_name=user_name, kind="resume", model=config.MODEL,
+            trace_id=_trace_id_of(request), resume_payload=resume_payload)
+        return StreamingResponse(stream.generate(), media_type="text/event-stream", background=BackgroundTask(admission.release),
+                                 headers={**sse_headers(session["id"]), "X-Run-Id": stream.req.run_id})
+    except BaseException:
+        admission.release()
         raise
 
 
@@ -533,26 +458,9 @@ async def get_interrupt(session_id: str, user_name: str | None = None):
     if not pending:
         return Response(status_code=204)
 
-    # 复用与流式一致的事件形状:前端可以用同一个渲染函数处理
+    from api.interrupts import interrupt_frame
     from api.sse_events import EventWriter
-
+    import json
     writer = EventWriter(session_id)
-    frames: list[dict[str, Any]] = []
-    for item in pending:
-        if isinstance(item, dict) and isinstance(item.get("action_requests"), list):
-            for action in item["action_requests"]:
-                frames.append(
-                    {
-                        "type": "interrupt",
-                        "session_id": session_id,
-                        "tool_approval": {
-                            "tool": action.get("name"),
-                            "args": action.get("args"),
-                            "reason": action.get("description") or "",
-                            "side_effect": "该操作需人工确认后才会执行",
-                        },
-                    }
-                )
-        else:
-            frames.append({"type": "interrupt", "session_id": session_id, "message": "智能体暂停,等待输入"})
-    return {"session_id": session_id, "interrupts": frames, "raw": pending}
+    frames = [json.loads(interrupt_frame(writer, item)[6:]) for item in pending]
+    return {"session_id": session_id, "interrupts": frames}

+ 42 - 0
api/concurrency.py

@@ -16,6 +16,7 @@ from __future__ import annotations
 
 import threading
 import time
+from contextlib import contextmanager
 from typing import Any
 
 import config
@@ -37,6 +38,46 @@ class TaskWallTimeoutError(RuntimeError):
     """任务超过墙钟上限。"""
 
 
+class SessionBusyError(RuntimeError):
+    """同一会话正在执行或修改。"""
+
+
+class SessionMissingError(RuntimeError):
+    """取得执行锁时会话已被删除或不属于当前用户。"""
+
+
+_sessions: set[str] = set()
+
+
+class SessionLease:
+    def __init__(self, session_id: str) -> None:
+        self.session_id = session_id
+        self.released = False
+
+    def release(self) -> None:
+        with _state_lock:
+            if not self.released:
+                _sessions.discard(self.session_id)
+                self.released = True
+
+
+def acquire_session(session_id: str) -> SessionLease:
+    with _state_lock:
+        if session_id in _sessions:
+            raise SessionBusyError("该会话正在执行,请等待结束后重试")
+        _sessions.add(session_id)
+    return SessionLease(session_id)
+
+
+@contextmanager
+def session_guard(session_id: str):
+    lease = acquire_session(session_id)
+    try:
+        yield
+    finally:
+        lease.release()
+
+
 class TaskSlot:
     """一个已占用的并发位。
 
@@ -129,3 +170,4 @@ def reset_for_tests() -> None:
     with _state_lock:
         _global_count[0] = 0
         _per_user_count.clear()
+        _sessions.clear()

+ 3 - 2
api/context_meter.py

@@ -173,7 +173,7 @@ class ContextMeter:
         if text:
             self._transcript.append(text)
 
-    def note_message(self, message: Any) -> None:
+    def note_message(self, message: Any, *, is_main_context: bool = True) -> None:
         """观测一条消息:抽取真实 usage、累积文本、更新上下文占用。"""
         usage = extract_usage(message)
         if usage is not None:
@@ -183,7 +183,8 @@ class ContextMeter:
             self.cache_read_tokens += usage["cache_read_tokens"]
             self.model_calls += 1
             # 最近一次调用的 input 就是「下一轮要带多少上下文进去」的近似
-            self._update_context(usage["input_tokens"])
+            if is_main_context:
+                self._update_context(usage["input_tokens"])
 
         if is_summarization_message(message):
             self._mark_compacted(reason="lc_source 标记")

+ 59 - 0
api/interrupts.py

@@ -0,0 +1,59 @@
+"""中断展示与恢复共用一个载荷和标识,批准范围等于卡片展示范围。"""
+import hashlib
+import json
+
+from fastapi import HTTPException
+
+
+def pack(item):
+    value = getattr(item, "value", item)
+    payload = dict(value) if isinstance(value, dict) else {"message": str(value)}
+    if getattr(item, "id", None):
+        payload["_interrupt_id"] = item.id
+    return payload
+
+
+def token_for(payload):
+    return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode()).hexdigest()
+
+
+def interrupt_frame(writer, payload):
+    payload = payload if isinstance(payload, dict) else {"message": str(payload)}
+    token = token_for(payload)
+    if payload.get("kind") == "ask_user":
+        return writer.frame("interrupt", interrupt_token=token,
+                            ask_user={"questions": payload["questions"]})
+    actions = payload.get("action_requests") or []
+    if actions:
+        first = actions[0]
+        return writer.interrupt_tool_approval(
+            tool=first.get("name", ""), args=first.get("args"),
+            reason=first.get("description", ""), side_effect="批准后执行卡片中的全部操作",
+            actions=actions, pending_count=len(actions), interrupt_token=token,
+        )
+    return writer.frame("interrupt", message=payload.get("message", "智能体暂停,等待输入"),
+                        interrupt_token=token)
+
+
+def resume_value(request, state):
+    from api.chat_routes import _to_official_resume
+    items = [item for task in getattr(state, "tasks", ()) for item in getattr(task, "interrupts", ())]
+    if not items:
+        raise HTTPException(status_code=409, detail={"code": "no_pending_interrupt", "message": "没有待确认请求"})
+    selected = next((item for item in items if token_for(pack(item)) == request.interrupt_token), None)
+    if selected is None:
+        raise HTTPException(status_code=409, detail={"code": "stale_interrupt", "message": "确认请求已变化,请刷新后重试"})
+    payload = pack(selected)
+    if payload.get("kind") == "ask_user":
+        if request.status == "cancelled":
+            value = {"cancelled": True}
+        else:
+            if not request.answers or len(request.answers) != len(payload["questions"]) or any(not a.strip() for a in request.answers):
+                raise HTTPException(status_code=400, detail={"code": "invalid_answers", "message": "请回答全部问题"})
+            value = {"answers": request.answers}
+    else:
+        if request.approved is None and request.status not in {"cancelled", "error"}:
+            raise HTTPException(status_code=400, detail={"code": "invalid_decision", "message": "请选择批准或拒绝"})
+        value = _to_official_resume(request, len(payload.get("action_requests") or []))
+    interrupt_id = getattr(selected, "id", None)
+    return {interrupt_id: value} if interrupt_id else value

+ 4 - 3
api/models.py

@@ -7,7 +7,7 @@
 
 from __future__ import annotations
 
-from typing import Any
+from typing import Any, Literal
 
 from pydantic import BaseModel, Field
 
@@ -20,6 +20,7 @@ class ResumeRequest(BaseModel):
     """
 
     session_id: str = Field(..., description="会话 ID")
+    interrupt_token: str | None = Field(None, description="审批卡携带的中断标识,防止陈旧确认")
     thread_id: str | None = Field(None, description="LangGraph 线程 ID(服务端强制等于 session_id)")
     answers: list[str] | None = Field(
         None, description="提问卡的用户答案列表(与问题一一对应)"
@@ -72,7 +73,7 @@ class ScheduleCreateRequest(BaseModel):
     title: str = Field("", max_length=255, description="任务标题")
     cron: str = Field(..., max_length=64, description="5 段 cron 表达式,如 '0 9 * * *'")
     prompt: str = Field(..., description="触发时喂给 agent 的指令")
-    mode: str = Field("full", description="plan | full")
+    mode: Literal["plan", "full"] = Field("full", description="plan | full")
     enabled: bool = Field(True, description="是否立即启用")
     user_name: str | None = Field(None, description="归属用户,缺省 default")
 
@@ -83,7 +84,7 @@ class ScheduleUpdateRequest(BaseModel):
     title: str | None = Field(None, max_length=255)
     cron: str | None = Field(None, max_length=64)
     prompt: str | None = None
-    mode: str | None = None
+    mode: Literal["plan", "full"] | None = None
     enabled: bool | None = None
 
 

+ 17 - 17
api/schedule_routes.py

@@ -36,9 +36,9 @@ def _validate_cron(expr: str) -> str:
     return value
 
 
-def _require_schedule(schedule_id: str):
+def _require_schedule(schedule_id: str, user_name: str | None = None):
     schedule = schedule_store.get_schedule(schedule_id)
-    if schedule is None:
+    if schedule is None or schedule["user_name"] != ((user_name or "default").strip() or "default"):
         raise HTTPException(
             status_code=404, detail={"code": "schedule_not_found", "message": "定时任务不存在"}
         )
@@ -48,7 +48,7 @@ def _require_schedule(schedule_id: str):
 @router.get("/schedules")
 async def list_schedules(user_name: str | None = None):
     """列出定时任务,附带最近一次执行状态。"""
-    items = await asyncio.to_thread(schedule_store.list_schedules, user_name, False, 200)
+    items = await asyncio.to_thread(schedule_store.list_schedules, (user_name or "default").strip() or "default", False, 200)
     for item in items:
         latest = await asyncio.to_thread(schedule_store.latest_run, item["id"])
         item["latest_run"] = latest
@@ -83,17 +83,17 @@ async def create_schedule(payload: ScheduleCreateRequest):
 
 
 @router.get("/schedules/{schedule_id}")
-async def get_schedule(schedule_id: str):
+async def get_schedule(schedule_id: str, user_name: str | None = None):
     """取任务详情与执行记录。"""
-    schedule = await asyncio.to_thread(_require_schedule, schedule_id)
+    schedule = await asyncio.to_thread(_require_schedule, schedule_id, user_name)
     runs = await asyncio.to_thread(schedule_store.list_runs, schedule_id, 20)
     return {**schedule, "runs": runs}
 
 
 @router.put("/schedules/{schedule_id}")
-async def update_schedule(schedule_id: str, payload: ScheduleUpdateRequest):
+async def update_schedule(schedule_id: str, payload: ScheduleUpdateRequest, user_name: str | None = None):
     """更新任务。传了 cron 就先校验;传了 enabled 就同步 job。"""
-    await asyncio.to_thread(_require_schedule, schedule_id)
+    await asyncio.to_thread(_require_schedule, schedule_id, user_name)
     fields: dict = {}
     if payload.cron is not None:
         fields["cron"] = _validate_cron(payload.cron)
@@ -120,27 +120,27 @@ async def update_schedule(schedule_id: str, payload: ScheduleUpdateRequest):
 
 
 @router.delete("/schedules/{schedule_id}")
-async def delete_schedule(schedule_id: str):
+async def delete_schedule(schedule_id: str, user_name: str | None = None):
     """删除任务及其执行记录。"""
-    await asyncio.to_thread(_require_schedule, schedule_id)
+    await asyncio.to_thread(_require_schedule, schedule_id, user_name)
     await asyncio.to_thread(sched.remove_job, schedule_id)
     deleted = await asyncio.to_thread(schedule_store.delete_schedule, schedule_id)
     return {"deleted": deleted, "schedule_id": schedule_id}
 
 
 @router.post("/schedules/{schedule_id}/pause")
-async def pause_schedule(schedule_id: str):
+async def pause_schedule(schedule_id: str, user_name: str | None = None):
     """暂停任务(保留定义,摘掉 job)。"""
-    await asyncio.to_thread(_require_schedule, schedule_id)
+    await asyncio.to_thread(_require_schedule, schedule_id, user_name)
     schedule = await asyncio.to_thread(schedule_store.set_enabled, schedule_id, False)
     await asyncio.to_thread(sched.remove_job, schedule_id)
     return {"schedule_id": schedule_id, "enabled": False}
 
 
 @router.post("/schedules/{schedule_id}/resume")
-async def resume_schedule(schedule_id: str):
+async def resume_schedule(schedule_id: str, user_name: str | None = None):
     """恢复任务。"""
-    await asyncio.to_thread(_require_schedule, schedule_id)
+    await asyncio.to_thread(_require_schedule, schedule_id, user_name)
     schedule = await asyncio.to_thread(schedule_store.set_enabled, schedule_id, True)
     if schedule is not None:
         # cron 可能在这期间被改坏,装载失败要如实报出来
@@ -155,7 +155,7 @@ async def resume_schedule(schedule_id: str):
 
 
 @router.post("/schedules/{schedule_id}/run")
-async def run_now(schedule_id: str):
+async def run_now(schedule_id: str, user_name: str | None = None):
     """立即执行一次(不影响后续 cron 计划)。
 
     同步等待执行完成——手动触发是用户主动点的,他想立刻看到结果。
@@ -163,7 +163,7 @@ async def run_now(schedule_id: str):
     """
     from scheduler.runner import run_schedule
 
-    await asyncio.to_thread(_require_schedule, schedule_id)
+    await asyncio.to_thread(_require_schedule, schedule_id, user_name)
     result = await run_schedule(schedule_id, trigger="manual")
     if not result.get("ok"):
         raise HTTPException(
@@ -174,8 +174,8 @@ async def run_now(schedule_id: str):
 
 
 @router.get("/schedules/{schedule_id}/runs")
-async def list_runs(schedule_id: str, limit: int = 20):
+async def list_runs(schedule_id: str, limit: int = 20, user_name: str | None = None):
     """任务的执行记录。"""
-    await asyncio.to_thread(_require_schedule, schedule_id)
+    await asyncio.to_thread(_require_schedule, schedule_id, user_name)
     items = await asyncio.to_thread(schedule_store.list_runs, schedule_id, min(limit, 100))
     return {"schedule_id": schedule_id, "items": items, "count": len(items)}

+ 29 - 9
api/session_routes.py

@@ -73,20 +73,40 @@ async def delete_session(session_id: str, user_name: str | None = None):
     """
     user = (user_name or "default").strip() or "default"
     await asyncio.to_thread(_require_session, session_id, user)
-    await asyncio.to_thread(chat_store.delete_session, session_id)
-    await agent_memory.delete_thread(session_id)
+    from api.concurrency import session_guard
+    with session_guard(session_id):
+        await agent_memory.delete_thread(session_id)
+        await asyncio.to_thread(chat_store.delete_session, session_id)
     return {"deleted": True, "session_id": session_id}
 
 
 @router.delete("/messages/{message_id}")
 async def delete_message(message_id: str, user_name: str | None = None):
-    """删除单条消息。"""
-    deleted = await asyncio.to_thread(chat_store.delete_message, message_id)
-    if not deleted:
-        raise HTTPException(
-            status_code=404, detail={"code": "message_not_found", "message": "消息不存在"}
-        )
-    return {"deleted": True, "message_id": message_id}
+    """归属校验后删除历史,并清除图状态以便下次按新历史重建。"""
+    from db import db_conn
+    from api.concurrency import session_guard
+    user = (user_name or "default").strip() or "default"
+    message = await asyncio.to_thread(db_conn.query_one,
+        "SELECT m.* FROM messages m JOIN sessions s ON s.id = m.session_id WHERE m.id = ? AND s.user_name = ?",
+        (message_id, user))
+    if not message:
+        raise HTTPException(status_code=404, detail={"code": "message_not_found", "message": "消息不存在"})
+    with session_guard(message["session_id"]):
+        from agents import agent_registry
+        from api.sse_core import pending_interrupts
+        agent = await asyncio.to_thread(agent_registry.get_agent)
+        if await pending_interrupts(agent, {"configurable": {"thread_id": message["session_id"]}}):
+            raise HTTPException(status_code=409, detail={"code": "pending_interrupt", "message": "请先完成或拒绝待确认操作,再删除消息"})
+        await agent_memory.delete_thread(message["session_id"])
+        deleted = await asyncio.to_thread(chat_store.delete_message, message_id)
+    return {"deleted": deleted, "message_id": message_id}
+
+
+@router.get("/sessions/{session_id}/runs")
+async def execution_runs(session_id: str, user_name: str | None = None):
+    from db import run_store
+    await asyncio.to_thread(_require_session, session_id, (user_name or "default").strip() or "default")
+    return {"items": await asyncio.to_thread(run_store.list_runs, session_id)}
 
 
 @router.get("/sessions/{session_id}/mode")

+ 110 - 75
api/sse_core.py

@@ -29,16 +29,20 @@ import asyncio
 import time
 from dataclasses import dataclass, field
 from pathlib import Path
+from datetime import datetime
+
+import anyio
 from typing import Any, AsyncIterator
 
 import config
 from api import concurrency
+from api.interrupts import pack, interrupt_frame
 from api.context_meter import ContextMeter
 from api.sse_events import EventWriter
 from api.source_tracker import SourceTracker
 from api.subagent_tracker import SubagentTracker
 from billing.meter import UsageMeter
-from db import chat_store
+from db import chat_store, db_conn, run_store
 from obs import get_logger, get_trace_id
 
 logger = get_logger(__name__)
@@ -71,6 +75,8 @@ class StreamRequest:
     agent_cn_name: str = MAIN_AGENT_CN
     # 非 None 表示这是一次中断恢复:不组装消息,直接把 Command(resume=...) 喂回图
     resume_payload: Any = None
+    session_lease: Any = None
+    run_id: str = field(default_factory=db_conn.new_id)
 
 
 def normalize_chunk(chunk: Any) -> tuple[str | None, tuple[Any, ...], Any]:
@@ -129,6 +135,8 @@ class ChatStream:
             kind=request.kind,
             model=request.model or config.MODEL,
             trace_id=request.trace_id,
+            usage_id=request.run_id,
+            usage_day=db_conn.day_key(datetime.fromtimestamp(request.started_at)),
         )
         # 累计状态
         self.answer_parts: list[str] = []
@@ -136,6 +144,13 @@ class ChatStream:
         self.todos: list[dict[str, Any]] = []
         self.interrupt_payloads: list[Any] = []
         self.finished = False
+        self.status = "running"
+        self.error = ""
+        self._billing_added = False
+        self._started = False
+        self._snapshot = None
+        self._answer_saved = False
+        self._settlement_task: asyncio.Task | None = None
 
     # ========================================================
     # 主入口
@@ -153,6 +168,9 @@ class ChatStream:
         token = bind_trace_id(self.req.trace_id or None)
         stream = None
         try:
+            await asyncio.to_thread(run_store.start, self.req.run_id, self.req.session_id,
+                                    self.req.user_name, self.req.kind)
+            self._started = True
             yield self.writer.agent_start(self.req.agent_name, self.req.agent_cn_name)
             yield self.writer.thinking("正在理解您的问题...", self.req.agent_cn_name)
 
@@ -175,30 +193,41 @@ class ChatStream:
             async for frame in self._completion():
                 yield frame
 
-        except asyncio.CancelledError:
-            # 客户端断连:不当作错误,但要把已产出的部分落库并结算
-            logger.info("客户端断开连接 session=%s", self.req.session_id)
-            async for frame in self._completion(aborted=True):
-                yield frame
-            raise
-        except GeneratorExit:
-            logger.info("生成器被关闭 session=%s", self.req.session_id)
-            async for frame in self._completion(aborted=True):
-                yield frame
+        except (asyncio.CancelledError, GeneratorExit):
+            self.status = "cancelled"
             raise
         except Exception as exc:
+            self.status = "failed"
+            self.error = type(exc).__name__
             async for frame in self._error_frames(exc):
                 yield frame
         finally:
+            # 清理与发帧独立。ASGI 断连的取消作用域不能打断保存和释放。
+            with anyio.CancelScope(shield=True):
+                cleanup = asyncio.create_task(self._cleanup(stream))
+                try:
+                    await asyncio.shield(cleanup)
+                except asyncio.CancelledError:
+                    await cleanup
+            unbind_trace_id(token)
+
+    async def _cleanup(self, stream: Any) -> None:
+        try:
             if stream is not None:
                 close = getattr(stream, "aclose", None)
                 if close is not None:
                     try:
                         await close()
-                    except Exception:  # pragma: no cover - 关闭失败不影响结算
-                        pass
-            self._finalize()
-            unbind_trace_id(token)
+                    except Exception:
+                        logger.exception("关闭图事件流失败 session=%s", self.req.session_id)
+            await self._settle(aborted=self.status in {"cancelled", "failed"})
+        finally:
+            try:
+                await asyncio.to_thread(self._finalize)
+            finally:
+                if self.req.session_lease is not None:
+                    self.req.session_lease.release()
+
 
     # ========================================================
     # 输入准备
@@ -229,7 +258,7 @@ class ChatStream:
         messages: list[Any] = []
         warm = await agent_memory.has_checkpoint(self.req.thread_id)
         if not warm:
-            history = chat_store.get_messages(
+            history = await asyncio.to_thread(chat_store.get_messages,
                 self.req.session_id, limit=config.CTX_RECENT_LIMIT
             )
             for row in history:
@@ -248,7 +277,8 @@ class ChatStream:
             logger.info("热线程,仅喂当轮消息 session=%s", self.req.session_id)
 
         # 用户消息先落库:即使这一轮流到一半挂了,历史里也得有用户问了什么
-        chat_store.save_message(self.req.session_id, "user", self.req.message)
+        await asyncio.to_thread(chat_store.save_message, self.req.session_id, "user",
+                                self.req.original_user_message or self.req.message)
         self.meter.note_text(self.req.message)
 
         messages.append(HumanMessage(content=self.req.message))
@@ -262,17 +292,18 @@ class ChatStream:
 
     async def _drive(self, stream: Any) -> AsyncIterator[str]:
         """消费 astream,派发事件帧。带墙钟上限。"""
-        deadline = self.req.started_at + config.TASK_WALL_TIMEOUT
+        remaining = config.TASK_WALL_TIMEOUT - (time.time() - self.req.started_at)
+        deadline = time.monotonic() + max(remaining, 0)
         timed_out = False
 
         iterator = stream.__aiter__()
         while True:
-            if time.time() > deadline:
+            if time.monotonic() >= deadline:
                 timed_out = True
                 break
             try:
                 chunk = await asyncio.wait_for(
-                    iterator.__anext__(), timeout=max(deadline - time.time(), 1.0)
+                    iterator.__anext__(), timeout=max(deadline - time.monotonic(), 0.001)
                 )
             except StopAsyncIteration:
                 break
@@ -314,7 +345,7 @@ class ChatStream:
         # 中断会以 __interrupt__ 键出现在 updates 里,比 aget_state 更及时
         if "__interrupt__" in data:
             for item in data.get("__interrupt__") or ():
-                self.interrupt_payloads.append(getattr(item, "value", item))
+                self.interrupt_payloads.append(pack(item))
             return frames
 
         # 子智能体身份:updates 的 ns 指向它所属的 task 调用
@@ -433,7 +464,7 @@ class ChatStream:
 
     def _handle_messages(self, data: Any, ns: tuple[Any, ...]) -> list[str]:
         """处理 messages 通道:正文 token、思考 token、用量计量。"""
-        from langchain_core.messages import AIMessageChunk
+        from langchain_core.messages import AIMessage, AIMessageChunk
 
         frames: list[str] = []
         if not isinstance(data, tuple) or len(data) != 2:
@@ -443,16 +474,15 @@ class ChatStream:
 
         # 摘要中间件的模型调用也在这条通道上:它的 token 不能给用户看
         source = metadata.get("lc_source")
-        if source == _SUMMARIZATION_SOURCE:
+        # 汇总全部模型费用;只有主线程更新当前上下文窗口占用。
+        self.meter.note_message(chunk, is_main_context=not ns and source != _SUMMARIZATION_SOURCE)
+        if source == _SUMMARIZATION_SOURCE or ns:
             return frames
 
-        # 用量计量对所有 chunk 都做(包括子智能体的),口径才完整
-        self.meter.note_message(chunk)
-
         # 只有 AIMessageChunk 的内容才是「模型正在说的话」。ToolMessage 也走
         # 这条通道,它的 content 是工具返回的原始数据——不加这个判断,工具
         # 结果的 JSON 会混进给用户看的正文里。
-        if not isinstance(chunk, AIMessageChunk):
+        if not isinstance(chunk, (AIMessage, AIMessageChunk)):
             return frames
 
         reasoning = _reasoning_text(chunk)
@@ -461,6 +491,7 @@ class ChatStream:
 
         text = _message_text(chunk)
         if text:
+            self.meter.note_text(text)
             self.answer_parts.append(text)
             frames.append(self.writer.token(text))
 
@@ -482,6 +513,7 @@ class ChatStream:
         if not payloads:
             return
 
+        self.interrupt_payloads = payloads
         for payload in payloads:
             frame = self._adapt_interrupt(payload)
             if frame:
@@ -500,54 +532,29 @@ class ChatStream:
         found: list[Any] = []
         for task in getattr(state, "tasks", None) or ():
             for item in getattr(task, "interrupts", None) or ():
-                found.append(getattr(item, "value", item))
+                found.append(pack(item))
         return found
 
     def _adapt_interrupt(self, payload: Any) -> str | None:
-        """官方 HITL 载荷 → 1.5 的 interrupt 事件。
-
-        官方形状是 ``{"action_requests":[{name,args,description}], "review_configs":[...]}``;
-        1.5 前端要的是 ``tool_approval{tool,args,reason,side_effect}``。
-        这个转换是「协议完全兼容」的落点,改动前先看 sse_events 的契约表。
-        """
-        from tools.registry import get_label
-
-        if not isinstance(payload, dict):
-            return self.writer.interrupt_raw(message=str(payload)[:200])
-
-        requests = payload.get("action_requests")
-        if not isinstance(requests, list) or not requests:
-            # 不是工具审批类中断:给一条通用提示,别让前端静默卡住
-            return self.writer.interrupt_raw(
-                message="智能体暂停,等待输入", payload=payload
-            )
-
-        # 官方把同一轮的多个待审批调用合并成一个中断,这里取第一个作为展示主体,
-        # 其余挂在 extra 里——1.5 的审批卡一次只渲染一个工具
-        first = requests[0] if isinstance(requests[0], dict) else {}
-        tool_name = str(first.get("name") or "")
-        return self.writer.interrupt_tool_approval(
-            tool=tool_name,
-            args=first.get("args"),
-            reason=str(first.get("description") or ""),
-            side_effect="该操作需人工确认后才会执行",
-            cn_tool=get_label(tool_name),
-            pending_count=len(requests),
-        )
+        return interrupt_frame(self.writer, payload)
 
     # ========================================================
     # 收尾
     # ========================================================
 
-    async def _completion(self, aborted: bool = False) -> AsyncIterator[str]:
+    async def _settle(self, aborted: bool = False) -> None:
+        # 断连恰逢数据库写入时继续等待同一次收尾,避免在线程仍写入时重做。
+        if self._settlement_task is None:
+            self._settlement_task = asyncio.create_task(self._settle_once(aborted))
+        await asyncio.shield(self._settlement_task)
+
+    async def _settle_once(self, aborted: bool = False) -> None:
         """落库 → 计费 → 来源 → 上下文用量 → agent_done/done。
 
         无论正常结束、异常还是断连都走这里(幂等,重复调用只生效一次)。
         """
         if self.finished:
             return
-        self.finished = True
-
         duration_ms = int((time.time() - self.req.started_at) * 1000)
         answer = "".join(self.answer_parts)
 
@@ -557,40 +564,67 @@ class ChatStream:
         # 1) 上下文用量快照。**只取一次**:snapshot() 会消费掉压缩存档的待办标记,
         #    取两次会导致第二次拿不到存档内容。
         self._apply_estimate_fallback()
-        snapshot = self.meter.snapshot()
+        if self._snapshot is None:
+            self._snapshot = self.meter.snapshot()
+        snapshot = self._snapshot
 
         # 2) 计费:把上下文计量器的数字转给计费计量器。
         #    这是两个计量器唯一的连接点——漏了这一步,交互式对话全程不会计费,
         #    而 /usage/today 会显示 0,表面上「一切正常」。
-        self.billing.add(snapshot.input_tokens, snapshot.output_tokens, snapshot.usage_source)
+        if not self._billing_added:
+            self.billing.add(snapshot.input_tokens, snapshot.output_tokens, snapshot.usage_source)
+            self._billing_added = True
 
         # 3) 落库:assistant 消息是会话历史的事实源
         citations = self.sources.build_citations()
-        if answer or citations:
+        if (answer or citations) and not self._answer_saved:
             try:
-                chat_store.save_message(
-                    self.req.session_id,
+                await asyncio.to_thread(
+                    chat_store.save_message, self.req.session_id,
                     "assistant",
                     answer,
                     tool_calls=self.tool_calls or None,
                     citations=citations or None,
                 )
+                self._answer_saved = True
             except Exception as exc:
+                self.status = "failed"
+                self.error = "message_persistence_failed"
                 logger.error("落库失败 session=%s:%s: %s", self.req.session_id, type(exc).__name__, exc)
 
         # 4) 上下文用量 + 压缩存档
         await self._persist_context(snapshot)
 
         # 5) 计费入账(幂等)
-        self.billing.settle()
+        await asyncio.to_thread(self.billing.settle)
+        if self.status == "running":
+            self.status = "waiting_input" if self.interrupt_payloads else "success"
+        if self._started:
+            await asyncio.to_thread(run_store.finish, self.req.run_id, self.status, self.error)
+            # 用户在普通恢复接口完成定时任务审批后,同步其执行记录。
+            if self.req.kind == "resume":
+                await asyncio.to_thread(db_conn.execute,
+                    "UPDATE schedule_runs SET status = ?, finished_at = ? WHERE thread_id = ? AND status = 'waiting_input'",
+                    (self.status, None if self.status == "waiting_input" else db_conn.fmt_dt(), self.req.session_id))
+        self.finished = True
 
+    async def _completion(self, aborted: bool = False) -> AsyncIterator[str]:
+        await self._settle(aborted)
+        duration_ms = int((time.time() - self.req.started_at) * 1000)
+        citations = self.sources.build_citations()
         # 6) 来源标注
         yield self.writer.citations(citations, model_only=not citations)
 
-        if aborted:
+        if aborted or self.status in {"failed", "cancelled"}:
+            if self.error == "message_persistence_failed":
+                yield self.writer.error("回复未能保存,请稍后检查历史记录", code=self.error)
             yield self.writer.done(duration_ms=duration_ms, aborted=True)
             return
 
+        if self.status == "waiting_input":
+            yield self.writer.done(duration_ms=duration_ms, waiting_input=True, run_id=self.req.run_id, usage=self._usage_payload())
+            return
+
         yield self.writer.agent_done(
             self.req.agent_name,
             cn_agent=self.req.agent_cn_name,
@@ -598,7 +632,7 @@ class ChatStream:
             progress="已完成",
             **self.meter.summary_fields(),
         )
-        yield self.writer.done(duration_ms=duration_ms, usage=self._usage_payload())
+        yield self.writer.done(duration_ms=duration_ms, run_id=self.req.run_id, usage=self._usage_payload())
 
     def _usage_payload(self) -> dict[str, Any]:
         """给前端的用量摘要(便于测试页显示,1.5 前端会忽略未知字段)。"""
@@ -642,7 +676,7 @@ class ChatStream:
                 archive_path = await asyncio.to_thread(
                     self._archive_compaction, snapshot.archive_text, snapshot.archive_tokens
                 )
-            chat_store.save_context_usage(
+            await asyncio.to_thread(chat_store.save_context_usage,
                 session_id=self.req.session_id,
                 turn=chat_store.count_messages(self.req.session_id) // 2,
                 input_tokens=snapshot.input_tokens,
@@ -666,7 +700,8 @@ class ChatStream:
 
     def _archive_compaction(self, buffered_text: str | None, buffered_tokens: int) -> str:
         """登记(必要时写出)压缩前原文,返回存档路径或空串。"""
-        history_dir = config.ROOT / "conversation_history"
+        from agents.workspace import session_root
+        history_dir = session_root(self.req.user_name, self.req.session_id) / "conversation_history"
         framework_file = history_dir / f"{self.req.thread_id}.md"
         if framework_file.exists():
             path = str(framework_file)
@@ -683,7 +718,7 @@ class ChatStream:
             )
             return ""
 
-        archive_dir = config.ROOT / "data" / "context_archives"
+        archive_dir = history_dir
         archive_dir.mkdir(parents=True, exist_ok=True)
         stamp = time.strftime("%Y%m%d-%H%M%S")
         target = archive_dir / f"{self.req.session_id}-{stamp}.md"
@@ -720,7 +755,7 @@ class ChatStream:
 
         logger.exception("流式执行异常 session=%s", self.req.session_id)
         yield self.writer.error(
-            f"服务内部错误:{type(exc).__name__}: {exc}", code="internal_error"
+            f"服务内部错误:{type(exc).__name__}", code="internal_error"
         )
         async for frame in self._completion(aborted=True):
             yield frame
@@ -776,9 +811,9 @@ async def pending_interrupts(agent: Any, config: dict[str, Any]) -> list[Any]:
         state = await get_state(config)
     except Exception as exc:
         logger.warning("读取图状态失败:%s: %s", type(exc).__name__, exc)
-        return []
+        raise
     found: list[Any] = []
     for task in getattr(state, "tasks", None) or ():
         for item in getattr(task, "interrupts", None) or ():
-            found.append(getattr(item, "value", item))
+            found.append(pack(item))
     return found

+ 2 - 0
billing/guard.py

@@ -41,6 +41,8 @@ def precheck(user_name: str, usage_day: str | None = None) -> dict[str, Any]:
     day = usage_day or db_conn.day_key()
     try:
         quota = billing_store.get_quota(user_name, day)
+        from billing import pricing
+        pricing.compute_cost(config.MODEL, 0, 0)
     except Exception as exc:
         raise BillingConfigError(f"额度预检失败(计费库不可用):{type(exc).__name__}: {exc}") from exc
 

+ 40 - 3
billing/meter.py

@@ -11,6 +11,8 @@
 
 from __future__ import annotations
 
+import json
+
 import config
 from billing import pricing
 from db import billing_store, db_conn
@@ -30,6 +32,7 @@ class UsageMeter:
         model: str,
         usage_day: str | None = None,
         trace_id: str = "",
+        usage_id: str | None = None,
     ) -> None:
         self.user_name = user_name
         self.session_id = session_id
@@ -42,7 +45,7 @@ class UsageMeter:
         self.output_tokens = 0
         self.usage_source = "provider"
         self._settled = False
-        self._usage_id: str | None = None
+        self._usage_id = usage_id or db_conn.new_id()
 
     # -------- 累加 --------
 
@@ -77,6 +80,20 @@ class UsageMeter:
             self._settled = True
             return {"settled": False, "reason": "no_usage"}
 
+        try:
+            payload = {"user_name": self.user_name, "session_id": self.session_id,
+                       "kind": self.kind, "model": self.model, "usage_day": self.usage_day,
+                       "trace_id": self.trace_id, "usage_id": self._usage_id,
+                       "input_tokens": self.input_tokens, "output_tokens": self.output_tokens,
+                       "source": self.usage_source}
+            with billing_store._usage_lock, db_conn.transaction():
+                if not db_conn.query_one("SELECT id FROM billing_pending WHERE id = ?", (self._usage_id,)):
+                    db_conn.execute("INSERT INTO billing_pending (id, payload, created_at) VALUES (?, ?, ?)",
+                                    (self._usage_id, json.dumps(payload), db_conn.fmt_dt()))
+        except Exception:
+            logger.exception("无法保存待结算记录 usage_id=%s", self._usage_id)
+            return {"settled": False, "reason": "outbox_error"}
+
         try:
             cost, currency = pricing.compute_cost(
                 self.model, self.input_tokens, self.output_tokens
@@ -87,7 +104,6 @@ class UsageMeter:
                 self.user_name, self.model, self.input_tokens, self.output_tokens,
                 type(exc).__name__, exc,
             )
-            self._settled = True
             return {"settled": False, "reason": "pricing_error", "error": str(exc)}
 
         try:
@@ -101,6 +117,7 @@ class UsageMeter:
                 cost=cost,
                 usage_day=self.usage_day,
                 trace_id=self.trace_id,
+                usage_id=self._usage_id,
             )
         except Exception as exc:
             logger.error(
@@ -108,11 +125,14 @@ class UsageMeter:
                 self.user_name, self.session_id, self.input_tokens, self.output_tokens,
                 type(exc).__name__, exc,
             )
-            self._settled = True
             return {"settled": False, "reason": "store_error", "error": str(exc)}
 
         self._settled = True
         self._usage_id = usage_id
+        try:
+            db_conn.execute("DELETE FROM billing_pending WHERE id = ?", (usage_id,))
+        except Exception:
+            logger.exception("清理待结算记录失败,下次重试将按流水 ID 去重")
         logger.info(
             "入账完成 user=%s kind=%s model=%s tokens=%d/%d cost=%.6f%s source=%s",
             self.user_name, self.kind, self.model,
@@ -127,3 +147,20 @@ class UsageMeter:
             "currency": currency,
             "usage_source": self.usage_source,
         }
+
+
+def retry_pending() -> int:
+    """重试持久化的结算;相同流水 ID 不会再次累计额度。"""
+    settled = 0
+    for row in db_conn.query("SELECT * FROM billing_pending ORDER BY created_at LIMIT 100"):
+        try:
+            payload = json.loads(row["payload"])
+            inputs = payload.pop("input_tokens")
+            outputs = payload.pop("output_tokens")
+            source = payload.pop("source")
+            meter = UsageMeter(**payload)
+            meter.add(inputs, outputs, source)
+            settled += bool(meter.settle().get("settled"))
+        except Exception:
+            logger.exception("待结算记录恢复失败 id=%s", row["id"])
+    return settled

+ 0 - 8
billing/pricing.py

@@ -36,14 +36,6 @@ def seed_default_pricing() -> int:
         if billing_store.get_pricing(model) is None:
             billing_store.set_pricing(model, input_price, output_price)
             created += 1
-    # 当前配置的主模型必须在表内,否则首轮就会 fail-closed
-    for model in {config.MODEL}:
-        if model and billing_store.get_pricing(model) is None:
-            billing_store.set_pricing(model, 0.0, 0.0)
-            created += 1
-            logger.warning(
-                "模型 %s 未在种子单价表中,已按 0 价登记——上线前请补真实单价", model
-            )
     if created:
         logger.info("单价表初始化:新写入 %d 条", created)
     return created

+ 1 - 0
config.py

@@ -42,6 +42,7 @@ from dotenv import dotenv_values, load_dotenv
 # 项目根目录(本文件所在目录)。所有相对路径以此为基准解析,
 # 避免「从哪个目录启动服务」影响资源定位。
 ROOT = Path(__file__).resolve().parent
+APP_VERSION = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
 
 _ENV_PATH = ROOT / ".env"
 # dotenv(默认)| process | none

+ 13 - 2
db/billing_store.py

@@ -8,6 +8,9 @@
 from __future__ import annotations
 
 from typing import Any
+import threading
+
+_usage_lock = threading.RLock()
 
 from db import db_conn
 from obs import get_logger
@@ -63,11 +66,14 @@ def record_usage(
     cost: float,
     usage_day: str,
     trace_id: str = "",
+    usage_id: str | None = None,
 ) -> str:
     """落一条用量流水,返回流水 ID。"""
-    usage_id = db_conn.new_id()
+    usage_id = usage_id or db_conn.new_id()
     total = int(input_tokens) + int(output_tokens)
-    with db_conn.transaction():
+    with _usage_lock, db_conn.transaction():
+        if db_conn.query_one("SELECT id FROM billing_usage WHERE id = ?", (usage_id,)):
+            return usage_id
         db_conn.execute(
             "INSERT INTO billing_usage (id, user_name, session_id, kind, model, input_tokens,"
             " output_tokens, total_tokens, cost, usage_day, trace_id, created_at)"
@@ -127,6 +133,11 @@ def recent_usage(user_name: str, limit: int = 20) -> list[dict[str, Any]]:
 # ============================================================
 
 def _ensure_quota_row(user_name: str, usage_day: str) -> dict[str, Any]:
+    with _usage_lock, db_conn.transaction():
+        return _ensure_quota_row_locked(user_name, usage_day)
+
+
+def _ensure_quota_row_locked(user_name: str, usage_day: str) -> dict[str, Any]:
     """确保当日额度行存在并返回它。额度上限取自配置(0 = 不限)。"""
     import config
 

+ 1 - 0
db/chat_store.py

@@ -132,6 +132,7 @@ def delete_session(session_id: str) -> None:
         db_conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
         db_conn.execute("DELETE FROM session_context_usage WHERE session_id = ?", (session_id,))
         db_conn.execute("DELETE FROM context_archives WHERE session_id = ?", (session_id,))
+        db_conn.execute("DELETE FROM execution_runs WHERE session_id = ?", (session_id,))
         db_conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
     logger.info("删除会话 session_id=%s", session_id)
 

+ 56 - 12
db/db_conn.py

@@ -83,6 +83,7 @@ class _SqliteAdapter:
         self._path = path
         self._lock = threading.RLock()
         self._conn: sqlite3.Connection | None = None
+        self._transaction_depth = 0
 
     def _connection(self) -> sqlite3.Connection:
         if self._conn is None:
@@ -103,27 +104,52 @@ class _SqliteAdapter:
     def execute(self, sql: str, params: Sequence[Any] = ()) -> int:
         with self._lock:
             conn = self._connection()
-            cur = conn.execute(sql, tuple(params))
-            conn.commit()
+            try:
+                cur = conn.execute(sql, tuple(params))
+            except BaseException:
+                if not self._transaction_depth:
+                    conn.rollback()
+                raise
+            if not self._transaction_depth:
+                conn.commit()
             return cur.rowcount
 
     def execute_many(self, sql: str, params_seq: Sequence[Sequence[Any]]) -> int:
         with self._lock:
             conn = self._connection()
-            cur = conn.executemany(sql, [tuple(p) for p in params_seq])
-            conn.commit()
+            try:
+                cur = conn.executemany(sql, [tuple(p) for p in params_seq])
+            except BaseException:
+                if not self._transaction_depth:
+                    conn.rollback()
+                raise
+            if not self._transaction_depth:
+                conn.commit()
             return cur.rowcount
 
     @contextmanager
     def transaction(self) -> Iterator[None]:
         with self._lock:
             conn = self._connection()
+            depth = self._transaction_depth
+            savepoint = f"nested_{depth}"
+            conn.execute("BEGIN" if depth == 0 else f"SAVEPOINT {savepoint}")
+            self._transaction_depth += 1
             try:
                 yield
-                conn.commit()
-            except Exception:
-                conn.rollback()
+                if depth == 0:
+                    conn.commit()
+                else:
+                    conn.execute(f"RELEASE SAVEPOINT {savepoint}")
+            except BaseException:
+                if depth == 0:
+                    conn.rollback()
+                else:
+                    conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
+                    conn.execute(f"RELEASE SAVEPOINT {savepoint}")
                 raise
+            finally:
+                self._transaction_depth -= 1
 
     def schema_file(self) -> Path:
         return config.ROOT / "db" / "schema_sqlite.sql"
@@ -198,7 +224,7 @@ class _MySqlAdapter:
             self._local.conn = conn
         else:
             ident = id(conn)
-            if time.time() - self._last_used.get(ident, 0) > config.DB_PING_IDLE_SECONDS:
+            if not getattr(self._local, "transaction_depth", 0) and time.time() - self._last_used.get(ident, 0) > config.DB_PING_IDLE_SECONDS:
                 try:
                     conn.ping(reconnect=True)
                 except Exception:
@@ -239,13 +265,31 @@ class _MySqlAdapter:
     @contextmanager
     def transaction(self) -> Iterator[None]:
         conn = self._connection()
-        conn.begin()
+        depth = getattr(self._local, "transaction_depth", 0)
+        savepoint = f"nested_{depth}"
+        if depth == 0:
+            conn.begin()
+        else:
+            with conn.cursor() as cur:
+                cur.execute(f"SAVEPOINT {savepoint}")
+        self._local.transaction_depth = depth + 1
         try:
             yield
-            conn.commit()
-        except Exception:
-            conn.rollback()
+            if depth == 0:
+                conn.commit()
+            else:
+                with conn.cursor() as cur:
+                    cur.execute(f"RELEASE SAVEPOINT {savepoint}")
+        except BaseException:
+            if depth == 0:
+                conn.rollback()
+            else:
+                with conn.cursor() as cur:
+                    cur.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
+                    cur.execute(f"RELEASE SAVEPOINT {savepoint}")
             raise
+        finally:
+            self._local.transaction_depth = depth
 
     def schema_file(self) -> Path:
         return config.ROOT / "db" / "mysql_schema.sql"

+ 23 - 0
db/mysql_schema.sql

@@ -144,3 +144,26 @@ CREATE TABLE IF NOT EXISTS user_preferences (
   PRIMARY KEY (id),
   UNIQUE KEY uk_pref_user_key (user_name, pref_key)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户偏好,新会话自动注入';
+
+CREATE TABLE IF NOT EXISTS execution_runs (
+  id VARCHAR(64) PRIMARY KEY,
+  session_id VARCHAR(64) NOT NULL,
+  user_name VARCHAR(128) NOT NULL,
+  kind VARCHAR(24) NOT NULL,
+  status VARCHAR(24) NOT NULL,
+  error MEDIUMTEXT,
+  started_at VARCHAR(32) NOT NULL,
+  finished_at VARCHAR(32)
+);
+CREATE TABLE IF NOT EXISTS billing_pending (
+  id VARCHAR(64) PRIMARY KEY,
+  payload MEDIUMTEXT NOT NULL,
+  created_at VARCHAR(32) NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS request_keys (
+  id VARCHAR(64) PRIMARY KEY,
+  session_id VARCHAR(64) NOT NULL,
+  run_id VARCHAR(64) NOT NULL,
+  created_at VARCHAR(32) NOT NULL
+);

+ 27 - 0
db/run_store.py

@@ -0,0 +1,27 @@
+"""每次执行的持久化状态;checkpoint 负责续跑,运行表负责展示状态。"""
+from db import db_conn
+
+
+def start(run_id: str, session_id: str, user_name: str, kind: str) -> None:
+    db_conn.execute(
+        "INSERT INTO execution_runs (id, session_id, user_name, kind, status, started_at)"
+        " VALUES (?, ?, ?, ?, 'running', ?)",
+        (run_id, session_id, user_name, kind, db_conn.fmt_dt()),
+    )
+
+
+def finish(run_id: str, status: str, error: str = "") -> None:
+    db_conn.execute("UPDATE execution_runs SET status = ?, error = ?, finished_at = ? WHERE id = ?",
+                    (status, error, None if status == "waiting_input" else db_conn.fmt_dt(), run_id))
+
+
+def list_runs(session_id: str) -> list[dict]:
+    return db_conn.query("SELECT * FROM execution_runs WHERE session_id = ? ORDER BY started_at DESC LIMIT 50",
+                         (session_id,))
+
+
+def recover_interrupted() -> None:
+    db_conn.execute("UPDATE execution_runs SET status = 'interrupted', finished_at = ? WHERE status = 'running'",
+                    (db_conn.fmt_dt(),))
+    db_conn.execute("UPDATE schedule_runs SET status = 'interrupted', finished_at = ? WHERE status = 'running'",
+                    (db_conn.fmt_dt(),))

+ 2 - 2
db/schedule_store.py

@@ -113,10 +113,10 @@ def start_run(schedule_id: str, thread_id: str) -> str:
 def finish_run(
     run_id: str, status: str, error: str | None = None, output: str | None = None
 ) -> None:
-    """登记一次执行结束。status: success | failed。"""
+    """登记一次执行结束。status: success | waiting_input | failed | cancelled。"""
     db_conn.execute(
         "UPDATE schedule_runs SET status = ?, finished_at = ?, error = ?, output = ? WHERE id = ?",
-        (status, db_conn.fmt_dt(), error, output, run_id),
+        (status, None if status == "waiting_input" else db_conn.fmt_dt(), error, output, run_id),
     )
 
 

+ 23 - 0
db/schema_sqlite.sql

@@ -122,3 +122,26 @@ CREATE TABLE IF NOT EXISTS user_preferences (
   updated_at  TEXT NOT NULL
 );
 CREATE UNIQUE INDEX IF NOT EXISTS uk_pref_user_key ON user_preferences (user_name, pref_key);
+
+CREATE TABLE IF NOT EXISTS execution_runs (
+  id TEXT PRIMARY KEY,
+  session_id TEXT NOT NULL,
+  user_name VARCHAR(128) NOT NULL,
+  kind VARCHAR(24) NOT NULL,
+  status VARCHAR(24) NOT NULL,
+  error TEXT,
+  started_at VARCHAR(32) NOT NULL,
+  finished_at VARCHAR(32)
+);
+CREATE TABLE IF NOT EXISTS billing_pending (
+  id TEXT PRIMARY KEY,
+  payload TEXT NOT NULL,
+  created_at VARCHAR(32) NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS request_keys (
+  id TEXT PRIMARY KEY,
+  session_id TEXT NOT NULL,
+  run_id TEXT NOT NULL,
+  created_at VARCHAR(32) NOT NULL
+);

+ 324 - 0
docs/DEVELOPMENT.md

@@ -0,0 +1,324 @@
+# 开发参考
+
+API、SSE、扩展方式和框架约束。运行、调试、打包与部署见 [项目 README](../README.md)。以下源码命令均在项目根目录执行,使用项目虚拟环境。
+
+## API
+
+主入口与 1.5 一致,额外新增了若干辅助接口。
+
+### 对话
+
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| POST | `/api/chat` | 主入口,SSE 流式 |
+| POST | `/api/chat/resume` | 恢复被中断挂起的对话 |
+| GET | `/api/chat/interrupt` | 查挂起的中断(刷新页面补渲染卡片);有则 200,无则 204 |
+| GET | `/api/chat/history/{session_id}` | 会话历史 |
+
+不提供 `/api/chat/only`,统一使用 `/api/chat`。
+
+`POST /api/chat` 请求体是 `multipart/form-data`:
+
+| 字段 | 必填 | 说明 |
+|---|---|---|
+| `message` | 是 | 用户消息,支持 `#skill:<name>` 与 `#session:<uuid>` 前缀 |
+| `session_id` | 否 | 缺省则新建会话 |
+| `thread_id` | 否 | **必须等于 `session_id`**,否则 404 |
+| `mode` | 否 | `plan`(先出计划待批)或 `full`(直接执行) |
+| `file` | 否 | 附件,落到 `/workspace/uploads/` |
+| `request_id` | 否 | 客户端生成的请求唯一键;同一用户重复提交返回 409 和原 `session_id`、`run_id` |
+| `user_name` | 否 | 归属用户,缺省 `default`(**2.0 新增,替代 1.5 的鉴权依赖**) |
+
+响应是 `text/event-stream`,头含 `X-Session-Id`、`X-Trace-Id` 与 `X-Run-Id`。
+
+### 会话与用量
+
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| GET | `/api/sessions` | 会话列表(置顶优先) |
+| GET | `/api/sessions/search?q=` | 标题模糊搜索 |
+| DELETE | `/api/sessions/{id}` | 删除会话(**同时清图状态**) |
+| DELETE | `/api/messages/{id}` | 删除单条消息 |
+| GET/PUT | `/api/sessions/{id}/mode` | 权限模式 |
+| PUT | `/api/sessions/{id}/pin` · `/title` | 置顶 · 重命名 |
+| GET | `/api/sessions/{id}/runs` | 最近 50 次执行的状态与错误 |
+| GET | `/api/sessions/{id}/context` | 上下文用量明细与压缩存档索引 |
+| GET | `/api/usage/today` | 当日用量与额度 |
+| GET | `/api/usage/recent` | 最近用量流水 |
+| PUT | `/api/usage/quota` | 调整每日额度(0 = 不限) |
+| GET | `/api/usage/pricing` | 模型单价表 |
+| GET | `/api/preferences` · PUT · DELETE | 用户偏好(每轮自动注入) |
+
+### 模型与工具
+
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| GET | `/api/model` | 模型配置与 agent 组装摘要 |
+| GET | `/api/model/tools` | 工具清单(按分组) |
+| POST | `/api/model/thinking` | 切换思考级别(触发 agent 重建) |
+| POST | `/api/model/reload` | 强制重建 agent |
+
+### 定时任务
+
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| GET/POST | `/api/schedules` | 列表 / 新建 |
+| GET/PUT/DELETE | `/api/schedules/{id}` | 详情 / 更新 / 删除 |
+| POST | `/api/schedules/{id}/pause` · `/resume` · `/run` | 暂停 / 恢复 / 立即执行 |
+| GET | `/api/schedules/{id}/runs` | 执行记录 |
+
+### 运维
+
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| GET | `/health` | 逐子系统健康状态 |
+| GET | `/api/health/billing` | 计费自检(单价是否覆盖当前模型) |
+| GET | `/` | 重定向到测试页 |
+| GET | `/static/index.html` | 极简测试页 |
+
+---
+
+## SSE 事件契约
+
+14 种业务事件 + 1 种通用错误。**字段名是从 1.5 前端逐行反查出来的**,
+不是猜的。
+
+| 事件 | 前端读取的字段 |
+|---|---|
+| `agent_start` | `agent` · `cn_agent` |
+| `thinking` | `message` · `cn_agent` |
+| `thinking_token` | `content` |
+| `executing` | `message` · `cn_agent` · `tools[]` · `cn_tools[]` |
+| `tool_call` | `tool` · `cn_tool` · `args` · `agent_id` |
+| `tool_result` | `tool` · `cn_tool` · `content` · `error` · `agent_id` |
+| `token` | `content` |
+| `updated_todo_list` | `todos` |
+| `progress` | `message` |
+| `interrupt` | `tool_approval{tool,args,reason,side_effect}` 或 `ask_user{questions[]}` 或 `message` |
+| `citations` | `items[]` · `model_only` |
+| `agent_done` | `agent` · `cn_agent` · `duration_ms` · `progress` · `context_tokens` |
+| `agent_error` | `agent` · `cn_agent` · `message` · `duration_ms` |
+| `done` | `duration_ms` · `usage{}` |
+| `error` | `message` · `code` · `retry_after` |
+
+帧格式:`data: {json}\n\n`,每帧恒带 `session_id`,绑定 trace 时带 `trace_id`。
+
+HTTP 错误语义:额度超限 429;并发超限 429(带 `retry_after`);上传 400/413;
+会话不存在或越权 404;恢复时无挂起中断 409。
+
+**契约的唯一出口是 `api/sse_events.py`**,其他任何地方都不得手拼 `data: `
+字符串。改字段必须同步改 `tests/test_sse_contract.py`。
+
+---
+
+## 四项核心能力(相对 1.5 的做法改进)
+
+这是 2.0 与 1.5 的主要差异所在。
+
+### 子智能体跟踪
+
+| | 做法 |
+|---|---|
+| 1.5 | 从子图命名空间字符串 `tools:<X>` **反查** `subagent_type`;id 被上下文压缩裁掉后反查落空,子智能体身份丢失 |
+| 2.0 | 收到 task 调用的**那一刻**就建 `tool_call_id → {类型, 描述}` 表,之后只按 id 查表;表由跟踪器持有,不受历史窗口影响 |
+
+见 `api/subagent_tracker.py`。
+
+### 上下文计量
+
+| | 做法 |
+|---|---|
+| 1.5 | tiktoken **本地估算**优先,为此专门开了 `fix/context-usage-accounting` 分支修口径 |
+| 2.0 | **真实 usage 优先**(`usage_metadata`),provider 不给才回落估算并标 `usage_source=estimate` |
+
+两个量刻意分开:`input_tokens` 累加值是计费口径,`context_tokens`(最近一次
+调用的 input)是当前上下文占用。
+
+见 `api/context_meter.py`。
+
+### 自动压缩
+
+| | 做法 |
+|---|---|
+| 1.5 | 自研 `auto_compact.py`,默认**关闭**,未被信任到敢开 |
+| 2.0 | 直接用 deepagents 的 `SummarizationMiddleware`:85% 窗口触发,**把被淘汰的历史追加落盘**到 `/conversation_history/{thread_id}.md` 并把路径嵌进摘要,agent 能自己读回来 |
+
+调节旋钮是 `CONTEXT_LIMIT`(声明窗口),不是阈值比例——阈值由框架固定为
+85%。压缩存档位置可在 `GET /api/sessions/{id}/context` 的 `archives` 里查。
+
+### 来源标注
+
+| | 做法 |
+|---|---|
+| 1.5 | 靠提示词让**模型自觉**在正文写引用,再用启发式规则去正文里刮 |
+| 2.0 | **工具侧结构化产出**:工具返回 `sourced(data, sources)`,SSE 层从 ToolMessage 抽取、聚合、去重 |
+
+模型漏写和编造这两种失败模式,在读工具返回值的路径上根本不存在。
+
+见 `api/source_tracker.py` 与 `tools/registry.py`。
+
+---
+
+## 扩展方式
+
+### 加一个业务工具
+
+```python
+# tools/my_tools.py
+from tools.registry import register, source, sourced
+
+@register(group="我的分组", label="中文显示名", summary="一句话说明")
+def my_tool(query: str, limit: int = 5) -> dict:
+    """做某事。
+
+    Args:
+        query: 查询词。
+        limit: 最多返回几条。
+
+    Returns:
+        含 data 与 sources 的结构;sources 会被渲染成引用标注。
+    """
+    return sourced(
+        data=[...],
+        sources=[source("标题", "https://...", "摘要", score=0.9)],
+    )
+```
+
+然后把模块名加进 `tools/registry.py` 的 `_BUILTIN_MODULES`。工具名全局唯一,
+重名会在启动时抛错。
+
+**docstring 是给模型看的**:第一行说清做什么,正文说清何时用、参数含义、
+返回什么。模型选错工具多半是因为 docstring 写得含糊。
+
+### 加一个技能
+
+在 `skills/<name>/SKILL.md` 写带 frontmatter 的操作手册:
+
+```markdown
+---
+name: my-skill
+description: 什么时候该用这个技能(这段会被模型看到,要写具体)
+---
+
+# 标题
+## 步骤
+1. ...
+```
+
+agent 启动时只读 frontmatter,按需才 `read_file` 读全文(渐进披露)。
+用户用 `#skill:my-skill` 前缀显式指定。
+
+### 加一个子智能体
+
+改 `agents/agent_factory.py` 的 `_build_subagents()`。注意 `tools` 必须是
+`BaseTool` 列表(用 `registry.get_tools_by_name(...)`),`system_prompt`
+不继承父级。
+
+---
+
+## 配置
+
+全部配置项见 `.env.example`,每项都有默认值。几个容易搞错的:
+
+| 变量 | 说明 |
+|---|---|
+| `DEEP_AGENT_ENV_PRIORITY` | `.env` 与进程环境谁优先。`dotenv`(默认)/ `process` / `none`。**跑变体实例用 `process`**:`DEEP_AGENT_ENV_PRIORITY=process DB_DIALECT=mysql PORT=8081 python main.py` |
+| `DB_DIALECT` | `mysql`(生产)/ `sqlite`(本地开发与测试,无需数据库服务) |
+| `DB_NAME` | MySQL 库名;库不存在时启动会自动建库,不需要手工 `CREATE DATABASE` |
+| `CONTEXT_LIMIT` | 声明的上下文窗口,**压缩的唯一调节旋钮**。0 = 用模型登记值 |
+| `CTX_COMPACT_ENABLED` | 关掉会同时失去历史落盘能力,长会话会超窗报错 |
+| `CHECKPOINTER` | `sqlite`(默认,落盘)/ `memory`(重启丢图状态) |
+| `HITL_TOOLS` | 追加需要人工审批的工具名,逗号分隔。如 `calculate` 便于自测中断闭环 |
+| `BILLING_ALLOW_UNPRICED` | 模型未定价时是否按 0 计费。**默认 0 = 直接报错**(fail-closed) |
+| `TFAGENTS_DAILY_TOKEN_LIMIT` | 每用户每日 token 限额,0 = 不限 |
+
+**配置冲突会被大声报出来**:若 `.env` 与进程环境里有同名但不同的值,启动
+日志会打 WARNING(含脱敏后的生效值),`/health` 的 `config_conflicts` 也能
+看到。这解决的是一个很费时间的现象:改了配置却不生效,却没有任何提示。
+
+---
+
+## 验证
+
+```bash
+# 离线单元 + SSE 契约测试(不需要密钥,不需要数据库服务)
+python -m pytest
+
+# 结构自检:导入、建表、agent 编译、工具注册
+python scripts/selfcheck.py
+
+# HTTP 层端到端(需服务已启动 + 可用密钥)
+python scripts/http_e2e.py
+```
+
+`http_e2e.py` 覆盖:SSE 帧格式与响应头、落库与顺序、错误语义
+(404/400/409)、**中断/恢复闭环**、定时任务的手动触发与 cron 校验。
+
+---
+
+## 目录结构
+
+```
+main.py                    FastAPI 入口(lifespan / 健康检查 / 静态挂载)
+config.py                  配置唯一入口(.env 读取与冲突检测)
+api/                       chat · sse_core · session · model · usage · schedule · concurrency
+  sse_events.py            ★ SSE 契约唯一事实源
+  sse_core.py              ★ 流式内核(读流 → 派事件 → 收尾)
+  subagent_tracker.py      子智能体生命周期跟踪
+  context_meter.py         上下文计量(真实 usage 优先)
+  source_tracker.py        来源标注抽取与聚合
+agents/                    agent_factory · agent_registry · model_factory · prompts · middleware · agent_memory
+tools/                     registry(工具唯一事实源)· demo_tools(示例工具)
+skills/demo-skill/         示例技能
+db/                        db_conn(双方言)· mysql_schema.sql · schema_sqlite.sql · 三个 store
+billing/                   pricing · guard(预检)· meter(入账)
+scheduler/                 scheduler(APScheduler)· runner(独立会话执行)
+obs/                       log_setup · tracing(trace_id)
+static/index.html          极简测试页
+tests/                     pytest(离线单元、HTTP 与真实图引擎回归)
+scripts/                   selfcheck.py · http_e2e.py · start_server.sh
+```
+
+标 ★ 的两个文件是核心,改动时同步检查 SSE 契约与流式执行测试。
+
+---
+
+## 执行可靠性与隔离
+
+- **统一准入**:聊天、恢复和定时执行共用额度预检、并发闸、会话互斥、总超时、计量与收尾。并发满返回 429;同会话冲突返回 409。
+- **运行状态**:`execution_runs` 保存 `running / waiting_input / success / failed / cancelled / interrupted`。中断只表示等待输入,不发送完成事件;启动时将崩溃遗留的 running 标记为 interrupted。
+- **请求去重**:聊天可提交 `request_id`(1–128 字符)。相同用户的相同键不会再次运行,返回 409 和原会话、运行 ID;不重放旧 SSE。新任务应使用新键。
+- **取消收尾**:断连时停止消费图,保存已收到的部分回答、结算已观测用量,并释放占位。定时任务停机时等待取消收尾后再关闭 checkpoint。
+- **持久化结算**:同一流水使用稳定 ID,写账与累计额度在事务中完成。待结算数据保存在 `billing_pending`,服务启动后及每分钟重试。数据库完全不可写时仍只能日志告警;进程被强杀前尚未观测到的 provider 用量需要外部账单对账。
+- **文件隔离**:虚拟 `/workspace/`、`/conversation_history/` 映射到 `data/workspaces/<用户哈希>/<会话哈希>/`。子智能体共享其父会话目录,其他会话不可见;`/skills/` 共享只读。
+- **定时任务**:采用同一执行流程,注入模式和用户偏好;cron 与手动触发共用计划互斥,等待审批的运行记录为 `waiting_input`,从聊天恢复后同步结果状态。cron 显式使用 Asia/Shanghai。
+
+### Agent 人机交互
+
+- `ask_user(questions)`:提出一至三个必要问题并暂停,前端显示输入框;刷新页面可恢复问题卡。
+- `request_plan_approval(plan)`:提交计划等待确认。plan 模式提示词会准确指向这个工具。
+- 批量工具审批卡完整展示每项工具及参数,批准按钮明确写出批次数量。
+- 审批卡带 `interrupt_token`;恢复接口必须原样携带。服务端在会话锁内核对当前中断,过期、缺失和重复确认返回 409。
+- 业务工具可在 `@register(..., requires_approval=True)` 声明强制审批,组装时自动进入框架 HITL。plan 提示词本身不构成所有工具的硬权限边界,真实写操作应声明强制审批。
+- 子智能体和摘要模型的用量计入总费用,其内部正文不混入主回答,也不覆盖主线程上下文占用。
+
+### 升级已有部署
+
+启动自动增建 `execution_runs`、`billing_pending`、`request_keys`,不会删除旧表。
+同步发布 `static/app.js`;自定义客户端需补传 `interrupt_token`。定时任务的详情、修改、删除、运行接口需携带其归属 `user_name`。
+
+旧的共享 `workspace/`、`conversation_history/` 原样保留,**不会自动猜测文件归属或复制到新目录**。
+旧会话需要的附件可重新上传;需要迁移的历史文件应先确认用户和会话归属。
+未登记的新模型现在会在执行前拒绝请求,不再自动写入零价;既有单价记录不会被覆盖。
+
+## 已知缺口
+
+**当前只适合内网或开发环境**,主要因为:
+
+1. **没有鉴权** —— `user_name` 由客户端自报,可伪造。计费与会话归属都建立在
+   这个值上。需要为所有路由接入统一身份依赖,并区分普通用户与管理操作。
+2. **单进程** —— SQLite checkpointer、并发计数、调度器都是进程内的。多 worker
+   会导致会话状态分裂、定时任务重复触发。
+3. **并发计数不跨进程** —— 多进程部署需挪到 Redis/MySQL。
+
+尚未提供业务工具/技能、MCP 接入及 `remember`;默认标题使用文本截断。

+ 62 - 3
main.py

@@ -24,12 +24,28 @@ from fastapi.staticfiles import StaticFiles
 import config
 from api import router as api_router
 from api import concurrency
-from billing import pricing
+from billing import pricing, guard
 from db import db_conn
 from obs import bind_trace_id, get_logger, new_trace_id, setup_logging, unbind_trace_id
 
 setup_logging(config.LOG_LEVEL, config.LOG_FORMAT)
 logger = get_logger(__name__)
+_maintenance_stop: asyncio.Event | None = None
+_maintenance_task: asyncio.Task | None = None
+_prewarm_task: asyncio.Task | None = None
+
+
+async def _retry_billing(stop: asyncio.Event) -> None:
+    from billing.meter import retry_pending
+    while not stop.is_set():
+        try:
+            await asyncio.to_thread(retry_pending)
+        except Exception:
+            logger.exception("重试待结算流水失败")
+        try:
+            await asyncio.wait_for(stop.wait(), 60)
+        except asyncio.TimeoutError:
+            pass
 
 
 def _check_single_process() -> None:
@@ -63,6 +79,7 @@ async def _prewarm() -> None:
 
 
 async def _lifespan_startup() -> None:
+    global _maintenance_stop, _maintenance_task, _prewarm_task
     # 配置来源与冲突在日志装配后就打出来:排查「配置不生效」时它是第一手信息。
     # 放在 lifespan 而不是模块顶层,是因为 `python main.py` 会让本模块被导入
     # 两次(`__main__` 与 `main`),模块级副作用会重复执行。
@@ -78,6 +95,8 @@ async def _lifespan_startup() -> None:
     db_conn.init_schema()
     logger.info("存储就绪 %s", db_conn.health())
     pricing.seed_default_pricing()
+    from db import run_store
+    run_store.recover_interrupted()
 
     from agents import agent_memory
 
@@ -87,7 +106,9 @@ async def _lifespan_startup() -> None:
 
     await scheduler.start()
 
-    asyncio.create_task(_prewarm())
+    _prewarm_task = asyncio.create_task(_prewarm())
+    _maintenance_stop = asyncio.Event()
+    _maintenance_task = asyncio.create_task(_retry_billing(_maintenance_stop))
 
     if not config.OPENAI_API_KEY:
         logger.warning("OPENAI_API_KEY 未配置,对话会失败——请在 .env 里设置")
@@ -98,6 +119,12 @@ async def _lifespan_shutdown() -> None:
     from scheduler import scheduler
 
     await scheduler.shutdown()
+    if _maintenance_stop:
+        _maintenance_stop.set()
+    if _maintenance_task:
+        await _maintenance_task
+    if _prewarm_task:
+        await _prewarm_task
     await agent_memory.close_checkpointer()
     logger.info("deep_agent 2.0 已停止")
 
@@ -113,7 +140,7 @@ async def lifespan(app: FastAPI):
 app = FastAPI(
     title="deep_agent 2.0",
     description="基于 deepagents 的深度智能体服务骨架;主入口 POST /api/chat(SSE)",
-    version="2.0.0",
+    version=config.APP_VERSION,
     lifespan=lifespan,
 )
 
@@ -121,6 +148,36 @@ app = FastAPI(
 app.include_router(api_router)
 
 
+@app.exception_handler(guard.QuotaExceededError)
+async def quota_error(request: Request, exc: guard.QuotaExceededError):
+    return JSONResponse(status_code=429, content={"detail": {"code": "quota_exceeded", "message": str(exc)}})
+
+
+@app.exception_handler(guard.BillingConfigError)
+async def billing_error(request: Request, exc: guard.BillingConfigError):
+    logger.error("计费准入失败:%s", exc)
+    return JSONResponse(status_code=503, content={"detail": {"code": "billing_unavailable", "message": "计费暂不可用"}})
+
+
+@app.exception_handler(concurrency.TooManyTasksError)
+async def concurrency_error(request: Request, exc: concurrency.TooManyTasksError):
+    return JSONResponse(status_code=429, headers={"Retry-After": str(exc.retry_after)},
+                        content={"detail": {"code": "too_many_tasks", "message": str(exc),
+                                            "retry_after": exc.retry_after}})
+
+
+@app.exception_handler(concurrency.SessionBusyError)
+async def session_busy(request: Request, exc: concurrency.SessionBusyError):
+    return JSONResponse(status_code=409,
+                        content={"detail": {"code": "session_busy", "message": str(exc)}})
+
+
+@app.exception_handler(concurrency.SessionMissingError)
+async def session_missing(request: Request, exc: concurrency.SessionMissingError):
+    return JSONResponse(status_code=404,
+                        content={"detail": {"code": "session_not_found", "message": str(exc)}})
+
+
 if config.TRACE_MIDDLEWARE:
 
     @app.middleware("http")
@@ -173,6 +230,8 @@ async def health():
     from agents import agent_memory
 
     detail: dict = {
+        "version": config.APP_VERSION,
+        "pid": os.getpid(),
         "dialect": config.DB_DIALECT,
         "storage": db_conn.health(),
         "checkpointer": {

+ 122 - 0
release/README.md

@@ -0,0 +1,122 @@
+# Linux 发布说明
+
+**本地运行、Debug、打包和首次部署的完整步骤见 [项目 README](../README.md)。** 本页补充发布工具、目录、升级和托管操作。
+
+## 工具速查
+
+构建命令在源码根目录执行;安装命令在解压目录执行:
+
+| 操作 | 命令 |
+|---|---|
+| 打包 | `.venv/bin/python -B release/build.py --version 2.0.0` |
+| 校验解压内容 | `python3.12 -B release/verify.py` |
+| 安装版本 | `bash release/install.sh --prefix "$HOME/vent-agent"` |
+| 首次切换 | `bash release/service.sh activate 2.0.0 --prefix "$HOME/vent-agent"` |
+| 后台启动 | `~/vent-agent/current/release/service.sh start` |
+| 前台运行 | `~/vent-agent/current/release/service.sh run` |
+| 停止 / 状态 / 日志 | `~/vent-agent/current/release/service.sh stop` / `status` / `logs` |
+
+`start` 默认等待健康检查 90 秒,可通过 `--timeout 120` 调整。`status` 在停止或不健康时返回非零;`stop` 超时不会强制杀进程。重启使用 `stop` 再 `start`,没有 `restart` 子命令。
+
+同一部署目录只允许一个实例。统一通过发布脚本或 systemd 启动,避免绕过实例锁直接运行 `main.py`。
+
+## 目录与配置
+
+以 `--prefix "$HOME/vent-agent"` 为例:
+
+```text
+~/vent-agent/
+├── current  -> releases/2.0.1
+├── previous -> releases/2.0.0
+├── releases/
+│   ├── 2.0.0/                 # 旧版保留
+│   └── 2.0.1/                 # 新版源码、文档和独立 .venv
+└── shared/
+    ├── .env                   # 自动生成,权限 0600;升级不覆盖
+    ├── data/                  # SQLite、checkpoint、按用户/会话隔离的文件
+    ├── workspace/
+    ├── conversation_history/
+    ├── large_tool_results/
+    ├── logs/server.log        # 后台运行日志
+    └── run/                   # 实例锁和 PID 状态
+```
+
+每个版本的 `.env` 和运行数据目录都链接到 `shared/`。程序自动读取这份配置,修改后需重启。使用相对 `data/` 路径可让数据库保存在共享目录;自定义绝对路径需要另行备份。
+
+新版本安装时先创建独立虚拟环境、安装哈希锁定的依赖,再用临时数据库自检,不触碰线上数据库;失败清理本次新增目录。安装成功后也不会自动切换或启动。
+
+## 升级与回退
+
+以下演示从 `2.0.0` 升级到 `2.0.1`。上传新包及校验文件,在它们所在目录执行:
+
+```bash
+sha256sum -c vent-agent-2.0.1-linux.tar.gz.sha256
+tar -xzf vent-agent-2.0.1-linux.tar.gz
+cd vent-agent-2.0.1-linux
+bash release/install.sh --prefix "$HOME/vent-agent"
+~/vent-agent/current/release/service.sh stop
+```
+
+停服后备份共享数据,备份文件可能包含密钥,应限制访问:
+
+```bash
+(umask 077; tar -czf "$HOME/vent-agent-backup-$(date +%Y%m%d-%H%M%S).tar.gz" \
+  -C "$HOME/vent-agent" shared)
+```
+
+此命令覆盖共享目录内的 SQLite 和用户文件;**MySQL、自定义绝对路径的数据需另行备份**。确认新旧数据结构兼容后切换:
+
+```bash
+~/vent-agent/current/release/service.sh activate 2.0.1
+~/vent-agent/current/release/service.sh start
+~/vent-agent/current/release/service.sh status
+```
+
+仅在旧代码兼容当前数据时回退:
+
+```bash
+~/vent-agent/current/release/service.sh stop
+~/vent-agent/current/release/service.sh activate 2.0.0
+~/vent-agent/current/release/service.sh start
+~/vent-agent/current/release/service.sh status
+```
+
+**兼容性边界:** 目前没有数据库版本迁移、checkpoint 兼容检查或自动备份。启动时的 `CREATE TABLE IF NOT EXISTS` 不会为已有表补改字段。回退只改变代码和依赖,不恢复旧数据;从备份恢复会丢失备份后的写入。
+
+systemd 托管时,上面的启停改用 `systemctl stop/start vent-agent`;先停服务再备份和 activate,避免自动重启干扰切换。
+
+## systemd 托管
+
+先完成安装、配置和 activate,并停止手动启动的服务。将 [vent-agent.service.example](vent-agent.service.example) 中的账户和目录改为实际值:
+
+- `User`、`Group`:运行服务的账户,需能访问安装目录和共享数据。
+- `WorkingDirectory`、`ExecStart`:替换模板中的 `/opt/vent-agent`。
+
+将修改后的文件保存到 `/etc/systemd/system/vent-agent.service`,执行:
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable --now vent-agent
+sudo systemctl status vent-agent
+sudo journalctl -u vent-agent -f
+```
+
+此后统一使用 `systemctl start/stop/restart vent-agent`;日志在 journal。`service.sh status` 仍可检查健康状态。
+
+## 打包与依赖维护
+
+- `VERSION`:默认发布版本;`--version` 仅修改包内版本。同版本产物和安装目录均不覆盖。
+- `release-manifest.json`:包内文件哈希、版本、构建时间、Git 信息和检查结果;源码被修改后,activate / start 会拒绝校验。
+- 打包当前工作区,包含未提交修改;白名单排除 `.env`、运行数据、日志和虚拟环境。新增资源类型时同步调整 `build.py` 的白名单。
+- 默认执行离线 pytest 和自检;`--skip-checks` 仅供打包排查,会标记检查跳过,**不是服务 Debug 模式**。
+- 包中不带 Python 和依赖 wheel;服务器需要 Python 3.12 和下载依赖的网络。
+
+`requirements.txt` 定义依赖范围,`baseline.constraints` 保存已验证版本,`requirements.lock` 固定版本及哈希。更新前两者后,在装有 uv 的开发机执行:
+
+```bash
+python3.12 -B release/lock.py
+.venv/bin/python -m pip install --require-hashes --only-binary=:all: -r release/requirements.lock
+.venv/bin/python -B release/build.py --version 2.0.1
+```
+
+构建检查锁文件是否过期、开发环境版本是否一致。服务器安装无需 uv。

+ 81 - 0
release/baseline.constraints

@@ -0,0 +1,81 @@
+# 已通过项目回归的开发环境版本;刷新锁文件时作为约束。
+APScheduler==3.11.3
+PyMySQL==1.2.3
+PyYAML==6.0.3
+Pygments==2.21.0
+aiosqlite==0.22.1
+annotated-doc==0.0.5
+annotated-types==0.8.0
+anthropic==1.9.0
+anyio==4.15.1
+bracex==3.0.1
+certifi==2026.7.22
+cffi==2.1.1
+charset-normalizer==3.5.1
+click==8.5.0
+cryptography==50.0.1
+deepagents==0.6.12
+distro==1.9.0
+docstring_parser==0.18.0
+fastapi==0.141.1
+filetype==1.2.0
+google-auth==2.59.0
+google-genai==2.25.0
+h11==0.16.0
+httpcore2==2.13.1
+httpcore==1.0.9
+httptools==0.8.0
+httpx2==2.13.1
+httpx==0.28.1
+idna==3.20
+iniconfig==2.3.0
+jiter==0.17.0
+jsonpatch==1.33
+jsonpointer==3.1.1
+langchain-anthropic==1.7.4
+langchain-core==1.6.5
+langchain-google-genai==4.4.0
+langchain-openai==1.6.6
+langchain-protocol==0.0.19
+langchain==1.4.3
+langgraph-checkpoint-sqlite==3.1.1
+langgraph-checkpoint==4.2.0
+langgraph-prebuilt==1.1.0
+langgraph-sdk==0.4.5
+langgraph==1.2.12
+langsmith==0.14.1
+openai==3.20.0
+orjson==3.12.0
+ormsgpack==1.12.2
+packaging==26.3
+pluggy==1.6.0
+pyasn1==0.6.4
+pyasn1_modules==0.4.2
+pycparser==3.0
+pydantic==2.13.5
+pydantic_core==2.46.5
+pytest-asyncio==1.4.0
+pytest==9.1.1
+python-dotenv==1.2.3
+python-multipart==0.0.32
+regex==2026.9.29
+requests-toolbelt==1.0.0
+requests==2.34.2
+sniffio==1.3.1
+sqlite-vec==0.1.9
+starlette==1.7.0
+tenacity==9.1.4
+tiktoken==0.14.0
+truststore==0.10.4
+typing-inspection==0.4.4
+typing_extensions==4.16.0
+tzlocal==5.4.4
+urllib3==2.8.0
+uuid_utils==0.17.1
+uvicorn==0.54.0
+uvloop==0.22.1
+watchfiles==1.3.0
+wcmatch==11.0.1
+websockets==16.1.1
+xxhash==4.0.1
+zstandard==0.25.0

+ 164 - 0
release/build.py

@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+"""构建版本化 Linux 部署包;默认打包当前工作区并运行离线检查。"""
+from __future__ import annotations
+
+import argparse
+from datetime import datetime, timezone
+import gzip
+from importlib import metadata
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import tarfile
+import tempfile
+
+sys.dont_write_bytecode = True
+from common import sha256, validate_package, validation_env, version
+
+ROOT = Path(__file__).resolve().parents[1]
+ROOT_FILES = {"main.py", "config.py", "README.md", "docs/DEVELOPMENT.md", "requirements.txt", "pytest.ini", "VERSION", ".env.example"}
+TREE_SUFFIXES = {
+    "agents": {".py"}, "api": {".py"}, "billing": {".py"}, "db": {".py", ".sql"},
+    "obs": {".py"}, "scheduler": {".py"}, "tools": {".py"},
+    "skills": {".md", ".txt", ".json", ".yaml", ".yml"},
+    "static": {".html", ".css", ".js", ".md", ".LICENSE", ".svg", ".png", ".ico", ".woff", ".woff2"},
+    "tests": {".py", ".html"},
+}
+RELEASE_FILES = {"build.py", "common.py", "deploy.py", "verify.py", "install.sh", "service.sh",
+                 "lock.py", "requirements.lock", "lock.meta.json", "baseline.constraints", "README.md",
+                 "vent-agent.service.example"}
+EXCLUDED_PARTS = {"__pycache__", "node_modules", "data", "workspace", "logs", "dist", "venv"}
+
+
+def source_files(root: Path) -> list[Path]:
+    """白名单收集源文件;运行目录、隐藏文件和符号链接不会被打包。"""
+    selected = [root / name for name in sorted(ROOT_FILES)]
+    selected += [root / "release" / name for name in sorted(RELEASE_FILES)]
+    selected += [root / "scripts" / "selfcheck.py"]
+    for directory, suffixes in TREE_SUFFIXES.items():
+        base = root / directory
+        if base.is_symlink():
+            raise ValueError(f"不接受源目录符号链接:{directory}")
+        for current, dirs, names in os.walk(base, followlinks=False):
+            dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d not in EXCLUDED_PARTS)
+            for directory_name in dirs:
+                if (Path(current) / directory_name).is_symlink():
+                    raise ValueError(f"不接受源目录符号链接:{Path(current) / directory_name}")
+            for name in sorted(names):
+                path = Path(current) / name
+                if not name.startswith(".") and path.suffix in suffixes:
+                    selected.append(path)
+    for path in selected:
+        if not path.is_file() or any(p.is_symlink() for p in (path, *path.parents) if p != root and root in p.parents):
+            raise ValueError(f"源文件缺失或是符号链接:{path.relative_to(root)}")
+    return sorted(selected, key=lambda p: p.relative_to(root).as_posix())
+
+
+def git_info(root: Path) -> dict:
+    def git(*args):
+        try:
+            result = subprocess.run(["git", "-C", str(root), *args], text=True, capture_output=True)
+        except FileNotFoundError:
+            return None
+        return result.stdout.strip() if result.returncode == 0 else None
+    status = git("status", "--porcelain")
+    return {"commit": git("rev-parse", "HEAD"), "dirty": None if status is None else bool(status)}
+
+
+def verify_lock(root: Path, check_environment: bool) -> None:
+    meta = json.loads((root / "release/lock.meta.json").read_text())
+    for name in ("requirements.txt", "release/baseline.constraints", "release/requirements.lock"):
+        if meta["sha256"].get(name) != sha256(root / name):
+            raise ValueError(f"依赖输入或锁文件已变化,请先运行 release/lock.py:{name}")
+    if not check_environment:
+        return
+    from packaging.requirements import Requirement
+    # uv 的 requirements 输出:每项以 name==version 开始,后接哈希续行。
+    for line in (root / "release/requirements.lock").read_text().splitlines():
+        if not line or line[0].isspace() or line.startswith("#"):
+            continue
+        req = Requirement(line.removesuffix("\\").strip())
+        if req.marker and not req.marker.evaluate():
+            continue
+        if metadata.version(req.name) not in req.specifier:
+            raise ValueError(f"构建环境与锁文件不一致:{req.name};请安装 release/requirements.lock")
+
+
+def build(root: Path, output: Path, release_version: str, *, checks: bool = True) -> Path:
+    if sys.version_info[:2] != (3, 12):
+        raise ValueError("本发布流程使用 Python 3.12")
+    release_version = version(release_version)
+    verify_lock(root, checks)
+    output.mkdir(parents=True, exist_ok=True)
+    name = f"vent-agent-{release_version}-linux"
+    archive = output / f"{name}.tar.gz"
+    checksum = output / f"{name}.tar.gz.sha256"
+    if archive.exists() or checksum.exists():
+        raise ValueError(f"版本产物已存在,不覆盖:{archive}")
+    with tempfile.TemporaryDirectory(prefix="vent-release-") as temporary:
+        temp = Path(temporary)
+        staged = temp / name
+        staged.mkdir()
+        sources = source_files(root)
+        for source in sources:
+            target = staged / source.relative_to(root)
+            target.parent.mkdir(parents=True, exist_ok=True)
+            shutil.copyfile(source, target)
+            target.chmod(0o755 if target.suffix == ".sh" else 0o644)
+        (staged / "VERSION").write_text(release_version + "\n")
+        if checks:
+            env = validation_env(temp)
+            subprocess.run([sys.executable, "-m", "pytest", "-p", "no:cacheprovider"], cwd=staged, env=env, check=True)
+            subprocess.run([sys.executable, "scripts/selfcheck.py"], cwd=staged, env=env, check=True)
+            # 自检可能建出空运行目录。它们不会进入发布包。
+        files = {}
+        for source in sources:
+            relative = source.relative_to(root).as_posix()
+            path = staged / relative
+            files[relative] = {"sha256": sha256(path), "size": path.stat().st_size}
+        manifest = {"format": 1, "version": release_version, "python": "3.12", "target": "linux",
+                    "built_at": datetime.now(timezone.utc).isoformat(), "source": git_info(root),
+                    "checks": "passed" if checks else "skipped", "files": files}
+        (staged / "release-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
+        temporary_archive = temp / archive.name
+        with temporary_archive.open("wb") as raw, gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as gz:
+            with tarfile.open(fileobj=gz, mode="w") as tar:
+                for relative in sorted([*files, "release-manifest.json"]):
+                    path = staged / relative
+                    info = tar.gettarinfo(str(path), arcname=f"{name}/{relative}")
+                    info.uid = info.gid = 0
+                    info.uname = info.gname = ""
+                    info.mtime = 0
+                    with path.open("rb") as content:
+                        tar.addfile(info, content)
+        # 验证真正归档后的内容,避免只检查 staging 而遗漏打包错误。
+        extracted = temp / "verify"
+        with tarfile.open(temporary_archive) as tar:
+            tar.extractall(extracted, filter="data")
+        validate_package(extracted / name)
+        # 使用排他创建,重复构建同一版本不会覆盖已有发布文件。
+        with archive.open("xb") as target, temporary_archive.open("rb") as source:
+            shutil.copyfileobj(source, target)
+        with checksum.open("x") as target:
+            target.write(f"{sha256(archive)}  {archive.name}\n")
+    return archive
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--version", default=(ROOT / "VERSION").read_text().strip())
+    parser.add_argument("--output", type=Path, default=ROOT / "release/dist")
+    parser.add_argument("--skip-checks", action="store_true", help="仅调试:跳过检查,清单将标记 skipped")
+    args = parser.parse_args()
+    try:
+        path = build(ROOT, args.output.resolve(), args.version, checks=not args.skip_checks)
+    except (ValueError, subprocess.CalledProcessError, metadata.PackageNotFoundError) as exc:
+        parser.exit(1, f"构建失败:{exc}\n")
+    print(f"发布包:{path}\n校验文件:{path}.sha256")
+
+
+if __name__ == "__main__":
+    main()

+ 64 - 0
release/common.py

@@ -0,0 +1,64 @@
+"""发布脚本共用的版本、清单和隔离验证环境。仅依赖 Python 标准库。"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path, PurePosixPath
+import re
+
+VERSION_RE = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?\Z")
+
+
+def version(value: str) -> str:
+    value = value.strip()
+    if not VERSION_RE.fullmatch(value):
+        raise ValueError("版本格式应为 2.0.0 或 2.0.0-rc.1")
+    return value
+
+
+def sha256(path: Path) -> str:
+    return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+def validate_package(root: Path, *, installed: bool = False) -> dict:
+    root = root.resolve()
+    manifest = json.loads((root / "release-manifest.json").read_text())
+    if manifest.get("format") != 1 or manifest.get("python") != "3.12":
+        raise ValueError("不支持的发布清单或 Python 版本")
+    if version((root / "VERSION").read_text()) != version(manifest["version"]):
+        raise ValueError("VERSION 与发布清单不一致")
+    files = manifest.get("files", {})
+    for required in ("VERSION", "main.py", "config.py", ".env.example", "release/deploy.py", "release/requirements.lock"):
+        if required not in files:
+            raise ValueError(f"发布包缺少必需文件:{required}")
+    for name, expected in files.items():
+        relative = PurePosixPath(name)
+        if relative.is_absolute() or ".." in relative.parts or "\\" in name:
+            raise ValueError(f"非法清单路径:{name}")
+        path = root / name
+        if any(part.is_symlink() for part in (path, *path.parents) if part != root and root in part.parents):
+            raise ValueError(f"发布文件不允许是符号链接:{name}")
+        if not path.is_file() or sha256(path) != expected["sha256"]:
+            raise ValueError(f"文件缺失或校验失败:{name}")
+    if not installed:
+        actual = set()
+        for path in root.rglob("*"):
+            if path.is_symlink():
+                raise ValueError(f"发布包包含符号链接:{path.relative_to(root)}")
+            if path.is_file():
+                actual.add(path.relative_to(root).as_posix())
+        if actual != set(files) | {"release-manifest.json"}:
+            raise ValueError("发布包包含清单外文件")
+    return manifest
+
+
+def validation_env(temporary: Path) -> dict[str, str]:
+    """测试和自检只使用临时 SQLite 与假密钥,不读取服务器配置。"""
+    env = dict(os.environ)
+    env.update(DEEP_AGENT_ENV_PRIORITY="none", DB_DIALECT="sqlite",
+               DB_SQLITE_PATH=str(temporary / "validation.sqlite"), CHECKPOINTER="memory",
+               OPENAI_API_KEY="sk-release-offline", OPENAI_BASE_URL="https://example.invalid",
+               DEEPAGENT_MODEL="deepseek-flash", BILLING_ALLOW_UNPRICED="0",
+               PYTHONDONTWRITEBYTECODE="1", PYTHONPATH="", LOG_LEVEL="WARNING")
+    return env

+ 296 - 0
release/deploy.py

@@ -0,0 +1,296 @@
+#!/usr/bin/env python3
+"""Linux 部署包安装、版本切换与单进程服务管理。"""
+from __future__ import annotations
+
+import argparse
+from contextlib import contextmanager
+import fcntl
+import json
+import os
+from pathlib import Path
+import shutil
+import signal
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.request
+
+sys.dont_write_bytecode = True
+from common import validate_package, validation_env, version
+
+PACKAGE = Path(__file__).resolve().parents[1]
+SHARED_DIRS = ("data", "workspace", "conversation_history", "large_tool_results")
+
+
+def default_prefix() -> Path:
+    return PACKAGE.parent.parent if PACKAGE.parent.name == "releases" else Path.home() / "vent-agent"
+
+
+@contextmanager
+def locked(path: Path):
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with path.open("a+") as handle:
+        try:
+            fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
+        except BlockingIOError:
+            raise ValueError(f"已有操作或实例占用:{path}") from None
+        try:
+            yield
+        finally:
+            fcntl.flock(handle, fcntl.LOCK_UN)
+
+
+def running(prefix: Path) -> bool:
+    try:
+        with locked(prefix / "shared/run/instance.lock"):
+            return False
+    except ValueError:
+        return True
+
+
+def record_path(prefix: Path) -> Path:
+    return prefix / "shared/run/server.json"
+
+
+def read_record(prefix: Path) -> dict:
+    try:
+        return json.loads(record_path(prefix).read_text())
+    except (OSError, ValueError):
+        return {}
+
+
+def start_ticks(pid: int) -> str | None:
+    try:
+        # comm 可包含空格和括号;starttime 是右括号后第 20 个字段。
+        fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()
+        return None if fields[0] == "Z" else fields[19]
+    except (OSError, IndexError):
+        return None
+
+
+def same_process(record: dict) -> bool:
+    pid = record.get("pid", 0)
+    return isinstance(pid, int) and pid > 1 and bool(record.get("start_ticks")) and start_ticks(pid) == record["start_ticks"]
+
+
+def current_release(prefix: Path) -> Path:
+    link = prefix / "current"
+    if not link.is_symlink():
+        raise ValueError("请先 activate 一个已安装版本")
+    root = link.resolve(strict=True)
+    if root.parent != prefix / "releases":
+        raise ValueError("current 必须指向当前部署目录的 releases/<版本>")
+    return root
+
+
+def install(prefix: Path):
+    manifest = validate_package(PACKAGE)
+    target = prefix / "releases" / version(manifest["version"])
+    if target.exists() or target.is_symlink():
+        raise ValueError(f"版本已安装,不覆盖:{target}")
+    if prefix == PACKAGE or PACKAGE in prefix.parents or prefix in PACKAGE.parents:
+        raise ValueError("安装目录与解压目录必须分开")
+    target.parent.mkdir(parents=True, exist_ok=True)
+    target.mkdir()
+    try:
+        for name in [*manifest["files"], "release-manifest.json"]:
+            dest = target / name
+            dest.parent.mkdir(parents=True, exist_ok=True)
+            shutil.copy2(PACKAGE / name, dest)
+        python = target / ".venv/bin/python"
+        subprocess.run([sys.executable, "-m", "venv", str(target / ".venv")], check=True)
+        subprocess.run([str(python), "-m", "pip", "install", "--disable-pip-version-check",
+                        "--require-hashes", "--only-binary=:all:", "-r", str(target / "release/requirements.lock")], check=True)
+        subprocess.run([str(python), "-m", "pip", "check"], check=True)
+        # 自检只操作临时数据;在连接 shared 前执行,升级安装不会触碰线上数据库。
+        with tempfile.TemporaryDirectory(prefix="vent-install-") as temporary:
+            subprocess.run([str(python), "scripts/selfcheck.py"], cwd=target,
+                           env=validation_env(Path(temporary)), check=True)
+        shared = prefix / "shared"
+        shared.mkdir(exist_ok=True)
+        for name in (*SHARED_DIRS, "logs", "run"):
+            (shared / name).mkdir(exist_ok=True)
+        env_file = shared / ".env"
+        if not env_file.exists():
+            fd = os.open(env_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+            with os.fdopen(fd, "wb") as stream:
+                stream.write((target / ".env.example").read_bytes())
+        for name in (".env", *SHARED_DIRS):
+            path = target / name
+            if path.exists():  # 仅为这个新版本的隔离自检生成的目录
+                shutil.rmtree(path) if path.is_dir() else path.unlink()
+            path.symlink_to(shared / name, target_is_directory=name != ".env")
+        validate_package(target, installed=True)
+    except BaseException:
+        shutil.rmtree(target)
+        raise
+    print(f"已安装 {manifest['version']}:{target}\n配置:{prefix / 'shared/.env'}\n请配置后执行 activate,再 start。")
+
+
+def replace_link(path: Path, destination: Path):
+    temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
+    try:
+        temporary.symlink_to(destination)
+        os.replace(temporary, path)
+    finally:
+        temporary.unlink(missing_ok=True)
+
+
+def activate(prefix: Path, selected: str):
+    if running(prefix):
+        raise ValueError("请先 stop,再切换版本")
+    target = prefix / "releases" / version(selected)
+    if target.is_symlink() or not (target / ".venv/bin/python").is_file():
+        raise ValueError(f"版本尚未安装:{selected}")
+    validate_package(target, installed=True)
+    with locked(prefix / "shared/run/instance.lock"):
+        current = prefix / "current"
+        if current.exists() and not current.is_symlink():
+            raise ValueError("current 已存在且不是符号链接")
+        if current.is_symlink() and current.resolve() != target:
+            replace_link(prefix / "previous", current_release(prefix))
+        replace_link(current, target)
+    print(f"当前版本:{selected}")
+
+
+def serve(prefix: Path):
+    with locked(prefix / "shared/run/instance.lock"):
+        if current_release(prefix) != PACKAGE:
+            raise ValueError("启动期间版本已切换,请重试")
+        validate_package(PACKAGE, installed=True)
+        os.chdir(PACKAGE)
+        sys.path.insert(0, str(PACKAGE))
+        import config
+        import uvicorn
+        record = {"pid": os.getpid(), "start_ticks": start_ticks(os.getpid()),
+                  "version": config.APP_VERSION, "release": str(PACKAGE), "host": config.HOST, "port": config.PORT}
+        path = record_path(prefix)
+        temporary = path.with_suffix(".tmp")
+        temporary.write_text(json.dumps(record))
+        os.replace(temporary, path)
+        try:
+            uvicorn.run("main:app", host=config.HOST, port=config.PORT, workers=1,
+                        timeout_graceful_shutdown=60, log_level="info")
+        finally:
+            if read_record(prefix).get("pid") == os.getpid():
+                path.unlink(missing_ok=True)
+
+
+def health(record: dict) -> bool:
+    if not same_process(record):
+        return False
+    host = record["host"]
+    host = "127.0.0.1" if host in ("0.0.0.0", "") else "::1" if host == "::" else host
+    if ":" in host:
+        host = f"[{host}]"
+    try:
+        opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
+        with opener.open(f"http://{host}:{record['port']}/health", timeout=2) as response:
+            result = json.load(response)
+        return result.get("status") == "ok" and result.get("pid") == record["pid"] and result.get("version") == record["version"]
+    except (OSError, ValueError):
+        return False
+
+
+def stop(prefix: Path, timeout: float):
+    if not running(prefix):
+        record_path(prefix).unlink(missing_ok=True)
+        print("服务已停止")
+        return
+    record = read_record(prefix)
+    if not same_process(record):
+        raise ValueError("实例锁被占用,但无法确认进程身份;请检查日志,不自动终止其他进程")
+    # pidfd 避免检查后 PID 被回收再复用时误杀其他进程。
+    descriptor = os.pidfd_open(record["pid"])
+    try:
+        if not same_process(record):
+            raise ValueError("进程身份已变化,请重新检查状态")
+        signal.pidfd_send_signal(descriptor, signal.SIGTERM)
+    finally:
+        os.close(descriptor)
+    deadline = time.monotonic() + timeout
+    while time.monotonic() < deadline:
+        if not running(prefix):
+            print("服务已停止")
+            return
+        time.sleep(0.2)
+    raise ValueError("停止超时;服务可能仍在收尾,请检查日志后重试")
+
+
+def start(prefix: Path, timeout: float):
+    if running(prefix):
+        raise ValueError("服务已经运行,请使用 status 查看")
+    root = current_release(prefix)
+    validate_package(root, installed=True)
+    log = prefix / "shared/logs/server.log"
+    log.parent.mkdir(parents=True, exist_ok=True)
+    with log.open("ab") as stream:
+        child = subprocess.Popen([str(root / ".venv/bin/python"), "-B", str(root / "release/deploy.py"),
+                                  "_serve", "--prefix", str(prefix)], cwd=root,
+                                 stdin=subprocess.DEVNULL, stdout=stream, stderr=subprocess.STDOUT, start_new_session=True)
+    deadline = time.monotonic() + timeout
+    while time.monotonic() < deadline:
+        if child.poll() is not None:
+            raise ValueError(f"启动失败(退出码 {child.returncode}),请查看 {log}")
+        record = read_record(prefix)
+        if record.get("pid") == child.pid and health(record):
+            print(f"服务已启动:版本 {record['version']},PID {child.pid},端口 {record['port']}\n日志:{log}")
+            return
+        time.sleep(0.3)
+    # 只终止自己创建且尚未回收的子进程。
+    child.terminate()
+    try:
+        child.wait(timeout=timeout)
+    except subprocess.TimeoutExpired:
+        pass
+    raise ValueError(f"启动健康检查超时,已请求停止;请查看 {log}")
+
+
+def main():
+    os.umask(0o077)
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("command", choices=["install", "activate", "start", "stop", "status", "logs", "run", "_serve"])
+    parser.add_argument("version", nargs="?")
+    parser.add_argument("--prefix", type=Path, default=default_prefix())
+    parser.add_argument("--timeout", type=float, default=90)
+    args = parser.parse_args()
+    prefix = args.prefix.expanduser().resolve()
+    try:
+        if sys.platform != "linux" or sys.version_info[:2] != (3, 12):
+            raise ValueError("部署脚本需要 Linux + Python 3.12")
+        if args.timeout <= 0:
+            raise ValueError("timeout 必须大于 0")
+        if args.command == "_serve":
+            serve(prefix)
+        elif args.command == "run":
+            root = current_release(prefix)
+            python = root / ".venv/bin/python"
+            os.execv(python, [str(python), "-B", str(root / "release/deploy.py"), "_serve", "--prefix", str(prefix)])
+        elif args.command == "logs":
+            subprocess.run(["tail", "-n", "100", "-f", str(prefix / "shared/logs/server.log")], check=True)
+        elif args.command == "status":
+            active = running(prefix)
+            record = read_record(prefix)
+            ready = active and health(record)
+            print(json.dumps({"running": active, "healthy": ready, "process": record}, ensure_ascii=False, indent=2))
+            return 0 if ready else 1
+        else:
+            with locked(prefix / "shared/run/control.lock"):
+                if args.command == "install":
+                    install(prefix)
+                elif args.command == "activate":
+                    if not args.version:
+                        raise ValueError("用法:service.sh activate <版本>")
+                    activate(prefix, args.version)
+                elif args.command == "start":
+                    start(prefix, args.timeout)
+                elif args.command == "stop":
+                    stop(prefix, args.timeout)
+    except (OSError, ValueError, subprocess.CalledProcessError) as exc:
+        parser.exit(1, f"操作失败:{exc}\n")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 4 - 0
release/install.sh

@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+exec "${DEPLOY_PYTHON:-python3.12}" -B "$script_dir/deploy.py" install "$@"

+ 9 - 0
release/lock.meta.json

@@ -0,0 +1,9 @@
+{
+  "python": "3.12",
+  "resolver": "uv 0.12.13 (0ebbd9274 2026-09-10 aarch64-apple-darwin)",
+  "sha256": {
+    "requirements.txt": "807c1e564133f40e9990972a6e7bfd69399fce29917278cdf0e28093dbfda914",
+    "release/baseline.constraints": "042bea2532333ef79ebeaca0d17b882d04533675e93bf2c35f9122c11170afd0",
+    "release/requirements.lock": "ba050369acdb910ebf00673f22d880da933a1be1e9901d257758ff81cb78266c"
+  }
+}

+ 26 - 0
release/lock.py

@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+"""使用 uv 为 Python 3.12 解析跨平台依赖锁,并记录输入摘要。"""
+import json
+from pathlib import Path
+import subprocess
+import sys
+sys.dont_write_bytecode = True
+from common import sha256
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def main():
+    subprocess.run(["uv", "pip", "compile", "requirements.txt", "--constraints", "release/baseline.constraints",
+                    "--python-version", "3.12", "--universal", "--generate-hashes", "--only-binary", ":all:",
+                    "--no-annotate", "--no-header", "--output-file", "release/requirements.lock"],
+                   cwd=ROOT, stdout=subprocess.DEVNULL, check=True)
+    meta = {"python": "3.12", "resolver": subprocess.check_output(["uv", "--version"], text=True).strip(),
+            "sha256": {name: sha256(ROOT / name) for name in
+                       ("requirements.txt", "release/baseline.constraints", "release/requirements.lock")}}
+    (ROOT / "release/lock.meta.json").write_text(json.dumps(meta, indent=2) + "\n")
+    print("已更新 release/requirements.lock 和 lock.meta.json")
+
+
+if __name__ == "__main__":
+    main()

+ 1804 - 0
release/requirements.lock

@@ -0,0 +1,1804 @@
+aiosqlite==0.22.1 \
+    --hash=sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650 \
+    --hash=sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb
+annotated-doc==0.0.5 \
+    --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
+    --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
+annotated-types==0.8.0 \
+    --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
+    --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
+anthropic==1.9.0 \
+    --hash=sha256:a7fe05a9e4b72113b934501698df5d56484ac89f05677f0f793f8bfddb15cfff \
+    --hash=sha256:da5191a0af0b853a797fc0c6b09fc5b05cb5d9da063ba80fdd03c736abe81b6c
+anyio==4.15.1 \
+    --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \
+    --hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94
+apscheduler==3.11.3 \
+    --hash=sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30 \
+    --hash=sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a
+bracex==3.0.1 \
+    --hash=sha256:4e38e32392e4a4780fe15d644bfc7c8514057cfc3861e060b11814ce829c25e4 \
+    --hash=sha256:6523ad83aeb5098a4ee597cff0f964442ff74e460bd3fafaffab6a013ff2288c
+certifi==2026.7.22 \
+    --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
+    --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
+cffi==2.1.1 ; platform_python_implementation != 'PyPy' \
+    --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \
+    --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \
+    --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \
+    --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \
+    --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \
+    --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \
+    --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \
+    --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \
+    --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \
+    --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \
+    --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \
+    --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \
+    --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \
+    --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \
+    --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \
+    --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \
+    --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \
+    --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \
+    --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \
+    --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \
+    --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \
+    --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \
+    --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \
+    --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \
+    --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \
+    --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \
+    --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \
+    --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \
+    --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \
+    --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \
+    --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \
+    --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \
+    --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \
+    --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \
+    --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \
+    --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \
+    --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \
+    --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \
+    --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \
+    --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \
+    --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \
+    --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \
+    --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \
+    --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \
+    --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \
+    --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \
+    --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \
+    --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \
+    --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \
+    --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \
+    --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \
+    --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \
+    --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \
+    --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \
+    --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \
+    --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \
+    --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \
+    --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \
+    --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \
+    --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \
+    --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \
+    --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \
+    --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \
+    --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \
+    --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \
+    --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \
+    --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \
+    --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \
+    --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \
+    --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \
+    --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \
+    --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \
+    --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \
+    --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \
+    --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \
+    --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \
+    --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \
+    --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \
+    --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \
+    --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \
+    --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \
+    --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \
+    --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \
+    --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \
+    --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \
+    --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \
+    --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \
+    --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \
+    --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \
+    --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \
+    --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \
+    --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \
+    --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \
+    --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \
+    --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \
+    --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \
+    --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \
+    --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \
+    --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \
+    --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264
+charset-normalizer==3.5.1 \
+    --hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \
+    --hash=sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d \
+    --hash=sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5 \
+    --hash=sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b \
+    --hash=sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f \
+    --hash=sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f \
+    --hash=sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5 \
+    --hash=sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22 \
+    --hash=sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5 \
+    --hash=sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac \
+    --hash=sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838 \
+    --hash=sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90 \
+    --hash=sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626 \
+    --hash=sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4 \
+    --hash=sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369 \
+    --hash=sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b \
+    --hash=sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e \
+    --hash=sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee \
+    --hash=sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1 \
+    --hash=sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102 \
+    --hash=sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8 \
+    --hash=sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8 \
+    --hash=sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9 \
+    --hash=sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf \
+    --hash=sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0 \
+    --hash=sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031 \
+    --hash=sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e \
+    --hash=sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235 \
+    --hash=sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072 \
+    --hash=sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb \
+    --hash=sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c \
+    --hash=sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950 \
+    --hash=sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2 \
+    --hash=sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb \
+    --hash=sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e \
+    --hash=sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6 \
+    --hash=sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b \
+    --hash=sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2 \
+    --hash=sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d \
+    --hash=sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa \
+    --hash=sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2 \
+    --hash=sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818 \
+    --hash=sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032 \
+    --hash=sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71 \
+    --hash=sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96 \
+    --hash=sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687 \
+    --hash=sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8 \
+    --hash=sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3 \
+    --hash=sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61 \
+    --hash=sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9 \
+    --hash=sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1 \
+    --hash=sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a \
+    --hash=sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420 \
+    --hash=sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4 \
+    --hash=sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65 \
+    --hash=sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663 \
+    --hash=sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f \
+    --hash=sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591 \
+    --hash=sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a \
+    --hash=sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18 \
+    --hash=sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e \
+    --hash=sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f \
+    --hash=sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7 \
+    --hash=sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3 \
+    --hash=sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c \
+    --hash=sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3 \
+    --hash=sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7 \
+    --hash=sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96 \
+    --hash=sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486 \
+    --hash=sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3 \
+    --hash=sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6 \
+    --hash=sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b \
+    --hash=sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731 \
+    --hash=sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959 \
+    --hash=sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9 \
+    --hash=sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf \
+    --hash=sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8 \
+    --hash=sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e \
+    --hash=sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f \
+    --hash=sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885 \
+    --hash=sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0 \
+    --hash=sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506 \
+    --hash=sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2 \
+    --hash=sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0 \
+    --hash=sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e \
+    --hash=sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f \
+    --hash=sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e \
+    --hash=sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491 \
+    --hash=sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a \
+    --hash=sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20 \
+    --hash=sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449 \
+    --hash=sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af \
+    --hash=sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c \
+    --hash=sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712 \
+    --hash=sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7 \
+    --hash=sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a \
+    --hash=sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20 \
+    --hash=sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f \
+    --hash=sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3 \
+    --hash=sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9 \
+    --hash=sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e \
+    --hash=sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5 \
+    --hash=sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2 \
+    --hash=sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36 \
+    --hash=sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263 \
+    --hash=sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4 \
+    --hash=sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11 \
+    --hash=sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a \
+    --hash=sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3 \
+    --hash=sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375 \
+    --hash=sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa \
+    --hash=sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d \
+    --hash=sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5 \
+    --hash=sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99 \
+    --hash=sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d \
+    --hash=sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c \
+    --hash=sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488 \
+    --hash=sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6 \
+    --hash=sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc \
+    --hash=sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b \
+    --hash=sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f \
+    --hash=sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00 \
+    --hash=sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10 \
+    --hash=sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598 \
+    --hash=sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6 \
+    --hash=sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962 \
+    --hash=sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c \
+    --hash=sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08 \
+    --hash=sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab \
+    --hash=sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573 \
+    --hash=sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90 \
+    --hash=sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5 \
+    --hash=sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18 \
+    --hash=sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d \
+    --hash=sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af \
+    --hash=sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea \
+    --hash=sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c \
+    --hash=sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b \
+    --hash=sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6 \
+    --hash=sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8 \
+    --hash=sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774 \
+    --hash=sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004 \
+    --hash=sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a \
+    --hash=sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a \
+    --hash=sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2 \
+    --hash=sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2 \
+    --hash=sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa \
+    --hash=sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe \
+    --hash=sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3 \
+    --hash=sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc \
+    --hash=sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795 \
+    --hash=sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d \
+    --hash=sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc \
+    --hash=sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893 \
+    --hash=sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef \
+    --hash=sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d \
+    --hash=sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda \
+    --hash=sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17 \
+    --hash=sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30 \
+    --hash=sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7 \
+    --hash=sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5 \
+    --hash=sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182 \
+    --hash=sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f \
+    --hash=sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9 \
+    --hash=sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada \
+    --hash=sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876 \
+    --hash=sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a \
+    --hash=sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348 \
+    --hash=sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3 \
+    --hash=sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f \
+    --hash=sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0 \
+    --hash=sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f
+click==8.5.0 \
+    --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
+    --hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
+colorama==0.4.6 ; sys_platform == 'win32' \
+    --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
+    --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
+cryptography==50.0.1 \
+    --hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \
+    --hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \
+    --hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \
+    --hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \
+    --hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \
+    --hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \
+    --hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \
+    --hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \
+    --hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \
+    --hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \
+    --hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \
+    --hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \
+    --hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \
+    --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \
+    --hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \
+    --hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \
+    --hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \
+    --hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \
+    --hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \
+    --hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \
+    --hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \
+    --hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \
+    --hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \
+    --hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \
+    --hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \
+    --hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \
+    --hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \
+    --hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \
+    --hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \
+    --hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \
+    --hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \
+    --hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \
+    --hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \
+    --hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \
+    --hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \
+    --hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \
+    --hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \
+    --hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \
+    --hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \
+    --hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \
+    --hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \
+    --hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \
+    --hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \
+    --hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \
+    --hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \
+    --hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef
+deepagents==0.6.12 \
+    --hash=sha256:1f281c0bc5a63132f62e2ee345c1dc593b23188da6e23016401f6879fbe54b5f \
+    --hash=sha256:28b8fa0119ca0a689e3e18e288c4634e4046062acfc87a1cb34289d3af3a1c88
+distro==1.9.0 \
+    --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \
+    --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2
+docstring-parser==0.18.0 \
+    --hash=sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015 \
+    --hash=sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b
+fastapi==0.141.1 \
+    --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \
+    --hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1
+filetype==1.2.0 \
+    --hash=sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb \
+    --hash=sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25
+google-auth==2.59.0 \
+    --hash=sha256:43e47b1d3067fb90d77bd9ba21f139bb0577b3f2b9f3e2516fc00f37435efcad \
+    --hash=sha256:eb32f44f89f6b577947ebee5887c1db46e6b1a278889ba369a88179643f32240
+google-genai==2.25.0 \
+    --hash=sha256:2e8f3a5f76ed00d5ffc3153f6089bc7d3511054eed53ad9cfc6095a7dc59b028 \
+    --hash=sha256:ab603baa5eee0205926ad0f8d7f93e0400df6d67650e99c33e01ab228ea16ad6
+h11==0.16.0 \
+    --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
+    --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
+httpcore==1.0.9 \
+    --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
+    --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
+httpcore2==2.13.1 ; sys_platform != 'emscripten' \
+    --hash=sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103 \
+    --hash=sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a
+httptools==0.8.0 \
+    --hash=sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683 \
+    --hash=sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb \
+    --hash=sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b \
+    --hash=sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527 \
+    --hash=sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124 \
+    --hash=sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca \
+    --hash=sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081 \
+    --hash=sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c \
+    --hash=sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77 \
+    --hash=sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09 \
+    --hash=sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f \
+    --hash=sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085 \
+    --hash=sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376 \
+    --hash=sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5 \
+    --hash=sha256:5d7fa4ba7292c1139c0526f0b5aad507c6263c948206ea1b1cbca015c8af1b62 \
+    --hash=sha256:5eb911c515b96ee44bbd861e42cbefc488681d450545b1d02127f6136e3a86f5 \
+    --hash=sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8 \
+    --hash=sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681 \
+    --hash=sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999 \
+    --hash=sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1 \
+    --hash=sha256:7b71e7d7031928c650e1006e6c03e911bf967f7c69c011d37d541c3e7bf55005 \
+    --hash=sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d \
+    --hash=sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d \
+    --hash=sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d \
+    --hash=sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d \
+    --hash=sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba \
+    --hash=sha256:9fc1644f415372cec4f8a5be3a64183737398f10dbb1263602a036427fe75247 \
+    --hash=sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745 \
+    --hash=sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07 \
+    --hash=sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b \
+    --hash=sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4 \
+    --hash=sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2 \
+    --hash=sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557 \
+    --hash=sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d \
+    --hash=sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826 \
+    --hash=sha256:c08ffe3e79756e0963cbc8fe410139f38a5884874b6f2e17761bef6563fdcd9b \
+    --hash=sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813 \
+    --hash=sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0 \
+    --hash=sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150 \
+    --hash=sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e \
+    --hash=sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77 \
+    --hash=sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568 \
+    --hash=sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6 \
+    --hash=sha256:df31ef5494f406ab6cf827b7e64a22841c6e2d654100e6a116ea15b46d02d5e8 \
+    --hash=sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b \
+    --hash=sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7 \
+    --hash=sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168 \
+    --hash=sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a \
+    --hash=sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0 \
+    --hash=sha256:fe2a4c95aeba2209434e7b31172da572846cae8ca0bf1e7013e61b99fbbf5e72
+httpx==0.28.1 \
+    --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
+    --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
+httpx2==2.13.1 \
+    --hash=sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4 \
+    --hash=sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa
+httpx2-jsfetch==1.0 ; sys_platform == 'emscripten' \
+    --hash=sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60 \
+    --hash=sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32
+idna==3.20 \
+    --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \
+    --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c
+iniconfig==2.3.0 \
+    --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
+    --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
+jiter==0.17.0 \
+    --hash=sha256:00b5a98df3e3a3e8cf7b619f4ac2f8bf975bbf3d95d02c5d17b8dbfe5c8b8245 \
+    --hash=sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5 \
+    --hash=sha256:0239520085cac678e77a606fd7e3f1c60c371d719790c5e3807388d3da4354c2 \
+    --hash=sha256:02a360707033d8cef53f7f3480817a1489177a259ec6ec01e98c37e0b922ddca \
+    --hash=sha256:02adebb7ce6413c44d40af9ad59d1c1cd79630ccdcb6f7bdd2d461e48c03d8f9 \
+    --hash=sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12 \
+    --hash=sha256:0619d806e260ecf0c2a64521942c94af5d547c9ec99b55ae4f51b538b5576a76 \
+    --hash=sha256:073dc68c1a700c8fc480e877864a6b6ffc887533e261f4380c08c16bf09d057a \
+    --hash=sha256:0b52d52035b3907c5b1f6277857b29c1cbfc965e24e0f27330dbed83edb591ec \
+    --hash=sha256:10c5349312e5cb02b7a21e123a57665afa895953f05bf252a9dd4c13a572b7ab \
+    --hash=sha256:10cd64a5720ad7f809ac5466ff1705813f1b6b510f195a73acafba0ac0e1f675 \
+    --hash=sha256:10f5558eed511b830488003449d942bd75829ad6257dc58cb9a03e596a7777b1 \
+    --hash=sha256:11902505d401691720f5785c15b02204248526edee11b635cd6c40cd52b81599 \
+    --hash=sha256:155be7355bdb7ca76ab0961be8982c225f964a5c073a83984183f22391cc29fc \
+    --hash=sha256:16dd0c1baf098ae70b8f3616574eb3fedf34e26670b89e16a7e67561f737ed2d \
+    --hash=sha256:1b18434638228c0c184281609bf3d9459026a0f1ea48fb76c205e3ef72069caa \
+    --hash=sha256:29f49b325e0234e4ad9ecca5b861ffbd09b95ccac9bd46fa55841b6e56eea5fe \
+    --hash=sha256:2c45ad7c973ef33fe5114a953377b35a95240f4542c0724d9f781e47dc24bac7 \
+    --hash=sha256:300ce01ab0215e3dea4d00090143c909aedc65c0f809b3c07983e1d038f291b9 \
+    --hash=sha256:30793a24a31e968969757c9e08d830cbb15a2cd3c4959b4498b38f4b1c2258eb \
+    --hash=sha256:30c692d567ba206c7cca38c9d1d0ccc70c9786290173c184d871ca12e9981ed7 \
+    --hash=sha256:32aaaa764604496610a3ad2d98503ae88ccb2fbe769e892ff4533e778e85f708 \
+    --hash=sha256:362bb47423886d45a9f705d2d9d4008c6eedd4e41eb1bab4e96fb6daa06b33fd \
+    --hash=sha256:36ee6e69027396664e59995b9a635a947a5304ee9837279584a0bb8145c8f6b8 \
+    --hash=sha256:370d8fe5bf201dc6925e8a84c81ac7291f74d9fd1778234fc79d517064a5c76b \
+    --hash=sha256:37150a9e02e869475854fa20b7d0d5e26d18d0f8bc17293999973ff27e99ae7a \
+    --hash=sha256:37f33d327900bf2879613b3363fd48df97b4232d0c41f54bcf2e790c2fc40a71 \
+    --hash=sha256:3ad556afc289f15d2b181b941982d01f06190863c07440185b9f354e1bd2def3 \
+    --hash=sha256:3bf4dc2b84a464117fb097d15a25c58d100d2692888e3b0d92df5b48ed16b7c0 \
+    --hash=sha256:3c1a5336c04a41b1f1cf9572e294aec27cc569767ff73de7bf87a91f0bea7cb9 \
+    --hash=sha256:3e05f5adbf68c4bd11e1610f394034d984152988e84be6f8314235ce6f2139e5 \
+    --hash=sha256:40d2c240f8f80b5b0f201b29f0ae129c81448c60c772227a41747b5e0026f6a2 \
+    --hash=sha256:42b0260445251b1bc520a63baa94a32d88e0f931fba234f1764db7feb7c72174 \
+    --hash=sha256:454c4997d73cc466c71fd565d91e603b0274e48ea0c6b0b7a7aee6967e4ceb7c \
+    --hash=sha256:455e4ab35cb2a4a91a8404e08fd3c621bae433922e59bf1c494fe20a426b013b \
+    --hash=sha256:4607ec7d93355fbc25b8dc5189153cf21d66063b9f9cd04dd2774e6e783f9b6a \
+    --hash=sha256:470e1b1e4c42f1ead2189166a299691871a2df5056c976e7fb96feafaf5f9d44 \
+    --hash=sha256:492f37230bbf9581ab2c17bcda862c249afb9ae2e3ab2dd6db59943bc4cc3153 \
+    --hash=sha256:4dfbfe5a6e1e80a7082af559f66386405025ec278833e0c649f69cbc6e1004cc \
+    --hash=sha256:4e3f052c671d5f425cca5ea5901cf11a831369fba4a55a3862cab93c323b4c3b \
+    --hash=sha256:5078ab00664307fab2019b522a93aeb191122789f085daf5fd9e362154021d4a \
+    --hash=sha256:51e1519d676a9f14dad9c2a411170d43b022ddb7989562df4e849b261ce127b2 \
+    --hash=sha256:523c499235fb65add25d4bb01b1c4709ce695efdc7deb6c0a7bc515b5c44e0fb \
+    --hash=sha256:545c36a0f3b2238c242cc9785439d3242a871b7bc39fe3f441bcaa07bf3aa83e \
+    --hash=sha256:55d0e0e613a3f9ad600cf436e0e2b8057d1b52bcf1d91b2d36ac53451231e6a8 \
+    --hash=sha256:5888fe5abc1ca2fa834a3e1b4c7ef0dcece286a7d7e95a609ef0934b777b9fc9 \
+    --hash=sha256:58df29268a95e910f17db7ec9178eb7f15aa8619aaca3575275c4e6b3f4fe4c5 \
+    --hash=sha256:59bddbe6f9ffecc68d641e1e2d619ce64cf8a9e9eeb74e5c518f74fc87abf1b0 \
+    --hash=sha256:5a52a430d04225ffde633e6840bf2381d34c019ff98526b5929755b9052fb199 \
+    --hash=sha256:5bf350452a43173e69e1fc74847c57a60e3d7515807287f29849baa2a85d8718 \
+    --hash=sha256:5c23849235d2142ce444b2b8c6eceee9f82f4cc0bd5c9081602e4155c6197807 \
+    --hash=sha256:61aed66ee042b3b49ef85fdf75714234d055d89d8496ac1c6e47f89e7a30d5e4 \
+    --hash=sha256:6219adaf59711ba7063a52496e8ec6d3fa3e209d7827d83eee3b2abc780a1744 \
+    --hash=sha256:64846211a2debe7c071d2146d2283d2b0c1c93dc8fd5fb7794faac2ca6061b5c \
+    --hash=sha256:686c93d86f2b426c803024b805bd161a6cd10e9627c23e901640eab646c0ad8a \
+    --hash=sha256:6871973bfbd4408f7f1c632b30bbb5bbd9671c1bc8650af6823e24b7be13709b \
+    --hash=sha256:6af5b74073bd25bae695e6d00919f6a9be7ed5a9f8836d981eb1ffe84139e6fb \
+    --hash=sha256:6b303d88e6a0bda789ec4b7801c7bad68e27230ba1fe4baffc756d1fbd32dc9d \
+    --hash=sha256:6cb41cd1432f1dc19a231cf70b54d42b2c9f05085155859263fce06fa4d41388 \
+    --hash=sha256:6cf564d43c4388149ca58ee571d0f5ccf875e20d1fd4662fd94cc0d1ea3b10ef \
+    --hash=sha256:6eb6aedeb7352b8f3b6af9cbd67983840165c00428e63f1b420a85885128ea31 \
+    --hash=sha256:70f19a2ca8429f91e82eeffb2f51cb87bc2d6e953b009b91a92d29c3a16ccb03 \
+    --hash=sha256:71dbd74314c5df52a1bccf7b8bca46d14e943af7a2012e73b23f49977ef194c8 \
+    --hash=sha256:73b64e69c4150748e020356d958af94bec33c70a0a93d665cfa8f6d580fe1a63 \
+    --hash=sha256:746243a080b4ca790b8499af3d7cf9825d5f5987933950cd818e767ee353d826 \
+    --hash=sha256:755079792868ce5d4938e83b91a0939b34fb858a1ca65a104f2d771bea57faa1 \
+    --hash=sha256:7573e80232c5bcf80c24c038cf7e53a463f5c3b1dd1dd4109d66304f4dccc233 \
+    --hash=sha256:76eb4a5c20e86f9f848286f167024890f2862258a965d254774deb7fc1545ca1 \
+    --hash=sha256:77f6aac0137309b31448c1bdcda4c6c77077664a6d018ece8d94019c68a5a5b9 \
+    --hash=sha256:785a216bbaf8f15fc974e964ced7322cd3d774bb0e86949edd78c6bffd6ba35b \
+    --hash=sha256:7b68d3495d95da120651a5628c7ebadee84ed001a1b76e6afc325c42482f15b5 \
+    --hash=sha256:8079849db9a1371bfd90bad088458a8fb836261879df2233cc9632464ecf64e1 \
+    --hash=sha256:81c83c0abe614446a283d994d2c07c4f58632dea2cdf66ba9e2921bb8ccd593e \
+    --hash=sha256:826871c42cebaae22f0a2b5673a4a1a75c851bb2d13b3c17764a630a6b298984 \
+    --hash=sha256:84963d3f395ef5e9a32ce47155e08a7962fa292c159a10cb98b931cef1416925 \
+    --hash=sha256:84ac78df457e1ee3f7e733bd114823302ae8c5ad5542d7e6647d92ffaa090a04 \
+    --hash=sha256:86d703d9faa1ffc8ae4e9de0fa007712ed2171b5c0d93811a8e2e105ac729b0d \
+    --hash=sha256:86f3f9343a288eb85a81ef20a752b2f84564296636db54a9fff0b5c8deaf1df2 \
+    --hash=sha256:8adca2e793288e5f1bb29279bb439d0d3cfbb50eddca7e7e6ffd42ff4f482406 \
+    --hash=sha256:8c21265b251d99bbb40080d178a8953e35601d3a1564e05c4de4c0d2ca616797 \
+    --hash=sha256:8c286860abfe8b100cac1c02e225e5776eb9216edd71ba17cdb237da4af32bc9 \
+    --hash=sha256:8f770b0c77e5fac482e1ba03ca1a7e18286bfb213d749932a00a7e4cd5de5e06 \
+    --hash=sha256:93946d89fa04d5ba64dd323a8dd8d901676cb8a3c81d99ae4f6c051a9b4c3f2f \
+    --hash=sha256:96b8b0c6dc5d78682f54a450785e075aa929cde768304cad363cd4efba5a82ac \
+    --hash=sha256:9bd3caac219df476dd0cc3fe01d2f1581ed588906feac767abd9614c1c12f8b3 \
+    --hash=sha256:a277f97eba7d66b1ee27eb5dab5b774ff46a10c78d89a1d3dcce04ce1357c8ca \
+    --hash=sha256:a3cebb1fe4a1abb00465f3f8a17e09112603e8b7c59e5c3adbcd9f7815a64acd \
+    --hash=sha256:ac3c6ee3264d6f5c44c617f90bc7e8b9e1587e7d6708c9d8f811cb65582ee312 \
+    --hash=sha256:af2f7501580f274b63c4b2283bc425f5df7edf06ae5b171e5f87d912ff359a20 \
+    --hash=sha256:b550585523339b71cb852b811aae49d08d7601ad8ffe9f5dc1562f4c3d22fd87 \
+    --hash=sha256:b75f85660108965a94be77911a25a253429307294d9415b3c597118977a614de \
+    --hash=sha256:b847b18d066c46b3b7ae49d6c94a7634c5e4a8983146ee25562a092000f5e3ad \
+    --hash=sha256:bcc064f99183a9cbe7f26ed648c352031a74145cd61ed75d34632c73eb46a5a8 \
+    --hash=sha256:c19b9357309b8cc6de8a48fca8e44a8c9c2feaaa2f5896d037fa505d48fcab80 \
+    --hash=sha256:c4289293e5278d9314b00f15c37f2120fa51d3d68565292e715524c750e775a9 \
+    --hash=sha256:cfafd7be8b16ceadd298db542cead37cddc211c4c49e04ad2596924df18625b1 \
+    --hash=sha256:d0ce4feb52493e3513335b2accdcd75605652e4632772d3c8c2f7b86954d7f39 \
+    --hash=sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff \
+    --hash=sha256:d47687806f9c54c84ea38733507081337922beca90ce819c7d852dd485bc0f23 \
+    --hash=sha256:d85c558c9f8532bba287a990ac63767c7daf756f0d8c030219f62499b1fa228a \
+    --hash=sha256:da139721f4b7cafdbff580a4f511ea24cb91f4909330c6b926a1ca53836c0a59 \
+    --hash=sha256:dbbfe4e3c21c8166980cddc5bee1a315df082454f007947dfb6fb73800768165 \
+    --hash=sha256:dc0288ce39190ee33fe6e4ec73161eed34e7e2da509b525546ca061778d62b64 \
+    --hash=sha256:e088612ff90ebc9247e1a43074b72835804261c47e6a6c01cb3ddcb55360d688 \
+    --hash=sha256:e654b6b04e39c9cb19cb8b04c6ddf1f2db07751fa14156413969fd78bad0e5cb \
+    --hash=sha256:eaba834b72d573547b9d966465b3394b749d5e14208cc70acb63aca37619ab33 \
+    --hash=sha256:eae86b1f027031e39db2e0e9c4842221edb7b8cd474d23f87a79b3bd4b651768 \
+    --hash=sha256:eb2295da7c3769f6719b227a237aa6a5cfa6550e478bc838001b592c57e16575 \
+    --hash=sha256:ebf918dfd6a74adc1b9ad71f63c4ab00902fcd3b7fd39f2e24d871db8d713b91 \
+    --hash=sha256:ec89771f4272b989487a6364e519db6bbaba323e8bbf949ac89a45ea9c18b7a3 \
+    --hash=sha256:ed1a24005daac667d577402d75a2922f9775a165b146b883ff1ad3602d8be689 \
+    --hash=sha256:efe9f61bb30174d2f5c8396445c360c96c44e78164d0815dfe627ccf57849574 \
+    --hash=sha256:f0bc7f684b65bcda9c20434267577db71bf9905ceddd32b60d1d93278d8c8d3a \
+    --hash=sha256:f3d7f7b34114f7ddc6d72a8e882d49de636b35d9fd12b4d420d3c5729f6c9812 \
+    --hash=sha256:f753eb70b1474a29e635e7542ff7312e6d6b951e0b25e8a2e8c34eeb1ddcd478 \
+    --hash=sha256:fa13acf1046f95df808c64b1310705e143fab87aee73ae00cc42d640867fd2c1 \
+    --hash=sha256:fd7790aa79c8b518e512ebcdfce9f11d8ef5f30efd43720c8a19a548b39fa489 \
+    --hash=sha256:fe15ddf316f1f1f643347d3a474e74ce61880c79a11ec5dca53df20c071bd3e8 \
+    --hash=sha256:ffa0380ad091de7d3fc33e17a97ff479851ee18a0a2a3ee56ff3215cdc886656
+jsonpatch==1.33 \
+    --hash=sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade \
+    --hash=sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c
+jsonpointer==3.1.1 \
+    --hash=sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900 \
+    --hash=sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca
+langchain==1.4.3 \
+    --hash=sha256:0334066939dd1c7551889727ed2d09a72dea7a6aed2193ffb61817fb658d8921 \
+    --hash=sha256:aed5c1a7cd581f70ddc076b75fdbcbae8031b8373983f86efd287aa70798abc2
+langchain-anthropic==1.7.4 \
+    --hash=sha256:7d540300665a1c906986e505cdec41204e759f14dbd989f53f0c180d82624b94 \
+    --hash=sha256:eec1178389358eaa4cff4f3dd353f063746b8c61090cffe9236d9035df71e66c
+langchain-core==1.6.5 \
+    --hash=sha256:54c7b0e9314b9084fb04405bb33dc5d32986d512d92b7b8863899cd5f6267556 \
+    --hash=sha256:bdb059fef65473fd444558dbb18f50901657402c43ceed9dc0fdc7b76a393c6b
+langchain-google-genai==4.4.0 \
+    --hash=sha256:7871beec56ac07b719f77c46997845db6ff2267b817bffb0f97877053b0895d7 \
+    --hash=sha256:8e23a1307bd2158590bbf9d99f1d658fc84a9d9ddb77fb1b372f8875a2bafbbf
+langchain-openai==1.6.6 \
+    --hash=sha256:60dcaa443ddfdf1559feaf1d25916f394c7dda809aebe22a769fc39f02705be7 \
+    --hash=sha256:7b3848f42825aca40165463aa37757051165eb292ba7f24658a2f58f9bb221f9
+langchain-protocol==0.0.19 \
+    --hash=sha256:4cdf879a492a35980fd859ae792d3c65458ccaae504e183c9a10d7eac1f0720f \
+    --hash=sha256:79d90a1425122ac87e8052e2ec054fbd09c3edbf341bdfb6397112a495c7bf8c
+langgraph==1.2.12 \
+    --hash=sha256:5fae9fa4ce771e90f10ff19ce0d5aaef4b41d7632c713d769e89cbce1241a4b9 \
+    --hash=sha256:95403af7b510de8d79164742f71daaf866c69ca804a8cb72bef2cc1fa1ab9813
+langgraph-checkpoint==4.2.0 \
+    --hash=sha256:0547fd228935a0b758865de3a3d6d7a2537c308895d0f9ab092ce9151b5da942 \
+    --hash=sha256:51a593b6bee684b0818e5d6e58e28ab340c6db7794575056ce7bd1b746a84ed7
+langgraph-checkpoint-sqlite==3.1.1 \
+    --hash=sha256:6fcb20db4c37ef7aad52f29b539eb98c38e2dad6fab7c2446a2a9db24f37a70e \
+    --hash=sha256:8505c54c94a658080525d7e6780fdd4e0c078ff2566b30d399c02cc9f9af1c63
+langgraph-prebuilt==1.1.0 \
+    --hash=sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528 \
+    --hash=sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9
+langgraph-sdk==0.4.5 \
+    --hash=sha256:d49a98a2ee8e0c494b101a7caf8061c8b4b94d3ee5ada0ea2dbe50903a5487b1 \
+    --hash=sha256:e03ef033a20d21288af496e9b6c08ae3afcc1b58dc5abcd37575bfd1a93045de
+langsmith==0.14.1 \
+    --hash=sha256:0b5cd2473420c9c5acef33a9bafe6c812a7cf96741e9a5af465b1d93bf77f7ae \
+    --hash=sha256:dcfd8a25ba72d8663024bd87df02e372e470f43e18f4a726f37fbacc772c0193
+openai==3.20.0 \
+    --hash=sha256:5e5dc0a3c0a9362e69cd35a65d04580a73a66eb39d5abeb78881b6190ca5d98c \
+    --hash=sha256:fb1b109e3423ed51c6ade96bbb0fffddae39c8e64bb9b6e501659bcbaa5ad30c
+orjson==3.12.0 \
+    --hash=sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a \
+    --hash=sha256:01efac2074fffb4cb1ea3fab7861e9d0f2a26913854a972f5ac760525dbdaf6e \
+    --hash=sha256:03091c8a64db4be38746597ceea68f33c238e27acd9bfe99fb59420224ae7a55 \
+    --hash=sha256:08231552159be266a7269555bd9f7c016aee7d9ad6dab06eb58796c5ccb7101c \
+    --hash=sha256:0b1ac5bf6609b2716c7954011c5fef6254922df029f45d032ee4ebf5d363cbed \
+    --hash=sha256:103b5db66aa53c1f9e88c2524be4f383e831ba7dfd5f9f5af6336a177c622f11 \
+    --hash=sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b \
+    --hash=sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54 \
+    --hash=sha256:18a87929f31d94a77f7dc93cf527e91f39ce7fe7813d588a4de2507efd32a387 \
+    --hash=sha256:1c680706fc8396d95e7c4c1f9482563f552137aef91b57237a3ad5aaf64629df \
+    --hash=sha256:2b7bcefb9f40fa242fa6b06377232c048e655747790829609168c01162f60578 \
+    --hash=sha256:2bb3ce43203936072dd8b4917b01d3aecfc02329bfb42510cb7cfb24708adc9c \
+    --hash=sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83 \
+    --hash=sha256:2eb5c56e534127b2b8fa38d2363c8b1b8190367ee0d1d16c041517d880843b94 \
+    --hash=sha256:31ed278a36304390adc3eec5d7f6fd593a7c3e99e5a06cd07866396c4b1b4710 \
+    --hash=sha256:33efefcf5d88eaf400b47e2eba02f91f319bb9951be61ca500b7d536d3f2079d \
+    --hash=sha256:3bb17a06f9bd15237b3216c044209fe92597379124018cfc196fbb846cde64df \
+    --hash=sha256:3dbce9b6b3074b31a5d5dd322a9c4e5b16f206091ece4194c2e36952847a105e \
+    --hash=sha256:40f92192227505acca4e2533ce565f8e6b9535f7d0d09b0968452f18b7376b38 \
+    --hash=sha256:477ecaf6b9f88f873341b91fcc736119ca81b5e002a9f7f308ff5b4f2ce2a70e \
+    --hash=sha256:50fae885cb073eac7556353ff3df93312b0d5137b0a5056b2bb63f97ed9a93c7 \
+    --hash=sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873 \
+    --hash=sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f \
+    --hash=sha256:58c58e1de0006ffb580368d6793c36c7b0b021db066479cf281bf5061e732328 \
+    --hash=sha256:5a0fdbc216388f653d3752ff310e710f59253bd4ed6a2bfb3f4f06b84714bbd8 \
+    --hash=sha256:61318b6de893c7a9d9f3e5ecbadccbfc26a7eb417ccc7bbf0771de3b4d72f868 \
+    --hash=sha256:644d005bc82f917337a95ce270c9f6f92f9834c2bed7b1477572f8db00784222 \
+    --hash=sha256:6a2a79c89984dc719817d388c8709e0efc2a2795a934eaa746b4882eb6045adc \
+    --hash=sha256:6a31348d7dfa64cd9c78bd1f510ff44c48fe64d71094e6b90e364dba3b55949e \
+    --hash=sha256:747843254519dd43b93eee3153a19e5a509334320c4d2f823ec879232db5c796 \
+    --hash=sha256:784106539f4b9d4b930e0b4eb8d45168507dae001945e71b4675a367f1e5e806 \
+    --hash=sha256:7c2ad193c8004254f34b499f3bd2c80f043d10754aff2b38f93da574f4883f98 \
+    --hash=sha256:83445adc40cba26d6d621185a45128ce455b766af368cad2ab64b970603a7978 \
+    --hash=sha256:859fc4196855890150bb08e649b30d2c93b249b3e3edd0d3bb2231abf8aa8adc \
+    --hash=sha256:8c3bb86dd10f39b3fbf434b7d5dc7cac77d6fc8ac572ae30a10731ede2c4b647 \
+    --hash=sha256:8e29957429c35bbb5a185a119c523aa2428b7bbf1a293724c7b9375ed8f892a3 \
+    --hash=sha256:8e386b0bc0ddd7cd2056f884b5a0af33592bd01ac66a7ca4b42a65a7e7774a13 \
+    --hash=sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7 \
+    --hash=sha256:9a36ec60f1796f9a3f13e3b98390295e17a1c7c10155b448d264098bf9ee5900 \
+    --hash=sha256:9caf3d09f47c3c70c4451ada20ef9bc4a4cdffa26f49862cf0a253b329aae2d5 \
+    --hash=sha256:9e6fee342a48760e854d743e7a81534d8e2925a6f46e09f750cf56b50fd1de5d \
+    --hash=sha256:a15f9a891bce5f5cc5d210e3ad8614d4d1b489a56448c099d6d2a7168b2d954a \
+    --hash=sha256:a696529ec96a90d9a5f9570207efe403c8b08f8e4aa2783ee3403511e2fdfa10 \
+    --hash=sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5 \
+    --hash=sha256:a791f793b287bbc135b8e87c34e35c8bfc693e2a8a620fab1ae682b925f9a32e \
+    --hash=sha256:a94f0f0c6fcbb2b5bd9734c57a489c7584a732bbdf04a39e8c83b861e9d03e92 \
+    --hash=sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0 \
+    --hash=sha256:ad0422b92d5195443a39f80c3bcf731cc2e00f153bd32063a47b73b057bd0f03 \
+    --hash=sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d \
+    --hash=sha256:b85931be5b6763c31283805c9bdaae1ca03ad9f6f12a15f1cbf6745b907932c2 \
+    --hash=sha256:b9dca132b1fda5565088e65a6b6e742285e0aeceb6fae549fa8863e16c7d3998 \
+    --hash=sha256:bc7a872f03522d90e0429e6c0c5cd23084f767bedcb4c58048eec19294613344 \
+    --hash=sha256:bd57d79aefa3f84eec851d6de7a366795b9345cfaf17f82b4820430a7a5fa241 \
+    --hash=sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e \
+    --hash=sha256:c6b11be792c3d2c6a4be2af4ebf97a68d0bf5f580aca6e86a418a354f6cc846a \
+    --hash=sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5 \
+    --hash=sha256:d39f3f5c3927e2dc0913fe5bbc1a2f6b1b9d1bba1de6358340d0ad0d0c00ca92 \
+    --hash=sha256:d8e78d3d93705e3d27cc17cdb209e44d7a8ea203010cac6ce9c7ffc1ae1996f1 \
+    --hash=sha256:dce0166feb0a737ab84f598c9a338cbc0b764a036617aa686194f53c7eba0c3e \
+    --hash=sha256:e4ac5059baab4b3acbd99485de019ff8cda0fdf34b61fa74f7197a53db78bfe8 \
+    --hash=sha256:e9683ee9ea0659da64f36574ef675b8a86330c34c19ea75db1fb93c3ff99e0ef \
+    --hash=sha256:ed4ca42bd55955aa34deedcfdfd0e0c31abf51143aae158ae2bc3520b626e517 \
+    --hash=sha256:f06dd838d1e07d9b1de0932ec0485ec92c4d5f5d1ad4817a656268c3e88be1e1 \
+    --hash=sha256:f3c0683136acdc29afdf88a5bc2f7d3d0e34087788d1d63c0144b805a87a196f \
+    --hash=sha256:fb2539159dfe8d371914f354360fa50e4a577cc89222a3828b9650a5e5040252
+ormsgpack==1.12.2 \
+    --hash=sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d \
+    --hash=sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c \
+    --hash=sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d \
+    --hash=sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e \
+    --hash=sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9 \
+    --hash=sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d \
+    --hash=sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172 \
+    --hash=sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a \
+    --hash=sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5 \
+    --hash=sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d \
+    --hash=sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181 \
+    --hash=sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c \
+    --hash=sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553 \
+    --hash=sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033 \
+    --hash=sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7 \
+    --hash=sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc \
+    --hash=sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a \
+    --hash=sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685 \
+    --hash=sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355 \
+    --hash=sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163 \
+    --hash=sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8 \
+    --hash=sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c \
+    --hash=sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd \
+    --hash=sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7 \
+    --hash=sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b \
+    --hash=sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2 \
+    --hash=sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e \
+    --hash=sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b \
+    --hash=sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33 \
+    --hash=sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e \
+    --hash=sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2 \
+    --hash=sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f \
+    --hash=sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede \
+    --hash=sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709 \
+    --hash=sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c \
+    --hash=sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9 \
+    --hash=sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13 \
+    --hash=sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657 \
+    --hash=sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd \
+    --hash=sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a \
+    --hash=sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258 \
+    --hash=sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4 \
+    --hash=sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6 \
+    --hash=sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f \
+    --hash=sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92 \
+    --hash=sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6 \
+    --hash=sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285 \
+    --hash=sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1 \
+    --hash=sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd
+packaging==26.3 \
+    --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
+    --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
+pluggy==1.6.0 \
+    --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
+    --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
+pyasn1==0.6.4 \
+    --hash=sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81 \
+    --hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b
+pyasn1-modules==0.4.2 \
+    --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \
+    --hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6
+pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \
+    --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
+    --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
+pydantic==2.13.5 \
+    --hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 \
+    --hash=sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08
+pydantic-core==2.46.5 \
+    --hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \
+    --hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \
+    --hash=sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5 \
+    --hash=sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c \
+    --hash=sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184 \
+    --hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 \
+    --hash=sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc \
+    --hash=sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5 \
+    --hash=sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0 \
+    --hash=sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931 \
+    --hash=sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e \
+    --hash=sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25 \
+    --hash=sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d \
+    --hash=sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6 \
+    --hash=sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47 \
+    --hash=sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038 \
+    --hash=sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8 \
+    --hash=sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b \
+    --hash=sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a \
+    --hash=sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e \
+    --hash=sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074 \
+    --hash=sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0 \
+    --hash=sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433 \
+    --hash=sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f \
+    --hash=sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034 \
+    --hash=sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21 \
+    --hash=sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736 \
+    --hash=sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0 \
+    --hash=sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7 \
+    --hash=sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c \
+    --hash=sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4 \
+    --hash=sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c \
+    --hash=sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c \
+    --hash=sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069 \
+    --hash=sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb \
+    --hash=sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061 \
+    --hash=sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29 \
+    --hash=sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3 \
+    --hash=sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa \
+    --hash=sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e \
+    --hash=sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38 \
+    --hash=sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f \
+    --hash=sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b \
+    --hash=sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 \
+    --hash=sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e \
+    --hash=sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c \
+    --hash=sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be \
+    --hash=sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6 \
+    --hash=sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793 \
+    --hash=sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 \
+    --hash=sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b \
+    --hash=sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64 \
+    --hash=sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f \
+    --hash=sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761 \
+    --hash=sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d \
+    --hash=sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168 \
+    --hash=sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869 \
+    --hash=sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111 \
+    --hash=sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5 \
+    --hash=sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b \
+    --hash=sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7 \
+    --hash=sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129 \
+    --hash=sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e \
+    --hash=sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655 \
+    --hash=sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c \
+    --hash=sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec \
+    --hash=sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689 \
+    --hash=sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f \
+    --hash=sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf \
+    --hash=sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea \
+    --hash=sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9 \
+    --hash=sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464 \
+    --hash=sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519 \
+    --hash=sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b \
+    --hash=sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f \
+    --hash=sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee \
+    --hash=sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a \
+    --hash=sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e \
+    --hash=sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6 \
+    --hash=sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2 \
+    --hash=sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f \
+    --hash=sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a \
+    --hash=sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f \
+    --hash=sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a \
+    --hash=sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47 \
+    --hash=sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda \
+    --hash=sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0 \
+    --hash=sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4 \
+    --hash=sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d \
+    --hash=sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3 \
+    --hash=sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2 \
+    --hash=sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355 \
+    --hash=sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461 \
+    --hash=sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed \
+    --hash=sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f \
+    --hash=sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5 \
+    --hash=sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2 \
+    --hash=sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829 \
+    --hash=sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0 \
+    --hash=sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266 \
+    --hash=sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575 \
+    --hash=sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290 \
+    --hash=sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f \
+    --hash=sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62 \
+    --hash=sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f \
+    --hash=sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a \
+    --hash=sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7 \
+    --hash=sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed \
+    --hash=sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f \
+    --hash=sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1 \
+    --hash=sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615 \
+    --hash=sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8 \
+    --hash=sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3 \
+    --hash=sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a \
+    --hash=sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff \
+    --hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \
+    --hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \
+    --hash=sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3 \
+    --hash=sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13 \
+    --hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9
+pygments==2.21.0 \
+    --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \
+    --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c
+pymysql==1.2.3 \
+    --hash=sha256:14f1c68e2ed859243ae5ca41ffbe677027fc46bc136a9f0be8a4e928e5e7415a \
+    --hash=sha256:d5b288529782e536ae171866df3ca9dc4f6cbfb3cc2f18e6f837fbb90dbc262b
+pytest==9.1.1 \
+    --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
+    --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
+pytest-asyncio==1.4.0 \
+    --hash=sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1 \
+    --hash=sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42
+python-dotenv==1.2.3 \
+    --hash=sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9 \
+    --hash=sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35
+python-multipart==0.0.32 \
+    --hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \
+    --hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23
+pyyaml==6.0.3 \
+    --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
+    --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
+    --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
+    --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
+    --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
+    --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
+    --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
+    --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
+    --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
+    --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
+    --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
+    --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
+    --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
+    --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
+    --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
+    --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
+    --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
+    --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
+    --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
+    --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
+    --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
+    --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
+    --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
+    --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
+    --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
+    --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
+    --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
+    --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
+    --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
+    --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
+    --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
+    --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
+    --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
+    --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
+    --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
+    --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
+    --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
+    --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
+    --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
+    --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
+    --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
+    --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
+    --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
+    --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
+    --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
+    --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
+    --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
+    --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
+    --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
+    --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
+    --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
+    --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
+    --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
+    --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
+    --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
+    --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
+    --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
+    --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
+    --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
+    --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
+    --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
+    --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
+    --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
+    --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
+    --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
+    --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
+    --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
+    --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
+    --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
+    --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
+    --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
+    --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
+    --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
+regex==2026.9.29 \
+    --hash=sha256:01000ddf0e3ffef97f2413ceb514f6313040106b6d18a03ee00a4fe35c1eb1db \
+    --hash=sha256:0166844493626c5015c6088ee15c9ca2fd060ca15b7641d1657da6a58432ae33 \
+    --hash=sha256:044265d77d94f5e3cb2fd72c76723807c429cb8c533e9d4672d0334a6f14f588 \
+    --hash=sha256:0476e5bcbe6e1ba3d1c4cc7bbb1c3ba78e3b979b5c8a88d0a6a8cdd4992b8c84 \
+    --hash=sha256:066d0e3dbfdd739bce2bf8c2a41dd16f73e3d8adc2eb06dd803a36a307f56075 \
+    --hash=sha256:0b65c72739f981377c9c22e0c5c3cd7f42da7bd8a3c9209330fac772c7d893ed \
+    --hash=sha256:0c992c19cd45058a4b92f68f139c93db168b48fb1f322c9a7cd620806afb6b51 \
+    --hash=sha256:0cc63b5e47c12a48d90c7e9d7de6a035dd14f62868aaedbb4e0ff8ba2b8bfe7b \
+    --hash=sha256:0dd8af32e9f7b56b7f95cc1fd79b23054c3bdc172392ae560acc24d57b7ffe71 \
+    --hash=sha256:0def9fb6abac55492d6d51cddb7225d07d6f279e774e0adc08569a54a5fc8d46 \
+    --hash=sha256:0fd2c901cc307a745ad4bc87f20060d7a0825a3371d1e93488af22e7a387f78f \
+    --hash=sha256:1043aedf5917caa861bcb25a9c11460049656bdf0017a90a309fa8f255467725 \
+    --hash=sha256:121a76a0985db80ceae9e171c337f8c927868e37d01b54e3ce87bc87f9c6a208 \
+    --hash=sha256:143533cc4b6fbc5b95aca0a5b8d541088d374831593def000ec89322c220221d \
+    --hash=sha256:14e953ff3607c92d7675bf79c4d4509ef6782aa8c08509f179f9b3d6d0679e86 \
+    --hash=sha256:18ae8eed4526e35bdb754d61562b90bf5c00a67fdcf3cc1380dd59597486631b \
+    --hash=sha256:19959129885356df0e97556856f77eb2888380dac18bed075a7c05c5128c618d \
+    --hash=sha256:1ba8c6a416569ce0d37e83e28a254a61dc99a419084dfb6476cea02d997f74fa \
+    --hash=sha256:1c2a0026062abcc321a53db4a185ceba0b59a66b5d37b0808917a88b55a5257f \
+    --hash=sha256:1d9fe8091b2e89d470df68a9331111ed008ae8aae6bf1e8e1fba4086a495c84e \
+    --hash=sha256:2089fe39c406784d90101c726755ffa1497bb74638fd434300d2b88006186de8 \
+    --hash=sha256:23ae6fdad9e63e54038f5ef78aba2933faca61e24d432786589e737bc5522ebb \
+    --hash=sha256:26ec4ccce55aa533fbd603d08911b01101a8fcfec987845ac3ae2c7087b2bde3 \
+    --hash=sha256:2f7f7aa47b229f2b39a2ae2596d2ad5625d77b5eb9856fac2dab3eb506cdd0a0 \
+    --hash=sha256:31b003f9a070335e2a8233ee9b14a3ca8e6d792012ae011f741bf0aaf11744c5 \
+    --hash=sha256:32ab11df9677ca80bcbb5fe4eb1da9109a5019239a054836efc6fa1c64e683cf \
+    --hash=sha256:33026515aebc0e70d1c89978e53e8d695d35d9e472f8d5b34465ba3c74028650 \
+    --hash=sha256:34b6925af9853bf461950e6508910f179fd6e9b1a7ec8548e069606b7e51a26b \
+    --hash=sha256:352cf115a810b357caa35193ab656ecf5ef41056855e82f292c99e8514f8d954 \
+    --hash=sha256:39ab5894d971f9ac68baa6eca5c50387db579cfcacf36ae8df3feceb1815e6d0 \
+    --hash=sha256:3a21a9509d0ee88e7a70e1ad228cd2f0e0fd1e187458db132e8a8d18c97daf9d \
+    --hash=sha256:3c5c2ef13797466aa64170cbb66ad98a32351dd4127694cea7199f80f213750d \
+    --hash=sha256:3e778bfccd63075167709136afbc251c1f683758d5bf49c803c60ac3f894ce6b \
+    --hash=sha256:3f1e6cb402a89457582cd696f982559217d13484a193202c394015297968c86d \
+    --hash=sha256:42e82e578c904445d4c8a35b8f28052cf567593215fa5db06266fbc6f77aaa2e \
+    --hash=sha256:4408b2b27a95ca8cc48b7411945753773353b5c93b307754781086c99d3a576f \
+    --hash=sha256:446654b29bfaa30500d80947eda42cef1449dc8a87f4e3cf061cc8485d3a1f0b \
+    --hash=sha256:45010bcfe66df41522d56c9b6114e87ecc597a08970ff6a2ced24415c141ae5f \
+    --hash=sha256:49ee178ca31c94621294bf9b8b676a92a2e6bba8af0529591753719e57edb621 \
+    --hash=sha256:4d7d93613b01b0199961330e49cfc52d479b3d5776c56c691db31130c0a07d91 \
+    --hash=sha256:4fb41211d2333eb930a51e0546a65999761cf1f572a4da56ef9b8a62966c06f2 \
+    --hash=sha256:4fe97894d1b306c919b4e50def1e6f6c522f4d03a7283811f4d108f1ce5d3ac2 \
+    --hash=sha256:554bffadcbcb6d5f4e5fb10a61cc52084b9a63d1dab5f10bcd2c4343972e8e2c \
+    --hash=sha256:59b49507f47479e299a9e1bc41b5cb83a7afda0540625f1dbae886615978acbf \
+    --hash=sha256:5eeb8edc6110d9194a4d0d54610f64c37a31c605b5dbb7e407fc6ec7fa34a4a1 \
+    --hash=sha256:612b709381c0355b70d89cdb51b7f670591ed5cbbc0e3b5337488019dc667b65 \
+    --hash=sha256:61956f074ecd123f55adca68ee3eab46e6a07ad3f8e64e6db95dfacb444f55c4 \
+    --hash=sha256:6398d5145689503412cc1748895242598d8846b8967b851133b20dc2ed1e21e8 \
+    --hash=sha256:65b408d8fcb273e3499e7ef2ce796810da1becd208c7fb4373692a242d79d461 \
+    --hash=sha256:686ac5350fceae63830bb98805fcb8039325bf4c06d9f6f048ff65229d5bffa5 \
+    --hash=sha256:6a1a824fbed817e0a891103886b68f063b1e83cc51bc97192a90a60195a9291f \
+    --hash=sha256:6abb75ab16bc3281714a5b99548a2225db70dba1f995f6d7f7419b76eb5a8fbe \
+    --hash=sha256:6f7121a8914ed13fcfe2099f895341bfb789f004d4c5a0bdece8fa667da10849 \
+    --hash=sha256:7020ed44df30b3aa492c00ee3b52d0548c1f30c2c6c5bb13ae897680900d3413 \
+    --hash=sha256:720537c7ea6f80dc61913184edb0ce2497a306b39ef19f28505b322553d52bdb \
+    --hash=sha256:724184b4aafed865e4f13ca313fdcb43024300c028ec67319cfa16847d84685e \
+    --hash=sha256:7c03031610e3e6ed1768a2b7a8fc84637c1257b50c5eacaf094c6e17a84fc563 \
+    --hash=sha256:80a5ea3b4fd9d6a5b9a44f7976a9acaaab35aa3c1f6b29e5bd857dfabaded223 \
+    --hash=sha256:80c7cadd3fd2bfde5df8aa0787e315812cad0c313a753095d02f4c2b6c01677b \
+    --hash=sha256:80ea96f5c1a30bf09007d48466521d9c294bebe197c708c3359096e3e3691632 \
+    --hash=sha256:864e9b87ac33c3fb9fb4ad48166d4fdb579c351d5c77deb0d34bccb36a775cd9 \
+    --hash=sha256:87fb80cbe3557e27e7b28b995c2b2eedf689b8886f941ab93e0e288f0976518a \
+    --hash=sha256:8873c4a11c50b9989168881aeb3f08859f469d809941866aa1feefd8be5431f6 \
+    --hash=sha256:888d60953908dcf761aa320c3e390ab8556efbdb551ace63921de90f6ae0848d \
+    --hash=sha256:8b5fcc4771732191b2b7d1dd68d8f0353f47f8d90b6150f6dce58bf1112442cb \
+    --hash=sha256:8f39588af4731c8923c26810eb3b33f76f17633985e40f59c3cd45a33805a895 \
+    --hash=sha256:9173db3be74a35cb6731701094b98120f7ee4876a287882a59cdea1fa7da342f \
+    --hash=sha256:92f05c9c42bde5785dc48770bc2194d9f7442544156f951e19cd31b096cec562 \
+    --hash=sha256:951733b1bbdb71e377cec567b409f1a7881b47cfcad84121aa74cb575fa425ea \
+    --hash=sha256:957bb708e8057ab1649ba566456429d691ec9b90d1c9ad1af1ba7ffbbeaf05f2 \
+    --hash=sha256:9916fda742cd4eede63b286f58c06718324265d727ce0856eb1aac86d0d150d6 \
+    --hash=sha256:9e1d3a4cb7993b708f0ada8d0c84590efd853f169e7147d2202c9da503180242 \
+    --hash=sha256:9e4482589065c8ecd761cff522dcd85f2d39e62f551e37e025d1c7d54772def3 \
+    --hash=sha256:a5300757f8a68f5b6cc33f57338d72a0e3589c5cc9ad5f8504ea06f028be582a \
+    --hash=sha256:a540abfab208e1b7ef2df231c40ef3b6cbb30a0aad6204e9b6a81c10a6794628 \
+    --hash=sha256:a5758353650079898dc1b2b0e95aa51fa23a30d020e06f62c430dd08ee56cdd8 \
+    --hash=sha256:a64b85a4760337cfefdb27d42da6ed8b58e8cde3f2d57b6ef43e76ef6ea9ef47 \
+    --hash=sha256:a655d34b2a6943af32401f3d94f72e9d731f6ad16285815550bf2b4ee69d420a \
+    --hash=sha256:a714befaacbd10092ffe4cea0d3c5f008fb9efe9bc322c715bcdfdee414b9a3d \
+    --hash=sha256:a760da040b47767b4b873adfb7c3b691e9ba2fc60f113f9d0b88f1a62f323e85 \
+    --hash=sha256:addd736a0547d553283adaf4e05d7104e7f2c7b0b092e9b4d28756825f14531f \
+    --hash=sha256:ae4613d7d9dda60fcba95f846cc6f808017f1843f392cf9daad14a6534493d71 \
+    --hash=sha256:b11b589e00095ec69cf79841a76360f9b079e95b0368a25b5ebb951ab0c157ff \
+    --hash=sha256:b3e445b66c80b4eb4234e855ce94d9adc183eedbd632816228d89930b91b2c5b \
+    --hash=sha256:b7b893976e7fe42053da64f2aa27239c24252fd2ec6df471e1be197c0addc3b1 \
+    --hash=sha256:b84f186a7f0536fe4ff9a9fa12d06d007b9b71d4b5352ddcc41f59ad6522a312 \
+    --hash=sha256:b89efc38431793d28b7cd91227e2f952ad7c48df19132b17f43a5fec3c14143b \
+    --hash=sha256:b97a38fb4c732b6832db6bf108963adbcd82ef1268ba2025dce390f45af75efa \
+    --hash=sha256:b9d74e4eee9ddb64c2e92d5d61472c59c21684c059eb7b68767be9628e977859 \
+    --hash=sha256:bb90e7177944b6684738c1fc36aabd2dd00d1de3be7dbe09f91e196f1bc0dc81 \
+    --hash=sha256:bec37990e3d6121f29ecfb594bd8f1bf009e9f7926daba2e50e3b27d3892a783 \
+    --hash=sha256:bf3c49863c23a1ad6da9c30351aed6cff8d5ddbeb63c5c8420ae54e98c7d0138 \
+    --hash=sha256:bf48516e35cf848390ea68850aba53e7c333720d2945b4d2c25b69fc5171723f \
+    --hash=sha256:bfc71e6d970419c1309b3640305298643e2a734cad3f7cfb6d2ddee4175ab53d \
+    --hash=sha256:c0094897d7d01f184b2d7fe8c56c66d64efe01b31f4b7d34205b391387df1111 \
+    --hash=sha256:c03c6eb6ece86dfdcbb34799efaa339b093132e1aceed491ba5e08fe06cdf699 \
+    --hash=sha256:c1a9a6651197fbed6f0212591418b9def774fc3f8324f78d1bf0e6a63e5f8aa1 \
+    --hash=sha256:c3589f40749acce747510bf5d589d54e376cb0930ea58b35effac97e5312b0c1 \
+    --hash=sha256:c4e38dd8f39c43a91d2410ad2b85610701b0979342c3df1d69eaf8e838c757d8 \
+    --hash=sha256:c6c8fabf1dafc1f1ddcbb67896d3f93efb092e8c4b6322d7389b944e76a484e5 \
+    --hash=sha256:c90fcf7804ea0a54b896ce0f2b9565350220b8d4890fd0db461a476a4c687963 \
+    --hash=sha256:c9b602fae1e00b7c035d661ce85575365719192a7b46784bd71cf64c68053aa0 \
+    --hash=sha256:ccb64d887a9db1cd76dbc0f92051a1a478a2a67e7f56c62d915cb881d7734704 \
+    --hash=sha256:d06fcdecc10fc7954d7c8f27a03c96055fe525274dc84a7b0dbdc3d6b9e03dab \
+    --hash=sha256:d0c3082bf79bcd6a614d55916590ad4b8f93200e10b97f463ea5d9d07c9b5f23 \
+    --hash=sha256:d49c18f1ea294cf4adde2e5ac256e98c82ea9d708462ce4bf799dffa7cfe8a2c \
+    --hash=sha256:d60030baaa7bfbb02d650c126cdcddcb6e33dbff14d819434c8fa2fdcaeeeba5 \
+    --hash=sha256:d7cab119d0df0b9413f106b4d7fc34f2872d3574ed3806fb48959c830b1537da \
+    --hash=sha256:d9b77b25b4f395f92de6099ab08e8ae2bc7e51dfe157f22900902243a5cc90c7 \
+    --hash=sha256:dabee8f4935e731fb46b2a3091bdda0d3d94b3bbfb907d2b4f12eefce4009619 \
+    --hash=sha256:db5e82ba15c142425b8406690032df89e39cca4a2e8afbbb9a3d84edc2373ac3 \
+    --hash=sha256:dc79d36d0618752265f0d575915bdc5c5130ecb9c9f6b3bcefeae32e4bdfafcf \
+    --hash=sha256:ddfa987262763c3c22a8367d2a49c244b018a74c3a8e3ab1a864119ad45c5633 \
+    --hash=sha256:e1172147d28d8fbcf8cb8d26c41506169f5ad8fe9ec969cb116835a19d4d8eca \
+    --hash=sha256:e11edba5bc344a32b029a7af9d4b3173982dd79eeafa0b9dbd787364414b0509 \
+    --hash=sha256:e2c89e9b762c57f59d5e99ee8b20202adb892e35f8d3485741340999ca55058e \
+    --hash=sha256:e31f72490b7c12f7790e1e25c3afffd20503ee1bfb43461d7838b871ff244b19 \
+    --hash=sha256:e8c65ef3862a8ad6e86492b6ed9327805dd66904c012bd3649dc67d822ed6c34 \
+    --hash=sha256:ebb8912f565b8cdbbf27debfe00df04202c20e2f651b9e32767930c5eace3621 \
+    --hash=sha256:ed511a0708e2297e1d6431e7fb217e3402791e491e02da800658ace4973df1bb \
+    --hash=sha256:edf06545875f3efa31560d94121e95c7fd70d98b1dfedc0157097d79b13b52ea \
+    --hash=sha256:f0fe9834e5aeccaf19a0d8feb296d66a24be1a7c9922002f842a682cd5abb787 \
+    --hash=sha256:f1a0d5117230dd46b399a30a38afa44f79c99f3168988fdc4f425c3f928b39df \
+    --hash=sha256:f37964e4a5e993d2fd45147741e9dff7f34a2d8c00ab94c4ea0514a4677f959e \
+    --hash=sha256:f57dc6b8fef170f105d2cf5cdce254f47b137d7755086cf7050f47e16582abba \
+    --hash=sha256:f93bc1c3486ef3747e07c9d7c1d0a147b8fbaab975f80e348aed6f71309dfaca \
+    --hash=sha256:fb00027a09a8f9f08028b40dce4c933cf73e4833240ed356583fdc9cfa721566 \
+    --hash=sha256:fb99cc9d45f48895d9d67f6a0b8a57f08d39c174d9f25ad97a313e0470267b1c \
+    --hash=sha256:fdd88ed5e20b1bcdd234421e454962c971aa44b653bdb7f1ea9ef683e90fb649 \
+    --hash=sha256:fe3fa1dd453ed5c7f5ea23a26218329790ed7197a99b90e94330e313959a7f52
+requests==2.34.2 \
+    --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
+    --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
+requests-toolbelt==1.0.0 \
+    --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \
+    --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06
+sniffio==1.3.1 \
+    --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \
+    --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc
+sqlite-vec==0.1.9 \
+    --hash=sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786 \
+    --hash=sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb \
+    --hash=sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c \
+    --hash=sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32 \
+    --hash=sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9
+starlette==1.7.0 \
+    --hash=sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e \
+    --hash=sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d
+tenacity==9.1.4 \
+    --hash=sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 \
+    --hash=sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a
+tiktoken==0.14.0 \
+    --hash=sha256:087538c080e5ff421abd3a0785ed63c5111d06af98e6cd0d374dbe5969147ca3 \
+    --hash=sha256:10f31e63e40313f2e518d87f7086cfa44e45f64cc14d8ae14103b41220c30a14 \
+    --hash=sha256:11d8211b290855d2721334ff17dd9b3a17bfb26872be01f25d73612ef7ece890 \
+    --hash=sha256:144a3fc369f92b7d548995217c5d6e84038d3572157a0f6f34080d65291d0f78 \
+    --hash=sha256:149d97453c4c98c04b081d64a85e635921269b532710d6faf81e9e82b790e7d3 \
+    --hash=sha256:14b47e3674f2624803a8acc8fb367b7e24fc53055f9df3296482fe9a3a34a232 \
+    --hash=sha256:151d37a150c8f3dfc5f4345597b10e101876bd1bd13494e0185af6b508758d2e \
+    --hash=sha256:18a1b651c4b032004bf7b4f1713391a54b2a341a52c6e8a2b59acae9d16e13c7 \
+    --hash=sha256:19d643d701fdaa70e5b9c7f8f96abcaffe77ca5e482a3a1a7dde46feb4284695 \
+    --hash=sha256:1b6e4adcfd285c44502aed51df98aaaca4f0fea028165dbf8a9e857b9f98d8ea \
+    --hash=sha256:1f83081065ee5833d35b49e9180f3d8d15622a603dd1c435da0da6cc12b3662f \
+    --hash=sha256:2157f52e4b4d7ac5ecc7457b3716834706e7ef9a46f5144029bfeb7cf71f4e06 \
+    --hash=sha256:231dec90efcdccf1b565a1416107736f1e09b1a08fe736ef9d6363e626d03874 \
+    --hash=sha256:26cc4b4840fa0e9f4b72ed489883e12f57e00d1021ca794720e3c29a12f0edef \
+    --hash=sha256:26e60f6a956ee171ab728b37b8439905d7ea1db435c30f9822f291e9861c861d \
+    --hash=sha256:2cc19ac87b41c9493c9778ff5847f0c8bbcf5bd0ec6b87ce06c1c802adc8a771 \
+    --hash=sha256:2ea70afba6b9eddbf22c165142e5f0a2ad7aa36a452873c48b57bb2aeb8492ae \
+    --hash=sha256:2ec16eb585332c55d022d86354e209ddf27326b1ea3477585ab248e7776d3b1f \
+    --hash=sha256:2fc834fbe3f6a0736905c36ab709537e6840dbd63b982dc9e0216ae7d305ba1a \
+    --hash=sha256:380873f330b741c4435574f37edb20813d04603ace2d53e0a63560e1fec83010 \
+    --hash=sha256:3b12e54f8bec91433e41aff65d8d1f209a4f678081163747079806e5361f6c91 \
+    --hash=sha256:3c5349c9f916283bba32bec8af69b763e4faa304dc004d0eaaea66a3cf004c1f \
+    --hash=sha256:3de75343041a1c57333b1e707ac8a9769738241d7d6a55d39e12cf84548337c6 \
+    --hash=sha256:3fd7c14b1cb45b486c39fc9b3443bb341f3e2fc7e6f31247f3435a5836651632 \
+    --hash=sha256:447ada49af4898b5e992f0b5799d2f3af385921102c211947ce3fe960dd919da \
+    --hash=sha256:4d8d91d68353bd167fdf26467e5ff9e56aaa5f87d6410c0238608629e4dc0d33 \
+    --hash=sha256:50a7e5646cbac2a8f7c3e8c0934ffda1a4357ee9c44b652434b23c3ed54d0900 \
+    --hash=sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9 \
+    --hash=sha256:60c47ca69ddda0dea8256fffd12e1b86f4b59734a20e4a70c61f63cc5f021df4 \
+    --hash=sha256:6eb94895c45f26bb8f5546e5fd8a069efcf6e3f108ea9d5cbe3bf6f7f3983438 \
+    --hash=sha256:728303a072163130c5b477b1f20d6211895569c1d5302c24ffc93a3009160871 \
+    --hash=sha256:78571efc311c30b73f31eb949a921d6dac39a5d9dc42d1cfa8f8db157b3447b1 \
+    --hash=sha256:7896eea257fe497a2b7134474d909156c6744ce8da35bce88011a960e008aa0d \
+    --hash=sha256:7aab286a020660a039097912a088236b985d18a3090d73f136c4413d29d37ca0 \
+    --hash=sha256:7b7acbb7a4b8383707bce22ad3c162006478c27b56368acd3e1fcb1658a80425 \
+    --hash=sha256:7db45b98e94adf4173a5cd7422b150999a7ee11ff847783a14f6e1b80cc38cb6 \
+    --hash=sha256:86951a971c53979ec857bd8c4a32dc227ab0fd33f6c12a3bd62d3fbf5f0bfcaa \
+    --hash=sha256:86f66c85e796f5d05d5c4a60ec1d40cbfebc47a32464053528c797163fa9ab89 \
+    --hash=sha256:8e947aefe98ef74cce94923f90e48c98fe34eb1ec0a6bfdfadfc5a96359bfc36 \
+    --hash=sha256:90a762670c7f968184723769a06ed51f5cf5ce5dcd1e30164f25c72d85c2d1f1 \
+    --hash=sha256:94f77b60a8ab23580db19ae822744c9716c1720020d2179ca5605112d12326f1 \
+    --hash=sha256:979c1524f753b662b0f3cd261b135afe6659cce33caaa7a5ea00dd1756b3055c \
+    --hash=sha256:a140e83317fef02faeeb78d9a8efac623887f2feaf0055c55dcdb2b17f0226ad \
+    --hash=sha256:aa428a559d5fd02ae619aacaace86c7474a1f2702d2c01fc828908dd60f20f7a \
+    --hash=sha256:b950248272f1b303dc32986396e2dccfa10cf6d1e83ec8f0bba1776660305482 \
+    --hash=sha256:c2edf09b381fafbc014ae8e018ed25087abb9a3dafa8465a0ea63c6558c47a79 \
+    --hash=sha256:c3093001ddce822b4587e6e94bf6de36a5f97b3f31de1c9fc8d4fda144c59ff4 \
+    --hash=sha256:c6cb9896a82b9ee44e15ba0b5c8044072f2e4d48acaa704c8d3feeef5ad9487c \
+    --hash=sha256:c77d4a3e1deb2707819df92046b89aad1ac81d27e07616b797cbff3f62c037da \
+    --hash=sha256:ca4db6ff5c5bf600f9b7761a0070ed44dfe5797a76bd432fb978bc480ef40c58 \
+    --hash=sha256:cbe2cc3bba939bcdaf103e03df9d5039d33887080b315624be28ec69059e5f94 \
+    --hash=sha256:cd8ca1305c1c902fe42c486165f2e4808d9997625c98ffb05b9e0366d99d3948 \
+    --hash=sha256:d0781223705199b289faa59601bb9c2441712d4c600dd13c43d8fd6a33d22cd5 \
+    --hash=sha256:d6cebe67765569df3dafac8474e4eccf5c19d24140492567a5e58a11445732a4 \
+    --hash=sha256:e067f4cbcc5d036e8aff7fe7a6b530a8f4de2e4616ad9005a24a1879e24e6450 \
+    --hash=sha256:e2eca764c53490f8930dbce329e0769f11108d87d908282a80c5c130e26e7037 \
+    --hash=sha256:e3442bbb2f0c588cec876061e37ae67b455b9df9978b003c8fe30e45f2ef5b42 \
+    --hash=sha256:e4ddf863b59347deaa92302dcd90e5eb003cdc9be06ec2b692c38d1bdd9efd49 \
+    --hash=sha256:e9c5fe393aab56469f04e432ff851216d3def3436cf5f07e442a240164bf500f \
+    --hash=sha256:eceeff0c62419bc78d4b6e70a4762a4d25df3ae8f2d5946e3853ce93e7a57098 \
+    --hash=sha256:f2af4a336ea56d6c14f27741a0e1d8294a35dd0b038bcf990d232ebb54eb994b \
+    --hash=sha256:f3d6cf93fbe2e7117eb7bedca684216fbe328a41f0843ce34245451d8eb2df1c \
+    --hash=sha256:f5e7665f6624e052e5e7f6a36919ab69279decdc976d7b16b4fa15e1897d0513 \
+    --hash=sha256:f702e0aeeb6506e57687e881c59e844ebe8f0a6a097ddafe20e3ab25f387be4e
+truststore==0.10.4 ; sys_platform != 'emscripten' \
+    --hash=sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301 \
+    --hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981
+typing-extensions==4.16.0 \
+    --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
+    --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
+typing-inspection==0.4.4 \
+    --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \
+    --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
+tzdata==2026.4 ; sys_platform == 'win32' \
+    --hash=sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81 \
+    --hash=sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79
+tzlocal==5.4.4 \
+    --hash=sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4 \
+    --hash=sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15
+urllib3==2.8.0 \
+    --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \
+    --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63
+uuid-utils==0.17.1 \
+    --hash=sha256:01d9209d6fed20226af0d29b95c5e253a1907b61f1a00153187ac5412f1df9b6 \
+    --hash=sha256:0721f05b4f10cc7d49d91be65524a3bc6e6a5d88be054cdca05dff487a022091 \
+    --hash=sha256:0956a9422e132c8d4a3d808cc3d754fc8e81d34295a3a202b332c9dce064eda9 \
+    --hash=sha256:10c51d54ecdf0617640e505eae6d2e6443d8e414d4f9d6e8d43949a450c56e6b \
+    --hash=sha256:1a9e8c876a5d6247572e7f8eace39b4de9a91cd1b026e24552ec631c08c91394 \
+    --hash=sha256:1c8124c9b91fa8353d79e4e7bd44ed0f2ae683990c01818199668f7579e236ab \
+    --hash=sha256:1cf7a837c3467f69ba3ef32caa43b1c5f5a462b7d960bcc59083459aed2b4202 \
+    --hash=sha256:297c6be22e0dd0f7d372845b171ffee5657581759982413c0a5b01b900c5f592 \
+    --hash=sha256:2ff6a84cf6a0a28e4a75c7b11f0d52464ddce4b7a0bfcf14c8de2e740299903d \
+    --hash=sha256:304497d360e9ca5b019254bb25a2ee886b66de884aa08a949c8812fe5bf5327f \
+    --hash=sha256:33fa18507488c4dbde9a3969b6483183d934dbf7a7d46aba90bd5d664d4c81ea \
+    --hash=sha256:3404d50a60ec74642fd590b9d639d98770022f4b1ff8a4055b3c70742c85f096 \
+    --hash=sha256:3ca89347a01ddac94727578369feacc0969d9fc033b2c17ff7919121ee441e2f \
+    --hash=sha256:3d6ccaebaa3b2ff2e59d11d70c64e97ba572120162a00c25d6f8e5e0c1004b9a \
+    --hash=sha256:3dd5706a9874799013e82ac567425c535a0a4a7779c8551154915a4ba2fcb1c4 \
+    --hash=sha256:4adbea226bef0c619b33fa257113e36269a475b2c2049f55b8b83b4c6d507a9a \
+    --hash=sha256:4cdcb565ac4d8a005833e7c389867977e19670f1bee6ee9f8811872b86225274 \
+    --hash=sha256:4f04ba62482858a975d1d38cbb5ade181faae59e8ddc1499eacc9b6b6def3115 \
+    --hash=sha256:5141fc9cfc98d3802d6f1c2451e6822bf1d474c9beba7cba0a6fee7643dee46f \
+    --hash=sha256:53abc29fcfa4cdbf53988409f297fce76a8de6ad292115f5e48ff5e9e3606d79 \
+    --hash=sha256:548b33dddedefe5cd02e4ac421b7b3b3ad5411bdbd7f5b5f792d9e7ba288d894 \
+    --hash=sha256:55e2cec52e2c78d94277d4c990badd3ce97f5746d05029e63d81fb2433bc9684 \
+    --hash=sha256:5629c94f65249384314831ca0a95ca5f1dce5bcd5ee79600a5cd31d4f7d0f5f6 \
+    --hash=sha256:586a93993769873c389d38bd9a70c51e228e734e8f78742d959610509635b86b \
+    --hash=sha256:5e1b7aec35ee0dbb3875a3f151866f3362ef30ae5e7f95a42adf8779d305bb47 \
+    --hash=sha256:601d03cce6b8ec3734025c7dca9ab276df1ab649ca7b13d76ea03824724f5108 \
+    --hash=sha256:61378863a5e9410fa6e816364442b9d35cccf94fa83e3f67997d0ff57d901d0e \
+    --hash=sha256:617acaeb2586e87c9bd0c2e192e4f1d33caacaeb7f3e0cc75972bc38e703e099 \
+    --hash=sha256:653bf91ec2d1c3b0f10157815ca58b0b572d15ce17ed0823aae08ff0df207fad \
+    --hash=sha256:66ce3e84067261b2721dde6539427f4eb9286289769332ff07c859a661f87694 \
+    --hash=sha256:66f695b0950a630ce5f287d6d17acd1523e0e071863596bed73a7d11b8334a28 \
+    --hash=sha256:691a9c16db041a8d5c55d6414398b0fc97e33aad41ad3aef67a6f3c0661dcde1 \
+    --hash=sha256:6cca251f83d3ec2988fb7355b52c4b4fd2d96159f6f040e68f51af15fab49540 \
+    --hash=sha256:6ed203ff60932fe5b3a38453945bee4aa8dfb387789fd420698f607786ba42b8 \
+    --hash=sha256:71eabda671e055415ecfa8859364438a575eda84e6f43a513436977d9377b532 \
+    --hash=sha256:71f7ec7a1b54c1f84d6f8fd4e1110a66b3d1b936dbb17cda2927442621fc1e3d \
+    --hash=sha256:738b8fc2062c3dc17f1624af4aa8763bec4172cf295b13ffa3dfad3ddbdb4e0e \
+    --hash=sha256:7558c84414785d6ea54a186a3c68267eb8f33e2a0d792698bd9fa50d62150f77 \
+    --hash=sha256:764e4505821c20f1a45a54e9da076e6a97e4e46947159490aea893dc6b8d77be \
+    --hash=sha256:7970905d66e55f9a52d0e694d306501e8a9468aa99da73867cc1f8eba2817261 \
+    --hash=sha256:7a93b046552730b843e9d8784691cf8b0b3d5430dd9d053ade33383ccb2574ff \
+    --hash=sha256:7a9537e7afe2cd8851e636789124bcc26ff1d671906c5e56f6e8f293fa477ec2 \
+    --hash=sha256:7b296b67bf880085daa11ee496d385b941a3bf3e3d5691db7178d21f18f56f9b \
+    --hash=sha256:7e32ff7bd0fe4fdefce1d95f8f673a9b012286a7f40918ed1c08936d565aac4a \
+    --hash=sha256:7f3deaca1ba34f48da053884c1255c360fb123c4a9bc3815c05a7a8093e0bda5 \
+    --hash=sha256:87b3cccf71cbe4c1fe29eb007005cde2f86fda2549a5f412e27ee8579a264d27 \
+    --hash=sha256:87d818e1fffc39476c7934544f54455ea04c6297fcd983598802e81c746338a0 \
+    --hash=sha256:8d31f5725c874a656fa1b7c8feb20b54b01ea70b200a9ff0568672ad3fc80b85 \
+    --hash=sha256:92e8df2bc6a33229c105ea90839d98576fcecc5cc86fdb1dd772ab10367b744b \
+    --hash=sha256:9700430eb701f18bd995787228c2202a15d9db335e8bf9c583df7eca5487d5ce \
+    --hash=sha256:a0a276738fafcfd63e6a0af944ffb8fb86448fe4cedcf574dd7df1ca13259e22 \
+    --hash=sha256:a140db8866a45b1ce40d91164ff48d2722ffc3b2a39bfdb412bd770c7ea88be3 \
+    --hash=sha256:a3cd9443d0a3b6f631e6352cb9d9c0a9b68d808d71250eb44e7b00265ec382f7 \
+    --hash=sha256:a4f6b05598d0d29e8851b7668786b7cf105c98887c7ca36dac94c61321d16cb1 \
+    --hash=sha256:a550b3963960012ff3266bdb3079abf4cc3f6363201e63a649d5d3cd44fe52cd \
+    --hash=sha256:b1fc79cd8a24bc6c553cd6f708f5ca08c4182914bdcc87192e1e468e9858add3 \
+    --hash=sha256:b2735d128a3732e528229fc24295530caa75e79d5ea7fb0f8690ef3114d9b262 \
+    --hash=sha256:b4edcb1c9e52805a7619d68f8f673544a3df09425e2426bcdc30d17a2bb152e0 \
+    --hash=sha256:b6e71cff84184aa6071fdcc45e288e0f45d663a4cf5608fb9091b68c34bfd959 \
+    --hash=sha256:b7db3135ee5c1e9bbfd4d11cb2e3f2b4c26c37cb1f7b7d52407dbe94dddaec7e \
+    --hash=sha256:b94f1185f64d1fd2fa99ffc0b8264ee5980a862010daf1edf430412044f2aa53 \
+    --hash=sha256:bcf40ae13cf31727b84f00b1a505f1d4d10199bfea946c3554ef327702d2acca \
+    --hash=sha256:c3955fc653dc78a93ecbd880bc97a0ef8010a9a748e6307f11ad005d45390ce5 \
+    --hash=sha256:c3aae364b97bed00db4549784ab5ba769a7b88d0ceea87adb833501624fe16ed \
+    --hash=sha256:cc9da3c0d8208b53c28658340827505af437bff7a52a55fdaa262ccd4c5a5d87 \
+    --hash=sha256:cd347022f67f7fbbd6939181ab076cd17a1d856e09a160eb87e245e692cc5742 \
+    --hash=sha256:cd8043ac6d81b3f3f0dff22247866292c819e0d5e54a5a3ad2223f86f88dbd97 \
+    --hash=sha256:cddf08ed611c2ad1c791d4133dba2c63db4d294b2111e1db687537943cac25ae \
+    --hash=sha256:ce2f65e81429fcb145105a10c71bf2c71ac5cc9c8fc6c79ac3c13b9de091b2ef \
+    --hash=sha256:ce2fd8f8bc0026c0fc137cf5cce9de546ff9e0b4008be3eb21b5a06249eafec1 \
+    --hash=sha256:cf419a23bbeafed0fc8efb4ca5b3e0a8ea4ef4866de41c3393f888bfd5f60e15 \
+    --hash=sha256:d030ce5d3cca0f2f55509035bcd33d39494c50dabb9d53dc0419ad212eb0fd7f \
+    --hash=sha256:d0d847fb9b46b5c208f3b8d5ca6082a4ac819ec2ffe36de3548c489e8fa551bd \
+    --hash=sha256:d365e0c916bd9a4b0b7f67f3305c6704c4ff44ff9da836faf455b8b5dce0399f \
+    --hash=sha256:d45f93f362d39f63ba14bbd9e4e1dd89fbed4de9bbef6bb428a379bd86571da2 \
+    --hash=sha256:d8abfd2ed04af7df7b586621b11449a061b4b899f8b073e972b7282c89ae8335 \
+    --hash=sha256:d9aa5fccd372580d455ab769a60326c789c46027d3d58ff2a895dbc492200b0d \
+    --hash=sha256:de996e58b77d3e6eeee1a209ce93f424a5f021aa8b2879df6d35eda601acc831 \
+    --hash=sha256:dfd27f383ecf1559cc55b6ec39c850744abee56c0549b15149e20a8aa6b881f3 \
+    --hash=sha256:e22cf24db33a123c8e47a86fe766c770c83dedc6d1196c1172cbba0a0959cac0 \
+    --hash=sha256:e698d0ffccf167ece87c864667ab69724685af9e171fc51e57038c9711fd4e0a \
+    --hash=sha256:e9f5e23998625f6dc005a3a30238b4006424e366cb2966ec465e0287ae2534f0 \
+    --hash=sha256:e9ff97bf48606e5d817a01fdd4a4a8855b91382e384f524a960149da00adab5a \
+    --hash=sha256:ea207557393ead4084beb08f0e9c0c944ac2050678714b65ba979fae90cf782e \
+    --hash=sha256:ed6821646e37f49683b3e977f856421c08eb9d03418423ac1477e09d2fb5cf62 \
+    --hash=sha256:ee8dbeb24fa796f091658d5692c0e00f0d524db25e1f7a816933e80b6cd3883c \
+    --hash=sha256:eee4a1df744434e10a0d0a679c074e3128b58328b83c99144e363db320e801f4 \
+    --hash=sha256:f0cb9661bc0883e9278bc2436514298ace83121d14296cc672b2c44d37441cd0 \
+    --hash=sha256:f2e91fef913d654f643ba7e4f92347002aff0debeb84cccaf474912756b43a9c \
+    --hash=sha256:f63557d6e9fe10cb25c1d3e73dbf7cd18c1b1620349b6be6b51ea25fcadf3a1e \
+    --hash=sha256:f85fddf2e5c4f7a30ebdc2b26ec585b9d814d807bcf285d248ae451ffee16a03 \
+    --hash=sha256:f974aa1097b0b8245d8f29550eaac3b431c891ba7c76cc4beaa6ec7bf8cd27b6 \
+    --hash=sha256:f9f9f6835ab3163818156022c627eda60c38c874b369642b249b483384021743 \
+    --hash=sha256:fa1a1c9a72ef9757176c069f7bd8014b7f4abf5f9930ec62b883dc864ba28092
+uvicorn==0.54.0 \
+    --hash=sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf \
+    --hash=sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620
+uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' \
+    --hash=sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772 \
+    --hash=sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e \
+    --hash=sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743 \
+    --hash=sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54 \
+    --hash=sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec \
+    --hash=sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659 \
+    --hash=sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8 \
+    --hash=sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad \
+    --hash=sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7 \
+    --hash=sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35 \
+    --hash=sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289 \
+    --hash=sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142 \
+    --hash=sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77 \
+    --hash=sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733 \
+    --hash=sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd \
+    --hash=sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193 \
+    --hash=sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74 \
+    --hash=sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0 \
+    --hash=sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6 \
+    --hash=sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473 \
+    --hash=sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21 \
+    --hash=sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242 \
+    --hash=sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705 \
+    --hash=sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702 \
+    --hash=sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6 \
+    --hash=sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f \
+    --hash=sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e \
+    --hash=sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d \
+    --hash=sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370 \
+    --hash=sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4 \
+    --hash=sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792 \
+    --hash=sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa \
+    --hash=sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079 \
+    --hash=sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2 \
+    --hash=sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86 \
+    --hash=sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6 \
+    --hash=sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4 \
+    --hash=sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3 \
+    --hash=sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21 \
+    --hash=sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c \
+    --hash=sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e \
+    --hash=sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25 \
+    --hash=sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820 \
+    --hash=sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9 \
+    --hash=sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88 \
+    --hash=sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2 \
+    --hash=sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c \
+    --hash=sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c \
+    --hash=sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42
+watchfiles==1.3.0 \
+    --hash=sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441 \
+    --hash=sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95 \
+    --hash=sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4 \
+    --hash=sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61 \
+    --hash=sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977 \
+    --hash=sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509 \
+    --hash=sha256:45726c6b5a8d67c8087eb2933c4398948546d04206ef40f629dc4881d65d23d1 \
+    --hash=sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5 \
+    --hash=sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661 \
+    --hash=sha256:591de392458bca26f6130fea4e667c06b657e873ad334ea06fad5a79f5086cef \
+    --hash=sha256:5ec46a1ff4c1d81405a5fc645abdbff5e4e9388f7c9a6cdb392316a7c29b884f \
+    --hash=sha256:6398d250994baff581147ccba846223076fe6492ca93c0d1b3588fcf5355eb5c \
+    --hash=sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30 \
+    --hash=sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80 \
+    --hash=sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e \
+    --hash=sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207 \
+    --hash=sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0 \
+    --hash=sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef \
+    --hash=sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1 \
+    --hash=sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c \
+    --hash=sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29 \
+    --hash=sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7 \
+    --hash=sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935 \
+    --hash=sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3 \
+    --hash=sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39 \
+    --hash=sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc \
+    --hash=sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26 \
+    --hash=sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a \
+    --hash=sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6 \
+    --hash=sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694 \
+    --hash=sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca \
+    --hash=sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a \
+    --hash=sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3
+wcmatch==11.0.1 \
+    --hash=sha256:1ea2b4fa678b8ca268253798d5963935df39132d47c3e241c0a0732224005e7d \
+    --hash=sha256:fd149ecddb9f0a88ea780017d6dde17c994e494e7f7303d4e3c9d6251f978f4b
+websockets==16.1.1 \
+    --hash=sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175 \
+    --hash=sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a \
+    --hash=sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d \
+    --hash=sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985 \
+    --hash=sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1 \
+    --hash=sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573 \
+    --hash=sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb \
+    --hash=sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9 \
+    --hash=sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87 \
+    --hash=sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22 \
+    --hash=sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328 \
+    --hash=sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747 \
+    --hash=sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab \
+    --hash=sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499 \
+    --hash=sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d \
+    --hash=sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62 \
+    --hash=sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512 \
+    --hash=sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7 \
+    --hash=sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf \
+    --hash=sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa \
+    --hash=sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57 \
+    --hash=sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e \
+    --hash=sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3 \
+    --hash=sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0 \
+    --hash=sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc \
+    --hash=sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43 \
+    --hash=sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe \
+    --hash=sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31 \
+    --hash=sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b \
+    --hash=sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383 \
+    --hash=sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217 \
+    --hash=sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499 \
+    --hash=sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8 \
+    --hash=sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51 \
+    --hash=sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509 \
+    --hash=sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d \
+    --hash=sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428 \
+    --hash=sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead \
+    --hash=sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc \
+    --hash=sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1 \
+    --hash=sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3 \
+    --hash=sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0 \
+    --hash=sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f \
+    --hash=sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5 \
+    --hash=sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731 \
+    --hash=sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be \
+    --hash=sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df \
+    --hash=sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb \
+    --hash=sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293 \
+    --hash=sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e \
+    --hash=sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7 \
+    --hash=sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3 \
+    --hash=sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3 \
+    --hash=sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3 \
+    --hash=sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a \
+    --hash=sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9 \
+    --hash=sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56 \
+    --hash=sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562 \
+    --hash=sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0 \
+    --hash=sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15 \
+    --hash=sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869 \
+    --hash=sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7 \
+    --hash=sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999 \
+    --hash=sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01 \
+    --hash=sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57 \
+    --hash=sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838 \
+    --hash=sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4 \
+    --hash=sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1 \
+    --hash=sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458 \
+    --hash=sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3 \
+    --hash=sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392 \
+    --hash=sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3 \
+    --hash=sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a \
+    --hash=sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9 \
+    --hash=sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785 \
+    --hash=sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648 \
+    --hash=sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49 \
+    --hash=sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1 \
+    --hash=sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7 \
+    --hash=sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d \
+    --hash=sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a \
+    --hash=sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6 \
+    --hash=sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231 \
+    --hash=sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00 \
+    --hash=sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac \
+    --hash=sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea \
+    --hash=sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c \
+    --hash=sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81 \
+    --hash=sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f \
+    --hash=sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751 \
+    --hash=sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68 \
+    --hash=sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2 \
+    --hash=sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c \
+    --hash=sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57 \
+    --hash=sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b \
+    --hash=sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165 \
+    --hash=sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737 \
+    --hash=sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b \
+    --hash=sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854 \
+    --hash=sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87 \
+    --hash=sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2 \
+    --hash=sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847 \
+    --hash=sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d \
+    --hash=sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4 \
+    --hash=sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8 \
+    --hash=sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d \
+    --hash=sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29 \
+    --hash=sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051 \
+    --hash=sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a
+xxhash==4.0.1 \
+    --hash=sha256:0163b5d259de23ae9e07b7eabf435ce4704f6f205589a2b154e6af4be985ce1b \
+    --hash=sha256:03600a8987849b2bef7be795a60a6052b635c63fa98b718b08ca5ee823691cfc \
+    --hash=sha256:04f9a24de11a6647666d5302fd73d6a5224ce50ddc965fb0bb44cee736e6bd7c \
+    --hash=sha256:06713a5aaf1d0905c5579416c020c02e42b3ceb931e86c7d3b7fb85403dee3f3 \
+    --hash=sha256:06d7fbd609503c3be5e65cdb6bb2f040d6a98574404e2e1d5c60815c97fff4aa \
+    --hash=sha256:0718ad66f4ded2411f8e62bdba549ee71e313a2d26ef5060ca3fdbf29897dd3c \
+    --hash=sha256:08ed8da18cd4fd0a6a5d6a444852d8fbd0e565388a74a4937085451b5f1a312a \
+    --hash=sha256:09f9feb118966cc6650e1806205d577eae7ca394aa6acf349a0b62a94bbeb329 \
+    --hash=sha256:0ab851b45c70d4992be7cdeeee16f97a0b677408c758c4b1efb1cfe8030bfd37 \
+    --hash=sha256:0b1082fd0f089ce9098ed77aad8b777b5d156f8ac601c69cab73811822b8ef07 \
+    --hash=sha256:0b20a06454b34f1531fc677c54efe2ecdec691ef9224f7fa919bf2c1363f7ff1 \
+    --hash=sha256:0b42a5a26607e4b2409fea174773a66f2dff9dfdbf2c1a851bb7b804e2c97535 \
+    --hash=sha256:101aa300de6ceef3d9c77569706330d8921fc45dd82bceed2084f1e9f2557a24 \
+    --hash=sha256:1216f7ba5683f17a89eb7dcb4bc50a0b743dfe1902278d7b3d0786f538118433 \
+    --hash=sha256:1642907941ee4b75aacc3db688af52ea02ca2305ab22af7ee686ed726b332684 \
+    --hash=sha256:168dd6b51725a222abc722832e56624d15a63fc2e8249021509c93f1063913f6 \
+    --hash=sha256:1749f0688020209fe0d357ce1e1cd9ec9c6161ed0405ea949d24581c4c43fa91 \
+    --hash=sha256:1b3cccf75eeb5b01639b2feadb042a8e07889293b7ca72fa2985e7dcb64763cf \
+    --hash=sha256:1b50223d92df94d54e1a31469335a2c74b16692e6c1cb726f1e6949514458706 \
+    --hash=sha256:1bc591533fc975614f7e13594daee76af96b8e1fbcf8de76c8773858fa9e7cea \
+    --hash=sha256:1c2200b98a805351cb3142ae4e1fdcc9e91b5e20f5d30d4862b0b96f92558f4e \
+    --hash=sha256:1c7c642a0f79c3e3cf2965475507574d3d1a50ec71060039d60cb87358667cb2 \
+    --hash=sha256:1ee523f51718e41753f04f7102bb4dc55a18d2ea5cbaceef8ec7ca08571bd428 \
+    --hash=sha256:1f3346c5c287ac3c7f38b20380f55e8768230e7252af59fabcf3b87ab21e4256 \
+    --hash=sha256:2194bf96d5f3d4e0cb65deba370ec83dda3edfba42155f9384190ed5e51ea5e2 \
+    --hash=sha256:237b8f63a2a0fcfb1ffc06e21dad23add44e6d354b2b014364a1d41e419a4dee \
+    --hash=sha256:23a4376b4a3183cb50d4d2a3179f887a7773cc695eb2c908e551bec3221b8c60 \
+    --hash=sha256:247ece770647c0aef080561fa996f9774b4dadce2d0c42eeb98229db7dcf820d \
+    --hash=sha256:2696bbac613f6880fed60316c298bf3091d4f8eee3ae2e9466f70bb76204fb0c \
+    --hash=sha256:26fe6238c2d5b11ed5063b9bf4eb290624b004fd074688da6bb079bd564f10d7 \
+    --hash=sha256:2d52dc7c33c1b83082b707f6b7814dc76d2faaa2ea62bd9c5fab4b36f83c087f \
+    --hash=sha256:2df3ca8757dc381e75e90a4d7995a6324f58a923c7145220a7b2c0231f66fddc \
+    --hash=sha256:303121aab4b7f898058582d7962ea79d9e26e2379d7b6d8743f70f2671674481 \
+    --hash=sha256:3088dadbffa33c29e0518578430a7dff2e901a212e487aefa5faaa0dc06dad34 \
+    --hash=sha256:31d86f9e81f3e84e00131ac7c54caf5119ae4ddd82c09c31cff597c813ce1ee2 \
+    --hash=sha256:3358097d333d40657569ec1121e21043dd7d0efa10aead1b50e8b4fa83077d7b \
+    --hash=sha256:33e270d302c95ec426dfa0f5a4e16bff2ab8d7b8a46faa4746affb05e684ac77 \
+    --hash=sha256:33fd538191f47071deef6b1f676535e2aa770f1fd150ae4cc75a34c9e930be3d \
+    --hash=sha256:348c8f288dc961d6bbd1985c8152a3ed7a85c95df00e82320f0c5215d922a399 \
+    --hash=sha256:349775ac30372b344d2338b2a168c0a1312a644194da25b8bec476d55761a128 \
+    --hash=sha256:34ed93e20bfd98d722b902121643791eeb4b1641871e2dc63d0d4c2d93f187df \
+    --hash=sha256:37f667dee0f867c42894b34e2a6fe26bf195c0ea4683d9d2b713db023f242c3a \
+    --hash=sha256:3891efe3d7a531ce6da0a4a50a99dd41c75b8fd4ca19d73c86431b4db5c305f0 \
+    --hash=sha256:38c3d22129a6958846a3098d68bc8e661704461c0be4793ae28836e4690c8478 \
+    --hash=sha256:3c2445edafc300cc40feb6a25a8356a971c30cd0bf47b5349c2ad74c508343b1 \
+    --hash=sha256:3f68fe400ceec235f3e4a4b02a28c2fd2d283584a193223c921dd4c48f1d0754 \
+    --hash=sha256:3fb1d30d4b6d6e2c4a08e5ac6fffdb2b572d2cfcca15a5509cf4e7a1350f955c \
+    --hash=sha256:41e579025a6e13a99e6d71e39c9cfc621a0dcdbbf19106325e145fa858f2d794 \
+    --hash=sha256:421b94f3ba7067958d02e38960d987756347aa150df06df11aa68ae1af78c619 \
+    --hash=sha256:427b62d62d4f967fbb10b82a3813e4875c2a6e7e7634739f17265b650c7f65a6 \
+    --hash=sha256:436e11b4dd966afe5f7f665e4cc4c5485ffe3ceb42f25a22e1701d236abf1853 \
+    --hash=sha256:43bcf2a871f28f16135545415cab3ec43904d4c80425a64598a9e6cebfb2b5ba \
+    --hash=sha256:43e5f9169e73d0f0db33b5f6b8554bcce69ac278c966daf83d5eb4eb2f13829f \
+    --hash=sha256:440c401e146ce64bdb3beb8ff0c84677b6f21307c28a34779071cecee5d4d70c \
+    --hash=sha256:44ab12e8cd17d4f001769f00ad465208b4bcb897ed29e65f058f74466b57a98f \
+    --hash=sha256:4528cf80ebbbf57d40edfb31521ae265daa6dd636d615b1cf0ac86209579e59d \
+    --hash=sha256:45e88111ebe331de478ef8d4293efbe88f3cf8b863386c9a2357136b838e1af0 \
+    --hash=sha256:4741d42d59e4e5fa1a86c17ab9c27dc8ea459c700d91b6742fdb9138d9a516cb \
+    --hash=sha256:4751f1d7eecae6b2d2a773630f1a7248f125c9a92a456694d03c15bceffc9d68 \
+    --hash=sha256:488ca5c5e28ef56ec4bbb12f835b3f1cbecc5f3510062e70117bc6594851932a \
+    --hash=sha256:4972332c079d6aad69c4620a68d015a4ecb33141583f70d642cf9edf6a713763 \
+    --hash=sha256:4a252fb862b0ae2590587e625f47a0e03da05cf0205e8830b67b6596c06038b1 \
+    --hash=sha256:4a76345f5aceb4ec404918edf9c7f2b5507db864dc0d7455982009ac0890b57b \
+    --hash=sha256:4af350bc3f329970c0e3a59af84a8a30998bf8a9167eb50cd48e59baaa1d7bec \
+    --hash=sha256:4bbf3ff651e0f1a19beb5d0f48e0874a9bad2482a588c9d214c96ef1fff1cd9c \
+    --hash=sha256:4e5141543c7f7fe3087500bbb4ac2845cb528a980aa91f8f1e661e2292ff4a5d \
+    --hash=sha256:4f5e5c6df4b703afcbe9352d238a51efd97c3b91fdc3a2052e40fdacb1e7505f \
+    --hash=sha256:515a822c73abbf6a0b7c70976d9662be342835c9d78b8dc7c023411f39c35dbc \
+    --hash=sha256:554f87034635bcec47c5d72447bf3db7e02da1bf493a0ada010db28a76f891c6 \
+    --hash=sha256:567cbc630302a46a8ecfd943b309ccf5372bb3718f1f3762d452df30f033bcf0 \
+    --hash=sha256:57d7fa8f23908d173001c21a9e82bfc6ad997d1b6c270fb121812b7ed158891c \
+    --hash=sha256:5adf927dca8c47fde7e683fe69efdd81bc865c4db1fb6bb00b391e2b6185207b \
+    --hash=sha256:5b7875ac1a2edcb691f27642b8b94b904baa6bcecb7d79c72df2228ba8cb5c51 \
+    --hash=sha256:5b7979f71d06ae45a769de0699900a246d8cb632db1e8bfdc79ec019063a503c \
+    --hash=sha256:5c2d525a3afabcd8e3549d85fc7e111fde6bc302d06a1893fe73adb79823415e \
+    --hash=sha256:5dc434c946012e6d8a72b10f970ea30755b718251dd7591dbfdabafd3bcb21bc \
+    --hash=sha256:5f1ea31d61bcd2cd2f3ec4ca80a64187bbd7948f490b63cf0dcbc6e717b4c1e9 \
+    --hash=sha256:62198213fc3e0c56e567894b318ba45834e007d065f84ba6dc9165d21546fc56 \
+    --hash=sha256:63aa52659bc32bb9bd7cb5caf523b4d14429a477762cfac886132d687c1f80fc \
+    --hash=sha256:649f2682c090cca1ac4037866381f3652eaacbd56e5178030f4ce1325b8f945b \
+    --hash=sha256:67e57b834e07ed973cee7b6da1548ff28a56458d77696fd2a5f397f340694848 \
+    --hash=sha256:684160b3c0a9b62c6f0de90f44e11dc5d8643dcfa18a5856b45fb1c47478bb71 \
+    --hash=sha256:6a8c5ce76b94ba49f3be8a8f2611abc6564210702c72ac9e237ca2bebfd17794 \
+    --hash=sha256:6a9f98af872355e0c02439e48583958eee00e60b928bb20476460d9d40cb7b4e \
+    --hash=sha256:6c45258a37fc22721395c09927cb982d3e7a83607cab15be7e2416501bd3a330 \
+    --hash=sha256:6cbf4e21ef0890804b5bb9ad25c48f9c127758d7f6c66bef374efcacc63c738a \
+    --hash=sha256:6cf633df84d80a1668fcf61e330791dae46825e395549e7d34f376411e75088a \
+    --hash=sha256:6efb8f21cc136c79b3e5bb747c8682d37916fb202cdbbc32182de5c4e47f821f \
+    --hash=sha256:70129ebb8f20e1ac1da58b78ed381624bd689a43a9a7366560bd8fabea145105 \
+    --hash=sha256:704381264b36a18b9c62ecbabe2e71d0fc58c77c129c15355c989b10bf05b6b0 \
+    --hash=sha256:7236be540d6be9ce448d98b940dd26ddf70ca41012e8a14a53fd9354cefe4e8d \
+    --hash=sha256:72f34834518157a75e7090f328ee7a16c70c804cfc7c694fa069cc888e9fc03e \
+    --hash=sha256:74379a577a9f3b6afbdedf1b90e5c7764467051977f18a326d7d607336d743bd \
+    --hash=sha256:74a164e8b63f1e9cf35c9a7809d082b033d1a00e7375d5d814415436e7867e57 \
+    --hash=sha256:760de77279e9cf9c81d012ce0705cba13afccee9b09c480f17d778c8c5cefae8 \
+    --hash=sha256:764b32d52d15b8b95ac8160e540772fa1adeb611fe40bffaeb42e7bf98279e44 \
+    --hash=sha256:79a3203aadf39637869dfea1185227d8452844d78b837e54fb1117b4d34ba5c3 \
+    --hash=sha256:7c343ee174d417a44d0c3355602c0cbbfa52a04d1bbbf1723378c7d2c8f60626 \
+    --hash=sha256:7e27dbed5c4ba033919e4b4ed8dc14e029e91d14a93cd9f920d25277c7df6781 \
+    --hash=sha256:81507a68ba84c55241fb61cce1469f473a5da4205fc8ef6f698e5948eea8dd88 \
+    --hash=sha256:81664268dba92e037b740ecf37fa02f1cab4a391f93f28e35792b3341c60648f \
+    --hash=sha256:839f58c5bd9989875be0fd28446dbf32cace2c2cd8bf2f6762acdc38a95cd1aa \
+    --hash=sha256:83b8c2013edb5dc1f9e7268b6496130705bc48d79c86bb8817b3d210b81a5513 \
+    --hash=sha256:84df5f8da574caadbc0cb1b8866ecc2368cc941f0cd05f677756c802f370dafa \
+    --hash=sha256:8580aab306888224074c7edeec734de0c3c5ccde65b2da4e6c9a5e28f7c0a1bd \
+    --hash=sha256:85bdd40cb505a11e0ca04191711266c5fd696ed786ae83849955e457774edc96 \
+    --hash=sha256:85e402dab0f9acd3604539747c6fcc57dc188a18af6ab07eb8189351cd32466c \
+    --hash=sha256:863f3d3b44110f7243e86cf994aa5c5d88f2348b6e84ab4402fadadfbf9f7da7 \
+    --hash=sha256:86b2b12bec60c678ed8f5cca0258ad93a8928ebddb6ca7732f0875afe1451d1a \
+    --hash=sha256:87aa309a93bd5ec13f14309a305ff4e9bf74c5363fc46c264c0a22edfd5b0670 \
+    --hash=sha256:87cbdec1a7dd930079671a60b249f3ca4e773e6fbd0676e21e36fdc9dd0f3b00 \
+    --hash=sha256:87da13df72c5612771cd905a8b121e0bfea62d7659b1c92198736eb722220e83 \
+    --hash=sha256:88d87719fe6bddf117238b341c5db851f8e96ba68ad9832b450e4a43dc60b37f \
+    --hash=sha256:8b4477edc03091f51f5309406d230851c23cf4822029e3bf40b8df53093fff1c \
+    --hash=sha256:8b99ebaf9e816ac5069423b1367ee7e8078fbcebcf62545506bb0608d2f4f468 \
+    --hash=sha256:8ba782ca3bf1e81492611152b9a0d5264971339e95e34d69de0ac2c926be496d \
+    --hash=sha256:8bcba9456242ebf180a04d9443812fd85ffe6bd12bda464dd116fcece8886ff3 \
+    --hash=sha256:8c9fe122444e129881afd1d4d1c7ac0d3ce2d91b68c2b40173b6025ff1c31f9a \
+    --hash=sha256:8ec4777d92fd61a5c8fdeddab894fd65bea301a8092fb5419ec6472aa4d458d7 \
+    --hash=sha256:90cb2a1c9cc503a054a19612b48ff6e8e47805f618bdb3224a07568aad03a37e \
+    --hash=sha256:9283d9dd6b44acad35118e2976fc763a065509e4118debdb61916ec322ed17b9 \
+    --hash=sha256:94ac8a6b8c47951173f0b67bf862bcb971bf24e493b9fbbdb0e010cbbc7d9f54 \
+    --hash=sha256:96d8de55029d42251945531f6aa7590c32b48163c66a43bf29d8657d7446a377 \
+    --hash=sha256:96dedccfb09a73a25751053a183159b88f4ee75f388df8166040c152ac0531c6 \
+    --hash=sha256:9761ff4a0ffa583fe850731ad24fe82c88cccb7a2294727db0955f3279a4cb3f \
+    --hash=sha256:97b455de3e8b1b0b1e4594cb61a468992563f03ca264062fbb0a66b393c01d90 \
+    --hash=sha256:97b94fb29abf21f5f0bde15f7dbdd3a4aa2dc59f37026adc7b4bee8563b84375 \
+    --hash=sha256:99054b838b74d8d3995ea0d410976ae967c46207ae22d6ddfc535e809197dab9 \
+    --hash=sha256:99166cc98637e8bf550cda2aab07f4f1d5f899c45fbd721801aeabcc9d404824 \
+    --hash=sha256:9a51b061d54cda8b83e62c44458bfbf0dabbef9b975dd9649952ba5076b9f349 \
+    --hash=sha256:9b1dddc257279417d93c9e59420d49ef90aece90d7a01996db3aade74b0281b1 \
+    --hash=sha256:9c3c4b9aa9a27196b921197f7daf9e6c1412739df06a99cfa6e923879362eff6 \
+    --hash=sha256:a14578102a6081465aec9cf73c76c3cd3f79f0709bdb3b8ae7ab0b54c9d8b089 \
+    --hash=sha256:a16a3fa6936e36bb1414d16a6bd012c9033e5161b68b426805b61d895392437d \
+    --hash=sha256:a33de7633c948ab2dc144af370a66e7e7af29b425dcd0f7e4f59689fb9391b53 \
+    --hash=sha256:a43418e1a90b4809a9caf64aeb8b0696e3e1f300a323acc1e6ee2f93ae319fcf \
+    --hash=sha256:a4553d36cc0b7fce1f35ba8a94dfd775aa3ed12f5eab2dc3b46ac75a0706b0bb \
+    --hash=sha256:a5b21b42a01a343096a1c018d35e9b7aec9c7065dda53ae8da071e37478b2cea \
+    --hash=sha256:a65785e653573fcd1e33062760ab4c3c3440e8e910765018e4b6ed4ad07b54a0 \
+    --hash=sha256:a6671a8f6ea4f2101ce11fab5023a2e59391cff249fc3928cecb69d971525fd5 \
+    --hash=sha256:a69e8946e4902ea11fc1c557740cdbfe7d75c78fcc5e4324ff89a696a634357d \
+    --hash=sha256:a6e3653df1a70b8ac4191216324242e4be2bca18c9a7c10934e1bd56dc7ca15e \
+    --hash=sha256:a865d2d470220e659220fdb59d5b6c4422802d8d6098e1324bc4d12444798914 \
+    --hash=sha256:a949b072ea59c6eca0811ccd9e95133cc50d2afda8d464b5b077c78f78efa269 \
+    --hash=sha256:aa6ccc7f31018484d652cf52db020003433f3c9fa83189c028bd807d2adde503 \
+    --hash=sha256:ac0f291ab6485bd71f33941f9b92771318332a05d505460b41e893a549caadc0 \
+    --hash=sha256:acb31ecdd1a97fab5cd39a84ee9f515e727d319f796fec48703b8339b9998360 \
+    --hash=sha256:acf52474b2494ef66dc7e0fb6d5e2b50c18313039ad4d275fbf9f9907c804bc5 \
+    --hash=sha256:ad889d58361a26ba75f5d6a1a0da08ed4950ec4ac8a6da86e1c5ce1b95ccb43f \
+    --hash=sha256:adbd48b30e3f82c89fb2b3e6a87cdd28d113b190a5ed0ee2dee286323ee9a621 \
+    --hash=sha256:af05a3f650220a6c59fa0ad2410249f2d2470a05225807c378fb67458693f8df \
+    --hash=sha256:b3662719007e059abde7eddacf8517142ba076ddc7b30c807260e57d28c3c191 \
+    --hash=sha256:b3bece52127ac20044311ee73567f9f0893b5de64f9028aecc90cc740cfd525a \
+    --hash=sha256:b4c8842fb19d78b5e8c2a52baf4c8357658cc56c62bc822b86ce0f942f28e286 \
+    --hash=sha256:b659fad79c99b0238c7ad7e9d7dbf4eebfea9097c2dba65fa0a4d18a25b29a2f \
+    --hash=sha256:b6c1f9c59bbe593f88a0aad30be4150f15bd57bd64efb95feeabcb8e563f1ecd \
+    --hash=sha256:bdd16718b63aa3ebd68aabb79021a40e47c81374852d41a306b9453141bbcbee \
+    --hash=sha256:bf430c587f447a554c53768ad76b9846fe7c5632180ef6f69c4fce8b0552fbd0 \
+    --hash=sha256:bfed61996d618eb90d6eaae0178002e3466a28b06bfc557a7a3a7266378d8c5a \
+    --hash=sha256:c09ada495567c9c9a8156c5ebcfb93be7fece0755062d738c972dcbecd0d84b5 \
+    --hash=sha256:c0e6ccc2b19ec8a726b2e26062ac71ea63e15500d6bf85910e42481844fdffc1 \
+    --hash=sha256:c101180495cb4ba3617b279a944345c53a5e73b0c150053d1fa8d8af32de9579 \
+    --hash=sha256:c10b9206753b64aa791b35b201485477525b26fdec5bf86e8364c388a03e2592 \
+    --hash=sha256:c3074db513c81f764053e3da079312ecf85a50d8350c71f4cc0105d9662a9e6c \
+    --hash=sha256:c30dd1af66a820820398b26e0d74e7a9aa43cae705924f23ed828cd8e5c26c3d \
+    --hash=sha256:c57963970d359a72262f7fe6be88f945e2334d4bc41462b7f08c37b0abf35ca6 \
+    --hash=sha256:c6301d92545c591ad31c3e050aa40a5f8a4c16413f1f9e6f9322c6f0f9d2b736 \
+    --hash=sha256:c6370189e8e66b7e608f533b939a9de092ddca6cce084ca0d3d414d2ed5b5d59 \
+    --hash=sha256:c6fc415b5568bd9accc7187f1729a99707330c0a67a8b9f93c1149ed573ed75d \
+    --hash=sha256:c7484fea54964edd417cc3a104d5180562514aa7c4e2a2bc26d776ef0c4cb4a1 \
+    --hash=sha256:cba763d84b06bda2c38d5185dee76f1b9dfdc0789e96e476d9e10005526d0788 \
+    --hash=sha256:cd878d32f5c6cbce9783f8d6897561fb772211edba9dde49d85672b88ed45276 \
+    --hash=sha256:ce6d5cc94a50291d080259a126cbf1e9ba4ac861e6429d2f3cdbb1474f51945d \
+    --hash=sha256:d0d24a4f3fb63852cd09af46ae4b7a4d00cc8b8615a046dca543786e728d1056 \
+    --hash=sha256:d1e0d1ea6e44f51808a9e8469c8afdebcdf6fa23d1ea524a0303d57d23919712 \
+    --hash=sha256:d54b8ae068af532c8cdf56abb9e09a60fbe7b10792444c9c27987bb6d3b450fa \
+    --hash=sha256:d55bf4ef10eb09b8b6866790e083d26d087d84caa3cc0946ba87c3ca7ecaf7b7 \
+    --hash=sha256:d9f3848ffaf010bdbabdbf4c25641fa258b6227ff27bc74a4d06edef521a4873 \
+    --hash=sha256:da0264844a09b538c894e5eff25313d941deb4dedec2131b98418a71a3c9944e \
+    --hash=sha256:da544672efd9ad76077928a3e6c5d894e52ce82d3bf14002db4a1bf17d1a36a2 \
+    --hash=sha256:daade8936c4deaaf7b01561324ce438ba4f885d717e9adc62b4d67212ad7d7bd \
+    --hash=sha256:dd649663ddeafbfd4734eb8abae921dd5baa1242f20bda54e8bc927369ccded4 \
+    --hash=sha256:deca2a30d983d240b8375ec2ee0a4288e72042827fc61df2f7671f8467e4cb2f \
+    --hash=sha256:e259bb7e1e2d8de6b35f430f5c7220b1c0ebf3962d1ba7ec7545980d5931edb8 \
+    --hash=sha256:e3996ff9b6f99180357024336bf5749a8ad6476a9a2523e535c5212b995b12a2 \
+    --hash=sha256:e3eba72f9bb84fe696516f4cbca68d3d74a376157e68bacddbb7f2516af61523 \
+    --hash=sha256:e4296fcc790876a8b0f297edc83d3b088457b774d8f67b4636807f8a2ec69a79 \
+    --hash=sha256:e53926e76131a74e79cc0b39fa712c227875f180afc68646bd1e1d8a17e60313 \
+    --hash=sha256:e681a6fc7e4f715252b9b5acfb30536ec7dd1f75033a32dc617e6fa95af1a3fd \
+    --hash=sha256:e71b34978e77868cbf2d18c5206a4603f9c644dd7181bec5643bd40141d3b8c5 \
+    --hash=sha256:e8cda075b10bb3917b002c74a04f9e02b7d13b5bf732571404d51c52b11c7329 \
+    --hash=sha256:e90b4bcf1d9eb1010fdaee7c9209fb667e74c0684f3ba17f9032bd7319da90c9 \
+    --hash=sha256:e961093277ff9d42addb9dad5614dfb7800ccba07c245c39c8e9b4daa35d160c \
+    --hash=sha256:e9701c073bd062fb6bf6be51b47186ad15f1e87feedf4ea07198e0333ec068dc \
+    --hash=sha256:e998cb3685b92101ec5de0fb4d9485cf01e50bc418211955c55d98064664cf4c \
+    --hash=sha256:ea5ecf800b45bdb34afe05a1d0dae1f8ea02a290e50636dccd399063f6b180f8 \
+    --hash=sha256:ec1a470c6db94ac4589c203921e89ac1bc13e796a8b1784d8135e1893559cd3b \
+    --hash=sha256:edccc2ec58435a580f96a48a3ccae8cd0a480824119165dd90108718ad81ae6e \
+    --hash=sha256:f00330ac7e24769e2032203f2b01794d670916b0c1799fd261340f1af9499875 \
+    --hash=sha256:f09ee747e2a5f876cc5ad56947734811828335e13b403dd8ea1e06d77a9dd48d \
+    --hash=sha256:f18732adcc271741bd651c3e56fa519d8a237d2cccda01fe3afb226bf87f783b \
+    --hash=sha256:f1b603d0686c99fa0879f104a74e7db58367634c6e50ba827bee9aa095e23205 \
+    --hash=sha256:f33cf0baa91eccd2cb7b62bf00f10c2264ef578b71dd33a12962e71a36eb4d32 \
+    --hash=sha256:f3e1a44af01b6692de0ec6caba5f0bf93ceb36896e02b7fc00952c6ea7ef39e1 \
+    --hash=sha256:f484ed57bb3e4142f9d6439568658c38be5f94b702ba00a1ff32c69783b6c66d \
+    --hash=sha256:f5d031f35962e5483a613214e61f09fe24ab523062c3646d592dc16c4a217451 \
+    --hash=sha256:f6247f5e23ee94f2557ac9dab738a336f607c6ff476fcf66ca70c3aef5eee15a \
+    --hash=sha256:f7db035447a0ac8959aa230c5d36545ecf9f547413eb1711c0ca6f0ba1418925 \
+    --hash=sha256:f83295394d34e1287e5b30fcc496c13b92cf886a131f3dae5444e38da8757efb \
+    --hash=sha256:fac4832b638000106207bc44e44b9616a6a416aaee56c62b01d61f3705e49f58 \
+    --hash=sha256:fb59a0dd61fb2ad481c03fda399d78ce57dab6bb62c2c8fdb446a7ba4754b89a \
+    --hash=sha256:fc737c05ca2d48e5dcdbbb249314df3fc6c2a0be6da8b0aa28e13d72afaad7cd \
+    --hash=sha256:ff48915bf1871a1f19f74c11834c6329443d306cedc0c05fe7fe617810422a80 \
+    --hash=sha256:ffa44b4c7c5d0ffa31356b4428659516c0e47647825c74079a296b3857b6d99d
+zstandard==0.25.0 \
+    --hash=sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64 \
+    --hash=sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a \
+    --hash=sha256:05353cef599a7b0b98baca9b068dd36810c3ef0f42bf282583f438caf6ddcee3 \
+    --hash=sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f \
+    --hash=sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6 \
+    --hash=sha256:07b527a69c1e1c8b5ab1ab14e2afe0675614a09182213f21a0717b62027b5936 \
+    --hash=sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431 \
+    --hash=sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250 \
+    --hash=sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa \
+    --hash=sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f \
+    --hash=sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851 \
+    --hash=sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3 \
+    --hash=sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9 \
+    --hash=sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6 \
+    --hash=sha256:19796b39075201d51d5f5f790bf849221e58b48a39a5fc74837675d8bafc7362 \
+    --hash=sha256:1cd5da4d8e8ee0e88be976c294db744773459d51bb32f707a0f166e5ad5c8649 \
+    --hash=sha256:1f3689581a72eaba9131b1d9bdbfe520ccd169999219b41000ede2fca5c1bfdb \
+    --hash=sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5 \
+    --hash=sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439 \
+    --hash=sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137 \
+    --hash=sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa \
+    --hash=sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd \
+    --hash=sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701 \
+    --hash=sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0 \
+    --hash=sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043 \
+    --hash=sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1 \
+    --hash=sha256:37daddd452c0ffb65da00620afb8e17abd4adaae6ce6310702841760c2c26860 \
+    --hash=sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611 \
+    --hash=sha256:3b870ce5a02d4b22286cf4944c628e0f0881b11b3f14667c1d62185a99e04f53 \
+    --hash=sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b \
+    --hash=sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088 \
+    --hash=sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e \
+    --hash=sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa \
+    --hash=sha256:4b14abacf83dfb5c25eb4e4a79520de9e7e205f72c9ee7702f91233ae57d33a2 \
+    --hash=sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0 \
+    --hash=sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7 \
+    --hash=sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf \
+    --hash=sha256:51526324f1b23229001eb3735bc8c94f9c578b1bd9e867a0a646a3b17109f388 \
+    --hash=sha256:53e08b2445a6bc241261fea89d065536f00a581f02535f8122eba42db9375530 \
+    --hash=sha256:53f94448fe5b10ee75d246497168e5825135d54325458c4bfffbaafabcc0a577 \
+    --hash=sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902 \
+    --hash=sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc \
+    --hash=sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98 \
+    --hash=sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a \
+    --hash=sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097 \
+    --hash=sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea \
+    --hash=sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09 \
+    --hash=sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb \
+    --hash=sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7 \
+    --hash=sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74 \
+    --hash=sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b \
+    --hash=sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b \
+    --hash=sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b \
+    --hash=sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91 \
+    --hash=sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150 \
+    --hash=sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049 \
+    --hash=sha256:89c4b48479a43f820b749df49cd7ba2dbc2b1b78560ecb5ab52985574fd40b27 \
+    --hash=sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a \
+    --hash=sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00 \
+    --hash=sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd \
+    --hash=sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072 \
+    --hash=sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c \
+    --hash=sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c \
+    --hash=sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065 \
+    --hash=sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512 \
+    --hash=sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1 \
+    --hash=sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f \
+    --hash=sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2 \
+    --hash=sha256:a51ff14f8017338e2f2e5dab738ce1ec3b5a851f23b18c1ae1359b1eecbee6df \
+    --hash=sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab \
+    --hash=sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7 \
+    --hash=sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b \
+    --hash=sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550 \
+    --hash=sha256:b9af1fe743828123e12b41dd8091eca1074d0c1569cc42e6e1eee98027f2bbd0 \
+    --hash=sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea \
+    --hash=sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277 \
+    --hash=sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2 \
+    --hash=sha256:c2ba942c94e0691467ab901fc51b6f2085ff48f2eea77b1a48240f011e8247c7 \
+    --hash=sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778 \
+    --hash=sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859 \
+    --hash=sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d \
+    --hash=sha256:d8c56bb4e6c795fc77d74d8e8b80846e1fb8292fc0b5060cd8131d522974b751 \
+    --hash=sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12 \
+    --hash=sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2 \
+    --hash=sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d \
+    --hash=sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0 \
+    --hash=sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3 \
+    --hash=sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd \
+    --hash=sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e \
+    --hash=sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f \
+    --hash=sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e \
+    --hash=sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94 \
+    --hash=sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708 \
+    --hash=sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313 \
+    --hash=sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4 \
+    --hash=sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c \
+    --hash=sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344 \
+    --hash=sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551 \
+    --hash=sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01

+ 4 - 0
release/service.sh

@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+exec "${DEPLOY_PYTHON:-python3.12}" -B "$script_dir/deploy.py" "$@"

+ 22 - 0
release/vent-agent.service.example

@@ -0,0 +1,22 @@
+[Unit]
+Description=Vent Agent
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=ventagent
+Group=ventagent
+WorkingDirectory=/opt/vent-agent/current
+ExecStart=/opt/vent-agent/current/.venv/bin/python -B /opt/vent-agent/current/release/deploy.py run --prefix /opt/vent-agent
+Restart=on-failure
+RestartSec=5
+TimeoutStopSec=90
+KillSignal=SIGTERM
+UMask=0077
+Environment=PYTHONUNBUFFERED=1
+StandardOutput=journal
+StandardError=journal
+
+[Install]
+WantedBy=multi-user.target

+ 19 - 0
release/verify.py

@@ -0,0 +1,19 @@
+#!/usr/bin/env python3
+"""验证解压后的发布包。SHA256SUMS 用于验证压缩包本身。"""
+import argparse
+import sys
+from pathlib import Path
+sys.dont_write_bytecode = True
+from common import validate_package
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("directory", nargs="?", type=Path, default=Path(__file__).resolve().parents[1])
+    args = parser.parse_args()
+    manifest = validate_package(args.directory)
+    print(f"校验通过:{manifest['version']},{len(manifest['files'])} 个文件")
+
+
+if __name__ == "__main__":
+    main()

+ 43 - 106
scheduler/runner.py

@@ -14,12 +14,8 @@ from __future__ import annotations
 import asyncio
 from typing import Any
 
-from langchain_core.messages import AIMessage, HumanMessage
-
 import config
-from api.context_meter import extract_usage
 from billing import pricing
-from billing.meter import UsageMeter
 from db import chat_store, db_conn, schedule_store
 from obs import get_logger
 
@@ -30,115 +26,56 @@ logger = get_logger(__name__)
 SCHEDULED_RECURSION_LIMIT = 30
 
 
-def _extract_final_text(result: dict[str, Any]) -> str:
-    """从图输出里取最后一条 AI 回复的正文。"""
-    for message in reversed(result.get("messages") or []):
-        if isinstance(message, AIMessage):
-            content = message.content
-            if isinstance(content, str) and content.strip():
-                return content
-            if isinstance(content, list):
-                text = "".join(
-                    block.get("text", "")
-                    for block in content
-                    if isinstance(block, dict)
-                )
-                if text.strip():
-                    return text
-    return ""
-
-
-def _sum_usage(messages: list[Any]) -> tuple[int, int, str]:
-    """汇总本次执行的真实 token 用量。"""
-    input_tokens = output_tokens = 0
-    source = "provider"
-    saw_provider = False
-    for message in messages or []:
-        usage = extract_usage(message)
-        if usage is None:
-            continue
-        saw_provider = True
-        input_tokens += usage["input_tokens"]
-        output_tokens += usage["output_tokens"]
-    if not saw_provider and messages:
-        # provider 没给用量:标 estimate 而不是静默按 0 记账
-        source = "estimate"
-    return input_tokens, output_tokens, source
-
-
 async def run_schedule(schedule_id: str, trigger: str = "cron") -> dict[str, Any]:
-    """执行一次定时任务。异常不外抛——调度器的回调抛异常会被 APScheduler
-    吞掉并只留一行日志,不如自己接住并写进 schedule_runs 让用户看得见。"""
+    """执行并持久化状态;准入失败由 HTTP 或调度回调处理。"""
     schedule = await asyncio.to_thread(schedule_store.get_schedule, schedule_id)
     if schedule is None:
         logger.warning("定时任务不存在 id=%s", schedule_id)
         return {"ok": False, "error": "schedule_not_found"}
 
-    user_name = schedule.get("user_name") or "default"
-    title = f"[定时] {schedule.get('title') or schedule_id[:8]}"
-
-    session = await asyncio.to_thread(
-        chat_store.create_session, user_name, schedule.get("mode") or "full", title
-    )
-    thread_id = session["id"]
-    run_id = await asyncio.to_thread(schedule_store.start_run, schedule_id, thread_id)
-
-    logger.info(
-        "定时任务开始 id=%s run=%s trigger=%s thread=%s",
-        schedule_id, run_id[:8], trigger, thread_id,
-    )
+    from agents import execution, prompts
+    from api import concurrency
+    from contextlib import aclosing
+    import anyio
 
-    prompt = schedule.get("prompt") or ""
-    await asyncio.to_thread(chat_store.save_message, thread_id, "user", prompt)
-
-    try:
-        from agents import agent_registry
-
-        agent = agent_registry.get_agent()
-        result = await agent.ainvoke(
-            {"messages": [HumanMessage(content=prompt)]},
-            config={
-                "configurable": {"thread_id": thread_id},
-                "recursion_limit": SCHEDULED_RECURSION_LIMIT,
-            },
-        )
-    except Exception as exc:
-        detail = f"{type(exc).__name__}: {exc}"
-        logger.exception("定时任务失败 id=%s run=%s", schedule_id, run_id[:8])
-        await asyncio.to_thread(
-            chat_store.save_message, thread_id, "assistant", f"(任务执行失败){detail}"
-        )
-        await asyncio.to_thread(schedule_store.finish_run, run_id, "failed", detail, None)
-        return {"ok": False, "run_id": run_id, "session_id": thread_id, "error": detail}
-
-    output = _extract_final_text(result)
-    await asyncio.to_thread(chat_store.save_message, thread_id, "assistant", output)
-    await asyncio.to_thread(schedule_store.finish_run, run_id, "success", None, output[:2000])
-
-    # 计费:定时任务同样消耗额度,不记就是白跑
-    input_tokens, output_tokens, source = _sum_usage(result.get("messages") or [])
-    if input_tokens or output_tokens:
-        meter = UsageMeter(
-            user_name=user_name,
-            session_id=thread_id,
-            kind="scheduled",
-            model=config.MODEL,
-        )
-        meter.add(input_tokens, output_tokens, source)
-        meter.settle()
-
-    logger.info(
-        "定时任务完成 id=%s run=%s 输出长度=%d tokens=%d/%d",
-        schedule_id, run_id[:8], len(output), input_tokens, output_tokens,
-    )
-    return {
-        "ok": True,
-        "run_id": run_id,
-        "session_id": thread_id,
-        "output": output,
-        "input_tokens": input_tokens,
-        "output_tokens": output_tokens,
-    }
+    user_name = schedule.get("user_name") or "default"
+    # 同一个计划的 cron 和手动执行共用互斥,避免重复触发。
+    with concurrency.session_guard(f"schedule:{schedule_id}"):
+        from billing import guard
+        await asyncio.to_thread(guard.precheck, user_name)
+        session = await asyncio.to_thread(chat_store.create_session, user_name,
+            schedule.get("mode") or "full", f"[定时] {schedule.get('title') or schedule_id[:8]}")
+        thread_id = session["id"]
+        admission = await execution.admit(user_name, thread_id)
+        run_id = None
+        stream = None
+        try:
+            run_id = await asyncio.to_thread(schedule_store.start_run, schedule_id, thread_id)
+            prompt = schedule.get("prompt") or ""
+            preferences = await asyncio.to_thread(chat_store.preferences_as_text, user_name)
+            message = prompt + prompts.build_preference_instruction(preferences) + prompts.build_mode_instruction(schedule.get("mode") or "full")
+            stream = await execution.build_stream(admission,
+                session_id=thread_id, thread_id=thread_id, user_name=user_name,
+                kind="scheduled", model=config.MODEL, original_user_message=prompt, message=message,
+                config={"configurable": {"thread_id": thread_id}, "recursion_limit": SCHEDULED_RECURSION_LIMIT})
+            async with aclosing(stream.generate()) as events:
+                async for _ in events:
+                    pass
+            output = "".join(stream.answer_parts)
+            await asyncio.to_thread(schedule_store.finish_run, run_id, stream.status,
+                                    stream.error or None, output[:2000])
+            return {"ok": stream.status in {"success", "waiting_input"}, "status": stream.status,
+                    "run_id": run_id, "session_id": thread_id, "output": output,
+                    "error": stream.error, "input_tokens": stream.meter.input_tokens,
+                    "output_tokens": stream.meter.output_tokens}
+        except BaseException as exc:
+            if run_id:
+                with anyio.CancelScope(shield=True):
+                    await asyncio.to_thread(schedule_store.finish_run, run_id,
+                        "cancelled" if isinstance(exc, asyncio.CancelledError) else "failed", type(exc).__name__, None)
+            raise
+        finally:
+            admission.release()
 
 
 def ensure_pricing_seeded() -> None:

+ 12 - 1
scheduler/scheduler.py

@@ -24,6 +24,7 @@ from scheduler.runner import run_schedule
 logger = get_logger(__name__)
 
 _scheduler: AsyncIOScheduler | None = None
+_active: set[asyncio.Task] = set()
 
 
 def _job_id(schedule_id: str) -> str:
@@ -32,15 +33,19 @@ def _job_id(schedule_id: str) -> str:
 
 def _parse_cron(expr: str) -> CronTrigger:
     """解析 5 段 cron。非法表达式抛 ValueError,由调用方转成 400。"""
-    return CronTrigger.from_crontab((expr or "").strip())
+    return CronTrigger.from_crontab((expr or "").strip(), timezone="Asia/Shanghai")
 
 
 async def _invoke(schedule_id: str) -> None:
     """job 回调。吞掉异常——见 scheduler/runner.run_schedule 的说明。"""
+    task = asyncio.current_task()
+    _active.add(task)
     try:
         await run_schedule(schedule_id, trigger="cron")
     except Exception:  # pragma: no cover - run_schedule 内部已兜底
         logger.exception("定时任务回调异常 id=%s", schedule_id)
+    finally:
+        _active.discard(task)
 
 
 def get_scheduler() -> AsyncIOScheduler | None:
@@ -83,6 +88,12 @@ async def shutdown() -> None:
     if _scheduler is None:
         return
     try:
+        _scheduler.pause()
+        active = list(_active)
+        for task in active:
+            task.cancel()
+        if active:
+            await asyncio.gather(*active, return_exceptions=True)
         _scheduler.shutdown(wait=False)
     except Exception as exc:  # pragma: no cover
         logger.warning("关闭调度器异常:%s: %s", type(exc).__name__, exc)

+ 5 - 4
scripts/http_e2e.py

@@ -171,6 +171,7 @@ def test_interrupt_loop() -> None:
         r = client.post(f"{BASE}/api/chat/resume", json={
             "session_id": sid, "thread_id": sid, "user_name": USER,
             "approved": True, "status": "answered",
+            "interrupt_token": intr["tool_approval"]["interrupt_token"],
         })
         check("resume 200", r.status_code == 200, str(r.status_code))
         if r.status_code == 200:
@@ -205,7 +206,7 @@ def test_scheduler() -> None:
             return
         sid_task = r.json()["id"]
 
-        r = client.post(f"{BASE}/api/schedules/{sid_task}/run")
+        r = client.post(f"{BASE}/api/schedules/{sid_task}/run", params={"user_name": USER})
         check("手动触发 200", r.status_code == 200, r.text[:250])
         if r.status_code == 200:
             body = r.json()
@@ -213,7 +214,7 @@ def test_scheduler() -> None:
             check("有独立会话", bool(body.get("session_id")))
             check("记录了 token", int(body.get("input_tokens") or 0) > 0, str(body.get("input_tokens")))
 
-        r = client.get(f"{BASE}/api/schedules/{sid_task}/runs")
+        r = client.get(f"{BASE}/api/schedules/{sid_task}/runs", params={"user_name": USER})
         check("执行记录 200", r.status_code == 200)
         runs = r.json().get("items", [])
         check("有成功记录", any(x["status"] == "success" for x in runs), str([x["status"] for x in runs]))
@@ -223,9 +224,9 @@ def test_scheduler() -> None:
         })
         check("非法 cron → 400", r.status_code == 400, str(r.status_code))
 
-        r = client.post(f"{BASE}/api/schedules/{sid_task}/pause")
+        r = client.post(f"{BASE}/api/schedules/{sid_task}/pause", params={"user_name": USER})
         check("暂停 200", r.status_code == 200 and r.json().get("enabled") is False)
-        r = client.delete(f"{BASE}/api/schedules/{sid_task}")
+        r = client.delete(f"{BASE}/api/schedules/{sid_task}", params={"user_name": USER})
         check("删除 200", r.status_code == 200 and r.json().get("deleted") is True)
 
 

+ 38 - 5
static/app.js

@@ -158,6 +158,7 @@ async function send() {
 function buildForm(text) {
   const fd = new FormData();
   fd.append('message', text);
+  fd.append('request_id', crypto.randomUUID());
   if (sessionId) fd.append('session_id', sessionId);
   fd.append('mode', el('mode').value);
   fd.append('user_name', USER);
@@ -222,6 +223,7 @@ async function stream(url, method, body, isJson) {
       }
     }
     if (!ui.failed) setStatus(pendingApproval ? '等待你的确认' : '回复已完成', pendingApproval ? 'busy' : 'ready');
+    return true;
   } catch (e) {
     if (e.name === 'AbortError') {
       addCard(ui.bubble, '', '已停止生成', '');
@@ -299,7 +301,7 @@ function handle(ev, ui, citations) {
       renderCitations(ui, citations, ev.model_only);
       break;
     case 'interrupt':
-      pendingApproval = !!ev.tool_approval;
+      pendingApproval = !!(ev.tool_approval || ev.ask_user);
       setStatus('等待你的确认', 'busy');
       renderInterrupt(ui, ev);
       break;
@@ -355,21 +357,48 @@ function renderCitations(ui, items, modelOnly) {
 
 function renderInterrupt(ui, ev) {
   const ta = ev.tool_approval;
+  if (ev.ask_user) {
+    const card = addCardFor(ui, 'approval', '需要补充信息', '');
+    const inputs = ev.ask_user.questions.map((question) => {
+      const label = document.createElement('label'); label.textContent = question;
+      const input = document.createElement('textarea'); input.rows = 2;
+      label.appendChild(input); card.appendChild(label); return input;
+    });
+    const submit = document.createElement('button'); submit.textContent = '提交回答';
+    submit.onclick = async () => {
+      if (streaming || resuming || inputs.some((input) => !input.value.trim())) return;
+      submit.disabled = true;
+      try { await resume({ answers: inputs.map((input) => input.value.trim()), interrupt_token: ev.interrupt_token }, ui); }
+      finally { if (!ui.node.dataset.resolved) submit.disabled = false; }
+    };
+    const cancel = document.createElement('button'); cancel.textContent = '取消';
+    cancel.onclick = async () => {
+      if (streaming || resuming || ui.node.dataset.resolved) return;
+      cancel.disabled = true;
+      try { await resume({ status: 'cancelled', interrupt_token: ev.interrupt_token }, ui); }
+      finally { if (!ui.node.dataset.resolved) cancel.disabled = false; }
+    };
+    const row = document.createElement('div'); row.className = 'actions';
+    row.append(submit, cancel); card.appendChild(row);
+    return;
+  }
   if (!ta) {
     addCardFor(ui, 'approval', '智能体暂停', ev.message || '等待输入');
     return;
   }
+  const actions = ta.actions || [{ name: ta.tool, args: ta.args, description: ta.reason }];
   const card = addCardFor(ui, 'approval',
-    `需确认:${ta.tool}`,
-    `${ta.reason || ''}\n${ta.side_effect || ''}\n\n参数:${JSON.stringify(ta.args, null, 1)}`);
+    actions.length > 1 ? `需确认 ${actions.length} 项操作` : `需确认:${ta.tool}`,
+    actions.map((action, index) => `${index + 1}. ${action.name}\n${action.description || ''}\n参数:${JSON.stringify(action.args, null, 1)}`).join('\n\n'));
   const row = document.createElement('div');
   row.className = 'actions';
   const ok = document.createElement('button'); ok.className = 'ok'; ok.textContent = '批准';
+  if (actions.length > 1) ok.textContent = `批准全部 ${actions.length} 项`;
   const no = document.createElement('button'); no.className = 'no'; no.textContent = '拒绝';
   const decide = async (extra) => {
     if (streaming || resuming || ui.node.parentNode !== log) return;
     ok.disabled = no.disabled = true;
-    await resume(extra, ui);
+    await resume({ ...extra, interrupt_token: ta.interrupt_token }, ui);
     // Failed requests keep their original approval controls available for retry.
     if (pendingApproval && !ui.node.dataset.resolved) ok.disabled = no.disabled = false;
   };
@@ -386,7 +415,11 @@ async function resume(extra, ui) {
   resuming = true;
   setStatus('恢复中...');
   try {
-    await stream(`${API}/chat/resume`, 'POST', buildResumeBody(extra), true);
+    const accepted = await stream(`${API}/chat/resume`, 'POST', buildResumeBody(extra), true);
+    if (accepted) {
+      ui.node.dataset.resolved = 'true';
+      ui.node.querySelectorAll('.approval button, .approval textarea').forEach((control) => { control.disabled = true; });
+    }
   } finally {
     resuming = false;
   }

+ 1 - 1
static/index.html

@@ -8,7 +8,7 @@
   <link rel="stylesheet" href="/static/styles.css?v=20260929-no-suggestions" />
   <script src="/static/vendor/marked.umd.js" defer></script>
   <script src="/static/vendor/purify.min.js" defer></script>
-  <script src="/static/app.js?v=20260929-no-suggestions" defer></script>
+  <script src="/static/app.js?v=20260930-runtime" defer></script>
 </head>
 <body>
 <svg class="icon-library" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs>

+ 3 - 0
tests/conftest.py

@@ -53,6 +53,9 @@ def _clean_tables():
     """
     for table in (
         "messages",
+        "execution_runs",
+        "request_keys",
+        "billing_pending",
         "sessions",
         "session_context_usage",
         "context_archives",

+ 21 - 0
tests/frontend_markdown.html

@@ -61,6 +61,27 @@ frame.addEventListener('load', async () => {
     w.document.getElementById('stopBtn').click();
     await pending;
     check(w.document.querySelector('#log .assistant:last-child strong')?.textContent === '保留部分回复', 'stopping preserves partial Markdown');
+    const originalResume = w.resume;
+    let submitted;
+    w.resume = async (payload) => { submitted = payload; };
+    const approval = w.newAssistant();
+    w.handle({type:'interrupt', tool_approval:{tool:'one', args:{}, interrupt_token:'batch-token', actions:[{name:'one',args:{path:'a'}},{name:'two',args:{path:'b'}}]}}, approval, []);
+    check(approval.bubble.textContent.includes('one') && approval.bubble.textContent.includes('two'), 'approval shows every action');
+    const approveButton = approval.bubble.querySelector('button.ok');
+    check(approveButton.textContent.includes('全部 2 项'), 'batch approval scope is explicit');
+    approveButton.click(); await pause();
+    check(submitted?.interrupt_token === 'batch-token' && submitted?.approved === true, 'approval submits current interrupt token');
+    const question = w.newAssistant();
+    w.handle({type:'interrupt', interrupt_token:'question-token', ask_user:{questions:['请选择区域','需要什么格式']}}, question, []);
+    const answers = question.bubble.querySelectorAll('textarea');
+    check(answers.length === 2, 'ask-user displays all questions');
+    answers[0].value = '一区'; answers[1].value = '表格';
+    question.bubble.querySelector('button').click(); await pause();
+    check(submitted?.interrupt_token === 'question-token' && submitted?.answers?.join(',') === '一区,表格', 'ask-user submits answers with interrupt token');
+    question.bubble.querySelectorAll('button')[1].click(); await pause();
+    check(submitted?.status === 'cancelled' && submitted?.interrupt_token === 'question-token', 'ask-user can cancel the current question');
+    check(Boolean(w.buildForm('test').get('request_id')), 'chat attaches a request id');
+    w.resume = originalResume;
     w.fetch = fetchOriginal;
     w.document.getElementById('newBtn').click();
     render(sample);

+ 1 - 1
tests/test_core_units.py

@@ -992,7 +992,7 @@ class TestResumeTranslation:
         """官方要求 decisions 与 action_requests 数量严格一致。"""
         from api.chat_routes import _to_official_resume
 
-        decisions = _to_official_resume(self._payload(approved=True), 3)["decisions"]
+        decisions = _to_official_resume(self._payload(approved=True, interrupt_token="reviewed-batch"), 3)["decisions"]
         assert len(decisions) == 3
         assert all(d["type"] == "approve" for d in decisions)
 

+ 162 - 0
tests/test_release.py

@@ -0,0 +1,162 @@
+"""发布包边界与部署管理的回归测试;使用临时目录,不触碰共享数据。"""
+from __future__ import annotations
+
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import tarfile
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "release"))
+import build as release_build
+import common as release_common
+import deploy as release_deploy
+
+
+@pytest.fixture
+def source(tmp_path):
+    root = tmp_path / "source"
+    for original in release_build.source_files(ROOT):
+        dest = root / original.relative_to(ROOT)
+        dest.parent.mkdir(parents=True, exist_ok=True)
+        shutil.copyfile(original, dest)
+    return root
+
+
+@pytest.mark.parametrize("value", ["../2.0.0", "2.0", "01.2.3", "2.0.0/evil", "2.0.0+local", ""])
+def test_invalid_versions(value):
+    with pytest.raises(ValueError):
+        release_common.version(value)
+
+
+def test_archive_boundary_and_integrity(source, tmp_path):
+    (source / ".env").write_text("secret")
+    (source / "data").mkdir()
+    (source / "data/private.sqlite").write_text("private")
+    (source / "agents/.env").write_text("secret")
+    archive = release_build.build(source, tmp_path / "dist", "2.1.0-rc.1", checks=False)
+    assert archive.with_suffix(".gz.sha256").read_text().split()[0] == release_common.sha256(archive)
+    extracted = tmp_path / "extract"
+    with tarfile.open(archive) as tar:
+        tar.extractall(extracted, filter="data")
+    root = extracted / "vent-agent-2.1.0-rc.1-linux"
+    manifest = release_common.validate_package(root)
+    assert manifest["checks"] == "skipped"
+    assert (root / "VERSION").read_text().strip() == "2.1.0-rc.1"
+    assert not (root / ".env").exists()
+    assert not (root / "data").exists()
+    assert not (root / "agents/.env").exists()
+    assert (source / "VERSION").read_text() != "2.1.0-rc.1\n"
+    # 校验脚本本身不应生成清单外 __pycache__ 导致第二次校验失败。
+    for _ in range(2):
+        subprocess.run([sys.executable, str(root / "release/verify.py")], check=True, capture_output=True)
+    with pytest.raises(ValueError, match="不覆盖"):
+        release_build.build(source, tmp_path / "dist", "2.1.0-rc.1", checks=False)
+    (root / "main.py").write_text("tampered")
+    with pytest.raises(ValueError, match="校验失败"):
+        release_common.validate_package(root)
+
+
+def test_source_symlink_rejected(source, tmp_path):
+    (source / "agents/injected.py").symlink_to(tmp_path / "outside.py")
+    with pytest.raises(ValueError, match="符号链接"):
+        release_build.source_files(source)
+
+
+def test_stale_lock_rejected(source):
+    with (source / "requirements.txt").open("a") as stream:
+        stream.write("\nnew-package==1.0\n")
+    with pytest.raises(ValueError, match="已变化"):
+        release_build.verify_lock(source, False)
+
+
+def test_git_is_optional(tmp_path, monkeypatch):
+    def missing(*args, **kwargs):
+        raise FileNotFoundError("git")
+
+    monkeypatch.setattr(release_build.subprocess, "run", missing)
+    assert release_build.git_info(tmp_path) == {"commit": None, "dirty": None}
+
+
+def test_extraneous_file_rejected(source, tmp_path):
+    archive = release_build.build(source, tmp_path / "dist", "2.0.1", checks=False)
+    with tarfile.open(archive) as tar:
+        tar.extractall(tmp_path / "extract", filter="data")
+    root = tmp_path / "extract/vent-agent-2.0.1-linux"
+    (root / ".env").write_text("unexpected")
+    with pytest.raises(ValueError, match="清单外"):
+        release_common.validate_package(root)
+    release_common.validate_package(root, installed=True)
+
+
+def test_instance_lock_prevents_activation(tmp_path):
+    assert not release_deploy.running(tmp_path)
+    with release_deploy.locked(tmp_path / "shared/run/instance.lock"):
+        assert release_deploy.running(tmp_path)
+        with pytest.raises(ValueError, match="先 stop"):
+            release_deploy.activate(tmp_path, "2.0.0")
+    assert not release_deploy.running(tmp_path)
+
+
+def test_reused_pid_not_signalled(monkeypatch):
+    monkeypatch.setattr(release_deploy, "start_ticks", lambda pid: "new")
+    assert not release_deploy.same_process({"pid": 123, "start_ticks": "old"})
+    assert not release_deploy.same_process({"pid": 1, "start_ticks": "new"})
+    assert release_deploy.same_process({"pid": 123, "start_ticks": "new"})
+
+
+@pytest.fixture
+def package(source, tmp_path):
+    archive = release_build.build(source, tmp_path / "dist", "2.0.1", checks=False)
+    with tarfile.open(archive) as tar:
+        tar.extractall(tmp_path / "extract", filter="data")
+    return tmp_path / "extract/vent-agent-2.0.1-linux"
+
+
+def test_install_preserves_shared_and_activation(package, tmp_path, monkeypatch):
+    prefix = tmp_path / "installed"
+    shared = prefix / "shared"
+    (shared / "data").mkdir(parents=True)
+    (shared / ".env").write_text("existing-secret")
+    (shared / "data/sentinel").write_text("keep")
+    monkeypatch.setattr(release_deploy, "PACKAGE", package)
+
+    def fake_run(command, **kwargs):
+        if "venv" in command:
+            python = Path(command[-1]) / "bin/python"
+            python.parent.mkdir(parents=True)
+            python.write_text("test")
+        return subprocess.CompletedProcess(command, 0)
+
+    monkeypatch.setattr(release_deploy.subprocess, "run", fake_run)
+    release_deploy.install(prefix)
+    target = prefix / "releases/2.0.1"
+    assert (target / ".env").resolve() == shared / ".env"
+    assert (target / "data").resolve() == shared / "data"
+    assert (shared / ".env").read_text() == "existing-secret"
+    assert (shared / "data/sentinel").read_text() == "keep"
+    assert not (prefix / "current").exists()
+    with pytest.raises(ValueError, match="不覆盖"):
+        release_deploy.install(prefix)
+    release_deploy.activate(prefix, "2.0.1")
+    assert release_deploy.current_release(prefix) == target
+
+
+def test_failed_install_cleans_only_new_version(package, tmp_path, monkeypatch):
+    prefix = tmp_path / "installed"
+    shared = prefix / "shared"
+    shared.mkdir(parents=True)
+    (shared / ".env").write_text("keep")
+    monkeypatch.setattr(release_deploy, "PACKAGE", package)
+
+    def fail(command, **kwargs):
+        raise subprocess.CalledProcessError(1, command)
+
+    monkeypatch.setattr(release_deploy.subprocess, "run", fail)
+    with pytest.raises(subprocess.CalledProcessError):
+        release_deploy.install(prefix)
+    assert not (prefix / "releases/2.0.1").exists()
+    assert (shared / ".env").read_text() == "keep"

+ 492 - 0
tests/test_runtime_regressions.py

@@ -0,0 +1,492 @@
+"""执行、存储和人机交互的失败路径回归;不访问模型网络。"""
+import asyncio
+import json
+from types import SimpleNamespace
+
+import httpx
+import pytest
+from langchain_core.messages import AIMessage, AIMessageChunk
+
+import config
+from api import concurrency
+from api.interrupts import pack
+from db import billing_store, chat_store, db_conn, run_store, schedule_store
+
+
+@pytest.fixture(autouse=True)
+def isolation(monkeypatch, tmp_path):
+    monkeypatch.setattr(config, "ROOT", tmp_path)
+    (tmp_path / "skills").mkdir()
+    concurrency.reset_for_tests()
+    yield
+    concurrency.reset_for_tests()
+
+
+
+
+class FakeAgent:
+    def __init__(self, interrupt=None, wait=None):
+        self.interrupt = interrupt
+        self.wait = wait
+        self.started = asyncio.Event()
+        self.inputs = []
+
+    async def aget_state(self, config):
+        return SimpleNamespace(tasks=[])
+
+    async def astream(self, graph_input, **kwargs):
+        self.inputs.append((graph_input, kwargs))
+        self.started.set()
+        yield {"type": "messages", "data": (AIMessageChunk(content="部分回答", usage_metadata={"input_tokens": 12, "output_tokens": 3, "total_tokens": 15}), {})}
+        if self.wait:
+            await self.wait.wait()
+        if self.interrupt:
+            yield {"type": "updates", "data": {"__interrupt__": [self.interrupt]}}
+
+
+def test_sqlite_rollback_and_nested_savepoint():
+    adapter = db_conn._SqliteAdapter(":memory:")
+    adapter.execute("CREATE TABLE probe (id INTEGER)")
+    with pytest.raises(RuntimeError):
+        with adapter.transaction():
+            adapter.execute("INSERT INTO probe VALUES (1)")
+            adapter.execute_many("INSERT INTO probe VALUES (?)", [(2,), (3,)])
+            raise RuntimeError()
+    assert adapter.query("SELECT * FROM probe") == []
+    with adapter.transaction():
+        adapter.execute("INSERT INTO probe VALUES (4)")
+        with pytest.raises(ValueError):
+            with adapter.transaction():
+                adapter.execute("INSERT INTO probe VALUES (5)")
+                raise ValueError()
+        adapter.execute("INSERT INTO probe VALUES (6)")
+    assert adapter.query("SELECT id FROM probe") == [{"id": 4}, {"id": 6}]
+
+
+def test_billing_transaction_rolls_back_quota_failure(monkeypatch):
+    def fail(*args):
+        raise RuntimeError("quota database failure")
+    monkeypatch.setattr(billing_store, "_accumulate_quota", fail)
+    with pytest.raises(RuntimeError):
+        billing_store.record_usage("u", "s", "chat", config.MODEL, 10, 2, 0, db_conn.day_key())
+    assert billing_store.recent_usage("u") == []
+
+
+def test_failed_settlement_survives_new_meter_and_deduplicates(monkeypatch, pricing_ready):
+    from billing.meter import UsageMeter, retry_pending
+    real = billing_store.record_usage
+    meter = UsageMeter("u", "s", "chat", config.MODEL)
+    meter.add(10, 2)
+    def fail(**kwargs):
+        raise RuntimeError("temporary outage")
+    monkeypatch.setattr(billing_store, "record_usage", fail)
+    assert not meter.settle()["settled"]
+    assert not meter.settled
+    assert db_conn.scalar("SELECT COUNT(*) FROM billing_pending") == 1
+    monkeypatch.setattr(billing_store, "record_usage", real)
+    assert retry_pending() == 1
+    assert meter.settle()["settled"]  # old request's retry cannot double-charge
+    assert len(billing_store.recent_usage("u")) == 1
+    assert billing_store.get_quota("u", db_conn.day_key())["used_tokens"] == 12
+    assert db_conn.scalar("SELECT COUNT(*) FROM billing_pending") == 0
+
+
+@pytest.mark.asyncio
+async def test_http_concurrency_returns_429(monkeypatch, pricing_ready):
+    from main import app
+    monkeypatch.setattr(config, "MAX_CONCURRENT_TASKS", 0)
+    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
+        response = await c.post("/api/chat", data={"message": "hello"})
+    assert response.status_code == 429
+    assert response.headers["retry-after"]
+    assert not concurrency._sessions
+
+
+@pytest.mark.asyncio
+async def test_cross_user_message_and_schedule_access(pricing_ready):
+    from main import app
+    session = chat_store.create_session("alice")
+    message_id = chat_store.save_message(session["id"], "user", "secret")
+    schedule = schedule_store.create_schedule("alice", "private", "0 9 * * *", "private")
+    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
+        response = await c.delete(f"/api/messages/{message_id}", params={"user_name": "bob"})
+        assert response.status_code == 404
+        for method, suffix in [("GET", ""), ("DELETE", ""), ("POST", "/run"), ("GET", "/runs")]:
+            response = await c.request(method, f"/api/schedules/{schedule['id']}{suffix}", params={"user_name": "bob"})
+            assert response.status_code == 404
+    assert chat_store.count_messages(session["id"]) == 1
+    assert schedule_store.get_schedule(schedule["id"])
+
+
+@pytest.mark.asyncio
+async def test_same_session_concurrent_request_rejected(monkeypatch, pricing_ready):
+    from main import app
+    agent = FakeAgent(wait=asyncio.Event())
+    monkeypatch.setattr("agents.agent_registry.get_agent", lambda: agent)
+    session = chat_store.create_session("default")
+    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
+        first = asyncio.create_task(c.post("/api/chat", data={"message": "one", "session_id": session["id"]}))
+        await asyncio.wait_for(agent.started.wait(), 2)
+        second = await c.post("/api/chat", data={"message": "two", "session_id": session["id"]})
+        assert second.status_code == 409
+        agent.wait.set()
+        assert (await first).status_code == 200
+    assert concurrency.stats()["global_active"] == 0
+    assert len(run_store.list_runs(session["id"])) == 1
+    assert "two" not in [m["content"] for m in chat_store.get_messages(session["id"])]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("cancel", [False, True])
+async def test_stream_close_or_cancel_settles_and_releases(cancel, pricing_ready):
+    from agents.execution import admit, build_stream
+    session = chat_store.create_session("default")
+    agent = FakeAgent(wait=asyncio.Event())
+    admission = await admit("default", session["id"])
+    execution = await build_stream(admission, agent=agent, session_id=session["id"], thread_id=session["id"],
+        user_name="default", message="test", config={"configurable": {"thread_id": session["id"]}})
+    generator = execution.generate()
+    while True:
+        frame = json.loads((await anext(generator))[6:])
+        if frame["type"] == "token":
+            break
+    if cancel:
+        pending = asyncio.create_task(anext(generator))
+        await asyncio.sleep(0)
+        pending.cancel()
+        with pytest.raises(asyncio.CancelledError):
+            await pending
+    await generator.aclose()
+    assert not concurrency._sessions
+    assert concurrency.stats()["global_active"] == 0
+    assert billing_store.get_quota("default", db_conn.day_key())["used_tokens"] == 15
+    assert run_store.list_runs(session["id"])[0]["status"] == "cancelled"
+    assert "不完整" in chat_store.get_messages(session["id"])[-1]["content"]
+
+
+@pytest.mark.asyncio
+async def test_schedule_uses_shared_runtime_and_pauses(monkeypatch, pricing_ready):
+    from scheduler.runner import run_schedule
+    item = SimpleNamespace(id="approval-id", value={"action_requests": [{"name": "calculate", "args": {"expression": "1+1"}}]})
+    agent = FakeAgent(interrupt=item)
+    monkeypatch.setattr("agents.agent_registry.get_agent", lambda: agent)
+    schedule = schedule_store.create_schedule("u", "plan", "0 9 * * *", "question", "plan")
+    result = await run_schedule(schedule["id"])
+    assert result["status"] == "waiting_input"
+    assert schedule_store.latest_run(schedule["id"])["status"] == "waiting_input"
+    assert run_store.list_runs(result["session_id"])[0]["status"] == "waiting_input"
+    assert "request_plan_approval" in agent.inputs[0][0]["messages"][-1].content
+    assert billing_store.get_quota("u", db_conn.day_key())["used_tokens"] == 15
+    assert concurrency.stats()["global_active"] == 0
+
+
+@pytest.mark.asyncio
+async def test_resume_and_schedule_enforce_quota(pricing_ready):
+    from main import app
+    from scheduler.runner import run_schedule
+    from billing.guard import QuotaExceededError
+    session = chat_store.create_session("u")
+    billing_store.set_quota_limit("u", db_conn.day_key(), 1)
+    billing_store.record_usage("u", session["id"], "chat", config.MODEL, 1, 0, 0, db_conn.day_key())
+    schedule = schedule_store.create_schedule("u", "x", "0 9 * * *", "x")
+    with pytest.raises(QuotaExceededError):
+        await run_schedule(schedule["id"])
+    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
+        response = await c.post("/api/chat/resume", json={"session_id": session["id"], "user_name": "u", "approved": True})
+        assert response.status_code == 429
+    assert not concurrency._sessions
+
+
+def test_workspace_isolation_and_skills(monkeypatch, tmp_path):
+    from agents.workspace import backend_for_runtime, session_root
+    (tmp_path / "skills" / "guide.md").write_text("shared guide")
+    def backend(user, session):
+        return backend_for_runtime(SimpleNamespace(config={"configurable": {"workspace_user": user, "workspace_session": session}}))
+    first, second, other_user = backend("alice", "one"), backend("alice", "two"), backend("bob", "one")
+    first.write("/workspace/private.txt", "secret")
+    assert "secret" in str(first.read("/workspace/private.txt"))
+    assert second.read("/workspace/private.txt").error
+    assert other_user.read("/workspace/private.txt").error
+    assert "shared guide" in str(first.read("/skills/guide.md"))
+    assert session_root("../escape", "../../other").is_relative_to(tmp_path / "data" / "workspaces")
+    with pytest.raises(ValueError):
+        backend_for_runtime(SimpleNamespace(config={}))
+
+
+def test_batch_requires_current_interrupt_and_shows_all():
+    from api.interrupts import interrupt_frame, resume_value
+    from api.models import ResumeRequest
+    from api.sse_events import EventWriter
+    from fastapi import HTTPException
+    item = SimpleNamespace(id="a", value={"action_requests": [{"name": "one", "args": {}}, {"name": "two", "args": {}}]})
+    frame = json.loads(interrupt_frame(EventWriter("s"), pack(item))[6:])
+    assert len(frame["tool_approval"]["actions"]) == 2
+    state = SimpleNamespace(tasks=[SimpleNamespace(interrupts=[item])])
+    payload = ResumeRequest(session_id="s", approved=True, interrupt_token=frame["tool_approval"]["interrupt_token"])
+    assert len(resume_value(payload, state)["a"]["decisions"]) == 2
+    item.id = "newer"
+    with pytest.raises(HTTPException) as error:
+        resume_value(payload, state)
+    assert error.value.status_code == 409
+
+
+def test_declared_side_effect_tools_require_approval():
+    from tools.registry import register
+    from agents.agent_factory import _build_interrupt_on
+    @register(group="test", requires_approval=True)
+    def external_write() -> str:
+        """External change."""
+        return "done"
+    assert _build_interrupt_on()["external_write"] is True
+
+
+def test_unknown_model_is_not_silently_priced(monkeypatch):
+    from billing import pricing, guard
+    monkeypatch.setattr(config, "MODEL", "unpriced-test-model")
+    pricing.seed_default_pricing()
+    assert billing_store.get_pricing(config.MODEL) is None
+    with pytest.raises(guard.BillingConfigError):
+        guard.precheck("u")
+
+
+def test_startup_marks_stale_runs_interrupted():
+    run_store.start("stale", "s", "u", "chat")
+    run_store.recover_interrupted()
+    assert run_store.list_runs("s")[0]["status"] == "interrupted"
+
+
+from langchain_core.language_models.chat_models import BaseChatModel
+from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
+from pydantic import Field
+
+
+class ScriptedModel(BaseChatModel):
+    """真实图引擎,确定性模型;覆盖 middleware、工具和 checkpoint。"""
+    responses: list[AIMessage] = Field(default_factory=list)
+    calls: int = 0
+
+    @property
+    def _llm_type(self):
+        return "offline-scripted"
+
+    def bind_tools(self, tools, **kwargs):
+        return self
+
+    def _next(self):
+        response = self.responses[self.calls]
+        self.calls += 1
+        return response
+
+    def _generate(self, messages, stop=None, run_manager=None, **kwargs):
+        return ChatResult(generations=[ChatGeneration(message=self._next())])
+
+    def _stream(self, messages, stop=None, run_manager=None, **kwargs):
+        message = self._next()
+        yield ChatGenerationChunk(message=AIMessageChunk(content=message.content, tool_calls=message.tool_calls,
+            usage_metadata={"input_tokens": 20, "output_tokens": 5, "total_tokens": 25}))
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("tool_name,args", [
+    ("ask_user", {"questions": ["请选择区域"]}),
+    ("request_plan_approval", {"plan": "读取文件后生成报告"}),
+])
+async def test_real_graph_interrupt_refresh_resume(monkeypatch, pricing_ready, tool_name, args):
+    from agents import agent_factory, agent_memory
+    from main import app
+    model = ScriptedModel(responses=[
+        AIMessage(content="", tool_calls=[{"id": "call1", "name": tool_name, "args": args}]),
+        AIMessage(content="已根据你的确认完成。"),
+    ])
+    monkeypatch.setattr("agents.model_factory.get_model", lambda: model)
+    graph = agent_factory.build_agent()
+    monkeypatch.setattr("agents.agent_registry.get_agent", lambda: graph)
+    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
+        first = await c.post("/api/chat", data={"message": "test", "user_name": "u"})
+        assert first.status_code == 200, first.text
+        events = [json.loads(line[6:]) for line in first.text.splitlines() if line.startswith("data: ")]
+        assert not [e for e in events if e["type"] == "error"], events
+        interrupted = next(e for e in events if e["type"] == "interrupt")
+        sid = first.headers["x-session-id"]
+        token = interrupted.get("interrupt_token") or interrupted["tool_approval"]["interrupt_token"]
+        refreshed = (await c.get("/api/chat/interrupt", params={"session_id": sid, "user_name": "u"})).json()["interrupts"][0]
+        assert (refreshed.get("interrupt_token") or refreshed["tool_approval"]["interrupt_token"]) == token
+        assert events[-1]["waiting_input"]
+        response = await c.post("/api/chat/resume", json={"session_id": sid, "user_name": "u",
+            "interrupt_token": token, "approved": True, "answers": ["一区"]})
+        assert response.status_code == 200, response.text
+        assert "已根据你的确认完成" in response.text
+        assert run_store.list_runs(sid)[0]["status"] == "success"
+        repeated = await c.post("/api/chat/resume", json={"session_id": sid, "user_name": "u", "interrupt_token": token, "approved": True})
+        assert repeated.status_code == 409
+    await agent_memory.delete_thread(sid)
+
+
+@pytest.mark.asyncio
+async def test_real_graph_workspace_write(monkeypatch, pricing_ready):
+    from agents import agent_factory, agent_memory
+    from agents.workspace import session_root
+    from main import app
+    model = ScriptedModel(responses=[
+        AIMessage(content="", tool_calls=[{"id": "w1", "name": "write_file", "args": {"file_path": "/workspace/result.txt", "content": "隔离的产物"}}]),
+        AIMessage(content="已写入"),
+    ])
+    monkeypatch.setattr("agents.model_factory.get_model", lambda: model)
+    graph = agent_factory.build_agent()
+    monkeypatch.setattr("agents.agent_registry.get_agent", lambda: graph)
+    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
+        result = await c.post("/api/chat", data={"message": "write", "user_name": "u"})
+    sid = result.headers["x-session-id"]
+    assert (session_root("u", sid) / "workspace/result.txt").read_text() == "隔离的产物", result.text
+    await agent_memory.delete_thread(sid)
+
+
+@pytest.mark.asyncio
+async def test_request_id_does_not_execute_twice(monkeypatch, pricing_ready):
+    from main import app
+    agent = FakeAgent()
+    monkeypatch.setattr("agents.agent_registry.get_agent", lambda: agent)
+    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
+        body = {"message": "once", "request_id": "client-generated-key"}
+        first = await c.post("/api/chat", data=body)
+        second = await c.post("/api/chat", data=body)
+    assert first.status_code == 200
+    assert second.status_code == 409
+    assert second.json()["detail"]["run_id"] == first.headers["x-run-id"]
+    assert len(agent.inputs) == 1
+    assert len(chat_store.list_sessions("default")) == 1
+
+
+@pytest.mark.asyncio
+async def test_real_subagent_shares_only_parent_workspace(monkeypatch, pricing_ready):
+    from agents import agent_factory, agent_memory
+    from agents.workspace import session_root
+    from main import app
+    model = ScriptedModel(responses=[
+        AIMessage(content="", tool_calls=[{"id": "t1", "name": "task", "args": {"subagent_type": "demo-searcher", "description": "write a report"}}]),
+        AIMessage(content="", tool_calls=[{"id": "w2", "name": "write_file", "args": {"file_path": "/workspace/sub.txt", "content": "子任务产物"}}]),
+        AIMessage(content="子任务完成"),
+        AIMessage(content="汇总完成"),
+    ])
+    monkeypatch.setattr("agents.model_factory.get_model", lambda: model)
+    graph = agent_factory.build_agent()
+    monkeypatch.setattr("agents.agent_registry.get_agent", lambda: graph)
+    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
+        result = await c.post("/api/chat", data={"message": "delegate", "user_name": "u"})
+    sid = result.headers["x-session-id"]
+    assert (session_root("u", sid) / "workspace/sub.txt").read_text() == "子任务产物", result.text
+    await agent_memory.delete_thread(sid)
+
+
+@pytest.mark.asyncio
+async def test_schedule_timeout_is_failed_and_billed(monkeypatch, pricing_ready):
+    from scheduler.runner import run_schedule
+    agent = FakeAgent(wait=asyncio.Event())
+    monkeypatch.setattr("agents.agent_registry.get_agent", lambda: agent)
+    monkeypatch.setattr(config, "TASK_WALL_TIMEOUT", 0.05)
+    schedule = schedule_store.create_schedule("u", "slow", "0 9 * * *", "wait")
+    result = await run_schedule(schedule["id"])
+    assert result["status"] == "failed"
+    assert schedule_store.latest_run(schedule["id"])["status"] == "failed"
+    assert billing_store.get_quota("u", db_conn.day_key())["used_tokens"] == 15
+    assert concurrency.stats()["global_active"] == 0
+
+
+@pytest.mark.asyncio
+async def test_upload_limit_removes_partial_file(monkeypatch, pricing_ready):
+    from fastapi import UploadFile, HTTPException
+    from io import BytesIO
+    from api.chat_routes import _save_upload
+    from agents.workspace import session_root
+    monkeypatch.setattr("api.chat_routes.MAX_UPLOAD_SIZE", 5)
+    with pytest.raises(HTTPException) as error:
+        await _save_upload(UploadFile(filename="data.txt", file=BytesIO(b"too long")), "u", "s")
+    assert error.value.status_code == 413
+    assert not list((session_root("u", "s") / "workspace/uploads").iterdir())
+
+
+def test_child_and_summary_usage_count_without_leaking_text_or_context():
+    from api.sse_core import ChatStream, StreamRequest
+    stream = ChatStream(StreamRequest(agent=None, session_id="s", thread_id="s", config={}, message="q"))
+    main = AIMessageChunk(content="主回答", usage_metadata={"input_tokens": 100, "output_tokens": 10, "total_tokens": 110})
+    child = AIMessageChunk(content="子任务内部材料", usage_metadata={"input_tokens": 10, "output_tokens": 2, "total_tokens": 12})
+    summary = AIMessageChunk(content="内部摘要", usage_metadata={"input_tokens": 20, "output_tokens": 3, "total_tokens": 23})
+    assert stream._handle_messages((main, {}), ())
+    assert stream._handle_messages((child, {}), ("task:1",)) == []
+    assert stream._handle_messages((summary, {"lc_source": "summarization"}), ()) == []
+    assert stream.answer_parts == ["主回答"]
+    assert stream.meter.total_tokens == 145
+    assert stream.meter.context_tokens == 100
+    assert not stream.meter.compacted
+
+
+@pytest.mark.asyncio
+async def test_scheduler_shutdown_waits_for_cleanup(monkeypatch):
+    from scheduler import scheduler
+    entered = asyncio.Event()
+    cleaned = False
+    calls = []
+    async def work(*args, **kwargs):
+        nonlocal cleaned
+        entered.set()
+        try:
+            await asyncio.Event().wait()
+        finally:
+            await asyncio.sleep(0)
+            cleaned = True
+    class Scheduler:
+        def pause(self):
+            calls.append("pause")
+        def shutdown(self, **kwargs):
+            assert cleaned
+            calls.append("shutdown")
+    monkeypatch.setattr(scheduler, "run_schedule", work)
+    monkeypatch.setattr(scheduler, "_scheduler", Scheduler())
+    task = asyncio.create_task(scheduler._invoke("s"))
+    await entered.wait()
+    await scheduler.shutdown()
+    assert task.done() and cleaned
+    assert calls == ["pause", "shutdown"]
+    assert not scheduler._active
+
+
+@pytest.mark.asyncio
+async def test_admission_revalidates_deleted_session(pricing_ready):
+    from agents.execution import admit
+    session = chat_store.create_session("u")
+    chat_store.delete_session(session["id"])
+    with pytest.raises(concurrency.SessionMissingError):
+        await admit("u", session["id"])
+    assert concurrency.stats()["global_active"] == 0
+    assert not concurrency._sessions
+
+
+@pytest.mark.asyncio
+async def test_cancellation_during_persistence_does_not_duplicate_answer(monkeypatch, pricing_ready):
+    import threading
+    from agents.execution import admit, build_stream
+    session = chat_store.create_session("u")
+    entered, release = threading.Event(), threading.Event()
+    original = chat_store.save_message
+    def slow_save(session_id, role, content, **kwargs):
+        if role == "assistant":
+            entered.set()
+            assert release.wait(3)
+        return original(session_id, role, content, **kwargs)
+    monkeypatch.setattr(chat_store, "save_message", slow_save)
+    admission = await admit("u", session["id"])
+    execution = await build_stream(admission, agent=FakeAgent(), session_id=session["id"],
+        thread_id=session["id"], user_name="u", message="question", config={"configurable": {"thread_id": session["id"]}})
+    async def consume():
+        async for _ in execution.generate():
+            pass
+    task = asyncio.create_task(consume())
+    assert await asyncio.to_thread(entered.wait, 3)
+    task.cancel()
+    await asyncio.sleep(0)
+    release.set()
+    with pytest.raises(asyncio.CancelledError):
+        await task
+    assert len([m for m in chat_store.get_messages(session["id"]) if m["msg_role"] == "assistant"]) == 1
+    assert len(billing_store.recent_usage("u")) == 1
+    assert concurrency.stats()["global_active"] == 0

+ 13 - 0
tools/demo_tools.py

@@ -243,3 +243,16 @@ def request_plan_approval(plan: str) -> str:
     # 正常情况下走不到这里:调用被 interrupt_on 拦下,用户批准后才会执行函数体。
     # 这条 return 是「批准」路径的返回值。
     return f"用户已批准以下计划,请按计划执行:\n{plan}"
+
+
+@register(group="人机协同", label="向用户提问", summary="缺少必要信息时暂停并向用户提问,收到回答后继续")
+def ask_user(questions: list[str]) -> dict[str, Any]:
+    """当任务缺少必要信息且无法自行确定时,提出一至三个简短问题。
+
+    Args:
+        questions: 问题文本列表。不要询问可通过已有工具查到的信息。
+    """
+    from langgraph.types import interrupt
+    if not 1 <= len(questions) <= 3 or any(not q.strip() for q in questions):
+        return {"error": "请提供一至三个非空问题"}
+    return interrupt({"kind": "ask_user", "questions": questions})

+ 6 - 0
tools/registry.py

@@ -45,6 +45,7 @@ class ToolSpec:
     group: str
     label: str
     summary: str = ""
+    requires_approval: bool = False
 
     @property
     def description(self) -> str:
@@ -85,6 +86,7 @@ def register(
     label: str = "",
     name: str | None = None,
     summary: str = "",
+    requires_approval: bool = False,
 ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
     """把函数注册为工具。
 
@@ -101,6 +103,7 @@ def register(
                 group=group,
                 label=label or tool_name,
                 summary=summary,
+                requires_approval=requires_approval,
             )
         )
         return func
@@ -114,6 +117,7 @@ def register_external(
     label: str = "",
     name: str | None = None,
     summary: str = "",
+    requires_approval: bool = False,
 ) -> Callable[..., Any]:
     """以函数式(非装饰器)方式注册,供不便加装饰器的场景使用。"""
     tool_name = name or getattr(func, "__name__", "unnamed_tool")
@@ -124,6 +128,7 @@ def register_external(
             group=group,
             label=label or tool_name,
             summary=summary,
+            requires_approval=requires_approval,
         )
     )
     return func
@@ -205,6 +210,7 @@ def describe() -> dict[str, Any]:
                     "name": name,
                     "label": _registry.tools[name].label,
                     "description": _registry.tools[name].description,
+                    "requires_approval": _registry.tools[name].requires_approval,
                 }
                 for name in names
                 if name in _registry.tools