已合并
docs: 文档重构支持项目记忆管理。文档三分重组——design/spec/feature 下沉模块根,spec 按子包拆分 #439
王明琦创建于 19 天前
docs: 文档重构支持项目记忆管理。文档三分重组——design/spec/feature 下沉模块根,spec 按子包拆分 #439
已合并
共 15 个文件变更+588-218
| @@ -0,0 +1,7 @@ | |||
| 1 | +# CLAUDE.md(根) | ||
| 2 | + | ||
| 3 | +多模块嵌套 git 仓库(独立于外层 jiuwenclaw,提交分开做)。 | ||
| 4 | + | ||
| 5 | +核心模块 agent_runtime(会话编排服务,M0–M8 已完成,维护期):一切开发指引见 `applications/agent_runtime/CLAUDE.md`(红线规则/测试/部署/文档体系,先读)。 | ||
| 6 | + | ||
| 7 | +其余内容:`service/`(openjiuwen_runtime 服务框架:App/Envelope/SystemContext)、`applications/echo`(App 范式参考)、`docs/{zh,en}/`(平台用户文档,与 agent_runtime 服务无关)。 | ||
| @@ -0,0 +1,65 @@ | |||
| 1 | +# CLAUDE.md | ||
| 2 | + | ||
| 3 | +本仓库实现**会话编排服务**(旁路式:gateway 直连 AgentServer Pod,服务只做控制面)。**M0–M6 已全部完成**(含 server 模式真环境验收),维护期。 | ||
| 4 | + | ||
| 5 | +## 文档体系与开发前必读(路径均相对本模块根) | ||
| 6 | + | ||
| 7 | +文档三分:`docs/design/`(设计论证与 Lua 全文)、`docs/spec/`(**模块规格,AI 向**:代码在哪/怎么协作/改哪里)、`docs/feature/`(改动史)。 | ||
| 8 | + | ||
| 9 | +**文档同步义务(与代码改动同一提交完成):** | ||
| 10 | +- 改动涉及 spec 覆盖的内容(模块行为 / Redis 键 / Lua / 接口 / 配置 / 错误码)→ **同步更新对应 spec 文档**(`docs/spec/` 按模块对号入座)。 | ||
| 11 | +- **较大改动**(新功能 / 行为变化 / 重构 / 里程碑)→ 按 `docs/feature/_TEMPLATE.md` 新建一份记录并登记其 README 索引;**小的修复(局部 bugfix、注释/文案)不用**。 | ||
| 12 | + | ||
| 13 | +1. `docs/spec/README.md` —— spec 索引 + 一页纸架构(先读) | ||
| 14 | +2. `docs/spec/session-manager.md` / `docs/spec/resource-manager.md` / `docs/spec/service-core.md` —— 三模块规格(改代码前读对应模块);e2e 用例逐条说明见 `docs/spec/e2e-test-cases.md`(场景/输入/预期输出) | ||
| 15 | +3. `docs/design/Agent-Runtime-HLD.md` —— 架构总览 / 接口契约 / 场景 A–N / Redis 键表(**语义权威,冲突以它为准**;§9 实现与验收状态) | ||
| 16 | +4. `docs/design/session-manager-design.md` —— SM 详细设计(7 个 Lua 全文) | ||
| 17 | +5. `docs/design/resource-manager-design.md` —— RM 详细设计(6 个 Lua 全文) | ||
| 18 | + | ||
| 19 | +## 红线规则(违反即返工) | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +## 测试 | ||
| 23 | + | ||
| 24 | +```bash | ||
| 25 | +cd applications/agent_runtime | ||
| 26 | +uv sync --extra local | ||
| 27 | +uv run pytest # 114 个用例:状态层 Lua / config 层 / 组件全链路 / HTTP 冒烟 / corner case / 双实例多副本 | ||
| 28 | +``` | ||
| 29 | + | ||
| 30 | +- 构造 `ServiceManager` 必须传 `deploy_mode="subprocess"`(默认 k8s 会挂死测试)。 | ||
| 31 | +- fakeredis 陷阱:消费组 id=`"0"` bug、pubsub 需共享 FakeServer、EVAL 内 PUBLISH 需实测。 | ||
| 32 | +- 双实例测试(`tests/integration/test_multi_replica.py` + `_dual_harness.py`):同进程两 App 共享一组 fakeredis/SQLite/FakeK8s,httpx ASGITransport 单事件循环驱动;lifespan 必须先手动驱动(否则 RestAdapter 惰性二建 sysctx 绕过后台 Job)。 | ||
| 33 | +- 旧 SDK 已知失败用例(非回归)见外层 jiuwenclaw 仓库 CLAUDE.md 末尾清单。 | ||
| 34 | + | ||
| 35 | +### 集成冒烟(真环境,部署后回归) | ||
| 36 | + | ||
| 37 | +```bash | ||
| 38 | +cd applications/agent_runtime | ||
| 39 | +./scripts/integration_smoke.sh # HLD 场景 A–L 端到端;会 FLUSHDB 目标 Redis DB(有防误刷保护) | ||
| 40 | +``` | ||
| 41 | + | ||
| 42 | +- 参数/前置见 `--help` 与 README;场景 N 待 AgentServer 支持 `GET /health` 后补验。 | ||
| 43 | +- 经多副本 LB 亦可跑(实测 65/65),前提:部署带 `AGENT_RUNTIME_SCOPE_FULL_TIMEOUT`(deploy 模板默认 8,**须显著小于模板 session_ttl**,否则等待者 deadline 与会话到期碰撞产生混合结果);排查实录见 `docs/spec/e2e-test-cases.md` §8.1。 | ||
| 44 | +- cleanup 空目标必须用**无匹配 label_selector**,不得指向业务 ns(同 label 真实 AgentServer 会被误删)或不存在的 ns(in-cluster SA 对其 403 而非空列表)。 | ||
| 45 | + | ||
| 46 | +### 多副本(真环境) | ||
| 47 | + | ||
| 48 | +```bash | ||
| 49 | +# 宿主机双进程(快速):8091/8092 共享 Redis/DB,选主键互斥竞争 | ||
| 50 | +./scripts/deploy_replicas.sh 2 .env.production.local 8091 | ||
| 51 | +# K8s 多副本 + Service LB(生产形态):deploy/ 目录(模板+Dockerfile+渲染部署) | ||
| 52 | +./deploy/render_and_apply.sh deploy/agent_runtime.env --nodeport | ||
| 53 | +# 多副本 e2e(真 LB 单入口,含 failover;单实例自动 DEGRADED) | ||
| 54 | +uv run --no-sync python scripts/e2e_multi_replica.py --base-url http://127.0.0.1:30091/api/session \ | ||
| 55 | + --redis-url redis://127.0.0.1:30001/2 --namespace default | ||
| 56 | +# 压测/浸泡(零依赖,场景化;无 FLUSHDB、不动 cleanup 端点) | ||
| 57 | +uv run --no-sync python scripts/load_test.py --base-url http://127.0.0.1:30091/api/session --duration 60 | ||
| 58 | +``` | ||
| 59 | + | ||
| 60 | +## 环境 | ||
| 61 | + | ||
| 62 | +- Python 3.11–3.13;Redis 须开 AOF/RDB;DB 用 MySQL/PostgreSQL(禁 SQLite 回退——server 模式;local 模式调试可用)。 | ||
| 63 | +- 框架:`service/openjiuwen_runtime/service`(App/Envelope/SystemContext);App 范式参考 `applications/echo/echo_server.py`(**不是** a2a_service)。 | ||
| 64 | +- 部署:`applications/agent_runtime/scripts/deploy.sh local|server`(server 读 `.env.production.local`)。 | ||
| 65 | +- 本仓库是嵌套 git 仓库(独立于外层 jiuwenclaw),提交分开做。 | ||
| @@ -10,7 +10,8 @@ | |||
| 10 | DB(`service_config_template` / `routing_rule` 表);跨模块只走 Facade, | 10 | DB(`service_config_template` / `routing_rule` 表);跨模块只走 Facade, |
| 11 | 不直读对方 Redis key。 | 11 | 不直读对方 Redis key。 |
| 12 | - 设计文档:`docs/design/`(语义权威 = HLD;冲突以 HLD 为准)。 | 12 | - 设计文档:`docs/design/`(语义权威 = HLD;冲突以 HLD 为准)。 |
| 13 | -- 代码说明:`docs/agent-runtime-code-guide.md`(模块结构 / 关键流程 / Lua 清单 / 测试与部署)。 | 13 | +- 模块规格(AI 向):`docs/spec/`(README 索引 / service-core / session-manager / resource-manager;代码在哪、怎么协作、改哪里)。 |
| 14 | +- 改动史:`docs/feature/`(每次改动一份文档,见其 README 写作规范)。 | ||
| 14 | 15 | ||
| 15 | ## 运行 | 16 | ## 运行 |
| 16 | 17 | ||
| @@ -27,7 +28,7 @@ cp agent_runtime.server.env.example .env.production.local | |||
| 27 | 28 | ||
| 28 | ## 测试 | 29 | ## 测试 |
| 29 | 30 | ||
| 30 | -> 全部 e2e 用例(场景/输入/预期输出)逐条说明:`docs/e2e-test-cases.md`。 | 31 | +> 全部 e2e 用例(场景/输入/预期输出)逐条说明:`docs/spec/e2e-test-cases.md`。 |
| 31 | 32 | ||
| 32 | ```bash | 33 | ```bash |
| 33 | cd applications/agent_runtime | 34 | cd applications/agent_runtime |
| @@ -53,7 +54,7 @@ cd applications/agent_runtime | |||
| 53 | - 经多副本 LB 亦可跑(实测 65/65)——前提:部署带 | 54 | - 经多副本 LB 亦可跑(实测 65/65)——前提:部署带 |
| 54 | `AGENT_RUNTIME_SCOPE_FULL_TIMEOUT`(deploy 模板已默认 8,须显著小于模板 | 55 | `AGENT_RUNTIME_SCOPE_FULL_TIMEOUT`(deploy 模板已默认 8,须显著小于模板 |
| 55 | session_ttl,否则等待者 deadline 与会话到期碰撞产生混合结果)。 | 56 | session_ttl,否则等待者 deadline 与会话到期碰撞产生混合结果)。 |
| 56 | - 排查实录与 cleanup 空目标的三个坑见 `docs/e2e-test-cases.md` §8.1。 | 57 | + 排查实录与 cleanup 空目标的三个坑见 `docs/spec/e2e-test-cases.md` §8.1。 |
| 57 | 58 | ||
| 58 | ## 多副本部署与测试(M7) | 59 | ## 多副本部署与测试(M7) |
| 59 | 60 | ||
Rdocs/design/resource-manager-design.md→applications/agent_runtime/docs/design/resource-manager-design.md+0-0
文件重命名但无更改。
Rdocs/design/session-manager-design.md→applications/agent_runtime/docs/design/session-manager-design.md+0-0
文件重命名但无更改。
| @@ -0,0 +1,43 @@ | |||
| 1 | +# M8:deploy 锁输家改为 follower 等待室——跨副本冷竞争零多余 Pod | ||
| 2 | + | ||
| 3 | +- 日期:2026-08-24 | ||
| 4 | +- 里程碑 / commit:M8 / `58fc3301` | ||
| 5 | +- 涉及模块:resource_manager(acquire 链路)、session_manager(models.pool_config)、双实例测试 | ||
| 6 | + | ||
| 7 | +## 背景与动机 | ||
| 8 | + | ||
| 9 | +多副本部署下,冷突发(多个 route 同时到达、scope 无 Pod)会发生跨副本冷竞争:多副本同时进 acquire 的 need_deploy 分支,抢 `lock:rm:deploy:{scope}` 选主。 | ||
| 10 | + | ||
| 11 | +改前的输家行为:清占位后短暂自旋(0.3s)重跑 ACQUIRE,等锁空闲后**自己再 deploy 一个 Pod**。后果: | ||
| 12 | + | ||
| 13 | +- 输家自建的第 2 个 Pod 在 `max_pods` 内合法,但大概率是空 Pod,要经 SM 空 Pod pass → idle_consider → reclaim 链路自愈——资源浪费,冷突发尾延迟高(实测 30.5s); | ||
| 14 | +- 该问题作为开放问题记录在(重组时已删除的)开发交接文档 §十一.1 与 `docs/spec/e2e-test-cases.md` §8.2,本次关闭。 | ||
| 15 | + | ||
| 16 | +## 方案(定案,与需求方逐条确认) | ||
| 17 | + | ||
| 18 | +- **等待有界**:follower 等待上界 = `ready_timeout + 10s` 余量(注册开销)。 | ||
| 19 | +- **leader 失败,follower 不接管、直接失败**:同镜像同环境下 follower 接管大概率也失败,接管只会放大故障;失败判定 = deploy 锁空闲且无新 Pod 注册进展。 | ||
| 20 | +- **等待室在 Redis**(ZSET + deadline score),准入原子;**防崩溃泄漏**用 `ZREMRANGEBYSCORE(deadline)` 兜底——裸 SET 只靠 finally 出队,进程崩溃即泄漏(SM waiter 集合的既有教训,这里不重蹈)。 | ||
| 21 | +- **overflow 严格快失败**:准入上限 `pod_concurrency - 1`(leader 会话之外新 Pod 恰剩这些槽);超限抛 MaxPodsReached→503 NO_POD_AVAILABLE;`pod_concurrency=1` 极端场景不做特殊处理。 | ||
| 22 | +- **错误路径双清**:deploying 占位 + deploy_followers 成员都进 finally。 | ||
| 23 | + | ||
| 24 | +被否方案:输家接管 deploy(见上,放大故障);进程内等待(多副本下无意义,等待室必须共享)。 | ||
| 25 | + | ||
| 26 | +## 实现 | ||
| 27 | + | ||
| 28 | +- `LUA_DEPLOY_FOLLOWER_GATE`(RM 第 6 个 Lua):先清过期成员 → ZADD 先行 → `ZCARD > pc-1` 自退——纪律同 SM 的 `LUA_WAITER_GATE`(禁止「先查后加」)。新键 `resource_manager:resource:scope:{sid}:deploy_followers`(ZSET,request_id→deadline)。 | ||
| 29 | +- `orchestrator.py:acquire` 锁忙分支:清占位 → `_follow_leader` 轮询——发现新 Pod 注册且 pod:info 有 sse_url → **直接复用返回**(与 reuse 分支同构);SM 侧重跑仲裁即可,**SM 零改动**、不破「RM 不读 SM 容量键」红线;锁空闲无进展 → DeployFailed;deadline → MaxPodsReached。 | ||
| 30 | +- `Template.pool_config()` / `resource:scope:{sid}:config` 携带 `pod_concurrency`——**仅用于推导 follower 上限 pc-1**,per-Pod 容量闸门仍在 SM 侧(红线不变)。 | ||
| 31 | + | ||
| 32 | +## 验证 | ||
| 33 | + | ||
| 34 | +- 单测:pytest 106 → **114**(follower 闸门单测 6 + 双实例 2),连跑 3 次稳定。 | ||
| 35 | +- 双实例用例 4 收紧:并发冷启动断言从「1 ≤ 部署次数 ≤ 2」收紧为「**恰好 1 次部署**」。 | ||
| 36 | +- 真环境(2 副本经 LB):M6 冒烟 **65/65**,多副本 e2e **35/35**。 | ||
| 37 | +- 性能实测:冷启动尾延迟 **30.5s → 10.2s**(冷突发从串行多部署变为 1 次部署 + follower 复用),零错误。 | ||
| 38 | + | ||
| 39 | +## 影响面 | ||
| 40 | + | ||
| 41 | +- 文档同步:HLD 键表+读图;RM 设计 §2.1/§3/§5(Lua 5→6 含全文);code-guide Lua 表+流程;e2e-test-cases §8.2 开放问题关闭。 | ||
| 42 | +- 测试计数基准更新为 114(模块 CLAUDE.md 同步)。 | ||
| 43 | +- 注:2026-08-24 文档重组后,原 code-guide 已拆分为 `docs/spec/` 各模块 spec,本文件为该改动的正式记录。 | ||
| @@ -0,0 +1,20 @@ | |||
| 1 | +# feature —— 每次改动一份文档 | ||
| 2 | + | ||
| 3 | +本目录是**项目记忆**:**较大改动**(新功能 / 行为变化 / 重构 / 部署形态变化 / 里程碑)新建一个文档,记录背景、方案定案、验证证据;**小的修复(局部 bugfix、注释/文案)不用建**。git commit 是事实源,这里的文档提供可读叙事与验收数据,回答「当时为什么这么改、怎么验证的」——这些恰恰是 commit message 和代码里读不出来的。 | ||
| 4 | + | ||
| 5 | +## 写作规范 | ||
| 6 | + | ||
| 7 | +- **时机**:改动定稿(合并/提交)时写,随改动一起提交;文档头登记 commit hash。 | ||
| 8 | +- **命名**:`YYYY-MM-<短横线-slug>.md`;有里程碑编号的带编号,如 `2026-08-M8-deploy-lock-follower-waitroom.md`。 | ||
| 9 | +- **结构**:按 [_TEMPLATE.md](_TEMPLATE.md);写完在下方索引表加一行。 | ||
| 10 | +- **内容纪律**: | ||
| 11 | + - 「与需求方确认的决策」逐条列出——这是防止后人重新踩已否决方案的关键。 | ||
| 12 | + - 验证写实测数据(pytest 计数、e2e 结果、延迟数字),不写「已验证」三个字。 | ||
| 13 | + - 被否方案与理由值得记,accepted-but-superseded 的决策标注演进关系。 | ||
| 14 | +- **颗粒度**:一次连贯的改动一份(一个 milestone / 一个有分量的 feature / 一次重构或文档重组),不求与 commit 一一对应。拿不准要不要建时,判据:半年后还有没有人需要知道「当时为什么这么改」。 | ||
| 15 | + | ||
| 16 | +## 索引 | ||
| 17 | + | ||
| 18 | +| 日期 | 文档 | 一句话 | | ||
| 19 | +|---|---|---| | ||
| 20 | +| 2026-08 | [M8 deploy 锁输家改 follower 等待室](2026-08-M8-deploy-lock-follower-waitroom.md) | 跨副本冷竞争零多余 Pod,冷启动尾延迟 30.5s→10.2s | | ||
| @@ -0,0 +1,27 @@ | |||
| 1 | +# <标题:做了什么,一句话> | ||
| 2 | + | ||
| 3 | +- 日期:YYYY-MM-DD | ||
| 4 | +- 里程碑 / commit:M*(可选)/ <hash> | ||
| 5 | +- 涉及模块:session_manager / resource_manager / service-core / 测试 / 部署 / 文档 | ||
| 6 | + | ||
| 7 | +## 背景与动机 | ||
| 8 | + | ||
| 9 | +为什么改。触发事件(bug 现象 / 验收发现 / 需求变化),以及不改会怎样。 | ||
| 10 | + | ||
| 11 | +## 方案 | ||
| 12 | + | ||
| 13 | +定案要点,逐条列出。与需求方确认过的决策要写明「确认过」;被否掉的备选方案及其否决理由也记在这里(防止后人重新踩)。 | ||
| 14 | + | ||
| 15 | +## 实现 | ||
| 16 | + | ||
| 17 | +文件级要点:改了哪些文件、新增键 / Lua / 接口、与红线规则相关的处理(占位清理、原子闸门、跨模块只走 Facade 等)。 | ||
| 18 | + | ||
| 19 | +## 验证 | ||
| 20 | + | ||
| 21 | +- 单测:pytest 计数变化、新增用例名与断言意图。 | ||
| 22 | +- 真环境:冒烟 / e2e 项数与结果(如 65/65)。 | ||
| 23 | +- 性能/行为实测数据(有则必填,给数字)。 | ||
| 24 | + | ||
| 25 | +## 影响面 | ||
| 26 | + | ||
| 27 | +同步了哪些文档(HLD / design / spec / e2e-test-cases);配置与兼容性注意;遗留开放问题与后续计划。 | ||
| @@ -0,0 +1,54 @@ | |||
| 1 | +# agent-runtime 模块规格(spec)索引 | ||
| 2 | + | ||
| 3 | +- 读者:AI / 维护工程师。本目录回答「**代码在哪、怎么协作、改哪里**」。 | ||
| 4 | +- 设计论证与 Lua 全文在 `../design/`;每次改动的记录在 `../feature/`(改动时新建一份,规范见 `../feature/README.md`)。 | ||
| 5 | +- 语义冲突时以 `../design/Agent-Runtime-HLD.md` 为准。 | ||
| 6 | + | ||
| 7 | +## 文档地图 | ||
| 8 | + | ||
| 9 | +| 文档 | 内容 | 何时读 | | ||
| 10 | +|---|---|---| | ||
| 11 | +| 本文件 | 架构一页纸 + 键前缀总览 + 测试/部署入口 | 先读 | | ||
| 12 | +| [service-core.md](service-core.md) | 组装(main)/CLI/配置(`AGENT_RUNTIME_*`)/错误码契约/字段分类/工具/部署 | 改装配、配置、错误契约、部署时 | | ||
| 13 | +| [session-manager.md](session-manager.md) | SM:route/touch/config_sync/cleanup 编排、7 个 Lua、SM 键表 | 改会话编排/配置层时 | | ||
| 14 | +| [resource-manager.md](resource-manager.md) | RM:acquire/后台任务/K8s 适配、6 个 Lua、RM 键表 | 改 Pod 池/扩缩容/清理时 | | ||
| 15 | +| [e2e-test-cases.md](e2e-test-cases.md) | 全部 e2e 用例的场景/输入/预期输出 | 写或跑 e2e 时 | | ||
| 16 | +| `../design/Agent-Runtime-HLD.md` | 架构总览/接口契约/场景 A–N/Redis 键表(语义权威) | 语义不确定时 | | ||
| 17 | +| `../design/session-manager-design.md` | SM 详细设计(7 个 Lua 全文) | 深挖 SM 设计动机 | | ||
| 18 | +| `../design/resource-manager-design.md` | RM 详细设计(6 个 Lua 全文) | 深挖 RM 设计动机 | | ||
| 19 | + | ||
| 20 | +## 一页纸架构 | ||
| 21 | + | ||
| 22 | +一个进程、一个 App(`/api/session`,端口 8091)、两个模块: | ||
| 23 | + | ||
| 24 | +``` | ||
| 25 | +gateway ──route/touch──► agent-runtime(uvicorn) | ||
| 26 | +claw mgr ──config_sync──► ├─ session_manager 持 App,4 个 HTTP handler | ||
| 27 | +运维 ──cleanup──────► └─ resource_manager 无 App,纯 Facade + 后台任务 | ||
| 28 | + 两模块共享同一 Redis(前缀隔离)+ 同一 DB,互调只走进程内 Facade | ||
| 29 | +数据面(本服务全程旁路):gateway ◄──SSE──► AgentServer Pod(route 返回 pod_sse_url) | ||
| 30 | +``` | ||
| 31 | + | ||
| 32 | +- 状态分层:**编排态在 Redis**(键前缀见下)、**配置在 DB**(`service_config_template` / `routing_rule` 表)、**Pod 物理态以 K8s 为唯一真相源**。 | ||
| 33 | +- 多副本无状态;后台任务经 Redis 选主锁(`agent_runtime:job:*`)全局单副本执行写操作。 | ||
| 34 | +- 所有编排态变更走 Lua(EVAL 原子);脚本不传 KEYS,`ARGV[1]` 为键前缀,键名在脚本内拼——调用统一经各模块 `state.py` 的 `eval()`。 | ||
| 35 | + | ||
| 36 | +## Redis 键前缀总览(逐键明细见各模块 spec) | ||
| 37 | + | ||
| 38 | +``` | ||
| 39 | +session_manager:… SM 编排态(会话四处/scope 闸门/等待队列/候选集/注册表) | ||
| 40 | +resource_manager:… RM 编排态(per-scope Pod 池/idle 暖池/deploy 占位/follower 等待室/选主锁) | ||
| 41 | +agent_runtime:job:… 后台任务选主锁(main.py:_build_jobs 注册) | ||
| 42 | +``` | ||
| 43 | + | ||
| 44 | +## 测试与部署(速查) | ||
| 45 | + | ||
| 46 | +```bash | ||
| 47 | +cd applications/agent_runtime | ||
| 48 | +uv sync --extra local && uv run pytest # 114 用例(fakeredis+SQLite+FakeK8s) | ||
| 49 | +./scripts/integration_smoke.sh # 真环境冒烟(场景 A–L;FLUSHDB 目标库,有防误刷) | ||
| 50 | +./scripts/deploy_replicas.sh 2 .env.production.local 8091 # 宿主机双进程 | ||
| 51 | +./deploy/render_and_apply.sh deploy/agent_runtime.env --nodeport # K8s 生产形态 | ||
| 52 | +``` | ||
| 53 | + | ||
| 54 | +用例分层表、多副本 e2e、压测入口与全部前置红线见 `e2e-test-cases.md` 与 `service-core.md` §部署。 | ||
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | - 日期:2026-08-18(M6 冒烟固化于 2026-08-15;M7 多副本补全于 2026-08-18) | 3 | - 日期:2026-08-18(M6 冒烟固化于 2026-08-15;M7 多副本补全于 2026-08-18) |
| 4 | - 读者:执行/评审端到端验收的工程师 | 4 | - 读者:执行/评审端到端验收的工程师 |
| 5 | -- 配套:语义权威 = `design/Agent-Runtime-HLD.md`(§6 场景、§5 键表);脚本本体在 | 5 | +- 配套:语义权威 = `../design/Agent-Runtime-HLD.md`(§6 场景、§5 键表);脚本本体在 |
| 6 | `applications/agent_runtime/scripts/`;本文回答"**每个 e2e 用例:场景、输入、预期输出**"。 | 6 | `applications/agent_runtime/scripts/`;本文回答"**每个 e2e 用例:场景、输入、预期输出**"。 |
| 7 | 7 | ||
| 8 | --- | 8 | --- |
| @@ -0,0 +1,124 @@ | |||
| 1 | +# resource_manager(RM)规格 | ||
| 2 | + | ||
| 3 | +> Pod 池管理:acquire(扩容决策)、后台任务(autoscale/reclaim/watch/reconcile)、K8s 适配。 | ||
| 4 | +> **无 App/端口/prefix**,纯进程内 Facade + 后台任务。**不读 SM 的容量键**(per-Pod 容量闸门在 SM 侧)。 | ||
| 5 | +> Lua 全文:`lua_scripts.py` 与 `../design/resource-manager-design.md` 双份,改时同步。 | ||
| 6 | + | ||
| 7 | +## 文件一览 | ||
| 8 | + | ||
| 9 | +| 文件 | 职责 | | ||
| 10 | +|---|---| | ||
| 11 | +| `facade.py` | `ResourceManagerFacade`(SM→RM 进程内入口,薄封装) | | ||
| 12 | +| `orchestrator.py` | acquire(取暖/选主 deploy/follower 等待室)+ idle_consider + update_pool_config + cleanup + 幂等缓存 | | ||
| 13 | +| `state.py` | RM Redis 键 schema 唯一出口(`RMKeys`/`ResourceState`) | | ||
| 14 | +| `lua_scripts.py` | 6 个 Lua 全文 | | ||
| 15 | +| `k8s.py` | `K8sPodClient` 接口 + `RealK8sPodClient`(kubernetes_asyncio)+ `FakeK8sPodClient` | | ||
| 16 | +| `sweeper.py` | 四个后台任务(各自带选主锁) | | ||
| 17 | +| `models.py` | `PodInfo`/`PodDeployInfo`/判死枚举/label 常量 | | ||
| 18 | + | ||
| 19 | +## facade.py —— SM→RM 契约 | ||
| 20 | + | ||
| 21 | +| 方法 | 返回/异常 | | ||
| 22 | +|---|---| | ||
| 23 | +| `acquire(scope_id, pod_spec, pool_config, request_id)` | `{pod_id, pod_sse_url}`;失败抛 `MaxPodsReached`/`DeployFailed`(SM 映射 503 NO_POD_AVAILABLE) | | ||
| 24 | +| `idle_consider(pod_id, scope_id)` | `{transitioned_to_idle}`,幂等 | | ||
| 25 | +| `update_pool_config(scope_id, pool_config, pod_spec?)` | `{updated}`(config_sync 触发) | | ||
| 26 | +| `cleanup(namespace?, label_selector?)` | `cleaned: int`(运维批删) | | ||
| 27 | + | ||
| 28 | +## orchestrator.py —— acquire 决策树 | ||
| 29 | + | ||
| 30 | +``` | ||
| 31 | +幂等缓存(request_id,键 resource_manager:idem:{rid},TTL 60,命中续期) | ||
| 32 | +→ 首见 scope:缓存池参数 + pod_spec_json 到 resource:scope:{sid}:config | ||
| 33 | +→ 循环 { LUA_ACQUIRE → (action, pod_id, sse_url): | ||
| 34 | + reuse → 直接返回(deploy_ver 过滤后的暖 Pod,已弹出 idle 池) | ||
| 35 | + max_reached → 清占位 → MaxPodsReached | ||
| 36 | + no_config → continue(上面已写配置) | ||
| 37 | + need_deploy → 抢 lock:rm:deploy:{scope}(TTL 360,盖住 ready_timeout 300+余量): | ||
| 38 | + 赢家 → _deploy_and_register(idle_flag=False) | ||
| 39 | + 输家 → 清占位 → _follow_leader(follower 等待室) } | ||
| 40 | +``` | ||
| 41 | + | ||
| 42 | +`_deploy_and_register`:`k8s.deploy(pod_spec)`(create+wait Ready)→ 拼 `pod_sse_url = http://{pod_ip}:{sse_port}{sse_path}` → `LUA_REGISTER`。**失败必须清 deploying 占位再抛 DeployFailed(红线:防 max_pods 永久虚高)**。 | ||
| 43 | + | ||
| 44 | +`_follow_leader`(M8,deploy 锁输家的等待室): | ||
| 45 | +- 准入走 `LUA_DEPLOY_FOLLOWER_GATE` 原子闸门,上限 `pod_concurrency - 1`(leader 会话之外新 Pod 恰剩这些槽);overflow 严格快失败 MaxPodsReached。 | ||
| 46 | +- 等待有界:`ready_timeout + 10s` 余量;轮询 `resource:scope:{sid}:pods` 出现新 Pod 且 pod:info 有 sse_url → **直接复用返回**(与 reuse 分支同构,SM 侧重跑仲裁即可)。 | ||
| 47 | +- leader 失败判定:deploy 锁空闲且无新 Pod → `DeployFailed`(**follower 不接管**——同镜像同环境大概率也失败);deadline 到 → MaxPodsReached。 | ||
| 48 | +- 错误路径双清:占位 + follower 成员都进 finally;崩溃遗留由闸门 `ZREMRANGEBYSCORE(deadline)` 兜底。 | ||
| 49 | + | ||
| 50 | +`idle_consider`:`LUA_RELEASE` 转 idle 暖池(起 pod_ttl 计时)+ pod:info.phase=idle;幂等。 | ||
| 51 | +`update_pool_config`:HSET 覆盖池参数;A 类变更附带 pod_spec 时同时刷 deploy_ver/pod_spec_json(autoscale 补位用新 deploy 字段)。 | ||
| 52 | +`cleanup`:K8s list+delete,**不操作 Redis 编排态**(被删 Pod 由 watch/reconcile 兜底发现);ns 404 容忍为 cleaned=0,**403 保持 fail-fast**(静默清零会掩盖部署配错)。 | ||
| 53 | + | ||
| 54 | +## state.py —— RM 键表(`RMKeys`,前缀 `resource_manager:`,业务键再带 `resource:` 段) | ||
| 55 | + | ||
| 56 | +| 键 | 类型 | 语义 | | ||
| 57 | +|---|---|---| | ||
| 58 | +| `resource:scope:{sid}:pods` | ZSET | 该 scope 全部 Pod(in_use ∪ idle);**ZCARD+deploying SCARD 参与 max_pods 判定** | | ||
| 59 | +| `resource:scope:{sid}:idle` | SET | idle 暖池;acquire 从此取暖 Pod | | ||
| 60 | +| `resource:scope:{sid}:config` | HASH | min_idle_pods/max_pods/pod_ttl/pod_concurrency/deploy_ver/pod_spec_json | | ||
| 61 | +| `resource:scope:{sid}:deploying` | SET | deploy 占位 token(计入 max_pods,防并发超配) | | ||
| 62 | +| `resource:scope:{sid}:deploy_followers` | ZSET | follower 等待室(request_id→deadline 秒级 score;闸门按 deadline 原子清过期) | | ||
| 63 | +| `resource:pod:{pod}:info` | HASH | scope_id/pod_sse_url/pod_ip/namespace/phase/created_ts/deploy_ver | | ||
| 64 | +| `resource:pod:{pod}:idle_since` | STR | idle 起始(reclaim 计时);存在 ⟺ 在 idle 池 | | ||
| 65 | +| `resource:pod:{pod}:health_fails` | STR | 健康探测连续失败次数(场景 N) | | ||
| 66 | +| `resource:pods:all` | SET | 全部 pod_id(watch/reconcile 枚举) | | ||
| 67 | +| `lock:rm:deploy:{sid}` | STR(NX EX 360) | per-scope deploy 选主串行 | | ||
| 68 | +| `lock:rm:autoscale\|reclaim\|watch\|reconcile` | STR(NX EX) | 后台任务 tick 级选主 | | ||
| 69 | +| `idem:{request_id}` | STR | acquire 结果幂等缓存(TTL 60) | | ||
| 70 | + | ||
| 71 | +计数全部派生自 SCARD/ZCARD,无独立计数器。 | ||
| 72 | + | ||
| 73 | +## lua_scripts.py —— 6 个 Lua | ||
| 74 | + | ||
| 75 | +| 脚本 | 一句话职责 | | ||
| 76 | +|---|---| | ||
| 77 | +| `LUA_ACQUIRE` | 取暖 Pod 复用(**跳过 deploy_ver 不匹配**——A 类变更后老版本暖 Pod 不外发,按 pod_ttl 自然回收)→ 无匹配判 max_pods(ZCARD pods + SCARD deploying)→ 占位 SADD deploying → need_deploy | | ||
| 78 | +| `LUA_PLACEHOLDER` | autoscale 专用占位(判 max_pods + SADD,**不碰 idle 池**——补位不该消耗暖 Pod) | | ||
| 79 | +| `LUA_REGISTER` | deploy 成功登记:pod:info / scope:pods / pods:all 同写,清占位;idle_flag=1(热备)入 idle 池 | | ||
| 80 | +| `LUA_RELEASE` | idle_consider:转 idle 暖池 + 起 pod_ttl 计时(SADD/SET 天然幂等) | | ||
| 81 | +| `LUA_PURGE` | Pod 死亡/reclaim 后清全部 RM key(返回其 scope_id;幂等) | | ||
| 82 | +| `LUA_DEPLOY_FOLLOWER_GATE` | follower 等待室原子准入:先 `ZREMRANGEBYSCORE` 清过期 → ZADD 先行 → ZCARD 超限自退(纪律同 LUA_WAITER_GATE,禁止先查后加) | | ||
| 83 | + | ||
| 84 | +约定同 SM:不传 KEYS,`ARGV[1]`=前缀,返回扁平字符串数组。 | ||
| 85 | + | ||
| 86 | +## k8s.py —— K8s 适配层 | ||
| 87 | + | ||
| 88 | +`K8sPodClient` 抽象(Real/Fake 同签名):`start/close/deploy/delete/get_pod/list_pods/probe_health`。 | ||
| 89 | + | ||
| 90 | +**RealK8sPodClient**(kubernetes_asyncio): | ||
| 91 | +- `start()`:先 `load_incluster_config()`(**同步函数,不可 await**——await 会 TypeError→in-cluster 必挂,M7 修复),ConfigException 再 `await load_kubeconfig()`。 | ||
| 92 | +- `deploy(pod_spec)`:pod_id = `{pod_name}-{随机10}-{随机5}`(**K8s 随机 Pod 名,严禁业务 id 当实例 id——历史死锁根因**);409 名字冲突重命名重试至多 3 次;`_wait_ready` 轮询至 Ready+有 podIP,终态(Failed/Succeeded)/消失/超时 → DeployFailed。 | ||
| 93 | +- `_build_pod_body`:label `{jiuwenclaw-component: agentserver, app: pod_id}`;NFS 卷挂载;资源 requests/limits;sse_port 必开(名 `sse`),container_port≠sse_port 加 `http`;readiness probe = `GET /health:sse_port`(AgentServer 固定约定,场景 N);restart_policy=Always。 | ||
| 94 | +- `normalize_phase`:deletion→Terminating;容器 waiting reason(ImagePullBackOff/CrashLoopBackOff/…)优先于 phase。 | ||
| 95 | + | ||
| 96 | +**FakeK8sPodClient**(local/单测):deploy 立即 Ready;可编程 `unready_pods`/`dead_pods`/`unhealthy_pods`/`deploy_failures` 模拟异常分支。 | ||
| 97 | + | ||
| 98 | +`probe_health(pod_ip, sse_port)`:`GET http://{pod_ip}:{sse_port}/health`,3s 超时,非 200/异常即不健康(K8sPodClient 基类默认实现,Real/Fake 共用)。 | ||
| 99 | + | ||
| 100 | +## models.py | ||
| 101 | + | ||
| 102 | +- `DEAD_POD_STATUSES`:Terminating/Failed/CrashLoopBackOff/ImagePullBackOff/ErrImagePull/InvalidImageName。**Pending 不判死**(deploy 靠 ready_timeout 兜)。 | ||
| 103 | +- `POD_LABEL_SELECTOR = "jiuwenclaw-component=agentserver"`(cleanup 默认 selector)。 | ||
| 104 | +- `PodDeployInfo`(deploy 产物物理信息)/`PodInfo`(get/list 状态视图:归一化 phase+ready+reason)。 | ||
| 105 | + | ||
| 106 | +## sweeper.py —— 四个后台任务 | ||
| 107 | + | ||
| 108 | +均 per-scope 操作、不读 SM Redis key、各自带 tick 级选主锁(调度由 main 注入): | ||
| 109 | + | ||
| 110 | +| 任务 | 周期/锁 | 逻辑 | | ||
| 111 | +|---|---|---| | ||
| 112 | +| `autoscale_once`(场景 H) | 1s / lock:rm:autoscale | 遍历 `known_scope_ids()`(SCAN scope:config):idle < min_idle_pods 且 pods+deploying < max_pods → `LUA_PLACEHOLDER` 占位 → 抢 deploy 锁 → `_deploy_and_register(idle_flag=True)` 热备入池;pod_spec 取 scope:config 缓存(A 类变更后为新值) | | ||
| 113 | +| `reclaim_once`(场景 K) | 1s / lock:rm:reclaim | idle 超 min_idle 底数的 excess 中 `aged ≥ pod_ttl` → `_purge_and_notify`(K8s delete → LUA_PURGE → notify_pod_dead);保护最早入 idle 的 min_idle 个(保底热备) | | ||
| 114 | +| `watch_once`(场景 J/N) | 10s / lock:rm:watch(TTL 15) | 遍历 pods:all:get_pod 为 None 或 phase∈DEAD → 清理;Running 但 `probe_health` **连续 2 次失败**(health_fails 阈值,防瞬时抖动误杀)→ 半死清理;成功清零计数。sse_port 从 scope:config 的 pod_spec_json 取 | | ||
| 115 | +| `reconcile_once`(场景 L) | 30s / lock:rm:reconcile(TTL 60) | ① Redis 有 K8s 无 → PURGE+notify;② RM 持有但 SM 候选集已无的 stale Pod(经 `sm_facade.reconcile_pods`,Facade 单向)→ `LUA_RELEASE` 转 idle 按 pod_ttl 回收 | | ||
| 116 | + | ||
| 117 | +`_purge_and_notify(pod_id)` 三步(K8s delete 若还在 → LUA_PURGE → notify_pod_dead);全幂等,单步失败仅记录(30s reconcile 兜底)。 | ||
| 118 | + | ||
| 119 | +## 高频踩点 | ||
| 120 | + | ||
| 121 | +- 错误路径必须清占位(deploy 失败 → SREM deploying);follower 路径还要清 deploy_followers 成员——**双清纪律**。 | ||
| 122 | +- 跨副本冷竞争:follower 等待室保证并发冷启动「恰好 1 次部署」;测试断言「窗口零重叠 + Pod ≤ max_pods」,多后端冷突发 NO_POD_AVAILABLE 快失败属预期。 | ||
| 123 | +- cleanup 空目标必须用**无匹配 label_selector**(业务 ns 同 label 会误删真实 AgentServer;不存在 ns 在 in-cluster SA 下 403 而非空列表)。 | ||
| 124 | +- 框架 `load_incluster_config` 是同步函数(已修);`kubernetes_asyncio` 仅 server extra 依赖,local 模式不得 import(判定用 `getattr(exc, "status")`)。 | ||
| @@ -0,0 +1,110 @@ | |||
| 1 | +# service-core 规格(组装 / 配置 / 错误码 / 字段分类 / 部署) | ||
| 2 | + | ||
| 3 | +> 覆盖 `src/agent_runtime/` 顶层 6 个文件:`main.py`、`cli.py`、`config.py`、`errors.py`、`spec_fields.py`、`util.py`。 | ||
| 4 | +> SM/RM 模块内文件见 [session-manager.md](session-manager.md) / [resource-manager.md](resource-manager.md)。 | ||
| 5 | + | ||
| 6 | +## main.py —— 组装入口(唯一可运行的壳) | ||
| 7 | + | ||
| 8 | +### build_resources(settings, arc) → (redis, db, k8s) | ||
| 9 | + | ||
| 10 | +双模式构造共享物理资源: | ||
| 11 | + | ||
| 12 | +| mode | redis | db | k8s | | ||
| 13 | +|---|---|---|---| | ||
| 14 | +| `local` | fakeredis(进程内) | 文件型 SQLite(`AGENT_RUNTIME_SQLITE_PATH`,默认 `./agent_runtime_local.db`;`:memory:` 在连接池下会丢表) | `FakeK8sPodClient` | | ||
| 15 | +| `server` | `build_redis_client(settings)` | `build_db_handler(settings)`,**None 即 RuntimeError**(禁 SQLite 回退,fail-fast) | `RealK8sPodClient` | | ||
| 16 | + | ||
| 17 | +### OrchestratorSystemContext(SystemContext 子类) | ||
| 18 | + | ||
| 19 | +SM 侧 ctx,级联管理全部生命周期(框架 App 的 lifespan 只认一个 ctx_factory——返回本类): | ||
| 20 | + | ||
| 21 | +- 构造时同时建 **rm_sysctx**(同 redis/db,仅 `key_prefix` 不同:SM=`session_manager`,RM=`resource_manager`)。 | ||
| 22 | +- `_bind_modules()`:先构造后绑定,破解 SM↔RM 循环引用—— | ||
| 23 | + `SessionState`/`ResourceState` → `SessionManagerFacade`/`ResourceManagerFacade(ResourceOrchestrator)` → `ConfigStore(push_pool_config=rm_facade.update_pool_config)` → `SessionOrchestrator` → `SessionSweeper`/`ResourceSweeper`。 | ||
| 24 | +- `_build_jobs()`:5 个后台任务,全部 `create_single_leader_job`(tick 级 Redis 选主锁,多副本全局单副本执行): | ||
| 25 | + | ||
| 26 | +| 任务 | tick | 锁键(`agent_runtime:job:*`) | 动作 | | ||
| 27 | +|---|---|---|---| | ||
| 28 | +| sm_sweep | `sweep_interval`(1s) | `sm_sweep` | 到期 pass + 空 Pod pass | | ||
| 29 | +| rm_autoscale | `autoscale_interval`(1s) | `rm_autoscale` | min_idle 热备补位 | | ||
| 30 | +| rm_reclaim | `reclaim_interval`(1s) | `rm_reclaim` | idle 超 pod_ttl 回收 | | ||
| 31 | +| rm_watch | `watch_interval`(10s) | `rm_watch` | 死 Pod 判定 + 健康探测 | | ||
| 32 | +| rm_reconcile | `reconcile_interval`(30s) | `rm_reconcile` | 孤儿/stale 对账 | | ||
| 33 | + | ||
| 34 | +- `start()` 顺序:super().start() → rm_sysctx.start() → k8s.start()(**失败仅降级扩缩容,不阻断启动**)→ 启动 5 个 job。`stop()` 逆序。 | ||
| 35 | +- DB 表初始化:构造参数 `table_definitions=[SERVICE_CONFIG_TEMPLATE_TABLE_DEF, ROUTING_RULE_TABLE_DEF]`(表结构在 `config_store.py`)。 | ||
| 36 | + | ||
| 37 | +### create_app(settings, arc, *, resources=None, instance_id=None, own_resources=True) | ||
| 38 | + | ||
| 39 | +构造唯一 App(`prefix=/api/session`,`enable_ws=False`)+ `/healthz` + 4 个 handler。 | ||
| 40 | +`resources`/`instance_id`/`own_resources` 仅供多实例测试注入共享物理资源(`tests/integration/_dual_harness.py`);生产路径不传。 | ||
| 41 | + | ||
| 42 | +### /healthz(main.py:_register_healthz) | ||
| 43 | + | ||
| 44 | +进程就绪探针(K8s probe / deploy_replicas.sh 就绪轮询 / e2e 实例观测)。sysctx 未就绪 → 503;就绪返回 `{ok, instance_id}`。 | ||
| 45 | +**坑**:模块顶部 `from __future__ import annotations` 下,FastAPI 经 `get_type_hints` 用模块全局解析注解——`Request` 必须顶层 import,函数内局部导入会被当成 query 参数(422)。 | ||
| 46 | + | ||
| 47 | +## cli.py —— 命令行入口 | ||
| 48 | + | ||
| 49 | +`deploy.sh` 调用。参数:`--mode local|server`(必填)、`--env-file`(dotenv 先加载,`override=False`)、`--host`/`--port`(覆盖 `OPENJIUWEN_SERVICE_*`)。流程:load_dotenv → 设 `AGENT_RUNTIME_MODE` → basicConfig(`AGENT_RUNTIME_LOG_LEVEL`)→ `ServiceConfig.from_env()` + `AgentRuntimeConfig.from_env()` → `create_app` → uvicorn.run。 | ||
| 50 | + | ||
| 51 | +## config.py —— AgentRuntimeConfig(本服务自有配置) | ||
| 52 | + | ||
| 53 | +框架级(host/port/redis/db)走 `ServiceConfig.from_env()`(`OPENJIUWEN_SERVICE_*`);本文件只放 `AGENT_RUNTIME_*`: | ||
| 54 | + | ||
| 55 | +| 字段 | env | 默认 | 说明 | | ||
| 56 | +|---|---|---|---| | ||
| 57 | +| mode | `AGENT_RUNTIME_MODE` | server | server\|local | | ||
| 58 | +| kubeconfig | `AGENT_RUNTIME_KUBECONFIG` | None(集群内 SA) | | | ||
| 59 | +| default_namespace | `AGENT_RUNTIME_DEFAULT_NAMESPACE` | default | | | ||
| 60 | +| sweep_interval | `AGENT_RUNTIME_SWEEP_INTERVAL` | 1 | SM:到期+空 Pod pass | | ||
| 61 | +| autoscale_interval | `AGENT_RUNTIME_AUTOSCALE_INTERVAL` | 1 | RM:min_idle 补位(**全局默认,无 per-scope 覆盖**) | | ||
| 62 | +| reclaim_interval | `AGENT_RUNTIME_RECLAIM_INTERVAL` | 1 | RM:idle 回收 | | ||
| 63 | +| watch_interval | `AGENT_RUNTIME_WATCH_INTERVAL` | 10 | RM:死 Pod+健康探测 | | ||
| 64 | +| reconcile_interval | `AGENT_RUNTIME_RECONCILE_INTERVAL` | 30 | RM:对账 | | ||
| 65 | +| scope_full_timeout | `AGENT_RUNTIME_SCOPE_FULL_TIMEOUT` | 30.0 | 等待队列阻塞上限(**部署须显著小于 session_ttl**,否则等待者 deadline 与会话到期碰撞) | | ||
| 66 | +| default_session_ttl | `AGENT_RUNTIME_DEFAULT_SESSION_TTL` | 60 | touch 兜底 ttl | | ||
| 67 | + | ||
| 68 | +常量:`SM_KEY_PREFIX="session_manager"`、`RM_KEY_PREFIX="resource_manager"`、`SERVICE_PREFIX="/api/session"`。 | ||
| 69 | + | ||
| 70 | +## errors.py —— 错误码契约(语义权威 HLD §3.1) | ||
| 71 | + | ||
| 72 | +`ErrorCode` 常量 + 异常类(均继承 `AgentRuntimeError(FrameworkError)`,类属性 `code`)+ `HTTP_STATUS_MAP` + `register_codes()`(幂等,main.py import 时调用)。 | ||
| 73 | + | ||
| 74 | +| 码 | HTTP | retry_after | 场景 | | ||
| 75 | +|---|---|---|---| | ||
| 76 | +| `SCOPE_QUEUE_FULL` | 503 | ✅ | 等待队列满,快失败 | | ||
| 77 | +| `SCOPE_FULL_TIMEOUT` | 504 | ✅ | 队列内等待超时 | | ||
| 78 | +| `NO_POD_AVAILABLE` | 503 | ✅ | acquire 失败(MaxPodsReached/DeployFailed 在 SM 侧映射而来) | | ||
| 79 | +| `CONFIG_NOT_FOUND` | 503 | ❌ | resolve 无匹配规则/模板禁用 | | ||
| 80 | +| `VALIDATION` | 400 | ❌ | 参数错 | | ||
| 81 | +| `CONFIG_SYNC_BUSY` | 409 | — | 上一次热更新未完成 / 日落待回收中间态 Pod | | ||
| 82 | + | ||
| 83 | +- `retry_after` 仅过载类携带(秒);Facade 间以 Python 异常传播,handler 捕获后映射为 `ResponseEnvelope(ok=False, error_code, retry_after)`。 | ||
| 84 | +- `MAX_PODS_REACHED` / `DEPLOY_FAILED` 是 RM Facade 内部异常,SM route 捕获后统一映射 `NO_POD_AVAILABLE`,不对外。 | ||
| 85 | + | ||
| 86 | +## spec_fields.py —— template 字段分类(SM/RM 静态共享) | ||
| 87 | + | ||
| 88 | +- `DEPLOY_FIELDS`:A 类(deploy 子集,值烘焙进运行中 Pod,变更需日落)。 | ||
| 89 | +- `DEPLOY_VER_FIELDS = DEPLOY_FIELDS + (ready_timeout, ready_poll_interval)`:deploy 指纹字段集。 | ||
| 90 | +- `POLICY_FIELDS`:B 类策略(`scope_concurrency/pod_concurrency/session_ttl/pod_ttl/min_idle_pods`)。 | ||
| 91 | +- **kubeconfig 例外**:在 deploy 子集但**不入指纹**(只影响新 deploy,不日落)。 | ||
| 92 | +- 约束:SM `Template.deploy_ver()` 与 RM `orchestrator._deploy_ver()` 必须用同一字段集与算法(`util.fingerprint`)——A 类版本过滤依赖两端一致。 | ||
| 93 | +- **新增 template 字段时**:先在此分类 → 再补 `config_store.py` 的 `_COLUMN_OF` 列映射与 `*_TABLE_DEF` 表结构。 | ||
| 94 | + | ||
| 95 | +## util.py —— 纯函数 | ||
| 96 | + | ||
| 97 | +- `scope_id_of(group_id, bot_id)` = `md5(group_id + "\x00" + bot_id)`;`\x00` 防 `(ab,c)`/`(a,bc)` 撞号。 | ||
| 98 | +- `fingerprint(fields)`:按 key 排序、剔 None、md5 取前 16 hex(deploy_ver 用)。 | ||
| 99 | +- `s()`/`to_int()`:Redis 返回值 bytes/str 归一(真实 client 是 bytes,fakeredis 可能是 str)。 | ||
| 100 | +- `now_ts()`:秒级 int(Redis 键内时间统一秒级)。 | ||
| 101 | + | ||
| 102 | +## 部署 | ||
| 103 | + | ||
| 104 | +- **双进程宿主机**:`scripts/deploy_replicas.sh N [env] [port]`(N 进程共 Redis/DB,`/healthz` 就绪轮询,trap 清理;local 模式 fail-fast)。 | ||
| 105 | +- **K8s 生产形态**:`deploy/` 目录——`agent_runtime.template.yaml`(SA+Role×2+Deployment 多副本/反亲和//healthz 探针+ClusterIP Service LB)、可选 NodePort(30091)、`Dockerfile`(**build context=仓库根**,保 `../../foundation`/`../../service` 布局,`uv sync --frozen --extra server --no-dev`,logs/ 预建归 appuser)、`render_and_apply.sh`(env 渲染→apply,残留 `<<` 即 fail-fast)、`build_image.sh`。 | ||
| 106 | +- K8s 部署红线: | ||
| 107 | + - `OPENJIUWEN_SERVICE_DEPLOY_REPLICAS=1` 固定(副本数=Deployment replicas;框架该项 >1 会因缺分布式锁后端启动即失败)。 | ||
| 108 | + - RBAC 两份:服务 ns + AgentServer 目标 ns(缺则 create pod 403 → route 全 503)。 | ||
| 109 | + - Pod 内 MySQL 用户须授权 Pod CIDR(`'agent_runtime'@'10.244.%'`)。 | ||
| 110 | + - server 模式硬要求:Redis 开 AOF/RDB;DB 用 MySQL/PostgreSQL。 | ||
| @@ -0,0 +1,133 @@ | |||
| 1 | +# session_manager(SM)规格 | ||
| 2 | + | ||
| 3 | +> 会话编排:route/touch HTTP 端点、配置层(config_sync)、老化 sweeper。 | ||
| 4 | +> **持唯一 App**(`/api/session`:8091),注册 4 个 handler。与 RM 互调只走进程内 Facade,**不直读 RM Redis key**。 | ||
| 5 | +> Lua 全文:`lua_scripts.py` 与 `../design/session-manager-design.md` 双份,改时同步。 | ||
| 6 | + | ||
| 7 | +## 文件一览 | ||
| 8 | + | ||
| 9 | +| 文件 | 职责 | | ||
| 10 | +|---|---| | ||
| 11 | +| `handlers.py` | 4 个 HTTP handler(route/touch/config_sync/cleanup)+ 错误信封映射 | | ||
| 12 | +| `orchestrator.py` | route 主循环(resolve→Lua 仲裁→acquire→等待队列)+ touch | | ||
| 13 | +| `state.py` | SM Redis 键 schema 唯一出口 + Lua 调用封装(`SMKeys`/`SessionState`) | | ||
| 14 | +| `lua_scripts.py` | 7 个 Lua 全文 | | ||
| 15 | +| `config_store.py` | template/routing_rule DB 持久化 + resolve 缓存 + config_sync 编排 | | ||
| 16 | +| `sweeper.py` | 到期 pass + 空 Pod pass(每 tick 选主) | | ||
| 17 | +| `facade.py` | `SessionManagerFacade`(RM→SM:notify_pod_dead / reconcile_pods) | | ||
| 18 | +| `models.py` | `Template` / `ScopeConfig` dataclass | | ||
| 19 | + | ||
| 20 | +## handlers.py —— 对外 4 端点 | ||
| 21 | + | ||
| 22 | +| 端点 | handler | 行为 | | ||
| 23 | +|---|---|---| | ||
| 24 | +| POST /api/session/route | `handle_route` | 同步路由+占额度,返回 `{pod_sse_url, pod_id}`;**幂等键 = metadata.request_id**(框架 idempotency,窗口 60s,回放缓存结果) | | ||
| 25 | +| POST /api/session/touch | `handle_touch` | 保活/EOS,返回 `{touched}`;False=已过期/不存在(gateway 回退重新 route) | | ||
| 26 | +| POST /api/session/config_sync | `handle_config_sync` | 配置下发,委托 `ConfigStore.config_sync` | | ||
| 27 | +| POST /api/session/cleanup | `handle_cleanup` | 运维批删 Pod,委托 `rm_facade.cleanup`(handler 在 SM,逻辑在 RM) | | ||
| 28 | + | ||
| 29 | +- 入参从 `Envelope.metadata`(session_id/group_id/bot_id/request_id)与 `rawdata` 取;`group_id` 在 `metadata.extra`。 | ||
| 30 | +- `AgentRuntimeError` 统一捕获 → `ResponseEnvelope(ok=False, error_code, error_message, retry_after)`。 | ||
| 31 | +- handler 无模块级可变状态;服务对象从 `sysctx` 取(`main._bind_modules` 注入)。 | ||
| 32 | + | ||
| 33 | +## orchestrator.py —— route 主循环 | ||
| 34 | + | ||
| 35 | +`SessionOrchestrator.route(request_id, session_id, group_id, bot_id)`: | ||
| 36 | + | ||
| 37 | +``` | ||
| 38 | +resolve(scope)(config_store:Redis 缓存→DB,规则优先级 精确>(g,*)>(*,b)>(*,*)) | ||
| 39 | +→ 循环 { LUA_ROUTE_PLACE 原子仲裁 → (action, pod_id): | ||
| 40 | + refresh/placed → 读 pod:info sse_url 返回(缺失=极端竞态被清,continue 重跑) | ||
| 41 | + scope_full → _wait_for_capacity(场景 F)后重跑 | ||
| 42 | + need_acquire → rm_facade.acquire(扩+1)→ state.register_pod → 重跑(新 Pod 必被 first-fit 选中) } | ||
| 43 | +finally: 若仍在等待队列 → remove_waiter(异常路径出队) | ||
| 44 | +``` | ||
| 45 | + | ||
| 46 | +`_wait_for_capacity`(场景 F 有界等待): | ||
| 47 | +- `max_waiters = 2 * scope_concurrency`;过 deadline(`scope_full_timeout`)→ 504 ScopeFullTimeout。 | ||
| 48 | +- **入队只走 `LUA_WAITER_GATE` 原子闸门**(SADD 先行+超限自退);满 → 503 ScopeQueueFull 快失败。 | ||
| 49 | +- 订阅 `scope:{sid}:free` PubSub + ≤500ms 安全轮询双保险(兜 publish 早于 subscribe 的丢失);收到信号即出队重跑 Lua——**原子 admit 是唯一仲裁,败者重 wait**。 | ||
| 50 | + | ||
| 51 | +`_acquire_pod`:`MaxPodsReached`/`DeployFailed` → 映射 `NoPodAvailable(503, retry_after=1)`。 | ||
| 52 | + | ||
| 53 | +`touch(session_id)`:LUA_TOUCH;不存在/已过期返回 False。 | ||
| 54 | + | ||
| 55 | +## state.py —— SM 键表(`SMKeys`,全部含 `session_manager:` 前缀) | ||
| 56 | + | ||
| 57 | +| 键 | 类型 | 语义 | | ||
| 58 | +|---|---|---| | ||
| 59 | +| `session:{sid}` | HASH | 亲和绑定:scope_id/pod_id/expiry/session_ttl | | ||
| 60 | +| `session_expiry` | ZSET | 到期时间戳(sweeper 到期 pass 扫它) | | ||
| 61 | +| `scope:{sid}:sessions` | SET | 活跃 session;**SCARD = scope_concurrency 闸门** | | ||
| 62 | +| `scope:{sid}:pods` | ZSET | first-fit 候选(score=接入序;ZREM 即退出候选——软摘除/idle 通知都用它) | | ||
| 63 | +| `scope:{sid}:pod_seq` | STRING | 单调递增,pods 的 score 来源 | | ||
| 64 | +| `scope:{sid}:config` | HASH | resolve 缓存(策略字段 + `template_json`;config_sync 主动 DEL 失效) | | ||
| 65 | +| `scope:{sid}:waiters` | SET | 等待队列(LUA_WAITER_GATE 原子进出) | | ||
| 66 | +| `scope:{sid}:free` | PubSub | 额度释放信号(EVICT 发布/route 订阅) | | ||
| 67 | +| `pod:{scope}:{pod}:sessions` | SET | per-Pod 会话;**SCARD < pod_concurrency = per-Pod 容量闸门(SM 侧,RM 不强制)** | | ||
| 68 | +| `pod:{scope}:{pod}:info` | HASH | sse_url / deploy_ver | | ||
| 69 | +| `pod:{scope}:{pod}:idle_notified` | STR(NX EX 60) | 空 Pod 通知去重 | | ||
| 70 | +| `pods:registered` | SET | 全部 `"{scope}:{pod}"`(不变量:scope:pods ⊆ pods:registered) | | ||
| 71 | +| `pods:{pod}:scopes` | SET | Pod 被哪些 scope 引用(notify_pod_dead 反查) | | ||
| 72 | +| `lock:sweep` / `lock:config_sync` | STR(NX EX) | tick 级选主 / config_sync 串行化(TTL 60) | | ||
| 73 | + | ||
| 74 | +不变量 1:一个活跃会话同时存在于四处(session HASH + scope:sessions + pod:sessions + session_expiry)——EVICT/惰性回收四处同删 + PUBLISH free。 | ||
| 75 | + | ||
| 76 | +## lua_scripts.py —— 7 个 Lua | ||
| 77 | + | ||
| 78 | +| 脚本 | 一句话职责 | | ||
| 79 | +|---|---| | ||
| 80 | +| `LUA_ROUTE_PLACE` | route 原子核心:亲和续期→惰性回收旧绑定→scope 闸门(SCARD)→first-fit(接入序)→达 max_pods 则 scope_full / 否则 need_acquire→原子提交四处同写(复用时清 idle_notified) | | ||
| 81 | +| `LUA_EVICT` | session 移除**唯一原语**(四处同删 + PUBLISH free 唤醒等待者;返回 scope/pod/remaining;幂等 noop) | | ||
| 82 | +| `LUA_TOUCH` | 保活续期;已过期当场惰性 evict;ttl 就地读 session HASH(不依赖 scope:config) | | ||
| 83 | +| `LUA_SWEEP_IDLE_NOTIFY` | 空 Pod 判定(SCARD==0)+ 60s NX 去重 + ZREM 退出候选(堵 reclaim 窗口内 route 直选的竞态 A) | | ||
| 84 | +| `LUA_REGISTER_POD` | acquire 成功登记:三处注册(scope:pods/pod:info/pods:registered)+ 接入序 + pods:{pod}:scopes | | ||
| 85 | +| `LUA_CLEANUP_POD` | notify_pod_dead 清该 (scope,pod) 全部注册(会话 evict 由调用方先行) | | ||
| 86 | +| `LUA_WAITER_GATE` | 等待队列原子入队(SADD 先行 + SCARD 超限自退)——**禁止改回「先 SCARD 再 SADD」**(M6 验收发现的并发超收事故) | | ||
| 87 | + | ||
| 88 | +约定:脚本不传 KEYS,`ARGV[1]`=键前缀,键在脚本内拼;返回扁平字符串数组(`SessionState.eval` 统一转 str)。 | ||
| 89 | + | ||
| 90 | +## config_store.py —— 配置层 | ||
| 91 | + | ||
| 92 | +**DB 表**(列名沿用 EE 兼容名,映射在 `_COLUMN_OF`):`service_config_template`(`min_idle_pods→min_idle_services`、`pod_concurrency→service_concurrency`、`pod_ttl→service_ttl`、`scope_concurrency→session_concurrency`)、`routing_rule`。表结构常量 `*_TABLE_DEF` 由 main 传给框架建表。 | ||
| 93 | + | ||
| 94 | +**resolve(scope_id, group_id, bot_id)**:缓存(`scope:{sid}:config`)命中直接回;miss 读 DB(优先级 精确>(g,\*)>(\*,b)>(\*,\*),模板须 enabled)→ 回写缓存。缓存值 = ScopeConfig 字段 + `template_json`(deploy 子集,need_acquire 时零 DB)。无匹配 → `ConfigNotFound(503)`。 | ||
| 95 | + | ||
| 96 | +**config_sync(payload)**(场景 M):`{kind: template|routing_rule, op: create|update|delete|sync, ...}` | ||
| 97 | + | ||
| 98 | +``` | ||
| 99 | +lock:config_sync 串行化(忙→409 CONFIG_SYNC_BUSY,TTL 60) | ||
| 100 | +→ 写 DB(失败立即中止,不碰缓存、不推送——红线:DB 写失败不得刷新缓存) | ||
| 101 | +→ template 变更扩散 _propagate_template_change: | ||
| 102 | + 完成判定:受影响 scope 仍有「已日落待回收」中间态 Pod(在 pods:registered 不在候选集)→ 409 拒绝 | ||
| 103 | + 逐字段 diff 判类(spec_fields): | ||
| 104 | + A 类(deploy_ver 变)→ 软摘除(ZREM 老版本 Pod 出候选;存量会话不受影响) | ||
| 105 | + + 写新缓存 + 推 RM(新 deploy_ver/pod_spec → 新流量落新 Pod,自然滚动) | ||
| 106 | + B 类(策略字段变) → DEL 缓存 + 推池参数(不带 pod_spec),立即生效 | ||
| 107 | +routing_rule 任何变更 → 无法定位受影响 scope(缓存无 group/bot)→ SCAN 全量 DEL 缓存(resolve 便宜) | ||
| 108 | +``` | ||
| 109 | + | ||
| 110 | +`Template.deploy_ver()` / RM `_deploy_ver()` 同一算法(`util.fingerprint` + `DEPLOY_VER_FIELDS`)——A 类过滤两端一致的前提。 | ||
| 111 | + | ||
| 112 | +## sweeper.py —— 老化扫描 | ||
| 113 | + | ||
| 114 | +`SessionSweeper.sweep_once()`(tick=`sweep_interval` 1s;`lock:sweep` SET NX EX 2 选主,抢不到即跳过): | ||
| 115 | +- **到期 pass**:ZRANGEBYSCORE `session_expiry` → 逐个 `LUA_EVICT`(废弃 session 的唯一回收路径)。 | ||
| 116 | +- **空 Pod pass**(idle_consider 的**唯一触发点**):枚举 `pods:registered` → `LUA_SWEEP_IDLE_NOTIFY` 原子判定(空+未通知过+ZREM 退出候选)→ notified=True 才 **fire-and-forget** `rm_facade.idle_consider`(失败不阻塞;60s 后 idle_notified 过期自愈重发)。统一覆盖三种空 Pod 成因:到期 evict / 惰性 evict / acquire 后从未放置的孤儿。 | ||
| 117 | + | ||
| 118 | +## facade.py —— RM→SM 入口 | ||
| 119 | + | ||
| 120 | +- `notify_pod_dead(pod_id)`(场景 G):反查 `pods:{pod}:scopes` → 逐 session `LUA_EVICT`(释放额度+唤醒等待者)→ `LUA_CLEANUP_POD` 清注册;幂等。 | ||
| 121 | +- `reconcile_pods(view)`(场景 L):对 RM 持有的每个 (pod, scope) 查 `scope:pods` 成员资格,非成员=SM 已不用 → stale;只读、单向。 | ||
| 122 | + | ||
| 123 | +## models.py | ||
| 124 | + | ||
| 125 | +- `Template`:template 行业务视图。派生:`max_pods = ⌈scope_concurrency/pod_concurrency⌉`(**派生值,不存储,不配置**);`deploy_subset()`=acquire 下发 RM 的 pod_spec;`deploy_ver()`=A 类指纹;`pool_config()`={min_idle_pods, max_pods, pod_ttl, pod_concurrency}(pod_concurrency 仅供 RM follower 等待室推导上限 pc-1)。 | ||
| 126 | +- `ScopeConfig`:resolve 产物(scope:config 缓存的 HASH 字段一一对应)。 | ||
| 127 | + | ||
| 128 | +## 高频踩点 | ||
| 129 | + | ||
| 130 | +- 改键名/Lua:HLD §5 键表、`state.py`、SM 详细设计三处同步。 | ||
| 131 | +- 等待队列入队只准走 `LUA_WAITER_GATE`;「先查后加」已被真环境验收证伪。 | ||
| 132 | +- config_sync 全局串行锁——e2e 脚本播种配置必须串行,并发即 409。 | ||
| 133 | +- 经多副本 LB 跑冒烟须设 `AGENT_RUNTIME_SCOPE_FULL_TIMEOUT`(显著小于 session_ttl),排查实录见 `e2e-test-cases.md` §8.1。 | ||
| @@ -1,214 +0,0 @@ | |||
| 1 | -# agent-runtime 代码说明文档 | ||
| 2 | - | ||
| 3 | -- 日期:2026-08-15(M0–M6 完成后整理) | ||
| 4 | -- 读者:维护/二次开发本服务的工程师 | ||
| 5 | -- 配套:语义权威 = `design/Agent-Runtime-HLD.md`;模块细节 = SM/RM 两份详细设计。本文回答"**代码在哪、怎么跑起来、改哪里**",不重复论证设计决策。 | ||
| 6 | - | ||
| 7 | ---- | ||
| 8 | - | ||
| 9 | -## 1. 一页纸架构 | ||
| 10 | - | ||
| 11 | -一个进程、一个 App(`/api/session`,端口 8091)、两个模块: | ||
| 12 | - | ||
| 13 | -``` | ||
| 14 | -gateway ──route/touch──► agent-runtime(uvicorn) | ||
| 15 | -claw mgr ──config_sync──► ├─ session_manager 持 App,4 个 HTTP handler | ||
| 16 | -运维 ──cleanup──────► └─ resource_manager 无 App,纯 Facade + 后台任务 | ||
| 17 | - 两模块共享同一 Redis(前缀隔离)+ 同一 DB,互调只走进程内 Facade | ||
| 18 | -数据面(本服务全程旁路):gateway ◄──SSE──► AgentServer Pod(route 返回 pod_sse_url) | ||
| 19 | -``` | ||
| 20 | - | ||
| 21 | -- 状态分层:**编排态在 Redis**(SM `session_manager:` / RM `resource_manager:` 前缀)、**配置在 DB**(`service_config_template` / `routing_rule` 表)、**Pod 物理态以 K8s 为唯一真相源**。 | ||
| 22 | -- 多副本无状态;后台任务经 Redis 选主锁全局单副本执行写操作。 | ||
| 23 | - | ||
| 24 | -## 2. 代码地图(`applications/agent_runtime/`) | ||
| 25 | - | ||
| 26 | -``` | ||
| 27 | -src/agent_runtime/ | ||
| 28 | -├── main.py ★ 组装入口:create_app → OrchestratorSystemContext | ||
| 29 | -│ (级联拉起 rm_sysctx + Facade 互绑 + 5 个后台任务) | ||
| 30 | -├── cli.py 命令行入口(deploy.sh 调用;--mode local|server) | ||
| 31 | -├── config.py AGENT_RUNTIME_* 环境变量(任务周期/超时/namespace) | ||
| 32 | -├── errors.py 错误码契约(异常类 + HTTP 状态注册;retry_after 语义) | ||
| 33 | -├── util.py scope_id 派生(md5+\x00)/ deploy 指纹 / to_int 等纯函数 | ||
| 34 | -├── spec_fields.py template 字段分类:A 类 deploy 子集 / B 类策略(静态,两端共享) | ||
| 35 | -├── session_manager/ SM 模块 | ||
| 36 | -│ ├── handlers.py 4 个 HTTP handler(route/touch/config_sync/cleanup) | ||
| 37 | -│ ├── orchestrator.py route 编排(resolve→Lua 仲裁→acquire→等待队列) + touch | ||
| 38 | -│ ├── state.py SM Redis 键 schema 唯一出口(含 Lua 调用封装) | ||
| 39 | -│ ├── lua_scripts.py 7 个 Lua 全文(见 §4) | ||
| 40 | -│ ├── sweeper.py 到期 pass(老化)+ 空 Pod pass(idle_consider) | ||
| 41 | -│ ├── config_store.py template/routing_rule 持久化 + resolve 缓存 + config_sync | ||
| 42 | -│ ├── facade.py SessionManagerFacade(notify_pod_dead / reconcile_pods) | ||
| 43 | -│ └── models.py Template/ScopeConfig dataclass | ||
| 44 | -└── resource_manager/ RM 模块 | ||
| 45 | - ├── orchestrator.py acquire(取暖/选主 deploy/封顶)+ idle_consider | ||
| 46 | - │ + update_pool_config + cleanup + 结果幂等缓存 | ||
| 47 | - ├── state.py RM Redis 键 schema 唯一出口 | ||
| 48 | - ├── lua_scripts.py 6 个 Lua 全文(见 §4) | ||
| 49 | - ├── k8s.py RealK8sPodClient(kubernetes_asyncio)/ FakeK8sPodClient | ||
| 50 | - │ (deploy 等 Ready、409 重命名重试、判死归一化、/health 探测) | ||
| 51 | - ├── sweeper.py autoscale / reclaim / watch(死 Pod+健康探测)/ reconcile | ||
| 52 | - ├── facade.py ResourceManagerFacade(acquire / idle_consider / update_pool_config / cleanup) | ||
| 53 | - └── models.py PodInfo / PodDeployInfo / 判死枚举 / Pod label 常量 | ||
| 54 | - | ||
| 55 | -scripts/deploy.sh local|server 启动 | ||
| 56 | -scripts/e2e_hld_acceptance.py 集成冒烟(场景 A–L,真环境) | ||
| 57 | -scripts/integration_smoke.sh 冒烟入口包装 | ||
| 58 | -tests/ 114 个单测(见 §7) | ||
| 59 | -``` | ||
| 60 | - | ||
| 61 | -## 3. 关键流程(读代码的切入点) | ||
| 62 | - | ||
| 63 | -### 3.1 route(`session_manager/orchestrator.py:route`) | ||
| 64 | - | ||
| 65 | -``` | ||
| 66 | -幂等回放(handler 层 ctx.idempotency,request_id 键) | ||
| 67 | -→ resolve(scope)(config_store:Redis 缓存 → DB,规则优先级 精确>(g,*)>(*,b)>(*,*)) | ||
| 68 | -→ 循环 { LUA_ROUTE_PLACE 原子仲裁: | ||
| 69 | - refresh/placed → 读 pod:info sse_url 返回 | ||
| 70 | - scope_full → LUA_WAITER_GATE 原子入队(满→503 快失败;否则订阅 free | ||
| 71 | - 等 scope_full_timeout→504;唤醒后重跑仲裁) | ||
| 72 | - need_acquire → rm_facade.acquire(扩 +1 Pod)→ register_pod → 重跑 } | ||
| 73 | -``` | ||
| 74 | - | ||
| 75 | -### 3.2 RM acquire(`resource_manager/orchestrator.py:acquire`) | ||
| 76 | - | ||
| 77 | -``` | ||
| 78 | -幂等缓存(request_id)→ LUA_ACQUIRE: | ||
| 79 | - reuse(暖 Pod,deploy_ver 过滤)→ 返回 | ||
| 80 | - max_reached → 抛 MaxPodsReached(SM 映射 503 NO_POD_AVAILABLE) | ||
| 81 | - need_deploy → 抢 lock:rm:deploy:{scope} 选主串行 deploy | ||
| 82 | - (输家 → follower 等待室:准入≤pc-1/overflow 快失败/等 leader | ||
| 83 | - Pod 注册即复用/leader 失败不接管/等待有界) | ||
| 84 | -k8s.deploy:create + wait Ready(409 重命名重试;超时/镜像失败 → DeployFailed) | ||
| 85 | -错误路径必须清 deploying 占位(红线) | ||
| 86 | -``` | ||
| 87 | - | ||
| 88 | -### 3.3 老化与回收链(场景 D→K) | ||
| 89 | - | ||
| 90 | -``` | ||
| 91 | -SessionSweeper(1s,lock:sweep): | ||
| 92 | - 到期 pass ZRANGEBYSCORE session_expiry → 逐个 LUA_EVICT(四处同删 + PUBLISH free) | ||
| 93 | - 空 Pod pass pods:registered 枚举 → LUA_SWEEP_IDLE_NOTIFY(原子:空判定+去重+ZREM 候选) | ||
| 94 | - → fire-and-forget rm_facade.idle_consider → RM 转 idle 暖池 | ||
| 95 | -ResourceSweeper: | ||
| 96 | - autoscale(1s) idle < min_idle 且未达 max_pods → LUA_PLACEHOLDER 占位 → deploy 热备 | ||
| 97 | - reclaim(1s) idle 超 min_idle 底数的 excess 中 aged ≥ pod_ttl → K8s delete + PURGE | ||
| 98 | - + notify_pod_dead(清 SM 注册;保底热备不被回收) | ||
| 99 | - watch(10s) 判死枚举(Terminating/Failed/CrashLoopBackOff/ImagePullBackOff/..., | ||
| 100 | - Pending 不判死)→ 清理;Running 但 /health 连续 2 次失败 → 半死清理(场景 N) | ||
| 101 | - reconcile(30s) Redis 有 K8s 无 → PURGE;SM 不再引用的 stale Pod → 转 idle | ||
| 102 | -``` | ||
| 103 | - | ||
| 104 | -### 3.4 配置热更新(场景 M,`config_store.py:config_sync`) | ||
| 105 | - | ||
| 106 | -``` | ||
| 107 | -lock:config_sync 串行化(忙 → 409 CONFIG_SYNC_BUSY) | ||
| 108 | -→ 写 DB(失败即中止,不碰缓存——红线) | ||
| 109 | -→ diff 判类(spec_fields.A/B): | ||
| 110 | - A 类(deploy 子集变,deploy_ver 变)→ SM 软摘除(ZREM 老版本 Pod 出候选,存量会话不受影响) | ||
| 111 | - + 推 RM(新 deploy_ver/pod_spec,新流量落新 Pod,自然滚动) | ||
| 112 | - B 类(策略字段变) → DEL scope:config + 推池参数,立即生效 | ||
| 113 | -→ 完成判定:受影响 scope 仍有日落待回收的中间态 Pod → 409 拒绝下一次 | ||
| 114 | -``` | ||
| 115 | - | ||
| 116 | -## 4. Lua 脚本清单(所有编排态变更,原子;Redis 单线程执行无 race) | ||
| 117 | - | ||
| 118 | -| 模块 | 脚本 | 一句话职责 | | ||
| 119 | -|---|---|---| | ||
| 120 | -| SM | `LUA_ROUTE_PLACE` | route 原子核心:亲和续期/惰性回收/闸门/first-fit/提交 | | ||
| 121 | -| SM | `LUA_EVICT` | session 移除唯一原语(四处同删 + PUBLISH free 唤醒等待者) | | ||
| 122 | -| SM | `LUA_TOUCH` | 保活续期(惰性 evict 兜底;ttl 就地读 session HASH) | | ||
| 123 | -| SM | `LUA_SWEEP_IDLE_NOTIFY` | 空 Pod 判定 + 60s 去重 + ZREM 退出候选(堵竞态 A) | | ||
| 124 | -| SM | `LUA_REGISTER_POD` | acquire 成功登记(三处注册 + 接入序) | | ||
| 125 | -| SM | `LUA_CLEANUP_POD` | notify_pod_dead 清该 (scope,pod) 全部注册 | | ||
| 126 | -| SM | `LUA_WAITER_GATE` | 等待队列原子入队(SADD 先行 + 超限自退;M6 修复的并发超收) | | ||
| 127 | -| RM | `LUA_ACQUIRE` | 取暖复用 / need_deploy 占位 / max_reached | | ||
| 128 | -| RM | `LUA_REGISTER` | deploy 成功登记(info/池/pods:all,清占位;idle_flag 入暖池) | | ||
| 129 | -| RM | `LUA_RELEASE` | idle_consider 转 idle 暖池(起 pod_ttl 计时) | | ||
| 130 | -| RM | `LUA_PURGE` | 清该 Pod 全部 RM key(返回其 scope) | | ||
| 131 | -| RM | `LUA_PLACEHOLDER` | autoscale 专用占位(计入 max_pods,不碰 idle 池) | | ||
| 132 | -| RM | `LUA_DEPLOY_FOLLOWER_GATE` | deploy 锁输家等待室原子准入(ZSET+deadline,≤pc-1;先清过期再 ZADD 先行+超限自退) | | ||
| 133 | - | ||
| 134 | -约定:脚本不传 KEYS(键由 `ARGV[1]` 前缀在脚本内拼);调用统一经各自 `state.py` 的 `eval()`。 | ||
| 135 | - | ||
| 136 | -## 5. Redis 键速查(全文见 HLD §5 两张表) | ||
| 137 | - | ||
| 138 | -``` | ||
| 139 | -session_manager:session:{sid} HASH 亲和绑定(scope/pod/expiry/ttl) | ||
| 140 | -session_manager:session_expiry ZSET 到期时间(sweeper 扫) | ||
| 141 | -session_manager:scope:{sid}:sessions SET SCARD = scope 闸门 | ||
| 142 | -session_manager:scope:{sid}:pods ZSET first-fit 候选(接入序) | ||
| 143 | -session_manager:scope:{sid}:config HASH resolve 缓存(config_sync DEL 失效) | ||
| 144 | -session_manager:scope:{sid}:waiters SET 等待队列(LUA_WAITER_GATE 原子进出) | ||
| 145 | -session_manager:scope:{sid}:free PubSub 额度释放信号 | ||
| 146 | -session_manager:pod:{scope}:{pod}:sessions|info SET|HASH per-Pod 会话 / sse_url+deploy_ver | ||
| 147 | -session_manager:pods:registered SET "{scope}:{pod}"(不变量 5) | ||
| 148 | -resource_manager:resource:scope:{sid}:pods|idle|config|deploying|deploy_followers | ||
| 149 | - ZSET|SET|HASH|SET|ZSET(follower 等待室,≤pc-1) | ||
| 150 | -resource_manager:resource:pod:{pod}:info|idle_since|health_fails HASH|STR|STR | ||
| 151 | -resource_manager:resource:pods:all SET 全部 pod_id(watch/reconcile 枚举) | ||
| 152 | -resource_manager:lock:rm:deploy:{sid}|autoscale|reclaim|watch|reconcile 选主/串行化锁 | ||
| 153 | -``` | ||
| 154 | - | ||
| 155 | -## 6. 错误码(`errors.py`,契约见 HLD §3.1) | ||
| 156 | - | ||
| 157 | -| 码 | HTTP | retry_after | 场景 | | ||
| 158 | -|---|---|---|---| | ||
| 159 | -| `SCOPE_QUEUE_FULL` | 503 | ✅ | 等待队列满,快失败 | | ||
| 160 | -| `SCOPE_FULL_TIMEOUT` | 504 | ✅ | 队列内等待超时 | | ||
| 161 | -| `NO_POD_AVAILABLE` | 503 | ✅ | acquire 失败(MaxPodsReached/DeployFailed 映射) | | ||
| 162 | -| `CONFIG_NOT_FOUND` | 503 | ❌ | resolve 无匹配规则/模板禁用 | | ||
| 163 | -| `VALIDATION` | 400 | ❌ | 参数错 | | ||
| 164 | -| `CONFIG_SYNC_BUSY` | 409 | — | 上一次热更新未完成 / 日落待回收 | | ||
| 165 | - | ||
| 166 | -Facade 间以 Python 异常传播,handler 捕获后映射为错误信封。 | ||
| 167 | - | ||
| 168 | -## 7. 测试与验收 | ||
| 169 | - | ||
| 170 | -> 全部 e2e 用例的场景/输入/预期输出逐条说明见 **`docs/e2e-test-cases.md`**。 | ||
| 171 | - | ||
| 172 | -```bash | ||
| 173 | -cd applications/agent_runtime | ||
| 174 | -uv sync --extra local && uv run pytest # 114 用例,fakeredis+SQLite+FakeK8s | ||
| 175 | -./scripts/integration_smoke.sh # 真环境冒烟(场景 A–L;FLUSHDB 目标库,有防误刷) | ||
| 176 | -``` | ||
| 177 | - | ||
| 178 | -| 层 | 文件 | 内容 | | ||
| 179 | -|---|---|---| | ||
| 180 | -| SM 状态层 | `tests/session_manager/test_sm_state.py` | Lua 原子语义(亲和/first-fit/闸门/老化/幂等) | | ||
| 181 | -| RM 状态层 | `tests/resource_manager/test_rm_state.py` | acquire 占位/封顶/暖池/PURGE | | ||
| 182 | -| SM config 层 | `tests/session_manager/test_config_store.py` | resolve 优先级/缓存/AB 类 diff/串行化/DB 失败红线 | | ||
| 183 | -| RM 业务 | `tests/resource_manager/test_rm_business.py` | watch/健康探测/reconcile/cleanup/update_pool_config | | ||
| 184 | -| 组件全链路 | `tests/integration/test_route_flow.py` | 场景 A–K 全链路(Fakeredis 共享态) | | ||
| 185 | -| 分支/corner | `tests/integration/test_corner_cases.py` | 边界与异常分支(18 项) | | ||
| 186 | -| HTTP 冒烟 | `tests/integration/test_http_smoke.py` | 4 端点契约 + 错误码映射 + /healthz | | ||
| 187 | -| **双实例多副本** | `tests/integration/test_multi_replica.py` | 跨副本确定性语义(12 项,`_dual_harness.py` 同进程两 App 共享一组资源) | | ||
| 188 | -| 集成冒烟 | `scripts/e2e_hld_acceptance.py` | 真 Redis/MySQL/K8s,65 项断言;场景 N 暂缓(AgentServer /health 未支持) | | ||
| 189 | -| 多副本 e2e | `scripts/e2e_multi_replica.py` | 真 LB 单入口 35 项(选主互斥/突发/幂等/传播/failover);<2 实例自动 DEGRADED | | ||
| 190 | -| 压测/浸泡 | `scripts/load_test.py` | 场景化(route/route_touch/queued),分位数+错误直方图,周期浸泡报告 | | ||
| 191 | - | ||
| 192 | -- 双实例 harness 要点:`create_app(resources=..., instance_id=..., own_resources=False)` 注入共享资源;httpx `ASGITransport` 单事件循环驱动(**先手动驱动 lifespan**,否则 RestAdapter 惰性二建 sysctx 绕过后台 Job);两个 TestClient(双事件循环共享 fakeredis)不可行。 | ||
| 193 | -- 经多副本 LB 亦可跑(实测 65/65):部署须带 `AGENT_RUNTIME_SCOPE_FULL_TIMEOUT`(模板默认 8,显著小于 session_ttl——漏设曾致等待者 deadline 与会话到期碰撞,F 阶段混合结果);排查实录与 cleanup 空目标三坑见 `docs/e2e-test-cases.md` §8.1。 | ||
| 194 | - | ||
| 195 | -## 8. 配置与部署 | ||
| 196 | - | ||
| 197 | -- 启动:`scripts/deploy.sh local|server [env-file]`;server 读 `.env.production.local`(模板 `agent_runtime.server.env.example`)。 | ||
| 198 | -- 框架配置(`OPENJIUWEN_SERVICE_*`):host/port/Redis URL/DB。服务自有配置(`AGENT_RUNTIME_*`):任务周期、scope_full_timeout、kubeconfig、default_namespace——见 `config.py`。 | ||
| 199 | -- server 模式硬要求:Redis 开 AOF/RDB;DB 用 MySQL/PostgreSQL(fail-fast,禁 SQLite 回退)。 | ||
| 200 | -- 双模式实现:`main.py:build_resources`(local=fakeredis+SQLite+FakeK8s;server=真客户端);K8s 层 Real/Fake 同签名(`k8s.py:K8sPodClient`)。 | ||
| 201 | -- **多副本宿主机**:`scripts/deploy_replicas.sh N [env] [port]`(N 进程共 Redis/DB,`/healthz` 就绪轮询,trap 清理;local 模式 fail-fast)。 | ||
| 202 | -- **K8s 生产形态**:`deploy/` 目录——`agent_runtime.template.yaml`(SA+Role×2+Deployment 多副本/反亲和//healthz 探针+ClusterIP Service LB)、可选 NodePort(30091)、`Dockerfile`(**build context=仓库根**,保 `../../foundation`/`../../service` 布局,`uv sync --frozen --extra server --no-dev`,logs/ 须预建归 appuser)、`render_and_apply.sh`(env 渲染→apply,残留 `<<` 即 fail-fast)、`build_image.sh`。 | ||
| 203 | -- K8s 部署红线:`OPENJIUWEN_SERVICE_DEPLOY_REPLICAS=1` 固定(副本数=Deployment replicas);RBAC 两份(服务 ns + AgentServer 目标 ns,否则 create pod 403→route 全 503);Pod 内 MySQL 用户须授权 Pod CIDR(`'agent_runtime'@'10.244.%'`)。 | ||
| 204 | - | ||
| 205 | -## 9. 改动时的注意事项(高频踩点) | ||
| 206 | - | ||
| 207 | -- **改键名/Lua**:HLD §5 键表与两个 `state.py` 必须同步;Lua 全文在 SM/RM 详细设计与 `lua_scripts.py` 双份,同步改。 | ||
| 208 | -- **新增 template 字段**:先分类(`spec_fields.py`:A 类进 deploy 子集与指纹,或 B 类策略),再补 `config_store.py` 的 `_COLUMN_OF` 列映射(DB 列名沿用 EE 兼容名)与表结构 `*_TABLE_DEF`。 | ||
| 209 | -- **等待队列**:入队只准走 `LUA_WAITER_GATE`;「先查后加」是已被真环境验收证伪的写法。 | ||
| 210 | -- **跨模块**:SM↔RM 数据只走 Facade 方法,不直读对方 Redis key(架构红线)。 | ||
| 211 | -- **测试环境**:构造 `ServiceManager` 传 `deploy_mode="subprocess"`;fakeredis pubsub 需共享同一实例。 | ||
| 212 | -- **真环境验收**:独立 namespace + 独立 Redis DB(冒烟脚本自带防误刷与前置自检)。 | ||
| 213 | -- **多副本踩点**:选主元数据键 TTL ~3s,观测/采样须 ≤0.5s 间隔(SCAN);`instance_id` 前缀=hostname(容器内即 Pod 名),可反查副本。跨副本冷竞争时 deploy 锁输家会自建第 2 个 Pod(`max_pods` 内,空 Pod 经 empty-pod pass→idle_consider→reclaim 自愈)——测试断言「窗口零重叠+Pod≤max_pods」,不断言「恰好 1 个 Pod」;多后端冷突发 NO_POD_AVAILABLE 快失败属预期。`config_sync` 全局串行锁:脚本播种必须串行,并发即 409。框架 `load_incluster_config` 是**同步**函数(`k8s.py` 已修,M7 首次在 in-cluster 暴露)。部署须设 `AGENT_RUNTIME_SCOPE_FULL_TIMEOUT` 且显著小于 session_ttl(等待者 deadline≈会话到期会被到期驱逐唤醒,产生 200/503 混合而非干净 504)。 | ||
| 214 | -- **cleanup 踩点**:目标 ns 不存在时,宿主机 admin 凭据得空列表、in-cluster SA 的 namespaced RBAC 得 403——产品已对 404 容忍为 cleaned=0、403 保持 fail-fast;测试的「空目标」必须用无匹配 label_selector(业务 ns 会误删同 label 真实 AgentServer;min_idle 模板的热备 1s 内重建,刚清空的 ns 也不为空)。 | ||