# 实现计划: 熔断拒绝补齐等待档(issue #14)

- **设计**: `research-wiki/designs/2026-08-19-issue14-admission-wait-policy-design.md`(人类已确认 + Codex 已审)
- **分支**: `feat/issue-14-circuit-open-policy`
- **版本**: 1.3.0(新增配置键 + `retry_after_s` 语义变更)

## 目标

让"源不健康"不再等同于"这次调用当场判死"——补上 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 这一格准入策略,并把 `retry_after_s` 的语义在两个后端的五个出口上定死。

## 方案概述

三件事环环相扣: ①把 `retry_after_s` 定义为"距离**确定**可再试的时刻还有多久",HALF_OPEN 与准入允许一律 `0.0`(顺带修掉源冷却备忘被探针租约污染的 bug);②新增 `circuit_open` 策略键,`wait` 档下不抛 `CircuitOpenError` 而按 `retry_after` 睡、由 stall 预算兜底;③前置把三条治理循环里逐字复制的准入逻辑收敛成一份,否则本次修复会在 embedding/ocr 留下两个行为分叉的角落。

涉及技术: Python 3.11 asyncio、Redis Lua(EVALSHA)、pytest 双后端参数化契约测试。

## 保真校验适用性

**适用**。熔断状态机是 ARCHITECTURE.md §1.4 关键资产(蓝本 `reference/Video-Tree-TRM5/adapters/breaker.py` 与 `reference/CHSAnalyzer/app/coordination/provider_gate.py`),准入循环蓝本为 `reference/CHSAnalyzer/app/providers/governance.py:107-285`。T1 与 T2/T3 各带保真校验检查点。

## 文件结构

| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/middleware/admission.py` | **新建** | `SourceAdmission`(准入与无源可跑的处置,三条循环共用)+ 模块级 `settle_and_release` |
| `src/polygateway/middleware/retry.py` | 修改 | 删除本地 `_pick_runnable`/`_on_no_runnable`/`_settle_and_release`,改用 `SourceAdmission`;主循环与 `_attempt` 不动 |
| `src/polygateway/embedding.py` | 修改 | 同上 |
| `src/polygateway/ocr.py` | 修改 | 同上(注意 `_settle_and_release` 原签名只有 `permit`) |
| `src/polygateway/backends/memory/breaker.py` | 修改 | 抽 `_remaining(g)`,三处出口共用;HALF_OPEN 与授予探针恒 `0.0` |
| `src/polygateway/backends/redis/breaker.py` | 修改 | 五个 Lua 出口同步(`TRY_ENTER` 两处、`RECORD_SUCCESS`/`RECORD_FAILURE`/`RELEASE_PROBE` 各一处、`RETRY_AFTER` 一处) |
| `src/polygateway/config.py` | 修改 | `_CIRCUIT_OPEN` 常量、`GatewaySettings.circuit_open` 字段、`_validate_backends` 元组、`from_env` 装载 |
| `src/polygateway/client.py` | 修改 | 构造签名 + 透传 |
| `src/polygateway/errors.py` | 修改 | `GatewayUnavailableError` docstring 职责边界 |
| `tests/contracts/test_breaker_contract.py` | 修改 | 按五个出口逐个钉 `retry_after_s` |
| `tests/integration/test_redis_governance_time.py` | 修改 | Redis 真实等待变体补 HALF_OPEN 出口 |
| `tests/unit/test_backpressure.py` | 修改 | `circuit_open` 行为矩阵、备忘污染回归、`_nap` 上界 |
| `tests/unit/test_config.py` | 修改 | 新键的合法域、缺省、两条装配路一致 |

## 关键接口(跨任务消费,此处定死)

`SourceAdmission` 构造与两个方法:

```python
class SourceAdmission:
    def __init__(self, *, scope: str, sources: list[SourceConfig],
                 selector: SourceSelector, quota: QuotaGate, breaker: BreakerGate,
                 memo: SourceCooldownMemo, backpressure: BackpressurePolicy,
                 quota_full: str, circuit_open: str,
                 pacer: AdaptivePacer | None = None,
                 health_view: Callable[[str], float] | None = None,
                 now=time.monotonic, sleep=asyncio.sleep, rng=random.random) -> None: ...

    async def pick(self, reasons: dict[str, str], attempt_fails: dict[str, int]
                   ) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]: ...

    async def on_no_runnable(self, gate_rejections: int, reasons: dict[str, str],
                             clock: StallClock) -> None: ...

    async def stalled(self, clock: StallClock) -> bool: ...
```

`quota`/`breaker`/`pacer`/`selector`/`sources` 均为**调用方传入的同一实例**(不在 admission 内新建),因为三处 `_attempt` 仍需引用它们;`memo` 则由 admission 独占。`health_view` 对应 chat 的 `self._health_view`(由 `isinstance(selector, OutcomeAwareSelector)` 在 RetryMW 构造期判定一次),embedding/ocr 传 `None`。

模块级结算函数(三处 `_attempt` 的 finally 与 admission 共用):

```python
async def settle_and_release(permit: Permit, actual: int) -> None:
    """finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。"""
```

睡眠时长(T5 实现,写死在 `SourceAdmission._nap`):

```python
def _nap(self, hint: float, clock: StallClock) -> float:
    jitter = self._bp.poll_interval_s * (0.5 + 0.5 * self._rng())
    budget = self._bp.stall_window_s - clock.stalled_s() + self._bp.poll_interval_s
    wait = hint + jitter if hint > 0 else jitter
    return max(jitter, min(wait, budget))
```

`hint == 0` 时该式退化为 `jitter`,即现有 quota-wait 行为逐字不变(`tests/unit/test_backpressure.py` 已钉 `[0.5p, 1.0p]`)。**下界取 `jitter` 而非 `poll_interval_s`(实施期修正)**: 后者会把 `rng → 0` 那半边从 `0.5p` 抬到 `1.0p`,既有的 `test_poll_jitter_bounds` 当场变红;`jitter` 同样能在预算为负时兜住不返回负数、不忙循环。`budget` 加一个 `poll_interval_s` 是因为 `_stalled` 判据是 `>` 而非 `>=`(`retry.py:368`),恰好夹到窗口不会判死。

**调用约束**: `_nap` 必须在 `stalled()` 判定**之后**调用。若已 stall 超窗才进来,`budget` 为负,外层 `max(poll_interval_s, ...)` 会兜成一个 poll 间隔(不会返回负数),但那意味着本该判死却又睡了一轮——顺序由 `on_no_runnable` 保证(两条路汇合后统一判 `stalled()` 再 sleep)。验算示例: `hint=60, stall_window=300, 已 stall 290, poll=0.05` → `jitter∈[0.025,0.05]`、`budget=10.05` → 返回 `10.05`,醒来累计约 `300.05` > 300,下一轮判死。

## 任务清单

### T0 — 分支与基线

- [ ] 建分支 `feat/issue-14-circuit-open-policy`(从 main)
- [ ] 记录基线: `conda run -n PolyGateway python -m pytest tests/ -q` 与 `make check` + `lint-imports` 全绿,记下**本机本环境**的用例计数(执行时实测,2026-08-19 为 988 passed / 32 deselected)。该数只作同环境参照——`addopts = "-m 'not slow'"` 与 Redis 可达性都会改变它,不作硬验收

**验证**: `conda run -n PolyGateway python -m pytest tests/ -q` → 全 PASS;`git rev-parse --abbrev-ref HEAD` → 分支名正确

---

### T1 — 纯重构: 准入逻辑三处收敛(回滚点)

**动**: 新建 `src/polygateway/middleware/admission.py`;改 `middleware/retry.py`、`embedding.py`、`ocr.py`。

**要实现的行为**: 把 `_pick_runnable`/`_on_no_runnable`/`_stalled`/`_settle_and_release` 从三处搬进 `SourceAdmission` 与模块级 `settle_and_release`,三条循环改为持有 `SourceAdmission` 实例并调用其方法。**本任务不引入 `circuit_open` 参数**(构造签名先只收 `quota_full`,T4 再加),控制流一字不改。

三条循环的差异只用注入表达,不留 `if` 分支:

| 差异 | 处理 | 等价性依据 |
|---|---|---|
| 调用内降权(仅 chat) | `attempt_fails` 作 `pick()` 入参,内部无条件调 `_demote_call_failures` | 传空 dict 时 `demoted` 为空 → `return ordered` 原对象返回,恒等(`retry.py:148-150`) |
| AIMD pacer(仅 chat) | `pacer: AdaptivePacer \| None = None` | None 时跳过 `admit()` 与 `enter()` 两个调用点,无副作用 |
| `_settle_and_release` 签名 | OCR 原为 `(permit)`、体内恒 `settle(0)`;改为调 `settle_and_release(permit, 0)` | 逐字等价 |
| warning 文案**三处都不同** | 归一为 "permit 结算/释放失败(不掩盖主异常)" | chat `retry.py:536` 已是该文案;embedding `embedding.py:411` 为 "embedding permit …"、OCR `ocr.py:448` 为 "OCR permit …" 将被归一(Codex 审查补,原稿只承认了 OCR)。这是本任务**唯一**的可见行为变化,须在提交信息里点名 |
| `_stalled` 形态 | chat 已抽成方法,embedding/ocr 为内联表达式 | 两者语义逐字相同(已 diff 核实),统一用 `SourceAdmission.stalled()` |

**搬走 vs 共享(自审修正,这一条决定 T1 能否成立)**: 三处 `_attempt` 仍在引用 `self._breaker`(记账写回)、`self._quota`(mark_progress)、`self._pacer`(leave)、OCR 还有 `self._selector`(健康喂数,`ocr.py:426`)。因此这些字段**不搬走,而是共享同一实例**——循环保留自己的引用,构造 `SourceAdmission` 时把同一对象传进去(`AdaptivePacer` 有在途计数状态,必须是同一实例而非新建,否则 `admit`/`enter` 与 `leave` 分裂到两个计数器上)。真正搬走的只有 `_pick_runnable`/`_on_no_runnable`/`_stalled` 三个方法与 `self._memo`(仅被 `pick` 消费)。

**`_attempt` 的唯一改动**: `self._settle_and_release(permit, actual)` → 模块级 `settle_and_release(permit, actual)`,OCR 侧由 `(permit)` 变为 `(permit, 0)`。除此之外 `_attempt` 一行不动。原稿"三处 `_attempt` 本体不在边界内"的说法与"搬走 `_settle_and_release`"自相矛盾,此处更正。

**不在边界内、须原样保留**: chat 主循环顶部那次额外的 `_stalled` 预判(`retry.py:286`)、OCR 的 `_gate_on_terminal`(`ocr.py:412`)与健康喂数。

**保真校验检查点**: 对照 `reference/CHSAnalyzer/app/providers/governance.py:107-285`,确认搬运后 `_pick_runnable` 的候选跳过顺序(备忘 → pacer → 配额 → 熔断门)、`gate_rejections` 的计入规则(备忘与熔断门计入,pacer 与配额不计入)、`_on_no_runnable` 的三段判定顺序逐段未变。

**测试要求(本任务特殊)**: **不新增行为用例**。全套件绿是必要条件而非充分条件——它证明不了"逐字不变",故本任务额外要求一次**机械差异审查**: 把搬迁前后的 `pick`/`on_no_runnable` 逐语句对照,确认候选跳过顺序、`gate_rejections` 计入规则、`reasons` 的 `[]=` 与 `setdefault` 用法(两者语义不同,不可互换)一字未变。

**已知会碰到的既有测试**: `tests/unit/test_health_selector.py:146` 断言 `client._terminal._pacer._ceiling`,`tests/unit/test_client.py:380` 断言 `._terminal._emitter._text_cap`——这两个字段必须留在 `RetryMW` 上(与上面"共享而非搬走"一致),否则这些用例会红。

**验证**:
```bash
conda run -n PolyGateway python -m pytest tests/ -q      # 期望: 全 PASS,计数与 T0 同环境基线一致
conda run -n PolyGateway make check                      # 只读: ruff format --check + ruff check
conda run -n PolyGateway lint-imports                    # 依赖铁律
```
**不要用 `make lint` 做验证**——它带 `--fix` 会自动改文件(`Makefile:11`),只读验证用 `make check` + `lint-imports`。用例计数只作**同环境**参照,不作硬验收: `pytest` 默认 `-m 'not slow'`(`pyproject.toml:51`),且无 `REDIS_URL` 时 Redis 用例 skip,计数随环境浮动。

import-linter 层级(`pyproject.toml:76`)允许 `middleware/admission.py` 依赖 `ports`/`types`/`errors`/`sources`(更内层),但不得 import 任何 `backends/`、`transports/`、`telemetry/`。搬迁后须清理三个原文件中失去引用的 import(`CircuitOpenError`、`QuotaGate`、`BreakerGate`、`SourceCooldownMemo` 等),否则 ruff 报未使用导入。

- [ ] 提交: `refactor: 把三条治理循环的准入逻辑收敛为 SourceAdmission`

---

### T2 — `retry_after_s` 语义统一(两个后端一次到位)

**动**: `src/polygateway/backends/memory/breaker.py`、`src/polygateway/backends/redis/breaker.py`、`tests/contracts/test_breaker_contract.py`、`tests/integration/test_redis_governance_time.py`。

**为什么两个后端必须同一个提交(Codex 审查修正)**: 原稿把 memory 与 redis 拆成 T2/T3 两次提交,中间 redis 侧契约用例会处于 red。但 `.claude/settings.json` 注册的 `pre-commit-guard.sh` 在检测到 `git commit` 时会跑 `pytest tests/ --tb=line -q`(`pre-commit-guard.sh:61`),红态直接卡住提交。且两者本就是**同一个契约的两个实现**,分开提交没有独立意义。

**要实现的行为**: `retry_after_s` = "距离**确定**可再试的时刻还有多久"。HALF_OPEN 下探针随时可能出结果,不存在确定时刻,故 `0.0`;准入被允许时同样恒 `0.0`。`0 = 可立即重试` 是库既有约定(`errors.py` 与现有契约用例"健康 → 0、冷却到期 → 0")。

memory 侧: 抽私有纯方法 `_remaining(g: _SourceGate) -> float`(OPEN 返回 `max(0.0, g.open_until - now)`,其余状态含 HALF_OPEN 返回 `0.0`),`try_enter` 的 HALF_OPEN 拒绝分支(`memory:148`)与 `retry_after_s()`(`memory:267`)改用它。`_snapshot`(`memory:169`)与授予探针(`memory:114`)已符合新契约,保持不变。

redis 侧共**六个返回格**,逐处点名(改前先确认行号仍对得上):

| 脚本 | 位置 | 现状 | 改为 |
|---|---|---|---|
| `TRY_ENTER` HALF_OPEN 拒绝 | `redis:44` | `probe_until - now` | `0` |
| `TRY_ENTER` 授予探针 | `redis:53` | `tonumber(ARGV[2])`(= probe TTL) | `0` |
| `RECORD_SUCCESS` fencing 未命中 | `redis:124` | half_open 取 `probe_until` | half_open 记 `0`(只 OPEN 取 `open_until - now`) |
| `RECORD_FAILURE` fencing 未命中 | `redis:155` | 同上 | 同上 |
| `RELEASE_PROBE` fencing 未命中 | `redis:255` | 同上 | 同上 |
| `RETRY_AFTER` | `redis:275` | half_open 取 `probe_until` | half_open 记 `0` |

后四行修的是**既有的双后端语义分叉**(memory `_snapshot` 对非 OPEN 一律 `0.0`),与本 issue 同源,由契约测试盲区掩护至今——现有用例只钉"第二个进入者被拒",没钉它拿到什么数。

**保真校验检查点**: 状态机转换、双通道开路判据、`_cooldown_eff` 指数退避、epoch fencing 匹配条件、Lua 的原子性结构与 `redis.call('TIME')` 服务器时钟口径**一律不动**——本任务只改"对外报几"这一件事,即 return 元组里 `retry_after_ms` 那一格。改完逐脚本与 memory 实现对照走一遍状态机。

**测试要求**(先失败后通过,`tests/contracts/` 双后端参数化,一次覆盖 memory + redis):
- HALF_OPEN 被拒: `decision.retry_after_s == 0.0` 且 `decision.state is GateState.HALF_OPEN`
- 授予探针的决定: `retry_after_s == 0.0`
- `record_*` 在 fencing 未命中且门处于 HALF_OPEN: `GateUpdate.retry_after_s == 0.0`(须同时断言 `applied is False`、`state is HALF_OPEN`,否则用例可能在别的分支上误绿)
- `gate.retry_after_s(("s1",))` 探针在途时返回 `0.0`
- 现有 `test_retry_after_semantics` / `test_retry_after_takes_min_across_sources` 保持绿(OPEN 语义未变)

**Redis 时间语义变体**: 契约层用 `clock.advance()` 的用例在 redis 参数下会 skip(`conftest.py:39` 的 `SkipClock` 哨兵),故须在 `tests/integration/test_redis_governance_time.py` 补 1:1 真实等待变体(既有约定: 不缩放时长)。该文件的 `test_meta_variants_cover_all_time_cases`(`:56`)会**机械拦截**漏配,漏了就红。

**验证**:
```bash
conda run -n PolyGateway python -m pytest tests/contracts/test_breaker_contract.py -q     # 双后端全 PASS
conda run -n PolyGateway python -m pytest tests/integration/test_redis_governance_time.py -m slow -q
```
第二条**必须带 `-m slow`**: `pyproject.toml:51` 的 `addopts = "-m 'not slow'"` 默认排除真实等待变体,不加就是空跑(该文件单跑 12-15 分钟)。需真实 Redis(db3),不 mock Lua 行为。

- [ ] 提交: `fix: 把 retry_after_s 定义为确定可再试时刻,HALF_OPEN 归零(双后端)`

---

### T3 — (已并入 T2)

原计划把 redis 侧拆为独立任务,因 pre-commit hook 会拦截中间红态而合并进 T2。此编号保留以免后续引用错位。

---

### T4 — 新配置键 `{SCOPE}__CIRCUIT_OPEN`

**动**: `src/polygateway/config.py`、`src/polygateway/client.py`、`src/polygateway/middleware/admission.py`、`embedding.py`、`ocr.py`、`tests/unit/test_config.py`。

**要实现的行为**: 与 `quota_full` 逐项同构,不发明新形状。

| 位置 | 改动 |
|---|---|
| `config.py` 常量区 | `_CIRCUIT_OPEN = frozenset({"wait", "fail_fast"})`,紧邻 `_QUOTA_FULL` |
| `GatewaySettings` | 新增字段 `circuit_open: str`,**无默认值**(与该类全部既有字段一致),位置紧随 `quota_full` |
| `_validate_backends` | 校验元组加一行 `("circuit_open", _CIRCUIT_OPEN)` |
| `from_env` | `circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast")` |
| `client.py` | `GatewayClient.__init__` 加 `circuit_open: str = "fail_fast"`;`from_settings` 透传 `settings.circuit_open` |
| `admission.py` | 构造收 `circuit_open`,同 `quota_full` 做构造期域校验并抛 `ValueError` |
| `embedding.py` / `ocr.py` | 两个客户端的构造签名与"从 GatewayClient 派生"路径(`embedding.py:561`、`ocr.py:574` 邻域)各透传一处 |

**缺省取 `fail_fast`**(人类 2026-08-19 决策): 保证控制流对存量下游不变。

**测试要求**(先失败后通过):
- 缺省档: 不设该键时 `settings.circuit_open == "fail_fast"`
- 合法域: 设为 `"nope"` 时 `from_env` 与直接构造**两条路**都抛 `ValueError` 且消息点出键名/字段名
- 两条装配路一致: `from_env` 与直接构造同一取值产出同一行为
- `dataclasses.replace(settings, circuit_open="wait")` 仍通过全部装配守卫
- 透传链: 从 `GatewaySettings` 一路到三条循环的 `SourceAdmission` 实例上取值正确

**验证**:
```bash
conda run -n PolyGateway python -m pytest tests/unit/test_config.py tests/unit/test_client.py -q
```

- [ ] 提交: `feat: 新增 {SCOPE}__CIRCUIT_OPEN 策略键(缺省 fail_fast)`

---

### T5 — `on_no_runnable` 按原因分派 + `_nap`

**动**: `src/polygateway/middleware/admission.py`、`tests/unit/test_backpressure.py`。

**要实现的行为**: 把现状串行的两个分支改为按拒绝原因分派(伪码见设计 §3.3)。要点:

1. `gate_rejections == len(sources)`(全部因熔断类原因被拒)时,`fail_fast` 抛 `CircuitOpenError`(现行为),`wait` 取 `hint = await breaker.retry_after_s(names)` 后**不抛**;
2. 否则(至少一源是被配额/AIMD 挡的)走 `quota_full` 分支,`hint = 0.0`;
3. 两条路汇合后统一判 `stalled()`,再 `await sleep(self._nap(hint, clock))`。

**必须避免的坑**: 若只把第一分支改成"wait 时不抛"而不做分派,控制流会掉进 `quota_full` 分支——`quota_full=fail_fast` 的调用方会看到熔断等待被误报成 `reason="quota_exhausted"`。

**可观测性**: `wait` 档每轮进入等待时 `logger.info` 一条(scope、`per_source_reasons`、本次睡眠秒数)。**只此一条,不打"醒来"那条**(实施期决定): 每一轮等待各自留痕,时间线已可完整还原,而醒来后若仍被拒会立刻打下一条——补一条"醒来"只会让日志量翻倍且信息重复。**不新增遥测列**(等待期不发请求,无 attempt 行可记;调用级总耗时下游可自测)。

**计时归属**: 睡眠发生在 `clock.attempting()` 之外,自动计入 stall 账,与 ARCH §7.3"熔断冷却属非生产性等待"一致——**无需改 `StallClock`**。

**取消穿透**: `_nap` 只做算术,睡眠是裸 `await self._sleep(...)`,不得包 `try/except`。

**测试要求**(先失败后通过,注入时钟/睡眠/rng 保持确定性):
- `circuit_open=wait` + 全源开路 → **不**抛 `CircuitOpenError`,而是按 `retry_after` 睡;冷却结束后拿到探针并成功返回
- `circuit_open=wait` + `quota_full=fail_fast` + 全源开路 → **不**抛 `quota_exhausted`(这是上面那个坑的钉子)
- `circuit_open=wait` + 冷却比 stall 预算还长 → 抛 `AllSourcesExhausted(reason="stalled")`,`per_source_reasons` 含 `circuit_open`,累计墙钟 ≤ `stall_window_s + poll_interval_s`
- `circuit_open=wait` + 源持续 `force_open` → **`retry_exhausted` 而非 `stalled`**(整分支审查发现,原稿写错): 冷却结束后放行的探针是真实尝试,失败照样烧一格 `max_attempts`,故两个预算里先耗尽的那个决定 reason
- 混合原因(部分 `circuit_open` + 部分 `rate_limited`)→ 走 quota 分支,`per_source_reasons` 如实混合
- `hint == 0` 时睡眠落在 `[0.5p, 1.0p]`(现有 quota-wait 行为逐字不变)
- `wait` 档等待中收到 `CancelledError` → 逐字穿透,in-flight permit 已释放
- `circuit_open=fail_fast`(缺省)下,全部现有用例逐字绿
- **备忘污染回归**(issue #14 §1.3): 探针成功后 `memo.active(源名)` 为 False,该源立即重新可选——此用例由 `/tmp/.../probe_repro.py` 的复现脚本转化而来,在 T2 之前必然 red

**验证**:
```bash
conda run -n PolyGateway python -m pytest tests/unit/test_backpressure.py tests/unit/test_retry.py -q
conda run -n PolyGateway python -m pytest tests/ -q      # 全套件
```

- [ ] 提交: `feat: circuit_open=wait 下熔断拒绝改为等待而非当场判死`

---

### T6 — `errors.py` 职责边界补写

**动**: `src/polygateway/errors.py`。

**要实现的行为**: 改写 `GatewayUnavailableError` 的 docstring。现文"业务侧 catch 本类做延期重投(CHS arq 模式)"读起来像鼓励每个下游各写一份重试逻辑;改为明确边界——调用级的重试/退避/换源/等待全部在库内,本异常表示库的调用级预算(重试预算或 stall 预算)已耗尽;下游若要再投,那是**任务级重试**,语义与调用级重试不同(ARCH §7.2 单层重试原则)。

`retry_after_s` 那句保留并补一句: 它是"距离确定可再试的时刻",`0` 表示无确定等待(可立即重试)。

**测试要求**: 纯 docstring,无行为变更。验收为 `tests/unit/test_errors.py` 保持绿。

**验证**: `conda run -n PolyGateway python -m pytest tests/unit/test_errors.py -q`

- [ ] 提交: `docs: 收回 GatewayUnavailableError 的重试职责边界`

---

### T7 — 文档同步

**动**: `research-wiki/ARCHITECTURE.md`、`README.md`、`CHANGELOG.md`、Gitea wiki。

| 目标 | 内容 |
|---|---|
| ARCH §7.4 | 增补本次决策: 三条缺陷的成因、`retry_after_s` 的契约定义(五个出口)、`circuit_open` 策略键与缺省理由 |
| ARCH §9 配置面 | 登记 `{SCOPE}__CIRCUIT_OPEN` |
| README | 配置表新增该键;**明写"单源 scope 建议配 `wait`"**——缺了这句,这个开关等于不存在;核对安装命令的版本约束是否需要跟着改 |
| CHANGELOG | 记 1.3.0,`retry_after_s` 语义变更给"请先读这一条"待遇(缺省档下 `CircuitOpenError.retry_after_s` 在全源 HALF_OPEN 时由探针租约剩余变为 0) |
| Gitea wiki | 按 `research-wiki/docs-convention.md` §2 清单同步 |

**验证**: 人工逐项核对上表;`grep -n "CIRCUIT_OPEN" README.md research-wiki/ARCHITECTURE.md` 各有命中。

- [ ] 提交: `docs: 记录熔断等待档与 retry_after_s 契约`

---

### T8 — 合并前独立验证

- [ ] 派**全新上下文** verifier subagent(`verification-before-completion`),逐条核对: 设计每一节是否有对应实现、五个 `retry_after_s` 出口是否都改到、三条循环行为是否一致、测试证据是否都是"先失败后通过"
- [ ] `conda run -n PolyGateway make check` + `conda run -n PolyGateway lint-imports` 全绿(**不用 `make lint`**,它带 `--fix` 会改文件)
- [ ] `conda run -n PolyGateway make test` 全套件绿 + 覆盖率 ≥ 80%
- [ ] Redis integration 套件在真实 Redis 上绿,含 `-m slow` 的时间语义变体(默认 addopts 会排除它)
- [ ] `requesting-code-review` 走一次整分支审查
- [ ] `finishing-a-development-branch`: `--no-ff` 合并 main,合并后在 main 上重跑 lint 与全套件

**注**: 发布(tag/构建/上传 registry/建 Release)按 CLAUDE.md §4.4.1 九步走,**不在本计划范围**,需人类确认后单独执行。

## 自审记录

- 设计每一节到任务的映射: §3.1→T2+T3、§3.2→T4、§3.3→T5、§3.4→T1、§3.5→T4(缺省值)+T7(文档)、§3.6→T6、§4 行为矩阵→T5 测试、§5 测试策略→T2/T3/T5、§6 非功能→T5(取消/计时/上界)
- 无 TBD/TODO/"适当的错误处理"类占位
- 跨任务消费的 `SourceAdmission` 签名、`settle_and_release`、`_nap` 公式已在"关键接口"写出实际代码
- 任务顺序有硬依赖: T1(收敛)必须先于 T5(在单一位置加语义)。原 T2/T3 拆分已合并——pre-commit hook 跑全套件,任何跨提交的红态都会被拦
