Files
PolyGateway/research-wiki/plans/2026-08-06-issue8-stall-budget.md
T
iomgaa 573e505a4b docs: add the implementation plan for issue #8
Six tasks: StallClock plus the chat loop, then embedding, ocr, the config
comments, the full-suite regression with doc sync, and independent
verification. Codex review raised four points, all confirmed and folded in:
a stale line reference in the fidelity section, explicit cancellation
acceptance for T2/T3 (the new attempting() wrapper now wraps their existing
cancel paths), a telemetry-boundary test pinning the design's claim that
telemetry jitter must not feed the stall verdict, and concrete test
construction for the embedding/ocr regressions.
2026-08-06 09:09:31 -04:00

277 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 实施计划: 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`。