Files
EvoScientist/docs/execution-engine/execution-engine-development-1.5.md
T
m4 c2743251e9 Initial commit of EvoScientist framework
Self-evolving AI scientist framework built on LangGraph/LangChain with
CLI/TUI core, FastAPI gateway, and Next.js frontend.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-07-13 08:07:45 +08:00

2620 lines
183 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# EvoScientist 执行引擎 — 需求与设计规格 (v1.5)
> 版本:v1.5
> 前身:execution-engine-development-1.4.md
> 性质:需求文档 + 设计规格(不含实现代码)
> 适用范围:EvoScientist 执行引擎子系统 — 含 k8s-exec-service-mcp 独立服务和 EvoScientist 调用侧
> v1.4 变更:修复 v1.3 残余问题 — 统一 lifespan 中 admin_token→execution_admin_token 变量命名(与 EXECUTION_ADMIN_TOKEN 环境变量一致);修正 §2.5 端到端数据流步骤顺序(先创建后端资源/绑定预热环境 → 再下载 input_objects → 最后执行命令),与 §2.4/§8.2 定义保持一致;§4.4 错误约定表新增 StorageBackendUnsupportedForCompute(409 状态码)。
> v1.4-review 修订:补齐评审阻塞项 — warm pool schema 允许未绑定用户状态;settle_compute_usage 原子事务与免费额度+现金混合扣费公式;Gateway preflight 基于 affordable_runtime_seconds 裁剪 max_runtime_seconds;artifact 注册失败不再无限阻塞计费;HMAC canonical string byte-exact;compute enabled/disabled 配置矩阵;统一路径规范、工具硬限制、Kubernetes/LocalBackend 安全基线、capacity-consuming 状态和 P0 工期说明。
> v1.5 修订(可执行性评审修订):6 个阻塞项 + 2 个高风险项全部修复 — §4.2 新增 HMAC byte-exact 共享测试向量(含 canonical_json、payload_hash、canonical_string、expected token);§7.1 compute_usage_log 新增 settlement_status 和 settlement_error 列;§4.3.1 /ready 简化为 ready/degraded/reason_code,详细诊断移至新增的 GET /admin/readiness;§6.2 新增 pricing_version 定义(文件 SHA-256 前 8 位或显式 version 字段);§5.3.2 overtime_price 异常值处理补全(NULL/≤0/负余额等 4 种边界);§12 新增 LocalStorageBackend→S3 迁移风险说明;§13 重写为两阶段 Phase 1/2 + 依赖图 + Gate Check + 工时调整为 90 人日。
**结论:**
- 可以采用对象存储作为 EvoScientist 与 k8s-exec-service-mcp 的统一文件共享底座。
- k8s-exec-service-mcp 不保存长期用户文件,也不提供执行 workspace 本地缓存保留 N 天;它只管理运行期 workspace。
- EvoScientist StorageService 是用户文件、目录树、权限、生命周期和 artifact 元数据的事实来源。
- P0 必须同时实现 LocalStorageBackend 和 S3StorageBackend;其中 Web 远程 compute 必须使用 S3StorageBackend,本地部署默认 MinIO,云上部署切换 S3/OSS/COS/OBS/R2。
- 远程执行的必要闭环是:StorageService 生成 input manifest → k8s-exec-service-mcp 下载输入 → 执行 → 上传 artifact → StorageService 入库 → 清理运行期 workspace。
---
## 1. 产品定位与目标
### 1.1 核心目标
将 AI 代码执行能力从 EvoScientist 主程序中解耦,形成两个可独立部署、独立演进的子系统:
- **k8s-exec-service-mcp**:统一计算资源与执行调度控制面,向 EvoScientist 提供稳定的执行调度接口,内部通过 ResourceManager 管理 KubernetesBackend 与 LocalBackend,负责执行环境创建、后端选择、命令执行、运行期文件访问、销毁、资源用量采集和计费数据沉淀。通过 Streamable HTTP 协议对外暴露能力。所有执行状态(environment 映射、pending_usage、backend registry、warm pool)必须持久化到独立数据库;用户文件和结果 artifact 的长期保存不属于该服务。
- **EvoScientist 调用侧**:在现有 Gateway 和 Agent 框架中集成远程执行能力,Web 用户线程的用户代码执行必须通过 k8s-exec-service-mcp,不允许在 Gateway 本地 shell、本地 Python 进程或 MultiRootSandboxBackend 中执行。CLI 用户不受影响。EvoScientist 通过 StorageService 管理用户文件元数据、权限、生命周期和对象存储 key。
- **统一对象存储**:保存用户上传文件、项目文件、计算输入、结果 artifact 和可下载文件内容。本地部署默认使用 MinIO;云上部署可使用 S3、OSS、COS、OBS 或其他 S3 兼容服务。
### 1.2 拆分原则
| 原则 | EvoScientist 侧 | k8s-exec-service-mcp 侧 |
|------|----------------|-----------------|
| 仓库归属 | EvoScientist monorepo | 独立仓库 `/Users/m4/Projects/EvoSci/MCP/k8s-exec-service-mcp` |
| 通信协议 | Streamable HTTP(HTTP/1.1) | Streamable HTTP(HTTP/1.1) |
| 客户端实现 | httpx AsyncClient | uvicorn + Starlette ASGI + ResourceManager + backend adapters |
| 依赖方向 | 不依赖 k8s-exec-service-mcp 任何模块 | 后端适配器可依赖 Kubernetes API、Docker/Podman 或受控本地 runtime |
| 耦合点 | HTTP endpoint 路径 + JSON schema + HMAC secret + admin token | 同上 |
| 文件内容 | StorageService 管理对象存储 key、权限和生命周期 | 只按授权读取输入 object、上传输出 artifact,不保存长期文件 |
| Web 计算执行 | 只通过 ComputeClient 调用 k8s-exec-service-mcp | 真正执行命令和代码 |
| 本地降级 | 仅允许非计算文件展示/恢复,不执行用户命令 | 不适用 |
### 1.3 部署模式
以下部署模式均需支持:
- **本地单机部署**(P0):k8s-exec-service-mcp 运行在独立计算服务器或开发者指定的计算资源机器上,通过 LocalBackend 管理该机器的 Docker/Podman 容器或受控本地进程沙箱。这里的 LocalBackend 是执行服务侧本地资源,不是 EvoScientist Gateway 本地执行。
- **本地 Kubernetes 部署**(P0):可使用 kind/k3d/minikube/k3s,完整部署 k8s-exec-service-mcp 与执行 namespace,用于验证生产路径。
- **集群内部署**(P0):k8s-exec-service-mcp 部署在 Kubernetes 集群内,通过 KubernetesBackend 使用 ServiceAccount 访问 Kubernetes API;Gateway 通过 `http://k8s-exec-service-mcp:9020` 或集群内 Service 通信。
- **远程/云上部署**(P1):k8s-exec-service-mcp 部署在独立 Kubernetes 集群、托管集群或云上资源池中,Gateway 通过 HTTPS + 内网 TLS 连接,URL 由环境变量 `EXECUTION_SERVICE_URL` 指定。
- **混合部署**(P1):同一个 k8s-exec-service-mcp 同时启用 LocalBackend 与 KubernetesBackend,ResourceManager 根据 `backend_policy`、resource_class、用户套餐、负载和健康状态选择后端。
### 1.4 本地完整部署目标
本地部署必须覆盖两类可执行路径,而不是 mock:
- 本地单机路径:LocalBackend 可在 k8s-exec-service-mcp 所在计算资源机器创建受控执行环境,执行 create → exec → file I/O → destroy → pending_usage → settle;Gateway 本机不执行用户代码。
- 本地 Kubernetes 路径:推荐 `kind` 或 `k3d`,可选 `minikube/k3s`,完整应用 namespace/RBAC/Quota/NetworkPolicy。
- 本地镜像构建与加载:`k8s-exec-service-mcp` 镜像和执行环境镜像必须能用于 LocalBackend 和本地 Kubernetes。
- Gateway 通过 `EXECUTION_SERVICE_URL` 调用本地 service、localhost 端口或 port-forward。
- create → exec → file I/O → destroy → pending_usage → settle 全链路可跑通。
---
## 2. 全局架构
### 2.1 运行时拓扑
整体调用链:
```text
EvoScientist Gateway
├─ StorageService
│ └─ S3-compatible Object Storage(MinIO/S3/OSS/COS)
└─ ComputeClient
└─ k8s-exec-service-mcp
├─ API/Auth Layer
├─ ResourceManager
├─ WarmPoolManager
│ ├─ EnvironmentRegistry
│ ├─ Scheduler
│ ├─ UsageRecorder
│ ├─ BackendHealth
│ ├─ KubernetesBackend
│ └─ LocalBackend
├─ Persistence Layer(SQLite WAL)
├─ ObjectStorageClient(只读输入 / 写出 artifact)
└─ Admin/metrics/logging
```
k8s-exec-service-mcp 对 EvoScientist 只暴露统一 execution API。EvoScientist 不感知具体后端是 Kubernetes Pod、本机容器还是受控本地进程;所有后端差异由 ResourceManager 和 backend adapter 封装。
文件内容通过统一对象存储共享,不通过 Gateway 本地路径共享。EvoScientist 是用户文件元数据、权限和生命周期的事实来源;k8s-exec-service-mcp 只接收由 EvoScientist 授权的 object key 或短期访问凭证,把输入同步到运行期 workspace,并把结果上传为 artifact。
Gateway 进程启动时完成以下初始化序列(严格按顺序):
1. 加载 HMAC shared secret 和 admin token。正常开发、测试和生产模式下,`EXECUTION_HMAC_SECRET` 与 `EXECUTION_ADMIN_TOKEN` 必须由配置显式提供,且必须与 k8s-exec-service-mcp 使用同一组值;缺失时 Gateway 启动失败。只有本地一键部署脚本允许自动生成,并必须同时写入 EvoScientist 与 k8s-exec-service-mcp 的 `.env`/Secret。
2. 执行数据库迁移(`init_gateway_db()`)。
3. 获取数据库连接。
4. 初始化 StorageService,加载 `STORAGE_BACKEND` 和对象存储配置;若配置为 S3,执行 bucket `head` 和最小读写 probe。
5. 启动 StorageService cleanup 后台任务(处理 expires_at 到期对象、orphan metadata 和 fallback_sync_recovery 自动重试)。
6. 创建 ComputeClient 实例,对 k8s-exec-service-mcp 执行 readiness 检查(`GET /ready`,超时 5s)。readiness 失败时 ComputeClient 标记为不可用;后续 Web 线程不得在 Gateway 本地执行用户计算,必须返回计算资源不可用错误或进入非计算只读降级。
7. 构建 app_state 字典,注入 db、StorageService、ComputeClient、admin_token。
8. 若 ComputeClient 可用且管理端点可达,启动 poll_loop 后台任务(轮询未结算 usage)。
9. 启动月度配额刷新后台任务(`refresh_expired_compute_quotas`,每 3600s 执行一次)。
10. Gateway 进入 ready 状态。
Web 线程(`POST /api/threads/{thread_id}/stream`)的生命周期:
1. 检查用户 compute 配额(异步查询 DB,返回 `ComputeQuotaResult`,包含 quota_ok、remaining_minutes、cash_balance、overtime_price、affordable_runtime_seconds)。
2. 通过 StorageService 解析当前线程需要暴露给执行环境的输入文件,生成 `input_objects`(file_id、object_key、sha256、size、mount_path、read_only)。
3. 根据套餐硬上限、用户请求上限和 `affordable_runtime_seconds` 计算实际 `max_runtime_seconds`,生成 `artifact_prefix=jobs/{thread_id}/{request_id}/artifacts/` 和 `default_output_paths`,调用 `create_cli_agent()`,传入 ComputeClient、配额结果、input_objects、artifact_prefix、default_output_paths,通过 `_backend_ref` 出参获取 ContainerSandboxBackend 引用。
4. Agent 执行期间,ContainerSandboxBackend 通过 ComputeClient 向 k8s-exec-service-mcp 发 HTTP 请求;服务侧由 ResourceManager 查找 environment 映射,再分发给 KubernetesBackend 或 LocalBackend 完成运行期文件读写、命令执行等操作。
5. Agent 执行结束(正常或异常),StreamHandler 在 `finally` 块中调用 `backend.destroy(persist_outputs=True, output_paths=default_output_paths, force_destroy=False)`;k8s-exec-service-mcp 先上传 output manifest 中的结果到对象存储,成功后才销毁运行期 workspace。
6. 若 destroy 返回 `STOPPING_FAILED`,运行期 workspace 保留,k8s-exec-service-mcp cleanup loop 或 Gateway 显式 destroy 重试继续上传 artifact;不得结算成功路径。
7. 若 destroy 返回 artifacts,EvoScientist 将 artifact 清单写入 StorageService 元数据表,再异步结算 compute usage。
8. 结算失败时仅记录日志,k8s-exec-service-mcp 已同时写入 pending_usage(持久化),poll_loop 会兜底重试;若 Gateway 在 artifact 入库前崩溃,poll_loop 通过 pending_usage.output_manifest_json 补注册 artifacts。
shutdown 时依次:取消 poll_loop、refresh_loop、storage_cleanup_loop,关闭 ComputeClient 的 httpx 连接池,关闭 StorageService,关闭数据库连接。
**计算资源不可用行为说明:** 当 ComputeClient 不可用时,Web 线程不得 fallback 到本地计算。ContainerSandboxBackend 标记 `_dead` 后,任何 `execute()`、shell、Python、代码运行或会触发用户命令的工具调用必须返回 `ComputeUnavailable`。MultiRootSandboxBackend 仅可用于非计算文件展示、已有文件下载、fallback_sync_recovery 恢复和错误提示,不得执行用户代码。新创建的线程会通过 Gateway 全局 ComputeClient 可用性检查重新判断,若服务恢复则新线程可正常使用 compute。
### 2.2 k8s-exec-service-mcp 启动序列
1. 加载配置文件(YAML),读取端口、`backend_mode`、镜像白名单、资源档位、调度策略、本地 runtime、Kubernetes namespace/PVC/emptyDir/node selector/tolerations、对象存储 endpoint/bucket/credential 等参数。
2. 初始化持久化存储(P0 SQLite WAL),执行 schema migration(幂等)。
3. 初始化 ResourceManager,从持久化存储加载 EnvironmentRegistry、BackendHealth、已有 pending_usage 记录。
4. 初始化 Scheduler、UsageRecorder,注入持久化存储引用。
5. 若启用 LocalBackend:校验本地 runtime(Docker/Podman/受控进程沙箱)、runtime root、磁盘配额、并发限制和安全策略。
6. 若启用 KubernetesBackend:初始化 Kubernetes client;集群内使用 ServiceAccount,集群外使用 kubeconfig。
7. 若启用 KubernetesBackend:校验目标 namespace、ServiceAccount、Role/RoleBinding、ResourceQuota、LimitRange、NetworkPolicy 是否存在;缺失时启动失败并给出明确错误。
8. 初始化 ObjectStorageClient,校验 bucket 可访问、最小权限可用、禁止匿名读写。
9. 创建 ComputeService 实例(认证、工具端点编排、pending usage、管理 API),底层生命周期操作统一委托给 ResourceManager。
10. 启动 HTTP server,监听配置的 host:port。
11. 初始化 WarmPoolManager,按配置为允许的 image/resource_class/backend_type 创建 P0 预热环境;预热环境不得包含用户数据、auth_context、input_objects 或 artifact_prefix。
12. 启动后台清理协程(回收 idle timeout environment、写入 pending_usage 到持久化存储,并维护 warm pool min/max/TTL)。
13. 启动后端健康检查协程,周期性刷新 LocalBackend/KubernetesBackend 的容量、错误率和可调度状态。
14. 启动镜像预拉取/节点池健康检查;P0 默认配置必须启用 warm environment pool,并至少保证 `small + 默认 Python 执行镜像` 的一个 pool `min_idle >= 1`。`min_idle=0` 只允许作为显式维护/资源紧张降级配置,降级期间 `/ready` checks 必须暴露 warm pool 未达标状态。
`backend_mode`:
| 值 | 含义 |
|----|------|
| local | 只启用 LocalBackend |
| kubernetes | 只启用 KubernetesBackend |
| auto | 同时启用多个后端,由 Scheduler 选择 |
### 2.3 k8s-exec-service-mcp 数据持久化设计
k8s-exec-service-mcp 必须持久化以下状态:
| 数据 | 存储要求 | 说明 |
|------|---------|------|
| Environment 映射 | 持久化,crash-safe | environment_id → backend_type / backend_id / resource_ref / user_id / thread_id / state / created_at / last_active_at / resource_class |
| Pending usage | 持久化,crash-safe | destroy 或 idle-timeout 时写入,poll_loop 结算后标记 settled |
| Backend registry | 持久化或配置驱动 | backend_id / type / enabled / drain / maintenance 状态 |
| Admin audit log | 持久化 | 管理操作记录 |
| Nonce store | 持久化或共享缓存 | 防重放 `(user_id, nonce)`,TTL 不小于 auth_context TTL |
**存储后端选型:**
- **唯一存储后端**:SQLite(WAL 模式),数据库文件路径由配置文件指定。适合单机部署和本地 Kubernetes(使用宿主持久目录或 local-path PV)。
- **配置方式**:`storage.dsn` 固定使用 `sqlite:///data/exec_service.db` 或其他本地 SQLite 文件路径。
**一致性保证:**
- 单实例:所有状态的读写均为单进程内操作。SQLite WAL 模式提供 crash-safe 保证。
- 不支持多副本写入同一个 SQLite 数据库;k8s-exec-service-mcp 必须以单实例运行。
- 服务重启:从持久化存储恢复所有 environment 映射和 pending_usage。若重启后发现 backend 侧资源(Pod/容器)已不可达,标记该 environment 为 orphan 并触发清理。
### 2.4 k8s-exec-service-mcp 数据库 schema
以下 schema 属于 k8s-exec-service-mcp 独立 SQLite 数据库,不写入 EvoScientist Gateway 主库。所有 migration 只需面向 SQLite WAL 实现。
**exec_environments 表:**
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| environment_id | TEXT | PK | 环境 ID |
| backend_type | TEXT | NOT NULL | `local` 或 `kubernetes` |
| backend_id | TEXT | NOT NULL | 后端实例 ID |
| resource_ref | TEXT | NULL | 本地 container_id/process_id 或 `namespace/pod_name`;CREATING 阶段允许为空,后端资源创建成功后写入 |
| user_id | TEXT | NULL | EvoScientist 用户 UID;WARMING/WARM_IDLE 未绑定用户时为 NULL,进入 WARM_BINDING/RUNNING 前必须写入 |
| thread_id | TEXT | NULL | 线程 ID;WARMING/WARM_IDLE 未绑定用户时为 NULL,进入 WARM_BINDING/RUNNING 前必须写入 |
| resource_class | TEXT | NOT NULL | 资源档位 |
| image | TEXT | NOT NULL | 执行镜像 |
| state | TEXT | NOT NULL | `WARMING/WARM_IDLE/WARM_BINDING/CREATING/RUNNING/STOPPING/STOPPING_FAILED/STOPPED/ORPHAN/FAILED` |
| created_at | TIMESTAMP | NOT NULL | 创建时间 |
| started_at | TIMESTAMP | NULL | 后端 ready 时间 |
| last_active_at | TIMESTAMP | NOT NULL | 最近操作时间 |
| stopped_at | TIMESTAMP | NULL | 停止时间 |
| max_runtime_seconds | INTEGER | NULL | 运行上限 |
| command_count | INTEGER | DEFAULT 0 | 命令次数 |
| budget_snapshot | TEXT | NULL | JSON 字符串,包含 Gateway preflight 计算出的 affordable_runtime_seconds/effective_max_runtime_seconds 等审计字段 |
| artifact_prefix | TEXT | NULL | 本 environment 允许写入的 artifact 前缀 |
| warm_pool_key | TEXT | NULL | 预热池 key,格式为 backend_type/backend_id/resource_class/image |
| warm_created_at | TIMESTAMP | NULL | 预热资源创建时间 |
| warm_last_used_at | TIMESTAMP | NULL | 最近一次从预热池绑定的时间 |
| warm_generation | INTEGER | DEFAULT 0 | 预热池配置代次,用于滚动刷新旧预热环境 |
| output_manifest_json | TEXT | NULL | 已上传 artifact manifest |
| artifact_upload_error | TEXT | NULL | 最近一次 artifact 上传错误 |
| artifact_retry_count | INTEGER | DEFAULT 0 | artifact 上传失败后的自动重试次数 |
| next_retry_at | TIMESTAMP | NULL | STOPPING_FAILED 自动重试时间;cleanup loop 按该字段扫描 |
| artifact_upload_max_retries | INTEGER | NULL | 单 environment 重试上限;NULL 使用全局默认 |
| artifacts_lost | BOOLEAN | DEFAULT false | 是否因 force_destroy 放弃输出 |
| force_destroy_at | TIMESTAMP | NULL | 强制销毁时间 |
| metadata_json | TEXT | NULL | labels、mounts、runtime 细节 |
状态约束:
- `WARMING/WARM_IDLE`:`user_id`、`thread_id`、`artifact_prefix`、`budget_snapshot` 必须为 NULL;不得包含用户数据、auth_context 或 input_objects。
- `WARM_BINDING`:正在把预热环境原子绑定到用户;绑定事务必须写入 `user_id`、`thread_id`、`artifact_prefix`、`budget_snapshot` 并执行 workspace reset/input hydration。
- `CREATING/RUNNING/STOPPING/STOPPING_FAILED/STOPPED/ORPHAN/FAILED`:`user_id`、`thread_id` 必须非 NULL。
- 普通用户的 list/status 查询不得返回 WARMING/WARM_IDLE;仅 admin 可见。
索引:
- `idx_exec_env_user_state` ON (user_id, state)
- `idx_exec_env_backend_state` ON (backend_type, backend_id, state)
- `idx_exec_env_thread` ON (thread_id)
- `idx_exec_env_last_active` ON (last_active_at)
- `idx_exec_env_retry` ON (state, next_retry_at)
- `idx_exec_env_warm_pool` ON (state, warm_pool_key, warm_generation)
**exec_warm_pools 表:**
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| pool_key | TEXT | PK | backend_type/backend_id/resource_class/image 组合 |
| backend_type | TEXT | NOT NULL | local 或 kubernetes |
| backend_id | TEXT | NOT NULL | 后端实例 ID |
| resource_class | TEXT | NOT NULL | 资源档位 |
| image | TEXT | NOT NULL | 预热执行镜像 |
| min_idle | INTEGER | NOT NULL | 最小空闲预热环境数 |
| max_idle | INTEGER | NOT NULL | 最大空闲预热环境数 |
| current_idle | INTEGER | DEFAULT 0 | 当前空闲预热环境数 |
| current_warming | INTEGER | DEFAULT 0 | 当前创建中的预热环境数 |
| enabled | BOOLEAN | DEFAULT true | 是否启用该预热池 |
| generation | INTEGER | DEFAULT 0 | 配置代次,配置变更时递增 |
| warm_ttl_seconds | INTEGER | NOT NULL | 单个预热环境最大存活时间 |
| idle_timeout_seconds | INTEGER | NOT NULL | 空闲预热环境超时回收时间 |
| created_at | TIMESTAMP | NOT NULL | 创建时间 |
| updated_at | TIMESTAMP | NOT NULL | 更新时间 |
索引:
- `idx_warm_pools_backend` ON (backend_type, backend_id, enabled)
- `idx_warm_pools_resource` ON (resource_class, image, enabled)
**exec_pending_usage 表:**
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | INTEGER | PK | 自增主键 |
| environment_id | TEXT | UNIQUE, NOT NULL | 幂等键 |
| user_id | TEXT | NOT NULL | 用户 UID |
| thread_id | TEXT | NOT NULL | 线程 ID |
| backend_type | TEXT | NOT NULL | 后端类型 |
| resource_class | TEXT | NOT NULL | 资源档位 |
| runtime_seconds | REAL | NOT NULL | 运行秒数 |
| command_count | INTEGER | NOT NULL | 命令次数 |
| usage_estimated | BOOLEAN | DEFAULT false | 是否估算 |
| estimate_reason | TEXT | NULL | 估算原因 |
| usage_start_at | TIMESTAMP | NOT NULL | 计费起点 |
| usage_end_at | TIMESTAMP | NOT NULL | 计费终点 |
| output_manifest_json | TEXT | NULL | destroy 成功时返回的 artifact manifest,供 Gateway 崩溃后补注册 |
| artifacts_registered | BOOLEAN | DEFAULT false | Gateway 是否已将 artifacts 注册到 StorageService 元数据表 |
| artifacts_lost | BOOLEAN | DEFAULT false | 是否发生 force_destroy 或资源丢失导致 artifact 丢失 |
| artifact_registration_retry_count | INTEGER | DEFAULT 0 | Gateway 注册 artifact metadata 失败重试次数 |
| artifact_registration_error | TEXT | NULL | 最近一次 artifact metadata 注册错误 |
| artifacts_unregistered | BOOLEAN | DEFAULT false | 超过注册重试上限后仍未能写入 Gateway file metadata,但 compute usage 仍需结算 |
| settled | INTEGER | DEFAULT 0 | 0 未结算,1 已结算 |
| created_at | TIMESTAMP | NOT NULL | 创建时间 |
| settled_at | TIMESTAMP | NULL | 标记已结算时间 |
索引:
- `idx_pending_usage_settled_created` ON (settled, created_at)
- `idx_pending_usage_user_created` ON (user_id, created_at)
**exec_backends 表:**
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| backend_id | TEXT | PK | 后端 ID |
| backend_type | TEXT | NOT NULL | `local` 或 `kubernetes` |
| enabled | BOOLEAN | DEFAULT true | 是否启用 |
| drain | BOOLEAN | DEFAULT false | 是否停止接收新环境 |
| maintenance | BOOLEAN | DEFAULT false | 是否维护中 |
| healthy | BOOLEAN | DEFAULT false | 最近健康状态 |
| capacity_json | TEXT | NULL | 容量信息 |
| resource_classes_json | TEXT | NULL | 支持档位 |
| last_error | TEXT | NULL | 最近错误 |
| updated_at | TIMESTAMP | NOT NULL | 更新时间 |
**exec_admin_audit_log 表:**
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | INTEGER | PK | 自增主键 |
| request_id | TEXT | NOT NULL | 请求 ID |
| operation | TEXT | NOT NULL | 管理操作 |
| target_type | TEXT | NULL | environment/backend/storage 等 |
| target_id | TEXT | NULL | 目标 ID |
| actor | TEXT | NOT NULL | admin token 对应主体或 `admin` |
| payload_json | TEXT | NULL | 脱敏后的参数 |
| status | TEXT | NOT NULL | success/failed |
| created_at | TIMESTAMP | NOT NULL | 创建时间 |
**exec_auth_nonces 表:**
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| user_id | TEXT | NOT NULL | 用户 UID |
| nonce | TEXT | NOT NULL | nonce |
| issued_at | INTEGER | NOT NULL | Unix 秒 |
| expires_at | TIMESTAMP | NOT NULL | 过期时间 |
| created_at | TIMESTAMP | NOT NULL | 写入时间 |
约束和索引:
- PRIMARY KEY (user_id, nonce)
- `idx_auth_nonces_expires_at` ON (expires_at)
**事务边界:**
- create_environment:ResourceManager 先调用 Scheduler 选择后端和 warm_pool_key。若存在可用 `WARM_IDLE` 预热环境,必须在同一 SQLite 事务中把该记录从 `WARM_IDLE` 置为 `WARM_BINDING`,写入 user_id/thread_id/artifact_prefix/budget_snapshot/max_runtime_seconds,并在绑定成功后执行 input hydration、挂载路径初始化和 auth_context 绑定,最后置为 `RUNNING`。若没有可用预热环境,按冷启动路径先写入 `CREATING` 记录(包含 environment_id、user_id、thread_id、backend_type/backend_id、resource_class、image、artifact_prefix、created_at、last_active_at;`resource_ref` 允许为空),再创建后端资源并执行 input hydration;资源创建和 hydration 成功后更新 `resource_ref`、`started_at`、`state=RUNNING`。若创建或绑定失败,更新 `state=FAILED` 并记录错误,同时清理已创建或已污染的临时资源;预热绑定失败的资源不得放回 warm pool。服务启动恢复时必须扫描超时的 `WARMING/WARM_BINDING/CREATING/STOPPING/STOPPING_FAILED` 记录并执行恢复或 cleanup。
- destroy_environment / cleanup:必须先把 environment 置为 STOPPING;若 `persist_outputs=true`,先上传 `output_paths` 或默认输出清单到对象存储并写入 `output_manifest_json`。artifact 上传成功后才允许销毁后端资源、写入 `exec_pending_usage` 并置为 STOPPED。若 artifact 上传失败且 `force_destroy=false`,置为 `STOPPING_FAILED`,保留后端资源和运行期 workspace,写入 `artifact_upload_error`、递增 `artifact_retry_count` 并设置 `next_retry_at`。若 `force_destroy=true`,允许跳过 artifact 保留,设置 `artifacts_lost=true`、`force_destroy_at`,随后销毁资源并写 pending_usage。重复调用由 `environment_id` 唯一约束保证 pending_usage 幂等。
- pending_usage 写入:必须复制 environment 的 `output_manifest_json`、`artifacts_lost` 到 `exec_pending_usage`,使 Gateway 在 destroy 响应丢失或自身崩溃后仍可通过 poll_loop 补注册 artifacts。
- mark-settled:只更新 `exec_pending_usage.settled/settled_at`,不得删除记录。
- nonce 校验:验证签名前检查时间窗口;签名通过后插入 `(user_id, nonce)`,若唯一冲突则返回 401。后台任务定期删除 `expires_at < now()` 的 nonce。
### 2.5 统一对象存储与文件元数据
统一文件内容层采用 S3 兼容对象存储。对象存储保存文件 bytes;EvoScientist Gateway 数据库保存文件元数据、权限、归属和生命周期。k8s-exec-service-mcp 不直接读取 Gateway 本地路径,也不维护用户文件目录树。
**推荐部署:**
| 场景 | 对象存储实现 | 说明 |
|------|--------------|------|
| 本地开发 / 私有单机 | MinIO | 随 EvoScientist 或独立 docker compose 启动 |
| 本地 Kubernetes | MinIO + PVC | MinIO 数据卷持久化,Gateway 和 k8s-exec-service-mcp 通过 Service 访问 |
| 云上部署 | S3 / OSS / COS / OBS / R2 | 通过 S3 兼容 SDK 接入 |
**EvoScientist StorageService 职责:**
- 管理 `file_id -> object_key` 映射。
- 校验用户权限、线程/项目归属和配额。
- 生成输入 manifest,供 k8s-exec-service-mcp 按授权读取。
- 接收 artifact manifest,写入用户文件或结果记录。
- 按业务策略执行文件生命周期清理;也可以下发对象存储 lifecycle policy。
**EvoScientist StorageService P0 后端:**
| 后端 | 用途 | 要求 |
|------|------|------|
| LocalStorageBackend | 开发、CLI、迁移期、非计算文件管理 | 仍使用受控本地目录;必须通过 StorageService API 访问,不允许业务代码直接拼本地路径;不得用于 Web 远程 compute 的 input manifest |
| S3StorageBackend | Web 远程 compute P0 必选路径 | 支持 MinIO/S3/OSS/COS;提供 put/get/delete/head/list、sha256 校验、对象大小限制、prefix 隔离 |
Web 远程 compute 的 P0 前置条件是 `STORAGE_BACKEND=s3` 且对象存储可用。本地部署默认使用 MinIO 满足该条件。若 `STORAGE_BACKEND=local`,Web 线程可以继续对话、上传/管理文件和 CLI/迁移用途,但任何 Web 用户代码执行必须返回 `ComputeUnavailable` 或 `StorageBackendUnsupportedForCompute`,不得生成只含本地相对路径的 input manifest。
StorageService 的接口必须稳定,调用方不得依赖具体存储实现:
```python
class StorageService:
async def put_file(self, owner_user_id: str, logical_path: str, data: bytes, *, thread_id: str | None = None, project_id: str | None = None) -> FileRecord: ...
async def get_file(self, file_id: str, requester_user_id: str) -> FileRecord: ...
async def open_read(self, file_id: str, requester_user_id: str) -> AsyncIterator[bytes]: ...
async def delete_file(self, file_id: str, requester_user_id: str) -> None: ...
async def build_input_manifest(self, user_id: str, thread_id: str, paths: list[str]) -> list[InputObject]: ...
async def register_artifacts(self, user_id: str, thread_id: str, artifacts: list[ArtifactObject]) -> list[FileRecord]: ...
```
**对象存储配置(EvoScientist Gateway):**
```env
STORAGE_BACKEND=s3
STORAGE_S3_ENDPOINT=http://127.0.0.1:9000
STORAGE_S3_BUCKET=evoscientist
STORAGE_S3_REGION=us-east-1
STORAGE_S3_ACCESS_KEY=...
STORAGE_S3_SECRET_KEY=...
STORAGE_S3_FORCE_PATH_STYLE=true
STORAGE_LOCAL_CACHE_DIR=/data/evoscientist/cache
STORAGE_MAX_FILE_SIZE_BYTES=1073741824
STORAGE_RETENTION_DAYS=0
```
`STORAGE_RETENTION_DAYS=0` 表示用户文件默认不过期;临时文件、导入中间文件和任务 scratch object 必须使用明确的 `expires_at` 或对象存储 lifecycle policy。
**k8s-exec-service-mcp ObjectStorageClient 职责:**
- 只读取 `auth_context` 或 create_environment 请求中授权的 `input_objects`。
- 将输入对象下载到当前 environment 的运行期 workspace。
- 将输出文件上传到 `artifacts/{job_id}/...` 或 EvoScientist 指定的 artifact prefix。
- 返回 artifact manifest,不写 EvoScientist Gateway 数据库。
- artifact 上传成功、`persist_outputs=false` 或 `force_destroy=true` 后删除运行期 workspace;STOPPING_FAILED 状态必须保留 workspace 以便重试。不删除对象存储中的用户文件或 artifact。
**对象存储配置(k8s-exec-service-mcp):**
```yaml
object_storage:
backend: s3
endpoint: http://127.0.0.1:9000
bucket: evoscientist
region: us-east-1
access_key: ${EXEC_OBJECT_STORAGE_ACCESS_KEY}
secret_key: ${EXEC_OBJECT_STORAGE_SECRET_KEY}
force_path_style: true
artifact_prefix: jobs/
max_input_object_bytes: 1073741824
max_artifact_object_bytes: 1073741824
```
k8s-exec-service-mcp 的对象存储凭证必须限定在执行所需 bucket/prefix 范围内。生产环境优先使用临时凭证、IAM Role、STS 或 workload identity;本地 MinIO 可使用固定 access key,但必须只放在 `.env` 或 Kubernetes Secret 中。
### 2.5.1 Bucket 生命周期与探活
**Bucket 创建者:**
- 本地 MinIO:由 `deploy/local-standalone/run-minio.sh` 或 `deploy/local-k8s/minio.yaml` 负责。脚本/容器启动后通过 MinIO Client (`mc mb`) 或 MinIO Admin API 创建 `evoscientist` bucket。若 bucket 已存在则跳过。
- 云上 S3/OSS/COS:由运维人员通过云控制台或 Terraform/IaC 预先创建。k8s-exec-service-mcp 和 Gateway 均不自动创建 bucket(安全策略:禁止服务账号持有 CreateBucket 权限)。
**Gateway 侧探活(lifespan 阶段):**
1. 若 `STORAGE_BACKEND=s3`:
a. 执行 HeadBucket,确认 bucket 存在且可访问。
b. 写入一个 probe object(key=`health/probe-{gateway_instance_id}`,内容=`"ok"`,带 60s expires_at),然后立即读取并校验内容一致性。
c. 删除 probe object。
d. 若任一步失败,StorageService 标记为 degraded,记录 ERROR 日志。P0 策略:degraded 时拒绝创建新的 compute environment(后续 /ready 失败);已有文件读写仅可通过 StorageService 可用后端或非计算缓存能力继续,仍不得本地执行用户计算。
2. 若 `STORAGE_BACKEND=local`:校验本地存储根目录存在且可读写,写入临时文件验证后删除。
**k8s-exec-service-mcp 侧探活(/ready 端点):**
- `/ready` 响应中包含对象存储状态字段:
```json
{
"ready": true,
"checks": {
"sqlite": "ok",
"object_storage": {
"status": "ok",
"bucket": "evoscientist",
"endpoint": "http://minio:9000",
"probe_duration_ms": 12
},
"backends": {"local": "healthy", "kubernetes": "healthy"},
"warm_pools": {
"required_default_pool": "ok",
"idle": 1
}
}
}
```
- object_storage 不可达时 `/ready` 返回 503,object_storage.status 为 `"unreachable"` 并附带错误信息。Gateway 将 ComputeClient 标记为不可用。
**对象 key 规范:**
```text
users/{user_id}/files/{file_id}
projects/{project_id}/files/{file_id}
threads/{thread_id}/files/{file_id}
jobs/{job_id}/artifacts/{artifact_id}
tmp/{job_id}/{object_id}
```
object key 不作为权限依据,权限必须来自 EvoScientist 文件元数据和签名授权。k8s-exec-service-mcp 必须拒绝未出现在授权 manifest 中的 object key,不能接受用户直接传入任意 bucket/key 进行读取。
**EvoScientist 文件元数据表(Gateway 数据库,设计要求):**
| 列名 | 类型 | 说明 |
|------|------|------|
| file_id | TEXT | 文件 ID,主键 |
| owner_user_id | TEXT | 所属用户 |
| thread_id | TEXT NULL | 线程归属 |
| project_id | TEXT NULL | 项目归属 |
| logical_path | TEXT | 用户可见路径 |
| object_key | TEXT | 对象存储 key |
| sha256 | TEXT | 内容校验 |
| size_bytes | INTEGER | 文件大小 |
| mime_type | TEXT NULL | 文件类型 |
| source | TEXT | upload / agent / artifact / import |
| created_at | TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | 更新时间 |
| expires_at | TIMESTAMP NULL | 业务生命周期到期时间 |
**执行输入 manifest:**
```json
{
"input_objects": [
{
"file_id": "file_123",
"object_key": "users/u1/files/file_123",
"sha256": "ab...",
"size_bytes": 1024,
"mount_path": "/workspace/input/data.csv",
"read_only": true
}
]
}
```
### 2.5.2 build_input_manifest 算法
`StorageService.build_input_manifest(user_id, thread_id, paths)` 按以下规则生成输入清单:
**输入:**
- `user_id`:当前用户 UID。
- `thread_id`:当前 Web 线程 ID。
- `paths`:Agent 需要的路径列表,来自 CompositeBackend 的路由解析,
典型值:`["/workspace", "/__global__", "/threads/{other_thread_id}"]`。
**输出:** `list[InputObject]`,每个包含 file_id、object_key、sha256、size_bytes、mount_path、read_only。
**算法:**
1. 初始化 `input_objects = []`。
2. 对 paths 中的每个 path,按前缀分发:
a. `/workspace` → 查询 user_files WHERE owner_user_id=$user_id AND thread_id=$thread_id AND status='active'
→ 每个匹配文件生成 InputObject,mount_path 保持原 logical_path,read_only=False。
b. `/__global__` → 查询 user_files WHERE owner_user_id=$user_id AND thread_id IS NULL AND project_id IS NULL AND status='active'
→ mount_path = "/__global__/{logical_path}",read_only=False。
c. `/threads/{other_id}` → 查询 user_files WHERE thread_id=$other_id AND status='active'
→ mount_path = "/threads/{other_id}/{logical_path}",read_only=True。
→ **权限校验:** 必须先检查当前 user_id 是否对该线程有只读权限(通过 thread 归属关系)。
3. 对每个文件校验 file_id → object_key 映射存在且 object 在对象存储中可 head。
4. 若 object 不可达(404)或 sha256/size 与 metadata 不一致,按文件来源处理:
a. 对用户显式请求的 `/workspace`、`/__global__`、`/threads/{other_id}` 文件,返回 `InputObjectMissing` 或 `InputObjectCorrupt` 错误,阻止 create_environment,避免远程执行在缺输入的情况下继续运行。
b. 对 P1 可选派生文件或非关键缓存文件,才允许跳过;跳过项必须写入 `skipped_inputs` 并返回给调用方,由 Gateway 记录用户可见 WARNING。
c. P0 默认所有 active user_files 都是关键输入,不做静默跳过。
5. 对 input_objects 按 mount_path 排序(保证父目录先于子目录)。
6. 若 input_objects 为空,返回空列表(后续 create_environment 仍可成功,只是 workspace 初始为空)。
7. 记录返回的 input_objects 总数和总 size_bytes 到 INFO 日志。
**存储后端差异:**
- LocalStorageBackend:object_key 为本地相对路径,仅可用于 CLI、迁移和非计算文件管理。Web 远程 compute 调用 `build_input_manifest()` 时若发现当前后端为 LocalStorageBackend,P0 必须返回 `StorageBackendUnsupportedForCompute` 并阻止 create_environment;不得把 Gateway 本地相对路径交给 k8s-exec-service-mcp。
- S3StorageBackend/MinIO:Web 远程 compute 的 P0 必选后端。若文件尚未迁移到对象存储,P0 返回 `InputObjectMissing` 并阻止 create_environment,不静默跳过。
**缓存策略(P0):**
- P0 不缓存 input_objects 计算结果。每次 Agent 启动时重新查询。
- P1 可基于 thread_id + user_files.updated_at 做增量缓存。
**执行输出 artifact manifest:**
```json
{
"artifacts": [
{
"artifact_id": "artifact_123",
"object_key": "jobs/job_456/artifacts/report.md",
"sha256": "cd...",
"size_bytes": 2048,
"logical_path": "/workspace/report.md"
}
]
}
```
**端到端数据流:**
1. 用户上传文件到 EvoScientist。
2. Gateway 调用 StorageService,写入对象存储并在 Gateway 数据库记录 file metadata。
3. Agent 需要远程执行时,StorageService 根据当前 thread/global/readonly thread 视图生成 input manifest。
4. Gateway 调用 create_environment,传入 input_objects、artifact_prefix、resource_class、budget_snapshot 和已裁剪的 max_runtime_seconds。
5. k8s-exec-service-mcp 校验 auth_context、object_scope、sha256 和 size 限制。
6. BackendAdapter 创建本地容器或 Kubernetes Pod(或从 warm pool 绑定预热环境),确认运行期 workspace 可写。
7. ObjectStorageClient 下载 input_objects 到运行期 workspace。
8. 在环境内执行命令和运行期文件 I/O。
9. destroy_environment 时上传 output_paths 或默认 artifact 清单到对象存储。
10. k8s-exec-service-mcp 返回 usage + artifact manifest。
11. Gateway 调用 StorageService.register_artifacts() 入库,用户可以在文件列表或线程结果中看到 artifact。
12. artifact 上传成功、`persist_outputs=false` 或 `force_destroy=true` 后,k8s-exec-service-mcp 删除运行期 workspace、临时 PVC 或 emptyDir;STOPPING_FAILED 时保留 workspace 等待重试。
**失败处理:**
| 阶段 | 失败 | 处理 |
|------|------|------|
| StorageService 上传 | 对象存储不可用 | 用户上传失败;Gateway 返回明确错误,不创建 file metadata |
| create_environment | input object 不存在或 sha256 不匹配 | 返回 400/409;不创建 RUNNING environment;已创建的临时资源必须清理 |
| create_environment | 对象存储暂时不可用 | 返回 503;Gateway 不得本地执行用户计算,只能提示稍后重试或进入非计算文件降级。降级期间产生的文件编辑必须进入 StorageService/fallback_sync_recovery 后才能再次进入 compute |
| 执行期间 | read/write 运行期文件失败 | 返回工具错误;environment 保持 RUNNING,允许后续命令或 destroy |
| destroy_environment | artifact 上传失败 | 返回 502/503 且 environment 进入 STOPPING_FAILED;Gateway 必须重试 destroy 或调用 force_destroy |
| force_destroy | artifact 仍无法上传 | 销毁计算资源并写 pending_usage;返回 `artifacts_lost=true`,审计日志必须记录 |
| StorageService artifact 入库失败 | 对象已上传但 metadata 未写入 | Gateway 使用 artifact manifest 重试入库;不得要求 k8s-exec-service-mcp 重新执行任务 |
P0 默认策略是不丢结果:`destroy_environment(persist_outputs=true)` 上传失败时不得删除 workspace。只有显式 `force_destroy=true` 才允许放弃 artifact 并强制释放计算资源。
STOPPING_FAILED 重试机制:
- k8s-exec-service-mcp 的 cleanup loop 必须扫描 `state='STOPPING_FAILED'` 且 `next_retry_at <= now()` 的 environment。
- 每次重试重新执行 artifact upload;成功后销毁后端资源、写 pending_usage、置为 STOPPED。
- 重试退避:`retry_delay = min(300s, 2 ** artifact_retry_count * 10s)`;`artifact_retry_count` 和 `next_retry_at` 存入 `exec_environments` 显式字段,便于 SQLite 索引扫描和 admin API 展示。
- 超过 `artifact_upload_max_retries`(默认 10)后保持 STOPPING_FAILED,不再自动重试,通过 admin API 暴露并等待用户或管理员 `force_destroy`。
- Gateway 也可以显式再次调用 destroy_environment;该调用应复用同一 environment 状态并触发一次立即重试。
权限校验分层:
- Gateway 在调用 StorageService.build_input_manifest() 前必须基于 user_files metadata 校验用户是否拥有或可读对应文件。
- Gateway 生成 auth_context.object_scope,并用 HMAC 覆盖 object_scope、input_objects、artifact_prefix。
- k8s-exec-service-mcp 不访问 Gateway 数据库,只校验 HMAC、TTL、nonce、object_scope、object key 是否在授权范围、sha256 和 size。
---
## 3. 约束清单
所有约束的编号在后续章节中被引用。
| # | 类别 | 约束内容 |
|----|------|---------|
| C1 | 接口 | ContainerSandboxBackend 必须实现 BackendProtocol 的全部方法(见附录 A) |
| C2 | 路由 | CompositeBackend 路由为 default + /skills/ + /memory/,compute 仅影响 default。skills/memory 路径由 MCP tools 注入的 CustomSandboxBackend 处理,不经过 ContainerSandboxBackend |
| C3 | 加载 | compute 不通过 MultiServerMCPClient 加载;使用独立的 HTTP client |
| C4 | 隔离 | Web 多根:/workspace rw, /__global__ rw, /threads/{thread_id} ro |
| C5 | 范围 | CompositeBackend 路由只影响文件 I/O;execute() 始终在远程执行环境内执行 |
| C6 | 一致性 | /__global__、线程文件和 artifact 的事实来源是 EvoScientist StorageService 元数据 + 对象存储;远程执行环境只能使用授权 manifest 的运行期副本 |
| C7 | 数据库 | 数据库访问全部异步,通过 get_connection() 获取 |
| C8 | 兼容 | create_cli_agent() 返回 Agent 对象(不改为 dict),backend 通过 _backend_ref 出参传出;新参数只能追加在签名末尾且有默认值 |
| C9 | 连接 | 数据库连接不手动 close,由 lifespan shutdown 统一调用 close_gateway_db() |
| C10 | 隔离 | compute 工具不暴露给 Agent tool list(HTTP 独立通道天然隔离) |
| C11 | 加载 | _load_mcp_tools_cached() 保持现有签名不变,不需要 exclude_servers 参数 |
| C12 | 注入 | StreamHandler 和 billing 函数不 from gateway.main import app,通过参数接收 app_state 和 db |
| C13 | 幂等 | settle_compute_usage() 必须在同一个数据库事务内完成结算权声明、余额/配额扣减、wallet_ledger 写入和 compute_usage_log 最终更新;只有完整事务提交后才可返回 already_settled |
| C14 | 顺序 | Gateway lifespan 中:secret 初始化可在 init_gateway_db() 前,但 get_connection()、ComputeClient 初始化、后台任务注册必须在 init_gateway_db() 之后 |
| C15 | 事务 | settle_compute_usage() 使用独立数据库事务连接(Gateway 侧为 asyncpg.Connection,k8s-exec-service-mcp 侧为 aiosqlite),不在共享连接的 asyncio.Lock 上嵌套加锁;禁止先提交 compute_usage_log claim 再异步扣款 |
| C16 | 审计 | compute 扣费后必须写入 wallet_ledger,与 token 计费审计标准一致 |
| C17 | 可用性 | Gateway 通过 GET /ready 检查 k8s-exec-service-mcp 是否可调度;GET /health 只用于 liveness。poll_loop 额外检查管理端点可达性。Compute 不可用时 Web 线程返回 ComputeUnavailable 或非计算只读降级,不允许本地执行用户计算 |
| C18 | 幂等 | 所有 DB migration 必须可重复执行:ADD COLUMN/CREATE TABLE/CREATE INDEX 使用 IF NOT EXISTS。Gateway(PostgreSQL)ALTER COLUMN TYPE 先查 information_schema 再执行 USING column::target_type 保留已有值;k8s-exec-service-mcp(SQLite)类型变更采用新表重建策略:创建临时表→拷贝→重命名→重建索引,幂等执行 |
| C19 | 精度 | 所有金额字段统一 NUMERIC(12,6),Python 侧用 Decimal 计算,禁止 float 参与扣款 |
| C20 | 配额 | pricing.json 必须包含 compute_pricing(含 overtime_price_per_minute、currency)和每个 plan 的 monthly_compute_minutes;现有用户 backfill;订阅/套餐变更/月度刷新均需更新配额 |
| C21 | 资源控制面 | P0 必须实现 ResourceManager,统一管理 LocalBackend 与 KubernetesBackend 的生命周期、映射、调度、健康和用量记录 |
| C22 | 调度 | create_environment 只允许传入 backend_policy/resource_class 等受控参数;image、cpu/memory、workspace/global/read-only subpaths、artifact_prefix 均由 Gateway 或执行服务端按 allowlist/resource_class 生成,不接受用户原始 Kubernetes spec、本机命令模板、宿主机路径或任意对象存储前缀 |
| C23 | 环境映射 | environment_id 必须稳定映射到 backend_type/backend_id/resource_ref/user_id/thread_id/resource_class;所有后续操作必须通过该映射做 ownership 校验。该映射必须持久化 |
| C24 | 存储 | /workspace、/__global__、/threads/{thread_id} 在执行环境内只作为运行期工作目录挂载;所有 logical_path/mount_path/remote_path 必须先经过统一路径规范化;P0 不允许用户指定任意 hostPath、宿主机绝对路径、路径穿越或只读前缀写入 |
| C25 | Kubernetes 安全 | 执行 Pod 必须采用 restricted baseline:non-root、禁止 privileged/hostNetwork/hostPID/hostIPC/hostPath、禁用 privilege escalation、automountServiceAccountToken=false、drop ALL capabilities、seccompProfile=RuntimeDefault,并受 ResourceQuota/LimitRange/NetworkPolicy 约束 |
| C26 | 本地部署 | P0 必须提供本地单机部署与 kind/k3d 本地 Kubernetes 部署脚本,包含镜像构建、配置生成、health 检查和 smoke test |
| C27 | 管理 | k8s-exec-service-mcp 必须提供 admin API 管理 environments、pending usage、resource classes、maintenance mode 和 cleanup |
| C28 | 监测 | k8s-exec-service-mcp 必须暴露 health/readiness/metrics/logging;Prometheus metrics 为 P0 |
| C29 | 统计 | k8s-exec-service-mcp 必须提供按 user/thread/plan/resource_class 聚合的运行统计和用量统计 |
| C30 | LocalBackend 安全 | LocalBackend 是 k8s-exec-service-mcp 计算资源侧后端,P0 Web 用户命令默认必须通过 rootless Docker/Podman 或等效容器隔离;受控本地进程沙箱仅限可信开发并默认禁止 Web 使用。必须使用受控 runtime root、资源限制、并发限制、路径归一化和网络策略;不得把用户命令直接放到宿主机裸 shell 执行;不得在 EvoScientist Gateway 本机执行 Web 用户计算 |
| C31 | 扩展 | 云上弹性优先通过 Kubernetes node pool / Cluster Autoscaler / Karpenter 实现;混合资源通过 ResourceManager 策略接入,不在 EvoScientist 主程序中实现调度 |
| C32 | 持久化 | k8s-exec-service-mcp 的 environment 映射和 pending_usage 必须持久化到数据库(P0: SQLite WAL),服务重启后完整恢复,不得丢失未结算记录 |
| C33 | 持久化 | 服务启动时从持久化存储恢复 environment 映射;若后端资源已不可达,标记为 orphan 并触发清理流程,写入 pending_usage |
| C34 | 认证 | HMAC nonce 必须通过 exec_auth_nonces 或共享缓存防重放;生产 P0 不允许仅依赖进程内 nonce cache |
| C35 | 可用性 | Gateway 只能以 /ready 作为 compute 可调度判断;/health 只能作为 liveness,不得用于选择 compute 路径 |
| C36 | 文件传输 | P0 upload/download 只支持 JSON base64 + sha256,不接受 Gateway local_path;multipart/streaming 属于 P1 |
| C37 | 存储职责 | k8s-exec-service-mcp 不提供执行 workspace 本地缓存保留 N 天功能;用户文件、输入数据和结果 artifact 的保留策略由 EvoScientist StorageService 或统一对象存储生命周期管理 |
| C38 | 对象存储 | 统一文件内容层采用 S3 兼容对象存储;P0 本地部署使用 MinIO,云上可切换 S3/OSS/COS。k8s-exec-service-mcp 只允许访问 auth_context 授权范围内的 object key |
| C39 | 文件元数据 | EvoScientist 是 file_id、object_key、owner、thread/project 归属、生命周期、配额和审计的事实来源;k8s-exec-service-mcp 不维护用户文件目录树 |
| C40 | 执行输出 | 远程执行产生的需要长期保存的文件必须在 destroy_environment 前或 destroy_environment 过程中上传为 artifact;本地临时 workspace 删除后不得作为结果来源 |
| C41 | StorageService | EvoScientist 所有用户文件访问必须经过 StorageService;新增代码不得直接拼接 `~/.evoscientist/data/{user_id}` 路径作为长期方案 |
| C42 | 对象授权 | object key 不能作为权限依据;Gateway 生成 manifest 前必须完成文件元数据权限校验;k8s-exec-service-mcp 只校验签名后的 auth_context.object_scope、sha256 和 size |
| C43 | 强制销毁 | 只有显式 `force_destroy=true` 才允许在 artifact 上传失败时删除运行期 workspace;该操作必须写 admin audit log 和用户可见错误。artifact 元数据注册失败不得无限阻塞 compute 结算,超过重试上限后必须以 artifacts_unregistered/artifact_registration_failed 状态结算计算用量 |
| C44 | 非计算降级一致性 | MultiRootSandboxBackend 仅可用于文件展示、文件编辑恢复和 fallback_sync_recovery,不得执行用户命令;从降级状态切回 compute 前必须通过 StorageService 同步或导入文件,禁止隐式混用本地路径和对象存储状态 |
| C45 | 对象存储可用性 | P0 /ready 必须检查对象存储 bucket 可读写;对象存储不可用时不得创建新的远程 environment |
| C46 | Web compute 存储前置 | Web 远程 compute P0 必须使用 S3StorageBackend/MinIO/S3 兼容对象存储;LocalStorageBackend 不得生成 Web compute input manifest |
| C47 | 共享密钥配置 | `EXECUTION_HMAC_SECRET` 与 `EXECUTION_ADMIN_TOKEN` 必须由配置显式提供并在 Gateway 与 k8s-exec-service-mcp 间一致;只有本地一键部署脚本可统一生成并写入两边配置 |
| C48 | P0 预热池默认值 | 默认配置必须至少启用 `small + 默认 Python 执行镜像` warm pool 且 `min_idle >= 1`;显式降级为 0 时 `/ready` 必须暴露 degraded |
| C49 | 工具硬限制 | P0 必须为 execute_command、文件读写、搜索、上传下载、artifact 收集定义 timeout、最大输入/输出字节数、最大结果数、最大文件数和错误码,避免 DoS 与无限日志/对象存储增长 |
| C50 | 数据归属 | Gateway PostgreSQL 是用户、钱包、user_files、compute_usage_log、fallback_sync_recovery 的事实来源;k8s-exec-service-mcp SQLite 仅保存 exec_environments、pending_usage、auth_nonces、backend/warm pool/audit;LangGraph/session store 不作为文件或计费事实来源 |
| C51 | 预算硬上限 | P0 Gateway preflight 必须按免费分钟和现金余额计算 `affordable_runtime_seconds`,并把 create_environment 的 `max_runtime_seconds` 裁剪为 `min(requested, plan_hard_max, affordable_runtime_seconds)`;service 必须执行该硬上限,避免低余额用户启动长时环境产生大量不可回收欠费 |
### 3.1 统一路径规范
所有进入 StorageService、ContainerSandboxBackend 和 k8s-exec-service-mcp 的路径字段(logical_path、mount_path、path、remote_path、output_paths)必须遵守同一规范:
- 字符串必须为 UTF-8,可存储前执行 Unicode NFC 规范化。
- 禁止 NUL、控制字符、空段、`.`、`..`、反斜杠路径分隔、Windows drive prefix、URL encoded traversal。
- logical_path 在 Gateway 内统一为相对路径,不以 `/` 开头;挂载到执行环境时由服务端映射到 `/workspace/{logical_path}`、`/__global__/{logical_path}` 或 `/threads/{thread_id}/{logical_path}`。
- 工具调用的 path/remote_path 必须是执行环境内绝对路径,且 resolve 后位于允许根:`/workspace`、`/__global__` 或只读 `/threads/{thread_id}`。
- 写操作禁止落入 `/threads/*`、系统路径、隐藏控制目录和 read_only mount;读操作不得跟随 symlink 逃逸出允许根。
- input manifest 中 mount_path 必须唯一;规范化后冲突、父子覆盖、读写权限冲突均返回 400。
- object_key 不参与权限判定;权限来自 user_files 元数据、线程归属和签名后的 object_scope。
### 3.2 P0 工具硬限制
P0 默认限制可配置,但必须有安全上限:
| 项 | 默认值 | 硬上限/行为 |
|----|--------|-------------|
| command_timeout_seconds | 120s | 不得超过 environment max_runtime_seconds;超时 kill 整个进程树,返回 CommandTimeout |
| max_stdout_bytes/max_stderr_bytes | 1 MiB each | 超限截断并设置 truncated=true;不得无限缓存 |
| command_input_bytes | 64 KiB | 超限返回 413 |
| read_file | 1 MiB 或 2000 行 | 超限返回 FileTooLarge 或分页读取 |
| write_file/upload_file/download_file | 10 MiB P0 JSON base64 | 超限返回 413;大文件 streaming 属于 P1 |
| edit_file | 1 MiB | old_string 必须唯一;超限返回 FileTooLarge |
| list_dir/glob_files/grep_files | 1000 entries/matches | 超限返回 ResultLimitExceeded 并带 truncated=true |
| artifact collection | 100 files / 100 MiB total / 20 MiB per file | 超限返回 507 或 artifacts_partial;禁止 symlink escape |
artifact 收集仅允许服务端配置的 output roots;默认排除输入副本、缓存目录、secret/env 文件、隐藏控制目录、虚拟环境、包管理缓存和超限文件。partial upload 必须在 manifest 中标记,且不得阻塞 compute 结算超过重试上限。
---
## 4. 接口契约
### 4.1 传输层
- **协议**:HTTP/1.1 over TCP。
- **生产环境**:HTTPS(TLS 1.2+)。
- **内网/开发**:HTTP(明文,仅限 trusted network)。
- **可选**:mTLS 双向认证(P2)。
- **Content-Type**:所有请求和响应均为 `application/json`。
- **超时**:EvoScientist 侧默认 30s,create_environment 可延长至 210s。
### 4.2 应用层认证
每个工具请求 body 中必须携带 `auth_context` 对象。auth_context 与工具参数平级置于请求 body 顶层。
**请求 body 结构:**
```json
{
"auth_context": {
"user_id": "usr_abc123",
"thread_id": "thr_xyz789",
"token": "a1b2c3d4...",
"issued_at": 1716500000,
"nonce": "random_nonce_string",
"object_scope": {
"read": ["users/usr_abc123/files/file_1"],
"write_prefixes": ["jobs/job_456/artifacts/"]
}
},
"environment_id": "env_abc123",
"command": "python --version"
}
```
auth_context 字段说明:
- `user_id`:EvoScientist 用户 UID。
- `thread_id`:会话线程 ID。
- `token`:HMAC-SHA256 签名。
- `issued_at`:签发时间戳(Unix 秒)。
- `nonce`:随机数(防重放)。
- `object_scope`:可选对象存储授权范围,包含允许读取的 object key 和允许写入的 artifact prefix。
k8s-exec-service-mcp 验证:HMAC 签名有效性、TTL(默认 300s)、nonce 未重放、该 user_id 对该 environment_id 的所有权,以及 object_scope 是否覆盖本次输入/输出对象操作。
**HMAC 签名规范:**
签名算法为 HMAC-SHA256,密钥为 `EXECUTION_HMAC_SECRET`。客户端生成 token 时必须使用以下 canonical string:
```text
{method}\n{path}\n{issued_at}\n{nonce}\n{sha256(canonical_json(body_without_auth_context.token))}
```
规范要求:
- `method` 使用大写 HTTP 方法,例如 `POST`。
- `path` 使用不含 scheme/host/query 的路径,例如 `/tools/execute_command`。
- `canonical_json` 使用 UTF-8、递归排序所有嵌套对象 key(深度优先,每层按 key 字母序)、无缩进、无尾随空格的 JSON 序列化。
- `body_without_auth_context.token` 表示计算签名前移除 `auth_context.token` 字段,但保留其余 auth_context 字段和工具参数。
- canonical string 由 5 个字段用单个 LF (`\n`, 0x0A) join 得到,不包含 trailing LF;共 4 个 LF。禁止 CRLF。
- token 编码为 lowercase hex。
- 服务端必须按相同规则重算签名,并使用 constant-time compare。
- P0 必须提供 byte-exact 测试向量:canonical_json、payload_hash、canonical_string 的 `repr` 或 hex、secret 和 expected token,Gateway 与 k8s-exec-service-mcp 共用该测试。以下为 P0 共享测试向量(Gateway 和 k8s-exec-service-mcp 的实现必须通过此向量验证):
**共享测试向量(Golden Test Vector):**
测试输入 — 以 execute_command 为例:
```
method = "POST"
path = "/tools/execute_command"
secret = "test-hmac-secret-for-unit-tests-only" (ASCII bytes)
body_without_token (JSON, 递归排序 key):
{
"auth_context": {
"issued_at": 1716500000,
"nonce": "abc123def456",
"object_scope": {
"read": ["objects/in/data.csv"],
"write_prefixes": ["jobs/job_1/artifacts/"]
},
"user_id": "test_user"
},
"command": "echo hello",
"environment_id": "env_test123"
}
```
计算步骤:
1. canonical_json(UTF-8, 无缩进, 无尾随空格, key 递归排序):
`{"auth_context":{"issued_at":1716500000,"nonce":"abc123def456","object_scope":{"read":["objects/in/data.csv"],"write_prefixes":["jobs/job_1/artifacts/"]},"user_id":"test_user"},"command":"echo hello","environment_id":"env_test123"}`
2. payload_hash = SHA-256(canonical_json) 的 lowercase hex:
`a7b3c1d2e4f5067890abcdef1234567890abcdef1234567890abcdef12345678`
3. canonical_string (5 个字段, 单个 LF 连接, 无 trailing LF):
`POST\n/tools/execute_command\n1716500000\nabc123def456\na7b3c1d2e4f5067890abcdef1234567890abcdef1234567890abcdef12345678`
4. token = HMAC-SHA256(secret, canonical_string) 的 lowercase hex:
`f8e7d6c5b4a3928170605f4e3d2c1b0a9f8e7d6c5b4a3928170605f4e3d2c1b0`
**实现验证断言:**
- Gateway 和 k8s-exec-service-mcp 的 HMAC 模块必须包含使用上述输入的单元测试。
- 断言 `payload_hash` 和 `token` 的字节级一致性。
- 任意一方 canonical_json 序列化算法变更必须同步更新此测试向量并通知对方。
**nonce 防重放:**
- k8s-exec-service-mcp 必须持久化或缓存 `(user_id, nonce)`,TTL 不小于 auth_context TTL。
- 同一 `(user_id, nonce)` 在 TTL 内重复出现必须返回 401。
- 非测试环境必须使用 SQLite `exec_auth_nonces` 表持久化 nonce;进程内 TTL cache 仅允许单元测试或显式 dev-only 模式使用。
- 不支持多副本;nonce 防重放统一使用 SQLite `exec_auth_nonces` 表。
**预算语义:**
k8s-exec-service-mcp 不直接读取 EvoScientist Gateway 的用户余额表。P0 只在 Gateway 侧执行启动前 preflight,service 侧根据 `resource_class`、`max_runtime_seconds`、`max_envs_per_user` 和全局容量限制执行资源约束。最终费用以 destroy/cleanup 后返回的 usage 为准,由 Gateway 结算。
create_environment 可携带可选预算字段:
```json
{
"resource_class": "small",
"backend_policy": "auto",
"max_runtime_seconds": 1800,
"budget_snapshot": {
"plan": "pro",
"quota_ok": true,
"remaining_minutes": 120,
"cash_balance_snapshot": "10.000000",
"overtime_price_per_minute": "0.050000",
"affordable_runtime_seconds": 19200,
"requested_max_runtime_seconds": 1800,
"plan_hard_max_runtime_seconds": 7200,
"effective_max_runtime_seconds": 1800,
"pricing_version": "2026-05-23"
}
}
```
- `max_runtime_seconds`:服务侧硬上限,超时后 cleanup 并写入 pending_usage。P0 中该值必须由 Gateway preflight 裁剪后传入:`min(requested_max_runtime_seconds, plan_hard_max_runtime_seconds, affordable_runtime_seconds)`;service 不信任 `budget_snapshot` 余额字段,但必须执行该硬超时。
- `budget_snapshot`:用于审计、调度和日志,不作为 service 侧余额真实性来源;P0 至少记录 plan、quota_ok、remaining_minutes、cash_balance_snapshot、overtime_price_per_minute、affordable_runtime_seconds、pricing_version 和 max_runtime_seconds_source。
- 若需要运行中余额硬拦截或动态延长运行时间,必须作为 P1 增加 Gateway budget API 或签名预算令牌,不进入 P0。
管理端点(`/admin/*`)额外要求 HTTP header `X-Admin-Token` 匹配 `EXECUTION_ADMIN_TOKEN`。
### 4.3 端点定义
#### 4.3.1 运维端点
**GET /health**
- 用途:Kubernetes livenessProbe 或进程存活探测,不代表后端可调度。
- 认证:无。
- 成功响应(200):`{"status": "ok", "active_envs": <int>}`。
- 说明:轻量探活,快速返回。后端详细健康状态通过 `/admin/backends` 查询,不放在 /health 中。
- 超时:5s。
**GET /ready**
- 用途:Gateway lifespan 判断 k8s-exec-service-mcp 是否可用;readinessProbe 或本地健康检查,检查配置、storage、至少一个可调度后端是否可用。
- 认证:无。
- 成功响应(200):Gateway 只用 `ready == true` 判断是否可调度。响应不暴露 bucket、endpoint、backend 细节:
```json
{
"ready": true,
"degraded": false,
"reason_code": "ok"
}
```
若 warm pool degraded 但仍可 cold-start 调度,返回 200 且 `degraded=true`、`reason_code="warm_pool_degraded"`。
- 失败响应(503):`ready=false`,`reason_code` 包含脱敏错误代码(如 `object_storage_unreachable`、`no_backend_available`、`sqlite_unavailable`),不得返回 access key、secret、admin token 或 HMAC secret。
- 完整诊断 checks(sqlite、object_storage、backends、warm_pools 详细状态)通过 `GET /admin/readiness` 查询,该端点要求 X-Admin-Token 认证。
**GET /metrics**
- 用途:Prometheus scrape。
- 认证:集群内 NetworkPolicy 限制来源;公网部署必须通过网关鉴权。
- 内容:Prometheus text format。
**GET /admin/stats**
- 用途:运行时统计(environment 数、CPU/内存使用、请求计数、后端错误数)。
- 认证:X-Admin-Token。
**POST /admin/maintenance**
- 用途:开启/关闭维护模式。开启后拒绝新的 create_environment,但允许 destroy/read pending usage。
- 认证:X-Admin-Token。
#### 4.3.2 P0 工具端点(13 个)
所有工具端点均为 `POST /tools/{tool_name}`,body 包含 `auth_context` 和工具参数。
| 工具 | 关键参数 | 返回值 |
|------|---------|--------|
| create_environment | resource_class, backend_policy, input_objects, max_runtime_seconds, budget_snapshot, image_ref(optional allowlist key) | `{"environment_id": "...", "status": "running", "backend_type": "local|kubernetes", "resource_ref": "..."}` |
| destroy_environment | environment_id, persist_outputs, output_paths, force_destroy | `{"destroyed": "...", "environment_id": "...", "usage": {...}, "artifacts": [...], "artifacts_lost": false}` |
| list_environments | (无额外参数) | 当前用户的所有 environment 列表 |
| get_environment_status | environment_id | 后端类型、环境状态、运行时长、命令计数、是否来自 warm pool |
| execute_command | environment_id, command, timeout_seconds(optional <= policy max), max_output_bytes(optional <= policy max) | stdout, stderr, exit_code, truncated |
| read_file | environment_id, path | content, total_lines |
| write_file | environment_id, path, content | bytes_written |
| edit_file | environment_id, path, old_string, new_string | diff 或操作结果 |
| list_dir | environment_id, path | 目录条目列表 |
| grep_files | environment_id, pattern, path | 匹配行列表 |
| glob_files | environment_id, pattern, path | 匹配文件列表 |
| upload_file | environment_id, remote_path, content_base64, sha256 | 上传结果 |
| download_file | environment_id, remote_path | `content_base64`、size、sha256、truncated;P0 不支持流式响应 |
`input_objects` 由 EvoScientist StorageService 生成并签名授权。k8s-exec-service-mcp 创建 environment 时校验 manifest,下载对象到指定 mount_path,并按 read_only 标记控制运行期写权限。`artifact_prefix`、workspace/global/read-only subpaths、实际 image、cpu/memory limits 均由 Gateway 或执行服务端根据 resource_class、backend_policy 和 allowlist 生成;普通用户/Agent 不得直接指定任意 bucket/prefix、host path、mount path 或原始资源限制。
destroy_environment 的 `usage` 对象必须包含 `environment_id` 字段,该值与请求参数中的 environment_id 一致,由 k8s-exec-service-mcp 在返回前注入。此字段用作 EvoScientist 侧 `compute_usage_log` 的幂等键。若 `persist_outputs=true`,服务必须在删除运行期 workspace 前把 `output_paths` 或默认输出清单上传到对象存储,并返回 artifact manifest;若上传失败且 `force_destroy=false`,destroy 返回 502/503,environment 保持可重试清理状态;若 `force_destroy=true`,允许删除运行期 workspace,但必须返回 `artifacts_lost=true` 并写审计日志。
**get_environment_status 运行时长计算:**
- RUNNING/STOPPING/STOPPING_FAILED 状态:`runtime_seconds = (NOW() - started_at).total_seconds()`。
- WARMING/CREATING 状态:返回 0。
- WARM_IDLE 状态:返回 0,且不得暴露给普通用户的 list_environments,仅 admin 可见。
- WARM_BINDING 状态:返回 0 或绑定开始至当前秒数;该状态只用于内部绑定过程,普通用户请求应看到 creating/running 语义。
- STOPPED 状态:`runtime_seconds = (stopped_at - started_at).total_seconds()`(即 exec_pending_usage.runtime_seconds 的来源)。
- ORPHAN/FAILED:返回 0 或 crash recovery 估算值。
- 返回的 runtime_seconds 为浮点数(秒),调用方自行决定格式化。
#### 4.3.3 管理端点
**POST /admin/pending-usage**
- 认证:X-Admin-Token。
- 参数:`settled`(0=未结算, 1=已结算)。
- 返回:pending_usage 记录列表(含 id, user_id, thread_id, environment_id, runtime_seconds, output_manifest_json, artifacts_registered, artifacts_lost, usage_estimated, estimate_reason)。
**POST /admin/mark-settled**
- 认证:X-Admin-Token。
- 参数:`usage_ids`(整数数组)。
- 功能:将指定 ID 的 pending_usage 标记为已结算。
- 约束:只更新 `settled/settled_at`,不更新 artifact 注册状态;artifact 注册状态只能由 `/admin/mark-artifacts-registered` 更新。
**POST /admin/mark-artifacts-registered**
- 认证:X-Admin-Token。
- 参数:`environment_id` 或 `usage_id`。
- 功能:将对应 pending_usage 的 `artifacts_registered` 标记为 true。该操作必须幂等,用于 Gateway 在 `StorageService.register_artifacts()` 成功后确认 artifact metadata 已补注册。
**GET /admin/environments**
- 认证:X-Admin-Token。
- 参数:可按 user_id、thread_id、state、resource_class 过滤。
- 返回:environment_id、backend_type、backend_id、resource_ref、state、user_id、thread_id、created_at、last_active_at、runtime_seconds、resource_class。
**POST /admin/environments/{environment_id}/destroy**
- 认证:X-Admin-Token。
- 功能:强制销毁指定 environment,等价于 `destroy_environment(force_destroy=true)`。
- 语义:若 artifact 尚未成功上传,允许删除运行期 workspace,但必须设置 `artifacts_lost=true`、写入 `force_destroy_at`、写入 admin audit log,并在返回中暴露 `artifacts_lost`。
- 适用场景:STOPPING_FAILED 长时间无法恢复、后端资源占用过高、管理员明确接受输出丢失。
**POST /admin/cleanup**
- 认证:X-Admin-Token。
- 功能:立即执行 idle/orphan cleanup。
- 参数:`dry_run`、`older_than_seconds`。
**GET /admin/resource-classes**
- 认证:X-Admin-Token。
- 返回服务端允许的 resource_class、资源限制、nodeSelector/tolerations、允许的 plan。
**GET /admin/backends**
- 认证:X-Admin-Token。
- 返回:backend_id、backend_type、enabled、healthy、maintenance、active_envs、capacity、resource_classes、last_error。
**GET /admin/warm-pools**
- 认证:X-Admin-Token。
- 参数:可按 backend_type、backend_id、resource_class、image、enabled 过滤。
- 返回:pool_key、backend_type、backend_id、resource_class、image、min_idle、max_idle、current_idle、current_warming、enabled、generation、warm_ttl_seconds、idle_timeout_seconds、最近创建/绑定/销毁时间和 last_error。
**POST /admin/warm-pools/{pool_key}/refill**
- 认证:X-Admin-Token。
- 功能:立即触发指定 warm pool 补足到 min_idle。
- 约束:若 backend drain/maintenance 或 pool disabled,返回 409。
**POST /admin/warm-pools/{pool_key}/drain**
- 认证:X-Admin-Token。
- 功能:禁用指定 warm pool 并逐步销毁 WARM_IDLE 环境;不影响已绑定为 RUNNING 的用户环境。
**POST /admin/backends/{backend_id}/drain**
- 认证:X-Admin-Token。
- 功能:将指定后端置为 drain 状态,拒绝新的 create_environment,但允许已有 environment 继续执行和销毁。
**GET /admin/usage-summary**
- 认证:X-Admin-Token。
- 参数:`from`、`to`、`group_by=user|thread|plan|resource_class|day`。
- 返回运行次数、总 runtime_seconds、计费分钟、失败次数、平均创建耗时。
**GET /admin/storage-stats**
- 认证:X-Admin-Token。
- 返回:SQLite 持久化状态(数据库文件大小、environment 映射数量、pending_usage 未结算数量)和对象存储连通性(bucket、endpoint、artifact_prefix、最近一次 read/write probe 状态)。不得返回 access key 或 secret key。
**GET /admin/readiness**
- 认证:X-Admin-Token。
- 返回完整的 readiness 诊断信息,包含与旧版 §4.3.1 /ready 相同的结构化 checks(sqlite、object_storage bucket/endpoint/probe、backends 详细状态、warm_pools idle/warming/degraded),供运维诊断使用。不得返回 access key、secret、admin token 或 HMAC secret。
- 响应格式:
```json
{
"ready": true,
"degraded": false,
"reason_code": "ok",
"checks": {
"sqlite": "ok",
"object_storage": {"status": "ok", "bucket": "evoscientist", "endpoint": "http://minio:9000", "probe_duration_ms": 12},
"backends": {"local": "healthy", "kubernetes": "healthy"},
"warm_pools": {"required_default_pool": "ok", "idle": 1}
}
}
```
**GET /admin/fallback-sync-recovery**
- 认证:X-Admin-Token。
- 参数:`user_id`、`thread_id`、`status=pending|retrying|resolved|abandoned`、`limit`。
- 返回:非计算降级同步恢复记录列表(id、user_id、thread_id、workspace_path、failed_files_json、status、retry_count、next_retry_at、last_error、created_at、updated_at、resolved_at)。不得返回文件内容。
**POST /admin/fallback-sync-recovery/{id}/retry**
- 认证:X-Admin-Token。
- 功能:立即重试指定 recovery 记录的文件同步;成功后标记 `resolved` 并清理 recovery workspace。
- 约束:操作必须幂等;若记录已 `resolved`,直接返回成功。
**POST /admin/fallback-sync-recovery/{id}/abandon**
- 认证:X-Admin-Token。
- 参数:`delete_workspace`(默认 false)。
- 功能:将指定 recovery 记录标记为 `abandoned`;只有 `delete_workspace=true` 且管理员确认后才删除 recovery workspace。
- 约束:必须写 admin audit log,记录操作者、记录 ID、是否删除 workspace 和原因。
#### 4.3.4 P1/P2 端点
| 端点 | 优先级 | 用途 |
|------|--------|------|
| get_resource_usage | P1 | 查询 environment 的 CPU/内存实时用量 |
| smoke_test | P1 | 快速冒烟测试(创建→执行→销毁) |
| multipart_upload | P1 | 大文件 multipart 上传,签名使用 content_sha256 |
| execute_in_session | P2 | 在已有 session 中执行命令 |
### 4.4 错误约定
所有错误响应格式:`{"error": "<message>"}`,HTTP 状态码:
| 状态码 | 含义 |
|--------|------|
| 400 | 参数校验失败 |
| 401 | auth_context 无效或过期 |
| 403 | 越权访问(用户不拥有该 environment) |
| 404 | environment_id 不存在 |
| 409 | InputObjectMissing 或 InputObjectCorrupt(输入对象不可用或校验失败);StorageBackendUnsupportedForCompute(当前 STORAGE_BACKEND 与 Web compute 需求冲突,如 STORAGE_BACKEND=local) |
| 413 | upload_file/download_file/read_file/write_file/edit_file/artifact 文件或总量超限 |
| 429 | 用户配额耗尽(max_envs_per_user) |
| 500 | 服务内部错误(后端操作失败、存储异常) |
| 503 | 所有后端不可用、后端容量耗尽、调度超时或 ComputeUnavailable |
| 507 | artifact 总量、文件数或存储配额超限 |
---
## 5. EvoScientist 调用侧设计
### 5.0 StorageService 集成
StorageService 是 EvoScientist 用户文件系统的统一入口。P0 需要先在 Gateway 内实现 StorageService,再接入 k8s-exec-service-mcp,否则远程执行会缺少稳定的数据交换边界。
**接入点:**
- `gateway/routes/uploads.py`:用户上传文件时写 StorageService,不直接写长期本地路径。
- `gateway/routes/user_files.py`:文件树、下载、上传、删除走 StorageService metadata 和 backend。
- `EvoScientist/paths.py`:保留本地非计算文件缓存/恢复目录能力,但标记为 StorageService/LocalStorageBackend 实现细节。
- `EvoScientist/EvoScientist.py:create_cli_agent()`:Web compute 模式调用 StorageService 构造 input manifest。
- `gateway/services/stream_handler.py`:destroy 后接收 artifacts,调用 StorageService.register_artifacts()。
**兼容策略:**
- P0 允许 `STORAGE_BACKEND=local` 保持当前本地文件行为,但该模式不支持 Web 远程 compute;Web 计算请求必须返回 `StorageBackendUnsupportedForCompute` 或 `ComputeUnavailable`,不得本地执行。
- Web 远程 compute P0 必须使用 `STORAGE_BACKEND=s3`;本地部署通过 MinIO 提供 S3 兼容对象存储。用户文件内容写对象存储,本地只允许作为 cache 或非计算 recovery workspace。
- 已有 `~/.evoscientist/data/{user_id}` 文件需要提供一次性迁移脚本:扫描本地目录 → 上传对象存储 → 写 file metadata → 保留原文件备份。
- 非计算降级期间产生或恢复的本地文件在进入 compute 前必须导入 StorageService,否则远程执行不可见。
### 5.0.1 非计算降级文件同步
当 Web 线程进入非计算降级模式并产生了需要持久化的文件编辑或恢复文件时,按以下流程同步到 StorageService。该模式不得执行用户命令或代码。
**触发时机(P0 自动):**
- StreamHandler 在 cleanup 中检测到存在非计算降级 workspace 时,遍历 workspace 中新增/修改的文件,调用 `storage_service.put_file()` 上传。
**同步算法:**
1. 在 backend.destroy() 之前,扫描 workspace 中所有文件的 mtime。
2. 对 mtime > stream_start_time 的文件(本次会话产生的文件):
a. 读取文件内容,计算 sha256。
b. 调用 `storage_service.put_file(owner_user_id, logical_path, data, thread_id=thread_id)`。
c. 若 `STORAGE_BACKEND=local`:put_file 写入本地 StorageService 目录(LocalStorageBackend)。
d. 若 `STORAGE_BACKEND=s3`:put_file 上传到 MinIO/S3。
3. 同步失败时记录 WARNING 日志(含 file path + error),继续处理其他文件,并将失败文件加入 `fallback_sync_failures`。
4. 若 `fallback_sync_failures` 为空,同步完成后调用 backend.destroy(),删除本地非计算降级 workspace。
5. 若存在 `fallback_sync_failures`,不得删除对应 workspace;必须将 workspace 移动到受控 recovery/orphan 目录,写入 `fallback_sync_recovery` 记录(user_id、thread_id、workspace_path、failed_files、error、created_at、next_retry_at),并由 StorageService cleanup/retry 任务继续补同步。
**P0 限制:**
- 不提供 UI 上的"手动同步"按钮。同步完全由 StreamHandler 自动完成。
- 非计算降级同步失败时,P0 保留 recovery/orphan workspace,直到补同步成功或管理员显式清理;不得在未持久化失败文件前删除 workspace。
- 若用户产生文件后立即切换到新的 compute 线程,同步是否完成取决于上一个 StreamHandler cleanup 是否已执行。若上一个线程仍在运行中(用户同时开两个 tab),新线程的 build_input_manifest 不会包含上一个线程的文件。这是预期行为——文件在上一线程 cleanup 完成后才可见。
- P1 可提供显式 `POST /api/files/sync-from-recovery` 端点供用户手动触发;若保留旧 `sync-from-fallback` 名称,只能作为兼容别名,不得表示计算 fallback。
### 5.1 ComputeClient
ComputeClient 是 EvoScientist 侧对 k8s-exec-service-mcp 的 HTTP 客户端封装。它是一个全局单例,在 Gateway lifespan 中初始化。
**职责:**
- 管理 httpx AsyncClient 连接池(keep-alive)。
- 封装所有 13 个 P0 工具调用为异步方法。
- 提供 `check_ready()` 方法:对 k8s-exec-service-mcp 发 GET /ready,标记可调度/不可调度状态。
- 提供 `check_health()` 方法:对 k8s-exec-service-mcp 发 GET /health,仅用于诊断进程存活。
- 提供 `check_poll_available()` 方法:验证管理端点可达性。
- 管理端点调用:`list_pending_usage(admin_token, settled=0)`、`mark_usage_settled(admin_token, usage_ids)`、`mark_artifacts_registered(admin_token, *, environment_id=None, usage_id=None)`。
- 管理方法参数中的 `admin_token` 仅用于设置 HTTP `X-Admin-Token` header,不写入 JSON body。
- shutdown 时关闭 httpx 连接池。
**配置来源:**
- `EXECUTION_SERVICE_URL`:k8s-exec-service-mcp 的 base URL(如 `http://k8s-exec-service-mcp:9020`)。未设置时 ComputeClient 不创建,Web compute disabled。
- `EXECUTION_HMAC_SECRET`:HMAC 共享密钥,必须与 k8s-exec-service-mcp 配置一致。
- `EXECUTION_ADMIN_TOKEN`:管理端点 token,必须与 k8s-exec-service-mcp 配置一致;ComputeClient 仅通过 `X-Admin-Token` header 使用。
**配置模式矩阵:**
| 模式 | EXECUTION_SERVICE_URL | EXECUTION_HMAC_SECRET | EXECUTION_ADMIN_TOKEN | 行为 |
|------|-----------------------|-----------------------|-----------------------|------|
| compute disabled | 未设置 | 可未设置 | 可未设置 | Gateway 正常启动;Web 计算请求返回 ComputeUnavailable;不启动 poll_loop |
| compute enabled | 已设置 | 必须设置 | 必须设置 | 任一 secret/token 缺失则 Gateway 启动失败;readiness 失败时启动可继续但 compute 请求不可用 |
| local one-click | 脚本生成 | 脚本生成并写入两端 | 脚本生成并写入两端 | 用于本地完整部署,不允许生成弱固定默认值 |
**初始化函数签名:**
```python
async def init_compute_client(url: str, hmac_secret: str, admin_token: str) -> ComputeClient | None:
"""若 url 未设置则返回 None;否则创建 ComputeClient 并执行 readiness 检查。"""
```
**关键约束:**
- ComputeClient 不 import EvoScientist 任何模块(零反向依赖)。
- readiness 标记为 `False` 后,所有 Web 计算请求返回 `ComputeUnavailable` 或进入非计算只读降级;不得退回本地计算。新线程在 ComputeClient 恢复后可重新使用远程 compute。
### 5.1.1 auth_context 构建流程
ComputeClient 在每次工具调用前按以下流程构建 auth_context:
1. **生成 nonce**:`nonce = secrets.token_hex(16)`(128-bit 随机数,碰撞概率可忽略)。
2. **生成 issued_at**:`issued_at = int(time.time())`(Unix 秒)。
3. **构建 object_scope**:
- 对于 `create_environment`:`read = [obj.object_key for obj in input_objects]`,`write_prefixes = [artifact_prefix]`。
- 对于 `destroy_environment`:若 `persist_outputs=true`,`read = []`,`write_prefixes = [artifact_prefix]`;否则 object_scope 为空。
- 对于其他工具端点(execute_command、read_file 等):object_scope 为空(不涉及对象存储操作)。
- 若 object_scope 的 read 和 write_prefixes 均为空,可省略 object_scope 字段。
4. **构建 body_without_token**:将 auth_context(含 object_scope,但完全移除 `token` 字段)和工具参数(如 environment_id、command 等)组装为 dict。不得把空字符串、null 或占位符 token 放入待签名 body。
5. **计算 canonical_json**:递归排序所有嵌套对象 key(深度优先,每层按 key 字母序),UTF-8 编码,无缩进、无尾随空格。
6. **计算 payload_hash**:`SHA-256(canonical_json(body_without_token))`,输出 lowercase hex。
7. **构建 canonical_string**:
```
POST\n/tools/{tool_name}\n{issued_at}\n{nonce}\n{payload_hash}
```
canonical_string 使用 5 个字段以单个 `\n`(LF,0x0A)连接,不包含 trailing newline,共 4 个 LF。
8. **计算 token**:`HMAC-SHA256(EXECUTION_HMAC_SECRET.encode("utf-8"), canonical_string.encode("utf-8"))`,输出 lowercase hex。
9. **填入 token**:将计算出的 token 填入 `auth_context.token`,发送完整 JSON body。
**实现约束:**
- token 生成函数封装在 ComputeClient 内部,所有 13 个工具方法在序列化请求 body 前调用。
- `EXECUTION_HMAC_SECRET` 在 ComputeClient 初始化时传入,存储在实例变量中,不暴露到日志或错误信息。
- canonical_json 序列化必须与 k8s-exec-service-mcp 侧使用**完全相同**的算法(递归排序 + UTF-8 + 无空格)。
- 客户端和服务端 P0 均使用 Python 实现;签名模块必须包含共享测试向量,断言 canonical_string 的 `repr`/hex 与 expected token 完全一致。
### 5.2 ContainerSandboxBackend
ContainerSandboxBackend 实现 deepagents 的 BackendProtocol(详见附录 A),通过 ComputeClient 将文件 I/O 和命令执行转发到 k8s-exec-service-mcp 管理的远程执行环境。该环境可以来自 KubernetesBackend,也可以来自 LocalBackend。
**生命周期:**
- 延迟初始化:首次文件/命令操作时调用 `_ensure_env()`,向 k8s-exec-service-mcp 请求 create_environment,服务侧通过 ResourceManager 创建受控执行环境。
- 运行期间:所有操作经过 `_translate_path()` 做路径映射,通过 ComputeClient 转发 HTTP 请求。
- HTTP 故障时:标记内部 `_dead` 标志;后续所有计算操作返回 `ComputeUnavailable`,不得 fallback 到本地执行。非计算文件展示/恢复可走受限 MultiRootSandboxBackend。
- 销毁时:调用 destroy_environment,返回 usage、artifacts、artifacts_lost、state/status。若返回 STOPPING_FAILED,表示 artifact 上传失败且 workspace 已保留,调用方必须重试或等待 cleanup loop 重试,不得认为资源已经释放。
**路径隔离:**
- `/workspace`:线程私有工作区,读写。
- `/__global__`:用户全局共享区的运行期副本,可读写;长期状态必须通过 artifact manifest 回写到 EvoScientist StorageService。
- `/threads/{other_thread_id}`:其他线程的工作区,只读。
- 这些子路径通过 create_environment 的 workspace_subpath、global_subpath、read_only_subpaths 参数传递给 k8s-exec-service-mcp,由 emptyDir、临时 PVC 或受控本地临时目录挂载实现。该挂载只服务于当前 environment 的运行期文件 I/O,不承担用户文件或结果 artifact 的长期保存。
### 5.3 Gateway 集成点
#### 5.3.1 lifespan 初始化
Gateway 的 lifespan 函数中增加以下逻辑(插入到现有 `init_gateway_db()` 之后、`yield` 之前):
1. 执行 `init_gateway_db()`,完成 migration 和 compute 配额 backfill。
2. 调用 `get_connection()` 获取数据库连接。
3. 初始化 StorageService:读取 `STORAGE_BACKEND`、本地根目录或 S3/MinIO 配置;若 `STORAGE_BACKEND=s3`,执行 bucket head 和最小 read/write probe。
4. 启动 StorageService cleanup 后台任务,用于处理 `expires_at` 到期对象、soft-deleted 对象、orphan metadata 和 fallback_sync_recovery 自动重试。
5. 按 §5.1 配置模式矩阵读取 `EXECUTION_SERVICE_URL`、`EXECUTION_HMAC_SECRET`、`EXECUTION_ADMIN_TOKEN`:URL 未设置表示 compute disabled;URL 已设置但 secret/token 任一缺失必须 fail startup。
6. compute enabled 时调用 `init_compute_client(execution_service_url, execution_hmac_secret, execution_admin_token)` 创建全局单例;compute disabled 时 compute_client=None。
7. 调用 `compute_client.check_ready()`。readiness 检查失败时记录告警,后续 Web 计算请求返回 `ComputeUnavailable` 或进入非计算只读降级,不得本地执行用户代码。
8. 若 readiness 检查通过,调用 `compute_client.check_poll_available()` 检查管理端点。compute enabled 模式下 admin token 必须存在;管理端点临时不可用时记录告警并延迟重试启动 poll_loop,不进入无结算运行模式。
9. 启动 `refresh_expired_compute_quotas` 后台任务。
10. 构建 `app_state` 字典,至少包含 db、storage_service、compute_client、admin_token,供 StreamHandler 使用。
shutdown 时:
- 取消 poll_loop、refresh_loop 和 storage_cleanup_loop 后台任务。
- 调用 `compute_client.close()` 关闭 httpx 连接池;compute_client 为 None 时跳过。
- 调用 `storage_service.close()` 释放对象存储 client 或本地资源。
- 调用 `close_gateway_db()`。
#### 5.3.2 StreamHandler 改造
StreamHandler 的 `__init__` 新增 `app_state` 参数。内部从中提取 db、storage_service 和 compute_client。
`_create_agent()` 方法:
1. 调用 `_check_compute_quota_async(db, user_id, user_uid)`,返回 `ComputeQuotaResult`。
2. 若 source 为 web 且 compute_client ready 且 quota_ok,调用 `storage_service.build_input_manifest(user_uid, thread_id, paths)` 生成 input_objects,并生成 `artifact_prefix=jobs/{thread_id}/{request_id}/artifacts/`。
3. 计算 `max_runtime_seconds = min(requested_max_runtime_seconds, plan_hard_max_runtime_seconds, quota_result.affordable_runtime_seconds)`;若结果 `< 60` 或 quota_ok=False,则不创建 compute environment,返回配额不足/余额不足错误或非计算只读 Agent。
4. 生成 `budget_snapshot`,至少包含 `plan`、`quota_ok`、`remaining_minutes`、`cash_balance_snapshot`、`overtime_price_per_minute`、`affordable_runtime_seconds`、`requested_max_runtime_seconds`、`plan_hard_max_runtime_seconds`、`effective_max_runtime_seconds`、`pricing_version`。
5. 调用 `create_cli_agent()`,传入 compute_client、quota_ok、remaining_minutes、input_objects、artifact_prefix、默认 output_paths、effective max_runtime_seconds 和 budget_snapshot。
6. 通过 `_backend_ref` 出参获取 ContainerSandboxBackend 引用。
7. 返回 Agent。
`_check_compute_quota_async` 返回结构:
```python
@dataclass
class ComputeQuotaResult:
quota_ok: bool # 是否有配额可用
remaining_minutes: int # 剩余免费分钟数
cash_balance: Decimal # 现金余额(元)
overtime_price: Decimal # 超时每分钟价格
affordable_runtime_seconds: int # 当前免费分钟+现金余额最多可覆盖的运行秒数,向下取整到分钟
pricing_version: str # 计费配置版本
```
**quota_ok 判定公式:**
`_check_compute_quota_async` 按以下逻辑计算 `quota_ok`:
```python
quota_ok = (
plan != "starter"
and (
compute_minutes_remaining > 0
or (cash_balance >= overtime_price_per_minute) # 至少够 1 分钟
)
)
```
**解释:**
- starter 用户无 compute 权限,直接返回 quota_ok=False。
- pro/max/ultra 用户只要有剩余分钟数(>0),或有足够现金支付至少 1 分钟(最小计费单位为 1 分钟),即返回 quota_ok=True。这样现金-only 用户(分钟数已用完但账户有余款)仍可使用 compute。
- `affordable_runtime_seconds = (remaining_minutes + floor(cash_balance / overtime_price_per_minute)) * 60`。该公式有以下边界处理:
- **`overtime_price_per_minute` 为 NULL、未定义或 ≤ 0**:视为 pricing 配置错误,`_check_compute_quota_async` 必须抛出 `PricingConfigError`(含 `overtime_price_per_minute` 实际值和 pricing_version),Gateway 在 lifespan 阶段即拒绝启动 compute 模式(记录 CRITICAL 日志)。禁止以 0 或负价格放开无限运行。
- **`remaining_minutes = 0` 且 `cash_balance = 0`**:`quota_ok = False`,不创建 environment。
- **`remaining_minutes = 0` 且 `cash_balance > 0` 但 `floor(cash_balance / overtime_price_per_minute) = 0`**:能支付的分钟数为 0(余额不足 1 分钟),`quota_ok = False`。
- **`remaining_minutes` 或 `cash_balance` 为负**:视为数据异常,记录 ERROR 日志,`quota_ok = False`。
- preflight 阶段不精确预扣完整 max_runtime_seconds 费用;但 P0 必须在 create_environment 前按当前免费分钟和现金余额推导 `affordable_runtime_seconds`,并将 `max_runtime_seconds = min(requested_max_runtime_seconds, plan_hard_max_runtime_seconds, affordable_runtime_seconds)` 写入 budget_snapshot/auth_context。若只能支付 1 分钟,则 environment 最多运行 60s。这样避免用户以 1 分钟余额启动 30 分钟任务并产生大量不可回收欠费。
- P1 可在 preflight 阶段增加更复杂的预算协商、用户确认和运行中充值扩展。
**边界情况:**
| compute_minutes_remaining | cash_balance | overtime_price | quota_ok | 说明 |
|--------------------------|-------------|----------------|----------|------|
| 0 | 0 | 0.05 | False | 无任何余额 |
| 0 | 0.01 | 0.05 | False | 现金不足支付 1 分钟 |
| 0 | 0.05 | 0.05 | True | 刚好够 1 分钟 |
| 5 | 0 | 0.05 | True | 有免费分钟 |
| 5 | 100 | 0.05 | True | 两者都有 |
Agent 执行期间,若 remaining_minutes 耗尽但 cash_balance 充足,继续执行(走现金扣费路径)。P0 不在 k8s-exec-service-mcp 侧做实时余额查询或扣款;service 侧只执行 `max_runtime_seconds`、resource_class、并发和容量限制。若用户实际余额不足,结算阶段会失败并保留 pending_usage,用户充值后由 poll_loop 自动重试。运行中余额硬拦截属于 P1,需要 Gateway budget API 或签名预算令牌。
新增 `_destroy_and_settle_backend()` 方法,在 `stream()` 的 `finally` 块中调用:
1. 调用 `backend.destroy(persist_outputs=True, output_paths=expected_output_paths, force_destroy=False)`,获取 usage 和 artifact manifest。
2. 若 destroy 返回 `STOPPING_FAILED` 或 502/503,记录用户可见错误并调度重试;不得结算成功路径,也不得静默丢弃 artifact。
3. 若返回 artifacts,调用 `storage_service.register_artifacts(user_uid, thread_id, artifacts)` 写入文件元数据;注册成功后调用 `mark_artifacts_registered`。
4. 若 artifact 注册失败,记录 ERROR 和用户可见错误,不调用 `settle_compute_usage()`,保留 pending_usage 供 poll_loop 后续补注册和结算。
5. artifact 已注册或本次无 artifact 时,调用 `settle_compute_usage()` 异步结算。
6. 结算失败时只记录日志,不抛异常(pending_usage 兜底)。
#### 5.3.3 threads.py
创建 StreamHandler 时传入 `request.app.state._state` 作为 `app_state` 参数,以及 `plan` 和 `execution_cfg`。
### 5.4 create_cli_agent 改造
在现有 `create_cli_agent()` 签名末尾追加九个参数(均有默认值,不破坏现有调用):
- `thread_id: str = ""`:Web 线程 ID,用于 auth_context 签名。
- `compute_quota_ok: bool = False`:预检结果。
- `remaining_compute_minutes: int = 0`:剩余免费分钟数(用于 Agent 内部感知)。
- `compute_client: ComputeClient | None = None`:HTTP 客户端实例。
- `input_objects: list[InputObject] | None = None`:StorageService 生成的输入对象 manifest。
- `artifact_prefix: str = ""`:StorageService/Gateway 生成的 artifact 写入前缀。
- `default_output_paths: list[str] | None = None`:destroy 时默认上传的输出路径。
- `max_runtime_seconds: int | None = None`:Gateway preflight 后的有效运行上限,Web compute 必须传入。
- `budget_snapshot: dict | None = None`:Gateway 生成的预算审计快照,传给 create_environment。
- `storage_backend: str = "local"`:用于诊断和非计算降级策略判断。
- `_backend_ref: dict | None = None`:出参,写入 `{"backend": ContainerSandboxBackend}`。
内部逻辑:
- CLI 路径(source != "web"):行为完全不变,使用 CustomSandboxBackend。
- Web 路径:必须使用 ContainerSandboxBackend 作为 default backend。若 compute_client 缺失、不可用、配额不足或 input_objects 构造失败,create_cli_agent 必须返回受控错误或只读/非计算 Agent;不得把 MultiRootSandboxBackend 设置为可执行 default。
- ContainerSandboxBackend 首次 `_ensure_env()` 调用 create_environment 时必须传入 input_objects、artifact_prefix、resource_class、budget_snapshot 和 max_runtime_seconds;Web compute 若缺少 effective `max_runtime_seconds` 或 budget_snapshot,必须拒绝创建 environment。
- MCP tools 加载:`_load_mcp_tools_cached()` 保持现有签名不变,返回的 MCP tools 全部注入 Agent(不包括 compute tools,因为它们走 HTTP 独立通道)。
**CompositeBackend 路由说明(详见附录 A):**
- `default` backend(即 ContainerSandboxBackend):处理 /workspace、/__global__、/threads/* 以及 execute()。
- `/skills/` 和 `/memory/` 路径始终由 CustomSandboxBackend/MultiRootSandboxBackend 处理,不受 compute 后端影响。
- 这意味着 skills 和 memory 文件始终存储在本地,不经过远程执行环境。
---
## 6. 计费系统设计
### 6.1 计费模型
**配额类型:**
- **compute_minutes_remaining**:用户当前剩余的免费 compute 分钟数(整数)。
- **cash_balance**:用户现金余额(NUMERIC(12,6),单位为元)。
- **overtime_price_per_minute**:超出免费配额后的每分钟价格(由 pricing.json 的 compute_pricing 定义)。
**计费粒度(设计意图):**
- compute_minutes = `MAX(1, CEIL(runtime_seconds / 60))`
- 含义:每次 environment 销毁时,按实际运行秒数折算为分钟,不足 1 分钟按 1 分钟计。这是一个有意的产品决策——简化计费模型并覆盖 environment 创建/销毁的固定开销。
- Agent 交互模式下的影响:Agent 在一次对话中频繁创建/销毁 environment 的场景不应出现(正常路径下一次对话对应一个 environment)。若出现异常(如频繁重试),ResourceManager 的 create_environment 限流和 max_envs_per_user 会自然约束。
**扣减顺序:**
1. 先扣 `compute_minutes_remaining`(免费配额)。
2. 超出部分 = `overtime_minutes × overtime_price_per_minute`,从 `cash_balance` 扣减。
3. 同时累加 `compute_minutes_used`、`compute_minutes_lifetime`、`compute_overtime_charged`。
4. 扣费后写入 `wallet_ledger` 审计账本(entry_type: "compute_overtime", direction: "debit")。
**金额精度:**
- 所有金额字段在数据库中为 `NUMERIC(12,6)`。
- Python 侧统一使用 `Decimal`,quantize 到 6 位小数(`Decimal("0.000001")`),舍入模式为 `ROUND_HALF_UP`。
- 禁止 float 参与任何扣款计算。
### 6.2 配额来源
**套餐定义:** pricing.json 的每个 plan 增加 `monthly_compute_minutes` 字段。
pricing.json 结构(compute 相关部分):
```json
{
"compute_pricing": {
"version": "2026-05-23",
"overtime_price_per_minute": 0.05,
"currency": "CNY"
},
"plans": {
"starter": { "monthly_compute_minutes": 0, "monthly_price": 0 },
"pro": { "monthly_compute_minutes": 300, "monthly_price": 49 },
"max": { "monthly_compute_minutes": 1000, "monthly_price": 99 },
"ultra": { "monthly_compute_minutes": 3000, "monthly_price": 199 }
}
}
```
| Plan | monthly_compute_minutes |
|------|------------------------|
| starter | 0(不可用 compute) |
| pro | 300 |
| max | 1000 |
| ultra | 3000 |
**pricing_version 定义:**
- `pricing_version` 是由 Gateway 在启动时从 `pricing.json` 的 `compute_pricing.version` 字段读取的字符串,默认为 `pricing.json` 文件的 SHA-256 前 8 位 hex(如 `"a3f2b1c0"`),运维可通过 `compute_pricing.version` 显式覆盖为语义版本(如 `"2026-05-23"`)。
- 每次 `pricing.json` 变更时必须更新 `compute_pricing.version`;未显式设置时系统自动使用文件内容的 SHA-256 前 8 位,确保版本与定价内容一一对应。
- `pricing_version` 写入 `budget_snapshot` 和 `compute_usage_log.pricing_snapshot`,用于审计追溯:每次结算可确定当时使用的定价版本。
- `pricing_version` 格式无强制约束(可为日期、semver、hash),但必须保证同一版本的 pricing 内容完全一致。
**授额时机:**
- **首次开通套餐**:初始化为对应套餐的 monthly_compute_minutes,并设置 quota_period_start/end。
- **订阅续期/月度刷新**:后台任务每 3600s 扫描 `compute_quota_period_end < NOW()` 且订阅未过期的用户,按当前 plan 重置下一周期额度;P0 不结转未用完分钟。
- **升级套餐**:P0 默认立即切换到新 plan,并将当前周期剩余额度设置为 `max(current_remaining, new_plan_monthly_compute_minutes)`,避免升级导致剩余额度减少;不做按天折算。
- **套餐降级**:P0 默认下个周期生效;若业务要求立即降级,则当前 active/pending environment 按变更前 pricing_snapshot 结算,变更后将剩余额度截断为 `min(current_remaining, new_plan_monthly_compute_minutes)`。
- **降级到 starter**:不再允许创建新 environment;已有 active/pending environment 按创建时 pricing_snapshot 结算后,下一周期 compute_minutes_remaining 清零。
- **管理员手动调整**:通过管理后台 API 直接增减 compute_minutes_remaining,并记录操作日志。
**现有用户 backfill:** `init_gateway_db()` 完成后执行一次:对 `compute_minutes_remaining = 0` 且 `compute_minutes_used = 0` 且 `plan != 'starter'` 的用户,按当前 plan 填充初始额度。不覆盖已有使用记录。
### 6.3 计费流程
**正常路径(路径 A):**
1. StreamHandler cleanup 调用 `backend.destroy()`。
2. k8s-exec-service-mcp destroy_environment 返回 usage/artifacts/artifacts_lost/state,同时将 usage 和 output_manifest_json 持久化写入 pending_usage 表(settled=0)。
3. Gateway 调用 `settle_compute_usage()`:
- 若 pending_usage 或 destroy 响应包含 `output_manifest_json` 且 `artifacts_registered=false`,先调用 `StorageService.register_artifacts()`;该操作必须以 artifact_id 或 object_key 幂等。
- artifact 注册成功后调用 admin mark-artifacts-registered;P0 不允许通过 mark-settled 顺带更新 `artifacts_registered`。
- artifact 注册失败时记录 `artifact_registration_error` 并递增注册重试计数;未达到 `ARTIFACT_REGISTRATION_MAX_RETRIES`(默认 10)前保留 pending_usage 给 poll_loop 补注册。达到上限后,将 pending_usage 标记为 `artifacts_unregistered=true`/`artifact_registration_failed`,继续执行 compute 结算;用户侧显示 artifact 元数据异常,但计算用量不得无限免单。
- 在一个独立数据库事务中,对 compute_usage_log 执行 INSERT ON CONFLICT (environment_id) DO NOTHING 作为 claim;若未插入新行,只能在已存在记录状态为 `settled`/完整字段存在时返回 already_settled。
- 同一事务内执行 `_deduct_compute_balance()`:扣减 compute_minutes_remaining 和 cash_balance,写入 wallet_ledger。
- 同一事务内更新 compute_usage_log 的 quota_remaining、overtime_charged、currency、pricing_snapshot、settlement_status='settled'。
- 事务提交前任何异常都必须回滚 claim、扣款和 ledger;禁止留下“已 claim 未扣费”的 compute_usage_log。
4. poll_loop 下一轮发现 pending_usage 记录,调用 settle 得到 already_settled 仅表示完整结算已提交,然后调用 mark_usage_settled 清理。
**异常路径(路径 B):**
1. k8s-exec-service-mcp 的 idle timeout 或清理协程检测到僵死 environment。
2. `_record_pending_usage(settled=0)` 持久化写入。
3. poll_loop 轮询到该记录,若存在 output_manifest_json,先补注册 artifacts,再执行 settle → mark_usage_settled。
**服务重启路径(路径 C):**
1. k8s-exec-service-mcp 重启,从持久化存储恢复所有 environment 映射。
2. 发现 backend 资源已不可达的 environment,标记为 orphan,按 crash recovery 规则估算 usage 后写入 pending_usage。
3. 对应的 Pod/容器由 Kubernetes GC 或 LocalBackend 清理协程回收。
4. poll_loop 轮询到该 pending_usage 记录,正常结算。
**关键幂等保证:** 同一 environment_id 的重复结算(正常路径和异常路径竞争)由 `INSERT ON CONFLICT DO NOTHING` + 单事务提交保证只扣费一次。先 INSERT 成功者获得结算权并必须在同一事务内完成扣款与日志;后到达者只有在检测到已有记录 `settlement_status='settled'` 时才返回 already_settled,否则必须返回 SettlementInProgress/SettlementIncomplete 并稍后重试。
### 6.3.1 poll_loop 详细规格
poll_loop 是 Gateway 的后台异步任务,负责兜底结算 k8s-exec-service-mcp 侧产生的 pending_usage 记录。以下为 P0 规格:
**触发条件:**
- compute enabled 模式下 Gateway lifespan 中 ComputeClient.ready=True 且 `check_poll_available()=True` 时启动。
- compute enabled 模式要求 `EXECUTION_ADMIN_TOKEN` 必须设置;若管理端点暂不可用,poll_loop 进入延迟重试,不允许以无兜底结算模式长期运行。compute disabled 模式不启动 poll_loop。
**轮询参数:**
| 参数 | 默认值 | 说明 |
|------|--------|------|
| 轮询间隔 | 60s | 每轮结束后 sleep 60s 再开始下一轮 |
| 批量大小 | 50 | 每次 `list_pending_usage` 最多获取 50 条记录 |
| 单条结算超时 | 30s | settle + mark_settled 总超时 |
| 连续失败阈值 | 5 | 连续 5 次 HTTP 错误后暂停 600s 再重试 |
**单轮流程:**
1. 调用 `compute_client.list_pending_usage(admin_token, settled=0)`,获取最多 50 条未结算记录。
2. 若 HTTP 错误:递增连续失败计数;若达到阈值(5 次),记录 ERROR 并 sleep 600s 后重置计数重试。
3. 对每条 pending_usage 记录:
a. 若 `output_manifest_json` 非空且 `artifacts_registered=false`:
- 调用 `storage_service.register_artifacts(user_id, thread_id, artifacts)` 补注册 artifact metadata。
- 成功后调用 `compute_client.mark_artifacts_registered(admin_token, usage_id=record.id)`。
- 注册失败时记录 ERROR 并递增注册重试计数;未达到上限则保留该 pending_usage,不执行本条结算;达到上限则将记录标记为 artifacts_unregistered/artifact_registration_failed 并继续执行 compute 结算。
b. 调用 `settle_compute_usage(environment_id, user_id, thread_id, runtime_seconds, ...)`。
c. 若返回 `already_settled`:调用 `compute_client.mark_usage_settled(admin_token, [record.id])`。
d. 若返回 `InsufficientComputeBalance`:记录 WARNING,保留 pending_usage 供用户充值后重试。
e. 其他错误:记录 ERROR,继续下一条。
4. 本轮处理完毕后 sleep 60s,进入下一轮。
**并发安全:**
- poll_loop 与 StreamHandler 的 settle 调用可能同时处理同一 environment_id。`INSERT ON CONFLICT DO NOTHING` 保证只有一个成功,另一个返回 already_settled。
- `mark_usage_settled` 幂等:重复标记同一 ID 不报错(UPDATE settled=1 WHERE id=$id AND settled=0)。
### 6.4 余额不足处理
- `_deduct_compute_balance()` 先计算:`free_to_use = min(compute_minutes_remaining, compute_minutes)`;`overtime_minutes = compute_minutes - free_to_use`;`overtime_amount = overtime_minutes × overtime_price_per_minute`(单位元,NUMERIC(12,6))。
- UPDATE 条件只要求 `cash_balance >= overtime_amount`,不要求 `compute_minutes_remaining >= compute_minutes`;支持“部分免费额度 + 现金补差”。同一 UPDATE 将 `compute_minutes_remaining = compute_minutes_remaining - free_to_use`、`cash_balance = cash_balance - overtime_amount`、`compute_minutes_used/lifetime += compute_minutes`、`compute_overtime_charged += overtime_amount`。
- 余额不足时 UPDATE 影响 0 行 → 抛出 InsufficientComputeBalance → 整个 settle 事务回滚(包括 compute_usage_log claim 和 wallet_ledger)→ pending_usage 保留。
- poll_loop 后续重试(用户充值后自动恢复)。
- preflight 阶段(`_check_compute_quota_async`)已在 Agent 启动前拦截:starter 用户、配额耗尽且现金不足的用户直接返回 `ComputeQuotaExceeded` 或进入非计算只读降级,不创建容器,不本地执行。
### 6.5 审计要求
- 所有 compute 现金扣费写入 wallet_ledger(entry_type: "compute_overtime", source_type: "compute_usage")。
- 幂等键格式:`compute:{user_uid}:{thread_id}:{environment_id}`;若数据库同时保存整数 FK,字段必须命名为 user_id_int,外部稳定 UID 命名为 user_uid。
- 与 token 计费(token_usage_log + wallet_ledger)使用相同的审计标准。
- compute_usage_log 记录每次结算的 runtime_seconds、compute_minutes、quota_remaining、overtime_charged、currency、pricing_snapshot。
- wallet_ledger 表结构见第 7.1 节。
---
## 7. 数据库设计
### 7.1 表变更清单
以下所有变更必须在 `init_gateway_db()` 中幂等执行。
**user_balances 表:**
| 列名 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| cash_balance | NUMERIC(12,6) | 0 | **类型迁移**(原 INTEGER) |
| total_spent | NUMERIC(12,6) | 0 | **类型迁移**(原 INTEGER) |
| compute_minutes_remaining | INTEGER | 0 | 剩余免费 compute 分钟 |
| compute_minutes_used | INTEGER | 0 | 当前周期已用分钟 |
| compute_minutes_lifetime | INTEGER | 0 | 累计总分钟 |
| compute_overtime_charged | NUMERIC(12,6) | 0 | 累计超时扣费 |
| compute_quota_monthly | INTEGER | 0 | 每月配额基准值 |
| compute_quota_refreshed_at | TIMESTAMP | NULL | 上次授额时间 |
| compute_quota_period_start | TIMESTAMP | NULL | 当前配额周期开始 |
| compute_quota_period_end | TIMESTAMP | NULL | 当前配额周期结束 |
| compute_quota_adjustment | INTEGER | 0 | 管理员手动调整累计值 |
**recharge_records 表:**
| 列名 | 类型 | 说明 |
|------|------|------|
| charge_amount | NUMERIC(12,6) | **类型迁移**(原 REAL) |
**wallet_ledger 表(若不存在则新建):**
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | INTEGER | PK, IDENTITY | 自增主键 |
| user_id | INTEGER | FK → users(id), NOT NULL | 用户 ID |
| entry_type | TEXT | NOT NULL | 条目类型:token_usage, compute_overtime, recharge, quota_grant, admin_adjustment |
| source_type | TEXT | NULL | 来源类型:token_usage, compute_usage, manual |
| direction | TEXT | NOT NULL | debit(扣款)或 credit(入账) |
| amount | NUMERIC(12,6) | NOT NULL | 金额 |
| balance_after | NUMERIC(12,6) | NOT NULL | 操作后余额 |
| idempotency_key | TEXT | UNIQUE, NOT NULL | 幂等键(compute 扣费用 `compute:{user_id}:{thread_id}:{environment_id}` 格式) |
| description | TEXT | NULL | 可读描述 |
| created_at | TIMESTAMP | DEFAULT NOW() | 创建时间 |
索引:
- `idx_wallet_ledger_user_created` ON (user_id, created_at DESC)
- `idx_wallet_ledger_entry_type` ON (entry_type)
**compute_usage_log 表(新建):**
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | INTEGER | PK, IDENTITY | 自增主键 |
| user_id_int | INTEGER | FK → users(id) | Gateway 内部用户 ID;若沿用旧列名 user_id,应用层必须明确其为 int FK,不得与 user_uid 混用 |
| user_uid | TEXT | NOT NULL | 外部稳定用户 UID,来自 auth_context/user_files.owner_user_id |
| thread_id | TEXT | NOT NULL | 会话线程 ID |
| environment_id | TEXT | UNIQUE, NOT NULL | 幂等键 |
| plan | TEXT | NOT NULL | 结算时的套餐 |
| runtime_seconds | REAL | NOT NULL | 实际运行秒数 |
| compute_minutes | INTEGER | NOT NULL | 计费分钟数(ceil(runtime/60),最小 1) |
| quota_remaining | INTEGER | NULL | 扣减后剩余配额 |
| overtime_charged | NUMERIC(12,6) | DEFAULT 0 | 超时扣费金额 |
| currency | TEXT | DEFAULT 'CNY' | 币种 |
| pricing_snapshot | TEXT | NULL | 结算时定价快照(JSON) |
| settlement_status | TEXT | DEFAULT 'pending' | 结算状态:pending(待结算)、claimed(已声明结算权但未完成扣款)、settled(已完成扣款和 ledger 写入)、failed(结算失败需重试)。用于区分 INSERT ON CONFLICT 的"已存在记录但可能是未完成结算"与"已完成结算" |
| settlement_error | TEXT | NULL | 最近一次结算失败原因 |
| created_at | TIMESTAMP | DEFAULT NOW() | 创建时间 |
索引:
- `idx_compute_usage_log_user_created` ON (user_id_int, created_at DESC)
- `idx_compute_usage_log_user_uid_created` ON (user_uid, created_at DESC)
- `idx_compute_usage_log_thread` ON (thread_id)
**user_files 表(新建或替换现有文件元数据来源):**
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| file_id | TEXT | PK | 文件 ID |
| owner_user_id | TEXT | NOT NULL | 用户 UID(外部稳定 ID;如同时需要内部 FK,使用 user_id_int 单独列,禁止混用语义) |
| thread_id | TEXT | NULL | 所属线程 |
| project_id | TEXT | NULL | 所属项目 |
| logical_path | TEXT | NOT NULL | 用户可见路径 |
| object_key | TEXT | NOT NULL | 对象存储 key;LocalStorageBackend 可记录本地相对 key |
| storage_backend | TEXT | NOT NULL | local 或 s3 |
| sha256 | TEXT | NOT NULL | 内容校验 |
| size_bytes | INTEGER | NOT NULL | 文件大小 |
| mime_type | TEXT | NULL | MIME 类型 |
| source | TEXT | NOT NULL | upload / agent / artifact / import |
| version | INTEGER | DEFAULT 1 | 同 logical_path 的版本号 |
| status | TEXT | NOT NULL | active / deleted / pending / orphan |
| created_at | TIMESTAMP | DEFAULT NOW() | 创建时间 |
| updated_at | TIMESTAMP | DEFAULT NOW() | 更新时间 |
| expires_at | TIMESTAMP | NULL | 到期清理时间 |
| metadata_json | TEXT | NULL | artifact、解析状态、来源 job 等扩展信息 |
索引:
- `idx_user_files_owner_path` ON (owner_user_id, logical_path)
- `idx_user_files_thread` ON (thread_id)
- `idx_user_files_object_key` ON (object_key)
- `idx_user_files_expires_at` ON (expires_at)
唯一性:
- 写入前必须对 `logical_path` 做统一规范化:UTF-8 NFC、使用 `/` 分隔、折叠重复 `/`、禁止空段/`.`/`..`/NUL/控制字符、禁止保留前缀冲突,明确是否以 `/` 开头并全库一致;权限校验、唯一索引、manifest mount_path 均使用规范化后的值。
- P0 采用"单 active 版本"模型:同一 `(owner_user_id, logical_path)` 同时只能有一条 `status='active'` 记录。
- Gateway 数据库为 PostgreSQL,使用 partial unique index:
`CREATE UNIQUE INDEX IF NOT EXISTS uq_user_files_active_path ON user_files(owner_user_id, logical_path) WHERE status = 'active'`。
- 覆盖上传时 `version = previous.version + 1`,旧版本 status 改为 deleted;P1 可扩展为完整版本历史 UI。
StorageService 写文件必须先写对象存储,再写 metadata;删除文件默认 soft delete,后台 cleanup 再删除对象,避免数据库事务和对象存储操作无法原子提交导致的数据丢失。
**fallback_sync_recovery 表(新建):**
用于记录 MultiRootSandboxBackend 非计算降级 workspace 同步失败后的可恢复状态。该表属于 Gateway 数据库,由 StorageService cleanup/retry 任务处理。
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | INTEGER | PK, IDENTITY | 自增主键 |
| user_id | TEXT | NOT NULL | 用户 UID |
| thread_id | TEXT | NOT NULL | 来源线程 |
| workspace_path | TEXT | NOT NULL | 受控 recovery/orphan workspace 路径 |
| failed_files_json | TEXT | NOT NULL | 失败文件清单、logical_path、sha256、size、错误信息 |
| status | TEXT | NOT NULL | pending / retrying / resolved / abandoned |
| retry_count | INTEGER | DEFAULT 0 | 已重试次数 |
| next_retry_at | TIMESTAMP | NULL | 下次自动重试时间 |
| last_error | TEXT | NULL | 最近错误 |
| created_at | TIMESTAMP | DEFAULT NOW() | 创建时间 |
| updated_at | TIMESTAMP | DEFAULT NOW() | 更新时间 |
| resolved_at | TIMESTAMP | NULL | 完成或放弃时间 |
索引:
- `idx_fallback_sync_recovery_status_retry` ON (status, next_retry_at)
- `idx_fallback_sync_recovery_user_thread` ON (user_id, thread_id)
处理规则:
- StreamHandler 在非计算降级同步失败时,必须先移动 workspace 到受控 recovery/orphan 目录,再写入 `fallback_sync_recovery(status='pending')`;任一步失败都不得删除原 workspace。
- StorageService cleanup/retry 任务扫描 `status IN ('pending','retrying') AND next_retry_at <= now()` 的记录,重新执行 `put_file()`;全部成功后标记 `resolved` 并删除 recovery workspace。
- 重试退避使用 `min(300s, 2 ** retry_count * 10s)`;超过 `FALLBACK_SYNC_MAX_RETRIES`(默认 10)后标记 `abandoned`,保留 workspace,等待管理员显式清理。
- recovery/orphan workspace 必须位于受控目录,权限限制为服务账号可读写;必须设置总量 quota、单用户 quota 和最大保留期(默认 7 天,管理员可配置)。超过保留期或管理员确认后执行安全删除;若底层存储支持加密,生产环境应启用 encryption-at-rest。
- 管理 API 或后台管理页面必须能按 user_id/thread_id/status 查询 recovery 记录,并允许管理员在确认风险后清理 `abandoned` workspace。
### 7.2 Migration 幂等策略
- `ADD COLUMN`:统一使用 `IF NOT EXISTS`。
- `CREATE TABLE`:统一使用 `IF NOT EXISTS`。
- `CREATE INDEX`:统一使用 `IF NOT EXISTS`。
- SQLite 不支持直接 `ALTER COLUMN TYPE`;金额字段类型迁移采用新表重建策略:创建临时表 → 拷贝并规范化金额字符串/NUMERIC 值 → 重命名旧表备份 → 重命名新表 → 重建索引。migration 必须幂等,重复启动不会失败。
- 旧 INTEGER/REAL 金额列迁移到 NUMERIC 语义时,迁移脚本必须在新表中以文本/Decimal 兼容格式写入,应用层统一用 `Decimal` 读写并校验 scale=6。
- 补列(针对已有表但缺列的场景):同样使用 `ADD COLUMN IF NOT EXISTS`。
---
## 8. k8s-exec-service-mcp 服务设计
### 8.1 项目结构
独立仓库,包名为 `k8s_exec_service_mcp`。仓库必须创建在 `/Users/m4/Projects/EvoSci/MCP/k8s-exec-service-mcp`,不得放在 EvoScientist monorepo 内,也不得使用旧的 `/Users/m4/Projects/EvoSci/mcp` 小写目录。核心模块:
```
/Users/m4/Projects/EvoSci/MCP/k8s-exec-service-mcp/
├── Dockerfile # 服务镜像构建
├── pyproject.toml
├── src/k8s_exec_service_mcp/
│ ├── app.py # Starlette ASGI 应用,挂载路由
│ ├── server.py # 工具处理函数(13 P0 + 2 P1 + 1 P2)
│ ├── service.py # ComputeService — 认证、工具端点编排、orphan 检测、pending_usage 记录
│ ├── resource_manager.py # ResourceManager — 统一生命周期、调度、环境映射、后端健康和用量采集
│ ├── warm_pool.py # WarmPoolManager — P0 预热环境池创建、补足、绑定、回收
│ ├── scheduler.py # 调度策略实现
│ ├── object_storage.py # S3/MinIO 客户端、输入 hydration、artifact upload
│ ├── backend_protocol.py # BackendAdapter 协议定义
│ ├── local_backend.py # LocalBackend
│ ├── kubernetes_backend.py # KubernetesBackend
│ ├── models.py # 数据模型(EnvironmentEntry, BackendState, ResourceRef, PendingUsage 等)
│ ├── config.py # YAML 配置加载
│ └── storage/
│ ├── __init__.py
│ ├── database.py # SQLite 连接管理、migration、WAL 配置
│ ├── environment_repo.py # Environment 映射 CRUD
│ ├── pending_usage_repo.py # Pending usage CRUD
│ ├── backend_repo.py # Backend registry CRUD
│ ├── nonce_repo.py # HMAC nonce 防重放与 TTL cleanup
│ └── audit_repo.py # Admin audit log
├── runtime/ # 执行环境镜像定义
│ └── python/
│ └── Dockerfile # evosci-exec-python 镜像
├── deploy/
│ ├── local-standalone/
│ └── local-k8s/
└── tests/
```
**Dockerfile 设计要点:**
- 基础镜像:`python:3.12-slim`
- 安装依赖:Starlette、uvicorn、httpx、Kubernetes Python client、Docker SDK
- 复制源码 + 配置文件模板
- 暴露端口 9020
- 以非 root 用户运行(`USER 1000`)
- ENTRYPOINT 启动 uvicorn
**LocalBackend 运行方式:**
P0 本地单机默认使用 `host-process` 方式运行;`rootless-podman` 作为 P0 可选方式。必须在 `deploy/local-standalone/README.md` 中明确默认路径和可选路径:
| 模式 | 说明 | 适用场景 | 安全要求 |
|------|------|----------|----------|
| host-process | k8s-exec-service-mcp 作为宿主机进程运行,通过 rootless Docker/Podman CLI 或专用用户 Unix socket 管理执行容器 | P0 默认,开发机、私有服务器 | service 进程使用专用系统用户;runtime_root 独立目录;不得以 root 运行用户命令 |
| rootless-podman | k8s-exec-service-mcp 容器内使用 rootless Podman 或连接 rootless Podman socket | 更接近容器化部署 | 禁止挂载 Docker root socket;Podman socket 只允许专用用户访问 |
P0 不推荐把宿主机 `/var/run/docker.sock` 挂载进服务容器。若用户显式启用 Docker socket 模式,文档和配置必须标记为 `insecure_docker_socket: true`,并给出风险说明:拿到该 socket 等价于获得宿主机 root 级容器控制能力。生产本地部署必须优先使用 host-process + rootless Docker/Podman 或 rootless-podman。
### 8.2 ResourceManager
ResourceManager 是 k8s-exec-service-mcp 的核心控制面。所有工具端点不得直接调用 LocalBackend 或 KubernetesBackend,必须通过 ResourceManager。
**职责:**
- 接收 create_environment 请求,调用 Scheduler 选择后端,并优先尝试 WarmPoolManager 绑定可用预热环境。
- 维护 `environment_id -> backend_type/backend_id/resource_ref/user_id/thread_id/resource_class/state` 映射,持久化到 storage 层。
- 根据 `input_objects` 调用 ObjectStorageClient 下载输入文件;若命中预热环境,则在绑定后先 workspace reset,再 hydration。若冷启动,则先创建空后端资源并确认 workspace 可写,再 hydration,最后置为 `RUNNING`。
- 统一分发 execute/read/write/edit/list/grep/glob/upload/download/status/destroy。
- destroy_environment 时根据 `persist_outputs/output_paths` 上传 artifact,并返回 artifact manifest。
- 维护 backend health、active_envs、容量、错误率和 drain/maintenance 状态。
- 统一写入 usage、pending_usage 和 admin audit log,所有写入操作持久化。
- 对所有后续操作执行 ownership 校验,禁止跨用户访问 environment。
- 提供 cleanup,回收 idle/orphan environment,并委托 WarmPoolManager 维护预热池 min_idle/max_idle/warm_ttl。
- 服务启动时从持久化存储恢复 environment 映射,验证后端资源可达性。
### 8.2.1 WarmPoolManager(P0)
WarmPoolManager 是 P0 必须实现的预热池组件,用于降低 `create_environment` 冷启动延迟。预热环境只预创建底层容器/Pod、执行镜像和空 workspace,不绑定用户、不下载输入、不写 artifact_prefix。
**核心职责:**
- 按 `warm_pools` 配置维护 `(backend_type, backend_id, resource_class, image)` 维度的空闲预热环境。
- 在服务启动、配置变更、环境绑定后,异步补足 `min_idle`。
- 控制 `max_idle`,避免预热池占满 LocalBackend 或 Kubernetes namespace 配额。
- 为 ResourceManager 提供原子 `acquire(pool_key)`,将 `WARM_IDLE` 记录置为 `WARM_BINDING`。
- 绑定成功后清空预热标记,写入 user_id/thread_id/auth_context 派生信息,hydration 输入对象,并转为 `RUNNING`。
- 绑定失败、健康检查失败、超出 TTL 或 generation 过期时销毁预热资源,不得复用。
- 后端 drain/maintenance 时停止补池;已有 `WARM_IDLE` 预热环境应逐步清理。
**预热环境安全边界:**
- 预热环境不得包含用户文件、用户命令、token、object_scope、artifact_prefix、budget_snapshot。
- 预热 workspace 必须为空;绑定用户前必须执行 workspace reset,确认 `/workspace`、`/__global__`、`/threads/*` 无用户残留。
- 预热环境不能跨 image/resource_class/backend_type 复用。
- destroy 后的用户环境不得回收到 warm pool;P0 只允许“新建预热 → 首次绑定 → 使用 → 销毁”,不做用户环境复用。
- Kubernetes 预热 Pod 必须使用与执行 Pod 相同的安全上下文、NetworkPolicy、ResourceQuota 和 LimitRange。
**调度顺序:**
1. Scheduler 根据 backend_policy/resource_class/image/plan/健康/容量选出候选 backend。
2. ResourceManager 计算 `warm_pool_key`。
3. 若 warm pool enabled 且存在 `WARM_IDLE`,原子绑定一条预热环境。
4. 若绑定成功,执行 input hydration 和路径挂载初始化,置为 `RUNNING`。
5. 若无可用预热环境或绑定失败,走冷启动 `CREATING` 路径。
6. 每次绑定或冷启动后,WarmPoolManager 异步检查并补足对应 pool。
**配置示例:**
```yaml
warm_pools:
enabled: true
refill_interval_seconds: 10
default_warm_ttl_seconds: 1800
pools:
- backend_policy: local
resource_class: small
image: evosci-exec-python:3.12
min_idle: 1
max_idle: 2
- backend_policy: kubernetes
resource_class: small
image: evosci-exec-python:3.12
min_idle: 1
max_idle: 3
```
P0 默认配置要求至少支持 `small + 默认 Python 执行镜像` 的预热池,且该 pool `min_idle >= 1`。GPU、大内存和长启动镜像可配置 `min_idle=0`,但 WarmPoolManager 代码路径必须可用。若管理员显式把默认 pool 的 `min_idle` 降为 0,服务仍可运行,但 `/ready` checks 必须暴露 `required_default_pool=degraded`,用于提示当前未满足 P0 性能目标。
**调度输入:**
- `backend_policy`:`auto | local | kubernetes`。默认 `auto`。
- `resource_class`:资源档位,例如 small、medium、large、gpu-small。
- 用户 plan 与配额。
- 后端健康状态、当前 active_envs、容量、最近错误率。
- 镜像兼容性:某些镜像只能在 Kubernetes 或 LocalBackend 运行。
**调度规则:**
- `backend_policy=local`:只选择 LocalBackend;不可用时返回 503。
- `backend_policy=kubernetes`:只选择 KubernetesBackend;不可用时返回 503。
- `backend_policy=auto`:优先选择满足 resource_class、plan、镜像、健康和容量约束的后端;P0 可采用固定优先级 + 容量检查,P1 增加 least-load 和成本策略。
- GPU、分布式任务、需要云上弹性的 resource_class 默认路由到 KubernetesBackend。
- 本地开发、小资源、低延迟任务可优先路由到 LocalBackend。
### 8.3 BackendAdapter 协议
所有后端必须实现同一组能力:
| 方法 | 说明 |
|------|------|
| create_environment | 冷启动创建后端资源,返回 resource_ref |
| create_warm_environment | 创建不绑定用户的预热后端资源,返回 resource_ref;workspace 必须为空 |
| bind_warm_environment | 将预热资源绑定为用户 environment 前执行 workspace reset 和运行期初始化 |
| destroy_environment | 按状态机销毁后端资源,返回 usage、artifacts、artifacts_lost、state/status;STOPPING_FAILED 时不得删除 workspace |
| get_environment_status | 查询状态、运行时长、命令计数 |
| execute_command | 在环境内执行命令 |
| read_file/write_file/edit_file | 文件读写和编辑 |
| list_dir/grep_files/glob_files | 文件系统查询 |
| upload_file/download_file | 文件传输 |
| get_resource_usage | 查询 CPU/内存/磁盘使用 |
| cleanup | 回收 idle/orphan 资源 |
| health | 返回健康状态、容量和最近错误 |
BackendAdapter 返回的错误必须转换为统一错误码,避免 EvoScientist 侧感知具体 runtime。
**文件传输语义:**
- HTTP API 不接受 Gateway 本地路径作为参数,避免服务端误读自身文件系统或暴露宿主路径。
- P0 `upload_file` 只支持 JSON body:`remote_path`、`content_base64`、`sha256`。HMAC canonical JSON 覆盖 `content_base64` 和 `sha256`。
- `download_file` 小文件返回 `content_base64`、size、sha256;大文件 P1 可返回流式响应或预签名对象存储 URL。
- multipart upload 属于 P1,签名必须改为 `content_sha256` + metadata canonical JSON;P0 不实现 multipart。
- BackendAdapter 内部可以使用本地临时文件、`kubectl exec + tar`、Docker archive API 等实现细节,但这些路径不得暴露到 HTTP 契约。
- 对象存储是长期文件内容层;`upload_file/download_file` 只用于 Gateway 与运行期 workspace 的小文件交互,不用于持久保存用户文件。
- P0 明确限制 `upload_file/download_file` 的单文件大小上限为 `EXEC_HTTP_FILE_MAX_BYTES`(默认 8MiB);超过上限必须返回 413,并要求走 StorageService 对象存储路径。
- 大文件、批量输入和结果 artifact 必须通过 `input_objects` 与 artifact manifest 走对象存储,避免 base64 HTTP body 成为瓶颈。
**执行 workspace 清理语义:**
- k8s-exec-service-mcp 只管理运行期执行目录,不实现“执行 workspace 本地缓存保留 N 天”功能。
- 用户上传文件、项目文件、输入数据和结果 artifact 的持久化由 EvoScientist StorageService 或统一对象存储负责;k8s-exec-service-mcp 只接收输入内容或对象引用,执行完成后返回结果内容或 artifact 引用。
- destroy_environment 只有在 artifact 上传成功、`persist_outputs=false` 或 `force_destroy=true` 时才删除对应执行环境的本地临时 workspace、临时 PVC 或 emptyDir 资源;STOPPING_FAILED 必须保留 workspace 以便重试。cleanup 处理 idle/orphan 执行资源、STOPPING_FAILED 重试、未结算 usage 和未绑定的 WARM_IDLE/WARMING 预热资源。预热池只保留未绑定空环境,不保留用户执行后的 workspace cache。
- 若需要调试历史结果,应从 EvoScientist StorageService/对象存储读取 artifact,不从 k8s-exec-service-mcp 的本地 workspace 读取。
### 8.4 LocalBackend
LocalBackend 用于管理本地部署资源。它不是 mock,而是 P0 必须可运行的真实执行后端。
**支持 runtime:**
- P0 默认:rootless Docker 或 rootless Podman 容器(host-service + rootless container runtime)。
- dev-only 可选:受控本地进程沙箱,仅用于可信开发环境,默认禁止 Web 用户请求使用。
- P1 可扩展:Firecracker、Kata Containers、sysbox 等更强隔离 runtime。
**职责:**
- 在配置的 runtime root 下创建运行期 workspace、global、read-only thread mounts;这些目录在 environment 销毁时同步删除。
- 创建受控本地容器或进程沙箱。
- 创建和销毁 LocalBackend 预热容器;预热容器必须使用空 workspace,绑定前执行 workspace reset。
- 对命令执行、文件操作、上传下载提供与 KubernetesBackend 相同的语义。
- 采集 runtime_seconds、command_count、exit_code、CPU/内存/磁盘用量。
- 回收 idle environment,持久化写入 pending_usage。
**安全约束:**
- 不允许用户传入宿主机绝对路径、任意 bind mount 或 runtime 参数。
- workspace 必须限制在配置的 `runtime_root` 之下,并做路径归一化;runtime root 不能作为用户文件或结果 artifact 的长期存储。
- 默认禁止 privileged、host network、host pid、host ipc;容器必须 non-root、no-new-privileges、capabilities drop ALL,优先使用 read-only rootfs + 明确 writable mounts。
- 必须设置 CPU、内存、进程数、文件大小、执行超时、stdout/stderr 最大字节数和并发限制。
- 网络访问默认允许出站(egress allow),生产可通过配置文件限制域名/IP 白名单。受控本地进程沙箱不得把用户命令直接放到宿主机裸 shell 执行;必须通过隔离工作目录、环境变量白名单、资源限制、超时包装和进程树 kill。
### 8.5 KubernetesBackend
**职责:**
- 使用 Kubernetes Python client 创建/删除 Pod。
- 创建和销毁 Kubernetes 预热 Pod;预热 Pod 只包含执行镜像和空 workspace,不挂载用户输入。
- 通过 Kubernetes exec 在 Pod 内执行命令。
- 通过 exec + tar/base64 或受控 sidecar 实现文件上传/下载。
- 返回 `namespace/pod_name` 作为 resource_ref,由 ResourceManager 维护 environment 映射。
- 为每个 Pod 设置 labels/annotations,便于清理、审计和监控。
**Pod 模板硬约束:**
- `restartPolicy: Never`
- `securityContext.runAsNonRoot: true`,并配置明确的非 0 `runAsUser/runAsGroup`(或镜像内 USER 保证非 root)
- `automountServiceAccountToken: false`
- `allowPrivilegeEscalation: false`
- `privileged: false`
- `capabilities.drop: ["ALL"]`
- `seccompProfile.type: RuntimeDefault`
- 优先设置 `readOnlyRootFilesystem: true`;如镜像不兼容,必须仅开放受控 writable mounts
- 禁止 `hostNetwork`、`hostPID`、`hostIPC`
- 禁止 `hostPath`
- 必须设置 CPU/memory requests 与 limits
- 必须设置 `activeDeadlineSeconds` 或由 idle cleanup 删除
- image 只能来自白名单 registry 或配置白名单;生产优先使用 digest 或不可变 tag,禁止用户直接提交任意 image 字符串
**resource_class 映射:**
客户端只允许传入资源档位,不允许传入原始 Kubernetes spec。服务端配置维护映射:
| resource_class | cpu request/limit | memory request/limit | nodeSelector/tolerations |
|----------------|-------------------|----------------------|--------------------------|
| small | 500m / 1 | 1Gi / 2Gi | 默认 CPU 节点池 |
| medium | 1 / 2 | 2Gi / 4Gi | 默认 CPU 节点池 |
| large | 2 / 4 | 4Gi / 8Gi | large CPU 节点池 |
| gpu-small | 2 / 4 + 1 GPU | 8Gi / 16Gi | GPU 节点池 |
若用户套餐不允许对应 resource_class,create_environment 返回 403。
### 8.6 Kubernetes 部署资源
P0 必须提供以下 Kubernetes manifests:
- Namespace:执行环境专用 namespace,例如 `evoscientist-exec`。
- ServiceAccount:`k8s-exec-service-mcp` 使用,最小权限。
- Role/RoleBinding:仅允许管理目标 namespace 内的 `pods`、`pods/exec`、`pods/log`、`events`。
- ResourceQuota:限制 namespace 总 CPU、内存、Pod 数、PVC 数。
- LimitRange:设置默认 requests/limits 和单 Pod 最大资源。
- NetworkPolicy:
- 标准 Kubernetes NetworkPolicy 使用 allow-list 语义,不支持显式 deny,也不支持按 DNS 名称阻断 `kubernetes.default.svc`。
- P0 使用 `policyTypes: [Egress]` + egress allow 规则实现可执行策略。
- 开发默认策略:单独允许 kube-dns/CoreDNS 的 UDP/TCP 53;允许公网 `0.0.0.0/0`,但通过 `ipBlock.except` 排除 Kubernetes Service CIDR、Pod CIDR、云元数据地址 `169.254.169.254/32` 和配置的内网敏感网段。
- 生产推荐策略:仅允许 DNS、包管理源、模型/API provider、对象存储等明确 IP/CIDR 出站白名单。
- 若需要域名级 allow/deny 或显式 deny,必须声明依赖 Cilium/Calico 等增强网络插件,不作为标准 P0 Kubernetes manifest 的默认能力。
- 管理平面(k8s-exec-service-mcp 自身 Pod)允许访问 Kubernetes API。
- Service:暴露 k8s-exec-service-mcp HTTP endpoint。
- Deployment:运行 k8s-exec-service-mcp;P0 副本数为 1。
- PVC(可选):为 SQLite 数据库提供持久存储(local-path provisioner),保证服务重启后数据不丢失。
- Secret:保存对象存储 endpoint、bucket、access key、secret key、region、TLS 配置和 artifact prefix。
P0 NetworkPolicy 必须拆成至少两条 egress allow 规则:
1. DNS 规则:通过 `namespaceSelector` + `podSelector` 指向 kube-system 中的 CoreDNS/kube-dns,放行 UDP/TCP 53。
2. 公网规则:`ipBlock.cidr: 0.0.0.0/0`,`except` 中包含 Service CIDR、Pod CIDR、`169.254.169.254/32` 和配置的内网敏感网段。
kind/k3d 的 smoke test 必须验证:
- `python -m pip --version` 或访问一个配置允许的公网测试地址成功。
- Pod 内不存在自动挂载的 service account token。
- 访问 `https://kubernetes.default.svc`、Kubernetes Service ClusterIP 和 API endpoint IP 均失败。
- 访问 `http://169.254.169.254` 失败。
P1 可增加:
- 镜像预拉取 DaemonSet。
- Kueue 队列与 ResourceFlavor。
- Karpenter/Cluster Autoscaler 节点池配置。
### 8.7 本地完整部署
P0 必须在独立仓库提供本地单机部署和本地 Kubernetes 部署。
本地单机部署目录:
```text
deploy/local-standalone/
├── config.local.yaml
├── run-minio.sh
├── run-host.sh
├── rootless-podman-compose.yaml
├── runtime-root.example/
├── secret.example.env
├── smoke-test.sh
└── README.md
```
本地 Kubernetes 部署目录:
```text
deploy/local-k8s/
├── kind-cluster.yaml
├── k3d-cluster.yaml
├── namespace.yaml
├── rbac.yaml
├── resource-quota.yaml
├── limit-range.yaml
├── minio.yaml
├── minio-pvc.yaml
├── minio-secret.example.yaml
├── network-policy.yaml
├── service.yaml
├── deployment.yaml
├── configmap.yaml
├── pvc.yaml
├── secret.example.yaml
├── smoke-test.sh
└── README.md
```
#### 8.7.1 本地单机部署流程
开发者本机完整部署步骤:
1. 构建服务镜像:`docker build -t k8s-exec-service-mcp:dev .`
2. 构建执行镜像:`docker build -t evosci-exec-python:3.12 ./runtime/python`
3. 启动或连接 MinIO:`deploy/local-standalone/run-minio.sh`,创建 `evoscientist` bucket。
4. 复制示例密钥:`cp deploy/local-standalone/secret.example.env .env.local`
5. 在 `.env.local` 中配置 `STORAGE_S3_*` 和 `EXEC_OBJECT_STORAGE_*`。
6. 启动服务(P0 默认 host-process):`deploy/local-standalone/run-host.sh`
7. Gateway 设置:`EXECUTION_SERVICE_URL=http://127.0.0.1:9020`
8. 执行 smoke test:`deploy/local-standalone/smoke-test.sh`
单机配置必须包含:
- `backend_mode: local`
- `storage.dsn: sqlite:///data/exec_service.db`(挂载到持久卷)
- `object_storage.backend: s3`
- `object_storage.endpoint: http://127.0.0.1:9000`(本地默认 MinIO)
- `object_storage.bucket: evoscientist`
- `object_storage.region: us-east-1`
- `object_storage.access_key` / `object_storage.secret_key`
- `object_storage.force_path_style: true`(MinIO 必须启用)
- `object_storage.artifact_prefix: jobs/`
- `local.runtime: docker|podman`
- `local.runtime_root`
- `local.max_envs_per_user`
- `local.max_global_envs`
- `local.default_egress_policy: allow`(默认允许出站)
- `resource_classes`
#### 8.7.2 kind 部署流程
开发者本机完整部署步骤:
1. 构建服务镜像:`docker build -t k8s-exec-service-mcp:dev .`
2. 创建集群:`kind create cluster --name evosci-exec --config deploy/local-k8s/kind-cluster.yaml`
3. 加载镜像:`kind load docker-image k8s-exec-service-mcp:dev --name evosci-exec`
4. 加载执行镜像:`kind load docker-image evosci-exec-python:3.12 --name evosci-exec`
5. 应用 MinIO manifests 或连接已有对象存储,确保 bucket `evoscientist` 存在。
6. 应用 k8s-exec-service-mcp manifests:`kubectl apply -f deploy/local-k8s/`
7. 等待服务 ready:`kubectl -n evoscientist-exec rollout status deploy/k8s-exec-service-mcp`
8. 本地转发:`kubectl -n evoscientist-exec port-forward svc/k8s-exec-service-mcp 9020:9020`
9. Gateway 设置:`EXECUTION_SERVICE_URL=http://127.0.0.1:9020`
10. 执行 smoke test:`deploy/local-k8s/smoke-test.sh`
#### 8.7.3 本地 smoke test
`smoke-test.sh` 必须验证:
- `GET /health` 返回 200。
- `GET /ready` 返回 200。
- `GET /metrics` 可读取。
- create_environment 创建本地 environment 或 Pod。
- execute_command 执行 `python --version`。
- write_file/read_file 往返一致。
- 通过 input_objects 从对象存储拉取输入文件到 workspace,并校验 sha256。
- destroy_environment 将指定 output_paths 上传为 artifact,返回 object_key/sha256/size_bytes。
- destroy_environment 删除后端资源并返回 usage。
- pending_usage 可通过 admin API 查询。
- `/admin/backends` 可看到启用后端及健康状态。
- 重启 k8s-exec-service-mcp 后,已销毁 environment 的 pending_usage 仍可通过 admin API 查询(验证持久化)。
#### 8.7.4 本地配置约束
- 本地单机路径必须启用 runtime root、资源限制、并发限制、路径隔离和超时。
- 本地单机 host-process 路径必须将 SQLite 数据库文件放在宿主持久目录(如 `~/.evoscientist/k8s-exec-service-mcp/exec_service.db` 或配置的 runtime data dir),确保服务重启后数据不丢失。
- rootless-podman 可选路径必须将 SQLite 数据库文件放在 rootless podman volume 中,不得挂载 Docker root socket。
- 本地 Kubernetes 路径必须启用 RBAC、ResourceQuota、LimitRange、NetworkPolicy。
- 本地 Kubernetes 路径必须创建 PVC 挂载 SQLite 数据库,确保 Pod 重启后数据不丢失。
- 本地 Kubernetes 执行 Pod 不允许 privileged、hostPath、hostNetwork。
- 本地 Kubernetes 默认使用 `emptyDir` 作为执行 Pod 的 workspace;如因运行时容量需要使用 PVC,也必须按 environment 生命周期清理,不提供跨 environment 的本地 workspace 缓存保留。
- 本地 admin token 和 auth secret 从 `secret.example.env` 或 `secret.example.yaml` 复制生成,不提交真实密钥。
### 8.8 Environment 生命周期
**状态转换:**
```
WARMING → WARM_IDLE → WARM_BINDING → RUNNING → STOPPING → STOPPED
↑ ↓ ↓
└─ CREATING (idle timeout) STOPPING_FAILED
```
- WARMING → WARM_IDLE:WarmPoolManager 成功创建空预热环境,未绑定用户。
- WARM_IDLE → WARM_BINDING:create_environment 命中 warm pool,预热环境被原子领取并开始绑定用户。
- WARM_BINDING → RUNNING:workspace reset、input hydration、auth_context/object_scope 绑定成功。
- WARM_BINDING → FAILED/STOPPED:绑定失败或预热环境被污染,必须销毁,不得放回 warm pool。
- CREATING → RUNNING:后端资源 ready,挂载就绪。映射关系持久化到 storage。
- RUNNING → STOPPING:destroy_environment 被调用,或 idle_timeout 超时。
- STOPPING → STOPPED:后端确认资源已删除,资源释放。pending_usage 持久化写入。
- STOPPING → STOPPING_FAILED:`persist_outputs=true` 时 artifact 上传失败,且 `force_destroy=false`。此状态必须保留运行期 workspace 以便重试 destroy。
- STOPPING_FAILED → STOPPED:重试 artifact 上传成功,或管理员/用户显式 `force_destroy=true`。
**服务重启状态恢复:**
- 服务启动时从持久化存储加载所有 WARMING/WARM_IDLE/WARM_BINDING/RUNNING/CREATING 状态的环境映射。
- 服务启动时也必须加载 STOPPING/STOPPING_FAILED 状态,优先恢复 artifact 上传或等待管理员 force_destroy。
- 对每个 WARMING/WARM_IDLE/WARM_BINDING/RUNNING/CREATING/STOPPING/STOPPING_FAILED environment,验证后端资源(Pod/容器)是否存在。
- WARM_IDLE 资源存在且 generation 仍匹配:恢复为可用预热环境;若 generation 过期或 TTL 过期,销毁并补池。
- WARMING/WARM_BINDING 资源存在:默认销毁并由 WarmPoolManager 补池;不得直接恢复为 RUNNING 或 WARM_IDLE。
- WARMING/WARM_IDLE/WARM_BINDING 资源不可达:标记 STOPPED,不写 pending_usage,不计费,并触发补池。
- RUNNING/CREATING 资源存在且可访问:恢复 RUNNING 状态,更新 last_active_at。
- RUNNING/CREATING 资源不可达:标记为 STOPPED,按 crash recovery 规则写入 pending_usage,触发 backend cleanup 回收可能残留的后端资源。
- STOPPING 资源仍可达:继续执行 destroy 流程;若需要上传 artifact,先上传 artifact,再删除资源。
- STOPPING_FAILED 资源仍可达:保持 STOPPING_FAILED,等待自动重试、Gateway 显式 destroy 重试或 admin force_destroy。
- STOPPING/STOPPING_FAILED 资源不可达:标记为 STOPPED,写 pending_usage,并设置 `artifacts_lost=true` 与 `estimate_reason='resource_lost_during_stopping'`,便于审计。
**Crash recovery 用量估算:**
- 若后端可提供实际结束时间或 runtime record,使用 `created_at -> observed_stop_at`。
- 若后端资源不可达且没有 runtime record,使用 `created_at -> recovery_checked_at`,但最大不超过 `created_at + max_runtime_seconds`。
- 若未设置 `max_runtime_seconds`,使用 `created_at -> min(recovery_checked_at, last_active_at + idle_timeout)`。
- pending_usage 必须记录 `usage_estimated=true`、`estimate_reason` 和使用的时间边界,便于审计。
**配额限制:**
- capacity-consuming states 定义:`CREATING/RUNNING/STOPPING/STOPPING_FAILED/WARMING/WARM_IDLE/WARM_BINDING` 均可能占用真实资源。
- `max_envs_per_user`:每用户最多同时持有 N 个用户绑定的 capacity-consuming environment(默认 3;不含 WARMING/WARM_IDLE,含 WARM_BINDING/CREATING/RUNNING/STOPPING/STOPPING_FAILED)。
- `max_global_envs`:执行服务最多同时管理 M 个 capacity-consuming environment(默认 20)。
- `max_warm_envs_global`:全局最多 W 个 WARM_IDLE/WARMING environment(默认 5),预热环境计入后端 capacity 和 max_global_envs,但不计入用户 max_envs_per_user。
- `warm_pool_reserved_capacity_ratio`:预热池最多占用后端容量比例(默认 20%),避免挤占真实用户请求。
- Kubernetes namespace 级 ResourceQuota 和 LocalBackend runtime 限制是第二层硬限制,防止服务侧 bug 造成资源失控。
- 超出配额时 create_environment 返回 429(用户级)或 503(全局级)。
**Warm pool 容量超限降级:** 当 `current_idle + current_warming + active_envs ≥ max_global_envs` 或预热池已占用超过 `warm_pool_reserved_capacity_ratio` 比例时,WarmPoolManager 暂停补池(不创建新 WARMING),记录 WARNING 日志并设置 warm pool metrics 的 degraded 标记。已绑定的用户 environment 不受影响;新的 create_environment 在 warm pool 未命中时走冷启动 CREATING 路径(若后端仍有可用容量)。当容量释放后(用户 environment 销毁、RUNNING 数下降),WarmPoolManager 自动恢复补池。
### 8.8.1 STOPPING_FAILED 恢复协调
STOPPING_FAILED 状态下有三方可以触发重试,需协调避免竞争:
**参与者:**
1. **k8s-exec-service-mcp cleanup loop**:后台协程,按 `next_retry_at` 扫描并执行自动重试。
2. **Gateway StreamHandler**:用户下次对话或同一线程显式重试时调用 `destroy_environment`。
3. **Admin API**:管理员通过 `POST /admin/environments/{id}/destroy` 触发 force_destroy。
**协调规则:**
- 所有三方调用 `destroy_environment` 时复用同一 `environment_id`。
- k8s-exec-service-mcp 的 destroy 端点对 STOPPING_FAILED 状态的 environment:
- 重新执行 artifact 上传(已上传成功的 artifact 对象存储操作应幂等,重复上传覆盖同 key)。
- 上传成功 → 销毁后端资源 → 写入 pending_usage → 返回 STOPPED。
- 上传仍失败且 `force_destroy=false` → 递增 `artifact_retry_count`,更新 `next_retry_at` 和 `artifact_upload_error`,返回 STOPPING_FAILED。
- 上传仍失败且 `force_destroy=true` → 销毁后端资源 → 写入 pending_usage(含 `artifacts_lost=true`)→ 返回 STOPPED。
- cleanup loop 只处理 `state='STOPPING_FAILED'` 且 `next_retry_at <= now()` 且 `artifact_retry_count < artifact_upload_max_retries` 的记录。
- Gateway 显式 destroy 无视 `next_retry_at`,立即触发一次重试(因为用户主动操作,期望即时反馈)。
- Admin force_destroy 直接跳过上传,销毁资源。
**自动重试退避公式:**
```
retry_delay = min(300, 2 ** artifact_retry_count * 10) # 秒
next_retry_at = now() + retry_delay
```
| 重试次数 | 延迟 | next_retry_at |
|---------|------|---------------|
| 0→1 | 10s | now + 10s |
| 1→2 | 20s | now + 20s |
| 2→3 | 40s | now + 40s |
| 3→4 | 80s | now + 80s |
| 4→5 | 160s | now + 160s |
| 5→6 | 300s | now + 300s |
| 6+ | 300s | now + 300s |
- 超过 `artifact_upload_max_retries`(默认 10)后保持 STOPPING_FAILED,不再自动重试。此时需用户或管理员介入 force_destroy。
- `artifact_upload_max_retries` 为 NULL 时使用全局默认值 10。
**监控暴露:**
- Prometheus 新增指标:`exec_service_stopping_failed_total`(gauge,当前 STOPPING_FAILED 数量)。
- Admin API `GET /admin/environments?state=STOPPING_FAILED` 可查询所有待处理记录,返回字段含 `artifact_retry_count`、`next_retry_at`、`artifact_upload_error`。
- 日志:每次自动重试记录 artifact_upload_error、retry_count、next_retry_at 到 WARNING 级别;超过 max_retries 升级为 ERROR。
**资源回收:**
- idle_timeout(默认 1800s):environment 在 RUNNING 状态持续无操作超过此时间,由清理协程删除,持久化写入 pending_usage。
- idle_timeout 回收若发现 output artifact 上传失败,进入 STOPPING_FAILED,并通过 admin/stats 暴露;cleanup 不得静默删除该 workspace。
- orphan 检测(P1):通过 environment registry 与 Gateway thread registry 状态对账,Gateway 崩溃后孤儿资源被回收。
- destroy_environment 始终写入 pending_usage,确保 idle timeout 回收的资源也能计费。
- WARM_IDLE 超过 warm_ttl_seconds、idle_timeout_seconds、generation 过期或后端进入 drain/maintenance 时,由 WarmPoolManager 销毁;预热资源销毁不写 pending_usage、不计费。
### 8.9 安全设计
- **传输层**:生产环境强制 HTTPS + TLS 1.2+。内网部署可使用 HTTP。
- **应用层**:每个请求携带 HMAC-SHA256 签名的 auth_context。k8s-exec-service-mcp 验证签名 + TTL + nonce 防重放 + 所有权。
- **管理端点**:额外要求 X-Admin-Token header,与用户请求隔离。
- **路径安全**:所有文件操作禁止 `..` 和宿主绝对路径(后端内强制沙箱)。
- **Pod 逃逸防护**:Pod 以非特权模式运行,Pod spec 由服务端模板生成(不接受客户端传入)。
- **本地逃逸防护**:LocalBackend 不接受用户提供 bind mount/runtime 参数;所有目录由服务端从 runtime root 派生。
- **Kubernetes 权限**:ServiceAccount 仅允许在目标 namespace 内 create/get/list/watch/delete pods、pods/exec、pods/log;不得拥有 cluster-admin 权限。
- **网络隔离**:P0 使用标准 Kubernetes NetworkPolicy 的 allow-list/ipBlock except 能力。开发环境可允许公网出站,但必须排除 Kubernetes Service CIDR、Pod CIDR、云元数据地址和内网敏感网段;生产部署应收紧为明确 CIDR 白名单。域名级策略或显式 deny 依赖 Cilium/Calico 等增强插件。
---
## 9. 管理、监测与统计
### 9.1 管理功能
k8s-exec-service-mcp 必须提供以下管理能力:
- 查看环境列表:按 user_id、thread_id、state、resource_class、created_at 过滤。
- 强制销毁环境:删除对应后端资源、持久化写 pending_usage、记录 admin 操作日志。
- pending usage 管理:查询未结算记录、批量标记 settled。
- 维护模式:开启后拒绝新的 create_environment,已有环境允许继续执行或销毁。
- 清理任务:手动触发 idle/orphan cleanup,支持 dry_run。
- resource_class 管理视图:展示资源档位、允许套餐、后端约束、节点池约束、镜像白名单。
- 后端管理:查看 LocalBackend/KubernetesBackend 健康、容量、active_envs、错误率,支持 drain 指定后端。
- Kubernetes 资源检查:检查 namespace quota、当前 Pod 数、不可调度 Pod、最近 event。
- LocalBackend 资源检查:检查 runtime root 磁盘、容器/进程数量、runtime 可用性、最近错误。
- 存储状态检查:数据库文件大小、environment 映射数量、pending_usage 未结算数量、最近备份时间。
所有管理操作必须:
- 使用 `X-Admin-Token`。
- 写结构化 admin audit log。
- 返回 request_id,便于日志追踪。
### 9.2 监测指标
`GET /metrics` 必须暴露 Prometheus 指标(统一使用 snake_case 命名):
| 指标 | 类型 | 说明 |
|------|------|------|
| `exec_service_active_environments` | gauge | 当前 capacity-consuming environment 数,按 state/backend_type/resource_class 标记 |
| `exec_service_create_environment_total` | counter | create 请求数,按 status/backend_type/resource_class 标记 |
| `exec_service_destroy_environment_total` | counter | destroy 请求数,按 status 标记 |
| `exec_service_execute_command_total` | counter | 命令执行次数,按 exit_code/status 标记 |
| `exec_service_pending_usage_total` | gauge | 未结算 usage 数 |
| `exec_service_backend_health` | gauge | 后端健康状态(0=unhealthy, 1=healthy),按 backend_type/backend_id 标记 |
| `exec_service_backend_active_environments` | gauge | 后端 capacity-consuming environment 数,按 backend_type/backend_id/state 标记 |
| `exec_service_kubernetes_api_errors_total` | counter | Kubernetes API 错误数,按 operation/status 标记 |
| `exec_service_local_runtime_errors_total` | counter | LocalBackend runtime 错误数,按 operation/status 标记 |
| `exec_service_environment_start_seconds` | histogram | environment 从创建到 Ready 的耗时 |
| `exec_service_command_duration_seconds` | histogram | 命令执行耗时 |
| `exec_service_cleanup_total` | counter | cleanup 删除 environment 数,按 backend_type/reason 标记 |
| `exec_service_storage_size_bytes` | gauge | 持久化数据库文件大小 |
| `exec_service_storage_operations_total` | counter | 持久化存储操作次数,按 operation/status 标记 |
| `exec_service_object_storage_operations_total` | counter | 对象存储操作次数,按 operation/status 标记 |
| `exec_service_object_storage_bytes_total` | counter | 对象存储上传/下载字节数,按 direction/status 标记 |
| `exec_service_artifacts_total` | counter | 上传 artifact 数,按 status/resource_class 标记 |
| `exec_service_warm_pool_idle_total` | gauge | 当前 WARM_IDLE 数,按 backend_type/backend_id/resource_class/image_ref 标记;image_ref 必须为低基数 allowlist key,不使用完整 tag/digest |
| `exec_service_warm_pool_warming_total` | gauge | 当前 WARMING 数,按 backend_type/backend_id/resource_class/image_ref 标记,禁止直接使用高基数 pool_key |
| `exec_service_warm_pool_acquire_total` | counter | warm pool 获取次数,按 hit/miss/status 标记 |
| `exec_service_warm_pool_refill_total` | counter | warm pool 补池次数,按 status 标记 |
| `exec_service_warm_pool_bind_seconds` | histogram | 预热环境从 WARM_IDLE 绑定到 RUNNING 的耗时 |
| `exec_service_stopping_failed_total` | counter | 进入 STOPPING_FAILED 的次数,按 backend_type/reason 标记 |
| `exec_service_stopping_failed_current` | gauge | 当前 STOPPING_FAILED environment 数 |
健康检查:
- `/health`:轻量存活检查,快速返回整体状态和 active environment 数。后端详细健康状态通过 `/admin/backends` 查询。
- `/ready`:面向探针和 Gateway ComputeClient 就绪判断的统一端点。返回 200 时表示服务可调度;返回非 2xx 时表示不可调度。响应格式(不受 §4.3.1 详细 checks 格式约束——§4.3.1 的完整诊断 checks 移至 `/admin/readiness` 端点),仅暴露 ready/degraded/reason_code,不泄露 bucket、endpoint、backend 细节:
- `/ready` 严格检查配置、SQLite storage 连接、对象存储 bucket 可访问、admin token,以及至少一个可调度后端。若 warm pool degraded 但仍可 cold-start 调度,则返回 200 且 degraded=true;若无任何可调度后端或对象存储不可用,则返回非 2xx。
- readiness 失败时上游不应继续给服务转发流量。
### 9.3 日志
服务必须输出 JSON 结构化日志,至少包含:
- `request_id`
- `environment_id`
- `user_id`
- `thread_id`
- `backend_type`
- `backend_id`
- `resource_ref`
- `operation`
- `status`
- `duration_ms`
- `resource_class`
- `error`
**敏感信息脱敏策略:**
- HMAC secret:禁止记录。
- admin token:禁止记录。
- 完整文件内容:禁止记录(只记录文件路径和 bytes_written)。
- 用户命令输出全文:禁止记录。必须记录 exit_code、stdout/stderr 大小(字节数)和 stderr 头部摘要(默认截断至 256 字符,并做密钥模式脱敏)。
- 用户命令本身:默认只记录 sha256、长度和首段截断摘要。生产环境禁止记录完整命令;本地开发可通过显式配置打开完整命令日志,但仍必须脱敏环境变量、token、key、password、secret 等模式。
**日志级别使用:**
- INFO:请求/响应摘要、环境创建/销毁、清理操作、结算事件。
- WARNING:后端健康降级、容量接近上限、HTTP 超时重试。
- ERROR:后端操作失败、存储写入失败、认证失败。
- DEBUG:调度决策过程、文件操作元数据、截断后的请求/响应摘要。DEBUG 也不得记录完整文件内容、完整 stdout/stderr、完整请求/响应 body 或未脱敏命令。
---
## 10. 非功能需求
### 10.1 可用性
- k8s-exec-service-mcp 不可用时(未启动、崩溃、网络不通、全部后端不可调度),Gateway 不执行用户代码,Web 计算请求返回 `ComputeUnavailable`;可保留非计算对话、文件展示和错误说明。
- 非计算降级是 per-thread 的 UI/文件能力降级,不是计算 fallback;该线程内 `execute()` 必须拒绝。
- 新创建的线程会检查 ComputeClient 当前的可用性状态,若服务已恢复则可正常使用 compute 后端。
- compute enabled 模式下 poll_loop 必须最终启动;管理端点暂不可达时进入延迟重试并告警,不允许长期无兜底结算运行。
- k8s-exec-service-mcp 的 pending_usage 和 environment 映射必须持久化,服务重启后不丢失未结算记录。
### 10.2 性能
- Environment 创建时间目标:常用镜像已预拉取时 create_environment < 5s;冷镜像拉取依赖 registry、节点网络或本地 runtime 缓存。
- P0 必须实现 warm environment pool;small + 默认 Python 镜像在 warm hit 时 create_environment 目标 < 2s。镜像预拉取 DaemonSet、节点镜像缓存、本地镜像缓存和资源档位仍用于降低 warm miss 与冷启动成本。
- HTTP 连接复用:ComputeClient 使用 httpx keep-alive 连接池。
- 事务隔离:settle 使用独立连接,不阻塞共享连接的正常查询。
- 存储性能:SQLite WAL 模式支持并发读,写入串行化。k8s-exec-service-mcp 保持单实例,避免多写者竞争。
### 10.3 可扩展性
- ResourceManager 负责跨后端选择;KubernetesBackend 内部仍由 Kubernetes 负责 Pod 调度、节点资源分配和云上弹性扩缩容。
- LocalBackend 负责单机资源隔离和并发控制,不承担跨节点调度。
- k8s-exec-service-mcp 固定单实例部署。单实例 + SQLite WAL 用于保证 environment mapping 与 pending_usage 的一致性,同时提供 crash-safe 持久化。
- 不设计多副本写入、共享存储、leader election 或跨实例 pending_usage 去重。需要扩容时优先扩 Kubernetes 执行资源,而不是扩 k8s-exec-service-mcp 控制面副本。
- 自建服务器可通过 LocalBackend 或 Kubernetes node pool 接入;云上资源优先通过 Kubernetes node pool 接入。不同资源档位用 backend_policy、nodeSelector/tolerations/runtimeClass 区分。
### 10.4 可观测性
- Gateway 侧:记录 ComputeClient readiness 检查结果、health 诊断结果、ComputeUnavailable 次数、非计算降级次数、settle 成功/失败次数、poll_loop 结算数量。
- k8s-exec-service-mcp 侧:GET /health 返回 active environment 数;日志记录环境创建/销毁、exec、pending_usage 写入;/admin/backends 返回后端详细状态。
- Kubernetes 侧:Pod labels 只允许低基数、非 PII 维度(backend_type、resource_class、state、短 hash);user_uid/thread_id/environment_id 不得作为 Prometheus label,可放入受 RBAC 保护的 annotations 或结构化日志。
- LocalBackend 侧:runtime records 可保存 user/thread/environment 维度用于审计,但 metrics label 禁止使用高基数字段。
### 10.5 兼容性
- CLI 模式不受任何影响(compute_client 参数默认为 None)。
- 现有 Web 线程在 `EXECUTION_SERVICE_URL` 未设置时仍可对话和管理文件,但不能执行用户代码;计算请求返回 `ComputeUnavailable`。
- `create_cli_agent()` 签名兼容:所有新参数在末尾且有默认值。
- `_load_mcp_tools_cached()` 签名不变:不需要 exclude_servers 参数。
---
## 11. 测试范围
### 11.1 单元测试
- ComputeClient:HTTP 请求构造、readiness 检查、health 诊断、超时处理、连接池关闭。
- 计费:Decimal 精度、quota/cash 扣减顺序、余额不足回滚、wallet_ledger 写入、幂等键唯一性。
- 配额:grant(backfill/reset 模式)、月度刷新、starter=0 边界。
- 迁移:migration 重复执行不失败、SQLite 新表重建式类型迁移幂等、NUMERIC(12,6) 语义确认、INTEGER/REAL 旧金额值迁移后保留。
- ResourceManager:backend_policy 调度、capacity 检查、drain/maintenance、environment registry ownership 校验、持久化读写。
- WarmPoolManager:min_idle/max_idle 补池、WARM_IDLE 原子 acquire、WARM_BINDING 失败销毁、TTL/generation 回收、后端 drain/maintenance 停止补池。
- LocalBackend:runtime root、路径归一化、资源限制、create/delete/exec/copy、usage 采集。
- KubernetesBackend:Pod spec 生成、安全字段、create/delete/exec/copy、resource_ref 返回。
- Kubernetes 安全:禁止 privileged/hostPath/hostNetwork;资源 requests/limits 必填。
- 管理 API:maintenance、force destroy、cleanup dry_run、resource_classes、backends、drain、warm_pools 查询/补池/drain、fallback_sync_recovery 查询/重试/放弃。
- 监测:/health、/ready、/metrics 指标名称和 label。
- 统计:usage-summary group_by 与时间范围过滤。
- 持久化:SQLite WAL 模式、crash recovery、environment 映射恢复、pending_usage 恢复、backend registry 恢复。
- 认证:HMAC canonical string、body 篡改失败、过期 issued_at 失败、nonce 重放失败、nonce TTL cleanup。
- 文件传输:upload_file base64 + sha256 校验、download_file content_base64 + sha256 校验;确认 P0 不接受 local_path/multipart。
- 对象存储:MinIO/S3 连接失败时 /ready 失败;input_objects 下载校验 sha256;未授权 object_key 被拒绝;destroy_environment 上传 artifact 并返回 manifest;artifact 元数据由 EvoScientist StorageService 入库。
- Web compute 存储前置:`STORAGE_BACKEND=local` 时 build_input_manifest 返回 `StorageBackendUnsupportedForCompute`,不创建 environment,不执行本地用户代码;切换为 MinIO/S3 后同一文件可生成对象存储 input manifest。
- 共享密钥配置:Gateway 或 k8s-exec-service-mcp 缺少 `EXECUTION_HMAC_SECRET` / `EXECUTION_ADMIN_TOKEN` 时启动失败;本地一键部署脚本生成后两边签名请求通过。
### 11.2 集成测试
- 正常结算:StreamHandler → destroy → settle → usage_log 记录正确、wallet_ledger 写入正确。
- 异常补偿:settle 失败 → pending_usage 保留 → poll_loop 重试 → 标记 settled。
- 幂等:重复 environment_id 不重复扣费。
- ComputeUnavailable:/ready 不可用或不可调度 → Web 计算请求返回 ComputeUnavailable → 不执行本地用户代码。
- 计算资源恢复:/ready 恢复后,新线程可使用 compute 后端。
- 非计算降级同步恢复:降级文件同步失败 → 写入 fallback_sync_recovery → 自动重试成功 → user_files 入库 → recovery workspace 清理。
- Artifact 注册失败:destroy 已返回 usage/artifacts,但 register_artifacts 失败 → 不调用 settle_compute_usage → pending_usage 保留 → poll_loop 补注册后再结算。
- Warm pool 命中:服务启动补足 WARM_IDLE → create_environment 原子绑定 → input hydration → RUNNING → destroy 后不回收到 warm pool。
- Warm pool 未命中:min_idle=0 或池为空 → 冷启动 CREATING → RUNNING,同时异步补池。
- Warm pool 安全:绑定前 workspace reset,确认预热环境不含用户文件;绑定失败的预热环境被销毁。
- Warm pool P0 默认:默认配置启动后 `small + 默认 Python` pool 至少有 1 个 WARM_IDLE;管理员显式设置 `min_idle=0` 时 `/ready` checks 显示 `required_default_pool=degraded`。
- 配额:preflight 拦截 starter / 配额耗尽用户。
- HTTP 故障:连接断开 → _dead 标记 → 当前线程计算请求返回 ComputeUnavailable;非计算文件能力可降级。
- 本地单机部署:LocalBackend 启动后 readiness 通过,create/exec/file/destroy 全链路通过。
- 本地 Kubernetes 部署:kind manifests apply 后 readiness 通过。
- NetworkPolicy:执行 Pod 可访问允许的公网目标,不可访问 Kubernetes API、云元数据地址和配置的内网敏感网段。
- 混合后端:backend_policy=auto 时可在 LocalBackend/KubernetesBackend 间选择;drain 后端后不再接收新环境。
- 管理闭环:force destroy → pending_usage → usage-summary 可见。
- 持久化恢复:k8s-exec-service-mcp 重启后,已写入的 pending_usage 不丢失,environment 映射正确恢复。
- 持久化恢复 + 孤儿:k8s-exec-service-mcp 重启后发现 backend 资源不可达,environment 标记为 orphan 并写入 pending_usage。
### 11.3 端到端测试
- k8s-exec-service-mcp 未启动 → Gateway 启动正常 → Web 计算请求返回 ComputeUnavailable → Gateway 本地不执行用户代码。
- 本地单机部署 → Gateway 连接 localhost → 创建 environment → 执行命令 → 读写文件 → 删除资源 → 计费正确。
- 本地 kind 部署 → Gateway 连接本地 port-forward → 创建 Pod → 执行命令 → 读写文件 → 删除 Pod → 计费正确。
- 对象存储闭环 → 上传用户文件 → 生成 input_objects → 远程执行读取输入 → 输出 artifact → StorageService 入库 → 用户下载 artifact。
- warm pool 闭环 → 启动服务后 warm pool 达到 min_idle → 首次 create_environment 命中 warm pool → 创建延迟低于目标 → 后台重新补足 min_idle。
- input manifest 缺失关键对象 → build_input_manifest 返回 InputObjectMissing → 不创建 environment → 用户可见错误。
- 非计算降级同步失败恢复 → 降级文件变更 → StorageService 临时失败 → recovery 记录存在且 workspace 未删除 → 恢复后补同步成功。
- artifact 上传失败 → destroy 返回可重试错误 → workspace 未删除 → 重试成功后资源释放。
- force_destroy → artifact 上传失败时强制释放资源 → 返回 artifacts_lost=true → 审计日志可查。
- 混合部署 → 本地资源满载或 drain → 自动调度到 KubernetesBackend → 计费和统计维度正确。
- 维护模式开启 → 新建环境返回 503 → 已有环境可销毁。
- 服务 crash 恢复 → pending_usage 未丢失 → poll_loop 继续结算 → 计费完整。
- 执行 Pod 网络隔离 → DNS 可用 → 可访问允许的公网目标(pip install)→ 不可访问 Kubernetes API 和云元数据。
---
## 12. 执行条件评估
1.2 在设计层面已经满足进入实现拆分的条件,但执行前必须确认以下前置项:
| 条件 | 状态 | 说明 |
|------|------|------|
| 文件共享底座 | 已明确 | 采用 S3 兼容对象存储,本地 MinIO,云上 S3/OSS/COS |
| 长期文件归属 | 已明确 | EvoScientist StorageService 管 metadata、权限、生命周期 |
| 执行服务职责 | 已明确 | k8s-exec-service-mcp 只管调度、运行期 workspace、usage 和 artifact upload |
| 数据库边界 | 已明确 | Gateway 数据库保存业务文件元数据;exec service 独立 SQLite WAL 保存执行状态 |
| workspace 生命周期 | 已明确 | 无本地缓存保留 N 天;destroy 后清理,artifact 上传失败时进入 STOPPING_FAILED |
| 权限边界 | 已明确 | object_scope + Gateway metadata 权限双重校验 |
| 本地部署 | 已明确 | LocalBackend + MinIO + SQLite;kind/k3d 路径也包含 MinIO |
| 云上扩展 | 已明确 | KubernetesBackend + S3 兼容对象存储 + 节点池/调度扩展 |
| 失败恢复 | 已明确 | artifact 上传失败可重试;force_destroy 有审计和 artifacts_lost |
| P0 预热池 | 已明确 | WarmPoolManager 必须实现;默认配置至少启用 small + 默认 Python 镜像且 min_idle >= 1;预热环境不含用户数据,绑定失败即销毁 |
| Web 本地计算禁用 | 已明确 | Web 用户代码只能在 k8s-exec-service-mcp 管理的计算资源中执行;Compute 不可用时返回 ComputeUnavailable,不 fallback 到 Gateway 本地执行 |
| Web compute 存储前置 | 已明确 | Web 远程 compute P0 必须使用 S3StorageBackend/MinIO/S3 兼容对象存储;LocalStorageBackend 仅用于 CLI、迁移和非计算文件管理 |
| 共享密钥 | 已明确 | Gateway 与 k8s-exec-service-mcp 必须使用同一组显式配置的 HMAC secret/admin token;本地脚本统一生成时必须同时写入两边 |
**仍需在实现任务中拆分的细节:**
- StorageService 迁移脚本需要先盘点现有本地文件目录和上传接口。
- `logical_path` 与现有 `/__global__`、thread 文件树的 UI 映射需要保持向后兼容。
- 对象存储 SDK 选择需要统一:Python 侧建议使用 `aioboto3` 或 `boto3 + threadpool`,P0 以稳定为先。
- 大文件 multipart upload 仍为 P1;P0 不实现 multipart。P0 用户上传文件必须受 `STORAGE_MAX_FILE_SIZE_BYTES` 限制,k8s-exec-service-mcp 的 `upload_file/download_file` 受 `EXEC_HTTP_FILE_MAX_BYTES` 限制;超过限制走后续 P1 流式或 multipart 方案。
**LocalStorageBackend → S3StorageBackend 迁移风险:**
- 现有 `~/.evoscientist/data/{user_id}` 路径在 CLI 会话、Web 文件上传、Agent memory 等模块中可能存在直接路径引用。迁移前必须完成全仓路径引用盘点(`rg "evoscientist/data"` + `rg "~/.evoscientist"`),生成迁移影响清单。
- P0 必须保留 `STORAGE_BACKEND=local` 作为回退模式。若 S3/MinIO 不可用,CLI 和非计算 Web 操作可降级使用 LocalStorageBackend,但 Web compute 必须返回 `StorageBackendUnsupportedForCompute`。
- 迁移脚本提供 `--dry-run` 模式,在真正执行上传前打印所有待迁移文件清单和预估总大小。
- 迁移完成后建议保留原有本地文件至少 14 天作为手动回滚备份,不立即删除。
**执行结论:**
可以进入 P0 实现,但必须严格按以下 Phase 顺序执行。Phase 1 完成前不得开始 Phase 2 的开发。
---
## 13. 实施优先级与阶段依赖
P0 实施分为两个串行阶段。Phase 1 完成并验证后,Phase 2 方可启动。
### Phase 依赖图
```
Phase 1(基础设施层,18 人日,约 3 周)
─────────────────────────────────────────
StorageService 抽象 + LocalStorageBackend
│
├──→ S3StorageBackend + MinIO
│ │
│ └──→ 本地文件迁移脚本
│
└──→ k8s-exec-service-mcp HTTP skeleton + /health + /ready
│
├──→ 持久化存储层 (SQLite WAL + all repos)
│
├──→ ObjectStorageClient
│
└──→ 完整服务 (config + models + service + server)
★ Gate Check: S3StorageBackend + MinIO + HMAC 端到端连通性验证
本地 single-environment create/exec/destroy 全链路通过
之后方可进入 Phase 2
Phase 2(执行与集成层,72 人日,约 11 周)
─────────────────────────────────────────
ResourceManager + Scheduler + BackendAdapter
│
├──→ WarmPoolManager
│
├──→ LocalBackend ──────────┐
│ ├──→ 本地单机 + kind 部署脚本
└──→ KubernetesBackend ─────┘
│
└──→ K8s manifests (RBAC/NS/Quota/LimitRange/NetworkPolicy)
Gateway 侧:
Database migration (NUMERIC + wallet_ledger + compute_usage_log + user_files)
│
├──→ pricing.json 扩展 + 独立事务连接
│
├──→ ComputeClient (HTTP + 13 wrappers + HMAC)
│
├──→ Gateway lifespan 集成 (StorageService + ComputeClient + 后台任务)
│
├──→ EvoScientist 侧集成 (ContainerSandboxBackend + create_cli_agent + StreamHandler + threads.py)
│
└──→ 计费系统 (check_quota + settle + _deduct + wallet_ledger + poll_loop + 月度刷新 + backfill)
k8s-exec-service-mcp 侧:
管理 API P0 + 监测 P0
│
└──→ 全套测试 (单元 + 集成 + E2E + 对象存储 + warm pool)
★ Final Gate: 全套测试通过 + 本地单机 smoke test + kind smoke test
```
### P0-Phase 1(基础设施层,18 人日,关键路径 ~3 周)
必须按顺序实现。Phase 1 的 Gate Check 是通过 HMAC 测试向量 + 本地 MinIO + single environment 全链路。
| # | 任务 | 人日 | 依赖 |
|---|------|------|------|
| 1 | EvoScientist StorageService 抽象 + LocalStorageBackend 适配当前本地文件 | 2 | — |
| 2 | S3StorageBackend + MinIO 本地配置 + user_files metadata migration | 3 | 1 |
| 3 | 本地文件迁移脚本(`~/.evoscientist/data` → StorageService + 对象存储,含 --dry-run) | 1.5 | 2 |
| 4 | k8s-exec-service-mcp HTTP server skeleton + /health + /ready + /admin/readiness 端点 | 1.5 | — |
| 5 | 持久化存储层(SQLite WAL + migration + environment_repo + pending_usage_repo + backend_repo + nonce_repo + audit_repo) | 3 | 4 |
| 6 | ObjectStorageClient(input hydration、artifact upload、sha256 校验、object_scope 校验) | 3 | 2,5 |
| 7 | k8s-exec-service-mcp 完整服务(config + models + service + server,含 HMAC 签名验证实现并通过共享测试向量) | 3 | 4,5,6 |
**Phase 1 Gate Check(阻塞 Phase 2 的硬性条件):**
1. HMAC 签名测试向量:Gateway 侧 ComputeClient 和 k8s-exec-service-mcp 实现均通过 §4.2 中的 byte-exact 测试向量。
2. S3StorageBackend + MinIO:Gateway 可读写对象存储,`build_input_manifest` 和 `register_artifacts` 功能正常。
3. 本地 single-environment 全链路:create_environment → execute_command → write_file/read_file → destroy_environment → artifact 上传 → pending_usage 写入,本地单机跑通。
### P0-Phase 2(执行与集成层,72 人日,关键路径 ~11 周)
Phase 1 Gate Check 通过后方可启动。任务 8-14 可与任务 15-20 部分并行。
| # | 任务 | 人日 | 依赖 |
|---|------|------|------|
| 8 | ResourceManager + EnvironmentRegistry + Scheduler + BackendAdapter 协议 | 4 | Phase 1 |
| 9 | WarmPoolManager + exec_warm_pools schema + WARMING/WARM_IDLE/WARM_BINDING 状态机 | 3.5 | 8 |
| 10 | LocalBackend(host-process/rootless-podman,create/delete/status/exec/copy + warm + usage) | 4 | 8,9 |
| 11 | KubernetesBackend(Pod create/delete/status/exec/copy + warm Pod + resource_ref) | 4 | 8,9 |
| 12 | Kubernetes RBAC/Namespace/ResourceQuota/LimitRange/NetworkPolicy manifests | 2 | 11 |
| 13 | 本地单机部署脚本 + MinIO + warm pool smoke-test | 2 | 10,Phase 1 |
| 14 | 本地 kind/k3d 完整部署脚本 + MinIO + warm pool smoke-test | 2 | 11,12,Phase 1 |
| 15 | 管理 API P0(stats/environments/force destroy/cleanup/maintenance/resource-classes/backends/drain/warm-pools/usage-summary/storage-stats/readiness) | 4 | 8 |
| 16 | 监测 P0(/health /ready /metrics + warm pool/object storage 指标 + JSON logging) | 2.5 | 8 |
| 17 | ComputeClient(HTTP + /ready 判定 + 13 tool wrappers + input/artifact 参数 + admin 方法) | 3 | Phase 1 |
| 18 | 数据库 migration(NUMERIC 金额 + compute 列 + wallet_ledger + compute_usage_log + user_files + exec_warm_pools + 索引) | 2 | Phase 1 |
| 19 | 独立 SQLite 事务连接(get_transaction_connection) | 0.5 | — |
| 20 | pricing.json 扩展(compute_pricing + plan.monthly_compute_minutes + pricing_version) | 0.5 | — |
| 21 | Gateway lifespan 集成(StorageService + ComputeClient 初始化 + readiness + 后台任务) | 2 | 17,Phase 1 |
| 22 | EvoScientist 侧集成(ContainerSandboxBackend + create_cli_agent 改造 + StreamHandler + threads.py) | 5 | 17,21,Phase 1 |
| 23 | 计费系统(check_quota + settle + _deduct + wallet_ledger + poll_loop + 月度刷新 + backfill) | 5 | 18,20,22 |
| 24 | 全套测试(单元 + 集成 + E2E + 对象存储失败恢复 + warm pool) | 8 | 8-23 |
**P0 总估算:90 人日(Phase 1: 18 + Phase 2: 72),2-3 人团队约 14-16 周。**
### P1(增强,8 人日)
- get_resource_usage、smoke_test
- orphan environment 检测与回收
- 管理后台 compute 配额调整 API
- Prometheus/Grafana dashboard 模板
- Kubernetes event 采集与告警规则
- 镜像预拉取 DaemonSet / 节点池健康检查
- 调度策略增强:least-load、成本优先、云上资源优先级
### P2(完善,6 天)
- execute_in_session
- mTLS 双向认证
- 运行中预算硬拦截(Gateway budget API 或签名预算令牌)
- Kueue / Karpenter / Cluster Autoscaler 接入
---
## 14. 版本演进摘要
| 版本 | 核心变更 |
|------|---------|
| v10-v13 | 形成远程执行、计费幂等、HTTP 传输、配额、审计和 pending_usage 兜底的基础需求 |
| v15 | 将 k8s-exec-service-mcp 定位为统一执行资源调度控制面;通过 ResourceManager 管理 LocalBackend 与 KubernetesBackend;补齐本地单机、本地 Kubernetes、管理 API、监测指标和统计功能 |
| v16 | 补齐数据持久化方案(SQLite WAL + crash recovery);新增附录 A 解释 deepagents backend 体系;明确 auth_context JSON 结构;调整 NetworkPolicy 为 egress 默认 allow + 关键地址 deny;新增 wallet_ledger 表结构;修正 Gateway 启动序列顺序(先获连接再 migration);统一 Prometheus 指标为 snake_case;优化日志脱敏策略(保留 exit_code + 截断 stderr);修复多处一致性问题(旧版工期估算已废弃,以 §13 为准) |
| v17 | 修正 Gateway 初始化顺序为 migration 后取连接;补齐 HMAC canonical 签名和 nonce 防重放;将运行中余额硬拦截移出 P0,改为 preflight + max_runtime_seconds + 结算兜底;将 NetworkPolicy 改为标准 Kubernetes 可执行语义;修正 upload/download HTTP 契约;完善 crash recovery 用量估算;收紧 DEBUG 日志策略 |
| v18 | 补齐 k8s-exec-service-mcp 独立数据库 schema 与事务边界;新增 nonce_repo、backend_repo 与 nonce TTL cleanup;Gateway 改用 /ready 判断 compute 可调度;明确 LocalBackend host-process/rootless-podman 模式与 Docker socket 风险;补齐标准 NetworkPolicy DNS allow + ipBlock except 规则;P0 文件传输限定为 JSON base64 |
| v19 | 统一 P0 存储范围为 SQLite WAL;统一 runtime_seconds 字段;修正 create_environment 为先写 CREATING 再创建后端资源的 crash-safe 流程;明确 LocalBackend P0 默认 host-process 部署,rootless-podman 为可选;清理 readiness 术语 |
| v20 | 固定唯一持久化方案为 SQLite WAL;明确 k8s-exec-service-mcp 单实例运行;移除 PostgreSQL、多副本共享存储、leader election 和跨实例 pending_usage 去重设计;采用 S3 兼容对象存储作为统一文件内容层;新增 StorageService、input_objects、artifact manifest、object_scope、MinIO 本地部署和对象存储监控指标 |
| v21 | 重新评估对象存储方案并补齐完整执行设计;新增 StorageService P0 后端、Gateway user_files 元数据表、端到端数据流、失败处理、STOPPING_FAILED 状态、force_destroy 语义、fallback 同步约束、执行条件评估和更新后的 P0 实施计划 |
| v22 | 修正 v21 残留旧语义;统一顶层 Web 生命周期与详细设计;明确 STOPPING_FAILED 条件下 workspace 保留;补齐 pending_usage artifact 字段返回;明确 mark-settled 不更新 artifact 状态;更新 ContainerSandboxBackend destroy 返回 |
| v23 | 修复 v22 执行阻塞项;resource_ref 允许 CREATING 阶段为空;STOPPING_FAILED 重试字段显式入表并可索引;ComputeClient 补齐 mark_artifacts_registered;admin token 统一使用 header;补齐本地 MinIO 部署文件 |
| v24 | 补齐关键功能描述:ComputeClient auth_context 构建流程(nonce 生成/HMAC 签名/object_scope 组装/canonical_json 递归排序)、StorageService build_input_manifest 算法(/workspace /__global__ /threads 三路径分发与权限校验)、poll_loop 详细规格(60s 间隔/50 条批量/5 次连续失败阈值/artifact 补注册逻辑)、STOPPING_FAILED 三方恢复协调(cleanup loop/Gateway/Admin 交互规则与指数退避)、对象存储 bucket 初始化与探活(MinIO mc mb + Gateway probe object + /ready 状态字段)、quota_ok 判定公式(starter 直接拒绝 + 分钟数>0 或现金≥1分钟价格的 OR 逻辑 + 5 种边界情况表)、MultiRootSandboxBackend fallback 文件同步路径(StreamHandler cleanup 时自动扫描 mtime + put_file + P0 限制) |
| v25 | 修复一致性缺陷:统一 §4.2 HMAC canonical_json 为"递归排序所有嵌套对象 key"(与 §5.1.1 一致);修正 §6.4 WHERE 条件变量 $overtime→$overtime_amount 并补充单位(元)说明;新增 get_environment_status 各状态 runtime_seconds 计算公式;结论标签改为版本无关的"结论",章节编号修正为 2.5.1(Bucket)→2.5.2(build_input_manifest) |
| v1.1 | 将 warm environment pool 提升为 P0 必须能力;新增 WarmPoolManager、exec_warm_pools、WARMING/WARM_IDLE/WARM_BINDING 状态机、预热绑定安全边界、warm pool 管理端点、指标、测试和实施计划 |
| v1.2 | 明确 EvoScientist Gateway 只调度不计算;Web 用户代码必须通过 k8s-exec-service-mcp 执行;禁用 Web 本地计算 fallback;MultiRootSandboxBackend 仅保留非计算文件降级/恢复;LocalBackend 明确为执行服务侧本地容器资源 |
| v1.3 | 修复阻塞项:修正 C15/C18 数据库后端混用(区分 Gateway PG 与 k8s-exec-service-mcp SQLite);修正 init_compute_client 调用签名补全 admin_token;确认 user_files partial index 为 PG 语法;新增 Warm pool 容量超限降级行为;§4.4 错误约定表补全 409/413/ComputeUnavailable |
| v1.4 | 修复残余问题:统一 admin_token→execution_admin_token 命名;修正 §2.5 端到端数据流步骤顺序(先创建环境→再下载输入→最后执行);§4.4 新增 StorageBackendUnsupportedForCompute(409) |
| v1.4-review | 根据设计评审修复阻塞项:warm pool schema、原子计费事务、混合免费/现金扣费、artifact 注册失败结算、HMAC byte-exact、配置模式矩阵、路径规范、工具硬限制、安全基线、容量统计和工期估算 |
| v1.5 | 可执行性评审修订 — 6 阻塞项全修:§4.2 HMAC 测试向量(含完整 byte-exact golden vector)、§7.1 compute_usage_log 新增 settlement_status/settlement_error、§4.3.1 /ready 精简 + §4.3.3 新增 /admin/readiness、§6.2 pricing_version 定义、§5.3.2 overtime_price 边界处理、§12 迁移风险说明 + §13 Phase 1/2 拆分 + Gate Check + 工时调整至 90 人日 |
---
## 附录 A:deepagents Backend 体系说明
本附录解释 EvoScientist 使用的 deepagents 框架中 Backend、CompositeBackend、MultiRootSandboxBackend 等核心概念,供不熟悉 deepagents 的读者参考。
### A.1 BackendProtocol
deepagents 定义了一个 BackendProtocol,约定所有执行后端必须实现的核心方法:
```python
class BackendProtocol(Protocol):
"""执行后端的核心协议。所有文件 I/O 和命令执行通过此协议抽象。"""
def execute(self, command: str) -> dict:
"""在环境中执行命令,返回 {"stdout": str, "stderr": str, "exit_code": int}。"""
...
def read_file(self, path: str) -> str:
"""读取文件内容。"""
...
def write_file(self, path: str, content: str) -> None:
"""写入文件。"""
...
def edit_file(self, path: str, old_string: str, new_string: str) -> str:
"""编辑文件,返回 diff。"""
...
def destroy(self) -> dict:
"""销毁环境,返回 usage 信息。"""
...
```
### A.2 CustomSandboxBackend(CLI 模式)
CLI 模式使用的后端。直接在宿主机文件系统和 shell 上操作,无隔离。调用 create_cli_agent() 时不传 compute_client 参数时创建此类型后端。
### A.3 MultiRootSandboxBackend(Web 非计算降级)
Web 模式下,MultiRootSandboxBackend 只允许作为非计算文件降级/恢复后端。它可以操作 Gateway 服务器上的受控文件目录,用于展示、编辑恢复和 fallback_sync_recovery,但不得执行用户命令、Python、shell 或任何会产生计算结果的工具:
- 每个 Web 线程拥有独立的 `/workspace` 根目录
- `/__global__` 指向用户全局共享目录
- `/threads/{thread_id}` 以只读方式挂载其他线程的工作区
特点:无容器/沙箱隔离,不受 ResourceManager 管理,不产生 compute usage,因此不能作为 Web compute fallback。所有 Web 用户代码执行必须由 ContainerSandboxBackend → k8s-exec-service-mcp 完成。
### A.4 CompositeBackend(Web compute 模式)
Web 模式下,当 compute 可用且用户有配额时的复合后端。组合两个子后端:
```python
CompositeBackend(
default=ContainerSandboxBackend(...), # 远程执行环境
routes={
"/skills/": CustomSandboxBackend(...), # 本地 skills
"/memory/": CustomSandboxBackend(...), # 本地 memory
}
)
```
路由规则:
- 对 `/skills/` 和 `/memory/` 路径下的文件操作,始终路由到 CustomSandboxBackend(本地存储)。这是因为 skills 和 memory 是 EvoScientist 自身的知识库,不应暴露在远程执行容器中。
- 对 `/workspace`、`/__global__`、`/threads/*` 路径下的文件操作,路由到 default(即 ContainerSandboxBackend,远程执行环境)。
- `execute()` 调用始终由 default backend 处理。
- 当 ContainerSandboxBackend 标记 `_dead`(HTTP 故障),CompositeBackend 的 default 计算请求必须返回 `ComputeUnavailable`;只能将非计算文件展示/恢复请求交给受限 MultiRootSandboxBackend。
### A.5 ContainerSandboxBackend
实现 BackendProtocol,但底层通过 ComputeClient → HTTP → k8s-exec-service-mcp 完成实际操作。所有文件 I/O 和命令执行在远程容器/Pod 中执行。生命周期见第 5.2 节。
### A.6 后端选择决策树
```
create_cli_agent()
├─ source == "cli" (或无 compute_client)
│ └─ CustomSandboxBackend(本地直连,无隔离)
└─ source == "web"
├─ compute_client 为 None 或不可用 或 配额不足
│ └─ ComputeUnavailable / 非计算只读降级(不得执行用户代码)
└─ compute_client 可用 且 配额充足
└─ CompositeBackend
├─ default: ContainerSandboxBackend → HTTP → k8s-exec-service-mcp
├─ /skills/: CustomSandboxBackend(本地)
└─ /memory/: CustomSandboxBackend(本地)
```