# 实施计划: stall 判定改为非生产性等待口径(Issue #8)

- **依据设计**: `research-wiki/designs/2026-08-06-issue8-stall-budget-design.md`(**已批准 2026-08-06**)
- **分支**: `feat/issue-8-stall-budget`(已建,已含设计提交 `bfe423d` + `ce2dda7`)
- **目标**: 让 stall 计时器只累计非生产性等待,解除 `timeout_s` 与 `stall_window_s` 的隐式耦合,使重试预算在超时场景下真实可用。
- **方案概述**: 新增调用级 `StallClock`(总时间减去 `_attempt` 耗时),替换三条治理循环里的墙钟 `entered_at`。判死双条件的结构、`inf` 语义、错误面、429 免预算全部不动。
- **涉及技术**: Python 3.11 asyncio、`contextlib.asynccontextmanager`、pytest + `FakeClock`。

## 保真校验适用性

**适用**。三条治理循环均为 `reference/CHSAnalyzer app/providers/governance.py:200-285` 的移植物(ARCHITECTURE.md §1.4 关键资产)。但 **`reference/` 当前不在工作区**,无法逐段比对源码,故保真基准改为两处已入库的等价证据:

1. 设计文档 §4「旧版行为审计」表——9 条既有行为逐条标注保留/替换,实施时逐条核对;
2. 代码内既有的 CHS 行号注释(`retry.py:212`「调用级累计计时,循环内不重置(CHS governance.py:207)」、`:303-304`「双条件 stall 判死(CHS governance.py:270-281)」、`:315`「jitter 防惊群(CHS governance.py:283-285)」)与 `tests/unit/test_backpressure.py:1-6` 的蓝本 docstring。

**唯一允许的语义变更是条件 A 的度量口径**(设计 §4 中标"替换"的那一行)。其余任何条件分支、退避公式、jitter 区间、状态迁移若发生行为改变,即为违规,必须回退。

## 文件结构

| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/middleware/retry.py` | 修改 | 新增模块级 `StallClock`(共享单元);主循环与 `_on_no_runnable` 改用之 |
| `src/polygateway/embedding.py` | 修改 | 复用 `StallClock`;`_on_no_runnable` 改签名 |
| `src/polygateway/ocr.py` | 修改 | 同上 |
| `src/polygateway/config.py` | 修改 | `_validate_stall` docstring 改写(仅注释,不改逻辑) |
| `tests/unit/test_backpressure.py` | 修改 | 新增 `TestStallBudget` 类;订正 `:121` docstring |
| `tests/unit/test_embedding.py` | 修改 | 新增 embedding 回归用例 |
| `tests/unit/test_ocr_client.py` | 修改 | 新增 ocr 回归用例 |
| `.env.example` | 修改 | 第 41 行注释改写 |
| `research-wiki/ARCHITECTURE.md` | 修改 | §7.3 背压条目补记新口径 |
| `CHANGELOG.md` | 修改 | 记治理行为变更 |

**不创建任何新模块**。`StallClock` 放在 `retry.py`,沿用 `backoff_delay` 已被 embedding/ocr 复用的既有手法(依赖方向不变:`embedding.py:42`、`ocr.py:38` 已在 import 该模块)。

## 关键接口(跨任务消费,此处给出实际代码)

`StallClock` 由 T1 落地,T2/T3 直接消费,签名以此为准:

```python
class StallClock:
    """调用级 stall 计时器: 只累计非生产性等待(设计 §3.1)。

    stall 预算治理的是"无人治理的等待"(429 退避、配额轮询、熔断冷却),
    真实尝试已由重试预算 max_attempts 治理,故须从 stall 账里扣除——
    两者重叠计费正是 issue #8 的根因。

    每次调用创建一个实例。严禁提升为实例属性: 并发调用共享会互相污染计时。
    """

    __slots__ = ("_now", "_entered_at", "_productive_s")

    def __init__(self, now: Callable[[], float]) -> None:
        self._now = now
        self._entered_at = now()
        self._productive_s = 0.0

    def stalled_s(self) -> float:
        """非生产性等待累计秒数 = 总耗时 - 真实尝试耗时。"""
        return self._now() - self._entered_at - self._productive_s

    @contextlib.asynccontextmanager
    async def attempting(self) -> AsyncIterator[None]:
        """包裹一次真实尝试, 其耗时记为生产性(边界即 _attempt 的边界)。"""
        started = self._now()
        try:
            yield
        finally:
            # 只做算术, 不吞任何异常——CancelledError 逐字穿透(库铁律)
            self._productive_s += self._now() - started
```

需在 `retry.py` 新增 `import contextlib`;`AsyncIterator` 从 `collections.abc` 引入(该文件已有 `from __future__ import annotations`,类型注解延迟求值,若 `TYPE_CHECKING` 块中已有 `Callable` 则复用)。

三条循环的改造模式一致:

```python
clock = StallClock(self._now)                      # 替换 entered_at = self._now()
...
await self._on_no_runnable(gate_rejections, reasons, clock)   # 形参改类型
...
async with clock.attempting():
    outcome = await self._attempt(...)             # 原调用不变, 仅被包裹
```

判定式由 `self._now() - entered_at > stall` 改为 `clock.stalled_s() > stall`,**条件 B 与 `and` 结构逐字不动**。

---

## T1 — `StallClock` 落地与 chat 路径改造

- [ ] **文件**: `src/polygateway/middleware/retry.py`(修改)、`tests/unit/test_backpressure.py`(修改)

### 行为与验收标准

1. 按上文「关键接口」实现 `StallClock`,置于模块级(建议紧邻既有 `backoff_delay` 纯函数,便于 embedding/ocr 一并 import)。
2. `RetryMW.__call__`:`entered_at = self._now()`(`retry.py:212`)改为 `clock = StallClock(self._now)`;主循环判定(`:217`)改为 `clock.stalled_s() > stall`;`_attempt` 调用(`:228`)用 `async with clock.attempting():` 包裹。
3. `_on_no_runnable`(`:286-288`)形参 `entered_at: float` 改为 `clock: StallClock`,其内判定(`:306`)同步改为 `clock.stalled_s() > stall`。
4. **不得改动**:429 免预算分支(`:233-234`)、`max(fails, 1)` 退避(`:243`)、jitter 公式(`:315`)、`fail_fast` 分支、条件 B `await self._quota.progress_age_s() > stall`、`AllSourcesExhausted` 的任何字段。
5. 保留 `retry.py:212` 的 CHS 行号注释并补记新口径(说明"调用级累计、循环内不重置"仍然成立,变的只是不再计入真实尝试)。

### 测试要求(先失败后通过)

在 `tests/unit/test_backpressure.py` 新增 `class TestStallBudget`:

| 用例 | 构造 | 断言 |
|---|---|---|
| `test_single_timeout_does_not_exhaust_stall_budget` | `_STALL=300`,源 `timeout_s` 等价;脚本 `[TransientError(耗时 350s), _ok()]`——用 `FakeTransport` 配合在尝试中推进 `FakeClock` 350s | 返回成功响应。**改前**:抛 `AllSourcesExhausted(reason="stalled")` |
| `test_productive_time_excluded_from_stall` | 连续两次尝试各推进时钟 `_STALL+100`,第三次成功 | 返回成功;全程不触发 `stalled` |
| `test_nonproductive_wait_still_triggers_stall` | 沿用 `_blocked_limiter`,轮询中推进时钟超窗且不 `mark_progress` | 抛 `stalled`(兜底未被削弱) |
| `test_saturation_429_still_stalls` | 源持续抛 429(`TransientError(status_code=429)`),退避 sleep 中推进时钟 | 抛 `stalled` 而非无限循环(`BoundedSleep` 上限内)。钉住设计 §3.5 |
| `test_cancel_inside_attempt_pierces` | 在 `_attempt` 内挂起后 `task.cancel()` | 抛 `CancelledError`(`attempting()` 的 finally 不吞) |
| `test_concurrent_calls_do_not_share_clock` | 两路并发调用,一路长尝试、一路正常 | 两路互不影响;钉住 `StallClock` 不得为实例属性 |
| `test_telemetry_time_counts_as_productive` | 注入一个在 `emit_attempt` 中推进 `FakeClock` 超过 `_STALL` 的慢 emitter,transport 正常成功 | 返回成功响应,不触发 `stalled`。**钉住设计 §3.1 的边界声明**:遥测收尾属生产性,遥测抖动不得参与判死。若将来有人把 `attempting()` 的包裹范围收窄到只包 transport 调用,该不变式会被悄悄破坏而其余用例抓不到 |

**既有四象限用例(`TestStallQuadrants` 四条)必须原样通过,不得修改断言**——它们全程无真实尝试或真实尝试耗时为 0,`stalled_s()` 与旧墙钟等价。若其中任何一条需要改断言才能通过,说明实现越界,停下来复核。

同时订正 `tests/unit/test_backpressure.py:121` 的 docstring:「仅全局超窗(从未出餐 age=inf)」保持不变(该语义确实不变),但补一句说明本地口径已是非生产性等待。

### 验证命令

```bash
conda run -n PolyGateway pytest tests/unit/test_backpressure.py -v
conda run -n PolyGateway pytest tests/unit/test_retry.py -v
```

预期:全部 PASS。先在实现前跑新增用例,记录 `test_single_timeout_does_not_exhaust_stall_budget` 的 FAILED 输出作为红证据。

- [ ] **提交点**: `fix: bill only non-productive waiting against the chat stall budget`

---

## T2 — embedding 路径改造

- [ ] **文件**: `src/polygateway/embedding.py`(修改)、`tests/unit/test_embedding.py`(修改)

### 行为与验收标准

1. `embedding.py:42` 的 import 增加 `StallClock`(该行已 import `_failure_reason, backoff_delay`)。
2. `_embed_batch`(`:182`):`entered_at = self._now()` 改为 `clock = StallClock(self._now)`;`_attempt` 调用(`:188`)用 `async with clock.attempting():` 包裹;`_on_no_runnable` 传参(`:186`)改为 `clock`。
3. `_on_no_runnable`(`:230-232`)形参改 `clock: StallClock`,判定(`:248`)改 `clock.stalled_s() > stall`。
4. **不得新增主循环 stall 判定**(设计 §5.4:embedding 无 429 免预算,`fails += 1` 无条件,缺口不存在;新增等于凭空多一条判死路径)。
5. **不得改动**:`fails += 1` 的无条件性(`:191`)、`max_attempts` 判定、退避调用(`:200`)。

### 测试要求(先失败后通过)

在 `tests/unit/test_embedding.py` 新增 `test_single_timeout_does_not_exhaust_stall_budget`。

**构造方式(已核实可行,不必绕过既有 helper)**:`_embed_client`(`:219`)的 `**overrides` 直通 `EmbeddingClient.__init__`,而后者接受 `now`/`sleep`/`rng`(`embedding.py:109-111`),故可写 `_embed_client([src], script, now=clock, sleep=<推进时钟的 fake>)`。制造一轮 `_on_no_runnable` 沿用 `test_backpressure.py:75-85` `_blocked_limiter` 的手法:源 `max_concurrency=1`,测试先 `try_acquire` 占满 permit,在 fake sleep 回调里释放。helper 内的 `InMemoryLimiter` 未注入 `now` 不影响本用例——判定要的是 `progress_age_s()` 返回 `inf`(从未 `mark_progress`),与 limiter 时钟无关。

**断言**:第一次尝试推进 `FakeClock` 超过 `stall_window_s` 后抛 `TransientError`,随后经一轮 `_on_no_runnable` 再恢复,最终返回成功的 `EmbeddingResponse`。改前应抛 `AllSourcesExhausted(reason="stalled")`。

**取消穿透验收点**:既有 `test_cancel_releases_permit`(`test_embedding.py:331-339`)的取消路径**将被新的 `async with clock.attempting()` 包住**,故它是本任务的必过回归项,不得因改动而修改其断言。若它转红,说明 `attempting()` 的 `finally` 吞了 `CancelledError` 或泄漏了 permit,停下来复核而非改测试。

### 验证命令

```bash
conda run -n PolyGateway pytest tests/unit/test_embedding.py -v
```

预期:全部 PASS(含既有取消与遥测用例)。

- [ ] **提交点**: `fix: apply the non-productive stall budget to the embedding loop`

---

## T3 — ocr 路径改造

- [ ] **文件**: `src/polygateway/ocr.py`(修改)、`tests/unit/test_ocr_client.py`(修改)

### 行为与验收标准

与 T2 同构,对应行号:import(`:38`)、`_call` 的 `entered_at`(`:207`)、`_on_no_runnable` 传参(`:211`)、`_attempt` 调用(`:213`)、`_on_no_runnable` 签名(`:255-257`)与判定(`:273`)。同样**不得新增主循环 stall 判定**,不得改动 `fails += 1`(`:216`)与退避(`:225`)。

### 测试要求(先失败后通过)

`tests/unit/test_ocr_client.py` 新增与 T2 同构的 `test_single_timeout_does_not_exhaust_stall_budget`,覆盖 `recognize_text` 或 `parse_layout` 任一端点即可(两者共用 `_call`)。

**构造方式**:同 T2——经该文件既有的 `_client(...)` helper 传 `now=clock` 与推进时钟的 fake `sleep`;`_on_no_runnable` 一轮用"源 `max_concurrency=1` + 测试预先占满 permit + 在 fake sleep 回调里释放"制造。

**取消穿透验收点**:既有 `test_cancel_during_transport_releases_permit`(`test_ocr_client.py:339-348`)与其上方的退避期取消用例同样会被新包裹覆盖,均为必过回归项,不得修改断言。

### 验证命令

```bash
conda run -n PolyGateway pytest tests/unit/test_ocr_client.py tests/unit/test_monkey_ocr.py -v
```

预期:全部 PASS。

- [ ] **提交点**: `fix: apply the non-productive stall budget to the ocr loop`

---

## T4 — 配置侧注释对齐(无逻辑变更)

- [ ] **文件**: `src/polygateway/config.py`(修改)、`.env.example`(修改)

### 行为与验收标准

1. `config.py:240-241` `_validate_stall` 的 docstring 由「stall 窗口须 ≥ 最慢源 TTFT 上限,防把正常慢首包误判为卡死」改写为:说明该校验在新口径下**属保守冗余**——TTFT 等待是生产性时间,已不计入 stall;保留校验是为不改动 ARCHITECTURE.md §7.3 契约 G6(人类 2026-08-06 定夺)。**校验逻辑本身一字不改**。
2. `.env.example:41` 注释由「stall 双条件判死窗口;须 ≥ 最大源 TTFT」改写为说明它度量的是**非生产性等待**(429 退避/配额轮询/熔断冷却)累计,与 `TIMEOUT_S` 无耦合、无需按 `timeout × retries` 放大。
3. 不新增、不改名任何配置键(`_DEFAULT_STALL_WINDOW_S = 300.0` 保持不变)。

### 验证命令

```bash
conda run -n PolyGateway pytest tests/unit/test_config.py -v
conda run -n PolyGateway make lint
```

预期:全部 PASS(本任务不改逻辑,`test_config.py` 应零变化通过)。

- [ ] **提交点**: `docs: align the stall window comments with the new metering`

---

## T5 — 全套件回归与文档同步

- [ ] **文件**: `research-wiki/ARCHITECTURE.md`(修改)、`CHANGELOG.md`(修改)

### 行为与验收标准

1. 跑全套件确认零回归。**命令末尾不得接管道**(CLAUDE.md 执行模式:管道会掩盖真实退出码),需要后台跑时用 `wait`/轮询 PID 判完成。
2. `ARCHITECTURE.md` §7.3 背压条目(第 429-431 行区域)补记:stall 双条件的条件 A 现为**非生产性等待累计**,并给出本设计文档指针。既有 G6 契约行保留,补注其在新口径下为保守冗余。
3. `CHANGELOG.md` 记治理行为变更(属公共行为变更,须显式列出:单次调用最坏耗时由 `stall_window_s` 抬升至 `max_attempts × timeout_s`)。
4. 核对设计 §4 行为审计表 9 条,逐条确认实现与标注一致(保真校验检查点)。

### 副作用处置

修复后单次调用最坏耗时变为 `max_attempts × timeout_s`(本机 900s)。跑 e2e 前先评估 `tests/e2e` 的源 `timeout_s` 是否需调小,以免冒烟耗时失控。本机 `.env:37` 的临时缓解 `STALL_WINDOW_S=1200` 可回退默认值(`.env` 不入库,仅在本任务记录该动作)。

### 验证命令

```bash
conda run -n PolyGateway make lint
conda run -n PolyGateway pytest tests/unit tests/integration -v
conda run -n PolyGateway make test
```

预期:lint 通过(含 import-linter 依赖契约);单元与集成全绿;覆盖率不低于既有水平。

- [ ] **提交点**: `docs: record the stall metering change in architecture and changelog`

---

## T6 — 独立验证与 Wiki 同步

- [ ] **文件**: Gitea Wiki(独立仓库)

### 行为与验收标准

1. **派全新上下文 verifier subagent**(`verification-before-completion`,MANDATORY:跨 3 模块属里程碑级),**前台运行**(`run_in_background: false`,CLAUDE.md 执行模式)。核验对象:设计 §1 的 G1-G4 是否逐条兑现、§4 行为审计表 9 条是否与实现一致、是否出现设计未声明的语义变更、测试是否真的覆盖"先失败后通过"。
2. 按 `docs-convention.md` §2「治理行为变更」行同步 Wiki:`解释-治理行为`(stall 判定口径)、`指南-限流与熔断`(配置说明中删除"须按 timeout×retries 放大 stall"一类误导)。
3. 在 Gitea issue #8 下回帖:根因、方案、被否决的两个原建议方向及理由、影响面。
4. Wiki 注册:
   ```bash
   .claude/tools/research_wiki.py add_entity research-wiki/ --type plan --id issue8-stall-budget --title "stall 判定改为非生产性等待口径"
   .claude/tools/research_wiki.py add_edge research-wiki/ --from "plan:issue8-stall-budget" --to "design:issue8-stall-budget" --type implements --evidence "本计划实施该设计的 T1-T6"
   .claude/tools/research_wiki.py rebuild_index research-wiki/
   ```

### 验证命令

```bash
conda run -n PolyGateway make ci
```

预期:只读验证全绿。verifier 报告须逐条对应本会话内的工具输出(证据化声明,禁止虚报)。

- [ ] **提交点**: `chore: register the issue #8 plan and sync the wiki`

---

## 任务依赖

T1 → (T2 ‖ T3) → T4 → T5 → T6。T2 与 T3 相互独立,但都依赖 T1 落地的 `StallClock`。
