The breaker conflates "this source is unhealthy" with "kill this call
now". Limiter rejections already choose between wait and fail_fast;
breaker rejections had no such choice, so a single-source scope loses
its whole retry budget the moment the gate opens.
Design adds {SCOPE}__CIRCUIT_OPEN (default fail_fast, so existing
deployments keep their control flow) and pins retry_after_s to "time
until a *certain* retry moment" across all six gate exits. The latter
also fixes a separate bug the issue missed: a half-open rejection fed
the probe lease (up to 2x timeout) into the source cooldown memo, whose
set_until only moves forward -- so a recovered source stayed blacklisted
in-process long after the gate closed. That one bites multi-source
deployments too, it is just hidden when other sources absorb the load.
Human-approved 2026-08-19; both documents revised after Codex review.
23 KiB
实现计划: 熔断拒绝补齐等待档(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 构造与两个方法:
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 共用):
async def settle_and_release(permit: Permit, actual: int) -> None:
"""finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。"""
睡眠时长(T5 实现,写死在 SourceAdmission._nap):
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
return max(self._bp.poll_interval_s, min(hint + jitter if hint > 0 else jitter, budget))
hint == 0 时该式退化为 jitter,即现有 quota-wait 行为逐字不变(tests/unit/test_backpressure.py 已钉 [0.5p, 1.0p])。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 上(与上面"共享而非搬走"一致),否则这些用例会红。
验证:
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)会机械拦截漏配,漏了就红。
验证:
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实例上取值正确
验证:
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)。要点:
gate_rejections == len(sources)(全部因熔断类原因被拒)时,fail_fast抛CircuitOpenError(现行为),wait取hint = await breaker.retry_after_s(names)后不抛;- 否则(至少一源是被配额/AIMD 挡的)走
quota_full分支,hint = 0.0; - 两条路汇合后统一判
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+ 源持续force_open→ 最终抛AllSourcesExhausted(reason="stalled"),per_source_reasons含circuit_open,累计墙钟 ≤stall_window_s + poll_interval_s- 混合原因(部分
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
验证:
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 跑全套件,任何跨提交的红态都会被拦