|
|
2 дней назад | |
|---|---|---|
| .. | ||
| out | 1 неделя назад | |
| .gitignore | 1 неделя назад | |
| .model_state.json | 1 неделя назад | |
| README.md | 1 неделя назад | |
| RETIRED.md | 2 дней назад | |
| cfc_sessions_report.html | 1 неделя назад | |
| config.example.json | 1 неделя назад | |
| config.json | 4 дней назад | |
| err_sess.txt | 1 неделя назад | |
| lead_backlog.md | 1 неделя назад | |
| lead_backlog_archive.md | 1 неделя назад | |
| out_sess.json | 1 неделя назад | |
| tasks_backlog.json | 2 дней назад | |
| wait_branch.sh | 1 неделя назад | |
一个只依赖 Python 标准库 的命令行工具,用来:
| 项目 | 要求 |
|---|---|
| Python | 3.10+(实测 3.13.14,命令为 python) |
| 第三方包 | 无(仅 urllib / json / csv / argparse / concurrent.futures / msvcrt·fcntl 等标准库) |
| 网络 | 能访问 6 个 opencode 实例的 HTTP 端口 |
所有命令都在本目录下执行:
cd E:/cfc/tools/xaf_agent_hub
python hub.py instances
xaf_agent_hub/
├── config.json # 实例定义 + cfc_roots + exclude_dirs + 并发上限(**不含任何密码**)
├── config.example.json # 配置模板(占位值,可直接复制)
├── config.local.json # 本地凭证覆盖(已被 .gitignore 忽略,**永不提交**)
├── hub.py # CLI 入口(argparse 子命令 + 中文等宽表格)
├── opencode_api.py # HTTP 客户端(Basic 认证、超时、重试、异常归一、session 归一化)
├── cfc_filter.py # CFC 会话识别规则(归一化 / 根目录 / 路径段 / 排除)
├── scheduler.py # 配置加载 + 并发扫描 + 调度决策 + 并发安全闸门
├── matcher.py # 语义会话匹配(字符 n-gram + TF-IDF 余弦,无外部依赖)
├── slot_lock.py # 实例互斥锁 + 并发槽位预留(P0 并发正确性)
├── report.py # 导出 JSON / CSV / 单文件 HTML 总览页
├── README.md # 本文档
└── tests/
└── test_smart_cleanup.py # 单元测试(mock,无需可达实例)
├── .gitignore # 忽略 config.local.json / *.log / __pycache__
├── cfc_sessions_report.html # 生成产物:总览页
└── out/ # 生成产物:cfc_sessions.json / cfc_sessions.csv
运行期状态目录(不在仓库内):~/.xaf_agent_hub/locks/*.lock、~/.xaf_agent_hub/slots/*.slot。
config.json 里永远不写密码,5 个实例的 password 一律为 null,只保留 password_env 提示。
真实密码放在 config.local.json(已被 .gitignore 忽略),或用环境变量临时覆盖。
密码解析优先级(高 → 低):
| 优先级 | 来源 | 用法 |
|---|---|---|
| 1 | password_env 指定的环境变量 |
set XAF_HUB_PW_LAN_189=xxx(临时覆盖/排障最方便) |
| 2 | XAF_HUB_PW_<实例名大写,-/.→_> 环境变量 |
同上,不需要改配置 |
| 3 | config.local.json 的 instances.<name>.password |
日常使用,随实例配置一起覆盖 |
| 4 | password_file + password_key |
如 ~/.config/opencode/service.json 的 password |
config.local.json 格式(支持 {实例名: {...}} 字典,也支持数组):
{
"instances": {
"lan-189": {"password": "<在此填写>"},
"prod-etotem": {"password": "<在此填写>"}
}
}
用
--config 其它路径时,若该路径同级没有config.local.json,会自动回落到 本工具目录的config.local.json只借凭证:仅合并密码相关字段(password/password_key/password_file/password_env),不会把本工具的实例列表或其它配置 注入到你自己的配置文件里;你配置里没有的实例名会被直接忽略。
校验是否泄漏:
grep -rn "IwinTrue\|OpenCode@" E:/cfc/tools/xaf_agent_hub/ # 只应命中 config.local.json
cd E:/cfc && git check-ignore -v tools/xaf_agent_hub/config.local.json # 必须命中忽略规则
| 逻辑名 | 地址 | 环境 | 备注 |
|---|---|---|---|
local-45278 |
http://127.0.0.1:45278 |
本机 | 密码来自 ~/.config/opencode/service.json |
lan-248 |
http://192.168.16.248:4090 |
内网 | |
lan-204 |
http://192.168.16.204:4091 |
内网 | 4091 端口(不是 4090) |
lan-189 |
http://192.168.16.189:4090 |
内网 | |
prod-etotem |
https://opencode.etotem.com.cn |
生产 | danger: true + readonly_default: true,写操作需 --allow-prod |
pub-iwintrue |
http://opencode.iwintrue.com |
公网 | http(https 会 401) |
| 字段 | 说明 |
|---|---|
concurrency_limit |
每实例同时运行的 CFC 会话上限,默认 2 |
session_ttl_days |
自动清理的「未使用」阈值(天),默认 10;超过该时长且非运行态的 CFC 会话可被清理 |
match_threshold |
智能匹配的相似度阈值,默认 0.12;低于此值视为「无相关会话」,改走新建 |
auto_match |
是否默认启用智能匹配(dispatch 缺省行为),默认 true |
timeouts.connect_seconds |
TCP 连接超时(默认 8s,预检用) |
timeouts.read_seconds |
请求读超时(默认 30s) |
retries |
网络错误 / 5xx / 协议中断重试次数(默认 1,4xx 不重试) |
page_size |
GET /api/session 的 limit(默认 8000;必须显式传,否则服务端只给 50 条) |
scan_workers |
多实例并发扫描线程数(默认 6) |
lock.slot_ttl_seconds |
槽位预留有效期(默认 60s,超时自动清理) |
lock.lock_timeout_seconds |
获取实例调度锁的最长等待(默认 20s) |
lock.slot_grace_seconds |
会话创建后等待 /api/session/active 反映的宽限(默认 5s) |
lock.dir |
锁与槽位存储目录,null = ~/.xaf_agent_hub |
instances[].cfc_roots |
该实例的 CFC 根目录清单(前缀匹配,含子目录) |
instances[].default_directory |
该实例新建会话时的默认工作目录 |
instances[].danger |
true = 危险实例(生产),写入需 --allow-prod |
exclude_dirs / exclude_substrings |
排除目录(前缀)/ 排除子串(如 workbuddy) |
fallback_segments |
兜底路径段:cfc / caremate / danshop / xaf |
判定依据是会话的 location.directory(工作目录),先归一化:统一 / 分隔符、折叠重复分隔符、去尾分隔符、整体小写(Windows 盘符大小写不敏感)。规则优先级 从高到低:
exclude_dirs 前缀 + exclude_substrings 子串)——命中即判为非 CFC,即使同时命中后两条。
当前排除:E:\workspace\mes、E:\wbspace\train、E:\workspace\totem、E:\workspace\iwt、
/home/liaoxg/.config/opencode/plugins/omf、/sc-data/mes、/home/iwt/.config/opencode/plugins/omf、
D:\Documents、D:\tmp、D:\360极速、C:\Users\iwt、含 workbuddy 的任意目录。cfc_roots)——目录等于根目录或位于其下:
local-45278: E:\cfc(覆盖 E:\cfc\cfc-frontend、E:\cfc\docs 等)lan-248: /sc-data/cfclan-204: D:\cfclan-189: C:\code\cfc(覆盖 book、运营文案、小红书便秘和腹泻帐号)prod-etotem: /root/cfc、/root/carematepub-iwintrue: /sc-data/cfc、/sc-data/carematecfc / caremate / danshop / xaf 时也算 CFC(识别未知新目录)。--keyword 是在目录判定之上再按 标题 做包含过滤(默认不过滤)。
instances —— 实例健康与总量python hub.py instances
python hub.py instances --json
输出:实例 / 地址 / 状态 / 版本 / 会话总数 / CFC 会话数 / 运行中(总/CFC) / 上限 / 说明。 不可达实例只标记原因,单实例任何异常都不中断整体扫描(协议中断、超时、401、5xx 均已归一)。
sessions —— CFC 会话清单(可导出)python hub.py sessions # 表格,按最近更新倒序
python hub.py sessions --instance lan-189 # 只看某实例
python hub.py sessions --keyword 打卡 --limit 20 # 标题关键词 + 限制条数
python hub.py sessions --json # JSON 到 stdout
python hub.py sessions --out out/cfc_sessions.json # 导出 JSON 全量
python hub.py sessions --out out/cfc_sessions.csv # 导出 CSV(utf-8-sig,Excel 可直接打开)
python hub.py sessions --html cfc_sessions_report.html # 生成单文件 HTML 总览页
--limit只影响屏幕显示;--out/--html永远导出 全量。
CSV 列:实例, 会话ID, 工作目录, 运行状态, 最近更新, 创建时间, outcome, 标题, 消息成本
status —— 负载与可调度性python hub.py status
python hub.py status --json
python hub.py status --allow-prod # 把生产实例也计为可调度(仅影响展示)
输出:运行中总数 / 运行中 CFC / 在途预留 / CFC 会话总数 / 上限 / 是否可调度 / 原因。
可调度判定 = 运行中 CFC + 在途预留 < 上限 且非生产只读。
dispatch —— 调度会话做开发# 智能匹配:跨全部实例找出与任务最相关的已有 CFC 会话并复用;无相关则新建
python hub.py dispatch --text "修复登录页样式"
# 只看计划(含匹配相似度与候选)不发送
python hub.py dispatch --text "修复登录页样式" --dry-run
# 指定实例(在该实例内做智能匹配)
python hub.py dispatch --text "补充单元测试" --instance lan-189
# 复用指定会话(复用不占新额度,满载时也能追加指令)
python hub.py dispatch --text "继续上一轮改造" --session ses_xxx --instance local-45278
# 指定目录 / 标题(--title 关键词匹配)
python hub.py dispatch --text "改造打卡页" --instance lan-189 --dir 'C:\code\cfc\book' --title 打卡
# 强制新建(忽略任何匹配,--new 优先级最高)
python hub.py dispatch --text "改造打卡页" --instance lan-189 --dir 'C:\code\cfc\book' --title 打卡 --new
# 关闭智能匹配(按旧逻辑:无 --session/--title 则新建会话)
python hub.py dispatch --text "改造打卡页" --instance lan-189 --no-match
# 派发前先自动清理各实例上 10 天未用的 CFC 会话
python hub.py dispatch --text "修复登录页" --auto-cleanup
# 生产实例(默认拒绝,需显式放行)
python hub.py dispatch --text "只更新文档" --instance prod-etotem --allow-prod --dry-run
未显式指定 --session / --title / --new 时,dispatch 默认启用智能匹配:
match_threshold(默认 0.12)才开始考虑;匹配基于标题与目录(字符级),不读取会话正文,因此短标题的语义重合度足够区分「同一需求的续做」与「全新需求」。阈值可在
config.json的match_threshold调整;--dry-run会打印匹配相似度,便于校准。
执行顺序:
--instance,或自动挑选「可达 + 有 CFC 目录 + 未满载 + 非生产」中负载最低者);--session → 否则 --title 关键词匹配最近更新的 CFC 会话 → 否则新建(--new 强制新建);运行中 CFC + 在途预留 >= 2 → 拒绝,退出码 3;POST /api/session/:id/prompt,body {"text": ...},异步投递(不阻塞等模型跑完)。行为要点:
prompt 是 steer 语义,不新增并发会话),因此满载时仍可给运行中的会话追加指令;--title 命中已有会话但 --dir 与它的目录不一致时,会打印 [!] 复用了已有会话 …,已忽略 --dir …;如需在指定目录新建请加 --new;--force 才放行);danger)→ 拒绝,退出码 4(--allow-prod 才放行)。cleanup —— 清理超期未用的 CFC 会话# 安全预览:列出各实例上超过 10 天未使用、且当前未在运行的 CFC 会话
python hub.py cleanup
# 自定义阈值(天)
python hub.py cleanup --ttl-days 30
# 真正删除(默认只预览!必须显式 --execute)
python hub.py cleanup --execute
# 仅清理某个实例 / 生产实例
python hub.py cleanup --instance lan-189
python hub.py cleanup --allow-prod --execute
判定口径:
time.updated)为「最后使用」依据;早于 now - ttl_days 即视为过期;time.updated 缺失(≤0)的会话保守跳过(避免误删只是未采集到时间的新鲜会话);danger 实例(生产)默认跳过,需 --allow-prod;默认安全:
cleanup默认只统计、不删除(dry-run);只有显式--execute才会真正DELETE。删除不可恢复,建议先用cleanup(无参数)预览确认。
interrupt —— 中断运行python hub.py interrupt --session ses_xxx --instance lan-248
python hub.py interrupt --session ses_xxx # 不指定实例则全实例搜索
同样受「非 CFC 会话 → 5」「生产 → 4」闸门约束。
watch —— 观察运行状态变化python hub.py watch # 每 10s 打印一次状态变化
python hub.py watch --interval 5
python hub.py watch --once # 只打印一次当前状态
python hub.py watch --instance lan-189 --verbose
只打印变化(新运行 / 开始运行 / 运行结束 / 会话消失),Ctrl+C 退出。
dispatch 的新建路径由两级机制保护,避免「检查 → 创建」之间的竞态:
~/.xaf_agent_hub/locks/<instance>.lock
msvcrt.locking,POSIX 用 fcntl.flock,非阻塞轮询 + 超时(默认 20s,超时 → 退出码 3 并提示「未获取到调度锁」);/api/session/active → 统计 running_cfc → 加上有效在途预留 → 判定 → 创建会话 → 投递指令。~/.xaf_agent_hub/slots/<instance>.<token>.slot(JSON,含 pid / 创建时间 / session_id)
running_cfc + 有效预留数 >= concurrency_limit 即拒绝;session_id 并重置计时,保留 slot_ttl_seconds(默认 60s)作为 /api/session/active 的传播窗口;try/finally 立即释放。实测(3 个进程同时 dispatch,起点 running_cfc=0):恰好 2 个成功、1 个退出码 3,复核 running_cfc=2。
说明:
--dry-run只做预检(不做锁定),因此两次 dry-run 之间若有人真实调度,结果可能变化;真实执行时会重新加锁复核。
| 退出码 | 含义 | 典型场景 |
|---|---|---|
0 |
成功 | 正常输出 / 投递成功 / dry-run 通过 |
1 |
内部错误(未预期异常) | 打印简短原因,加 --debug 看调用栈 |
2 |
实例不可达 | DNS/连接失败、超时、5xx、401 认证被拒、响应中断 |
3 |
触发并发上限 / 未取得调度锁 | 目标实例 running_cfc + 预留 >= 2;或锁等待超时 |
4 |
参数 / 配置错误 | 未知实例、--text 为空、配置 JSON 非法、生产未放行、导出格式不支持 |
5 |
目标不是 CFC 会话 | 误操作非 CFC 会话(需 --force) |
python hub.py sessions --out out/cfc_sessions.json --html cfc_sessions_report.html
python hub.py sessions --out out/cfc_sessions.csv
out/cfc_sessions.json:完整结构化数据(generated_at / by_instance / unreachable / instances / sessions);out/cfc_sessions.csv:9 列清单,utf-8-sig 编码,Excel 双击可用;cfc_sessions_report.html:单文件、无外部依赖(无 CDN / 无图片 / 无 src=),顶部统计卡(实例健康数、CFC 会话总数、正在运行数、各实例分布),下方按实例分组表格,输入框实时过滤标题 / 目录 / 会话 ID,深色标题栏 + 浅色内容,字体优先 "Microsoft YaHei", system-ui。| 现象 | 排查方向 |
|---|---|
不可达: DNS 解析失败 |
域名写错或本机 DNS 不通;pub-iwintrue 必须用 http |
不可达: 认证被拒 (HTTP 401) |
密码错。检查 config.local.json,或用 XAF_HUB_PW_<实例> 覆盖 |
不可达: HTTP 502 |
实例服务挂了 / 反代异常(检查端口是否为 4091 而非 4090) |
不可达: 响应中断 (IncompleteRead) |
实例端连接被截断;工具已重试 1 次并标记该实例不可达,不影响其他实例 |
[并发上限] … 未获取到调度锁 |
另有 dispatch 正在执行;等几秒重试,或调大 lock.lock_timeout_seconds |
| 满载但想给运行中的会话追加指令 | 用 --session <正在运行的 CFC 会话>(复用不占新额度) |
| 明明目录对却复用了别的会话 | --title 会优先匹配已有会话,加 --new 强制新建 |
| CFC 会话数明显偏少 | 检查 cfc_roots 是否遗漏新目录;或该目录被 exclude_dirs 命中 |
| CFC 会话数明显偏多 | 兜底段 cfc/caremate/xaf 命中了无关目录,把该目录加到 exclude_dirs |
| 服务端只返回 50 条会话 | page_size 被改小;工具默认显式传 limit=8000 |
| 投递成功但会话没反应 | 指令是异步的:hub.py watch --interval 5 观察;或该会话正被其他运行占用 |
| 新建会话落在了错误目录 | 只有 location.directory 生效;--dir 必须属于该实例的 CFC 根目录 |
调试技巧:instances 看健康 → status 看负载/预留 → sessions --instance X --json --limit 5 看会话 → --debug 看调用栈。
性能参考(6 实例、约 2800 条会话,本机实测):dispatch --dry-run ≈ 3–5s、status ≈ 7s、watch --once ≈ 7.5s、sessions ≈ 7s。
dispatch 走「只取运行态 + 按需单查会话」的快路径,不再拉全量会话列表。
/api/version:版本号取自 GET /api/info({"version": "2.0.32", ...}),无此端点时显示 -。/api/session/active:槽位预留只能覆盖本工具的调度;若有人绕过本工具、直接用别的客户端开跑,实例端仍可能超载。运行中会话若连目录都查不到(GET /api/session/:id 失败),会按「占用额度」处理(fail-closed)并打印 [warn],宁可误拦不漏拦。slot_grace_seconds(5s):会话创建后 5s 内若已结束且 /active 不再返回,会短暂计入占用(偏保守)。--keyword 只匹配标题,不搜会话正文。cfc/caremate/danshop/xaf 作为独立路径段即判为 CFC;同名无关目录请加入 exclude_dirs。status / instances / sessions 为统计 CFC 总数必须拉全量会话,耗时随会话数增长(约 7s)。消息成本 来自 session 的 cost 字段,未开计费时恒为 0.0000。dispatch --new 会真实创建会话,请确认目录与用途;cleanup --execute 会真实删除过期 CFC 会话(默认仅预览)。prod-etotem)默认只读,工具不会在没有 --allow-prod 的情况下向其发送任何指令。--session / --title 或调高 match_threshold。cleanup 只枚举并清理 CFC 会话(按 cfc_roots 判定),非 CFC 会话不受影响;time.updated 缺失的会话会被跳过而非删除。loop_resume.py 是常驻/定时巡检编排器(WorkBuddy 自动化「XAF Hub 巡检·10分错峰01-06」每小时错峰跑 loop_resume.py --once)。每个周期依次执行:
fix_free_limit → check_agents → sync_design_tasks → fill_slots
额度自愈 执行情况检查 需求/设计进度扫描 任务池补位派发
tasks_backlog.json 取 status=pending 的任务派发,成功后标 dispatched,会话结束且 succeeded 标 done。可用开关:--no-freelimit / --no-check / --no-design / --no-fill(分别对应跳过某步);--design-script 可换扫描脚本。
tasks_backlog.json自动任务池,字段:id / title / priority(P0-P3) / module / instance(空=通用) / status(pending|dispatched|done) / spec(设计文档相对路径) / prompt(派发正文) / session_id / dispatched_at / dispatched_to / model / done_at。fill_slots 按优先级取 pending 派发,三级取用:指定本实例 → 通用 → 借用其他实例(各实例都有完整仓库副本)。
治理纪律(2026-10-25 定稿):
tasks_backlog.json 加一条 pending),由巡检自动补位,不要在群里口头记;status=dispatched + session_id(或先入池再让 fill_slots 派),保证池子与实际一致;lead_backlog.md 只放「等你拍板 / 决策类」事项,不放可自动派发的执行任务;git commit + git push 并回报 commit 哈希,不需要主理人确认(合并主干仍需人工裁决)——本工具派给远程 opencode 智能体的每一条任务指令都必须带上这条纪律;discipline.py 的 单一事实源统一注入派发链路,且按目标实例的分支策略决定口径:
sync_design_tasks(生成任务的业务正文,纪律不再内嵌)、fill_slots(巡检补位)、resume_interrupted(恢复中断会话,check_agents 复用同一 RESUME_PROMPT)、hub.py dispatch(人工派发,dry-run 亦可见);
fix_free_limit 的续做提示(CONTINUE_PROMPT)同步采用相同提交口径;
注入点收敛在 scheduler.plan_dispatch(目标实例经智能匹配/自动选路确定后才拼纪律),人工派发与巡检补位共用;
tasks_backlog.json 顶层的 discipline 字段可作任务池级覆盖(按实例策略改写后注入)。origin/cfclub 新建 feature/<slug> 分支,完成后 push 自己的分支,不合并主干——隔离开发,由主理人裁决合并;cfclub 主分支工作,不新建任何分支,完成后 push 到 origin/cfclub;local 开头即 mainline;也可在 config.json 实例级显式写 "branch_policy": "mainline"(显式声明优先,便于日后把某个远端实例也切到主分支口径);branch_policy: mainline(2026-10-29 主理人定稿):204 实例分派时保持在 cfclub 主分支工作,不再新建 feature/<slug> 分支;其余远端实例(lan-248 / lan-189 / prod-etotem / pub-iwintrue)仍为 branch 隔离口径;
-派发时 discipline.rewrite_branch_to_mainline() 会把任务书/规格文档里写死的「新建 feature/ 分支」类指令改写为主分支口径,剩余由追加的 mainline 纪律段声明覆盖;
判定=实质完成:分支 feature/u3 @ 300dd0765、「核验方违规在 feature/xxx 多推了 1 个空commit」这类核验结论只在其处于「新建/push/checkout/基于…」等指令上下文时才改写,避免历史记录失真;feature/<slug> 隔离口径);out/verify_branch_policy.py(37 项断言)。sync_design_tasks.py 实现「检查需求/设计情况,根据完成进度生成新任务」:
docs/superpowers/plans/ 与 docs/superpowers/specs/ 下 mtime ≤ 30 天 的 .md;|)中同时含待派发标记(待派发 / 未派发 / ⏳)与批次代号(F-?数字 / T数字 / W0.数字 / 批次 / 步骤 / 任务 / 阶段)的行;title、id 取批次代号(无则 AUTO-<slug>-<n>);spec + (id 或 title) 已存在则跳过,只新增 pending、不自动关闭(关闭以智能体汇报 / 主理人核验为准);tasks_backlog.json(仅在有新增时改写)。示例:CF 值统一方案文档把 F-8 行标为「待派发收尾」→ 巡检自动把 F-8 清理 作为
pending任务入池,下一轮fill_slots即派发。 注意:池任务spec引用的设计文档必须已提交到origin/cfclub,远端 opencode 才能读到;未提交的设计文档请先 commit,否则远程智能体会在 prompt 中被告知「文档不存在则停止并说明」。
口径:任务分派/模型切换时,无论哪个实例、哪个会话,只要 opencode 报 429(含免费档
free usage limit reached、日文本配额、rps exhausted/quota exhausted/rate limit等以 429 呈现的限流),opencode 全家族模型(opencode/与opencode-go/前缀)当日一律不可用,直到次日 08:00 才解禁。
fix_free_limit.py 命中即写 .model_state.json 的 opencode_blackout_until_ms = 次日 08:00(含 reason/source/时间戳,可审计);同日内重复触发只延长到次日 08:00,不累加。scheduler.resolve_instance_model / resolve_dispatch_model 都走 _blacked_out_models() 排除黑窗内的 opencode 家族——fill_slots、hub.py dispatch、fix_free_limit 的切换目标、实例链顺延全部生效。resolve_dispatch_model(explicit=...) 对 opencode 家族不再走 ignore_blackout=True 的旧语义(2026-10-24 为防「显式模型被策略链替换回 opencode」而设),否则 config.default_dispatch_model = opencode/deepseek-v4.1-flash-free 会绕过封禁把新任务继续派到已 429 的免费档;非 opencode 的显式模型仍保持「显式优先」语义。endpoint is unavailable / no-route / model unavailable / provider.internal / not available in the current token plan 属「模型不可用」而非限流,只切下一档、**不触发全家族封禁(常量清单见scheduler.RATE_LIMIT_PATTERNS`)。scheduler.mark_opencode_blackout(reason, source):fix_free_limit(idle + running 两条路径)、hub.py 的 confirm-wait 配额降级、confirm_session.py fix 子命令。confirm_session.py 会写黑窗,巡检链(fix_free_limit 等)从不调用它;现已把写黑窗接到巡检链并改为「先命中→先写黑窗→再算切换目标」的判定顺序。out/_verify_blackout.py(30 项断言:写入/解禁时刻、三个入口跳过 opencode、过期恢复、429 vs 非429特征边界、家族识别)。