Files
PolyGateway/research-wiki/plans/2026-08-06-issue8-stall-budget.md
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

16 KiB
Raw Permalink Blame History

实施计划: 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_sstall_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:42ocr.py:38 已在 import 该模块)。

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

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

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;AsyncIteratorcollections.abc 引入(该文件已有 from __future__ import annotations,类型注解延迟求值,若 TYPE_CHECKING 块中已有 Callable 则复用)。

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

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() > stallAllSourcesExhausted 的任何字段。
  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)」保持不变(该语义确实不变),但补一句说明本地口径已是非生产性等待。

验证命令

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,停下来复核而非改测试。

验证命令

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)、_callentered_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_textparse_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)与其上方的退避期取消用例同样会被新包裹覆盖,均为必过回归项,不得修改断言。

验证命令

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 保持不变)。

验证命令

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 不入库,仅在本任务记录该动作)。

验证命令

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 注册:
    .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/
    

验证命令

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