已合并
fix(config):测试issue修复 #540
fix(config):测试issue修复 #540
已合并
王明琦创建于 9月17日
共 39 个文件变更+1509-296
@@ -162,7 +162,8 @@ spec:
162 # 空 kubeconfig → Pod 内走 in-cluster ServiceAccount162 # 空 kubeconfig → Pod 内走 in-cluster ServiceAccount
163 - name: AGENT_RUNTIME_KUBECONFIG163 - name: AGENT_RUNTIME_KUBECONFIG
164 value: ""164 value: ""
165- # runtime 自身 ns(downward API):模板 namespace 为空时 AgentServer 落同名 ns165+ # runtime 自身 ns(downward API):模板 namespace 为空时 AgentServer
166+ # 落同名 ns;代码另有 SA 文件兜底(config.own_namespace),注入仅为显式化
166 - name: POD_NAMESPACE167 - name: POD_NAMESPACE
167 valueFrom:168 valueFrom:
168 fieldRef:169 fieldRef:
@@ -126,7 +126,7 @@ flowchart TB
126 126 
127| 字段 | 类型 | 说明 |127| 字段 | 类型 | 说明 |
128|---|---|---|128|---|---|---|
129-| `template_id` / `template_name` / `description` / `enabled` / `data` | — | 元信息(`enabled=False` 的模板路由不解析) |129+| `template_id` / `template_name` / `description` / `data` | — | 元信息(`enabled` 已删 2026-09:模板生命周期=存在性,禁用=从载荷删除,见 `docs/feature/2026-09-drop-enabled-fields.md`) |
130| `namespace` | str | K8s 命名空间(Pod 级;**空串 = 继承**——AgentServer 落 runtime 自身 ns,由 `POD_NAMESPACE`(downward API)解析,见 §deploy 兜底链与 docs/feature/2026-09-agentserver-ns-follow-runtime.md;省略仍落默认值 `"default"`,表达继承须显式下发 `""`) |130| `namespace` | str | K8s 命名空间(Pod 级;**空串 = 继承**——AgentServer 落 runtime 自身 ns,由 `POD_NAMESPACE`(downward API)解析,见 §deploy 兜底链与 docs/feature/2026-09-agentserver-ns-follow-runtime.md;省略仍落默认值 `"default"`,表达继承须显式下发 `""`) |
131| `nodeName` | str? | 节点绑定(A 类;渲染为 `V1PodSpec.nodeName` 绕过调度器点名上机,deploy tool 镜像预载场景用。`None` = 不绑定;空串同 `None`。坏值 → Pod 永久 Pending 挂满 ready_timeout,入口按 hostname 形态 ≤253 校验) |131| `nodeName` | str? | 节点绑定(A 类;渲染为 `V1PodSpec.nodeName` 绕过调度器点名上机,deploy tool 镜像预载场景用。`None` = 不绑定;空串同 `None`。坏值 → Pod 永久 Pending 挂满 ready_timeout,入口按 hostname 形态 ≤253 校验) |
132| `pod_name` | str | Pod 名前缀(pod_id = 前缀-随机后缀,默认 `agentserver`) |132| `pod_name` | str | Pod 名前缀(pod_id = 前缀-随机后缀,默认 `agentserver`) |
@@ -164,7 +164,7 @@ flowchart TB
164 164 
165> 内部实现注:水合后是**统一容器规范形**(2026-09 起,`containers.py`):`Template` 持模板级字段(namespace/node_name/pod_name/sse_path/ready_*/策略)+ `main_container`(canonical dict,13 键全填满:ports/env/env_from/resources/三类挂载/nfs/security_context/readiness_probe 等)+ `sidecars`(同款 canonical 列表,name 升序)——即快照与 RM `pod_spec` 契约,见 `docs/spec/session-manager.md` §models)——**同值必同 deploy_ver**(三段式水合 vs 手构 canonical 等值,承重断言固化;RM 侧 `normalize_pod_spec` 对缓存补缺省,未来加容器字段零伪日落)。`max_pods` 不在 template 里——它是派生值 `⌈scope_concurrency / pod_concurrency⌉`;`autoscale_interval` 是全局默认(0.5s)。165> 内部实现注:水合后是**统一容器规范形**(2026-09 起,`containers.py`):`Template` 持模板级字段(namespace/node_name/pod_name/sse_path/ready_*/策略)+ `main_container`(canonical dict,13 键全填满:ports/env/env_from/resources/三类挂载/nfs/security_context/readiness_probe 等)+ `sidecars`(同款 canonical 列表,name 升序)——即快照与 RM `pod_spec` 契约,见 `docs/spec/session-manager.md` §models)——**同值必同 deploy_ver**(三段式水合 vs 手构 canonical 等值,承重断言固化;RM 侧 `normalize_pod_spec` 对缓存补缺省,未来加容器字段零伪日落)。`max_pods` 不在 template 里——它是派生值 `⌈scope_concurrency / pod_concurrency⌉`;`autoscale_interval` 是全局默认(0.5s)。
166 166 
167-**`routing_scope`**(config_sync 下发,持久化到 DB 表 `routing_scope`):scope 定义 = `scope_id + index + template_id + routing_rules + enabled + expires_at`,scope↔模板多对一。167+**`routing_scope`**(config_sync 下发,持久化到 DB 表 `routing_scope`):scope 定义 = `scope_id + index + template_id + routing_rules + expires_at`,scope↔模板多对一。生命周期 = **存在性 + expires_at**(`enabled` 已删 2026-09,禁用=从载荷删除,残留键防御性视为缺席)。
168 168 
169| 字段 | 类型 | 说明 |169| 字段 | 类型 | 说明 |
170|---|---|---|170|---|---|---|
@@ -172,8 +172,7 @@ flowchart TB
172| `index` | int | 匹配序号;请求按 (index 升序, scope_id 升序) first-fit |172| `index` | int | 匹配序号;请求按 (index 升序, scope_id 升序) first-fit |
173| `template_id` | str | 引用的 template(必须在本批模板列表内;多个 scope 可引用同一模板) |173| `template_id` | str | 引用的 template(必须在本批模板列表内;多个 scope 可引用同一模板) |
174| `routing_rules` | str | **布尔表达式字符串**,条件经 `and`/`or` 与括号任意组合;**null/空串/纯空白 = 通配兜底 scope**(命中一切) |174| `routing_rules` | str | **布尔表达式字符串**,条件经 `and`/`or` 与括号任意组合;**null/空串/纯空白 = 通配兜底 scope**(命中一切) |
175-| `enabled` | bool | 默认 `true`;`false` 时不参与路由匹配与 eager 预热(仍落库/进快照,与模板 `enabled` 同款语义) |175+| `expires_at` | datetime? | 可选过期时间(ISO-8601);到点后视为不生效(`null` = 永不过期)。route 热路径按墙钟判定,无需再 sync。**生命周期唯一状态字段**(`enabled` 已删 2026-09:禁用 = 从载荷删除该 scope,残留 `enabled` 键防御性视为缺席) |
176-| `expires_at` | datetime? | 可选过期时间(ISO-8601);到点后视为不生效(`null` = 永不过期)。route 热路径按墙钟判定,无需再 sync |
177 176 
178表达式语法:177表达式语法:
179 178 
@@ -188,7 +187,7 @@ field := user_id | group_id | bot_id(固定小写枚举)
188上限 := 长度 ≤ 8000,括号嵌套 ≤ 32187上限 := 长度 ≤ 8000,括号嵌套 ≤ 32
189```188```
190 189 
191-**匹配语义**(resolve 权威定义):请求属性 (user_id, group_id, bot_id) 对 scopes 按 `(index ASC, scope_id ASC)` 排序遍历,**首个命中的 scope 即止**(first-fit);scope 命中 = 空 routing_rules(通配)或表达式求值为真;条件求值 = 属性值(缺省 `""`)`in`/`not in` 值集合。引用模板缺失或 `enabled=False` 的 scope、以及 scope 自身 `enabled=False` / `expires_at` 已过期的,均视为不命中,继续落下一个。**下发方保证含一个空表达式的通配 scope 兜底**(通配判定只计生效中的 scope);服务端缺失时仅 WARNING 放行,运行时无匹配 → 503 `CONFIG_NOT_FOUND`。190+**匹配语义**(resolve 权威定义):请求属性 (user_id, group_id, bot_id) 对 scopes 按 `(index ASC, scope_id ASC)` 排序遍历,**首个命中的 scope 即止**(first-fit);scope 命中 = 空 routing_rules(通配)或表达式求值为真;条件求值 = 属性值(缺省 `""`)`in`/`not in` 值集合。引用模板缺失(悬挂引用,防御)的 scope、以及 `expires_at` 已过期的,均视为不命中,继续落下一个(两级 `enabled` 已删 2026-09,生命周期 = 存在性 + expires_at)。**下发方保证含一个空表达式的通配 scope 兜底**(通配判定只计生效中的 scope);服务端缺失时仅 WARNING 放行,运行时无匹配 → 503 `CONFIG_NOT_FOUND`。
192 191 
193**`config_sync` 入参完整 schema**(三段式全量快照,一次请求同时携带三个列表,**独占**;旧 `kind/op` 协议与无 `containers` 键的 legacy 内联载荷均 400 拒绝):192**`config_sync` 入参完整 schema**(三段式全量快照,一次请求同时携带三个列表,**独占**;旧 `kind/op` 协议与无 `containers` 键的 legacy 内联载荷均 400 拒绝):
194 193 
@@ -211,13 +210,13 @@ field := user_id | group_id | bot_id(固定小写枚举)
211 "scope_concurrency": 3, "pod_concurrency": 2, "...": "..."} ],210 "scope_concurrency": 3, "pod_concurrency": 2, "...": "..."} ],
212 "scopes": [ {"scope_id": "...", "index": 0, "template_id": "tpl-1",211 "scopes": [ {"scope_id": "...", "index": 0, "template_id": "tpl-1",
213 "routing_rules": "user_id in ('admin') or group_id not in ('g1')",212 "routing_rules": "user_id in ('admin') or group_id not in ('g1')",
214- "enabled": true, "expires_at": null} ]213+ "expires_at": null} ]
215}214}
216```215```
217 216 
218-- 语义:**以数组为准的全量替换**(upsert 全部 + 删除消失项;容器以本批为集 GC);幂等重放收敛(affected_scopes 为空)。217+- 语义:**以数组为准的全量替换**(upsert 全部 + 删除消失项;容器以本批为集 GC);幂等重放收敛(affected_scopes 为空)。**残留防御(2026-09-drop-enabled-fields)**:两级 `enabled` 已删——载荷项带 `enabled:false` 视为缺席剔除 + WARNING(scope 缺席 = 删除走扩散③ 优雅排空;模板缺席被引用 → 400);`enabled:true` 残留键静默忽略。
219-- 校验(400 VALIDATION,锁外零副作用):缺 `containers` 键(legacy 内联载荷);`templates`/`scopes` 非 list;模板缺 `main_container_id`;**mixed**(引用键与 legacy 内联容器键并存);container_id 空/>100/同批重复/未被引用/双角色;容器逐项按角色校验(见 `container` 结构表;未知键/不可表示字段);模板引用不在本批 containers;sidecar 引用重复/>8;volumes(重复卷名/多源/无源/悬挂挂载/未挂载卷/`subPath` 非 configMap/NFS 逾界);模板级 int 严格/策略下界/`nodeName` hostname;scope_id 字符集/`index` 拒 bool/引用不在本批模板集/`routing_rules` 表达式语法/`enabled` 须 bool/`expires_at` ISO-8601 或 null/同批重复(语法细则见上文)。218+- 校验(400 VALIDATION,锁外零副作用):缺 `containers` 键(legacy 内联载荷);`templates`/`scopes` 非 list;模板缺 `main_container_id`;**mixed**(引用键与 legacy 内联容器键并存);container_id 空/>100/同批重复/未被引用/双角色;容器逐项按角色校验(见 `container` 结构表;未知键/不可表示字段);模板引用不在本批 containers;sidecar 引用重复/>8;volumes(重复卷名/多源/无源/悬挂挂载/未挂载卷/`subPath` 非 configMap/NFS 逾界);模板级 int 严格/策略下界/`nodeName` hostname;scope_id 字符集/`index` 拒 bool/引用不在本批模板集/`routing_rules` 表达式语法/`expires_at` ISO-8601 或 null/同批重复(语法细则见上文)。
220-- 每次成功下发都会:重建路由快照(§5.1 `routing:snapshot`)、对每个**生效中** scope 推 RM 池参数 + pod_spec(**eager 预热**:autoscale 下一拍即预热 min_idle)、对禁用/过期 scope 与被删 scope 推 `min_idle=0`(自然排空)。219+- 每次成功下发都会:重建路由快照(§5.1 `routing:snapshot`)、对每个**生效中** scope 推 RM 池参数 + pod_spec(**eager 预热**:autoscale 下一拍即预热 min_idle)、对过期 scope 与被删 scope 推 `min_idle=0`(停保温;被删 scope 先 bump 日落进排空窗口)。
221 220 
222**curl 调用示例**(Envelope 包装:`type` 须为端点名、`metadata.request_id` 必填(兼幂等键)、三段式载荷在 `rawdata`;带 K8s pod 内探测的脚本版本见 `scripts/config_sync_seed.sh`。示例载荷要点:主容器探针恒 `httpGet` 且**无** `timeoutSeconds`/sidecar `tcpSocket` + 特权三件套/模板只持容器引用与 `volumes`/空 `routing_rules` = 通配兜底 scope):221**curl 调用示例**(Envelope 包装:`type` 须为端点名、`metadata.request_id` 必填(兼幂等键)、三段式载荷在 `rawdata`;带 K8s pod 内探测的脚本版本见 `scripts/config_sync_seed.sh`。示例载荷要点:主容器探针恒 `httpGet` 且**无** `timeoutSeconds`/sidecar `tcpSocket` + 特权三件套/模板只持容器引用与 `volumes`/空 `routing_rules` = 通配兜底 scope):
223 222 
@@ -258,7 +258,7 @@
258> `{session_manager}:`,hash tag)拼出;调用侧把前缀同时声明为 `KEYS[1]` 作路由锚258> `{session_manager}:`,hash tag)拼出;调用侧把前缀同时声明为 `KEYS[1]` 作路由锚
259> (集群客户端据此把 EVAL 路由到 tag 归属节点;单实例无影响)。下文伪码从简省略。259> (集群客户端据此把 EVAL 路由到 tag 归属节点;单实例无影响)。下文伪码从简省略。
260 260 
261-**`LUA_ROUTE_PLACE(session_id, scope_id, expiry_ts, session_ttl, scope_concurrency, pod_concurrency, max_pods, now)`** —— route 的原子核心:一次脚本内完成"亲和续期 / 惰性回收 / scope 闸门 / first-fit 选 Pod / 提交",中途无其它请求插入(无 race)。`session_ttl` 随亲和写入 session HASH,供 TOUCH 就地读取(不依赖 scope:config)。261+**`LUA_ROUTE_PLACE(session_id, scope_id, expiry_ts, session_ttl, scope_concurrency, pod_concurrency, max_pods, now)`** —— route 的原子核心:一次脚本内完成"亲和续期 / rebind 重解 / scope 闸门 / first-fit 选 Pod / 提交",中途无其它请求插入(无 race)。`session_ttl` 随亲和写入 session HASH,供 TOUCH 就地读取(不依赖 scope:config)。**传入 scope 的语义(2026-09-scope-affinity-hold,#152)**:调用方要维持的绑定 scope(已有绑定的持有续期)或新会话的 first-fit 结果(全新放置)——规则变化不即时迁移存量会话,放置只发生在无绑定的调用里。
262```262```
263# 1. 读现有亲和绑定(若该 session_id 之前 route 过)263# 1. 读现有亲和绑定(若该 session_id 之前 route 过)
2641. existing = HGETALL(session:{session_id})2641. existing = HGETALL(session:{session_id})
@@ -270,19 +270,25 @@
2701b. if existing 且 existing.scope_id/pod_id/expiry 任一为 nil:2701b. if existing 且 existing.scope_id/pod_id/expiry 任一为 nil:
271 ZREM session_expiry session_id; DEL session:{session_id}; 落穿到 4271 ZREM session_expiry session_id; DEL session:{session_id}; 落穿到 4
272 272 
273-# 2. 亲和命中且未过期 → 仅续期,返回原 Pod。273+# 2. 亲和命中且未过期且 Pod 注册仍在 → 仅续期,返回原 Pod。
274# 不重新抢额度:chat_session 粒度模型下,额度在首次 route 已占用,持续到 TTL 老化;274# 不重新抢额度:chat_session 粒度模型下,额度在首次 route 已占用,持续到 TTL 老化;
275# 同一 chat_session 的后续请求只刷新老化计时,不重复扣 scope/Pod 容量。275# 同一 chat_session 的后续请求只刷新老化计时,不重复扣 scope/Pod 容量。
276-2. if existing.scope_id == scope_id and existing.expiry > now:276+# Pod 注册缺失(notify_pod_dead 清理窗口)判死绑定:继续续期会让会话对着已删
277+# 的 sse_url 无限自旋且 sweeper 永远收不走。
278+2. if existing.scope_id == scope_id and existing.expiry > now
279+ and EXISTS(pod:{scope_id}:{existing.pod_id}:info):
277 HSET session:{session_id} expiry expiry_ts session_ttl {session_ttl} # 续期 + 刷新 ttl(config 改了随 route 生效)280 HSET session:{session_id} expiry expiry_ts session_ttl {session_ttl} # 续期 + 刷新 ttl(config 改了随 route 生效)
278 ZADD session_expiry expiry_ts session_id281 ZADD session_expiry expiry_ts session_id
279 return {action:"refresh", pod_id: existing.pod_id}282 return {action:"refresh", pod_id: existing.pod_id}
280 283 
281-# 3. 惰性兜底:绑定存在但已过期,或 group/bot 变了导致 scope_id 不同 → 先回收旧绑定。284+# 3. 惰性兜底:绑定存在但已过期 / Pod 注册消失 / scope 不匹配(并发竞态)→
282-# "惰性"= 在访问当场发现 session 已死就立即清理,不等 sweeper 下一 tick。285+# 回收旧绑定并返回 rebind,handler 换 first-fit 结果重试——**本调用内不落
283-# 空在此处不触发 idle_consider(统一交 sweeper 空 Pod pass,见 §5.4)。286+# 放置**(曾见绑定的调用直接按传入 scope 放置,会把会话部署进已禁用/删除
287+# 的 scope)。"惰性"= 在访问当场发现 session 已死就立即清理,不等 sweeper
288+# 下一 tick。空在此处不触发 idle_consider(统一交 sweeper 空 Pod pass,见 §5.4)。
2843. if existing:2893. if existing:
285 内部走 EVICT 逻辑(用 existing.scope_id/pod_id)290 内部走 EVICT 逻辑(用 existing.scope_id/pod_id)
291+ return {action:"rebind"}
286 292 
287# 4. scope 闸门:scope_concurrency 限的是"活跃 chat_session 数",而非"在途请求数"293# 4. scope 闸门:scope_concurrency 限的是"活跃 chat_session 数",而非"在途请求数"
288# (gateway 保证每 chat_session ≤1 在途,故活跃数 == 在途数上界)。SCARD 即活跃数。294# (gateway 保证每 chat_session ≤1 在途,故活跃数 == 在途数上界)。SCARD 即活跃数。
@@ -393,18 +399,30 @@ cached = ctx.idempotency.get(metadata.request_id); if cached: return cached
393# resolve:热路径先查 scope:{scope_id}:config 缓存(命中不查库);miss 或被 config_sync 失效才读共享 DB。399# resolve:热路径先查 scope:{scope_id}:config 缓存(命中不查库);miss 或被 config_sync 失效才读共享 DB。
394scope_config = resolve(scope_id, group_id, bot_id, user_id)400scope_config = resolve(scope_id, group_id, bot_id, user_id)
395 401 
402+# 亲和持有判定(2026-09-scope-affinity-hold,#152):已有绑定 → 仲裁目标改为绑定
403+# scope(session_ttl 取其当前模板;scope/模板已从快照消失则回退哈希留存的
404+# session_ttl)。规则变化不即时迁移存量会话;first-fit 只决定新会话(与 rebind
405+# 后)的去处。
406+binding = HGETALL(session:{session_id})
407+target_scope = binding.scope_id if binding 有 scope_id+pod_id else scope_id
408+ 
396# 容量数学(见术语表):pod_concurrency = 单 Pod 满载容量(per-scope 独占);max_pods = 本 scope 的 Pod 数上限。409# 容量数学(见术语表):pod_concurrency = 单 Pod 满载容量(per-scope 独占);max_pods = 本 scope 的 Pod 数上限。
397max_pods = ceil(scope_config.scope_concurrency / scope_config.pod_concurrency)410max_pods = ceil(scope_config.scope_concurrency / scope_config.pod_concurrency)
398 411 
399pod_spec = extract_pod_spec(scope_config.template) # acquire 时下发给 Resource Manager 的部署子集412pod_spec = extract_pod_spec(scope_config.template) # acquire 时下发给 Resource Manager 的部署子集
400 413 
401loop:414loop:
402- # 原子核心:一次 Lua 完成 亲和续期/惰性回收/闸门/选 Pod/提交。415+ # 原子核心:一次 Lua 完成 亲和续期/rebind 重解/闸门/选 Pod/提交。
403 # 传 expiry_ts = now + session_ttl,并把 session_ttl 传入(写入 session HASH 供 TOUCH 就地读)。416 # 传 expiry_ts = now + session_ttl,并把 session_ttl 传入(写入 session HASH 供 TOUCH 就地读)。
404- result = redis.eval(LUA_ROUTE_PLACE, session_id, scope_id,417+ result = redis.eval(LUA_ROUTE_PLACE, session_id, target_scope,
405- now + scope_config.session_ttl, scope_config.session_ttl,418+ now + session_ttl, session_ttl,
406 scope_config.scope_concurrency, scope_config.pod_concurrency, max_pods, now)419 scope_config.scope_concurrency, scope_config.pod_concurrency, max_pods, now)
407 420 
421+ if result.action == "rebind":
422+ # 绑定已被惰性回收(过期/Pod 注册消失):换 first-fit 结果重试——曾见绑定
423+ # 的调用内不落放置(防向已禁用/删除 scope 重部署)。
424+ target_scope = scope_id; continue
425+ 
408 if result.action in ("refresh","placed"):426 if result.action in ("refresh","placed"):
409 # 成功:取 Pod 的 sse_url 返回 gateway;gateway 据此直连 Pod(数据面绕过 Session Manager)。427 # 成功:取 Pod 的 sse_url 返回 gateway;gateway 据此直连 Pod(数据面绕过 Session Manager)。
410 pod_sse_url = HGET pod:{scope_id}:{result.pod_id}:info sse_url428 pod_sse_url = HGET pod:{scope_id}:{result.pod_id}:info sse_url
Aapplications/agent_runtime/docs/feature/2026-09-drop-enabled-fields.md+75-0文件内容审核中,请稍后刷新重试
Aapplications/agent_runtime/docs/feature/2026-09-scope-affinity-hold.md+55-0文件内容审核中,请稍后刷新重试
Aapplications/agent_runtime/docs/feature/2026-09-sunset-drain-window.md+48-0文件内容审核中,请稍后刷新重试
@@ -17,8 +17,11 @@
17 17 
18| 日期 | 文档 | 一句话 |18| 日期 | 文档 | 一句话 |
19|---|---|---|19|---|---|---|
20+| 2026-09 | [删除两级 enabled(禁用=删除,生命周期=存在性+expires_at)](2026-09-drop-enabled-fields.md) | #154 定案:template/scope 两级 enabled 半死态(准入看/暖侧不看→暖 Pod 死锁,refresh 重焐热同族)整字段删除;「关」=从载荷删除(删 scope 走扩散③ 优雅排空,删 template 被 parse 400 强制显式组合);expires_at 保留为唯一生命周期字段;残留 enabled:false 防御性视为缺席(防静默重开准入);refresh 对过期 scope 只日落不保温;DB 两表先发版后 DROP+前置检查;555 用例 |
21+| 2026-09 | [scope 亲和保持(规则变更不迁移旧会话 + 路由性排除日落)](2026-09-scope-affinity-hold.md) | #152:route 每请求重算 first-fit 参与仲裁存量绑定=规则一改旧会话被即时迁移;改为亲和只认「绑定+Pod 存活」(重算只服务新放置,曾见绑定的调用不落放置,rebind 重试);禁用/失权/删 scope bump gen 走排空窗口(与 #151 同哲学,首因定窗);index 重排零打扰;corner 用例反转;554 用例 |
22+| 2026-09 | [日落优雅排空窗口(drain=session_ttl + surge 余量 1)](2026-09-sunset-drain-window.md) | 会话中的 Pod 不再被日落秒杀:排空截止=首因下发+session_ttl(route 亲和/touch 只查 pod:info 天然连续),过窗即收;max_pods 排空期 +1(三闸门同步);闸门 409 等排空(窗 ≤ session_ttl);A 类 sync 统一进窗;取代 09-15 硬切决策;542 用例+cluster 23/23 |
20| 2026-09 | [删除模板字段 message_timeout](2026-09-drop-message-timeout.md) | 全链路死参数硬删:runtime 只落库无出口(pool_config/route 响应/Redis 键均不含,单改连 affected_scopes 都不进;实测 10s/90s 配 40s 慢调用均不超时)——契约/DB/代码/文档同步删;载荷残留键静默忽略(同 max_pods)不 400;数据面 SSE 超时定性为 gateway 自身配置;存量库先发版后 DROP COLUMN |23| 2026-09 | [删除模板字段 message_timeout](2026-09-drop-message-timeout.md) | 全链路死参数硬删:runtime 只落库无出口(pool_config/route 响应/Redis 键均不含,单改连 affected_scopes 都不进;实测 10s/90s 配 40s 慢调用均不超时)——契约/DB/代码/文档同步删;载荷残留键静默忽略(同 max_pods)不 400;数据面 SSE 超时定性为 gateway 自身配置;存量库先发版后 DROP COLUMN |
21-| 2026-09 | [AgentServer 默认 namespace 跟随 runtime 自身 ns](2026-09-agentserver-ns-follow-runtime.md) | 模板下发 namespace 空串=继承——AgentServer 落 runtime 自身 ns(downward API 注入 `POD_NAMESPACE`,`default_namespace = POD_NAMESPACE or "default"` 单点改 config.from_env;`AGENT_RUNTIME_DEFAULT_NAMESPACE` 变量移除);部署模板换注入源;DB/wire 契约/存量库零改动 |24+| 2026-09 | [AgentServer 默认 namespace 跟随 runtime 自身 ns](2026-09-agentserver-ns-follow-runtime.md) | 模板下发 namespace 空串=继承——AgentServer 落 runtime 自身 ns(`own_namespace()`:`POD_NAMESPACE` env > **SA 文件兜底** > `"default"`,镜像/模板任何升级顺序零配置自愈);`AGENT_RUNTIME_DEFAULT_NAMESPACE` 变量移除;DB/wire 契约/存量库零改动;09-17 cyz2 403 复盘见篇内 |
22| 2026-09 | [日落排空语义修复(闸门基准=当前生效版本 + reclaim 落后免老化)](2026-09-sunset-drain-semantics-fix.md) | config_sync 闸门原比新载荷版本,当前代空闲 Pod 必然≠新版被误判遗留、又受 min_idle 底数保护永不回收=**永久 409**(cyz 实测 idle 33min≫pod_ttl);基准改当前生效版本(与 refresh 闸门比当前代次同构)+reclaim 对 ver/gen 落后 idle Pod 免老化即刻回收(当前版超额仍抗抖);会话硬切决策接受(②B 否决留档);528 用例 |25| 2026-09 | [日落排空语义修复(闸门基准=当前生效版本 + reclaim 落后免老化)](2026-09-sunset-drain-semantics-fix.md) | config_sync 闸门原比新载荷版本,当前代空闲 Pod 必然≠新版被误判遗留、又受 min_idle 底数保护永不回收=**永久 409**(cyz 实测 idle 33min≫pod_ttl);基准改当前生效版本(与 refresh 闸门比当前代次同构)+reclaim 对 ver/gen 落后 idle Pod 免老化即刻回收(当前版超额仍抗抖);会话硬切决策接受(②B 否决留档);528 用例 |
23| 2026-09 | [data / created_at / updated_at 三元组对齐](2026-09-data-triad-alignment.md) | container/routing_scope/link_binding_state 补 data;config_sync 透传模板/资源 data;Gateway agent-resource 补时间戳;实例资源更新保留 created_at |26| 2026-09 | [data / created_at / updated_at 三元组对齐](2026-09-data-triad-alignment.md) | container/routing_scope/link_binding_state 补 data;config_sync 透传模板/资源 data;Gateway agent-resource 补时间戳;实例资源更新保留 created_at |
24| 2026-09 | [删除 service_config_template legacy 内联容器列](2026-09-drop-legacy-inline-template-columns.md) | 硬删 24 个拆表前内联列;Manager 只认 data.config_sync.containers + refs;Runtime 保留 wire mixed 黑名单;存量库须 DROP COLUMN |27| 2026-09 | [删除 service_config_template legacy 内联容器列](2026-09-drop-legacy-inline-template-columns.md) | 硬删 24 个拆表前内联列;Manager 只认 data.config_sync.containers + refs;Runtime 保留 wire mixed 黑名单;存量库须 DROP COLUMN |
Mapplications/agent_runtime/docs/spec/evaluation.md+5-3文件内容审核中,请稍后刷新重试
@@ -11,7 +11,7 @@
11| `facade.py` | `ResourceManagerFacade`(SM→RM 进程内入口,薄封装) |11| `facade.py` | `ResourceManagerFacade`(SM→RM 进程内入口,薄封装) |
12| `orchestrator.py` | acquire(取暖/选主 deploy/follower 等待室)+ idle_consider + update_pool_config + cleanup + 幂等缓存 |12| `orchestrator.py` | acquire(取暖/选主 deploy/follower 等待室)+ idle_consider + update_pool_config + cleanup + 幂等缓存 |
13| `state.py` | RM Redis 键 schema 唯一出口(`RMKeys`/`ResourceState`) |13| `state.py` | RM Redis 键 schema 唯一出口(`RMKeys`/`ResourceState`) |
14-| `lua_scripts.py` | 6 个 Lua 全文 |14+| `lua_scripts.py` | 7 个 Lua 全文 |
15| `k8s.py` | `K8sPodClient` 接口 + `RealK8sPodClient`(kubernetes_asyncio)+ `FakeK8sPodClient` |15| `k8s.py` | `K8sPodClient` 接口 + `RealK8sPodClient`(kubernetes_asyncio)+ `FakeK8sPodClient` |
16| `sweeper.py` | 四个后台任务(各自带选主锁) |16| `sweeper.py` | 四个后台任务(各自带选主锁) |
17| `models.py` | `PodInfo`/`PodDeployInfo`/判死枚举/label 常量 |17| `models.py` | `PodInfo`/`PodDeployInfo`/判死枚举/label 常量 |
@@ -22,8 +22,8 @@
22|---|---|22|---|---|
23| `acquire(scope_id, pod_spec, pool_config, request_id)` | `{pod_id, pod_sse_url}`;失败抛 `MaxPodsReached`/`DeployFailed`(SM 映射 503 NO_POD_AVAILABLE) |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}`,幂等 |24| `idle_consider(pod_id, scope_id)` | `{transitioned_to_idle}`,幂等 |
25-| `update_pool_config(scope_id, pool_config, pod_spec?)` | `{updated}`(config_sync 触发;mapping **永不含 generation**) |25+| `update_pool_config(scope_id, pool_config, pod_spec?)` | `{updated}`(config_sync/refresh push 触发;mapping **永不含 generation**;含 session_ttl;检出落后 Pod 时戳记排空纪元 `scope:drain_until`,2026-09-17) |
26-| `bump_generation(scope_id)` | `generation: int`(config_refresh 触发的代次日落,HINCRBY 原子自增,唯一写点) |26+| `bump_generation(scope_id)` | `generation: int`(代次日落:HINCRBY 原子自增,唯一写点;调用方=config_refresh 全量 + config_sync 路由性排除变更[2026-09-scope-affinity-hold]) |
27| `sunset_pending_pods(scope_id)` | `list[str]`(config_refresh 前置闸门:scope 注册 Pod 中 `generation ≠ 当前配置代次` 者,忙排空/idle 待回收均计;非空 → SM 侧 409 CONFIG_SYNC_BUSY,见 session-manager spec ⓪ 步) |27| `sunset_pending_pods(scope_id)` | `list[str]`(config_refresh 前置闸门:scope 注册 Pod 中 `generation ≠ 当前配置代次` 者,忙排空/idle 待回收均计;非空 → SM 侧 409 CONFIG_SYNC_BUSY,见 session-manager spec ⓪ 步) |
28| `cleanup(namespace?, label_selector?)` | `cleaned: int`(运维批删) |28| `cleanup(namespace?, label_selector?)` | `cleaned: int`(运维批删) |
29| `known_scope_ids()` | RM 已知 scope 枚举(SCAN scope:config;config_sync 的被删 scope drain 收敛用——RM config 键是幻影预热的真源) |29| `known_scope_ids()` | RM 已知 scope 枚举(SCAN scope:config;config_sync 的被删 scope drain 收敛用——RM config 键是幻影预热的真源) |
@@ -49,13 +49,13 @@
49 49 
50`_follow_leader`(M8,deploy 锁输家的等待室):50`_follow_leader`(M8,deploy 锁输家的等待室):
51- 准入走 `LUA_DEPLOY_FOLLOWER_GATE` 原子闸门,上限 `pod_concurrency - 1`(leader 会话之外新 Pod 恰剩这些槽);overflow 严格快失败 MaxPodsReached。51- 准入走 `LUA_DEPLOY_FOLLOWER_GATE` 原子闸门,上限 `pod_concurrency - 1`(leader 会话之外新 Pod 恰剩这些槽);overflow 严格快失败 MaxPodsReached。
52-- 等待有界:`ready_timeout + 10s` 余量;轮询 `resource:scope:{sid}:pods` 出现新 Pod 且 pod:info 有 sse_url → **直接复用返回**(与 reuse 分支同构,SM 侧重跑仲裁即可)。52+- 等待有界:`ready_timeout + 10s` 余量;轮询 `resource:scope:{sid}:pods` 出现新 Pod 且 pod:info 有 sse_url → `LUA_POP_IDLE` 忙记账(SREM idle + DEL idle_since,与 reuse 分支对齐;幂等)→ **直接复用返回**(与 reuse 分支同构,SM 侧重跑仲裁即可)。不摘则 autoscale(leader=补位路径,idle_flag=True 注册)的热备被接管后仍占 warm 底数 → 恒 skip_warm 不补位,池卡 1 忙 Pod(2026-09-17 wangchang follower_reuse 泄漏实录)。
53- leader 失败判定:deploy 锁空闲且无新 Pod → `DeployFailed`(**follower 不接管**——同镜像同环境大概率也失败);deadline 到 → MaxPodsReached。53- leader 失败判定:deploy 锁空闲且无新 Pod → `DeployFailed`(**follower 不接管**——同镜像同环境大概率也失败);deadline 到 → MaxPodsReached。
54- 等待期进度行:每 `FOLLOWER_PROGRESS_LOG_SEC`(5s)一条 INFO `follower still waiting: scope= follower= waited_s=`——ready_timeout 最长 300s,INFO 下不留日志空白(部署风暴期的观测窗口);复用成功另有一条 INFO `acquire follower reuses leader pod`。54- 等待期进度行:每 `FOLLOWER_PROGRESS_LOG_SEC`(5s)一条 INFO `follower still waiting: scope= follower= waited_s=`——ready_timeout 最长 300s,INFO 下不留日志空白(部署风暴期的观测窗口);复用成功另有一条 INFO `acquire follower reuses leader pod`。
55- 错误路径双清:占位 + follower 成员都进 finally;崩溃遗留由闸门 `ZREMRANGEBYSCORE(deadline)` 兜底。55- 错误路径双清:占位 + follower 成员都进 finally;崩溃遗留由闸门 `ZREMRANGEBYSCORE(deadline)` 兜底。
56 56 
57`idle_consider`:`LUA_RELEASE` 转 idle 暖池(起 pod_ttl 计时)+ pod:info.phase=idle;幂等。57`idle_consider`:`LUA_RELEASE` 转 idle 暖池(起 pod_ttl 计时)+ pod:info.phase=idle;幂等。
58-`update_pool_config`:HSET 覆盖池参数;A 类变更附带 pod_spec 时同时刷 deploy_ver/pod_spec_json(autoscale 补位用新 deploy 字段);**mapping 永不含 generation——代次只经 `bump_generation` 单调递增,推送永不重置**。58+`update_pool_config`:HSET 覆盖池参数(含 `session_ttl`,排空 deadline 计算用);A 类变更附带 pod_spec 时同时刷 deploy_ver/pod_spec_json(autoscale 补位用新 deploy 字段);**mapping 永不含 generation——代次只经 `bump_generation` 单调递增,推送永不重置**。**日落排空纪元戳记(2026-09-17)**:推送后注册表存在版本(A 类)或代次(refresh / config_sync 路由性排除已先 bump)落后的 Pod 且纪元未活跃 → SET `scope:drain_until = now + session_ttl`(EX 同值 + DRAIN_SLACK_S=60 崩溃兜底);期间容量上限 +SURGE_MARGIN(见 LUA_ACQUIRE/autoscale),reclaim 对 stale 延后到截止后回收。**首因下发定窗**:纪元已活跃则不重戳(B 类纯池参数下发不延长);排空中的 refresh 本就被闸门 409 拦住(候选集内老代 Pod 放行的竞态形态除外,见 R8)。
59`bump_generation`:HINCRBY `scope:config.generation`;现有 Pod 代次全部落后 → LUA_ACQUIRE 过滤 / `_current_version_idle` 判 stale / autoscale 重建(场景 M-R,SM 的 config_refresh 触发)。"当前版本"判定 = **deploy_ver 相等 ∧ generation 相等**;两侧同缺(空串)视为一致——从未刷新过的 scope 零行为变化。59`bump_generation`:HINCRBY `scope:config.generation`;现有 Pod 代次全部落后 → LUA_ACQUIRE 过滤 / `_current_version_idle` 判 stale / autoscale 重建(场景 M-R,SM 的 config_refresh 触发)。"当前版本"判定 = **deploy_ver 相等 ∧ generation 相等**;两侧同缺(空串)视为一致——从未刷新过的 scope 零行为变化。
60`cleanup`:K8s list+delete,**不操作 Redis 编排态**(被删 Pod 由 watch/reconcile 兜底发现);ns 404 容忍为 cleaned=0,**403 保持 fail-fast**(静默清零会掩盖部署配错);逐 Pod 一条 INFO `cleanup deleted pod: pod= namespace=`(批删中途中断时可见删到哪;k8s.delete 自身明细在 DEBUG)+ 结尾 WARNING 聚合。60`cleanup`:K8s list+delete,**不操作 Redis 编排态**(被删 Pod 由 watch/reconcile 兜底发现);ns 404 容忍为 cleaned=0,**403 保持 fail-fast**(静默清零会掩盖部署配错);逐 Pod 一条 INFO `cleanup deleted pod: pod= namespace=`(批删中途中断时可见删到哪;k8s.delete 自身明细在 DEBUG)+ 结尾 WARNING 聚合。
61 61 
@@ -65,9 +65,10 @@
65|---|---|---|65|---|---|---|
66| `resource:scope:{sid}:pods` | ZSET | 该 scope 全部 Pod(in_use ∪ idle);**ZCARD+deploying SCARD 参与 max_pods 判定** |66| `resource:scope:{sid}:pods` | ZSET | 该 scope 全部 Pod(in_use ∪ idle);**ZCARD+deploying SCARD 参与 max_pods 判定** |
67| `resource:scope:{sid}:idle` | SET | idle 暖池;acquire 从此取暖 Pod |67| `resource:scope:{sid}:idle` | SET | idle 暖池;acquire 从此取暖 Pod |
68-| `resource:scope:{sid}:config` | HASH | min_idle_pods/max_pods/pod_ttl/pod_concurrency/deploy_ver/pod_spec_json/**generation**(config_refresh 的代次日落标记,**唯一写点 = HINCRBY**,config_sync 推送永不重置)。**config_sync 对每个存活 scope 主动写入/刷新(带 pod_spec)——无请求 scope 的 min_idle 预热依赖它**;首 acquire 兜底写入;被删 scope 推 min_idle=0 停预热自然排空 |68+| `resource:scope:{sid}:config` | HASH | min_idle_pods/max_pods/pod_ttl/**session_ttl**(排空窗口用,2026-09-17)/pod_concurrency/deploy_ver/pod_spec_json/**generation**(config_refresh 的代次日落标记,**唯一写点 = HINCRBY**,config_sync 推送永不重置)。**config_sync 对每个存活 scope 主动写入/刷新(带 pod_spec)——无请求 scope 的 min_idle 预热依赖它**;首 acquire 兜底写入;被删 scope 推 min_idle=0 停预热自然排空 |
69| `resource:scope:{sid}:deploying` | ZSET | deploy 占位 token→deadline 秒级 score(计入 max_pods,防并发超配;闸门/autoscale 按 deadline 原子清崩溃遗留——硬崩后进程内清理不存在,占位不得永久虚占容量) |69| `resource:scope:{sid}:deploying` | ZSET | deploy 占位 token→deadline 秒级 score(计入 max_pods,防并发超配;闸门/autoscale 按 deadline 原子清崩溃遗留——硬崩后进程内清理不存在,占位不得永久虚占容量) |
70| `resource:scope:{sid}:deploy_followers` | ZSET | follower 等待室(request_id→deadline 秒级 score;闸门按 deadline 原子清过期) |70| `resource:scope:{sid}:deploy_followers` | ZSET | follower 等待室(request_id→deadline 秒级 score;闸门按 deadline 原子清过期) |
71+| `resource:scope:{sid}:drain_until` | STR | 日落排空截止时间戳(秒);存在 ⟺ 排空纪元活跃 → 容量上限 +SURGE_MARGIN / stale 回收延后到截止。写点=update_pool_config(首因下发,不重戳);正常终止=reclaim 收完落后 Pod 主动 DEL;EX=window+60s 为崩溃兜底(2026-09-17) |
71| `resource:pod:{pod}:info` | HASH | scope_id/pod_sse_url/pod_ip/namespace/phase/created_ts/deploy_ver/**sse_port/health_path**(Pod 自己烘焙的探测契约;A 类变更后 scope 当前配置已换代,watch 探测必须用 Pod 自己的参数,否则存量老 Pod 被探错路径误杀)/**generation**(注册时刻代次烙印,REGISTER 服务端读 scope:config——与 bump 原子排队,deploy 中途刷新不误伤晚注册的新 Pod) |72| `resource:pod:{pod}:info` | HASH | scope_id/pod_sse_url/pod_ip/namespace/phase/created_ts/deploy_ver/**sse_port/health_path**(Pod 自己烘焙的探测契约;A 类变更后 scope 当前配置已换代,watch 探测必须用 Pod 自己的参数,否则存量老 Pod 被探错路径误杀)/**generation**(注册时刻代次烙印,REGISTER 服务端读 scope:config——与 bump 原子排队,deploy 中途刷新不误伤晚注册的新 Pod) |
72| `resource:pod:{pod}:idle_since` | STR | idle 起始(reclaim 计时);存在 ⟺ 在 idle 池 |73| `resource:pod:{pod}:idle_since` | STR | idle 起始(reclaim 计时);存在 ⟺ 在 idle 池 |
73| `resource:pod:{pod}:health_fails` | STR | 健康探测连续失败次数(场景 N) |74| `resource:pod:{pod}:health_fails` | STR | 健康探测连续失败次数(场景 N) |
@@ -80,14 +81,15 @@
80 81 
81`eval()` 统一出口带异常留痕(同 SM:空表异常 WARNING、>200ms 慢 eval WARNING、常规 DEBUG)。**诊断只读方法**:`health_fails(pod_id)`(/visualization/scope 用)。82`eval()` 统一出口带异常留痕(同 SM:空表异常 WARNING、>200ms 慢 eval WARNING、常规 DEBUG)。**诊断只读方法**:`health_fails(pod_id)`(/visualization/scope 用)。
82 83 
83-## lua_scripts.py —— 6 个 Lua84+## lua_scripts.py —— 7 个 Lua
84 85 
85| 脚本 | 一句话职责 |86| 脚本 | 一句话职责 |
86|---|---|87|---|---|
87-| `LUA_ACQUIRE` | 取暖 Pod 复用(**跳过 deploy_ver 或 generation 不匹配**——A 类变更后老版本、config_refresh 后老代次暖 Pod 不外发,由 reclaim 版本/代次感知回收)→ 无匹配判 max_pods(ZCARD pods + ZCARD deploying)→ 占位 ZADD deploying(score=deadline,先清过期) → need_deploy |88+| `LUA_ACQUIRE` | 取暖 Pod 复用(**跳过 deploy_ver 或 generation 不匹配**——A 类变更后老版本、config_refresh 后老代次暖 Pod 不外发,由 reclaim 版本/代次感知回收)→ 无匹配判容量(ZCARD pods + ZCARD deploying;**排空纪元活跃(EXISTS scope:drain_until)→ 上限 +SURGE_MARGIN=1**,2026-09-17 优雅排空)→ 占位 ZADD deploying(score=deadline,先清过期) → need_deploy |
88-| `LUA_PLACEHOLDER` | autoscale 专用占位(判 max_pods + ZADD,**不碰 idle 池**——补位不该消耗暖 Pod;同款 deadline 自清) |89+| `LUA_PLACEHOLDER` | autoscale 专用占位(判容量 + ZADD,**不碰 idle 池**——补位不该消耗暖 Pod;同款 deadline 自清;同款排空 surge +1) |
89| `LUA_REGISTER` | deploy 成功登记:pod:info(含 sse_port/health_path + **generation 服务端烙印**——读注册时刻 scope:config 当前代次)/ scope:pods / pods:all 同写,清占位;idle_flag=1(热备)入 idle 池 |90| `LUA_REGISTER` | deploy 成功登记:pod:info(含 sse_port/health_path + **generation 服务端烙印**——读注册时刻 scope:config 当前代次)/ scope:pods / pods:all 同写,清占位;idle_flag=1(热备)入 idle 池 |
90| `LUA_RELEASE` | idle_consider:转 idle 暖池,**仅首次转入(SADD=1)起 pod_ttl 计时**;周期重放(reconcile stale/idle_consider 去重重发)不刷新计时——否则空闲 Pod 永不回收;acquire 弹出后再转 idle 重新计时;**已 PURGE 的 Pod(info 已清)no-op**(防 TOCTOU 幽灵成员) |91| `LUA_RELEASE` | idle_consider:转 idle 暖池,**仅首次转入(SADD=1)起 pod_ttl 计时**;周期重放(reconcile stale/idle_consider 去重重发)不刷新计时——否则空闲 Pod 永不回收;acquire 弹出后再转 idle 重新计时;**已 PURGE 的 Pod(info 已清)no-op**(防 TOCTOU 幽灵成员) |
92+| `LUA_POP_IDLE` | 忙占用摘出 idle 暖池(SREM idle + DEL idle_since;幂等,非 idle 成员/请求驱动 leader 的 idle_flag=False 注册均 no-op)——`_follow_leader` follower 接管 leader 热备 Pod 的忙记账,与 LUA_ACQUIRE reuse 分支对齐(2026-09-17 修复) |
91| `LUA_PURGE` | Pod 死亡/reclaim 后清全部 RM key(返回其 scope_id;幂等) |93| `LUA_PURGE` | Pod 死亡/reclaim 后清全部 RM key(返回其 scope_id;幂等) |
92| `LUA_DEPLOY_FOLLOWER_GATE` | follower 等待室原子准入:先 `ZREMRANGEBYSCORE` 清过期 → ZADD 先行 → ZCARD 超限自退(纪律同 LUA_WAITER_GATE,禁止先查后加) |94| `LUA_DEPLOY_FOLLOWER_GATE` | follower 等待室原子准入:先 `ZREMRANGEBYSCORE` 清过期 → ZADD 先行 → ZCARD 超限自退(纪律同 LUA_WAITER_GATE,禁止先查后加) |
93 95 
@@ -102,7 +104,7 @@
102- **单次调用超时(2026-09 健壮性加固)**:create/read/list/delete 一律传 `_request_timeout`(常量 `CREATE_TIMEOUT=30`/`READ_TIMEOUT=10`/`LIST_TIMEOUT=15`/`DELETE_TIMEOUT=60`)——kubernetes_asyncio 不传时 aiohttp ClientTimeout 全 None(连库默认都覆盖),API server/网络挂起会无限悬挂并逐级拖死 deploy 与上层 route。超时异常走既有 except 链归一 DeployFailed(契约不变)。104- **单次调用超时(2026-09 健壮性加固)**:create/read/list/delete 一律传 `_request_timeout`(常量 `CREATE_TIMEOUT=30`/`READ_TIMEOUT=10`/`LIST_TIMEOUT=15`/`DELETE_TIMEOUT=60`)——kubernetes_asyncio 不传时 aiohttp ClientTimeout 全 None(连库默认都覆盖),API server/网络挂起会无限悬挂并逐级拖死 deploy 与上层 route。超时异常走既有 except 链归一 DeployFailed(契约不变)。
103- **生命周期并发(同批加固)**:`close()` 锁内只做引用摘除(快照+置空+复位 `_loaded`),网络收尾 `api_client.close()` 放锁外(持锁等网络会饿死 start);在飞调用持旧引用,各调用点(`deploy/get_pod/list_pods/delete/_read`)在 start 后快照 `self._core`,None 即 `DeployFailed("k8s client closed")`(不裸抛 AttributeError);close 后的惰性调用(`_loaded=False`)经 `start()` 自愈重建。105- **生命周期并发(同批加固)**:`close()` 锁内只做引用摘除(快照+置空+复位 `_loaded`),网络收尾 `api_client.close()` 放锁外(持锁等网络会饿死 start);在飞调用持旧引用,各调用点(`deploy/get_pod/list_pods/delete/_read`)在 start 后快照 `self._core`,None 即 `DeployFailed("k8s client closed")`(不裸抛 AttributeError);close 后的惰性调用(`_loaded=False`)经 `start()` 自愈重建。
104- `deploy(pod_spec)`:pod_id = `{pod_name}-{随机10}-{随机5}`(**K8s 随机 Pod 名,严禁业务 id 当实例 id——历史死锁根因**);409 名字冲突重命名重试至多 3 次;`_wait_ready` 轮询至 Ready+有 podIP(每 30s 一条 INFO 进度行,终态/超时 WARNING 带 waited_s),终态(Failed/Succeeded)/消失/超时 → DeployFailed;**create 之后的任何失败/取消先 best-effort 删除该 Pod 再抛,DeployFailed 携带 pod_id/namespace 属性供上层兜底**(契约:失败路径不留孤儿物理 Pod——未 REGISTER 的 Pod 不在 pods:all,watch/reconcile 只做 Redis→K8s 单向对账,孤儿无人认领);`get_pod`/`list_pods`/`delete` 带 DEBUG 耗时;`probe_health` 异常原因 DEBUG 留痕(调用方 sweeper 同节奏 WARNING)。106- `deploy(pod_spec)`:pod_id = `{pod_name}-{随机10}-{随机5}`(**K8s 随机 Pod 名,严禁业务 id 当实例 id——历史死锁根因**);409 名字冲突重命名重试至多 3 次;`_wait_ready` 轮询至 Ready+有 podIP(每 30s 一条 INFO 进度行,终态/超时 WARNING 带 waited_s),终态(Failed/Succeeded)/消失/超时 → DeployFailed;**create 之后的任何失败/取消先 best-effort 删除该 Pod 再抛,DeployFailed 携带 pod_id/namespace 属性供上层兜底**(契约:失败路径不留孤儿物理 Pod——未 REGISTER 的 Pod 不在 pods:all,watch/reconcile 只做 Redis→K8s 单向对账,孤儿无人认领);`get_pod`/`list_pods`/`delete` 带 DEBUG 耗时;`probe_health` 异常原因 DEBUG 留痕(调用方 sweeper 同节奏 WARNING)。
105-- **namespace 解析(2026-09 跟随自身 ns)**:`deploy`/`_build_pod_body` 取 `pod_spec.get("namespace") or self.default_namespace`——模板显式 ns 优先,**空串 = 继承语义**落 `default_namespace`(= `POD_NAMESPACE`(downward API 注入的自身 ns,deploy 模板注入),未注入时 `"default"`;Real/Fake 同款)。Pod 记录存的 namespace 是**解析后**值(register_pod),回收/删除跨 ns 无歧义。不带 ns 的 `cleanup` 兜底同链——集群内默认目标从 `"default"` 变为 runtime 自身 ns,"空目标必须无匹配 label_selector"红线继续适用。107+- **namespace 解析(2026-09 跟随自身 ns)**:`deploy`/`_build_pod_body` 取 `pod_spec.get("namespace") or self.default_namespace`——模板显式 ns 优先,**空串 = 继承语义**落 `default_namespace`(= `config.own_namespace()`:`POD_NAMESPACE` env > SA namespace 文件 > `"default"`,Real/Fake 同款)。Pod 记录存的 namespace 是**解析后**值(register_pod),回收/删除跨 ns 无歧义。不带 ns 的 `cleanup` 兜底同链——集群内默认目标从 `"default"` 变为 runtime 自身 ns,"空目标必须无匹配 label_selector"红线继续适用。
106- `_build_pod_body`(2026-09 统一规范形):入口 `normalize_pod_spec`(补缺省键)→ shape 探测(缺 `main_container` = 旧扁平缓存 → **DeployFailed**,防渲染空镜像 Pod)→ `find_container_conflict` fail-fast(撞容器名/撞端口,防 agent 经 127.0.0.1 连错进程)→ **单一 `_build_container(c, cont, role, idx, pvc_seen, nfs_seen)` 渲染主/sidecar**,role 分支仅两处:ports 有名(sse/http)vs 无名声明、探针恒 httpGet 打 sse 端口无 timeout vs 可选 tcp/http 带 timeout。securityContext/command/args/挂载四族/env/envFrom/resources 主/sidecar 一致渲染(决策 B:securityContext 全量,apparmor 走 Pod annotation;全默认 → None 走镜像默认;command/args 缺省走镜像 ENTRYPOINT/CMD)。label `{jiuwenclaw-component: agentserver, app: pod_id}`;资源 requests/limits;**容器 env 注入**(真 AgentServer 需 `AGENT_HTTP_ENABLED/HOST/PORT` 开 HTTP 入口);**envFrom 引用 → `envFrom`(`_render_env_from`:secretRef/configMapRef/prefix/optional 逐字段透传;缺省 None 不设;脏缓存坏项跳过;值不落模板/快照,只传引用名——密钥不再明文)**;restart_policy=Always。`probe_health`(场景 N)与 readiness 同源取主容器探针 path(sweeper 从 scope:config 的 pod_spec_json 读,legacy 形回退旧扁平键)。108- `_build_pod_body`(2026-09 统一规范形):入口 `normalize_pod_spec`(补缺省键)→ shape 探测(缺 `main_container` = 旧扁平缓存 → **DeployFailed**,防渲染空镜像 Pod)→ `find_container_conflict` fail-fast(撞容器名/撞端口,防 agent 经 127.0.0.1 连错进程)→ **单一 `_build_container(c, cont, role, idx, pvc_seen, nfs_seen)` 渲染主/sidecar**,role 分支仅两处:ports 有名(sse/http)vs 无名声明、探针恒 httpGet 打 sse 端口无 timeout vs 可选 tcp/http 带 timeout。securityContext/command/args/挂载四族/env/envFrom/resources 主/sidecar 一致渲染(决策 B:securityContext 全量,apparmor 走 Pod annotation;全默认 → None 走镜像默认;command/args 缺省走镜像 ENTRYPOINT/CMD)。label `{jiuwenclaw-component: agentserver, app: pod_id}`;资源 requests/limits;**容器 env 注入**(真 AgentServer 需 `AGENT_HTTP_ENABLED/HOST/PORT` 开 HTTP 入口);**envFrom 引用 → `envFrom`(`_render_env_from`:secretRef/configMapRef/prefix/optional 逐字段透传;缺省 None 不设;脏缓存坏项跳过;值不落模板/快照,只传引用名——密钥不再明文)**;restart_policy=Always。`probe_health`(场景 N)与 readiness 同源取主容器探针 path(sweeper 从 scope:config 的 pod_spec_json 读,legacy 形回退旧扁平键)。
107- **多容器(pod_spec.sidecars,canonical 见 containers.py)**:`normalize_pod_spec` 兜底(pod_spec 可能来自 Redis 缓存脏数据,坏项静默丢弃);sidecar 经同一 `_build_container` 渲染(端口纯声明性**无名**、独立 resources、security_context(privileged/caps/seccomp/run_as)、apparmor unconfined 落 **Pod annotation**、tcp/http readiness 探针);`containers=[主容器, *sidecars]`,无 sidecars 时 annotations=None、单容器——**与历史逐字节一致**。sidecar readiness 参与 Pod Ready → `_wait_ready` 天然等 sidecar 就绪(慢启动 sidecar 需调大模板 ready_timeout)。109- **多容器(pod_spec.sidecars,canonical 见 containers.py)**:`normalize_pod_spec` 兜底(pod_spec 可能来自 Redis 缓存脏数据,坏项静默丢弃);sidecar 经同一 `_build_container` 渲染(端口纯声明性**无名**、独立 resources、security_context(privileged/caps/seccomp/run_as)、apparmor unconfined 落 **Pod annotation**、tcp/http readiness 探针);`containers=[主容器, *sidecars]`,无 sidecars 时 annotations=None、单容器——**与历史逐字节一致**。sidecar readiness 参与 Pod Ready → `_wait_ready` 天然等 sidecar 就绪(慢启动 sidecar 需调大模板 ready_timeout)。
108- **卷挂载渲染(`mounts.py` 规范形,主容器与 sidecar 共用 `_render_volume_mounts`)**:hostPath/ConfigMap/PVC/NFS 四种(挂载族在 canonical `*_mounts` 键,主/sidecar 一致);卷名 `_scoped_volume_name(prefix, 容器名净化, 容器idx, 挂载idx)`,前缀 `hp-`/`cm-`/`pvc-`/`nfs-`,≤63 防撞;**PVC 同 claim 跨容器去重**(`pvc_seen` 登记簿)与 **NFS 同共享(server+path)跨容器去重**(`nfs_seen` 同款)——同源只建**一个**共享卷(卷名取首现容器,主容器先渲染),后继容器的 volumeMount 复用该卷名(防 kubelet 挂第二个同源卷死锁/超时);卷级 `read_only` 取首现值(kubelet 语义:卷源 ro 压 mount 级 rw);ConfigMap 支持 `sub_path` 与 `items`(V1KeyToPath);RM 入口 `normalize_mounts` 兜底脏缓存;无挂载时零增量(与历史一致)。110- **卷挂载渲染(`mounts.py` 规范形,主容器与 sidecar 共用 `_render_volume_mounts`)**:hostPath/ConfigMap/PVC/NFS 四种(挂载族在 canonical `*_mounts` 键,主/sidecar 一致);卷名 `_scoped_volume_name(prefix, 容器名净化, 容器idx, 挂载idx)`,前缀 `hp-`/`cm-`/`pvc-`/`nfs-`,≤63 防撞;**PVC 同 claim 跨容器去重**(`pvc_seen` 登记簿)与 **NFS 同共享(server+path)跨容器去重**(`nfs_seen` 同款)——同源只建**一个**共享卷(卷名取首现容器,主容器先渲染),后继容器的 volumeMount 复用该卷名(防 kubelet 挂第二个同源卷死锁/超时);卷级 `read_only` 取首现值(kubelet 语义:卷源 ro 压 mount 级 rw);ConfigMap 支持 `sub_path` 与 `items`(V1KeyToPath);RM 入口 `normalize_mounts` 兜底脏缓存;无挂载时零增量(与历史一致)。
@@ -127,8 +129,8 @@
127 129 
128| 任务 | 周期/锁 | 逻辑 |130| 任务 | 周期/锁 | 逻辑 |
129|---|---|---|131|---|---|---|
130-| `autoscale_once`(场景 H) | 1s / lock:rm:autoscale | 先 `reap_expired_deploying`(崩溃遗留占位自愈)再遍历 `known_scope_ids()`(SCAN scope:config):**当前版本+代次** idle < min_idle_pods(`_current_version_idle`:deploy_ver ∧ generation 均与 scope:config 一致——A 类变更后旧版、config_refresh 后老代次 idle Pod 永不可能被复用,不能拿来满足 min_idle,否则暖池被旧版钉死)且 pods+deploying < max_pods → `LUA_PLACEHOLDER` 占位 → 抢 deploy 锁 → `_deploy_and_register(idle_flag=True)` 热备入池;pod_spec 取 scope:config 缓存(A 类变更后为新值;刷新后同值仅换代)。**config_sync 会主动写每个存活 scope 的 config(带 pod_spec)→ 从未被请求过的 scope 也会被预热(eager,下发即预备热备)** |132+| `autoscale_once`(场景 H) | 1s / lock:rm:autoscale | 先 `reap_expired_deploying`(崩溃遗留占位自愈)再遍历 `known_scope_ids()`(SCAN scope:config):**当前版本+代次** idle < min_idle_pods(`_current_version_idle`:deploy_ver ∧ generation 均与 scope:config 一致——A 类变更后旧版、config_refresh 后老代次 idle Pod 永不可能被复用,不能拿来满足 min_idle,否则暖池被旧版钉死)且 pods+deploying < 容量上限(max_pods;**排空纪元活跃 +SURGE_MARGIN=1**,与 Lua 闸门同步,2026-09-17)→ `LUA_PLACEHOLDER` 占位 → 抢 deploy 锁 → `_deploy_and_register(idle_flag=True)` 热备入池;pod_spec 取 scope:config 缓存(A 类变更后为新值;刷新后同值仅换代)。**config_sync 会主动写每个存活 scope 的 config(带 pod_spec)→ 从未被请求过的 scope 也会被预热(eager,下发即预备热备)** |
131-| `reclaim_once`(场景 K) | 1s / lock:rm:reclaim | excess = 旧版本/老代次 idle Pod(**恒为 excess**——acquire want_ver+generation 过滤后永不可复用,若受底数保护则暖池钉死旧版+蹲占 max_pods)+ 当前版本代次 idle 超出 min_idle 的部分(按转 idle 先后);**两级回收(2026-09-15 ③)**:stale(旧版本/老代次)免老化**即刻回收**——零复用价值,蹲满剩余 pod_ttl 只白占 max_pods 槽位并拖长 config_sync/refresh 日落闸门等待(含扩散后仍带会话的 Pod,会话硬切重放置,决策接受);当前版本超额须 `aged ≥ pod_ttl`(抗复用抖动,真镜像冷启动 p50≈12s)→ `_purge_and_notify`(K8s delete → LUA_PURGE → notify_pod_dead);底数只保护当前版本代次最早入 idle 的 min_idle 个(保底热备)。**scope 被删除时 config_sync 推 min_idle=0 → 本任务把其空闲 Pod 按 pod_ttl 自然排空(存量会话到期止,不强制驱逐)** |133+| `reclaim_once`(场景 K) | 1s / lock:rm:reclaim | excess = 旧版本/老代次 idle Pod(**恒为 excess**——acquire want_ver+generation 过滤后永不可复用,若受底数保护则暖池钉死旧版+蹲占 max_pods)+ 当前版本代次 idle 超出 min_idle 的部分(按转 idle 先后);**两级回收(2026-09-17 优雅排空,取代 2026-09-15 免老化即刻)**:stale(旧版本/老代次)在**排空窗口截止后回收**——`scope:drain_until` 存在且 `now < 截止` 则跳过(窗口 = 首因下发 + session_ttl;期间已绑定会话继续服务:route 亲和/touch 只查 pod:info 不查候选集);**键缺失(过期/残局)兜底立即回收**,闸门收敛保证不破坏;当前版本超额仍须 `aged ≥ pod_ttl`(抗复用抖动,真镜像冷启动 p50≈12s)→ `_purge_and_notify`(K8s delete → LUA_PURGE → notify_pod_dead);底数只保护当前版本代次最早入 idle 的 min_idle 个(保底热备)。**排空收尾**:本拍收过 stale 且注册表已无落后 Pod(`has_lagged_pods`)→ DEL drain_until 精确终止 surge。**scope 被删除/禁用/失权时 config_sync 先 bump 再推 min_idle=0(2026-09-scope-affinity-hold)→ 其 Pod 走同款日落排空:存量会话窗口内继续原 Pod,过窗回收后 rebind 落新规则;窗口内会话停止活跃即回收(与 refresh 日落同语义)** |
132| `watch_once`(场景 J/N) | 10s / lock:rm:watch(TTL 15) | 遍历 pods:all:get_pod 为 None 或 phase∈DEAD → 清理;Running 但 `probe_health` **连续 2 次失败**(health_fails 阈值,防瞬时抖动误杀)→ 半死清理;成功清零计数。**探测参数优先取 Pod 自己 info 烘焙的 sse_port/health_path**(A 类变更后存量老 Pod 用旧契约探测;旧 Pod 无字段时回退 scope:config 的 pod_spec_json) |134| `watch_once`(场景 J/N) | 10s / lock:rm:watch(TTL 15) | 遍历 pods:all:get_pod 为 None 或 phase∈DEAD → 清理;Running 但 `probe_health` **连续 2 次失败**(health_fails 阈值,防瞬时抖动误杀)→ 半死清理;成功清零计数。**探测参数优先取 Pod 自己 info 烘焙的 sse_port/health_path**(A 类变更后存量老 Pod 用旧契约探测;旧 Pod 无字段时回退 scope:config 的 pod_spec_json) |
133| `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 回收 |135| `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 回收 |
134 136 
@@ -133,7 +133,7 @@ SM 侧 ctx,级联管理全部生命周期(框架 App 的 lifespan 只认一个 c
133|---|---|---|---|133|---|---|---|---|
134| mode | `AGENT_RUNTIME_MODE` | server | server\|local |134| mode | `AGENT_RUNTIME_MODE` | server | server\|local |
135| kubeconfig | `AGENT_RUNTIME_KUBECONFIG` | None(集群内 SA) | |135| kubeconfig | `AGENT_RUNTIME_KUBECONFIG` | None(集群内 SA) | |
136-| default_namespace | `POD_NAMESPACE` | default | downward API 注入的自身 ns(deploy 模板已注入);未注入/非 K8s 落字面 default。模板 namespace 为空串时 AgentServer 的落点(k8s 层 falsy 兜底) |136+| default_namespace | `POD_NAMESPACE` | default | 解析链(`config.own_namespace()`):`POD_NAMESPACE` env(显式覆盖/downward API)> in-cluster SA namespace 文件(Pod 必挂,**零部署配置**)> 字面 default。模板 namespace 为空串时 AgentServer 的落点(k8s 层 falsy 兜底);`AGENT_RUNTIME_DEFAULT_NAMESPACE` 已删,设了也忽略 |
137| sweep_interval | `AGENT_RUNTIME_SWEEP_INTERVAL` | 1 | SM:到期+空 Pod pass |137| sweep_interval | `AGENT_RUNTIME_SWEEP_INTERVAL` | 1 | SM:到期+空 Pod pass |
138| autoscale_interval | `AGENT_RUNTIME_AUTOSCALE_INTERVAL` | 1 | RM:min_idle 补位(**全局默认,无 per-scope 覆盖**) |138| autoscale_interval | `AGENT_RUNTIME_AUTOSCALE_INTERVAL` | 1 | RM:min_idle 补位(**全局默认,无 per-scope 覆盖**) |
139| reclaim_interval | `AGENT_RUNTIME_RECLAIM_INTERVAL` | 1 | RM:idle 回收 |139| reclaim_interval | `AGENT_RUNTIME_RECLAIM_INTERVAL` | 1 | RM:idle 回收 |
@@ -49,6 +49,10 @@ touch 不分桶(无容量信号,HGET 反查 scope 热路径加一跳,不做)。
49四参非空校验(缺 → InvalidParams 400)49四参非空校验(缺 → InvalidParams 400)
50→ resolve(user_id, group_id, bot_id)(config_store:读路由快照 first-fit 匹配)50→ resolve(user_id, group_id, bot_id)(config_store:读路由快照 first-fit 匹配)
51 返回 (scope_id, template);无匹配 → ConfigNotFound(503)51 返回 (scope_id, template);无匹配 → ConfigNotFound(503)
52+→ **亲和持有判定(2026-09-scope-affinity-hold,#152)**:读 `session:{sid}` 哈希,
53+ 已有绑定(scope_id+pod_id 在)→ 本次仲裁目标改为**绑定 scope**(session_ttl 取
54+ 该 scope 当前模板,scope/模板已从快照消失则回退哈希留存的 session_ttl)——
55+ 规则变化不即时迁移存量会话;first-fit 只决定新会话(与 rebind 后)的去处
52→ 循环 { **总预算校验:total_deadline = now + template.ready_timeout56→ 循环 { **总预算校验:total_deadline = now + template.ready_timeout
53 + ROUTE_BUDGET_MARGIN_SEC(10s),每圈 monotonic 复核。57 + ROUTE_BUDGET_MARGIN_SEC(10s),每圈 monotonic 复核。
54 总预算封的是 need_acquire 的无上界循环(一轮可触发完整 deploy,ready_timeout58 总预算封的是 need_acquire 的无上界循环(一轮可触发完整 deploy,ready_timeout
@@ -59,6 +63,8 @@ touch 不分桶(无容量信号,HGET 反查 scope 热路径加一跳,不做)。
59 LUA_ROUTE_PLACE 原子仲裁 → (action, pod_id):63 LUA_ROUTE_PLACE 原子仲裁 → (action, pod_id):
60 refresh/placed → 读 pod:info sse_url 返回(缺失=极端竞态被清,continue 重跑;64 refresh/placed → 读 pod:info sse_url 返回(缺失=极端竞态被清,continue 重跑;
61 refresh 分支有 info 存活守卫,下一轮惰性回收死绑定重新放置,不构成自旋)65 refresh 分支有 info 存活守卫,下一轮惰性回收死绑定重新放置,不构成自旋)
66+ rebind → 绑定已惰性回收(过期/Pod 注册消失)→ 目标切回 first-fit 结果
67+ 重试(曾见绑定的调用内不落放置——防向已禁用/删除 scope 重部署)
62 scope_full → 立即 raise ScopeFull(503, retry_after=1)——场景 F 快失败68 scope_full → 立即 raise ScopeFull(503, retry_after=1)——场景 F 快失败
63 (2026-09):不排队不订阅,Lua 闸门即唯一仲裁,被拒者毫秒级返回69 (2026-09):不排队不订阅,Lua 闸门即唯一仲裁,被拒者毫秒级返回
64 need_acquire → rm_facade.acquire(扩+1)→ state.register_pod → 重跑(新 Pod 必被 first-fit 选中) }70 need_acquire → rm_facade.acquire(扩+1)→ state.register_pod → 重跑(新 Pod 必被 first-fit 选中) }
@@ -95,7 +101,7 @@ touch 不分桶(无容量信号,HGET 反查 scope 热路径加一跳,不做)。
95 101 
96| 脚本 | 一句话职责 |102| 脚本 | 一句话职责 |
97|---|---|103|---|---|
98-| `LUA_ROUTE_PLACE` | route 原子核心:**残骸自卫(2026-09,同 EVICT:缺 scope_id/pod_id/expiry 的半成品哈希自清两处后落穿全新放置——nil 比较/nil 拼接是 Lua runtime error,该会话 route 永久 500)**→亲和续期(**前提 pod:info 存在**——注册已被清的绑定判死,惰性回收后走重新放置;否则 notify_pod_dead 窗口内新落的会话会无限自旋且每圈续期 expiry)→惰性回收旧绑定→scope 闸门(SCARD)→first-fit(接入序)→达 max_pods 则 scope_full / 否则 need_acquire→原子提交四处同写(复用时清 idle_notified) |104+| `LUA_ROUTE_PLACE` | route 原子核心:**残骸自卫(2026-09,同 EVICT:缺 scope_id/pod_id/expiry 的半成品哈希自清两处后落穿全新放置——nil 比较/nil 拼接是 Lua runtime error,该会话 route 永久 500)**→亲和续期(**前提 pod:info 存在**——注册已被清的绑定判死,惰性回收后走重新放置;否则 notify_pod_dead 窗口内新落的会话会无限自旋且每圈续期 expiry)→**其余(过期/Pod 注册消失/scope 不匹配竞态)惰性回收旧绑定后返回 `rebind`(2026-09-scope-affinity-hold:曾见绑定的调用内不落放置,handler 换 first-fit 结果重试;传入 scope 即要维持的绑定 scope 或新会话的 first-fit 结果——曾见绑定的调用直接按传入 scope 放置会把会话部署进已禁用/删除的 scope)**→scope 闸门(SCARD)→first-fit(接入序)→达 max_pods 则 scope_full / 否则 need_acquire→原子提交四处同写(复用时清 idle_notified) |
99| `LUA_EVICT` | session 移除**唯一原语**(四处同删;返回 scope/pod/remaining;幂等 noop;**残骸自卫**:哈希缺 scope/pod(外部直改键半成品)→ 自清两处返回 rubble,调用侧 WARNING——单坏键不得使到期 pass 崩溃循环) |105| `LUA_EVICT` | session 移除**唯一原语**(四处同删;返回 scope/pod/remaining;幂等 noop;**残骸自卫**:哈希缺 scope/pod(外部直改键半成品)→ 自清两处返回 rubble,调用侧 WARNING——单坏键不得使到期 pass 崩溃循环) |
100| `LUA_TOUCH` | 保活续期;**残骸自卫(2026-09,同 EVICT:缺 scope_id/pod_id/expiry 自清返回 False,不得 Lua runtime error)**;已过期当场惰性 evict;ttl 就地读 session HASH(不依赖 scope:config) |106| `LUA_TOUCH` | 保活续期;**残骸自卫(2026-09,同 EVICT:缺 scope_id/pod_id/expiry 自清返回 False,不得 Lua runtime error)**;已过期当场惰性 evict;ttl 就地读 session HASH(不依赖 scope:config) |
101| `LUA_SWEEP_IDLE_NOTIFY` | 空 Pod 判定(SCARD==0)+ 60s NX 去重 + ZREM 退出候选(堵 reclaim 窗口内 route 直选的竞态 A) |107| `LUA_SWEEP_IDLE_NOTIFY` | 空 Pod 判定(SCARD==0)+ 60s NX 去重 + ZREM 退出候选(堵 reclaim 窗口内 route 直选的竞态 A) |
@@ -106,9 +112,9 @@ touch 不分桶(无容量信号,HGET 反查 scope 热路径加一跳,不做)。
106 112 
107## config_store.py —— 配置层(scope 重构版)113## config_store.py —— 配置层(scope 重构版)
108 114 
109-**DB 表**(策略四列 2026-09 起与 wire 术语同名,identity 映射在 `_COLUMN_OF`;曾用 EE 兼容名,见文末 RENAME 义务):`service_config_template`(模板级:`namespace`/`node_name`/`fs_group`/`pod_name`/`sse_path`/`kubeconfig`/`ready_*` + 策略四列 `min_idle_pods`/`pod_concurrency`/`pod_ttl`/`scope_concurrency` + 三段式引用 `main_container_id`(string 100)/`sidecar_container_ids`(JSON)/`volumes`(JSON);**2026-09 起已 DROP 拆表前内联容器列**,见 `docs/feature/2026-09-drop-legacy-inline-template-columns.md`)、**`service_config_container`**(容器规格表,15 列:`container_id` unique ≤100、`name`/`image`/`image_pull_policy` 标量 + `ports`/`env`/`env_from`/`resources`/`volume_mounts`/`security_context`/`readiness_probe` 七个内部规范形 JSON 段落列;框架 init_table 自动建,无需手工 DDL)、`routing_scope`(`scope_id` unique / `match_index`(避 SQL 保留字 index) / `template_id` / `routing_rules` JSON / `enabled` bool 默认 true / `expires_at` datetime 可空)。表结构常量 `*_TABLE_DEF` 由 main 传给框架建表。旧 `routing_rule` 表已废弃(不再读写,老库残留无害)。**模板表三段式三列为后期新增:存量库须先手工 ALTER 再发版**(`ALTER TABLE service_config_template ADD COLUMN main_container_id VARCHAR(100) NULL; ADD COLUMN sidecar_container_ids JSON NULL; ADD COLUMN volumes JSON NULL;`,框架建表只 create_all 不补列)。**routing_scope 的 `enabled`/`expires_at` 同为后期新增:存量库须** `ALTER TABLE routing_scope ADD COLUMN expires_at DATETIME NULL; ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;`(方言类型按 MySQL/PG 调整)。**模板表策略四列 2026-09 改名(wire 术语统一):存量库须先手工 RENAME 再发版**(`ALTER TABLE service_config_template RENAME COLUMN min_idle_services TO min_idle_pods; RENAME COLUMN service_concurrency TO pod_concurrency; RENAME COLUMN service_ttl TO pod_ttl; RENAME COLUMN session_concurrency TO scope_concurrency;`,MySQL 8+/PG 均支持;见 `docs/feature/2026-09-template-table-runtime-terms.md`)。**legacy 内联容器列 DROP:存量库须先按 feature 篇 DROP COLUMN 再发版**(框架不 DROP)。115+**DB 表**(策略四列 2026-09 起与 wire 术语同名,identity 映射在 `_COLUMN_OF`;曾用 EE 兼容名,见文末 RENAME 义务):`service_config_template`(模板级:`namespace`/`node_name`/`fs_group`/`pod_name`/`sse_path`/`kubeconfig`/`ready_*` + 策略四列 `min_idle_pods`/`pod_concurrency`/`pod_ttl`/`scope_concurrency` + 三段式引用 `main_container_id`(string 100)/`sidecar_container_ids`(JSON)/`volumes`(JSON);**2026-09 起已 DROP 拆表前内联容器列**,见 `docs/feature/2026-09-drop-legacy-inline-template-columns.md`)、**`service_config_container`**(容器规格表,15 列:`container_id` unique ≤100、`name`/`image`/`image_pull_policy` 标量 + `ports`/`env`/`env_from`/`resources`/`volume_mounts`/`security_context`/`readiness_probe` 七个内部规范形 JSON 段落列;框架 init_table 自动建,无需手工 DDL)、`routing_scope`(`scope_id` unique / `match_index`(避 SQL 保留字 index) / `template_id` / `routing_rules` JSON / `expires_at` datetime 可空;`enabled` 列 2026-09 删除,生命周期=存在性+expires_at,存量库先发版后 DROP,见 `docs/feature/2026-09-drop-enabled-fields.md`)。表结构常量 `*_TABLE_DEF` 由 main 传给框架建表。旧 `routing_rule` 表已废弃(不再读写,老库残留无害)。**模板表三段式三列为后期新增:存量库须先手工 ALTER 再发版**(`ALTER TABLE service_config_template ADD COLUMN main_container_id VARCHAR(100) NULL; ADD COLUMN sidecar_container_ids JSON NULL; ADD COLUMN volumes JSON NULL;`,框架建表只 create_all 不补列)。**routing_scope 的 `expires_at` 为后期新增:存量库须** `ALTER TABLE routing_scope ADD COLUMN expires_at DATETIME NULL;`(方言类型按 MySQL/PG 调整);`enabled` 列 2026-09 起删除(先发版后 DROP,见 drop-enabled-fields)。**模板表策略四列 2026-09 改名(wire 术语统一):存量库须先手工 RENAME 再发版**(`ALTER TABLE service_config_template RENAME COLUMN min_idle_services TO min_idle_pods; RENAME COLUMN service_concurrency TO pod_concurrency; RENAME COLUMN service_ttl TO pod_ttl; RENAME COLUMN session_concurrency TO scope_concurrency;`,MySQL 8+/PG 均支持;见 `docs/feature/2026-09-template-table-runtime-terms.md`)。**legacy 内联容器列 DROP:存量库须先按 feature 篇 DROP COLUMN 再发版**(框架不 DROP)。
110 116 
111-**`namespace` 空串语义(2026-09,跟随 runtime ns)**:显式下发 `"namespace": ""` = 继承——AgentServer 落 runtime 自身 ns(k8s 层 falsy 兜底到 `default_namespace`,= `POD_NAMESPACE`(downward API),见 spec/service-core.md / resource-manager.md)。列 NOT NULL 允许空串,**省略仍落默认值 `"default"`**——表达继承须显式空串。deploy_ver 以空串字面量进指纹(runtime 搬家不触发日落,老 Pod 按 idle 自然回收);库与 wire 契约零改动。117+**`namespace` 空串语义(2026-09,跟随 runtime ns)**:显式下发 `"namespace": ""` = 继承——AgentServer 落 runtime 自身 ns(k8s 层 falsy 兜底到 `default_namespace`,= `config.own_namespace()`:`POD_NAMESPACE` env > SA 文件,见 spec/service-core.md / resource-manager.md)。列 NOT NULL 允许空串,**省略仍落默认值 `"default"`**——表达继承须显式空串。deploy_ver 以空串字面量进指纹(runtime 搬家不触发日落,老 Pod 按 idle 自然回收);库与 wire 契约零改动。
112 118 
113**行形态单轨(2026-09 统一规范形起)**(`template_from_row(row, containers)`):模板级行列 + 容器行引用 + volumes join → 统一 canonical 水合(**任一引用容器行缺失/容器段落校验失败 → WARNING + 整模板跳过**,绝不静默丢单个 sidecar——那会隐形改 deploy_ver;引用它的 scope 视为不命中落兜底);**无 `main_container_id` 的行不再水合**(→ WARNING + None,fail-closed;wire 已三段式独占)。写行(`row_from_template_split`)只写模板级列 + 引用列 + volumes(不再写已 DROP 的内联列死值)。wire 层仍用 `_LEGACY_INLINE_CONTAINER_KEYS` 拒 mixed(template 带内联容器键 → 400)。119**行形态单轨(2026-09 统一规范形起)**(`template_from_row(row, containers)`):模板级行列 + 容器行引用 + volumes join → 统一 canonical 水合(**任一引用容器行缺失/容器段落校验失败 → WARNING + 整模板跳过**,绝不静默丢单个 sidecar——那会隐形改 deploy_ver;引用它的 scope 视为不命中落兜底);**无 `main_container_id` 的行不再水合**(→ WARNING + None,fail-closed;wire 已三段式独占)。写行(`row_from_template_split`)只写模板级列 + 引用列 + volumes(不再写已 DROP 的内联列死值)。wire 层仍用 `_LEGACY_INLINE_CONTAINER_KEYS` 拒 mixed(template 带内联容器键 → 400)。
114 120 
@@ -126,7 +132,7 @@ touch 不分桶(无容量信号,HGET 反查 scope 热路径加一跳,不做)。
126- **卷 join(K8s spec.volumes 同构)**:模板级 `volumes`(每卷恰一源:hostPath/configMap/persistentVolumeClaim/nfs;卷名 DNS-1123 唯一)+ 容器 `volumeMounts` 按名引用;`fuse_mounts` 重建内部 fused 挂载(mounts.py 规范形,指纹承重)。源类型规则:悬挂引用/未挂载卷 → 400;`subPath` 仅 configMap;`readOnly` 缺省按内部规范(cm→**true**、hp/pvc/nfs→false)。**NFS 与 PVC 同构**:卷源(server/path)是模板级(pod 级)卷,主容器与 sidecar 一律按名引用挂载,条数/挂载点不限(2026-09 起废除「NFS 仅主容器单挂载」的三元组特化)。132- **卷 join(K8s spec.volumes 同构)**:模板级 `volumes`(每卷恰一源:hostPath/configMap/persistentVolumeClaim/nfs;卷名 DNS-1123 唯一)+ 容器 `volumeMounts` 按名引用;`fuse_mounts` 重建内部 fused 挂载(mounts.py 规范形,指纹承重)。源类型规则:悬挂引用/未挂载卷 → 400;`subPath` 仅 configMap;`readOnly` 缺省按内部规范(cm→**true**、hp/pvc/nfs→false)。**NFS 与 PVC 同构**:卷源(server/path)是模板级(pod 级)卷,主容器与 sidecar 一律按名引用挂载,条数/挂载点不限(2026-09 起废除「NFS 仅主容器单挂载」的三元组特化)。
127- 水合出口:`build_canonical`(内部规范形 + volumes join → containers.canonical 收口)×2 → `validate_pod_containers`——读路径(`_template_from_split_row`)与写路径(`template_from_split_payload`)共用 `_hydrate_containers` 单出口。**同值必同 deploy_ver**(`test_split_contract_deploy_ver_identical_to_inline`:三段式水合 vs 手构 canonical Template 逐字节相等,承重)。133- 水合出口:`build_canonical`(内部规范形 + volumes join → containers.canonical 收口)×2 → `validate_pod_containers`——读路径(`_template_from_split_row`)与写路径(`template_from_split_payload`)共用 `_hydrate_containers` 单出口。**同值必同 deploy_ver**(`test_split_contract_deploy_ver_identical_to_inline`:三段式水合 vs 手构 canonical Template 逐字节相等,承重)。
128 134 
129-**routing.py(纯函数)**:`routing_rules` 是**布尔表达式字符串**——条件 `field in|not in ('v1', 'v2')` 经 `and`/`or` 与括号任意组合;优先级 条件 > and > or;关键字大小写不敏感,字段名固定小写枚举(user_id/group_id/bot_id);值单引号串(`''` 加倍或 `\'`/`\\` 转义);空值列表 `()` → in 恒假、not_in 恒真;不支持一元 `not`;上限长度 8000、括号嵌套 32。**空 routing_rules(null/空串/纯空白)= 通配兜底**;遍历按 `(index ASC, scope_id ASC)` **first-fit**;引用模板缺失/禁用的 scope、以及 scope 自身 `enabled=False` / `expires_at` 已过期(墙钟判定,`null`=永不过期)的,跳过落下一个。通配告警只计生效中的空表达式 scope。解析器 = 词法(`_TOKEN_RE`)+ 递归下降(`_Parser`:or_expr → and_expr → primary),产物为表达式树(`MatchExpression` 叶 / `AndNode` / `OrNode`),存于 `RoutingScopeDef.rule`(与原始串 `expr` 成对,后者是 wire/DB/快照载体)。`SCOPE_ID_RE = ^[0-9A-Za-z._-]{1,128}$`(禁 `:`/`*`/空白——Redis 键与 `pods:registered` 切分依赖)。135+**routing.py(纯函数)**:`routing_rules` 是**布尔表达式字符串**——条件 `field in|not in ('v1', 'v2')` 经 `and`/`or` 与括号任意组合;优先级 条件 > and > or;关键字大小写不敏感,字段名固定小写枚举(user_id/group_id/bot_id);值单引号串(`''` 加倍或 `\'`/`\\` 转义);空值列表 `()` → in 恒假、not_in 恒真;不支持一元 `not`;上限长度 8000、括号嵌套 32。**空 routing_rules(null/空串/纯空白)= 通配兜底**;遍历按 `(index ASC, scope_id ASC)` **first-fit**;引用模板缺失(悬挂引用)的 scope、以及 `expires_at` 已过期(墙钟判定,`null`=永不过期)的,跳过落下一个(两级 enabled 已删 2026-09,生命周期=存在性+expires_at;残留 `enabled:false` 载荷防御性视为缺席)。通配告警只计生效中的空表达式 scope。解析器 = 词法(`_TOKEN_RE`)+ 递归下降(`_Parser`:or_expr → and_expr → primary),产物为表达式树(`MatchExpression` 叶 / `AndNode` / `OrNode`),存于 `RoutingScopeDef.rule`(与原始串 `expr` 成对,后者是 wire/DB/快照载体)。`SCOPE_ID_RE = ^[0-9A-Za-z._-]{1,128}$`(禁 `:`/`*`/空白——Redis 键与 `pods:registered` 切分依赖)。
130 136 
131**resolve(user_id, group_id, bot_id) → (scope_id, Template)**:读单键快照 `routing:snapshot`(1 GET;进程内按原文 memo 免重复解析)→ first-fit 匹配;快照缺失/损坏 → 从 DB 重建;无匹配 → `ConfigNotFound(503)`。137**resolve(user_id, group_id, bot_id) → (scope_id, Template)**:读单键快照 `routing:snapshot`(1 GET;进程内按原文 memo 免重复解析)→ first-fit 匹配;快照缺失/损坏 → 从 DB 重建;无匹配 → `ConfigNotFound(503)`。
132 138 
@@ -148,7 +154,7 @@ touch 不分桶(无容量信号,HGET 反查 scope 热路径加一跳,不做)。
148 坏值 Pod 永久 Pending 挂满 ready_timeout 才暴露;空串归一 None 同未设;154 坏值 Pod 永久 Pending 挂满 ready_timeout 才暴露;空串归一 None 同未设;
149 snake 双形态 node_name → 400,用 K8s 拼写 nodeName)155 snake 双形态 node_name → 400,用 K8s 拼写 nodeName)
150 scope 段同前(scope_id 字符集/index 拒 bool/引用不在本批模板集/routing_rules156 scope 段同前(scope_id 字符集/index 拒 bool/引用不在本批模板集/routing_rules
151- 表达式串语法/enabled 须 bool/expires_at ISO-8601 或 null/重复);缺通配 scope157+ 表达式串语法/expires_at ISO-8601 或 null/重复;残留 `enabled:false` 视为缺席);缺通配 scope
152 → 仅 WARNING 放行(响应 wildcard_present:false;通配只计生效中的空表达式)158 → 仅 WARNING 放行(响应 wildcard_present:false;通配只计生效中的空表达式)
153lock:config_sync 串行化(忙→409 CONFIG_SYNC_BUSY;基线 TTL 60 + **看门狗续期**:159lock:config_sync 串行化(忙→409 CONFIG_SYNC_BUSY;基线 TTL 60 + **看门狗续期**:
154 周期 TTL//3 经 SessionState.refresh_lock(Lua compare-and-EXPIRE,同 unlock 守卫160 周期 TTL//3 经 SessionState.refresh_lock(Lua compare-and-EXPIRE,同 unlock 守卫
@@ -158,7 +164,9 @@ lock:config_sync 串行化(忙→409 CONFIG_SYNC_BUSY;基线 TTL 60 + **看门
158 经 compare-and-del 误删他人锁);续期发现锁丢失 → ERROR 停止续期、本批跑完164 经 compare-and-del 误删他人锁);续期发现锁丢失 → ERROR 停止续期、本批跑完
159 (DB 单事务收敛);unlock 失败只记日志,不把成功变 500/不吞原异常)165 (DB 单事务收敛);unlock 失败只记日志,不把成功变 500/不吞原异常)
160→ 读 DB 旧态(containers + templates(双形态水合) + scopes)166→ 读 DB 旧态(containers + templates(双形态水合) + scopes)
161-→ diff:模板 changed_ids(_diff_class 沿用)/ 引用切换 ref_switched → affected167+→ diff:模板 changed_ids(_diff_class 沿用)/ 引用切换 ref_switched → affected /
168+ 路由性排除 routing_excluded(expr 变化 / 生效→失效即 expires_at 缩到已过,
169+ 2026-09-scope-affinity-hold;enabled 已删,关=删除走扩散③)
162→ 日落中间态检查(★先于写库,拒绝时零副作用;★按版本判定,**基准=当前生效170→ 日落中间态检查(★先于写库,拒绝时零副作用;★按版本判定,**基准=当前生效
163 版本**(2026-09-15 修正,原比新载荷版本——版本变更时当前代空闲 Pod 必然171 版本**(2026-09-15 修正,原比新载荷版本——版本变更时当前代空闲 Pod 必然
164 ≠ 新版被误判遗留,而它受 min_idle 底数保护永不回收 → 永久 409,cyz 实测):172 ≠ 新版被误判遗留,而它受 min_idle 底数保护永不回收 → 永久 409,cyz 实测):
@@ -175,16 +183,25 @@ lock:config_sync 串行化(忙→409 CONFIG_SYNC_BUSY;基线 TTL 60 + **看门
175 生效参数 INFO `snapshot template: id= sc= pc= min_idle= session_ttl= pod_ttl=183 生效参数 INFO `snapshot template: id= sc= pc= min_idle= session_ttl= pod_ttl=
176 max_pods=`(2026-09-08 观测增强)——「配置页 vs 运行时实际生效值」对账只看184 max_pods=`(2026-09-08 观测增强)——「配置页 vs 运行时实际生效值」对账只看
177 这行,不必从 max_followers 等间接证据反推)185 这行,不必从 max_followers 等间接证据反推)
186+→ **路由性排除日落(2026-09-scope-affinity-hold,#152)**:diff 出的
187+ routing_excluded(expr 变化 / 生效→失效即 expires_at 缩到已过;**不含 index 重排与引用切换**——
188+ 重排不排除命中,零打扰;引用切换走 ver 日落)逐 scope `_bump_or_warn`(gen
189+ bump)+ `_soft_remove_all_pods`(候选软摘,防 drain 期新会话 first-fit 落上
190+ 老代 Pod);**时序红线:bump 先于下方 eager 预热 push**(RM update_pool_config
191+ 凭 gen lag 戳排空纪元,反序则永远不戳)。失败 warn 不 raise——DB 已提交,
192+ 丢 bump 的 scope 退化为软界(会话自然结束后 pod_ttl 回收)
178→ eager 预热:每个**生效中** scope 推 push(sid, pool_config, deploy_subset)——必须带193→ eager 预热:每个**生效中** scope 推 push(sid, pool_config, deploy_subset)——必须带
179 pod_spec(RM 才落 pod_spec_json/deploy_ver;autoscale 无请求预热 min_idle 的依赖);194 pod_spec(RM 才落 pod_spec_json/deploy_ver;autoscale 无请求预热 min_idle 的依赖);
180- 禁用/过期 scope 推 min_idle=0 停预热(与被删同款)195+ 过期 scope 推 min_idle=0 停预热(与被删同款;enabled 已删,「关」=从载荷删除)
181→ 候选集版本收敛(声明式,非 one-shot):对**每个生效中** scope 把 deploy_ver ≠ 当前版本的196→ 候选集版本收敛(声明式,非 one-shot):对**每个生效中** scope 把 deploy_ver ≠ 当前版本的
182 Pod ZREM 出候选——不由 diff 驱动,写 DB 后中途失败/同载荷重试(diff==none)也每拍197 Pod ZREM 出候选——不由 diff 驱动,写 DB 后中途失败/同载荷重试(diff==none)也每拍
183 重算,旧版 Pod 不会无限期接新流量198 重算,旧版 Pod 不会无限期接新流量
184→ 删除处理:目标集 = **RM 已知 scope(known_rm_scopes 回调)∪ DB 旧 scope** − 本批;199→ 删除处理:目标集 = **RM 已知 scope(known_rm_scopes 回调)∪ DB 旧 scope** − 本批;
185- 推 push(sid, {**旧模板池参数, min_idle_pods:0}, None)——RM config 键是幻影预热的200+ **本批新删的 scope(s∈DB 旧集)先 `_bump_or_warn` 再推** push(sid,
186- 真源,只看 DB(删行后失忆)的话一次推送失败后 min_idle=0 永远补不上201+ {**旧模板池参数, min_idle_pods:0}, None)——同款日落排空(仅首删一次,rm_known
187-→ 响应 {ok, templates_synced/deleted, **containers_synced/deleted**, scopes_synced/deleted, affected_scopes, wildcard_present}202+ 里的陈年旧删每拍只重推不重复 bump);RM config 键是幻影预热的真源,只看 DB(删行
203+ 后失忆)的话一次推送失败后 min_idle=0 永远补不上
204+→ 响应 {ok, templates_synced/deleted, **containers_synced/deleted**, scopes_synced/deleted, affected_scopes, **routing_sunset(路由性排除日落集,2026-09)**, wildcard_present}
188```205```
189 206 
190幂等重放收敛(changed 空 → affected=[]);启动期 `main.start()` 调 `ensure_snapshot()` 无条件重建(消冷启动窗口);`Template.deploy_ver()` / RM `_deploy_ver()` 同一算法(`util.fingerprint` + `DEPLOY_VER_FIELDS`)——A 类过滤两端一致的前提。207幂等重放收敛(changed 空 → affected=[]);启动期 `main.start()` 调 `ensure_snapshot()` 无条件重建(消冷启动窗口);`Template.deploy_ver()` / RM `_deploy_ver()` 同一算法(`util.fingerprint` + `DEPLOY_VER_FIELDS`)——A 类过滤两端一致的前提。
@@ -203,7 +220,10 @@ lock:config_sync 串行化(忙→409 CONFIG_SYNC_BUSY;基线 TTL 60 + **看门
203 代次感知保证收敛,闸门不会永久 409;防连续 refresh 多代日落堆积蹲占220 代次感知保证收敛,闸门不会永久 409;防连续 refresh 多代日落堆积蹲占
204 max_pods——2026-09-11 wangchang 环境 9 分钟 4 连刷实录)221 max_pods——2026-09-11 wangchang 环境 9 分钟 4 连刷实录)
205 ① bump_generation(sid)(rm_facade → HINCRBY scope:config generation;严格,失败上抛)222 ① bump_generation(sid)(rm_facade → HINCRBY scope:config generation;严格,失败上抛)
206- ② _push_or_warn(sid, pool_config, deploy_subset)(值未变,确保 RM 缓存就绪;失败仅告警——良性)223+ ② _push_or_warn(sid, pool_config, deploy_subset)(值未变,确保 RM 缓存就绪;失败仅告警——良性)。
224+ **过期 scope(is_active 纯过期判定,时间感知)只日落不保温**:推
225+ {**pool, min_idle_pods:0} + 无 pod_spec——否则一次强制刷新就把已停池按
226+ 模板 min_idle 重新焐热(#154 同族半死态,2026-09-drop-enabled-fields 修)
207 ③ _soft_remove_all_pods(sid)(候选集全量 ZREM,不按版本过滤;严格)227 ③ _soft_remove_all_pods(sid)(候选集全量 ZREM,不按版本过滤;严格)
208→ 响应 {ok, scopes_refreshed, pods_sunset, generations}228→ 响应 {ok, scopes_refreshed, pods_sunset, generations}
209```229```
@@ -211,7 +231,8 @@ lock:config_sync 串行化(忙→409 CONFIG_SYNC_BUSY;基线 TTL 60 + **看门
211- **顺序红线 bump → ZREM**:"ZREM 而未 bump"会造出"被摘却仍是当前代次 warm"的搁浅态(min_idle 底数保护 → 永久蹲占 max_pods 且不重建);bump 在前的任何中途失败都收敛于"老 Pod 暂时继续接新流量",重试即收敛。231- **顺序红线 bump → ZREM**:"ZREM 而未 bump"会造出"被摘却仍是当前代次 warm"的搁浅态(min_idle 底数保护 → 永久蹲占 max_pods 且不重建);bump 在前的任何中途失败都收敛于"老 Pod 暂时继续接新流量",重试即收敛。
212- 不写 DB、不动路由快照;日落收敛/重建全复用既有后台任务(reclaim 代次感知回收 + autoscale 按缓存 pod_spec 重建,见 resource-manager spec)。232- 不写 DB、不动路由快照;日落收敛/重建全复用既有后台任务(reclaim 代次感知回收 + autoscale 按缓存 pod_spec 重建,见 resource-manager spec)。
213- `pods_sunset` 只计 SM 候选集摘除量(未入候选的 RM 暖 Pod 不计但同样被代次日落)。233- `pods_sunset` 只计 SM 候选集摘除量(未入候选的 RM 暖 Pod 不计但同样被代次日落)。
214-- **非幂等但收敛**(每次调用 = 一轮全量日落重建,成功后勿自动重试);config_sync 的日落中间态守卫基准=**当前生效版本**、不扩展看 generation——老代 Pod 版本与当前配置相等 → 对守卫不可见 → B 类/A 类下发均不因此 409(2026-09-15 修正;原「A 类照旧 409 到排空完成」在老代 Pod 受 min_idle 底数保护时永不放行=配置面永久 409)。A 类落库后由扩散②软摘 + reclaim 版本感知即刻回收老代 Pod。234+- **非幂等但收敛**(每次调用 = 一轮全量日落重建,成功后勿自动重试);config_sync 的日落中间态守卫基准=**当前生效版本**、不扩展看 generation——老代 Pod 版本与当前配置相等 → 对守卫不可见 → B 类/A 类下发均不因此 409(2026-09-15 修正;原「A 类照旧 409 到排空完成」在老代 Pod 受 min_idle 底数保护时永不放行=配置面永久 409)。A 类落库后由扩散②软摘 + reclaim **在排空窗口后**回收老代 Pod(2026-09-17 优雅排空,与 refresh 统一:窗口 = 首因下发 + session_ttl,期间已绑定会话继续服务;见 docs/feature/2026-09-sunset-drain-window.md)。
235+- **排空窗口对闸门的时长影响(2026-09-17)**:⓪ 的等待窗从"~一拍 reconcile+reclaim"拉长到 ≤ session_ttl + tick(有界:窗口截止后 reclaim 必收 → 放行)。两闸门判据维度不同——refresh 闸门看**代次**(对 A 类排空 Pod 失明)、sync 守卫看**版本**(对 refresh 排空 Pod 失明)——各自入口在自家排空窗口内 409,异类入口可放行(R3)。运维节奏须适配:攒完编辑再刷新。
215- 构造注入:`ConfigStore(..., bump_generation=rm_facade.bump_generation)`(`main._bind_modules`)。236- 构造注入:`ConfigStore(..., bump_generation=rm_facade.bump_generation)`(`main._bind_modules`)。
216 237 
217## sweeper.py —— 老化扫描238## sweeper.py —— 老化扫描
Mapplications/agent_runtime/scripts/verify_redis_cluster.py+52-1文件内容审核中,请稍后刷新重试
@@ -20,6 +20,27 @@ SERVICE_PREFIX = "/api/session" # 唯一 App 的 prefix(端口 8091)
20 20 
21logger = logging.getLogger("agent_runtime.config")21logger = logging.getLogger("agent_runtime.config")
22 22 
23+_SA_NS_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
24+ 
25+ 
26+def own_namespace() -> str:
27+ """default_namespace 解析链:POD_NAMESPACE env(显式覆盖/downward API)
28+ > in-cluster SA namespace 文件(Pod 必挂,自身 ns,零部署配置)> "default"。
29+ 
30+ 2026-09-17 cyz2 实录:镜像带"删 AGENT_RUNTIME_DEFAULT_NAMESPACE"新代码 +
31+ 旧部署模板无 POD_NAMESPACE 注入 → 空 ns 模板落字面 "default" → SA 越
32+ ns 建 Pod 403;SA 文件兜底使存量部署零改动自愈(env 仍可显式覆盖)。
33+ """
34+ ns = (os.getenv("POD_NAMESPACE") or "").strip()
35+ if ns:
36+ return ns
37+ try:
38+ with open(_SA_NS_FILE, encoding="utf-8") as f:
39+ ns = f.read().strip()
40+ except OSError:
41+ return "default"
42+ return ns or "default"
43+ 
23 44 
24def _env_float(name: str, default: float) -> float:45def _env_float(name: str, default: float) -> float:
25 """float 型 env(评估 LLM 超时用;场景 F 快失败时随 scope_full_timeout46 """float 型 env(评估 LLM 超时用;场景 F 快失败时随 scope_full_timeout
@@ -89,8 +110,8 @@ class AgentRuntimeConfig:
89 return cls(110 return cls(
90 mode=os.getenv("AGENT_RUNTIME_MODE", "server").strip().lower(),111 mode=os.getenv("AGENT_RUNTIME_MODE", "server").strip().lower(),
91 kubeconfig=os.getenv("AGENT_RUNTIME_KUBECONFIG") or None,112 kubeconfig=os.getenv("AGENT_RUNTIME_KUBECONFIG") or None,
92- # 解析链:POD_NAMESPACE(downward API 注入的自身 ns)> "default"113+ # 解析链见 own_namespace():POD_NAMESPACE env > SA 文件 > "default"
93- default_namespace=(os.getenv("POD_NAMESPACE") or "default"),114+ default_namespace=own_namespace(),
94 sweep_interval=_env_int("AGENT_RUNTIME_SWEEP_INTERVAL", 1),115 sweep_interval=_env_int("AGENT_RUNTIME_SWEEP_INTERVAL", 1),
95 autoscale_interval=_env_int("AGENT_RUNTIME_AUTOSCALE_INTERVAL", 1),116 autoscale_interval=_env_int("AGENT_RUNTIME_AUTOSCALE_INTERVAL", 1),
96 reclaim_interval=_env_int("AGENT_RUNTIME_RECLAIM_INTERVAL", 1),117 reclaim_interval=_env_int("AGENT_RUNTIME_RECLAIM_INTERVAL", 1),
Mapplications/agent_runtime/src/agent_runtime/evaluation/collector.py+4-4文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/src/agent_runtime/evaluation/evaluator.py+1-4文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/src/agent_runtime/evaluation/rules.py+2-12文件内容审核中,请稍后刷新重试
@@ -1,5 +1,5 @@
1# coding: utf-81# coding: utf-8
2-"""Resource Manager 的 6 个 Lua 脚本(所有编排态变更,原子)。2+"""Resource Manager 的 7 个 Lua 脚本(所有编排态变更,原子)。
3 3 
4约定同 SM:``ARGV[1]`` 恒为键前缀(``{resource_manager}:``,hash tag 使 cluster4约定同 SM:``ARGV[1]`` 恒为键前缀(``{resource_manager}:``,hash tag 使 cluster
5下全键域同槽);返回扁平字符串数组。5下全键域同槽);返回扁平字符串数组。
@@ -9,6 +9,7 @@
9- LUA_REGISTER deploy 成功登记(info / scope:pods / pods:all,清占位;热备入 idle;9- LUA_REGISTER deploy 成功登记(info / scope:pods / pods:all,清占位;热备入 idle;
10 generation 服务端烙印注册时刻 scope 当前代次)10 generation 服务端烙印注册时刻 scope 当前代次)
11- LUA_RELEASE idle_consider:转 idle 暖池 + 起 pod_ttl 计时(幂等)11- LUA_RELEASE idle_consider:转 idle 暖池 + 起 pod_ttl 计时(幂等)
12+- LUA_POP_IDLE 忙占用摘出 idle 暖池(follower 接管 leader 热备 Pod;幂等)
12- LUA_PURGE Pod 死亡 / reclaim 后清全部 RM key(幂等)13- LUA_PURGE Pod 死亡 / reclaim 后清全部 RM key(幂等)
13- LUA_DEPLOY_FOLLOWER_GATE deploy 锁输家的等待室原子准入(ZSET+deadline,14- LUA_DEPLOY_FOLLOWER_GATE deploy 锁输家的等待室原子准入(ZSET+deadline,
14 上限 pod_concurrency-1;先清过期成员再 ZADD 先行+超限自退——原子闸门15 上限 pod_concurrency-1;先清过期成员再 ZADD 先行+超限自退——原子闸门
@@ -60,7 +61,14 @@ end
60-- 2. 无匹配暖 Pod:判 max_pods(含 deploying 占位,防并发超配)61-- 2. 无匹配暖 Pod:判 max_pods(含 deploying 占位,防并发超配)
61local total = redis.call('ZCARD', pfx .. 'resource:scope:' .. scope .. ':pods')62local total = redis.call('ZCARD', pfx .. 'resource:scope:' .. scope .. ':pods')
62 + redis.call('ZCARD', dep_key)63 + redis.call('ZCARD', dep_key)
63-if total >= max_pods then64+-- 排空纪元活跃(sunset drain)→ 容量上限 +SURGE_MARGIN(暂写死 1,与
65+-- sweeper.DRAIN_SURGE_MARGIN 对齐):日落老 Pod 在 drain_until 前占槽不清,
66+-- 无余量则新代补位 Pod 无处部署(2026-09-17 优雅排空配套头寸)
67+local cap = max_pods
68+if redis.call('EXISTS', pfx .. 'resource:scope:' .. scope .. ':drain_until') == 1 then
69+ cap = cap + 1
70+end
71+if total >= cap then
64 return {'max_reached', '', ''}72 return {'max_reached', '', ''}
65end73end
66 74 
@@ -86,7 +94,12 @@ local dep_key = pfx .. 'resource:scope:' .. scope .. ':deploying'
86redis.call('ZREMRANGEBYSCORE', dep_key, '-inf', now)94redis.call('ZREMRANGEBYSCORE', dep_key, '-inf', now)
87local total = redis.call('ZCARD', pfx .. 'resource:scope:' .. scope .. ':pods')95local total = redis.call('ZCARD', pfx .. 'resource:scope:' .. scope .. ':pods')
88 + redis.call('ZCARD', dep_key)96 + redis.call('ZCARD', dep_key)
89-if total >= max_pods then97+-- 排空纪元活跃 → 上限 +SURGE_MARGIN(1),同 LUA_ACQUIRE(2026-09-17 优雅排空)
98+local cap = max_pods
99+if redis.call('EXISTS', pfx .. 'resource:scope:' .. scope .. ':drain_until') == 1 then
100+ cap = cap + 1
101+end
102+if total >= cap then
90 return {'max_reached'}103 return {'max_reached'}
91end104end
92redis.call('ZADD', dep_key, deadline, token)105redis.call('ZADD', dep_key, deadline, token)
@@ -159,6 +172,22 @@ end
159return {'true'}172return {'true'}
160"""173"""
161 174 
175+# Argv: prefix, pod_id, scope_id
176+LUA_POP_IDLE = r"""
177+local pfx = ARGV[1]
178+local pod = ARGV[2]
179+local scope = ARGV[3]
180+ 
181+-- follower 接管 leader 新 Pod 的忙记账(与 LUA_ACQUIRE reuse 分支同款):
182+-- 摘出 idle 池 + 清 idle_since。幂等:非 idle 成员(请求驱动 leader 的
183+-- idle_flag=False 注册 / 已被并发 acquire 弹出)两指令均为 no-op。不摘则
184+-- autoscale 的 warm 底数把忙 Pod 数成热备,min_idle 永不重建(2026-09-17
185+-- wangchang follower_reuse 泄漏实录:池卡 1 忙 Pod 无补位)。
186+redis.call('SREM', pfx .. 'resource:scope:' .. scope .. ':idle', pod)
187+redis.call('DEL', pfx .. 'resource:pod:' .. pod .. ':idle_since')
188+return {'ok'}
189+"""
190+ 
162# Argv: prefix, pod_id191# Argv: prefix, pod_id
163LUA_PURGE = r"""192LUA_PURGE = r"""
164local pfx = ARGV[1]193local pfx = ARGV[1]
@@ -35,6 +35,9 @@ FOLLOWER_WAIT_MARGIN = 10 # follower 等待上界 = ready_timeout + 此余
35NO_CONFIG_MAX_LOOPS = 5 # acquire 内 no_config 重跑上限(真异常态有界,防热自旋)35NO_CONFIG_MAX_LOOPS = 5 # acquire 内 no_config 重跑上限(真异常态有界,防热自旋)
36NO_CONFIG_BACKOFF_SEC = 0.2 # no_config 重跑退避(正常首见建配置后 1 次即过)36NO_CONFIG_BACKOFF_SEC = 0.2 # no_config 重跑退避(正常首见建配置后 1 次即过)
37FOLLOWER_PROGRESS_LOG_SEC = 5 # follower 轮询进度 INFO 行间隔(限频)37FOLLOWER_PROGRESS_LOG_SEC = 5 # follower 轮询进度 INFO 行间隔(限频)
38+DRAIN_SLACK_S = 60 # 排空纪元键 TTL 的崩溃兜底余量(盖住 reconcile 30s
39+ # + reclaim 1s tick + 选主抖动;正常终止是 reclaim
40+ # 收完主动 DEL,TTL 只防进程死透后 surge 悬挂)
38 41 
39 42 
40def _deploy_ver(pod_spec: dict[str, Any]) -> str:43def _deploy_ver(pod_spec: dict[str, Any]) -> str:
@@ -94,6 +97,7 @@ class ResourceOrchestrator:
94 "min_idle_pods": int(pool_config.get("min_idle_pods", 0)),97 "min_idle_pods": int(pool_config.get("min_idle_pods", 0)),
95 "max_pods": int(pool_config.get("max_pods", 1)),98 "max_pods": int(pool_config.get("max_pods", 1)),
96 "pod_ttl": int(pool_config.get("pod_ttl", 300)),99 "pod_ttl": int(pool_config.get("pod_ttl", 300)),
100+ "session_ttl": int(pool_config.get("session_ttl", 600)),
97 "pod_concurrency": int(pool_config.get("pod_concurrency", 1)),101 "pod_concurrency": int(pool_config.get("pod_concurrency", 1)),
98 "deploy_ver": deploy_ver,102 "deploy_ver": deploy_ver,
99 "pod_spec_json": json.dumps(pod_spec),103 "pod_spec_json": json.dumps(pod_spec),
@@ -241,6 +245,13 @@ class ResourceOrchestrator:
241 for pod in new_pods:245 for pod in new_pods:
242 info = await self.state.pod_info(pod)246 info = await self.state.pod_info(pod)
243 if info.get("pod_sse_url"):247 if info.get("pod_sse_url"):
248+ # 忙记账对齐 LUA_ACQUIRE 的 reuse 分支:leader 以
249+ # idle_flag=True 注册的热备 Pod 被接管即转忙,须摘出
250+ # idle 池——否则 autoscale 的 warm 底数把忙 Pod 数成
251+ # 热备,min_idle 永不重建(2026-09-17 wangchang 实录:
252+ # follower_reuse 泄漏 → 池卡 1 忙 Pod,久无补位)。
253+ # 幂等:请求驱动 leader(idle_flag=False)注册时为 no-op。
254+ await self.state.pop_idle(pod, scope_id)
244 logger.info(255 logger.info(
245 "acquire follower reuses leader pod: scope=%s "256 "acquire follower reuses leader pod: scope=%s "
246 "pod=%s follower=%s", scope_id, pod, request_id)257 "pod=%s follower=%s", scope_id, pod, request_id)
@@ -382,21 +393,43 @@ class ResourceOrchestrator:
382 pool_config: dict[str, Any],393 pool_config: dict[str, Any],
383 pod_spec: dict[str, Any] | None = None,394 pod_spec: dict[str, Any] | None = None,
384 ) -> dict[str, bool]:395 ) -> dict[str, bool]:
385- """config_sync 主动刷新(场景 M):HSET 覆盖(幂等),立即生效。396+ """config_sync/refresh 主动刷新(场景 M):HSET 覆盖(幂等),立即生效。
386 397 
387 A 类变更附带 pod_spec:同时刷新 deploy_ver / pod_spec_json →398 A 类变更附带 pod_spec:同时刷新 deploy_ver / pod_spec_json →
388 autoscale 补位的新暖 Pod 用新 deploy 字段。mapping 永不含 generation399 autoscale 补位的新暖 Pod 用新 deploy 字段。mapping 永不含 generation
389 ——代次只经 bump_generation 单调递增,config_sync 推送不重置。400 ——代次只经 bump_generation 单调递增,config_sync 推送不重置。
401+ session_ttl 进 config 供排空 deadline 计算与后续读点。
390 """402 """
391 mapping: dict[str, Any] = {403 mapping: dict[str, Any] = {
392 "min_idle_pods": int(pool_config.get("min_idle_pods", 0)),404 "min_idle_pods": int(pool_config.get("min_idle_pods", 0)),
393 "max_pods": int(pool_config.get("max_pods", 1)),405 "max_pods": int(pool_config.get("max_pods", 1)),
394 "pod_ttl": int(pool_config.get("pod_ttl", 300)),406 "pod_ttl": int(pool_config.get("pod_ttl", 300)),
407+ "session_ttl": int(pool_config.get("session_ttl", 600)),
395 }408 }
396 if pod_spec is not None:409 if pod_spec is not None:
397 mapping["deploy_ver"] = _deploy_ver(pod_spec)410 mapping["deploy_ver"] = _deploy_ver(pod_spec)
398 mapping["pod_spec_json"] = json.dumps(pod_spec)411 mapping["pod_spec_json"] = json.dumps(pod_spec)
399 await self.state.save_scope_config(scope_id, mapping)412 await self.state.save_scope_config(scope_id, mapping)
413+ # ---- 日落排空纪元戳记(2026-09-17 优雅排空):
414+ # 本次推送后仍有版本(A 类 sync)或代次(refresh / 路由性排除已先 bump)
415+ # 落后的注册 Pod
416+ # → 它们已被/将被软摘出候选集,戳 drain_until = now + session_ttl;
417+ # 期间容量上限 +SURGE_MARGIN(reclaim 对应延后回收)。**纪元已活跃则不
418+ # 重戳**——首因下发(refresh 的 bump / A 类的换版)定窗口,后续不改变
419+ # ver/gen 的下发(B 类纯池参数)不延长;排空中的 refresh 本就被闸门 409
420+ # 拦住到不了这里。无落后 Pod 不戳(空 scope/B 类不得激活 surge)。
421+ # 检出读注册表不读候选集,与软摘除的先后顺序无关。
422+ if (await self.state.drain_until(scope_id) is None
423+ and await self.state.has_lagged_pods(scope_id)):
424+ ttl = int(pool_config.get("session_ttl", 600))
425+ deadline = now_ts() + ttl
426+ await self.state.set_drain_until(
427+ scope_id, deadline, ttl + DRAIN_SLACK_S)
428+ logger.info(
429+ "sunset drain epoch: scope=%s drain_until=%d window=%ds "
430+ "(lagged pods keep serving sessions until deadline)",
431+ scope_id, deadline, ttl,
432+ )
400 logger.info("update_pool_config: scope=%s fields=%s", scope_id, sorted(mapping))433 logger.info("update_pool_config: scope=%s fields=%s", scope_id, sorted(mapping))
401 return {"updated": True}434 return {"updated": True}
402 435 
@@ -404,7 +437,8 @@ class ResourceOrchestrator:
404 """config_refresh 的代次日落(facade 出口):scope 代次 +1,返回新代次。437 """config_refresh 的代次日落(facade 出口):scope 代次 +1,返回新代次。
405 438 
406 效果:现有 Pod 的 generation 全部落后于 config → LUA_ACQUIRE 不再复用、439 效果:现有 Pod 的 generation 全部落后于 config → LUA_ACQUIRE 不再复用、
407- _current_version_idle 判 stale(reclaim 按 pod_ttl 回收、autoscale 重建)。440+ _current_version_idle 判 stale(reclaim 按排空窗口延后回收、autoscale
441+ 重建;排空纪元由随后的 update_pool_config 推送戳记)。
408 """442 """
409 generation = await self.state.bump_generation(scope_id)443 generation = await self.state.bump_generation(scope_id)
410 logger.info("bump_generation: scope=%s generation=%d", scope_id, generation)444 logger.info("bump_generation: scope=%s generation=%d", scope_id, generation)
@@ -76,6 +76,17 @@ class RMKeys:
76 """76 """
77 return f"{self.prefix}:resource:scope:{scope_id}:deploy_followers"77 return f"{self.prefix}:resource:scope:{scope_id}:deploy_followers"
78 78 
79+ def scope_drain_until(self, scope_id: str) -> str:
80+ """STRING: 日落排空截止时间戳(秒)。存在 ⟺ 排空纪元活跃。
81+ 
82+ update_pool_config 检出版本/代次落后 Pod 时戳记(值 = now +
83+ session_ttl,EX 同值 + DRAIN_SLACK_S 崩溃兜底);期间 LUA_ACQUIRE /
84+ LUA_PLACEHOLDER 的容量上限 +SURGE_MARGIN、reclaim 对 stale Pod 延后
85+ 到截止后才回收;全部 stale Pod 收完后由 reclaim 主动 DEL(TTL 只是
86+ 崩溃兜底,非正常终止路径)。
87+ """
88+ return f"{self.prefix}:resource:scope:{scope_id}:drain_until"
89+ 
79 # ---- Pod 级90 # ---- Pod 级
80 def pod_info(self, pod_id: str) -> str:91 def pod_info(self, pod_id: str) -> str:
81 """HASH: scope_id / pod_sse_url / pod_ip / namespace / phase / created_ts /92 """HASH: scope_id / pod_sse_url / pod_ip / namespace / phase / created_ts /
@@ -204,6 +215,14 @@ class ResourceState:
204 ret = await self.eval(lua.LUA_RELEASE, pod_id, scope_id, now)215 ret = await self.eval(lua.LUA_RELEASE, pod_id, scope_id, now)
205 return bool(ret and ret[0] == "true")216 return bool(ret and ret[0] == "true")
206 217 
218+ async def pop_idle(self, pod_id: str, scope_id: str) -> None:
219+ """LUA_POP_IDLE:忙占用摘出 idle 暖池 + 清 idle_since。幂等。
220+ 
221+ follower 接管 leader 热备 Pod 时用——与 LUA_ACQUIRE 的 reuse 分支
222+ 记账对齐(release 的逆操作,不起计时而是清计时)。
223+ """
224+ await self.eval(lua.LUA_POP_IDLE, pod_id, scope_id)
225+ 
207 async def purge(self, pod_id: str) -> str:226 async def purge(self, pod_id: str) -> str:
208 """LUA_PURGE:清该 Pod 全部 RM key。返回其 scope_id(空串=本就不存在)。"""227 """LUA_PURGE:清该 Pod 全部 RM key。返回其 scope_id(空串=本就不存在)。"""
209 ret = await self.eval(lua.LUA_PURGE, pod_id)228 ret = await self.eval(lua.LUA_PURGE, pod_id)
@@ -269,6 +288,36 @@ class ResourceState:
269 self.k.scope_config(scope_id), "generation", 1288 self.k.scope_config(scope_id), "generation", 1
270 ))289 ))
271 290 
291+ async def drain_until(self, scope_id: str) -> int | None:
292+ """排空截止时间戳;None = 无活跃排空纪元(或已过期,兜底语义 = 立即回收)。"""
293+ raw = await self.redis.get(self.k.scope_drain_until(scope_id))
294+ return to_int(raw) if raw is not None else None
295+ 
296+ async def set_drain_until(self, scope_id: str, deadline: int, ttl: int) -> None:
297+ """戳记排空纪元(EX=ttl 崩溃兜底;正常终止走 clear_drain_until)。"""
298+ await self.redis.set(self.k.scope_drain_until(scope_id), deadline, ex=ttl)
299+ 
300+ async def clear_drain_until(self, scope_id: str) -> None:
301+ await self.redis.delete(self.k.scope_drain_until(scope_id))
302+ 
303+ async def has_lagged_pods(self, scope_id: str) -> bool:
304+ """注册表中是否存在版本或代次落后于 scope 当前配置的 Pod。
305+ 
306+ 排空纪元的统一判据(update_pool_config 戳记 / reclaim 收尾终止共用):
307+ 读注册表(scope:pods + pod:info),不读 SM 候选集——与软摘除的先后
308+ 顺序无关。幽灵成员(info 已清)不构成落后。
309+ """
310+ cfg = await self.load_scope_config(scope_id)
311+ ver = cfg.get("deploy_ver") or ""
312+ gen = cfg.get("generation") or ""
313+ for pod_id in await self.pod_ids(scope_id):
314+ info = await self.pod_info(pod_id)
315+ if info and (
316+ (info.get("deploy_ver") or "") != ver
317+ or (info.get("generation") or "") != gen):
318+ return True
319+ return False
320+ 
272 async def has_scope_config(self, scope_id: str) -> bool:321 async def has_scope_config(self, scope_id: str) -> bool:
273 return to_int(322 return to_int(
274 await self.redis.hlen(self.k.scope_config(scope_id))323 await self.redis.hlen(self.k.scope_config(scope_id))
@@ -34,6 +34,9 @@ logger = logging.getLogger("agent_runtime.resource_manager")
34HEALTH_FAIL_THRESHOLD = 2 # 连续失败判半死(防瞬时抖动误杀,场景 N)34HEALTH_FAIL_THRESHOLD = 2 # 连续失败判半死(防瞬时抖动误杀,场景 N)
35WATCH_LOCK_TTL = 15 # 10s tick + 余量35WATCH_LOCK_TTL = 15 # 10s tick + 余量
36RECONCILE_LOCK_TTL = 60 # 30s tick + 余量36RECONCILE_LOCK_TTL = 60 # 30s tick + 余量
37+DRAIN_SURGE_MARGIN = 1 # 排空纪元活跃时的容量上限加成(暂写死,2026-09-17
38+ # 优雅排空决策;与 lua_scripts 两闸门内的 +1 对齐——
39+ # 改动须三处同步)
37 40 
38 41 
39class ResourceSweeper:42class ResourceSweeper:
@@ -132,9 +135,15 @@ class ResourceSweeper:
132 f"warm={len(warm)} min_idle={min_idle} idle_total={len(idle)}"135 f"warm={len(warm)} min_idle={min_idle} idle_total={len(idle)}"
133 )136 )
134 total = await self.state.pod_count(scope_id) + await self.state.deploying_count(scope_id)137 total = await self.state.pod_count(scope_id) + await self.state.deploying_count(scope_id)
135- if total >= max_pods:138+ # 排空纪元活跃 → 容量上限 +SURGE_MARGIN:日落老 Pod 在 drain_until 前
139+ # 占槽不清,无余量则新代补位 Pod 无处部署(与 Lua 两闸门同步加成)
140+ cap = max_pods
141+ if await self.state.drain_until(scope_id) is not None:
142+ cap += DRAIN_SURGE_MARGIN
143+ if total >= cap:
136 return "skip_max", (144 return "skip_max", (
137 f"warm={len(warm)} min_idle={min_idle} total={total} max_pods={max_pods}"145 f"warm={len(warm)} min_idle={min_idle} total={total} max_pods={max_pods}"
146+ + (f" cap={cap}(drain)" if cap != max_pods else "")
138 )147 )
139 # 热备 deploy 用缓存的 pod_spec(config_sync A 类变更后为新值)。148 # 热备 deploy 用缓存的 pod_spec(config_sync A 类变更后为新值)。
140 # shape 探测:缺 main_container = 统一规范形前的旧扁平缓存(渲染会149 # shape 探测:缺 main_container = 统一规范形前的旧扁平缓存(渲染会
@@ -237,36 +246,62 @@ class ResourceSweeper:
237 aged = {p: await self.state.idle_since(p) for p in idle}246 aged = {p: await self.state.idle_since(p) for p in idle}
238 ranked_warm = sorted(warm, key=lambda p: aged[p])247 ranked_warm = sorted(warm, key=lambda p: aged[p])
239 excess = stale + ranked_warm[min_idle:]248 excess = stale + ranked_warm[min_idle:]
249+ # 排空纪元(优雅排空,2026-09-17):日落 Pod(ver/gen 落后,
250+ # 已被软摘出候选集)在 drain_until 前不回收——期间已绑定
251+ # 会话继续在其上服务(route 亲和/touch 只查 pod:info 不查
252+ # 候选集),窗口给到 session_ttl 让停活跃的会话自然结束;
253+ # 键缺失(过期/旧版本遗留)兜底为立即可收,闸门收敛不破坏
254+ drain = await self.state.drain_until(scope_id)
240 # 待回收留痕:excess 成员变化才 INFO(30s tick,稳定成员255 # 待回收留痕:excess 成员变化才 INFO(30s tick,稳定成员
241 # 不重复);到龄即回收的走 _reclaim_pod 自有日志256 # 不重复);到龄即回收的走 _reclaim_pod 自有日志
242 sig = tuple(excess)257 sig = tuple(excess)
243 if self._reclaim_state.get(scope_id) != sig:258 if self._reclaim_state.get(scope_id) != sig:
244 ages = [now - aged[p] for p in excess if aged.get(p)]259 ages = [now - aged[p] for p in excess if aged.get(p)]
245 overflow = ranked_warm[min_idle:]260 overflow = ranked_warm[min_idle:]
246- due = len(stale) + sum(261+ due = sum(
262+ 1 for p in stale
263+ if drain is None or now >= drain
264+ ) + sum(
247 1 for p in overflow265 1 for p in overflow
248 if aged.get(p) and now - aged[p] >= pod_ttl266 if aged.get(p) and now - aged[p] >= pod_ttl
249 )267 )
268+ drain_ctx = (
269+ f" drain_wait={max(0, drain - now)}s"
270+ if drain is not None and any(
271+ p in stale_set for p in excess) else "")
250 logger.info(272 logger.info(
251 "reclaim pending: scope=%s excess=%d pending=%d "273 "reclaim pending: scope=%s excess=%d pending=%d "
252- "oldest_age=%ds pod_ttl=%ds pods=%s",274+ "oldest_age=%ds pod_ttl=%ds%s pods=%s",
253 scope_id, len(excess), len(excess) - due,275 scope_id, len(excess), len(excess) - due,
254 max(ages) if ages else 0,276 max(ages) if ages else 0,
255- pod_ttl, ",".join(excess),277+ pod_ttl, drain_ctx, ",".join(excess),
256 )278 )
257 self._reclaim_state[scope_id] = sig279 self._reclaim_state[scope_id] = sig
258- # 两级回收:stale(ver/gen 落后)免老化即刻回收——acquire 的280+ # 两级回收:stale(ver/gen 落后)排空到龄回收(drain 缺失
259- # want_ver+generation 过滤已判死刑,零复用价值,蹲满剩余281+ # 兜底立即——acquire 的 want_ver+generation 过滤已判死刑,
260- # pod_ttl 只白占 max_pods 槽位并拖长 config_sync/refresh 日落282+ # 零复用价值,蹲着只白占 max_pods 槽位并拖长 config_sync/
261- # 闸门的等待(含扩散后仍带会话的 Pod:会话被硬切重放置,283+ # refresh 日落闸门的等待);当前版本超额保留 pod_ttl 老化,
262- # 2026-09-15 决策接受);当前版本超额保留 pod_ttl 老化,抗284+ # 抗「马上会被复用」的抖动(真镜像冷启动 p50≈12s)。
263- # 「马上会被复用」的抖动(真镜像冷启动 p50≈12s)。285+ reclaimed_stale = 0
264 for pod_id in excess:286 for pod_id in excess:
265- if pod_id in stale_set or (287+ if (pod_id in stale_set and (
288+ drain is None or now >= drain)) or (
289+ pod_id not in stale_set and
266 aged.get(pod_id)290 aged.get(pod_id)
267 and now - aged[pod_id] >= pod_ttl):291 and now - aged[pod_id] >= pod_ttl):
268 await self._reclaim_pod(pod_id, scope_id)292 await self._reclaim_pod(pod_id, scope_id)
269 reclaimed += 1293 reclaimed += 1
294+ if pod_id in stale_set:
295+ reclaimed_stale += 1
296+ # 排空收尾:本拍收过 stale 且注册表已无任何落后 Pod →
297+ # 纪元结束,DEL drain_until 精确终止 surge(不留 TTL 悬挂
298+ # 的超额窗口);仍有落后 Pod(含未释放的带会话者)则续期等待
299+ if reclaimed_stale and not await self.state.has_lagged_pods(
300+ scope_id):
301+ await self.state.clear_drain_until(scope_id)
302+ logger.info(
303+ "sunset drain epoch done: scope=%s (lagged pods all "
304+ "reclaimed, surge released)", scope_id)
270 except Exception: # noqa: BLE001 - per-scope 隔离,下拍重试305 except Exception: # noqa: BLE001 - per-scope 隔离,下拍重试
271 logger.exception("reclaim scope failed: scope=%s", scope_id)306 logger.exception("reclaim scope failed: scope=%s", scope_id)
272 finally:307 finally:
@@ -110,7 +110,8 @@ SERVICE_CONFIG_TEMPLATE_TABLE_DEF = TableDefinition(
110 ColumnDefinition("pod_ttl", "integer", nullable=False, default=300),110 ColumnDefinition("pod_ttl", "integer", nullable=False, default=300),
111 ColumnDefinition("scope_concurrency", "integer", nullable=False, default=3),111 ColumnDefinition("scope_concurrency", "integer", nullable=False, default=3),
112 ColumnDefinition("session_ttl", "integer", nullable=False, default=60),112 ColumnDefinition("session_ttl", "integer", nullable=False, default=60),
113- ColumnDefinition("enabled", "boolean", nullable=False, default=True),113+ # enabled 列 2026-09 删除(生命周期=存在性;存量库先发版后 DROP,
114+ # 见 docs/feature/2026-09-drop-enabled-fields.md)
114 ColumnDefinition("data", "json", nullable=True),115 ColumnDefinition("data", "json", nullable=True),
115 ColumnDefinition("created_at", "datetime", nullable=False),116 ColumnDefinition("created_at", "datetime", nullable=False),
116 ColumnDefinition("updated_at", "datetime", nullable=False),117 ColumnDefinition("updated_at", "datetime", nullable=False),
@@ -129,7 +130,8 @@ ROUTING_SCOPE_TABLE_DEF = TableDefinition(
129 # 结构化规则原样落库(wire 格式;空列表/NULL = 通配 scope)130 # 结构化规则原样落库(wire 格式;空列表/NULL = 通配 scope)
130 ColumnDefinition("routing_rules", "json", nullable=True),131 ColumnDefinition("routing_rules", "json", nullable=True),
131 ColumnDefinition("expires_at", "datetime", nullable=True),132 ColumnDefinition("expires_at", "datetime", nullable=True),
132- ColumnDefinition("enabled", "boolean", nullable=False, default=True),133+ # enabled 列 2026-09 删除(生命周期=存在性+expires_at;存量库先发版
134+ # 后 DROP,见 docs/feature/2026-09-drop-enabled-fields.md)
133 ColumnDefinition("data", "json", nullable=True),135 ColumnDefinition("data", "json", nullable=True),
134 ColumnDefinition("created_at", "datetime", nullable=False),136 ColumnDefinition("created_at", "datetime", nullable=False),
135 ColumnDefinition("updated_at", "datetime", nullable=False),137 ColumnDefinition("updated_at", "datetime", nullable=False),
@@ -156,7 +158,6 @@ _COLUMN_OF: dict[str, str] = {
156 "pod_ttl": "pod_ttl",158 "pod_ttl": "pod_ttl",
157 "scope_concurrency": "scope_concurrency",159 "scope_concurrency": "scope_concurrency",
158 "session_ttl": "session_ttl",160 "session_ttl": "session_ttl",
159- "enabled": "enabled",
160 "data": "data",161 "data": "data",
161}162}
162 163 
@@ -182,9 +183,10 @@ _LEGACY_INLINE_CONTAINER_KEYS = frozenset({
182# 模板级字段(留在模板表;容器级由容器表水合为统一 canonical,见183# 模板级字段(留在模板表;容器级由容器表水合为统一 canonical,见
183# container_spec.build_canonical)。三段式契约的 template dict 只认这些键 +184# container_spec.build_canonical)。三段式契约的 template dict 只认这些键 +
184# main_container_id/sidecar_container_ids/volumes;与 legacy 内联容器键185# main_container_id/sidecar_container_ids/volumes;与 legacy 内联容器键
185-# 并存 = mixed 形态 → 400。186+# 并存 = mixed 形态 → 400。enabled 已删(2026-09-drop-enabled-fields):残留
187+# 键由 _parse_payload 防御性剔除(真值 false 视为缺席)或静默忽略(真值 true)。
186TEMPLATE_LEVEL_FIELDS: tuple[str, ...] = (188TEMPLATE_LEVEL_FIELDS: tuple[str, ...] = (
187- "template_id", "template_name", "description", "enabled", "data",189+ "template_id", "template_name", "description", "data",
188 "namespace", "node_name", "fs_group", "pod_name", "sse_path",190 "namespace", "node_name", "fs_group", "pod_name", "sse_path",
189 "ready_timeout", "ready_poll_interval", "kubeconfig",191 "ready_timeout", "ready_poll_interval", "kubeconfig",
190 "scope_concurrency", "pod_concurrency", "session_ttl", "pod_ttl",192 "scope_concurrency", "pod_concurrency", "session_ttl", "pod_ttl",
@@ -199,8 +201,8 @@ _TEMPLATE_WIRE_ALIASES = {"node_name": "nodeName",
199 201 
200 202 
201def _scope_row(scope: RoutingScopeDef) -> dict[str, Any]:203def _scope_row(scope: RoutingScopeDef) -> dict[str, Any]:
202- """scope → DB 行(时间戳由 _upsert_row_tx 统一处理;enabled/expires_at204+ """scope → DB 行(时间戳由 _upsert_row_tx 统一处理;expires_at 为
203- 为 2026-09 routing-scope 扩展字段,随三段式载荷透传)。"""205+ routing-scope 扩展字段随三段式载荷透传;enabled 已删,生命周期=存在性)。"""
204 data = getattr(scope, "data", None)206 data = getattr(scope, "data", None)
205 return {207 return {
206 "jiuwenclaw_id": TENANT_ID,208 "jiuwenclaw_id": TENANT_ID,
@@ -209,7 +211,6 @@ def _scope_row(scope: RoutingScopeDef) -> dict[str, Any]:
209 "template_id": scope.template_id,211 "template_id": scope.template_id,
210 # 原始表达式串(空 = 通配);JSON 列存标量字符串212 # 原始表达式串(空 = 通配);JSON 列存标量字符串
211 "routing_rules": scope.expr,213 "routing_rules": scope.expr,
212- "enabled": bool(scope.enabled),
213 "expires_at": scope.expires_at,214 "expires_at": scope.expires_at,
214 "data": data if isinstance(data, dict) else None,215 "data": data if isinstance(data, dict) else None,
215 }216 }
@@ -236,7 +237,7 @@ def _hydrate_containers(
236def template_from_row(row: Any,237def template_from_row(row: Any,
237 containers: dict[str, dict[str, Any]] | None = None,238 containers: dict[str, dict[str, Any]] | None = None,
238 ) -> Template | None:239 ) -> Template | None:
239- """DB 行 → Template 业务对象(未命中 enabled=False 的模板仍返回,调用方判定)。240+ """DB 行 → Template 业务对象。
240 241 
241 单轨水合(2026-09 起):模板级行列 + 容器引用 + volumes join → 统一242 单轨水合(2026-09 起):模板级行列 + 容器引用 + volumes join → 统一
242 canonical。任一引用容器行缺失/水合校验失败 → WARNING + None(fail-closed,243 canonical。任一引用容器行缺失/水合校验失败 → WARNING + None(fail-closed,
@@ -430,8 +431,6 @@ def _scope_from_row(row: Any) -> RoutingScopeDef | None:
430 f"scope_id must not contain '{{' or '}}': "431 f"scope_id must not contain '{{' or '}}': "
431 f"{getattr(row, 'scope_id', None)!r}"432 f"{getattr(row, 'scope_id', None)!r}"
432 )433 )
433- enabled_raw = getattr(row, "enabled", True)
434- enabled = True if enabled_raw is None else bool(enabled_raw)
435 data_raw = getattr(row, "data", None)434 data_raw = getattr(row, "data", None)
436 return RoutingScopeDef(435 return RoutingScopeDef(
437 scope_id=s(getattr(row, "scope_id")),436 scope_id=s(getattr(row, "scope_id")),
@@ -439,7 +438,6 @@ def _scope_from_row(row: Any) -> RoutingScopeDef | None:
439 template_id=s(getattr(row, "template_id")),438 template_id=s(getattr(row, "template_id")),
440 expr=expr_raw,439 expr=expr_raw,
441 rule=parse_routing_expr(expr_raw) if expr_raw.strip() else None,440 rule=parse_routing_expr(expr_raw) if expr_raw.strip() else None,
442- enabled=enabled,
443 expires_at=parse_datetime(getattr(row, "expires_at", None)),441 expires_at=parse_datetime(getattr(row, "expires_at", None)),
444 data=data_raw if isinstance(data_raw, dict) else None,442 data=data_raw if isinstance(data_raw, dict) else None,
445 )443 )
@@ -579,7 +577,6 @@ class ConfigStore:
579 continue577 continue
580 out.append({578 out.append({
581 "template_id": t.template_id,579 "template_id": t.template_id,
582- "enabled": bool(t.enabled),
583 "agent_image": t.agent_image,580 "agent_image": t.agent_image,
584 "namespace": t.namespace,581 "namespace": t.namespace,
585 "scope_concurrency": t.scope_concurrency,582 "scope_concurrency": t.scope_concurrency,
@@ -626,6 +623,22 @@ class ConfigStore:
626 )623 )
627 return scope.scope_id, snapshot.templates[scope.template_id]624 return scope.scope_id, snapshot.templates[scope.template_id]
628 625 
626+ async def scope_template(
627+ self, scope_id: str
628+ ) -> tuple[RoutingScopeDef | None, Template | None]:
629+ """快照查指定 scope:(scope 定义, 其模板)。
630+ 
631+ scope 亲和保持(2026-09-scope-affinity-hold)专供 route 持有分支取
632+ 绑定 scope 的 session_ttl;scope 已删 / 模板缺失时对应项为 None,
633+ 调用方回退绑定哈希里留存的 session_ttl。非匹配语义:不判
634+ expires_at(持有不重算规则,日落界由 RM 排空窗口管)。
635+ """
636+ snapshot = await self._load_snapshot()
637+ for scope in snapshot.scopes:
638+ if scope.scope_id == scope_id:
639+ return scope, snapshot.templates.get(scope.template_id)
640+ return None, None
641+ 
629 # -------------------------------------------------------------- 路由快照642 # -------------------------------------------------------------- 路由快照
630 643 
631 async def _load_snapshot(self) -> RoutingSnapshot:644 async def _load_snapshot(self) -> RoutingSnapshot:
@@ -788,6 +801,12 @@ class ConfigStore:
788 containers_raw = payload.get("containers")801 containers_raw = payload.get("containers")
789 802 
790 # ---- 阶段 1:浅扫模板(收引用),不构造803 # ---- 阶段 1:浅扫模板(收引用),不构造
804+ # 残留防御(2026-09-drop-enabled-fields):enabled 真值为 false 的模板
805+ # 视为缺席——「关」的正确表达是从载荷删除,静默忽略会让存量禁用配置
806+ # 升级后重开准入。引用照收(其容器不因剔除变成未引用),仅阶段 3 跳过
807+ # 构造:引用它的 scope 得到直指问题的 400「引用不在本批模板集」。
808+ # 真值 true 的残留键在构造层随未知键忽略。
809+ dropped_templates: set[str] = set()
791 main_refs: dict[str, str] = {} # tid → main_container_id810 main_refs: dict[str, str] = {} # tid → main_container_id
792 sidecar_refs: dict[str, list[str]] = {}811 sidecar_refs: dict[str, list[str]] = {}
793 for item in templates_raw:812 for item in templates_raw:
@@ -796,6 +815,8 @@ class ConfigStore:
796 tid = str(item.get("template_id") or "")815 tid = str(item.get("template_id") or "")
797 if not tid:816 if not tid:
798 raise InvalidParams("template item missing template_id")817 raise InvalidParams("template item missing template_id")
818+ if item.get("enabled") is False:
819+ dropped_templates.add(tid)
799 main_cid = item.get("main_container_id")820 main_cid = item.get("main_container_id")
800 if not isinstance(main_cid, str) or not main_cid.strip():821 if not isinstance(main_cid, str) or not main_cid.strip():
801 raise InvalidParams(822 raise InvalidParams(
@@ -804,6 +825,12 @@ class ConfigStore:
804 )825 )
805 main_refs[tid] = main_cid826 main_refs[tid] = main_cid
806 sidecar_refs[tid] = list(item.get("sidecar_container_ids") or [])827 sidecar_refs[tid] = list(item.get("sidecar_container_ids") or [])
828+ if dropped_templates:
829+ logger.warning(
830+ "config_sync: templates with enabled=false treated as absent "
831+ "(field removed 2026-09, disable = remove from payload): %s",
832+ sorted(dropped_templates),
833+ )
807 834 
808 # ---- 阶段 2:容器逐项按角色校验(角色 = 引用位置;未引用/双角色 → 400)835 # ---- 阶段 2:容器逐项按角色校验(角色 = 引用位置;未引用/双角色 → 400)
809 main_ids, sidecar_ids = set(main_refs.values()), set().union(836 main_ids, sidecar_ids = set(main_refs.values()), set().union(
@@ -841,10 +868,12 @@ class ConfigStore:
841 f"{missing}"868 f"{missing}"
842 )869 )
843 870 
844- # ---- 阶段 3:构造模板871+ # ---- 阶段 3:构造模板(enabled=false 剔除项不构造 → 引用它的 scope 400)
845 templates_in: dict[str, TemplateSync] = {}872 templates_in: dict[str, TemplateSync] = {}
846 for item in templates_raw:873 for item in templates_raw:
847 tid = str(item.get("template_id") or "")874 tid = str(item.get("template_id") or "")
875+ if tid in dropped_templates:
876+ continue
848 if tid in templates_in:877 if tid in templates_in:
849 raise InvalidParams(f"duplicate template_id {tid!r}")878 raise InvalidParams(f"duplicate template_id {tid!r}")
850 template, volumes = template_from_split_payload(879 template, volumes = template_from_split_payload(
@@ -857,11 +886,23 @@ class ConfigStore:
857 )886 )
858 887 
859 scopes_in: dict[str, RoutingScopeDef] = {}888 scopes_in: dict[str, RoutingScopeDef] = {}
889+ dropped_scopes: list[str] = []
860 for item in scopes_raw:890 for item in scopes_raw:
861 scope = parse_scope(item, set(templates_in))891 scope = parse_scope(item, set(templates_in))
892+ if scope is None:
893+ # 残留防御(2026-09-drop-enabled-fields):enabled=false 的 scope
894+ # 视为缺席剔除 = 发送方删除该 scope → 扩散③ 优雅日落
895+ dropped_scopes.append(str(item.get("scope_id") or "?"))
896+ continue
862 if scope.scope_id in scopes_in:897 if scope.scope_id in scopes_in:
863 raise InvalidParams(f"duplicate scope_id {scope.scope_id!r}")898 raise InvalidParams(f"duplicate scope_id {scope.scope_id!r}")
864 scopes_in[scope.scope_id] = scope899 scopes_in[scope.scope_id] = scope
900+ if dropped_scopes:
901+ logger.warning(
902+ "config_sync: scopes with enabled=false treated as absent "
903+ "(field removed 2026-09, disable = remove from payload): %s",
904+ dropped_scopes,
905+ )
865 906 
866 wildcard = has_wildcard_scope(scopes_in.values())907 wildcard = has_wildcard_scope(scopes_in.values())
867 if not wildcard:908 if not wildcard:
@@ -888,7 +929,7 @@ class ConfigStore:
888 }929 }
889 old_scopes = {scope.scope_id: scope for scope in await self.list_scopes()}930 old_scopes = {scope.scope_id: scope for scope in await self.list_scopes()}
890 931 
891- # ---- diff:模板变更集 / 引用切换932+ # ---- diff:模板变更集 / 引用切换 / 路由性排除
892 changed_ids = {933 changed_ids = {
893 tid for tid, new in templates_in.items()934 tid for tid, new in templates_in.items()
894 if tid not in old_templates935 if tid not in old_templates
@@ -898,6 +939,17 @@ class ConfigStore:
898 sid for sid, scope in scopes_in.items()939 sid for sid, scope in scopes_in.items()
899 if sid in old_scopes and old_scopes[sid].template_id != scope.template_id940 if sid in old_scopes and old_scopes[sid].template_id != scope.template_id
900 }941 }
942+ # 路由性排除(scope 亲和保持,#152):expr 变化(失权)或 生效→失效
943+ # (expires_at 缩到已过;enabled 已删,关 = 从载荷删除走扩散③)。
944+ # **不含** index 重排——重排不排除命中,存量会话零打扰;也不含模板
945+ # 引用切换——走既有 ver 日落。
946+ routing_excluded = {
947+ sid for sid, scope in scopes_in.items()
948+ if sid in old_scopes and (
949+ old_scopes[sid].expr != scope.expr
950+ or (old_scopes[sid].is_active() and not scope.is_active())
951+ )
952+ }
901 affected = sorted(953 affected = sorted(
902 {sid for sid, scope in scopes_in.items() if scope.template_id in changed_ids}954 {sid for sid, scope in scopes_in.items() if scope.template_id in changed_ids}
903 | ref_switched955 | ref_switched
@@ -966,6 +1018,23 @@ class ConfigStore:
966 # ---- 重建快照(DB 读回最终态 → 原子 SET;B 类立即生效由此完成)1018 # ---- 重建快照(DB 读回最终态 → 原子 SET;B 类立即生效由此完成)
967 await self.rebuild_snapshot()1019 await self.rebuild_snapshot()
968 1020 
1021+ # ---- 路由性排除日落(scope 亲和保持,#152):expr 变化 / 生效→失效的
1022+ # scope 先 bump gen——随后的扩散① push 凭 gen lag 截排空纪元
1023+ # (日落 + session_ttl,与 #151 排空窗口同哲学):存量会话窗口内
1024+ # 继续原池(route 亲和保持),Pod 过窗回收后旧会话 rebind 落新
1025+ # 规则;新会话立即走新规则。bump 后顺带软摘候选集,防 drain 期
1026+ # 新会话 first-fit 落上老代 Pod。**时序红线:bump 必须先于扩散①
1027+ # push**(纪元戳记依赖 gen lag 已形成)。失败 warn 不 raise——DB
1028+ # 已提交,丢 bump 的 scope 退化为软界(会话自然结束后 pod_ttl
1029+ # 回收),同扩散① push_or_warn 先例。
1030+ for sid in sorted(routing_excluded):
1031+ await self._bump_or_warn(sid)
1032+ try:
1033+ await self._soft_remove_all_pods(sid)
1034+ except Exception: # noqa: BLE001 - 软摘失败不阻断扩散,下拍可补
1035+ logger.exception(
1036+ "routing sunset soft-remove failed: scope=%s", sid)
1037+ 
969 # ---- 扩散①:eager 预热——每个生效中的 scope 推池参数 + pod_spec;1038 # ---- 扩散①:eager 预热——每个生效中的 scope 推池参数 + pod_spec;
970 # 禁用/过期 scope 推 min_idle=0 停预热(与被删 scope 同款自然排空)。1039 # 禁用/过期 scope 推 min_idle=0 停预热(与被删 scope 同款自然排空)。
971 # 带 pod_spec 时 RM 才会落 pod_spec_json/deploy_ver,autoscale 才能1040 # 带 pod_spec 时 RM 才会落 pod_spec_json/deploy_ver,autoscale 才能
@@ -994,7 +1063,8 @@ class ConfigStore:
994 "new_ver=%s", sid, removed, new_ver,1063 "new_ver=%s", sid, removed, new_ver,
995 )1064 )
996 1065 
997- # ---- 扩散③:被删 scope → 推 min_idle=0(停预热自然排空;存量会话到期止)。1066+ # ---- 扩散③:被删 scope → bump 日落 + 推 min_idle=0(停预热;存量会话
1067+ # 排空窗口内继续原池,过窗回收后 rebind 落新规则)。
998 # 目标集 = RM 已知 scope ∪ DB 旧 scope − 本批 payload:RM config 键1068 # 目标集 = RM 已知 scope ∪ DB 旧 scope − 本批 payload:RM config 键
999 # 是幻影预热的真源,DB old_scopes 删行后即失忆——只看 DB 的话,1069 # 是幻影预热的真源,DB old_scopes 删行后即失忆——只看 DB 的话,
1000 # 一次推送失败(滚动重启中断)后该 scope 的 min_idle=0 永远补不上。1070 # 一次推送失败(滚动重启中断)后该 scope 的 min_idle=0 永远补不上。
@@ -1005,11 +1075,18 @@ class ConfigStore:
1005 except Exception: # noqa: BLE001 - 枚举失败退回 DB 视图1075 except Exception: # noqa: BLE001 - 枚举失败退回 DB 视图
1006 logger.exception("known_rm_scopes failed, falling back to db diff")1076 logger.exception("known_rm_scopes failed, falling back to db diff")
1007 for sid in sorted((rm_known | set(old_scopes)) - set(scopes_in)):1077 for sid in sorted((rm_known | set(old_scopes)) - set(scopes_in)):
1078+ # 本批新删的 scope = 最强排除:bump 日落后下方 push 凭 gen lag 戳
1079+ # 排空纪元(仅首删一次;rm_known 里的陈年旧删每拍只重推
1080+ # min_idle=0,不重复 bump)。存量会话窗口内继续原池(亲和保持),
1081+ # 过窗回收后 rebind 落新规则。
1082+ if sid in old_scopes:
1083+ await self._bump_or_warn(sid)
1008 old_tpl = old_templates.get(old_scopes[sid].template_id) if sid in old_scopes else None1084 old_tpl = old_templates.get(old_scopes[sid].template_id) if sid in old_scopes else None
1009 pool = (1085 pool = (
1010 {**old_tpl.pool_config(), "min_idle_pods": 0}1086 {**old_tpl.pool_config(), "min_idle_pods": 0}
1011 if old_tpl is not None1087 if old_tpl is not None
1012- else {"min_idle_pods": 0, "max_pods": 1, "pod_ttl": 300}1088+ else {"min_idle_pods": 0, "max_pods": 1, "pod_ttl": 300,
1089+ "session_ttl": 60}
1013 )1090 )
1014 await self._push_or_warn(sid, pool, None)1091 await self._push_or_warn(sid, pool, None)
1015 1092 
@@ -1022,6 +1099,10 @@ class ConfigStore:
1022 "scopes_synced": len(scopes_in),1099 "scopes_synced": len(scopes_in),
1023 "scopes_deleted": len(set(old_scopes) - set(scopes_in)),1100 "scopes_deleted": len(set(old_scopes) - set(scopes_in)),
1024 "affected_scopes": affected,1101 "affected_scopes": affected,
1102+ # 路由性排除日落集(expr 变化 / 生效→失效 / 本批删除):已 bump gen,
1103+ # 存量会话经 RM 排空窗口有界回收(scope 亲和保持,#152)
1104+ "routing_sunset": sorted(
1105+ routing_excluded | (set(old_scopes) - set(scopes_in))),
1025 "wildcard_present": parsed.wildcard,1106 "wildcard_present": parsed.wildcard,
1026 }1107 }
1027 1108 
@@ -1043,6 +1124,22 @@ class ConfigStore:
1043 scope_id, sorted(pool),1124 scope_id, sorted(pool),
1044 )1125 )
1045 1126 
1127+ async def _bump_or_warn(self, scope_id: str) -> None:
1128+ """路由性排除变更的 gen bump(diff 驱动);失败仅告警不中止。
1129+ 
1130+ DB 已提交,raise 只会留下更大的半同步面;丢 bump 的 scope 退化为软界
1131+ ——存量会话自然结束后 Pod 转 idle、按 pod_ttl 回收(同扩散③自然排空)。
1132+ """
1133+ if self._bump_generation is None:
1134+ return
1135+ try:
1136+ await self._bump_generation(scope_id)
1137+ except Exception: # noqa: BLE001
1138+ logger.exception(
1139+ "routing sunset bump failed (scope=%s) -- old sessions "
1140+ "degrade to natural-drain bound", scope_id,
1141+ )
1142+ 
1046 async def _config_refresh_locked(self) -> dict[str, Any]:1143 async def _config_refresh_locked(self) -> dict[str, Any]:
1047 """锁内的强制刷新编排:前置日落中间态检查 → 逐 scope bump → push → 全量软摘除。1144 """锁内的强制刷新编排:前置日落中间态检查 → 逐 scope bump → push → 全量软摘除。
1048 1145 
@@ -1077,8 +1174,10 @@ class ConfigStore:
1077 # 回收(在集=合法服务中),闸门等它=等会话生命周期(600s 会话1174 # 回收(在集=合法服务中),闸门等它=等会话生命周期(600s 会话
1078 # 15s 连刷 409 连坐 2min+ 实录)。放行语义与 config_sync 闸门跳过1175 # 15s 连刷 409 连坐 2min+ 实录)。放行语义与 config_sync 闸门跳过
1079 # 在集 Pod 一致;且自愈闭环:本次 refresh 的全量软摘除把它 ZREM1176 # 在集 Pod 一致;且自愈闭环:本次 refresh 的全量软摘除把它 ZREM
1080- # 出集 → reconcile ≤30s release 入 idle → reclaim stale 免老化1177+ # 出集 → reconcile ≤30s release 入 idle → reclaim 在排空窗口
1081- # 即刻回收(会话硬切重放置,2026-09-15 决策接受)。1178+ # (drain_until = 日落 + session_ttl,2026-09-17 优雅排空,此前为
1179+ # 免老化即刻硬切)后回收,闸门等待窗相应从 ~分钟内拉长到
1180+ # ≤ session_ttl + tick;见 docs/feature/2026-09-sunset-drain-window.md。
1082 if self._sunset_pending is not None:1181 if self._sunset_pending is not None:
1083 for scope in scopes:1182 for scope in scopes:
1084 pending = await self._sunset_pending(scope.scope_id)1183 pending = await self._sunset_pending(scope.scope_id)
@@ -1097,11 +1196,20 @@ class ConfigStore:
1097 # ① 代次日落(严格:失败上抛中止——该 scope 尚未摘除,重试收敛)1196 # ① 代次日落(严格:失败上抛中止——该 scope 尚未摘除,重试收敛)
1098 generations[scope.scope_id] = await self._bump_generation(scope.scope_id)1197 generations[scope.scope_id] = await self._bump_generation(scope.scope_id)
1099 # ② 重推池参数 + pod_spec(值未变,确保 RM 缓存/预热就绪;失败仅告警:1198 # ② 重推池参数 + pod_spec(值未变,确保 RM 缓存/预热就绪;失败仅告警:
1100- # 刷新不改配置,scope:config 旧值与欲写值相同,良性)1199+ # 刷新不改配置,scope:config 旧值与欲写值相同,良性)。
1200+ # **过期 scope 只日落不保温**(is_active 纯过期判定,时间感知):
1201+ # 推 min_idle=0 + 无 pod_spec——否则一次强制刷新就把已停池按
1202+ # 模板 min_idle 重新焐热(#154 同族半死态)
1101 template = templates[scope.template_id]1203 template = templates[scope.template_id]
1102- await self._push_or_warn(1204+ if not scope.is_active():
1103- scope.scope_id, template.pool_config(), template.deploy_subset()1205+ await self._push_or_warn(
1104- )1206+ scope.scope_id,
1207+ {**template.pool_config(), "min_idle_pods": 0}, None,
1208+ )
1209+ else:
1210+ await self._push_or_warn(
1211+ scope.scope_id, template.pool_config(), template.deploy_subset()
1212+ )
1105 # ③ 候选集全量软摘除(严格:失败上抛;从此刻起老 Pod 不接新会话)1213 # ③ 候选集全量软摘除(严格:失败上抛;从此刻起老 Pod 不接新会话)
1106 removed = await self._soft_remove_all_pods(scope.scope_id)1214 removed = await self._soft_remove_all_pods(scope.scope_id)
1107 pods_sunset += len(removed)1215 pods_sunset += len(removed)
Mapplications/agent_runtime/src/agent_runtime/session_manager/lua_scripts.py+9-3文件内容审核中,请稍后刷新重试
@@ -61,10 +61,10 @@ class Template:
61 sidecars: list[dict[str, Any]] | None = None61 sidecars: list[dict[str, Any]] | None = None
62 # deploy 凭证(B 类例外:只影响新 deploy,不日落)62 # deploy 凭证(B 类例外:只影响新 deploy,不日落)
63 kubeconfig: str | None = None63 kubeconfig: str | None = None
64- # 元信息64+ # 元信息(enabled 已删:模板生命周期 = 存在性,禁用 = 从载荷删除,
65+ # 见 2026-09-drop-enabled-fields;残留载荷键在解析层防御性剔除)
65 template_name: str = ""66 template_name: str = ""
66 description: str = ""67 description: str = ""
67- enabled: bool = True
68 data: dict[str, Any] = field(default_factory=dict)68 data: dict[str, Any] = field(default_factory=dict)
69 69 
70 def __post_init__(self) -> None:70 def __post_init__(self) -> None:
@@ -124,10 +124,14 @@ class Template:
124 124 
125 pod_concurrency 供 RM 的 deploy follower 等待室推导上限(pc-1)——125 pod_concurrency 供 RM 的 deploy follower 等待室推导上限(pc-1)——
126 不参与 max_pods 判定(per-Pod 容量闸门仍在 SM 侧,红线不变)。126 不参与 max_pods 判定(per-Pod 容量闸门仍在 SM 侧,红线不变)。
127+ session_ttl 供 RM 日落排空窗口计 deadline(= 日落时刻 + session_ttl,
128+ 2026-09-17 优雅排空)——SM 侧 session 过期仍走自己的 session_expiry
129+ ZSET,两处用途互不依赖。
127 """130 """
128 return {131 return {
129 "min_idle_pods": self.min_idle_pods,132 "min_idle_pods": self.min_idle_pods,
130 "max_pods": self.max_pods,133 "max_pods": self.max_pods,
131 "pod_ttl": self.pod_ttl,134 "pod_ttl": self.pod_ttl,
132 "pod_concurrency": self.pod_concurrency,135 "pod_concurrency": self.pod_concurrency,
136+ "session_ttl": self.session_ttl,
133 }137 }
Mapplications/agent_runtime/src/agent_runtime/session_manager/orchestrator.py+52-13文件内容审核中,请稍后刷新重试
@@ -10,8 +10,9 @@
10 ``in`` → 值 ∈ values;``not_in`` → 值 ∉ values;空 values ():in 恒假、not_in 恒真;10 ``in`` → 值 ∈ values;``not_in`` → 值 ∉ values;空 values ():in 恒假、not_in 恒真;
11- scope 命中 = 空 routing_rules(null/空串/纯空白 → 通配兜底)或表达式为真;11- scope 命中 = 空 routing_rules(null/空串/纯空白 → 通配兜底)或表达式为真;
12- 遍历:scopes 按 (index ASC, scope_id ASC) 排序,first-fit 首个命中即止;12- 遍历:scopes 按 (index ASC, scope_id ASC) 排序,first-fit 首个命中即止;
13-- 引用模板缺失/禁用的 scope 视为不命中,继续落下一个(防御,正常不该发生);13+- 引用模板缺失的 scope 视为不命中,继续落下一个(防御,正常不该发生);
14-- scope 自身 ``enabled=False`` 或 ``expires_at`` 已过期视为不命中,继续落下一个;14+- scope 自身 ``expires_at`` 已过期视为不命中,继续落下一个
15+ (生命周期 = 存在性 + expires_at,布尔开关已删,见 2026-09-drop-enabled-fields);
15- 无匹配 → ConfigNotFound(503)。16- 无匹配 → ConfigNotFound(503)。
16 17 
17解析失败(未知字段/裸 `not`/悬空括号/未引号值……)在 config_sync 下发校验时18解析失败(未知字段/裸 `not`/悬空括号/未引号值……)在 config_sync 下发校验时
@@ -90,7 +91,9 @@ class RoutingScopeDef:
90 91 
91 ``expr`` 是 wire/DB/快照的存储载体(原始字符串,空 = 通配);92 ``expr`` 是 wire/DB/快照的存储载体(原始字符串,空 = 通配);
92 ``rule`` 是它的解析产物(通配时 None)——二者由构造方(解析入口)保证一致。93 ``rule`` 是它的解析产物(通配时 None)——二者由构造方(解析入口)保证一致。
93- ``enabled`` / ``expires_at`` 控制生效:禁用或过期的 scope 不参与匹配与预热。94+ ``expires_at`` 控制生效:过期的 scope 不参与匹配与预热。生命周期 =
95+ **存在性 + expires_at**(2026-09-drop-enabled-fields):「关」的自然表达
96+ 是从载荷删除该 scope(扩散③ 优雅排空),布尔开关已删。
94 """97 """
95 98 
96 scope_id: str99 scope_id: str
@@ -98,14 +101,11 @@ class RoutingScopeDef:
98 template_id: str101 template_id: str
99 expr: str102 expr: str
100 rule: BoolNode | None103 rule: BoolNode | None
101- enabled: bool = True
102 expires_at: datetime | None = None104 expires_at: datetime | None = None
103 data: dict[str, Any] | None = None105 data: dict[str, Any] | None = None
104 106 
105 def is_active(self, now: datetime | None = None) -> bool:107 def is_active(self, now: datetime | None = None) -> bool:
106- """enabled 且未过期才生效(expires_at=None = 永不过期)。"""108+ """未过期才生效(expires_at=None = 永不过期)。"""
107- if not self.enabled:
108- return False
109 if self.expires_at is None:109 if self.expires_at is None:
110 return True110 return True
111 current = as_utc_naive(now) if now is not None else utc_now()111 current = as_utc_naive(now) if now is not None else utc_now()
@@ -129,7 +129,6 @@ class RoutingScopeDef:
129 "index": self.index,129 "index": self.index,
130 "template_id": self.template_id,130 "template_id": self.template_id,
131 "routing_rules": self.expr,131 "routing_rules": self.expr,
132- "enabled": self.enabled,
133 "expires_at": (132 "expires_at": (
134 self.expires_at.isoformat() if self.expires_at is not None else None133 self.expires_at.isoformat() if self.expires_at is not None else None
135 ),134 ),
@@ -332,8 +331,14 @@ def parse_routing_expr(text: str) -> BoolNode:
332 return _Parser(text).parse()331 return _Parser(text).parse()
333 332 
334 333 
335-def parse_scope(payload: Any, known_template_ids: set[str]) -> RoutingScopeDef:334+def parse_scope(payload: Any, known_template_ids: set[str]) -> RoutingScopeDef | None:
336- """wire scope → RoutingScopeDef;template 引用必须在本批模板集内。"""335+ """wire scope → RoutingScopeDef;template 引用必须在本批模板集内。
336+ 
337+ 残留防御(2026-09-drop-enabled-fields):载荷带 ``enabled`` 真值为 false
338+ → 视为缺席返回 None(调用方剔除 + 告警)——等价于发送方删除该 scope,
339+ 不静默忽略(那会让存量禁用配置升级后重开准入)。``enabled=true`` 或
340+ 其他真值 → 原样忽略该键。字段本体已删,禁用 = 从载荷删除。
341+ """
337 if not isinstance(payload, dict):342 if not isinstance(payload, dict):
338 raise InvalidParams(f"scope item must be an object, got {payload!r}")343 raise InvalidParams(f"scope item must be an object, got {payload!r}")
339 scope_id = str(payload.get("scope_id") or "")344 scope_id = str(payload.get("scope_id") or "")
@@ -367,11 +372,8 @@ def parse_scope(payload: Any, known_template_ids: set[str]) -> RoutingScopeDef:
367 rule = parse_routing_expr(expr_raw) if expr_raw.strip() else None372 rule = parse_routing_expr(expr_raw) if expr_raw.strip() else None
368 except InvalidParams as exc:373 except InvalidParams as exc:
369 raise InvalidParams(f"scope {scope_id!r}: {exc}") from exc374 raise InvalidParams(f"scope {scope_id!r}: {exc}") from exc
370- enabled = payload.get("enabled", True)375+ if payload.get("enabled") is False:
371- if not isinstance(enabled, bool):376+ return None
372- raise InvalidParams(
373- f"scope {scope_id!r} enabled must be a boolean, got {enabled!r}"
374- )
375 try:377 try:
376 expires_at = parse_datetime(378 expires_at = parse_datetime(
377 payload.get("expires_at"), field=f"scope {scope_id!r}.expires_at"379 payload.get("expires_at"), field=f"scope {scope_id!r}.expires_at"
@@ -390,7 +392,6 @@ def parse_scope(payload: Any, known_template_ids: set[str]) -> RoutingScopeDef:
390 template_id=template_id,392 template_id=template_id,
391 expr=expr_raw,393 expr=expr_raw,
392 rule=rule,394 rule=rule,
393- enabled=enabled,
394 expires_at=expires_at,395 expires_at=expires_at,
395 data=data_raw if isinstance(data_raw, dict) else None,396 data=data_raw if isinstance(data_raw, dict) else None,
396 )397 )
@@ -411,12 +412,12 @@ def match_scope(
411 *,412 *,
412 now: datetime | None = None,413 now: datetime | None = None,
413) -> RoutingScopeDef | None:414) -> RoutingScopeDef | None:
414- """按 (index, scope_id) 序 first-fit;禁用/过期 scope 与模板缺失/禁用跳过。"""415+ """按 (index, scope_id) 序 first-fit;过期 scope 与模板缺失(悬挂引用)跳过。"""
415 for scope in snapshot.scopes:416 for scope in snapshot.scopes:
416 if not scope.is_active(now):417 if not scope.is_active(now):
417 continue418 continue
418 template = snapshot.templates.get(scope.template_id)419 template = snapshot.templates.get(scope.template_id)
419- if template is None or not template.enabled:420+ if template is None:
420 continue421 continue
421 if scope.matches(user_id, group_id, bot_id):422 if scope.matches(user_id, group_id, bot_id):
422 return scope423 return scope
@@ -495,9 +496,6 @@ def snapshot_from_json(text: str) -> RoutingSnapshot:
495 expr = ""496 expr = ""
496 if not isinstance(expr, str):497 if not isinstance(expr, str):
497 raise ValueError(f"routing_rules must be a string, got {type(expr).__name__}")498 raise ValueError(f"routing_rules must be a string, got {type(expr).__name__}")
498- enabled = item.get("enabled", True)
499- if not isinstance(enabled, bool):
500- raise ValueError(f"enabled must be a boolean, got {type(enabled).__name__}")
501 expires_at = parse_datetime(item.get("expires_at"))499 expires_at = parse_datetime(item.get("expires_at"))
502 data_raw = item.get("data")500 data_raw = item.get("data")
503 if data_raw is not None and not isinstance(data_raw, dict):501 if data_raw is not None and not isinstance(data_raw, dict):
@@ -510,7 +508,6 @@ def snapshot_from_json(text: str) -> RoutingSnapshot:
510 template_id=str(item["template_id"]),508 template_id=str(item["template_id"]),
511 expr=expr,509 expr=expr,
512 rule=parse_routing_expr(expr) if expr.strip() else None,510 rule=parse_routing_expr(expr) if expr.strip() else None,
513- enabled=enabled,
514 expires_at=expires_at,511 expires_at=expires_at,
515 data=data_raw if isinstance(data_raw, dict) else None,512 data=data_raw if isinstance(data_raw, dict) else None,
516 )513 )
Mapplications/agent_runtime/src/agent_runtime/session_manager/state.py+4-1文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/src/agent_runtime/visualization_api.py+2-4文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/tests/evaluation/test_collector.py+8-4文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/tests/evaluation/test_rules.py+10-9文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/tests/integration/test_corner_cases.py+43-17文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/tests/integration/test_force_refresh.py+32-48文件内容审核中,请稍后刷新重试
Aapplications/agent_runtime/tests/integration/test_scope_affinity_hold.py+184-0文件内容审核中,请稍后刷新重试
Aapplications/agent_runtime/tests/integration/test_sunset_drain.py+107-0文件内容审核中,请稍后刷新重试
@@ -220,6 +220,52 @@ async def test_follower_progress_and_reuse_logged(runtime, monkeypatch, caplog):
220 assert any("acquire follower reuses leader pod" in m for m in messages)220 assert any("acquire follower reuses leader pod" in m for m in messages)
221 221 
222 222 
223+@requires_lua
224+async def test_follower_reuse_pops_idle_warm_pod(runtime, monkeypatch):
225+ """follower 接管 leader 热备 Pod 必须摘出 idle 池(2026-09-17 回归)。
226+ 
227+ wangchang 实录:config_refresh 日落 → autoscale 抢锁部署热备
228+ (idle_flag=True 注册即入 idle)→ 并发请求 acquire 落 follower 接管,
229+ 旧实现直接 return 不摘 idle → 忙 Pod 永占 warm 底数 → autoscale 恒
230+ skip_warm 不补位(池卡 1 忙 Pod,min_idle 热备永不重建),且
231+ idle_since 卡在部署时刻(LUA_RELEASE 幂等不刷新)致 pod_ttl 误计。
232+ """
233+ await runtime.seed_template(min_idle_pods=1) # sc=3/pc=2 → max_pods=2
234+ monkeypatch.setattr(rm_orch, "DEPLOY_WAIT_ON_BUSY", 0.01)
235+ cfg = await runtime.rm_state.load_scope_config(SCOPE)
236+ 
237+ # 生产时序:leader(=autoscale)先持 deploy 锁;chat 的 acquire 后到落等待室
238+ lock_key = runtime.rm_state.k.lock_deploy(SCOPE)
239+ assert await runtime.rm_state.try_lock(lock_key, 60, "leader-token")
240+ task = asyncio.create_task(runtime.rm_orchestrator.acquire(
241+ SCOPE, {"ready_timeout": 5}, {"pod_concurrency": 2}, "req-pop",
242+ ))
243+ await asyncio.sleep(0.05)
244+ 
245+ # leader 注册热备 Pod(同 autoscale._deploy_and_register 的 idle_flag=True)
246+ await runtime.rm_state.register_pod(
247+ pod_id="leader-pod", scope_id=SCOPE,
248+ pod_sse_url="http://1.2.3.4:8080/sse", pod_ip="1.2.3.4",
249+ namespace="default", deploy_ver=cfg.get("deploy_ver", ""),
250+ deploy_token="tok", idle_flag=True, now=now_ts(),
251+ sse_port=8080, health_path="/health",
252+ )
253+ result = await asyncio.wait_for(task, timeout=5)
254+ assert result["pod_id"] == "leader-pod"
255+ 
256+ # ★ 忙记账:接管即摘出 idle 池 + 清 idle_since(对齐 reuse 分支)
257+ assert await runtime.rm_state.idle_pods(SCOPE) == []
258+ assert await runtime.rm_state.redis.exists(
259+ runtime.rm_state.k.pod_idle_since("leader-pod")) == 0
260+ 
261+ await runtime.rm_state.unlock(lock_key, "leader-token") # leader 完成
262+ 
263+ # ★ 下游闭环:忙 Pod 不占 warm 底数 → autoscale 补回 1 个新热备
264+ await runtime.rm_sweeper.autoscale_once()
265+ idle = await runtime.rm_state.idle_pods(SCOPE)
266+ assert len(idle) == 1 and idle[0] != "leader-pod"
267+ 
268+ 
223async def test_cleanup_namespace_missing_returns_zero(runtime):269async def test_cleanup_namespace_missing_returns_zero(runtime):
224 """cleanup 目标 namespace 不存在(404)→ 容忍为 cleaned=0。270 """cleanup 目标 namespace 不存在(404)→ 容忍为 cleaned=0。
225 271 
@@ -4,6 +4,7 @@ eager 预热推送 / A-B 类扩散 / 409 / 红线(场景 M)。"""
4 4 
5from __future__ import annotations5from __future__ import annotations
6 6 
7+import asyncio
7from datetime import datetime, timedelta, timezone8from datetime import datetime, timedelta, timezone
8 9 
9import pytest10import pytest
@@ -148,8 +149,13 @@ async def test_config_sync_full_replace_and_validation(runtime):
148 149 
149 150 
150@requires_lua151@requires_lua
151-async def test_config_sync_persists_scope_enabled_expires_and_skips_on_resolve(runtime):152+async def test_config_sync_scope_lifecycle_presence_and_expiry(runtime):
152- """routing_scope.enabled / expires_at 落库;禁用与过期不参与 resolve。"""153+ """生命周期 = 存在性 + expires_at(2026-09-drop-enabled-fields):
154+ 
155+ - enabled=false 的 scope 视为缺席(不落库,resolve 落下一个);
156+ - 过期 scope 落库但不参与 resolve,推 min_idle=0 不预热;
157+ - 生效 scope 正常全量推送。
158+ """
153 store = runtime.config_store159 store = runtime.config_store
154 now = datetime.now(timezone.utc)160 now = datetime.now(timezone.utc)
155 past = (now - timedelta(hours=1)).isoformat().replace("+00:00", "Z")161 past = (now - timedelta(hours=1)).isoformat().replace("+00:00", "Z")
@@ -165,20 +171,21 @@ async def test_config_sync_persists_scope_enabled_expires_and_skips_on_resolve(r
165 ],171 ],
166 ))172 ))
167 by_id = {s.scope_id: s for s in await store.list_scopes()}173 by_id = {s.scope_id: s for s in await store.list_scopes()}
168- assert by_id["s-off"].enabled is False174+ # enabled=false → 缺席:不落库
175+ assert "s-off" not in by_id
169 assert by_id["s-exp"].expires_at is not None176 assert by_id["s-exp"].expires_at is not None
170- assert by_id["s-ok"].enabled is True and by_id["s-ok"].expires_at is not None177+ assert by_id["s-ok"].expires_at is not None
171 178 
172 scope_id, _ = await store.resolve("u-ok", "g", "b")179 scope_id, _ = await store.resolve("u-ok", "g", "b")
173 assert scope_id == "s-ok"180 assert scope_id == "s-ok"
174 scope_id, _ = await store.resolve("other", "g", "b")181 scope_id, _ = await store.resolve("other", "g", "b")
175 assert scope_id == "fallback"182 assert scope_id == "fallback"
176 183 
177- # 禁用/过期 scope 推 min_idle=0,不带 pod_spec(不预热)184+ # 过期 scope 推 min_idle=0,不带 pod_spec(不预热);生效 scope 带全量
178 pushed = {sid: (pool, spec) for sid, pool, spec in runtime.pool_pushes}185 pushed = {sid: (pool, spec) for sid, pool, spec in runtime.pool_pushes}
179- assert pushed["s-off"][0]["min_idle_pods"] == 0186+ assert "s-off" not in pushed # 缺席 = 无推送面
180- assert pushed["s-off"][1] is None
181 assert pushed["s-exp"][0]["min_idle_pods"] == 0187 assert pushed["s-exp"][0]["min_idle_pods"] == 0
188+ assert pushed["s-exp"][1] is None
182 assert pushed["s-ok"][1] is not None189 assert pushed["s-ok"][1] is not None
183 190 
184 191 
@@ -366,8 +373,178 @@ async def test_config_sync_scope_template_switch_sunsets(runtime):
366 [_scope(SCOPE, "tpl-b")],373 [_scope(SCOPE, "tpl-b")],
367 ))374 ))
368 assert await runtime.sm_state.scope_pod_ids(SCOPE) == []375 assert await runtime.sm_state.scope_pod_ids(SCOPE) == []
369- _, template = await runtime.config_store.resolve("u", "grp", "bot")376+ 
370- assert template.template_id == "tpl-b"377+ 
378+# -------------------------------------------------------------- 路由性排除日落(亲和保持,#152)
379+ 
380+@requires_lua
381+async def test_config_sync_index_reorder_no_bump_no_migrate(runtime):
382+ """index 重排(不排除命中)→ 不 bump 不日落,存量会话零打扰留原池。
383+ 
384+ 路径 A(#152):vip(10)/team(20) 双命中身份旧会话落 vip;team.index 改 5
385+ 后新会话进 team,旧会话继续原 Pod。
386+ """
387+ await runtime.config_store.config_sync(_payload(
388+ [_tpl("tpl-vip"), _tpl("tpl-team")],
389+ [_scope("vip", "tpl-vip", index=10),
390+ _scope("team", "tpl-team", index=20)],
391+ ))
392+ first = await runtime.route("sess_1")
393+ runtime.gen_bumps.clear()
394+ 
395+ await runtime.config_store.config_sync(_payload(
396+ [_tpl("tpl-vip"), _tpl("tpl-team")],
397+ [_scope("vip", "tpl-vip", index=10),
398+ _scope("team", "tpl-team", index=5)],
399+ ))
400+ # 重排不触发日落:vip/team 均无 bump、无排空纪元
401+ assert runtime.gen_bumps == []
402+ assert await runtime.rm_state.drain_until("vip") is None
403+ assert await runtime.rm_state.drain_until("team") is None
404+ # 旧会话亲和保持:同 Pod 同 scope;新会话(first-fit)进 team
405+ again = await runtime.route("sess_1")
406+ assert again["pod_id"] == first["pod_id"]
407+ binding = await runtime.sm_state.redis.hgetall(
408+ runtime.sm_state.k.session("sess_1"))
409+ assert binding[b"scope_id"] == b"vip"
410+ fresh = await runtime.route("sess_2")
411+ binding2 = await runtime.sm_state.redis.hgetall(
412+ runtime.sm_state.k.session("sess_2"))
413+ assert binding2[b"scope_id"] == b"team"
414+ assert fresh["pod_id"] != first["pod_id"]
415+ 
416+ 
417+@requires_lua
418+async def test_config_sync_routing_rules_change_bumps_with_drain(runtime):
419+ """expr 变化(失权)→ bump gen + 软摘候选集 + 排空纪元生效(时序自证)。
420+ 
421+ drain_until 非空本身证明 bump 先于扩散① push——纪元戳记要求 push 时刻
422+ gen lag 已形成;反序则永远不戳。
423+ """
424+ await runtime.seed_template()
425+ await runtime.route("sess_1")
426+ assert await runtime.sm_state.scope_pod_ids(SCOPE)
427+ runtime.gen_bumps.clear()
428+ 
429+ await runtime.config_store.config_sync(_payload(
430+ [_tpl("tpl-1")],
431+ [_scope(SCOPE, "tpl-1", expr="user_id in ('someone-else')")],
432+ ))
433+ assert runtime.gen_bumps == [SCOPE]
434+ # 老代 Pod 被软摘出候选集(新会话不再 first-fit 落上)
435+ assert await runtime.sm_state.scope_pod_ids(SCOPE) == []
436+ # 排空纪元已戳(bump→push 时序正确的唯一可观测证据)
437+ assert await runtime.rm_state.drain_until(SCOPE) is not None
438+ # 存量会话绑定仍在(窗口内继续原池)
439+ assert await runtime.sm_state.redis.exists(
440+ runtime.sm_state.k.session("sess_1"))
441+ # 同载荷重放(幂等)不重复 bump,纪元仍活跃(首因定窗,不重戳)
442+ runtime.gen_bumps.clear()
443+ await runtime.config_store.config_sync(_payload(
444+ [_tpl("tpl-1")],
445+ [_scope(SCOPE, "tpl-1", expr="user_id in ('someone-else')")],
446+ ))
447+ assert runtime.gen_bumps == []
448+ assert await runtime.rm_state.drain_until(SCOPE) is not None
449+ 
450+ 
451+@requires_lua
452+async def test_config_sync_disable_scope_bumps_with_drain(runtime):
453+ """残留 enabled=false → 视为缺席 = 删除路径(2026-09-drop-enabled-fields):
454+ bump + 排空纪元;新会话立即兜底。「禁用」的正确表达即从载荷删除。"""
455+ await runtime.config_store.config_sync(_payload(
456+ [_tpl("tpl-1"), _tpl("tpl-fb")],
457+ [_scope(SCOPE, "tpl-1", index=0, expr="group_id in ('grp')"),
458+ _scope("scope-fb", "tpl-fb", index=100)],
459+ ))
460+ old = await runtime.route("sess_1")
461+ runtime.gen_bumps.clear()
462+ 
463+ await runtime.config_store.config_sync(_payload(
464+ [_tpl("tpl-1"), _tpl("tpl-fb")],
465+ [_scope(SCOPE, "tpl-1", index=0, expr="group_id in ('grp')",
466+ enabled=False),
467+ _scope("scope-fb", "tpl-fb", index=100)],
468+ ))
469+ assert runtime.gen_bumps == [SCOPE]
470+ assert await runtime.rm_state.drain_until(SCOPE) is not None
471+ # 禁用 scope 推 min_idle=0 停预热(无 pod_spec)
472+ disable_pushes = [p for p in runtime.pool_pushes if p[0] == SCOPE]
473+ assert disable_pushes and disable_pushes[-1][2] is None
474+ assert int(disable_pushes[-1][1]["min_idle_pods"]) == 0
475+ # 窗口内旧会话继续原 Pod;新会话落兜底
476+ again = await runtime.route("sess_1")
477+ assert again["pod_id"] == old["pod_id"]
478+ fresh = await runtime.route("sess_2")
479+ binding = await runtime.sm_state.redis.hgetall(
480+ runtime.sm_state.k.session("sess_2"))
481+ assert binding[b"scope_id"] == b"scope-fb"
482+ 
483+ 
484+@requires_lua
485+async def test_config_sync_expires_at_shortened_bumps_with_drain(runtime):
486+ """expires_at 缩短到已过(生效→失效的唯一保留翻转)→ routing_excluded
487+ 仍触发:bump + 排空纪元 + 候选软摘(enabled 删除后的生命周期自证)。"""
488+ now = datetime.now(timezone.utc)
489+ future = (now + timedelta(days=1)).isoformat().replace("+00:00", "Z")
490+ past = (now - timedelta(hours=1)).isoformat().replace("+00:00", "Z")
491+ await runtime.config_store.config_sync(_payload(
492+ [_tpl("tpl-1")],
493+ [_scope(SCOPE, "tpl-1", index=0, expires_at=future)],
494+ ))
495+ await runtime.route("sess_1")
496+ assert await runtime.sm_state.scope_pod_ids(SCOPE)
497+ runtime.gen_bumps.clear()
498+ 
499+ await runtime.config_store.config_sync(_payload(
500+ [_tpl("tpl-1")],
501+ [_scope(SCOPE, "tpl-1", index=0, expires_at=past)],
502+ ))
503+ assert runtime.gen_bumps == [SCOPE]
504+ assert await runtime.sm_state.scope_pod_ids(SCOPE) == []
505+ assert await runtime.rm_state.drain_until(SCOPE) is not None
506+ 
507+ 
508+@requires_lua
509+async def test_config_sync_deleted_scope_bumps_once(runtime):
510+ """删除 scope → bump + 推 min_idle=0(带 session_ttl);重放不重复 bump。"""
511+ await runtime.config_store.config_sync(_payload(
512+ [_tpl("tpl-1"), _tpl("tpl-keep")],
513+ [_scope("s-drop", "tpl-1"), _scope("s-keep", "tpl-keep")],
514+ ))
515+ runtime.gen_bumps.clear()
516+ await runtime.config_store.config_sync(_payload(
517+ [_tpl("tpl-keep")],
518+ [_scope("s-keep", "tpl-keep")],
519+ ))
520+ assert runtime.gen_bumps == ["s-drop"]
521+ drop_pushes = [p for p in runtime.pool_pushes if p[0] == "s-drop"]
522+ assert drop_pushes and drop_pushes[-1][2] is None
523+ assert int(drop_pushes[-1][1]["min_idle_pods"]) == 0
524+ assert "session_ttl" in drop_pushes[-1][1]
525+ 
526+ # 同载荷重放:rm_known 仍含 s-drop(每拍重推 min_idle=0),但不再 bump
527+ runtime.gen_bumps.clear()
528+ runtime.pool_pushes.clear()
529+ await runtime.config_store.config_sync(_payload(
530+ [_tpl("tpl-keep")],
531+ [_scope("s-keep", "tpl-keep")],
532+ ))
533+ assert runtime.gen_bumps == []
534+ assert any(p[0] == "s-drop" for p in runtime.pool_pushes)
535+ 
536+ 
537+@requires_lua
538+async def test_config_sync_b_class_params_no_bump(runtime):
539+ """B 类纯池参数(session_ttl 等)→ 不 bump(scope/模板定义未动)。"""
540+ await runtime.seed_template()
541+ runtime.gen_bumps.clear()
542+ await runtime.config_store.config_sync(_payload(
543+ [_tpl("tpl-1", session_ttl=99, pod_ttl=120)],
544+ [_scope(SCOPE, "tpl-1")],
545+ ))
546+ assert runtime.gen_bumps == []
547+ assert await runtime.rm_state.drain_until(SCOPE) is None
371 548 
372 549 
373# -------------------------------------------------------------- 锁 / 409 / 红线550# -------------------------------------------------------------- 锁 / 409 / 红线
@@ -417,22 +594,29 @@ async def test_config_sync_a_class_passes_with_current_version_idle_pod(runtime)
417 2026-09-15 cyz 实测病理:原判据比**新载荷**版本——版本变更时当前代空闲594 2026-09-15 cyz 实测病理:原判据比**新载荷**版本——版本变更时当前代空闲
418 Pod 必然 ≠ 新版,被误判日落遗留;而它 ver==cfg 受 min_idle 底数保护永不595 Pod 必然 ≠ 新版,被误判日落遗留;而它 ver==cfg 受 min_idle 底数保护永不
419 回收,等它 = 配置面永久 409(暖 Pod idle 33min ≫ pod_ttl=180s 仍 409)。596 回收,等它 = 配置面永久 409(暖 Pod idle 33min ≫ pod_ttl=180s 仍 409)。
420- 修正后基准=当前生效版本 → 放行;老 Pod 落库后被扩散②软摘,reconcile597+ 修正后基准=当前生效版本 → 放行;老 Pod 落库后被扩散②软摘,reclaim 在
421- 转入 idle 池,reclaim 免老化即刻回收(带会话硬切重放置,决策接受)。"""598+ 排空窗口(A 类统一进排空,2026-09-17;session_ttl=1)截止后回收——
422- await runtime.seed_template(min_idle_pods=1)599+ 窗口内不收(带会话 Pod 优雅排空),过窗即收(零复用价值不蹲 pod_ttl)。"""
600+ await runtime.seed_template(min_idle_pods=1, session_ttl=1)
423 await runtime.route("sess_1")601 await runtime.route("sess_1")
424 pod_id = (await runtime.sm_state.registered_pods())[0].split(":", 1)[1]602 pod_id = (await runtime.sm_state.registered_pods())[0].split(":", 1)[1]
425 # 合法中间态:软摘出候选集;版本保持当前值(对照上例,不伪造旧版本号)603 # 合法中间态:软摘出候选集;版本保持当前值(对照上例,不伪造旧版本号)
426 await runtime.sm_state.redis.zrem(runtime.sm_state.k.scope_pods(SCOPE), pod_id)604 await runtime.sm_state.redis.zrem(runtime.sm_state.k.scope_pods(SCOPE), pod_id)
427 result = await runtime.config_store.config_sync(_payload(605 result = await runtime.config_store.config_sync(_payload(
428- [_tpl("tpl-1", agent_image="agentserver:2.0")],606+ [_tpl("tpl-1", agent_image="agentserver:2.0", session_ttl=1)],
429 [_scope(SCOPE, "tpl-1")],607 [_scope(SCOPE, "tpl-1")],
430 ))608 ))
431 assert result["ok"] is True609 assert result["ok"] is True
432- # 落库后闭环:扩散②软摘(已在集外)+ reconcile 转 idle + reclaim 即刻回收610+ # 落库后闭环:扩散②软摘(已在集外)+ reconcile 转 idle
433 await runtime.rm_sweeper.reconcile_once()611 await runtime.rm_sweeper.reconcile_once()
612+ # 排空窗口内:不回收(优雅排空)
613+ await runtime.rm_sweeper.reclaim_once()
614+ assert pod_id in await runtime.rm_state.all_pod_ids()
615+ # 过窗(session_ttl=1)→ 回收 + 纪元收尾释放 surge
616+ await asyncio.sleep(1.2)
434 await runtime.rm_sweeper.reclaim_once()617 await runtime.rm_sweeper.reclaim_once()
435 assert pod_id not in await runtime.rm_state.all_pod_ids()618 assert pod_id not in await runtime.rm_state.all_pod_ids()
619+ assert await runtime.rm_state.drain_until(SCOPE) is None
436 620 
437 621 
438@requires_lua622@requires_lua
@@ -1072,7 +1256,6 @@ async def test_new_form_row_with_missing_container_skipped(runtime):
1072 "ready_timeout": 300, "ready_poll_interval": 2,1256 "ready_timeout": 300, "ready_poll_interval": 2,
1073 "scope_concurrency": 3, "pod_concurrency": 2,1257 "scope_concurrency": 3, "pod_concurrency": 2,
1074 "pod_ttl": 300, "session_ttl": 60, "min_idle_pods": 0,1258 "pod_ttl": 300, "session_ttl": 60, "min_idle_pods": 0,
1075- "enabled": True,
1076 "main_container_id": "c-missing", "sidecar_container_ids": None,1259 "main_container_id": "c-missing", "sidecar_container_ids": None,
1077 "volumes": None,1260 "volumes": None,
1078 "created_at": datetime.utcnow(), "updated_at": datetime.utcnow(),1261 "created_at": datetime.utcnow(), "updated_at": datetime.utcnow(),
@@ -31,13 +31,12 @@ def _tpl(template_id: str, **overrides) -> Template:
31 31 
32 32 
33def _scope(scope_id: str, index: int, template_id: str = "tpl",33def _scope(scope_id: str, index: int, template_id: str = "tpl",
34- expr: str = "", *, enabled: bool = True,34+ expr: str = "", *, expires_at=None) -> RoutingScopeDef:
35- expires_at=None) -> RoutingScopeDef:
36 """expr → RoutingScopeDef(与生产构造路径一致:解析串并同时保存原串)。"""35 """expr → RoutingScopeDef(与生产构造路径一致:解析串并同时保存原串)。"""
37 return RoutingScopeDef(36 return RoutingScopeDef(
38 scope_id=scope_id, index=index, template_id=template_id, expr=expr,37 scope_id=scope_id, index=index, template_id=template_id, expr=expr,
39 rule=parse_routing_expr(expr) if expr.strip() else None,38 rule=parse_routing_expr(expr) if expr.strip() else None,
40- enabled=enabled, expires_at=expires_at,39+ expires_at=expires_at,
41 )40 )
42 41 
43 42 
@@ -218,26 +217,20 @@ def test_match_scope_first_fit_by_index_then_scope_id():
218 assert match_scope(snap2, "u1", "g", "b").scope_id == "a-tie"217 assert match_scope(snap2, "u1", "g", "b").scope_id == "a-tie"
219 218 
220 219 
221-def test_match_scope_skips_disabled_and_missing_template():220+def test_match_scope_skips_missing_template():
222- """模板缺失/禁用的 scope 不命中,继续落下一个。"""221+ """模板缺失(悬挂引用)的 scope 不命中,继续落下一个。"""
223- disabled = _scope("s-off", 0, "tpl-off")
224 missing = _scope("s-miss", 1, "tpl-none")222 missing = _scope("s-miss", 1, "tpl-none")
225 fallback = _scope("s-ok", 2, "tpl-ok")223 fallback = _scope("s-ok", 2, "tpl-ok")
226- snap = build_snapshot(224+ snap = build_snapshot([missing, fallback], [_tpl("tpl-ok")], 1)
227- [disabled, missing, fallback],
228- [_tpl("tpl-off", enabled=False), _tpl("tpl-ok")],
229- 1,
230- )
231 hit = match_scope(snap, "u", "g", "b")225 hit = match_scope(snap, "u", "g", "b")
232 assert hit is not None and hit.scope_id == "s-ok"226 assert hit is not None and hit.scope_id == "s-ok"
233 227 
234 228 
235-def test_match_scope_skips_disabled_and_expired_scope():229+def test_match_scope_skips_expired_scope():
236- """scope 自身 enabled=False / expires_at 已过期不命中,继续落下一个。"""230+ """scope 自身 expires_at 已过期不命中,继续落下一个。"""
237 from datetime import datetime, timedelta231 from datetime import datetime, timedelta
238 232 
239 now = datetime(2026, 9, 1, 12, 0, 0)233 now = datetime(2026, 9, 1, 12, 0, 0)
240- disabled = _scope("s-off", 0, enabled=False)
241 expired = _scope(234 expired = _scope(
242 "s-exp", 1, expires_at=now - timedelta(seconds=1),235 "s-exp", 1, expires_at=now - timedelta(seconds=1),
243 )236 )
@@ -245,7 +238,7 @@ def test_match_scope_skips_disabled_and_expired_scope():
245 "s-ok", 2, expires_at=now + timedelta(hours=1),238 "s-ok", 2, expires_at=now + timedelta(hours=1),
246 )239 )
247 snap = build_snapshot(240 snap = build_snapshot(
248- [disabled, expired, future], [_tpl("tpl")], 1,241+ [expired, future], [_tpl("tpl")], 1,
249 )242 )
250 hit = match_scope(snap, "u", "g", "b", now=now)243 hit = match_scope(snap, "u", "g", "b", now=now)
251 assert hit is not None and hit.scope_id == "s-ok"244 assert hit is not None and hit.scope_id == "s-ok"
@@ -265,11 +258,17 @@ def test_match_scope_no_match_returns_none():
265 258 
266 259 
267def test_has_wildcard_ignores_inactive_scopes():260def test_has_wildcard_ignores_inactive_scopes():
268- assert not has_wildcard_scope([_scope("fb", 0, enabled=False)])261+ from datetime import datetime, timedelta
262+ 
263+ now = datetime(2026, 9, 1, 12, 0, 0)
264+ expired = _scope("fb", 0, expires_at=now - timedelta(seconds=1))
265+ assert not has_wildcard_scope([expired])
269 assert has_wildcard_scope([_scope("fb", 0)])266 assert has_wildcard_scope([_scope("fb", 0)])
270 267 
271 268 
272-def test_parse_scope_enabled_and_expires_at():269+def test_parse_scope_enabled_false_treated_as_absent():
270+ """残留防御(2026-09-drop-enabled-fields):enabled=false → None(调用方
271+ 剔除);expires_at 正常解析。"""
273 from datetime import datetime272 from datetime import datetime
274 273 
275 tids = {"tpl"}274 tids = {"tpl"}
@@ -284,17 +283,14 @@ def test_parse_scope_enabled_and_expires_at():
284 },283 },
285 tids,284 tids,
286 )285 )
287- assert scope.enabled is False286+ assert scope is None
288- assert scope.expires_at == datetime(2026, 9, 1, 12, 0, 0)287+ # 残留 enabled=true → 原样忽略
289- assert scope.to_payload()["enabled"] is False288+ kept = parse_scope(
290- assert scope.to_payload()["expires_at"] == "2026-09-01T12:00:00"289+ {"scope_id": "s", "index": 0, "template_id": "tpl",
291- 290+ "routing_rules": "", "enabled": True},
292- with pytest.raises(InvalidParams, match="enabled"):291+ tids,
293- parse_scope(292+ )
294- {"scope_id": "s", "index": 0, "template_id": "tpl",293+ assert kept is not None and kept.to_payload()["expires_at"] is None
295- "enabled": "yes"},
296- tids,
297- )
298 with pytest.raises(InvalidParams, match="expires_at"):294 with pytest.raises(InvalidParams, match="expires_at"):
299 parse_scope(295 parse_scope(
300 {"scope_id": "s", "index": 0, "template_id": "tpl",296 {"scope_id": "s", "index": 0, "template_id": "tpl",
@@ -303,23 +299,21 @@ def test_parse_scope_enabled_and_expires_at():
303 )299 )
304 300 
305 301 
306-def test_snapshot_roundtrips_enabled_expires_at():302+def test_snapshot_roundtrips_expires_at():
307 from datetime import datetime303 from datetime import datetime
308 304 
309 scope = _scope(305 scope = _scope(
310- "s", 0, expires_at=datetime(2026, 9, 1, 12, 0, 0), enabled=True,306+ "s", 0, expires_at=datetime(2026, 9, 1, 12, 0, 0),
311 )307 )
312 text = snapshot_to_json(build_snapshot([scope], [_tpl("tpl")], 7))308 text = snapshot_to_json(build_snapshot([scope], [_tpl("tpl")], 7))
313 restored = snapshot_from_json(text)309 restored = snapshot_from_json(text)
314- assert restored.scopes[0].enabled is True
315 assert restored.scopes[0].expires_at == datetime(2026, 9, 1, 12, 0, 0)310 assert restored.scopes[0].expires_at == datetime(2026, 9, 1, 12, 0, 0)
316- # 旧快照无字段 → 默认生效311+ # 旧快照含 enabled 残键/无 expires_at → 忽略/默认生效
317 legacy = snapshot_from_json(312 legacy = snapshot_from_json(
318 '{"ver":1,"templates":{"tpl":{"template_id":"tpl"}},'313 '{"ver":1,"templates":{"tpl":{"template_id":"tpl"}},'
319 '"scopes":[{"scope_id":"fb","index":0,"template_id":"tpl",'314 '"scopes":[{"scope_id":"fb","index":0,"template_id":"tpl",'
320- '"routing_rules":""}]}'315+ '"routing_rules":"","enabled":true}]}'
321 )316 )
322- assert legacy.scopes[0].enabled is True
323 assert legacy.scopes[0].expires_at is None317 assert legacy.scopes[0].expires_at is None
324 318 
325 319 
@@ -398,11 +392,12 @@ def test_template_json_roundtrip():
398 min_idle_pods=2, data={"k": "v"})392 min_idle_pods=2, data={"k": "v"})
399 restored = template_from_json(template_to_json(t))393 restored = template_from_json(template_to_json(t))
400 assert restored == t394 assert restored == t
401- # int/bool 矫正 + 未知键忽略395+ # int 矫正 + 未知键忽略(enabled 已删,残留键随未知键忽略)
402 mixed = {**template_to_json(t), "ready_timeout": "90",396 mixed = {**template_to_json(t), "ready_timeout": "90",
403 "enabled": 0, "unknown_key": "x"}397 "enabled": 0, "unknown_key": "x"}
404 restored2 = template_from_json(mixed)398 restored2 = template_from_json(mixed)
405- assert restored2.ready_timeout == 90 and restored2.enabled is False399+ assert restored2.ready_timeout == 90
400+ assert not hasattr(restored2, "enabled")
406 401 
407 402 
408def test_template_from_json_rejects_legacy_flat_snapshot():403def test_template_from_json_rejects_legacy_flat_snapshot():
Mapplications/agent_runtime/tests/session_manager/test_sm_state.py+48-3文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/tests/test_config.py+41-9文件内容审核中,请稍后刷新重试